Skip to content

fix(pm): derive the gates that re-derive from a package export surface - #14207

Merged
baozhoutao merged 1 commit into
mainfrom
claude/issue-13518-spec-export-gate-derivation
Sep 1, 2026
Merged

baozhoutao merged 1 commit into
mainfrom
claude/issue-13518-spec-export-gate-derivation

Conversation

@claude

@claude claude Bot commented Sep 1, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #13518

The reading, re-derived on this branch's base (987fe370, after PR #13862 / #14012 / #14188)

The card's premise still held. Its own re-check, run on a fresh tree:

node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack packages/spec/src/index.ts
gate before after
check:api-surface absent matched
check:export-origins absent matched
check:entry-nameability absent matched
check:exported-any absent matched
check:dual-source-exports absent matched
check:browser-reachable-entries absent matched
check:strictness-ledger (positive control) present present

33 matched families before, 39 after. The control was present in both runs, which is what makes the six an absence rather than a dead query.

Why the whole CLASS fell out of the derivation

Not six defects. Every one of the six re-derives its population from @objectstack/spec's public export surface, and none of them spells that surface as a path. Each reads packages/spec/package.json, walks its exports map, and resolves each subpath into a built dist/*.d.ts. So the population is computed through the manifest, and both places a literal could have carried it fail:

  • dist/ is a build output and untracked, so any literal reaching it dies in the reachability sweep by construction;
  • the literals these gates do write are anchored at a package root (resolve(PKG_DIR, 'src/index.ts')), and extractWatchHints resolves against exactly one anchor -- the writer's own directory. A literal with no ./ prefix is taken from the repo root, so 'src/index.ts' becomes the root hint src/index.ts and reaches nothing.

The derivation models three follows -- import, program-text read, exec -- and all three end at a program whose declared population can be inherited. A package manifest is not a program. So the entire class ended at a manifest read that contributed nothing, and each of the six scored silent with only its helper-module imports as population.

That this is a class and not a story is printed on other gates today: check:generated's dead leads are api-surface, export-origins and src/meta-spelling/... -- three literals alive under packages/spec/ and dead at the root.

The repair: an EDGE, not six names

⛔ No gate name is added to any table. Following the pattern PR #14012 established for exec edges, this adds the fourth follow -- the manifest edge -- routed through the same three pieces and adding none:

  • the followed file's own declaration decides what a caller inherits. For a module that is declaredInheritedPopulation; for a manifest it is the exports map, read from the tree rather than from a regex over the gate's prose;
  • hintOrigin / hintEdge label the inherited hint so it never travels as a claim the gate made itself. The via column reads gate source via the export surface declared by packages/spec/package.json;
  • it is appended last, after own, imported and run hints, so it can only fill a hole.

A second, smaller hop was required by one member: build-export-origins.ts reaches its package root through a function parameter (collectSourceEntries(pkgDir)), where the others use a constant. singleCallSiteParameters reads a parameter bound at exactly one call site -- the same one-hop shape singleReturnExpressions already uses. It is consulted only where nothing binds the name, which is the branch that returned unknown before, so it can add a reading and can never move one.

The narrowing that decides the number

A gate must actually read the exports map to follow the edge. Without that test the edge admits every reader of any manifest -- +1678 pairs on this tree, every one fabricated. The three live refusals, each checked at its own declaration site:

gate reads verdict
check:docs-image-tag packages/cli/package.json -> .version refused (235 fabricated pairs withdrawn)
check:authorable-surface packages/spec/package.json -> .version refused
check:generated packages/spec/package.json -> the scripts map refused

check:generated is under-matched for packages/spec/src for the other reason above (package-root-anchored literals dying at the root). That is a different defect and is filed separately rather than fixed here under a test it does not pass.

Does a seventh gate of the class get covered automatically?

