Skip to content

feat(types): verify a link:/file: install by the LOCATION its host declared - #17542

Merged
os-justin merged 2 commits into
mainfrom
claude/issue-17046-link-finder-realpath-axis
Sep 10, 2026
Merged

feat(types): verify a link:/file: install by the LOCATION its host declared#17542
os-justin merged 2 commits into
mainfrom
claude/issue-17046-link-finder-realpath-axis

Conversation

@os-justin

@os-justin os-justin commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator

Closes #17046

Clause-②: yes — this relaxes a published accept set. The PR is barred from the merge queue until an in-seat clause-② review PASS is on record; it stays draft.

The defect

The ESM fallback finder in packages/types/src/node.ts verifies the one directory it consults — HOSTROOT/node_modules/KEY — against what the host app's own package.json declares. Until now it could do that only by NAME, and a link: / file: value promises no name, so the KEY stood in for one. A package linked exactly as the app asked, whose own manifest happens to carry a different name, was refused.

Reproduced on origin/main (74f06116) with a real symlink fixture, verbatim:

Error: Cannot load module 'linked-key': the host app DECLARES it (dependencies: "link:../linked"),
a package IS installed at that key, and this ESM fallback cannot confirm it is the declared one.
  installed at: /tmp/os-17046-loads-h5kR0a/app/node_modules/linked-key
  its package.json is named: "@fixture/named-something-else"
  this finder expected: "linked-key"

Nothing is broken there. The operator's only recourse was to stop using a supported linking mode.

The change — a SECOND axis, not a looser first one

A link: / file: / portal: declaration does name something checkable: a directory. So the finder now asks that too —

realpath(HOSTROOT/node_modules/KEY)  ===  realpath(resolve(HOSTROOT, DECLARED_PATH))

Either axis is sufficient; both are read out of the host's own manifest. A directory the app declared neither by name nor by path is refused exactly as before, so the finder stays strictly tighter than the CommonJS resolution it backs up — which asks neither question and takes whatever sits at the key. This is emphatically not "skip the check when the specifier is a location", the shape triage on #15045 predicted a dev would reach for.

The normalisation, and why each part of it is what it is

⛔ Not a raw string comparison and ⛔ not a basename match — the two shapes packages/cli's own isProcessEntry ruled out (src/utils/invocation.ts, the #10086 / PR #10084 discipline). The discipline is reused; nothing is imported, since packages/types sits below packages/cli.

  • symlinks — the whole point. link: installs the key AS a symlink, so the left side is a link and the right side its target; without realpath they never compare equal. Chains, .. spans and trailing separators collapse here too, which is why resolve() alone is not enough on either side. Driven by a fixture that declares link:../linked/../alias/ (where alias is itself a symlink) while the key points at ../../linked.

  • pnpm's store layout — MEASURED on pnpm 10.33, not reasoned:

    linked-key -> ../../linked-dir                                     # link:../linked-dir
    filed-key  -> .pnpm/@fixture+file-named-this@file+..+file-dir/…    # file:../file-dir
    

    link: symlinks the key straight at the declared directory and verifies. A file: directory install routes through the virtual store — a hard-linked copy whose realpath is inside the host's own node_modules, not the declared path — so it does not verify and keeps today's refusal. Deliberate: reading the store's encoded directory name to recover the origin would be parsing a package manager's private layout, and accepting "anything under node_modules" is the relaxation this must not become. npm's file: (a symlink) does verify, and is pinned.

  • case-insensitive filesystemsnot covered by a test, and the comparison deliberately does not fold case. The CI filesystem is case-sensitive, so a fixture could only assert what the other cases already assert. The direction is the safe one: folding would ACCEPT a path the host did not write wherever case matters, while declining to fold can at worst fail to verify — which is today's refusal, not a regression. realpathSync (not .native) is used because the sibling read in hostInstalledPackageDir uses it; comparing two different canonicalisers is its own defect class.

  • file:// URL spellings are declined, not guessed at — the remainder after file: is not a path, and a second grammar (authority, percent-decoding) is not parsed on the way to licensing a directory.

Evidence

Red before / green after. The suite fails on 74f06116's finder and passes on this branch. Measured as an ablation on the committed branch: packages/types/src/node.ts restored to its 74f06116 blob (proved on disk — blob e9c6c4cd… vs HEAD's 67bebb32…, marker counts declaredLocationAxis 7 → 0), then re-run.

