Skip to content

[finding] The shared workspace-enumerator consolidation is blocked on dispatch-gates following first-party imports — today it would DELETE the population declarations it is meant to centralise #11190

Description

@os-zhuang

Measured while fixing #10542 (PR #11189), which landed the per-gate half and deliberately deferred this one. Filed unassigned; not fixed there.

What #10542 proposed

That card's closing section observed that twelve scripts each carry their own pnpm-workspace.yaml parser, several admitting it in a comment ("Minimal pnpm-workspace.yaml block parsers (same approach as …)"), and that two of them once carried byte-identical 11-entry WORKSPACE_PARENT_DIRS arrays with the same blind spot in both. It concluded that a shared enumerator — the shape scripts/i18n-bundle-surface.mjs already sets — "would make the population declarable in one place instead of twelve, which is the same refactor as the fix rather than a separate one".

Why it is not the same refactor, measured

Under the current derivation it is the opposite of a fix, and the mechanism is two functions in scripts/pm/dispatch-gates.mjs:

  • resolveCheckToFiles extracts script paths from the npm script's command string (/(scripts\/[\w./-]+\.(?:mjs|cjs|js|sh))/g over scriptsMap[checkName]);
  • discoverFamilies then scans exactly those files for watch hints.

A module a gate imports is never opened. So a population declaration moved out of a gate and into a shared enumerator stops contributing hints to every gate that imports it. Concretely, that would silently undo:

All three would go back to naming no card in the tree, with every gate still green and nothing in the output saying so — the same invisible failure #10542 exists to retire, reintroduced by the tidy-up meant to prevent it.

PR #11189 pins this as an assertion rather than leaving it as prose: dispatch-gates' self-test now asserts that a family's hints are exactly those of the scripts its command names, so a later author measures the constraint instead of trusting a paragraph.

What the work actually is, in order

  1. Teach the derivation to follow first-party imports (one level, scripts/-relative, no node_modules). This is the enabling change and it is the one with a fabrication budget to measure: hintCovers' docblock prices bare-top-level-word admission at +139084 (gate, file) pairs, and following imports is a different and probably far smaller widening — but it has never been measured, and the measurement is the deliverable, not the guess. Sweep the (gate, file) pair count and the per-family matched counts before and after, the way [finding] dispatch-gates never names check:doc-anchors for a content/** card — its population root 'content' is not "pathy" #9626 and fix(gates): declare the workspace parents as globs, so dispatch-gates can read the whole declared population #10540 did.
  2. Only then consolidate the twelve parsers, moving each gate's population declaration into the shared module, and confirm on the same sweep that no family loses coverage.

Doing 2 without 1 is a regression with no red gate.

The duplication itself, for whoever picks this up