Yes, and it is demonstrated two ways rather than argued:

  1. A live seventh gate the card never named. check:dual-build-cjs-loads walks the same exports map and require()s every published entry (metadata-core's CJS entry point is unloadable since #12843 — import.meta emitted verbatim into dist/index.cjs is a parse-time error, so the guarding try/catch never runs #12971). It is picked up by the edge with nothing listing it. It gains no pairs -- its CI job filter is packages/** and already covered them -- it gains the right provenance, and the self-test pins it by name.
  2. An eighth that exists nowhere but the case. The self-test writes a gate in the class shape, in no table anywhere, and asserts it inherits the same population.

Blast radius, measured before and after

Over 199 discovered families x 7762 tracked files, counted through coveringKey -- the same key the printed block renders from:

(family, file) pairs the derivation covers   166173 -> 172797  (+6624)
pairs LOST                                                          0
pairs RE-ATTRIBUTED (same pair, new via)                            0
families whose VERDICT changes                                      6

The six take 1104 pairs each -- the whole of packages/spec/src, once, which is the defect exactly. No family outside the class moves in either direction.

Verification (all at f2b1d1fa)

scripts/pm/dispatch-gates.mjs is a gate script, so its own self-test is owed on top of the derived family:

command result
pnpm check:pm-dispatch-gates 1110 cases pass, 0 fail -- 20 of them added by this diff
node scripts/pm/bare-root-worklist.mjs --self-test exit 0
node scripts/check-ratchet-remedy-authority.mjs exit 0
pnpm check:declared-population-live exit 0
pnpm check:watch-hint-literal exit 0
pnpm check:entry-guard / check:parse-guard exit 0
pnpm check:cross-package-test-inputs + node scripts/check-cross-package-test-inputs.mjs exit 0
node scripts/check-ci-filter-parity.mjs exit 0
node scripts/check-shard-attestation.mjs exit 0
node scripts/check-self-test-wired.mjs exit 0
pnpm check:agent-test-spelling / check:bash32-floor / check:cli-command-ids / check:pnpm-filter-targets exit 0
pnpm check:nul-bytes exit 0
node scripts/check-test-completeness.mjs NOT MEASURED -- it needs a saved turbo run test log; run bare it exits 3 and its own text says to record it as not measured

The family list was derived from the actual diff with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands (no path arguments -- the script takes its own change set), not quoted from the card.

Every exit code above was captured before any pipe. Two exit=3 readings on the first pass (check-ci-filter-parity, check-shard-attestation) were PREREQUISITE NOT MET -- a fresh worktree with no node_modules -- and are recorded green above only after pnpm install and a re-run.

Both directions pinned

The 15 new self-test cases pin the repair and its refusals, not that a derivation ran:

  • the six derive for packages/spec/src/index.ts, and the control still does;
  • and none of the six reaches that path on anything it spells itself -- without this half every case above passes on a key the gate already had, which is the reading that would let someone "fix" this by widening an unrelated literal;
  • the via column names the export surface, not a population the gate declares;
  • the identical manifest read with the exports read removed contributes nothing;
  • a manifest read inside a self-test body is a fixture, not the gate reaching a package;
  • a parameter with two call sites contributes nothing rather than a pick;
  • entry.manifests is exactly what the scan finds over the family's own files -- never a list kept in the file;
  • the live tree really has gates reading a manifest without reading its exports (6 gate files), and not one inherits a package source.

20 cases in total, counted from the diff. The pre-change baseline was not separately measured, so no before/after case count is claimed -- only that the suite is green at f2b1d1fa with 0 failures.

No changeset

The diff is one file under scripts/pm/** and publishes nothing from any package, so skip-changeset applies and the label is set on this PR.

Note for review

scripts/pm/dispatch-gates.mjs is the board's single-writer toolchain file. No open PR claimed it when this branch started -- re-checked against all 8 open PRs immediately before the first push.

Authoring session: https://claude.ai/code/session_01WLJQhde67SeTccsmnBVarV


Generated by Claude Code


Generated by Claude Code

Six gates re-derive their population from `@objectstack/spec`'s public
export surface, and every one was absent from the derivation for
`packages/spec/src/index.ts` -- the entry point that IS their subject.
One shape, one hole.

None of them spells that subject as a path. Each reads the package
manifest, walks its `exports` map, and resolves each subpath into a
built `dist/` that is untracked by construction. The derivation models
three follows and all three end at a PROGRAM whose declared population
it can inherit; a manifest is not a program, so the whole class ended
at a read that contributed nothing.

Adds the fourth follow -- the manifest edge -- routed through the same
pieces as the other three: the followed file's own declaration decides
what a caller inherits (here the `exports` map), `hintOrigin`/`hintEdge`
label it so it never travels as a claim the gate made itself, and it is
appended last so it can only fill a hole. A gate must READ the exports
map to follow it: three live gates read a manifest for `.version` or the
`scripts` map and inherit nothing.

Also reads a package root held in a PARAMETER (one call site, one
value), consulted only where nothing binds the name -- the branch that
returned `unknown` before -- which is the spelling one of the six uses.

Measured over 199 families x 7762 files: 166173 -> 172797 pairs (+6624),
0 lost, 0 re-attributed, 6 verdicts changed. A seventh live gate the
card never named, `check:dual-build-cjs-loads`, is covered by the same
edge, and the self-test pins an eighth that exists nowhere but the case.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WLJQhde67SeTccsmnBVarV
@claude claude Bot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Sep 1, 2026
@github-actions github-actions Bot added the size/l label Sep 1, 2026
@baozhoutao
baozhoutao marked this pull request as ready for review September 1, 2026 12:57
@baozhoutao
baozhoutao added this pull request to the merge queue Sep 1, 2026
Merged via the queue into main with commit 24b6635 Sep 1, 2026
37 checks passed
@baozhoutao
baozhoutao deleted the claude/issue-13518-spec-export-gate-derivation branch September 1, 2026 13:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/l skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[finding] Six packages/spec export-surface gates are absent from the derivation when packages/spec/src/index.ts is the input — one shape, one repair

2 participants