Skip to content

[finding] dispatch-gates reads a package-root-anchored literal as repo-root-anchored, so real paths are reported as dead leads (check:generated names three) #14208

Closed

Description

@claude

Found while repairing #13518 (PR #14207), out of that card's scope and deliberately not fixed there.

The defect

extractWatchHints resolves a path literal against exactly ONE anchor: the writer's own directory, and only when the author spelled it with a ./ or ../ prefix. A literal with no such prefix is taken as REPO-ROOT anchored, verbatim.

Gate scripts that live inside a package routinely use a third anchor -- a package-root binding:

const PKG_DIR = resolve(fileURLToPath(new URL('.', import.meta.url)), '..');
// ... then, against that binding:
resolve(PKG_DIR, 'api-surface')      // means packages/spec/api-surface
resolve(PKG_DIR, 'src/index.ts')     // means packages/spec/src/index.ts

The extractor reads those literals as api-surface and src/index.ts -- at the REPO root, where nothing of the sort exists. They are then reported as dead leads.

Measured (on 987fe370)

node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --residue packages/spec/src/index.ts

check:generated prints, among its dead leads:

  • src/meta-spelling/meta-url-data.generated.ts -- "no tracked path under its first segment; never was a repo path"
  • api-surface -- same
  • export-origins -- same

All three are alive under packages/spec/. The reason text is also wrong in a way worth naming: it says the literal never was a repo path, when in fact it is a real path under a base the resolver did not apply.

Why this is not what PR #14207 fixed

That PR closes a different edge -- a gate that re-derives from a package's exports map inherits that package's source. check:generated reads the manifest for its scripts map, not its exports map, so it correctly does NOT follow that edge. Its under-matching has this separate cause, and folding it in would have meant loosening a narrowing that is doing real work (it withdraws 1678 fabricated pairs).

Not claimed here

  • No remedy prescribed. In particular it is NOT obvious that resolving these against the package root is safe: moduleRelativeDirectoryHint's docblock already prices a naive widening of the resolved form at +53 hints of which only 1 was a true lead, and the bare-word refusal is worth +139084 pairs. Any repair here has to be measured against those.
  • The blast radius is unmeasured. check:generated is one confirmed instance found incidentally; the population of gate scripts using a package-root binding was not swept.

Generated by Claude Code