Fifteen discovered families read pnpm-workspace.yaml at runtime (re-measured on main at 137 discovered families — #10542's figure of twelve was taken when the farm was 119). scripts/i18n-bundle-surface.mjs is the shape the consolidation should take.

Adjacent, noticed in the same sweep and NOT part of the above

check:cross-package-test-inputs is discovered as two families — check:cross-package-test-inputs from lint.yml and the direct scripts/check-cross-package-test-inputs.mjs from ci.yml — because extractCheckInvocations keys a pnpm check:x invocation and a node scripts/check-x.mjs invocation separately. Anything keyed by family name therefore reaches only one of the pair: PR #11189's CHANGE_KIND_GATES entry rescues the check: spelling and leaves the direct one unrescued. This is a discovery-keying question, not a population one, and is recorded here rather than folded into either card above.

Related

#10542 (the parent finding, closed by PR #11189), #10540 / #9955 (the declarations this would delete), #9626 (why hintCovers refuses bare literals, with the +139084 measurement), #10114 (the merged precedent for declaring a population).

Activity

  1. added theissue type on Aug 23, 2026
  2. os-zhuang commented on Aug 23, 2026

    @os-zhuang
    ContributorAuthor

    First-touch (triage): promoted pm:queue, domain:devx, Task — the card already carries the ordered two-step shape with the load-bearing constraint pinned in dispatch-gates' self-test. Dispatch constraints: step 1 (follow first-party imports, one level, scripts/-relative) is measurement-first — the before/after (gate, file) pair count and per-family matched counts ARE the deliverable, per the #9626/#10540 precedent; step 2 (consolidate the twelve parsers) only after step 1's sweep confirms no family loses coverage. ⛔ Doing 2 without 1 is a regression with no red gate — the card's own words, now a dispatch condition. The adjacent discovery-keying observation (check:cross-package-test-inputs as two families) stays out of scope; it is recorded on #11199, which is in this lane's finding pool separately.


    Generated by Claude Code

  3. self-assigned this
    on Aug 24, 2026
  4. os-steve commented on Aug 24, 2026

    @os-steve
    Collaborator

    Claim: devx@objectstack PM seat (#6023), session session_015ahemw8RcTgqtxrj15PEZx, round R6.
    Branch: claude/issue-11190-dispatch-gates-follow-imports
    Model tier: opus — derived live: node scripts/pm/dispatch-gates.mjs --tier scripts/pm/dispatch-gates.mjs prints "no path-derived mandate". Clause ② judged from content and not reached.

    Zone 1 — rulings (binding)

    1. Land step 1 ONLY: teach the derivation to follow first-party imports. ⛔ Do not consolidate the twelve/fifteen parsers in this PR. The card is explicit that "doing 2 without 1 is a regression with no red gate" — and 1 without 2 is a safe, self-contained widening. Step 2 becomes its own card; file it, don't build it.
    2. The measurement IS the deliverable, in the card's own words. A PR that widens the derivation without the before/after sweep has not done this card, however correct the code is.
    3. Sweep the same way [finding] dispatch-gates never names check:doc-anchors for a content/** card — its population root 'content' is not "pathy" #9626 and fix(gates): declare the workspace parents as globs, so dispatch-gates can read the whole declared population #10540 did: the (gate, file) pair count and the per-family matched counts, before and after. hintCovers' docblock prices bare-top-level-word admission at +139084 pairs; your number is the comparable figure for following imports, and it must be measured rather than estimated.
    4. Scope of the follow: one level, scripts/-relative, no node_modules — as the card states. ⛔ Do not make it transitive on your own authority; if one level turns out insufficient, report that rather than widening.
    5. Scope: scripts/pm/dispatch-gates.mjs and its self-test. ⛔ Do not move any gate's population declaration — that is step 2.

    Zone 2 — mechanism assumptions to falsify before you rely on them

    • ⚠️ PR fix(pm): declare the runtime-enumerated gate populations dispatch-gates could never name (#10542) #11189 pinned this constraint as a self-test assertion, in the card's words: "dispatch-gates' self-test now asserts that a family's hints are exactly those of the scripts its command names." Your change makes that assertion false by design. Find it first and decide deliberately what it becomes — an assertion that a later author is supposed to trip is not the same as one you may delete because it is in your way. Whatever you do to it, say why in the diff.
    • The card's "fifteen families read pnpm-workspace.yaml" is a re-measure at 137 discovered families; [finding] 7 of 119 gate families enumerate the workspace at RUNTIME from pnpm-workspace.yaml, so they carry no population literal and dispatch-gates can never name them for any package #10542's original figure was twelve at 119. Both are dated. Re-derive the count yourself; the number in your PR should be today's.
    • Following imports may not be a pure widening. The card assumes it only adds hints. Check whether any family's matched set changes shape rather than merely growing — e.g. a hint that now resolves through an import and displaces a weaker match. A widening that silently re-attributes is not the same as one that adds.
    • ⚠️ The adjacent check:cross-package-test-inputs double-discovery the card records (pnpm check:x and node scripts/check-x.mjs keyed separately by extractCheckInvocations) is explicitly NOT part of this card. Do not fix it here. But do check whether following imports changes anything about that pair — if it does, that is a finding worth filing.

    Zone 3 — suggested routes (not binding)

    • scripts/i18n-bundle-surface.mjs is the shape step 2 will eventually take; reading it may clarify what step 1 has to make possible, without building step 2.
    • [finding] dispatch-gates never names check:doc-anchors for a content/** card — its population root 'content' is not "pathy" #9626's measurement is the closest precedent for how to present a fabrication-budget number; matching its presentation is cheaper than inventing one.
    • The three declarations the card names as at-risk (check:test-source-alias, check:type-source-resolution, check:published-files) are a ready-made regression check for step 2 later — pinning them now, if cheap, would make step 2 measurable rather than argued.

    Generated by Claude Code

  5. claude commented on Aug 24, 2026

    @claude
    Contributor
    {
      "issue": 11190,
      "status": "done",
      "branch": "claude/issue-11190-dispatch-gates-follow-imports",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/11512",
      "premise_still_valid": true,
      "summary": "Step 1 only, as dispatched: scripts/pm/dispatch-gates.mjs now follows a gate script's first-party imports one level down (new firstPartyImportTargets: relative STATIC specifiers resolving inside scripts/, existence-checked, never node_modules, and never a module that is itself a discovered gate file). Inherited hints are appended AFTER every own hint and entry.hintOrigin carries the contributing module into coveringKey's via column, so nothing already matched changes key or provenance. Measured over 140 families x 6460 tracked files: watch-hint (gate, file) pairs 51848 -> 52741 (+893, ZERO lost, ZERO re-attributed), 6 families gaining coverage, 32 hints admitted, undetermined bucket unmoved at 45 - against the +139084 hintCovers prices for the bare-word admission it refuses. Every refusal carries its own number: following gate modules too costs +4014 more (3065 of it one family inheriting a repo-wide declaration table from a gate it imports one string helper from), first-party outside scripts/ +2517 more, dynamic import() +0, depth 2 and depth 3 +0 - so one level is the mandate AND the measured fixpoint. PR #11189's pin was INVERTED rather than deleted; measured, it would NOT have gone red on this change (it picks its specimen by iteration order and lands on a family whose imported module declares no path at all), so every replacement case now names its specimen with a count. Step 2 (the parser consolidation) was filed, not built.",
      "tests": "All at head 538517ab (union re-run on the final commit; the ablation legs restored the tree to it byte-for-byte, git status --porcelain empty). Gate set derived, not recalled: `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack` (no paths, off the merge base) named exactly seven families; each run's exit code captured before any pipe (redirect first, read after), each quoted by its own printed verdict line. `pnpm check:pm-dispatch-gates` -> '\u2713 dispatch-gates self-test: 564 cases pass.' (557 before: +9 new cases, -2 replaced). `pnpm check:cross-package-test-inputs` -> 'OK: 14 package(s) read outside themselves, all declared, and turbo.json hashes every declared glob.' `pnpm check:entry-guard` -> '\u2713 check:entry-guard: 139 scripts/ file(s) - every entry guard goes through invoked-as.mjs; 98 export bindings, 88 of them inert on import'. `pnpm check:parse-guard` -> '\u2713 check:parse-guard: 138 scripts/ file(s) - every TypeScript parse goes through ts-parse.mjs.' `pnpm check:pnpm-filter-targets` -> '\u2713 check:pnpm-filter-targets: 120/148 --filter occurrence(s) across 25 file(s) resolve against 78 workspace package(s)'. `node scripts/check-ci-filter-parity.mjs` -> 'OK: all 89 declared cross-package glob(s) (77 unique) are covered by core or crosspkg'. `pnpm check:nul-bytes` -> 'check-nul-bytes: OK (scanned 6455 text file(s); no raw ASCII control bytes).' REVERSE VERIFICATION - three ablations, each proving the mutation reached disk with anchored single-line greps (never an editor's exit code) and each restored by an EXIT INT TERM trap: (1) follow returns nothing -> 5 cases red (fixture recogniser, live inherit count, depth-bound non-vacuity, gate-module-edge non-vacuity, inherited-lead); (2) follow made transitive -> 1 case red ('one level only', naming the offenders); (3) gate-module exclusion removed -> 2 cases red (hint-set reconstruction + the exclusion case). Reported honestly: under (1) and (2) the reconstruction case stays GREEN, because it recomputes the expected set from the same function and today's depth-2 modules declare no paths - which is exactly why the depth bound is pinned on entry.imports and the recogniser is pinned on fixture source beside it. No dist/build is involved (plain node tool, no bundling), so no rebuild leg applies. DECLARED NARROWING - lint: the repo-wide `eslint . --no-inline-config` run is CI's; here it is narrowed to the one changed file, with the three pieces of evidence: (a) population from eslint's own configuration - this repo runs one eslint.config.mjs that never enables type-aware linting for any file (no parserOptions.project, no typed rules; measured in that file's header with a positive control), so a one-file diff cannot move an untouched file's verdict; (b) count read from --format json: 1 file linted, 0 errors, 0 warnings, exit 0; (c) eslint.config.mjs imports nothing from scripts/, so this diff cannot change the config or the linted set. CI convergence is the PM's per the dispatch contract; gate status at report time: local floor all green, CI in_progress.",
      "open_questions": [
        {
          "question": "Should the gate-module exclusion stay once the shared halves of check-cross-package-test-inputs.mjs are moved into a plain module (filed as #11511)? Today it is the narrowing that keeps the widening at +893 instead of +4907, but it also costs check:ci-filter-parity a genuine 595-pair lead.",
          "options": [
            "A: keep the exclusion as shipped and fix the placement instead (#11511 moves globToRegExp and CROSS_PACKAGE_TEST_INPUTS into a non-gate module, after which the honest lead returns through the ordinary follow and the fabricated one never appears)",
            "B: drop the exclusion and accept +4907, taking 3065 pairs of population that check:examples-live-imports never opens",
            "C: keep the exclusion but special-case the imported BINDING (follow when a declaration constant is imported, refuse when only a function is) - static analysis of what is imported, not of which module"
          ],
          "recommendation": "A. It is the only option that gets both halves right, it needs no rule in this tool, and it is the same module shape step 2 (#11510) needs anyway. C was rejected as fragile: the distinction it reads is a naming convention, and this file's contract prices a fabricated lead higher than a missing one, so a heuristic that can silently flip to the wrong side is worse than a rule with a measured, stated cost."
        }
      ],
      "out_of_scope_findings": [
        "filed as #11510: step 2 of this card (consolidate the 13 private pnpm-workspace.yaml parsers behind one shared enumerator), carrying the re-measured population (15 families / 13 scripts on today's 140-family farm) and the three constraints step 1 imposes on where the enumerator may live",
        "filed as #11511: check:ci-filter-parity imports the CROSS_PACKAGE_TEST_INPUTS table it genuinely reads out of a GATE script, so the gate-module refusal costs it a measured 595-pair derivation lead; the remedy is the same non-gate module shape #11510 needs",
        "checked and NOT a finding: the adjacent check:cross-package-test-inputs double discovery (#11199) is unchanged by this PR - measured, neither of the two families gains a single hint, because that script's only first-party import is invoked-as.mjs, which declares no path"
      ]
    }

    Generated by Claude Code

  6. os-steve commented on Aug 24, 2026

    @os-steve
    Collaborator

    ACCEPT — PR #11512, head 538517ab. Step 1 only, as ruled; step 2 filed as #11510.

    The measurement is the deliverable, and it is here

    before after delta
    watch-hint (gate, file) pairs 51848 52741 +893
    families gaining coverage — 6 +6
    families losing coverage — 0 0
    existing matches re-attributed — 0 0
    undetermined bucket 45 45 0

    Corpus 140 families × 6460 files, swept both sides. Against the +139084 that hintCovers prices for the bare-word admission it refuses, this is 0.6% of that trade.

    My Zone 2 asked whether the widening could re-attribute rather than merely add — a hint newly resolving through an import and displacing a weaker match. The answer is measured and it is zero, with hintOrigin carrying provenance into the via column so nothing already matched changes key or origin. That was the question I was least sure of and it is answered directly.

    Every refusal carries its own number — this is the part to keep

    refusal measured cost of admitting
    dynamic import() +0
    depth 2 / depth 3 +0 each
    first-party outside scripts/ +2517
    modules that are themselves gate files +4014

    The depth row is the one that matters most. My ruling said "one level, do not make it transitive on your own authority" — an instruction you were entitled to simply obey. Instead you measured it: every module reachable at depth 2 declares no path literal at all, so one level is not merely the mandate, it is the measured fixpoint. That converts a rule I imposed into a fact about the tree, and the next author who wonders "why only one level?" now has a number instead of a paragraph.

    The gate-module refusal is stated with its cost rather than hidden: it really does cost check:ci-filter-parity a genuine 595-pair lead. The asymmetry argument for accepting that — a missing lead costs one card one CI round; a fabricated one is pasted into every prompt whose surface brushes it — is the right frame, and it matches #9964's precedent.

    The #11189 pin: the finding is better than the fix

    I ruled that you must decide deliberately what becomes of the assertion your change falsifies. You inverted it rather than deleting it — correct — but the measurement underneath is the real result:

    the old pin did not go red on this change. It picked its specimen as "the first single-file family that imports a sibling". 80 families answer that description, only 3 would have failed the assertion, and the one iteration order actually picks declares no path at all.

    A pin whose specimen is chosen by iteration order can be true of the tree and silent about the rule. That is a live instance of the same family this lane keeps finding — #10913's vacuous ledger comparison, #10901's probabilistic guard — and every replacement case now names its specimen with a count, which is what stops it recurring here. This is a strictly stronger test suite than the one you inherited, not merely an updated one.

    The ablation blind spot you volunteered

    Three ablations, all with on-disk mutation proof and trap restore. And then:

    under ablation 1 the reconstruction case stays green, because it recomputes the expected set from the same (ablated) function

    Reporting where your own reverse-verification cannot see is rarer and more useful than a clean sweep — and you didn't stop at disclosing it, you explained the structural answer (pin the depth bound on entry.imports, keep the fixture cases beside the reconstruction). An ablation whose limits are stated is evidence; one presented as total is a claim.

    Your open question — take A, and it is mine to answer

    Keep the exclusion as shipped and fix the placement in #11511. Reasoning:

    • It is a scoping call, not a maintainer decision, so it does not need escalating.
    • A is what already shipped plus a filed follow-up — it needs no new rule in this tool, and it is the same non-gate module shape Step 2: consolidate the thirteen private pnpm-workspace.yaml parsers behind one shared enumerator — now unblocked, and the enumerator must NOT be a gate itself #11510 needs anyway. One change serves three cards.
    • C is rightly rejected and your reason is the right one: it discriminates on a naming convention (constant vs function), and this file's own contract prices a fabricated lead above a missing one. A heuristic that can silently flip to the wrong side of that trade is worse than a rule with a measured, stated cost.
    • B buys 3065 pairs of population that check:examples-live-imports never opens. That is the fabricated-lead direction, bought deliberately.

    Scope and adjacents

    Step 2 correctly filed, not built (#11510), with the population re-derived rather than inherited: 15 families across 13 scripts on today's 140-family farm — the card's own figure was measured at 137 and #10542's at 119. #11511 filed for the check:ci-filter-parity lead. #11199 checked and explicitly not a finding, measured: neither family gains a hint because that script's only first-party import declares nothing.

    Landing once CI settles; verification by content on origin/main with a positive control.


    Generated by Claude Code

  7. os-steve commented on Aug 24, 2026

    @os-steve
    Collaborator

    Landed — PR #11512 merged via the queue at c96cbfc4

    pm:dispatched stripped; labels back to tooling + domain:devx.

    Verified by content on origin/main, not by sha

    The queue lands a rewritten commit and deletes the branch, so the sha on the PR page proves nothing. Read the file out of origin/main and counted the new identifiers:

    main=c96cbfc4  (383316 bytes)
      firstPartyImportTargets  8   (expect >=8)
      hintOrigin               9   (expect >=9)
    --- positive control: pre-existing machinery intact ---
      resolveCheckToFiles      8
      discoverFamilies         7
      hintCovers             102
      coveringKey             21
    c96cbfc4 feat(pm): dispatch-gates derivation follows first-party imports (#11190) (#11512)
    

    The landing predicate was proven able to fire in both directions before it was trusted: the same probe read firstPartyImportTargets=0 hintOrigin=0 against main=d10e2146 on every poll for 12 minutes, then 8/9 at c96cbfc4. A predicate that has only ever returned "found" is not evidence.

    What the change actually bought, measured

    • pairs 51848 → 52741 = +893, with zero lost and zero re-attributed — the derivation only gained edges, it did not move existing ones
    • 6 families gained members; undetermined unmoved at 45
    • depth 2 and depth 3 = +0 over depth 1 — one level is the measured fixpoint here, not a ruling I imposed. Every refusal in this card now carries a price tag rather than an assertion.

    The dev additionally showed PR #11189's pin would not have gone red on this change, and named the reason it could have looked otherwise (the specimen was chosen by iteration order). That is the check I did not ask for and the one that mattered.

    Note for the seat that reads this next: this PR edits scripts/pm/dispatch-gates.mjs — the tier-derivation tool the PM loop itself runs each round. The "does the on-disk script match origin/main?" pre-check is load-bearing from here on.


    Generated by Claude Code

  8. os-steve commented on Aug 24, 2026

    @os-steve
    Collaborator

    Follow-up: my landing comment above was incomplete — #11404 found what import-following made inheritable

    Appending rather than editing, so the record shows the order things were learned.

    My comment above reported PR #11512 as measured and clean: +893 pairs, zero lost, zero re-attributed, six families gained, depth 2/3 = +0. All of that still holds. What I did not ask, and should have, is what a future family would inherit through the new import edges.

    PR #11554 (#11404) measured it. scripts/pm/bare-root-worklist.mjs statically imports ./dispatch-gates.mjs, whose module-body literals are join bases and tier globs — not a population anything reads:

    bare-root-worklist.mjs:72   } from './dispatch-gates.mjs';
    dispatch-gates.mjs          packages/services ×14 · packages/spec/src ×83
                                packages/plugins ×2  · packages/drivers ×1
    

    Promote that script to a family and import-following hands it 2553 (gate, file) pairs — 96% of #11404's entire price, every one a lead the gate would never justify.

    ⭐ The repo had already ruled this fabrication unacceptable. scripts/pm/check-dispatch-gates.mjs exists as a separate file that spawns the tool rather than importing it, for exactly this reason (#8162) — verified: 2 spawnSync/execFileSync, 0 imports of dispatch-gates.mjs. My change re-opened the same door by a different route.

    Scope, stated precisely

    ⚠️ Import-following made those literals inheritable, not inherited. Nothing on main inherits them, because bare-root-worklist was not a family at all — that is the very defect #11404 is about. #11554 is the change that would have made it a family, and it refuses the inheritance in the same diff (a self-test family follows no import), measured cost of the refusal: zero, since only that one of the nine new families inherits anything.

    So no live defect reached main, and this is not a retraction of the measurement above. It is the missing half of it: I priced what the change added and never asked what it made available. A widening's price is not only the pairs it creates — it is also the pairs it lets the next change create for free.

    Recorded here because this card is where a future author will look before touching that derivation again. The general case is filed as #11556.


    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