tests
branch 96 passed, 0 failed (file), 612 passed / 20 files (package suite)
finder restored to 74f06116 11 failed, 85 passed — every new load case plus the two message pins

Restore proved by blob equality with the HEAD blob and an empty git diff HEAD, not by an exit code; the script carried trap … EXIT INT TERM with absolute paths.

The negative side, which is where this card's real risk lives. A green suite proving only the new load would be proving the wrong half. Pinned:

  • a directory at the key the app did not declare (a real package, correctly named, exactly where CJS resolution would take it) — refused;
  • a link pointing somewhere else than the declaration names — refused, with a decoy/ carrying the same manifest name so only the PATH distinguishes it;
  • a plain version range with an on-disk shape identical to the loading case — refused; a range licenses no path;
  • an npm: alias — still checked by name;
  • github: / a tarball URL / the bare owner/repo shorthand — no on-disk location, nothing gained;
  • a declared location with nothing at the key, and a key with nothing at the declared location — neither verifies the other (the undefined === undefined trap, pinned in both directions);
  • a package publishing a require condition never reaches this fallback at all, so no load that succeeds today changes.

Gates. Derived with node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, re-derived after a fetch (byte-identical list), reconciled with --ran carrying each family's exit code: 92 derived / 92 run / 0 UNRUN / 0 NOT-MEASURED — "a DERIVED zero — all 92 recorded an exit code and none of them is 3". Four families first answered PREREQUISITE NOT MET (an unbuilt package, and one heap ceiling) rather than a verdict; each was re-run once its prerequisite was met and each then measured green — ⛔ no gate was weakened and no baseline was touched. Verdicts read from each gate's own printed line, exit codes captured by redirect-then-$?, never through a pipe.

pnpm lint — the FULL repo union, not a narrowing: eslint . --no-inline-config, 6585 files, 0 errors, 0 warnings, exit 0, at 37118385 (the final commit).

Typecheck@objectstack/types and @objectstack/cli both exit 0, the latter including check:test-typecheck.

Tests@objectstack/types: 20 files / 612 passed. @objectstack/cli --project unit: 194 files / 2685 passed. The integration tier is declared to CI: the diff touches no tier-integration file, no bin/ entry and no spawn helper.

Control charactersgrep -naP over all seven touched files returns nothing, with a positive control that fires and a negative control that does not; check:nul-bytes exit 0.

Also in this PR — the serve.ts sub-case, DRIVEN

The card recorded, as an un-driven observation, that formatOrganizationsInstallRemedy mints its own remedy for declared-unresolvable instead of deferring to err.message. Driven — by composing exactly the stage-1 refusal at serve.ts's catch (orgErr) — the observation holds, and the card's prescription ("whoever implements this removes the sub-case outright") does not. The sub-case is narrowed, not removed: pnpm's file: store copy and every git / tarball declaration still land on that kind. So the arm was not removed; it now defers, exactly as the declared-no-loadable-entry arm has since it landed. Rendered, before:

      • this app DECLARES @objectstack/organizations (dependencies: "file:../elsewhere") — the
        declaration is NOT the problem and re-reading package.json will not help.
        Repair the INSTALL in /tmp/…/app: run `pnpm install`, check that a
        production prune did not drop it, and that its dist is actually built — or
    …
    cause: … This is NOT an install problem, and NOT a declaration problem — the package is
    on disk and the declaration is right, so re-running `pnpm install`, un-pruning a
    deploy and rebuilding a dist all change nothing here.

One screen contradicting itself — the documented #14270 class. And on the other shape the same kind covers, driven separately, the bullet was a word-for-word duplicate of the three remedies the cause: line four lines below already carried. Deferring therefore loses nothing on either shape.

Docs

content/docs/deployment/tenancy-modes.mdx (not a governed surface) paraphrased that screen as "the message … tells you to repair the install". That sentence now describes a bullet that no longer says it, so it was edited rather than audited: it names the two shapes the declared-unresolvable kind covers, what each one's cause: says, and that a link: install pointing at exactly the declared directory now loads. The quoted refusal block on that page renders from the undeclared arm, which this PR does not touch, and is byte-identical.

Changesets

  • @objectstack/types: minor — previously-refused input now succeeds, which is added functionality rather than a restored documented behaviour (the old refusal was documented as deliberate and pinned). Measured, not assumed: the new string ships in dist/node.js and dist/node.mjs, with a pre-existing published string as the control.
  • @objectstack/cli: patch — a wrong remedy bullet removed. Measured the same way in dist/commands/serve.js.

Neither is major, so no ADR-0087 disposition applies.

Authored in Claude Code session session_01DapQyvYrFb1MxSYe7BL2nt (https://claude.ai/code/session_01DapQyvYrFb1MxSYe7BL2nt) — attribution kept in prose because this surface appends its own footer block on every body EDIT, so a session-URL footer sent here survives verbatim and gains a bare one underneath it, leaving two.


Generated by Claude Code

…clared

WIP checkpoint: second verification axis + tests, before the closure build.

Claude-Session: https://claude.ai/code/session_01DapQyvYrFb1MxSYe7BL2nt
Co-authored-by: Claude <noreply@anthropic.com>
Also updates content/docs/deployment/tenancy-modes.mdx for the two shapes the
declared-unresolvable kind now covers, and adds both changesets.

Claude-Session: https://claude.ai/code/session_01DapQyvYrFb1MxSYe7BL2nt
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Sep 10, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/cli, @objectstack/types, touching 18 documentable anchor(s).

21 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json edaf3b2f7832805d4d7c544fb88d696ff8056924.

7 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 4 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 23 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json edaf3b2f7832805d4d7c544fb88d696ff8056924packageMentionDocs.

Which tree this was computed on

This run read content/docs from 2ed8c1ea0a5777985b3c18554f85c1017ab2d9f6 — the merge of head 3711838529f80aba629b3cd25d72ce0e2c39d38d into base edaf3b2f7832805d4d7c544fb88d696ff8056924, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 2ed8c1ea0a5777985b3c18554f85c1017ab2d9f6 && git checkout 2ed8c1ea0a5777985b3c18554f85c1017ab2d9f6
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin edaf3b2f7832805d4d7c544fb88d696ff8056924 3711838529f80aba629b3cd25d72ce0e2c39d38d && git checkout -B drift-repro edaf3b2f7832805d4d7c544fb88d696ff8056924 && git merge --no-ff 3711838529f80aba629b3cd25d72ce0e2c39d38d

node scripts/docs-audit/affected-docs.mjs --json edaf3b2f7832805d4d7c544fb88d696ff8056924

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs edaf3b2f7832805d4d7c544fb88d696ff8056924 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

Copy link
Copy Markdown
Collaborator Author

Contract review

Reviewed head: 3711838529f80aba629b3cd25d72ce0e2c39d38d (37118385), PR #17542 / card #17046 — compared once against the PR object's head.sha, identical, and all 34 check runs carry it.

Tier: default judgment tier, self-review plus gates — 「余席条款②复核 = 默认判断档自审加门禁」. Contract-review tier is ⛔ reserved to the skills and spec seats.

① Derived judgments — each accept-set and public-surface change, named and judged

  1. The finder's accept set WIDENS, deliberately and as the card asks: a link: / file: / portal: install whose realpath(node_modules/KEY) equals realpath(resolve(hostRoot, DECLARED_PATH)) now LOADS where it was refused. CORRECT — this is the card's whole ask, and the reason its declaration is yes.
  2. The forbidden shape was NOT taken, and this is the judgment the card turns on. A link: / file: install whose manifest name differs from the key still keeps the wrong INSTALL wording — the location-carrying sub-case #14278 could not reach #15045's triage predicted a dev would 「skip the check when the specifier is a location」, which 「accepts any directory sitting at the key」. Verified at source, ⛔ not from the report: packages/types/src/node.ts:1154 reads if (!namedAsDeclared && declaredLocationAxis(declaration, linked)?.verified !== true) return undefined;either axis is sufficient and neither is skipped, and ?.verified !== true fails closed on undefined. declaredLocationPath (around :795-802) licenses a path only when the specifier starts with one of the three prefixes and the remainder is neither empty nor a // URL ⇒ a plain version range licenses no path. A directory declared by neither name nor path is refused exactly as before. CORRECT.
  3. Normalisation: realpathSync on both sides, ⛔ not a raw string compare and ⛔ not a basename match — the two shapes isProcessEntry ruled out under the The invokedDirectly entry guard is spelled ~8 ways across scripts/, and every one of them makes its script silently inert (exit 0, no output) when reached through a symlink — including check-governed-merges.mjs, the governed-surface register #10086 / PR fix(pm): dispatch-gates runs its CLI only when invoked directly (#9757) #10084 discipline, reused as discipline rather than imported (packages/types sits below packages/cli; importing upward would have been the defect). It uses realpathSync rather than .native because the sibling read in hostInstalledPackageDir does — comparing two different canonicalisers is its own defect class. CORRECT, and it answers triage's 「say which normalisation you used and why」 head-on.
  4. ⚠️ Case-insensitive filesystems: not covered by a test, and the comparison deliberately does not fold case — declared, not hidden. CORRECT as a bounded position: folding would ACCEPT a path the host did not write wherever case matters, while declining to fold can at worst fail to verify, which is today's refusal and ⛔ not a regression. A CI fixture on a case-sensitive filesystem could only have re-asserted what the other cases assert.
  5. ⚠️ pnpm's file: DIRECTORY install keeps today's refusal — measured on pnpm 10.33, ⛔ not reasoned: it routes through the virtual store as a hard-linked copy whose realpath sits inside the host's own node_modules, so the location axis cannot tie it to the declaration. CORRECT as a declared boundary — recovering the origin would mean parsing a package manager's private layout, and 「accept anything under node_modules」 is precisely the relaxation this card must not become. npm's file: (a symlink) does verify and is pinned.
  6. Public surface: formatOrganizationsInstallRemedy loses its third parameter (hostRoot). Measured rather than assumed: there is no packages/cli/api-surface ledger; packages/cli's exports are ., ./console, ./hook-body, ./package.json and serve.ts is not among them; and git grep over origin/main finds exactly one consumer outside serve.ts — its own spelling test, which this diff updates. ⇒ the export is a test seam, not a published API. CORRECT, and it does not move the grade, which is already yes.
  7. packages/spec untouched ⇒ the clause-② path leg is unhit and only the declaration leg is live. content/docs/** is ⚠️ not a governed surface, and the file list carries 0 governed-surface paths.

Machine predicates at this head, exit codes captured by redirect before any pipe: check-clause2-carriers --pair 17542exit 0, 「the clause-② declaration is readable in the fixed spelling and both carriers agree」. check-widening-tells --declaration yes --diff (1,214-line diff supplied) ⇒ exit 0, 「a yes already routes to contract review, so a tell on top of it decides nothing」.

② Semver grading — levels consistent with the declaration

  • @objectstack/types: minor — previously-refused input now succeeds. Consistent, and it satisfies the level axis a clause-② yes owes (at least one package at minor).
  • @objectstack/cli: patch — a wrong remedy bullet removed. Consistent.
  • Neither is major ⇒ no ADR-0087 disposition applies.

⭐ Both were measured into dist rather than asserted: the new strings were grepped out of packages/types/dist/node.js / node.mjs and packages/cli/dist/commands/serve.js, each with a pre-existing published string as a positive control. That is 「已发布 = 各包 files[] 实际发运的内容」 done properly, and it is the check most reports skip.

③ Boundary flags and out-of-scope notes — each answered

Independence

Implemented-by: claude/issue-17046-link-finder-realpath-axis
Reviewed-by:    session_01DapQyvYrFb1MxSYe7BL2nt

⚠️ Stated plainly: the implementer was a mode:subagent dev dispatched by this seat, which is why the rule spells its identity as a branch (「子代理无自有 session」). Default-tier in-seat review plus the gates, ⛔ not an isolated adversarial one — that instrument is the spec seat's, for a genuine contract fork or an unresolved dev flag, and neither is present: open_questions is empty and every boundary above is answered. The one judgment I re-derived hardest is ① item 2, because it is the one where an approving reviewer and a wrong PR look identical from the report alone.

Verdict

PASS.

All three pre-landing checks are satisfied at this head: ① this record; ② carriers to be cleared in the next stroke with a --pair re-read; ③ 34 check runs, 30 success / 4 skipped, 0 not-green, commit status success. Proceeding to strip both carriers, then ready + auto-merge. ⛔ This seat never merges outside the queue, and pm:dispatched stays on #17046 until MERGED is verified by measurement on origin/main.

派发席位 · session_01DapQyvYrFb1MxSYe7BL2nt · R72 · 2026-09-10T21:10Z(读表) · 本评论来自 domain:cli 派发座位


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

2 participants