Skip to content

Derive a gate's program-text reads as family inputs, so a staged gate reaches the family that runs a copy of it - #13129

Merged
os-elon merged 3 commits into
mainfrom
claude/issue-13000-staging-dependency-derivation
Aug 29, 2026
Merged

Derive a gate's program-text reads as family inputs, so a staged gate reaches the family that runs a copy of it#13129
os-elon merged 3 commits into
mainfrom
claude/issue-13000-staging-dependency-derivation

Conversation

@os-elon

Copy link
Copy Markdown
Collaborator

Fixes#13000

scripts/pm/dispatch-gates.mjs could not see the dependency where one gate script opens another script's source at a path anchored to its own location — the shape scripts/objectui-changeset-digest.mjs uses to stage a copy of scripts/check-adr-0087-registration.mjs into a throwaway repo and run it. A PR that added an import to the staged gate broke the digest's self-test with ERR_MODULE_NOT_FOUND, and the derivation had scored check:objectui-changesetsilent for that diff.

The classification came first, and it decided the mechanism

The card's fence was explicit: mkdtemp is not the same as stages a sibling gate, and the class size decides between a registry and a convention-trigger. Measured on this tree (5404 tracked sources, 181 discovered families):

measurementcounthow
mkdtempSync call sites621text sweep, all tracked sources
calls copying a real tree file (a readFileSync inside a write-ish call's args)18 sites / 8 filescomment-masked call-arg scan
scripts staging another script's real source and executing it2manual adjudication of those 8

The two are scripts/objectui-changeset-digest.mjs and scripts/sync-template-versions.mjs. Everything else in the 8 either mutates a fixture in place, copies a package.json, or reads a gate's source to assert on it (all twelve dispatch-gates.mjs sites are that last kind — live-tree contact, which is a separate card and is not touched here).

Why neither mechanism the card proposed

  • (a) a staging-manifest registry. With a class of two it is nearly free to write, and it still needs the guard the card asked for: refuse when a stager exists with no row. But detecting "a stager exists" is the derivation — and once the derivation exists the registry is a second copy of an answer already computed at runtime, with a way to go stale that the derivation does not have.
  • (b) grep stager scripts for the edited gate's filename. Measured, because the false-positive rate was the deciding number: a basename grep of the 176 gate files across the tracked sources hits 924 mentions in 451 files; blanking comments leaves 372 in 187, still almost entirely fixture names and prose. Unusable.
  • (c) resolve, do not grep — what landed.anchoredReadTargets finds readFileSync/copyFileSync sites, skips those inside comments and inside string literals by position, and resolves the first argument with the module's existing resolvePathExpression. An expression it cannot read returns unknown and contributes nothing; a resolved path the repo does not track contributes nothing. It reports 5.

(c) has (a)'s precision and (b)'s zero maintenance, and it is available only because the tool already owns the resolver — scratchDirSitesInSource reads path expressions for a different question and pays for this half.

The target restriction, and what it deliberately declines

readProgramTargetsInSource keeps only PROGRAM-TEXT targets. The wider reading — every tracked file a gate opens at an anchored path — is not wrong: measured at 81 pairs, 62 of them leads no other key gives, and every one is a file some gate really opens. It is a bigger card. 34 of the 62 land on four files (package.json +12, .github/workflows/lint.yml +9, packages/spec/package.json +8, turbo.json +5), which would take a root-manifest card from 5 leads to 17, and this file's header prices that direction. The line drawn instead is statable: a gate that opens another file's PROGRAM TEXT depends on that program; a gate that opens data it parses is the other question. The refused half is 79 anchored reads of tracked data, and the self-test pins that it is non-vacuous, so a future card widening it reds a case rather than discovering the boundary.

Does #13049's mechanism generalise? No — and that was worth checking

PR #13049 taught the derivation to follow needs.JOB.outputs.NAME back to its dorny/paths-filter block. That hop is declared to declared: a path population already sitting in YAML the tool parses, one reference away. This card's hop is imperative JavaScript that produces no declared population at all, so nothing in #13049 reaches it. The generalisation that does exist is one door over: firstPartyImportTargets already follows a gate's ./sibling.mjs import by reading source text, and this is the same species — an undeclared input, spelled in code instead of in an import statement. It is placed beside it for that reason.

Worth naming: the staging literal never became a watch hint because extractWatchHints runs maskSelfTests first, and the staging sits inside the digest's --self-test — which is the invocation CI runs. The two scans want opposite things from the same bytes, so this one masks comments only, and a self-test case pins that the family has no hint covering the staged gate, so the main case cannot pass on a key it is not testing.

Verification

The reconstructed original failure, same tree, two tool versions (the diff is one path, scripts/check-adr-0087-registration.mjs — the original PR's edit was adding an import to it):

BEFORE (tool at origin/main e9c1055ea2, run from a worktree at that commit)
14 matched families; check:objectui-changeset appears only under
"Silent (source names paths, none of which cover yours — the weakest verdict)"
as: pnpm check:objectui-changeset [lint.yml] names: .changeset
AFTER (this branch)
15 matched families; the one added is exactly
- pnpm check:objectui-changeset [lint.yml] matched via
scripts/check-adr-0087-registration.mjs
⇢ program text read by scripts/objectui-changeset-digest.mjs

Whole-tree blast radius, every tracked file crossed with every family, with the new key on and off:

ADDED (family, path) leads: 5
RE-ATTRIBUTED (must be 0): 0
LOST (must be 0): 0
tracked files whose gate list changes: 5 of 7288

The five are scripts/check-adr-0087-registration.mjs and scripts/bump-objectui.sh (both to check:objectui-changeset), scripts/invoked-as.mjs to check:template-version-sync, apps/docs/source.config.ts, and packages/spec/src/meta-spelling/meta-url-data.generated.ts. Zero re-attribution is structural, not luck: the key is consulted last in coveringKey, so it can only fill a hole.

Ablation — predicted before running, both legs hit exactly. Predictions were written to a file first; each leg starts from a proven-HEAD tree, the mutation is confirmed on disk by fixed-string counts of both the removed anchor and an injected marker plus a blob-hash change, and the restore is proven by blob hash equal to the HEAD blob plus an empty git diff HEAD. No build leg applies: the tool is executed from source by path, never through a package exports, so there is no dist/ copy that could serve a stale mutation.

legpredicted redobserved red
A — delete the read branch in coveringKey2 (the staged-gate case, the every-edge-earns-a-key case)exactly those 2
B — delete the read collection in discoverFamilies4 (add: the non-vacuity case, the reconstruction case)exactly those 4
control (restored)0879 cases pass

A first attempt at leg A was VOID and is reported as such: the on-disk proof used grep -c on an anchor carrying regex metacharacters, the instrument refused rather than reporting, and because that leg returned before its restore, leg B then ran on a tree still carrying mutation A. Both readings were discarded and both legs re-run from a hash-proven HEAD with grep -cF. The numbers above are from the clean run.

Gates. Derived with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack on the final commit (no STALE TREE line; tree at origin/mainadf70f763b). Union run at 8b4156139f, which is the final head.

pm-dispatch-gates exit=0 ✓ dispatch-gates self-test: 879 cases pass.
self-test-wired exit=0 nul-bytes exit=0
agent-test-spelling exit=0 bash32-floor exit=0
cli-command-ids exit=0 cross-package-ti exit=0
entry-guard exit=0 parse-guard exit=0
pnpm-filter-targets exit=0 watch-hint-literal exit=0
ci-filter-parity exit=0 shard-attestation exit=0
bare-root-worklist exit=0
pnpm lint (eslint . --no-inline-config, WHOLE repo) LINT_EXIT=0

check-test-completeness.mjs is NOT MEASURED, not red: run standalone it prints check-test-completeness: usage: check-test-completeness.mjs turbo-test-log ... and exits 1 because it consumes a turbo test log CI produces. That false red is already recorded as #13110.

Every exit code above was captured by redirecting to a file first and reading $? before any pipe.

Scope

Seven new self-test cases; 879 total. No changeset: the diff is one CI-internal PM tooling file and publishes nothing, and the changeset requirement is a label opt-out in pr-automation.yml rather than a path exemption, so this PR carries skip-changeset.

#12983 is untouched here — it remains open and is a separate card about that gate's header prose, not about the derivation. One side-finding was recorded as #13126: editing a first-party module a gate script IMPORTS still names no family, measured at 228 unreached (family, module) pairs; it is deliberately not addressed in this PR because 228 pairs need their own pricing.

Generated by Claude Code


Generated by Claude Code

@os-elonos-elon added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Aug 29, 2026 — with Claude
@os-elon
os-elon marked this pull request as ready for review August 29, 2026 06:14
@os-elon
os-elon enabled auto-merge August 29, 2026 06:14
@os-elon
os-elon added this pull request to the merge queueAug 29, 2026
Merged via the queue into main with commit cea9157Aug 29, 2026
34 checks passed
@os-elon
os-elon deleted the claude/issue-13000-staging-dependency-derivation branch August 29, 2026 07:06
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/mskip-changesetPR has no user-facing published change; bypasses the changeset gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

dispatch-gates cannot see runtime staging dependencies between gate scripts (digest sandbox broke on a staged gate's new import)

2 participants

@os-elon@claude