feat(types): verify a link:/file: install by the LOCATION its host declared - #17542
Conversation
…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>
📓 Docs Drift CheckThis PR changes 2 package(s): 21 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 7 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 23 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # 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
|
Contract reviewReviewed head: 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
Machine predicates at this head, exit codes captured by redirect before any pipe: ② Semver grading — levels consistent with the declaration
⭐ Both were measured into ③ Boundary flags and out-of-scope notes — each answered
Independence
VerdictPASS. All three pre-landing checks are satisfied at this head: ① this record; ② carriers to be cleared in the next stroke with a 派发席位 · Generated by Claude Code |
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.tsverifies the one directory it consults —HOSTROOT/node_modules/KEY— against what the host app's ownpackage.jsondeclares. Until now it could do that only by NAME, and alink:/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 differentname, was refused.Reproduced on
origin/main(74f06116) with a real symlink fixture, verbatim: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 —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 ownisProcessEntryruled out (src/utils/invocation.ts, the #10086 / PR #10084 discipline). The discipline is reused; nothing is imported, sincepackages/typessits belowpackages/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; withoutrealpaththey never compare equal. Chains,..spans and trailing separators collapse here too, which is whyresolve()alone is not enough on either side. Driven by a fixture that declareslink:../linked/../alias/(wherealiasis itself a symlink) while the key points at../../linked.pnpm's store layout — MEASURED on pnpm 10.33, not reasoned:
link:symlinks the key straight at the declared directory and verifies. Afile:directory install routes through the virtual store — a hard-linked copy whose realpath is inside the host's ownnode_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 undernode_modules" is the relaxation this must not become. npm'sfile:(a symlink) does verify, and is pinned.case-insensitive filesystems — not 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 inhostInstalledPackageDiruses it; comparing two different canonicalisers is its own defect class.file://URL spellings are declined, not guessed at — the remainder afterfile: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.tsrestored to its74f06116blob (proved on disk — blobe9c6c4cd…vs HEAD's67bebb32…, marker countsdeclaredLocationAxis7 → 0), then re-run.74f06116Restore proved by blob equality with the HEAD blob and an empty
git diff HEAD, not by an exit code; the script carriedtrap … EXIT INT TERMwith 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:
decoy/carrying the same manifest name so only the PATH distinguishes it;npm:alias — still checked by name;github:/ a tarball URL / the bareowner/reposhorthand — no on-disk location, nothing gained;undefined === undefinedtrap, pinned in both directions);requirecondition 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--rancarrying 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 answeredPREREQUISITE 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, at37118385(the final commit).Typecheck —
@objectstack/typesand@objectstack/cliboth exit 0, the latter includingcheck:test-typecheck.Tests —
@objectstack/types: 20 files / 612 passed.@objectstack/cli--project unit: 194 files / 2685 passed. Theintegrationtier is declared to CI: the diff touches no tier-integrationfile, nobin/entry and no spawn helper.Control characters —
grep -naPover all seven touched files returns nothing, with a positive control that fires and a negative control that does not;check:nul-bytesexit 0.Also in this PR — the
serve.tssub-case, DRIVENThe card recorded, as an un-driven observation, that
formatOrganizationsInstallRemedymints its own remedy fordeclared-unresolvableinstead of deferring toerr.message. Driven — by composing exactly the stage-1 refusal atserve.ts'scatch (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'sfile:store copy and every git / tarball declaration still land on that kind. So the arm was not removed; it now defers, exactly as thedeclared-no-loadable-entryarm has since it landed. Rendered, before: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 thedeclared-unresolvablekind covers, what each one'scause:says, and that alink:install pointing at exactly the declared directory now loads. The quoted refusal block on that page renders from theundeclaredarm, 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 indist/node.jsanddist/node.mjs, with a pre-existing published string as the control.@objectstack/cli: patch — a wrong remedy bullet removed. Measured the same way indist/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