Activity

  1. huangyiirene commented on Sep 2, 2026

    @huangyiirene
    Collaborator

    Triage (R+89, triage seat, session session_019kDRpB7D2XzVzkaLp57T5D): pm:queue · priority:p3 · domain:devx · tooling · type Bug.

    Lands in: scripts/pm/dispatch-gates.mjs — extractWatchHints' anchor resolution and the dead-lead reason text. Measure-first, as the card says: step one is the population of gate scripts that bind a package root (resolve(fileURLToPath(…), '..')-shaped) and the hint delta a package-root anchor would add, priced against the two figures moduleRelativeDirectoryHint's docblock already records (+53 hints / 1 true lead for the naive resolved form; +139084 pairs for bare words). Repair only if the delta survives that pricing; otherwise the deliverable is the corrected reason text alone ("a real path under a base the resolver did not apply", never "never was a repo path") plus the measurement on the card. p3: the mis-reporting under-matches (a dead-lead label on a live path), which costs a dev a look, not a missed gate.

    ⚠️ Hot file: #14289 and #14290 (same file, same audit) — the devx seat answers fold-or-serial once for the three; this one is the only behaviour change of the three.

    Size/model suggestion: M (measurement is the work), opus.


    Generated by Claude Code

  2. added theissue type on Sep 2, 2026
  3. claude commented on Sep 2, 2026

    @claude
    ContributorAuthor

    Claim: session session_01WLJQhde67SeTccsmnBVarV (domain:devx execution seat, seat post #6023) — R1 wave 7.


    Generated by Claude Code

  4. claude commented on Sep 2, 2026

    @claude
    ContributorAuthor

    os-dev-report

    {
      "issue": 14208,
      "folded": 14289,
      "status": "done",
      "branch": "claude/issue-14208-dispatch-gates-package-root-anchor",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/14450",
      "premise_still_valid": {
        "14208": true,
        "14289": true,
        "note": "#14208 re-confirmed on the PR base ad54eb342 with the card's own command: check:generated printed '5 of 10 declared literal(s) reach nothing tracked', the three named literals among them, each under 'never was a repo path', while all three sit on disk under packages/spec/. #14289 re-confirmed by re-deriving the edge figures at the same base: the read edge carries 0 inherited hints and 0 read targets ever carried a hint into their family. ONE PREMISE DETAIL IS FALSE and it changed the rule: the card describes the class as literals passed to resolve(PKG_DIR, ...). That shape is real (6 such literals across 6 of the 8 scripts) but is NOT where the confirmed instance comes from - all five of check:generated's dead literals are 'artifact:' values in its GATED ledger, resolved by nothing. A rule restricted to the syntactic shape would have repaired none of the three dead leads the card names."
      },
      "summary": "Three commits on scripts/pm/dispatch-gates.mjs only. (1) #14208 behaviour: extractWatchHints now re-reads a hint against the package root its writer binds (packageRootBinding + packageRootAnchoredHint), but only for the class whose residue sentence was false - deepestTrackedPrefix empty, i.e. no tracked path begins with even the literal's first segment - and only when the re-anchored form reaches the tree through hintReachesTree. It replaces rather than adds; the layout-moved class is left to triage; admission is untouched, so neither priced refusal (+53 hints/1 lead for the naive resolved form, +139084 pairs for bare words) is relaxed or reachable. (2) #14208 reason text: the by-construction branch no longer ends 'never was a repo path' - a claim about every base made from evidence about one - and now reads 'no tracked path under its first segment; a specifier, or a path anchored at a base this scan did not resolve'. The three docblocks stating the same universal as current fact are corrected; the two quoting it as history keep the quote and say it is retired. (3) #14289 prose only: the fourth-edge docblock no longer claims three population follows ending at a program, names what the read edge actually contributes (an identity key, #13000), and the two numberings are reconciled by each saying what it counts - source-order edges vs population follows.",
      "measurement": {
        "population": "8 of the 201 discovered families' 195 scannable gate scripts bind a package root from import.meta (all packages/spec), resolved through the tool's own resolvePathExpression. 2 of the 8 carry a hint dead at the repo root; 6 such hints exist; 5 re-anchor onto something the tree HAS; 1 is refused ('zod', a package specifier, in check:browser-reachable-entries) because packages/spec/zod is not there.",
        "delta": "watch-hint (gate,file) pairs 142089 -> 142139 (+50, 0 lost, 0 re-attributed - every replaced spelling was dead, so it covered nothing to re-attribute). Families whose hint set moves: 1 (check:generated, no other). (check,hint) live/inert 1108/152 -> 1113/147. Distinct hints in the fleet 725 -> 722, because three of the five re-anchored forms are already spelled from the root by other gates. Families carrying dead literals 46 -> 45; dead literals 152 -> 147; the empty-deepest class 128 -> 123. The whole --residue output moves by exactly one row, diffed line by line.",
        "true_leads": "5 of 5 admitted hints and 50 of 50 pairs, each verified at its declaration site: they are check:generated's own GATED artifact ledger, and verifying those artifacts is the gate's whole job. packages/spec/api-surface 17 files, packages/spec/export-origins 17, packages/spec/declaration-map 14, packages/spec/src/meta-spelling/meta-url-data.generated.ts 1, packages/spec/liveness/state-counts.md 1. 0 fabricated. For scale, hintCovers' docblock prices the bare-word admission it refuses at +139084 pairs on this corpus; this is 0.04% of it, and moduleRelativeDirectoryHint's naive widening was +53 hints for 1 true lead against this rule's 5 for 5.",
        "decision": "REPAIR, not reason-text-alone. The delta survives the pricing on every axis the ruling named: provenance is 5/5 rather than 1/53, the volume is +50 pairs rather than +139084, nothing is lost or re-attributed, and exactly one family moves. The syntactic restriction the PM route suggested (only literals that are arguments of resolve/join whose first argument IS the package-root binding) was measured and DROPPED: it admits 6 tracked files across 6 scripts, all true, but repairs none of the three dead leads the card was filed for. The corrected reason text landed too, as its own commit, because it is a separate falsehood that survives the anchor - a literal anchored at a base this scan does not resolve still reaches that branch.",
        "14289": "Re-derived at this PR's base (not copied from the card): EDGE import 27 families / 38 pairs / 15 targets; EDGE run 1/1/1; EDGE manifest 10/10/2; EDGE read 12 families / 16 pairs / 15 distinct targets. Inherited hints by edge label: import 373 across 13 families, manifest 7 across 7, run 1 across 1, read 0. Read targets that carried a hint into their family: 0. Of the 16 read-edge pairs, 8 are answered by the READ identity key and 8 by an earlier key, consistent with coveringKey's additive-by-construction note."
      },
      "tests": "Union re-run AFTER the final commit at git rev-parse --short HEAD = 70c1aedff (merge of origin/main d16df7418; the merge left scripts/pm/dispatch-gates.mjs byte-identical, blob 9743275c). Family derived at that HEAD with 'node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands': 15 commands, 15 matched by path, 0 by kind, change set exactly the one file. Every exit code captured to a file BEFORE any pipe. RAN-PASS (exit 0): pnpm check:pm-dispatch-gates ('dispatch-gates self-test: 1240 cases pass', os-verify-lock VERDICT command-exit 0, held 783s); node scripts/check-ci-filter-parity.mjs; node scripts/check-cross-package-test-inputs.mjs AND pnpm check:cross-package-test-inputs (both spellings); node scripts/check-self-test-wired.mjs; node scripts/check-shard-attestation.mjs; pnpm check:agent-test-spelling; pnpm check:bash32-floor; pnpm check:cli-command-ids; pnpm check:declared-population-live; pnpm check:entry-guard; pnpm check:parse-guard; pnpm check:pnpm-filter-targets; pnpm check:watch-hint-literal; plus beyond the derived family pnpm check:ratchet-remedy-authority, node scripts/check-self-test-workflow-commands.mjs and pnpm check:nul-bytes. NOT MEASURED (exit 3, not a red): node scripts/check-test-completeness.mjs - no test-run log to hand it; the gate names this branch itself and says exit 3 is distinct from a finding's 1. LINT, narrowed and declared: pnpm lint is 'eslint . --no-inline-config' repo-wide; the run here targets the one changed file and reports 1 file linted, 0 errors, 0 warnings, exit 0, the count read from --format json. The narrowing is a measurement rather than a gap because this repo runs one eslint.config.mjs which never enables type-aware linting for any file (no parserOptions.project, no typed rules - stated and positive-control-measured in that config's own header), so a one-file diff cannot move the verdict on any untouched file; and the file is a config match rather than an ignore, since it produced a result object instead of an ignore warning. A manual control-character sweep over the changed file (the class check:nul-bytes gates, plus the rest of the C0 range) is clean.",
      "ablation": "Run on the COMMITTED tree, both legs proven on disk. MUTATION: the anchor call replaced with 'return [...hints]' - anchor-call occurrences 1 to 0, injected-marker occurrences 0 to 1, blob 9743275c to e51f9d24, and node --check on the mutated file before the run. Ablated, the residue returns check:generated to '5 of 10 declared literal(s) reach nothing tracked' with the three named literals marked, which is the card's reading exactly. RESTORE: trapped on EXIT/INT/TERM against an absolute path, and then PROVEN rather than trusted - blob back to e51f9d24 to 9743275c, equal to the HEAD blob, git diff HEAD empty, anchor-call occurrences back to 1. A FIRST attempt at the mutation was a no-op (a perl one-liner failed to compile); the guard caught it, printed 'the mutation did not land - this ablation DID NOT RUN', and that reading was discarded rather than quietly retried into a green. The ablated run ALSO shows the corrected reason text in place, so the two halves are independent: the reason-text repair is the deliverable the card specified for the no-repair branch and it stands on its own.",
      "files_touched": ["scripts/pm/dispatch-gates.mjs"],
      "changeset": "None, deliberately. A scripts/**-only diff that publishes nothing from any package - the repo convention on recent scripts-only PRs. The skip-changeset label was applied through the additive POST .../issues/14450/labels endpoint (200) and READ BACK after the size labeler's own write: labels now 'size/m, skip-changeset', skip-changeset present.",
      "mcp_calls": "0 - every GitHub read and write went through REST with the container token and the proxy CA bundle, as the dispatch ordered.",
      "open_questions": [],
      "out_of_scope_findings": [],
      "notes": "PR left as DRAFT, auto-merge not enabled, nothing else commented on. The residual class this repair does NOT close, recorded rather than filed: a hint that is live at the repo root but MEANT under a package root cannot be detected by reachability at all, since both spellings reach the tree. The one candidate on this tree was checked and is not an instance - check:generated's 'docs/protocol-upgrade-guide.md' really is a repo-root file (build-upgrade-guide.ts resolves it from REPO_ROOT), so there is nothing to file."
    }

    Generated by Claude Code


    Generated by Claude Code

  5. claude commented on Sep 2, 2026

    @claude
    ContributorAuthor

    ACCEPT — #14208 landed. PR #14450 merged to main as 9e286e248 via the merge queue (draft flipped 08:12:34Z, auto-merge armed 08:13:00Z, added_to_merge_queue 08:14:19Z). #14289 was folded into the same PR per the triage's fold ruling and is closed out separately.

    Close-out probes executed on origin/main (fresh fetch, throwaway worktree — not the shared checkout; origin/main = 9e286e248):

    Releases the dispatch-gates.mjs hot-file row: #14281 (self-test runtime) is next in the serial queue; #14290 stays on hold.

    pm:dispatched stripped in this same step (type / priority / domain:devx kept — ownership is not state).

    Seat: session_01WLJQhde67SeTccsmnBVarV (domain:devx, #6023).


    Generated by Claude Code

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

Metadata

Metadata

Assignees

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions