Skip to content

fix(scripts): read nested exports conditions in check-lean-entry-closure, and refuse unreadable ones by name - #17100

Merged
baozhoutao merged 1 commit into
mainfrom
claude/issue-16980-nested-exports-condition
Sep 9, 2026
Merged

baozhoutao merged 1 commit into
mainfrom
claude/issue-16980-nested-exports-condition

Conversation

@claude

@claude claude Bot commented Sep 9, 2026 •

Copy link
Copy Markdown
Contributor

Part of #16980 — ⛔ deliberately NOT Fixes. This lands options 1, 2 and 3. The half left open is the one the ruling reserved: adding the @objectstack/metadata/errors row to GUARDED_ENTRIES, which needs a second ADMITTED_PACKAGES set and therefore a MAINTAINER decision. The PM decides whether the card closes on this PR.

scripts/check-lean-entry-closure.mjs's resolveConditions() handed exports[subpath][flavour] straight to node:path's resolve() with no string check. Options 1 + 2 + 3 from the card, per the dispatch ruling. ⛔ No GUARDED_ENTRIES row is added and ADMITTED_PACKAGES is untouched.

Clause-②: no
scripts/** only — one file. Re-derived rather than copied: 70 published (non-private) manifests, zero ship scripts/check-lean-entry-closure.mjs in their files[]; positive control, 70 of 70 ship a dist/ path. So the change reaches 0 published surfaces.

Before leg — the card's own recipe, reproduced verbatim

Measured on a freshly created worktree at origin/main c43bac7048, UNBUILT (packages/objectql/dist and packages/metadata/dist both absent). The gate blob is byte-identical to the 854639b311 the brief pinned (e9370b2db2), so the brief's line numbers still hold.

row    { id: '@objectstack/metadata/errors', pkgDir: 'packages/metadata', subpath: './errors' }
  =>   THREW TypeError: The "paths[2]" argument must be of type string. Received an instance of Object

row    { id: '@objectstack/objectql/core', pkgDir: 'packages/objectql', subpath: './core' }
  =>   returned normally: {"conditions":[],"missing":[
         "@objectstack/objectql/core (import) -> packages/objectql/dist/core.mjs",
         "@objectstack/objectql/core (require) -> packages/objectql/dist/core.js"]}

⚠️ The control's missing is the deliberate exit-3 PREREQUISITE path on an unbuilt tree, not the defect — as the card and the brief both warn.

After leg — same recipe, same rows, same tree

row    { id: '@objectstack/metadata/errors', ... }
  =>   returned normally: {"conditions":[],"missing":[
         "@objectstack/metadata/errors (import) -> packages/metadata/dist/errors.js",
         "@objectstack/metadata/errors (require) -> packages/metadata/dist/errors.cjs"]}

The metadata row now reaches the gate's own diagnostics with its targets named, instead of never reaching a diagnostic at all.

The flat control is byte-identical, and that is measured mechanically rather than eyeballed: git show origin/main:scripts/check-lean-entry-closure.mjs was written beside the patched file (same directory, so ROOT resolves identically), both were imported in one process, and their control-row results were string-compared.

BEFORE(origin/main gate): RETURNED {"conditions":[],"missing":["@objectstack/objectql/core (import) -> packages/objectql/dist/core.mjs","@objectstack/objectql/core (require) -> packages/objectql/dist/core.js"]}
AFTER (patched gate)    : RETURNED {"conditions":[],"missing":["@objectstack/objectql/core (import) -> packages/objectql/dist/core.mjs","@objectstack/objectql/core (require) -> packages/objectql/dist/core.js"]}
BYTE-IDENTICAL: YES

The temporary baseline copy was removed; git status --porcelain showed only the one intended modification afterwards.

What a future author now gets for an unreadable shape (option 2)

End-to-end through resolveConditions() against a real fixture manifest whose import condition is an object with no default:

THREW Error:
…/thirdshape/package.json declares exports["./errors"]["import"] as an object with keys
[types, browser] and no string "default"; this gate reads a condition only as a path string,
or as a nested condition object whose "default" is a path string.
          entry:   @thirdshape/pkg/errors
          subpath: ./errors
          flavour: import
          ⇒ Either that manifest is wrong, or it uses an exports spelling conditionTarget() in
            this file has not been taught. This gate cannot tell which from here, so it names the
            shape and stops. ⛔ What it may not do is resolve something anyway: a closure measured
            from the wrong artifact would be reported as if it were the right one.

Error, not TypeError — so the refusal is distinguishable from the node:path crash it replaces. On acceptance criterion 5 (the message must be honest): it names both live causes and asserts neither, because from inside conditionTarget() the gate genuinely cannot tell a malformed manifest from an exports spelling it has not been taught. It does not tell the reader to build, and it does not claim the manifest is wrong.

⚠️ Deliberate behaviour narrowing, stated because it is not free. The old if (!rel) continue skipped every falsy value. Now only undefined/null mean "this condition is not published"; "" and false are refusals. No manifest in this workspace has either (measured below), so nothing in the tree changes — but it is a change, not a refactor.

Negative control — the gate on a BUILT tree, figures RE-DERIVED

Built with pnpm turbo run build --filter=@objectstack/objectql --concurrency=2 under scripts/pm/os-verify-lock.sh (15 of 15 tasks successful; VERDICT command-exit 0).

✓ check-lean-entry-closure self-test: 22 cases pass (attribution, all three judgements, the
  disjointness wall, two real child loads, both condition spellings plus two refusals, the prerequisite).
✓ check-lean-entry-closure: 2 published condition(s) measured from a real load.
  @objectstack/objectql/core (import): 15 packages, 185 modules
  @objectstack/objectql/core (require): 15 packages, 183 modules
  Admitted set held exactly (15 packages); 6 denied names absent.

⭐ Re-derived on c43bac7048 + this branch, not copied from the card. They land on the card's figures — 2 conditions, 15 packages, 185 / 183 modules, admitted set held exactly — which is the result, not the input.

And the strongest form of "unchanged": the origin/main gate and the patched gate were both run against this same built tree and their stdout diffed byte for byte — identical.

Self-test: one case per branch, and the floor ratcheted

New battery reading a condition, 6 cases: flat condition, nested condition, absent condition, an object with no default refused, a third shape (array) refused, and one end-to-end resolveConditions() over a nested fixture manifest. Roster 5 → 6 batteries; SELF_TEST_BATTERY_FLOOR 5 → 6; cases 16 → 22.

Every mutation below was proved on disk (grep -c on the exact anchor text before and after, plus the file's git hash-object against its HEAD blob) and restored with git checkout HEAD -- <path>, verified by an empty git diff HEAD and by the restored blob hashing back to 8672f581160c61f80134ea605072ade106d2b850. The fix was committed first, so the restore leg pointed at a HEAD that carried it.

Firing control — SELF_TEST_BATTERY_FLOOR 6 → 7, i.e. above the 6 declared batteries:

✗ SELF_TEST_BATTERIES declares 6 batteries, below the pinned 7 — a battery deleted from the
  roster takes its own floor with it.
✗ check-lean-entry-closure self-test: 1 of 23 case(s) failed.

Nonsense control — the roster name changed to one no case registers under ('reading a condition' → 'reading a conditionZZZ'), the exact silencing the roster exists to catch:

✗ self-test battery "reading a condition" registered 6 case(s) but is not declared in
  SELF_TEST_BATTERIES — an assertion attributed to no declared battery is one nothing floors.
✗ self-test battery "reading a conditionZZZ" DID NOT RUN — 0 cases registered, 6 pinned.
  The verdict below would have claimed those cases hold.

Ablation of the fix itself — conditionTarget() reverted to the pre-fix return value;. The new battery does not merely go red, it reproduces the reported defect exactly, at the same argument index:

TypeError [ERR_INVALID_ARG_TYPE]: The "paths[2]" argument must be of type string.
  Received an instance of Object
    at resolve (node:path:1272:7)
    at resolveConditions (…/scripts/check-lean-entry-closure.mjs:541:20)

Header (option 3) — the sentence that stays false after 1 and 2

The table shape is what makes that a one-row change for whoever owns it. is replaced with what a second entry actually costs: its own ADMITTED_PACKAGES set, and a package entering an admitted set moves the ADR-0076 D2 boundary and carries RATCHET_AUTHORITY_MARKER = '⛔ MAINTAINER-ONLY'. ⇒ Whoever owns a second entry owns a maintainer decision, not just a row.

Does option 1 actually cover this repo? (the triage seat left this unmeasured)

The triage comment asked, explicitly, not to assume .default exists on all nested conditions. Measured across all 71 workspace manifests with an exports map:

condition value count lacking a string default
flat (string) 109 n/a
nested (object) 102 0
any third shape 0 n/a

⇒ Option 1 covers 211 of 211 live conditions. And because there are zero third shapes in the tree today, option 2 is prophylactic by construction — which is why its self-test cases have to supply the third shape synthetically. That is the honest reading of what each half buys.

Gates run — TEXT, not just exit codes

node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 31 families from the merge base c43bac704 (its own change-set derivation, not a hand-written list). All 31 were run and reconciled:

Run reconciliation — 31 derived, 31 run, 0 NOT-MEASURED, 0 UNRUN.
✓ dispatch-gates --ran: 31 derived famil(ies) accounted for — 31 run, 0 NOT-MEASURED.

Zero non-zero exits across the batch. Named among them: check:lean-entry-closure, check:ratchet-remedy-authority (it reads this very file), check:nul-bytes, check:self-test-wired, check:scripts-symbol-anchors, check:pm-dispatch-gates.

Beyond the derived set:

  • eslint . --no-inline-config was run in full, not narrowed — 6410 files, 0 errors, 0 warnings, on the final commit 9998c82a5e. No narrowing argument is needed. (For the record, the config states at eslint.config.mjs:328 that there is no parserOptions.project and no typed rules.)
  • packages/objectql/src/core-boundary.ratchet.test.ts — the neighbouring source-scan guard: 1 file / 2 tests passed. It names this gate only in a comment; there is no executable coupling.
  • Control-character sweep beyond check:nul-bytes: grep -naP '[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]' on the changed file — no hits.

⚠️ What I swept and what I did NOT — this list is not complete

Swept: the 31 derived families, the full repo eslint, the neighbouring ratchet test, the published-manifest files[] reach, the workspace-wide condition-shape census, control characters.

Not swept, and I am naming them rather than implying clearance:

  • The 48 artifact-roster families, 11 wide-population families and 13 pending-changeset families that dispatch-gates itself prints as outside the derived total. Their silence is a fact about a list, not about these paths.
  • Two families that take a value from the workflow (check-shard-attestation, check-test-completeness) — NOT MEASURED locally by construction.
  • CI convergence. I did not wait for it.

⚠️ Two judgement calls a reviewer should check rather than take

  1. The refusal throws; it does not become a findings[] entry. So through main() it still surfaces as an uncaught Error with a stack — the message is on top and carries everything, but it is not routed through the gate's own ✗ N finding(s) report. I kept the existing mechanism deliberately: the shape guard one level up (exports[subpath] is not an object) already throws the same way, and the triage seat's advice was to move that existing pattern down a level rather than design a new diagnostic style. Deciding that "this gate cannot measure this row" deserves its own report channel — a fourth judgement alongside pass, finding and PREREQUISITE — is a wider call that belongs with whoever adds a second entry. ⛔ It is not the PREREQUISITE path: routing it there would print "run the build first", which is a cause that did not occur.
  2. Line number correction for the record. The brief cites the header sentence at :116; on origin/main c43bac7048 it spans :120-121. RATCHET_AUTHORITY_MARKER at :156 is exact.

⛔ I did not add the @objectstack/metadata/errors row and did not widen ADMITTED_PACKAGES. Nothing I measured changed my view that adding it is a separate card and a maintainer decision — and the census above sharpens the price rather than lowering it: the row would resolve cleanly now, which is precisely what makes the second admitted set the whole remaining cost.

Changeset: scripts/ reaches 0 published manifests (measured above). If Check Changeset reds, I have not labelled — that is the PM's skip-changeset to apply.

🤖 Generated with Claude Code

https://claude.ai/code/session_012GKcPZbMoGq7WPzKLfRBTU


Generated by Claude Code

…nes by name

`resolveConditions()` handed `exports[subpath][flavour]` straight to
`node:path`'s `resolve()` with no string check. The one guarded row
(`@objectstack/objectql/core`) spells its conditions flat, so the gate's
live verdict was correct and nothing was mis-measured. The cost fell
entirely on the next author: `packages/metadata`'s `./errors` spells them
NESTED (`import`/`require` -> objects of `types`/`default`), and that row
died with a raw `TypeError: The "paths[2]" argument must be of type
string` — no gate name, no entry id, no repair.

- `conditionTarget()` reads both spellings this workspace uses: a path
  string, or a nested condition object's `default`.
- Any other shape is REFUSED with this gate's own diagnostic naming the
  entry id, subpath, flavour and the shape found — never a crash from a
  node builtin. The gate does not guess a target, because a closure
  measured from the wrong artifact would be reported as if it were the
  right one. The shape guard one level up already had the right form; it
  was checking the wrong level.
- Absent now means `undefined`/`null` only. The previous `if (!rel)`
  silently skipped `""` and `false`, which are not a manifest declining
  to publish a condition.
- The header no longer sizes a second `GUARDED_ENTRIES` row as "a one-row
  change": a second entry needs its own `ADMITTED_PACKAGES` set, and
  admitting a package there moves the ADR-0076 D2 boundary and carries
  `RATCHET_AUTHORITY_MARKER`. That sentence was false before this change
  and stays false after it, so it says the real price instead.
- Self-test gains a `reading a condition` battery: one case per branch
  (flat, nested, absent) plus two refusals and one end-to-end nested
  manifest. Battery floor ratcheted 5 -> 6; cases 16 -> 22.

No `GUARDED_ENTRIES` row is added and `ADMITTED_PACKAGES` is untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012GKcPZbMoGq7WPzKLfRBTU
@github-actions github-actions Bot added the size/m label Sep 9, 2026
@baozhoutao baozhoutao added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Sep 9, 2026 — with Claude
@baozhoutao
baozhoutao marked this pull request as ready for review September 9, 2026 09:04
@baozhoutao
baozhoutao enabled auto-merge September 9, 2026 09:04
@baozhoutao
baozhoutao added this pull request to the merge queue Sep 9, 2026
Merged via the queue into main with commit 91f65c4 Sep 9, 2026
44 of 48 checks passed
@baozhoutao
baozhoutao deleted the claude/issue-16980-nested-exports-condition branch September 9, 2026 09:32
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…ld to turbo.json by check-ci-filter-parity (objectstack-ai#21338)

Fixes objectstack-ai#21202
Clause-②: no

## What changes

1. **`.github/workflows/ci.yml`, the `core:` filter** gains 18 literal
entries: `turbo.json`, the root `tsup.config.ts`, and the 16
`scripts/*.mjs` files that a build task Build Core runs declares as a
`$TURBO_ROOT$/...` input. A PR that changes only one of them now starts
Build Core at PR time. Before this change the merge queue was the first
place that build ran (PR objectstack-ai#21199, a `turbo.json`-only diff, skipped Build
Core).
2. **`scripts/check-ci-filter-parity.mjs`** gets a third subject,
`judgeBuildInputs`. It derives the required set and does not keep a
copy:
- from `turbo.json`: the file itself, its `globalDependencies`, and
every `$TURBO_ROOT$/...` input of a `build` or `PKG#build` task;
- minus the builds that Build Core's `run: pnpm build` skips. Those are
read from the root `build` script's `--filter=!PKG` tokens (today
`@objectstack/docs#build` and its `content/**` input). If the script is
misread, the gate can only drop an exclusion, which makes it require
more of `core`. So a misread is a loud red, never a silent gap.
   - It reds when a declared build input has no covering `core` entry.
- It also checks the reverse direction where the declarations alone can
decide it. A literal `core` entry sitting in a directory that holds
declared build inputs, but covering none of them, reds as a leftover.
- It refuses eight unread states: no `build-core` job; its `if:` no
longer names `core`; no `run: pnpm build` step; `turbo.json` or the root
manifest unreadable or not JSON; no `tasks` map; no build task; a
`build` script that is not `turbo run build ...`.
- It uses the same pure-string coverage rule as the existing `crosspkg`
subject, so no third matcher is added.
3. **Two forced follow-ons, both outside the claim's declared file
surface.** Each is the remedy text of a gate that this change turned
red. Neither is a design choice.
- **Build Core gains `node scripts/check-dts-emitted.mjs --self-test`.**
Once `core:` named that file, `check:self-test-wired` reported that a
workflow names a script whose `--self-test` no workflow runs. That is
true: 67 package build commands end with that checker, and its self-test
ran nowhere in CI. The gate's remedy is "wire it into the step that runs
it". The step takes milliseconds, needs no prerequisites, and runs
before `pnpm build`.
- **`scripts/pm/dispatch-gates.mjs`, one self-test specimen.** The case
"a name-shaped script no family runs is NOT a gate script" used
`scripts/check-dts-emitted.mjs`, which item 3a turned into a family
file. The specimen is now `scripts/check-test-typecheck.mts`, which only
package manifests run. That file is already named in the same docblock's
list of such files.

4. **Three comments this change made false, corrected in `e6aa08f322`
(patch round 2, comments only):** the GAP 1 block in
`scripts/sync-template-versions.mjs`, the same block in
`.github/workflows/lint.yml`, and the extglob docblock sentence in
`scripts/pm/dispatch-gates.mjs` ("one entry of six"). Each now reads
true with `core` matching the root build inputs. The re-measure for a
diff confined to `scripts/sync-template-versions.mjs` reads `core=true`,
`crosspkg=true`.

No change to `turbo.json` or `fleet-write.yml`. `lint.yml` changes only
its GAP 1 comment block (about lines 2660 to 2675), which is disjoint
from objectstack-ai#21295's pm roster block.

## Granularity: literal files, not `scripts/**`

I measured this over the 3288 first-parent commits of `main` in the 30
days to `1371dc980c`, with `scripts/pm/git-history.mjs` proving the
window was present (floor 2026-06-27). I used picomatch `{ dot: true }`,
the options `dorny/paths-filter` uses, under both 2.3.1 and 4.0.5. The
two versions agree on every commit.

| `core` filter | commits it matches | added by this change |
|:--|--:|--:|
| before | 2247 | — |
| + the 18 literal build inputs (this PR) | 2275 | **+28** |
| + `scripts/**`, `turbo.json`, `tsup.config.ts` | 2840 | +593 |

516 of the 593 that `scripts/**` would add touch a `scripts/pm/` tool or
a `scripts/check-*` gate. The gate holds the literal list to the
declarations, so the narrow choice costs nothing in drift.

Widening `core` starts every job that `core` gates, not only Build Core:
Test Core, Temporal Conformance and Dogfood also run on those 28. That
matches turbo's own view, because every one of those jobs builds, and a
moved build hash re-runs everything downstream of it.

## Acceptance: the filter evaluated per path

The `core` list is parsed out of `ci.yml` at the base and at this head,
in the action's order (YAML, then the `filters` string). Each path is
matched with picomatch `{ dot: true }`.

| path | before (2.3.1 / 4.0.5) | after (2.3.1 / 4.0.5) |
|:--|:--|:--|
| `turbo.json` | false / false | true / true |
| `tsup.config.ts` | false / false | true / true |
| each of the 16 `scripts/*.mjs` build inputs | false / false | true /
true |
| `tsconfig.json` (globalDependencies) | true / true | true / true |
| `packages/cli/src/commands/init.ts` (create-objectstack#build) | true
/ true | true / true |
| control: `scripts/check-ci-filter-parity.mjs` | false / false | false
/ false |
| control: `scripts/pm/dispatch-gates.mjs` | false / false | false /
false |
| control: `content/docs/index.mdx` | false / false | false / false |
| control: `packages/spec/src/ui/view.zod.ts` | true / true | true /
true |

The 16 scripts are each checked individually: `build-input-hash`,
`check-dev-prereqs`, `check-dts-emitted`, `check-dts-references`,
`check-regen-pending`, `cli-build-prerequisite`, `git-env`,
`import-prerequisite`, `invoked-as`, `js-comment-mask`,
`regen-artifacts`, `sync-scaffold-emission-policy`,
`sync-template-versions`, `ts-parse`, `tsup-drop-sources-content`,
`workspace-enumerator`.

**Live run: none yet.** This PR changes `ci.yml`, which `core` already
matched, so its own run proves nothing. The first demonstration will be
the first PR after this one lands whose diff is confined to listed
paths. At the measured rate that is about one a day (28 in 30 days). The
`turbo.json` card objectstack-ai#20790 is the known candidate if its PR touches only
`turbo.json`.

## The new leg can go red

- **Self-test battery (9), 33 cases; the gate goes from 68 to 101
assertions.**
- A build input dropped from a fixture `core:` reds, naming the path and
the task that declared it.
- `turbo.json` itself, a `globalDependencies` entry, and an input under
a new root are each required.
- Test-task inputs, negated inputs, and the excluded docs build are left
out. The docs build's input is required again once the build script
stops excluding it.
  - A leftover entry reds.
  - All eight refusals are covered (ten cases).
- The checked-in tree is green. With `scripts/invoked-as.mjs` dropped
from it, the gate reds through `main()`, the report path.
- **Battery (8)'s report-path scratch root** now carries the real
`turbo.json` and root manifest, so a red there cannot come from the new
subject refusing a missing file.
- **Before/after on the real tree.** The extended gate run over the base
`ci.yml` exits 1 and lists the 18 inputs. Over this head it exits 0.
- **Ablations**, done with `scripts/ablation-replace.mjs`. Each mutation
was confirmed on disk, and each restore was proven by blob equal to
`HEAD` and an empty `git diff HEAD`:
- Coverage forced true in `judgeBuildInputs`: production exit 0 (green),
`--self-test` exit 1 with 9 named failures.
- Leftover rule disabled: production exit 0, `--self-test` exit 1 with 1
named failure.
- My first attempt at the leftover ablation was a no-op. Its replacement
text contained the anchor, so the tool refused and wrote nothing. I
re-ran it with a non-overlapping replacement.

## Gates (local, head ec413ce)

- I re-derived the list with `node scripts/pm/dispatch-gates.mjs --repo
objectstack-ai/objectstack --commands` (no paths; merge base
`1371dc980`): 58 commands.
- `--ran` reconciliation: "58 derived famil(ies) accounted for — 54 run,
4 NOT-MEASURED (0 DERIVED from a recorded exit 3, 4 claimed)". Every one
of the 54 recorded exit 0.
- That includes `check-ci-filter-parity` and its `--self-test`,
`check:self-test-wired`, `bare-root-worklist --self-test`,
`check:required-contexts`, `check:workflow-step-name-quoting`,
`check:step-collectors`, `check:stall-guard-budget` and
`check:nul-bytes`.
- It also includes `check:pm-dispatch-gates`, whose self-test passed
1976 cases (778 s on this box). Its "fabrication direction" case passes
with the new specimen.
- The four `dist/`-reading Build Core gates (`check:dts-closure`,
`check:dual-build-cjs-loads`, `check:lean-entry-closure`,
`check:sourcemap-no-sources-content`): their `--self-test` legs exit 0.
Their real legs are **NOT MEASURED** locally, because they read a full
workspace `dist/` and this diff moves no build hash. The changed files
are a workflow and two root scripts, none of them a declared build
input. Build Core runs them on this PR.

## Changeset

None. The changed files are a workflow, a root script and a
`scripts/pm/` tool. None of them is in any published package's
`files[]`, and the root package is private. `skip-changeset` applies.

## Acceptance notes (not changed here)

- **Two comments this change made false, plus a third the grep found:**
all three are corrected in this PR (`e6aa08f322`, item 4 above), per the
seat's patch round. The carrier this note first named, objectstack-ai#21294's PR, had
already landed. (Seat edit.)
- **Build Core's own guard scripts do not schedule Build Core.**
`check-dts-closure`, `check-dual-build-cjs-loads`,
`check-sourcemap-no-sources-content` and `check-lean-entry-closure` run
only inside Build Core. A diff confined to one of them leaves `core`
false (picomatch 2.3.1 and 4.0.5, both before and after this PR).
Measured on PR objectstack-ai#17100 (`91f65c4ea4`), which changed only
`scripts/check-lean-entry-closure.mjs`: Build Core was `skipped`, so
neither the changed gate's self-test nor its real leg ran at PR time.
This is a different population from build inputs, because turbo does not
declare these files, so it is reported here and left unchanged.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01UtnxvdiN376GF3sgXwAw4d)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…pts, held to the job steps by check-ci-filter-parity (objectstack-ai#21368)

Fixes objectstack-ai#21341
Clause-②: no

## What changes

1. **`.github/workflows/ci.yml`, the `core:` filter**, gains five
literal rows after the build-input rows objectstack-ai#21202 added:
   - `scripts/check-dts-closure.mjs`
   - `scripts/check-dual-build-cjs-loads.mjs`
   - `scripts/check-lean-entry-closure.mjs`
   - `scripts/check-sourcemap-no-sources-content.mjs`
   - `scripts/population-floor.mjs` (the dual-build guard imports it)

A PR that changes only one of them now starts Build Core, so the changed
guard runs its `--self-test` and its real leg at PR time. Before this
change Build Core skipped on such a PR. PR objectstack-ai#17100 changed only
`scripts/check-lean-entry-closure.mjs`, and its Build Core concluded
`skipped`.

2. **`scripts/check-ci-filter-parity.mjs`** holds those rows. The Build
Core subject objectstack-ai#21202 added (`judgeBuildInputs`) now judges a second
population beside the build inputs from turbo.json: every repository
script that a `build-core` `run:` step runs, plus that script's
first-party imports.
- **Derived from the job, not kept by hand.** Each step goes through
`collectInvocations`. That is the recognizer `check:self-test-wired`
already uses to answer "which repository scripts does this CI command
run": a `scripts/...` path named directly, or one reached through a root
`package.json` alias chain. Each script it names brings its first-party
closure from `first-party-closure.mjs`.
- **Uncovered goes red.** If no `core` row covers a script that a step
runs, the gate fails and names the path and the step. So a guard step
added to Build Core without a filter row goes red.
- **Leftovers go red.** objectstack-ai#21202's leftover rule now covers this
population too. A literal `core` row next to the build inputs and the
guards that covers neither is reported. So a row left behind by a
deleted guard step goes red too.
- **No new gate, no new workflow step, no new list.** The existing
parity gate gains a population, and the rows in ci.yml are the one list.

## Why derived, and not a `BUILD_GUARDS` table like `CONSOLE_GUARDS`

`CONSOLE_GUARDS` exists because the console subject has to classify each
row. A `console:` row is either hashed into the dist key or a guard, and
nothing in the workflow says which. Build Core rows need no
classification: the scripts its steps run can be read from the steps,
just as objectstack-ai#21202 reads build inputs from turbo.json.

A `BUILD_GUARDS` table would be a second list of the same fact, kept in
step by hand. It would also need two holding rules of its own: table
against steps, and table against rows. The derivation needs only one
rule in each direction, and the self-test pins it against the real job.

## Measurements

Branch head `aa0b21ae2c`, base `96b12b589f`.

**The card's instance, through the matcher.** `core` is read at base and
at head. Each path is evaluated with picomatch using `{ dot: true }`,
which is how `dorny/paths-filter@v4` calls it. Version 2.3.1 is the one
the action bundles; 4.0.5 is the one this tree resolves.

| path | before, 2.3.1 | before, 4.0.5 | after, 2.3.1 | after, 4.0.5 |
|---|---|---|---|---|
| `scripts/check-dts-closure.mjs` | false | false | true | true |
| `scripts/check-dual-build-cjs-loads.mjs` | false | false | true | true
|
| `scripts/check-sourcemap-no-sources-content.mjs` | false | false |
true | true |
| `scripts/check-lean-entry-closure.mjs` | false | false | true | true |
| `scripts/population-floor.mjs` | false | false | true | true |
| `scripts/check-dts-emitted.mjs` (already a build input) | true | true
| true | true |
| control: `scripts/check-ci-filter-parity.mjs` | false | false | false
| false |
| control: `scripts/check-self-test-wired.mjs` | false | false | false |
false |
| control: `scripts/pm/dispatch-gates.mjs` | false | false | false |
false |
| control: `scripts/dual-build-cjs-loads.baseline.json` | false | false
| false | false |

The two versions agree on every row.

**Width.** The window is the 3287 first-parent commits of `main` in the
30 days to `96b12b589f`. `scripts/pm/git-history.mjs` proved it
complete; the history floor is 2026-06-27.
- `core` matched 2276 of those commits. With the five rows it matches
2278: +2, identical under 2.3.1 and 4.0.5.
- Seven commits touched a guard path. Only one touched nothing but guard
paths: `91f65c4ea4`, which is PR objectstack-ai#17100, the card's own instance.
- The other newly scheduled commit is `83649b870a`. It changed
`check-dual-build-cjs-loads.mjs` and `population-floor.mjs` along with
two lint-side gates. `population-floor.mjs` on its own adds 0 commits in
the window.

**The gate before the rows.** This is the new script run over the base
ci.yml: exit 1, naming exactly five scripts, each with the step that
runs it.

```text
- 5 script(s) the `build-core` job's own steps run are covered by no `core:` entry in .github/workflows/ci.yml. ...
    scripts/check-dts-closure.mjs   (Sweep the built closure for vanished declarations (pnpm check:dts-closure))
    scripts/check-dual-build-cjs-loads.mjs   (Every published require entry point actually loads (pnpm check:dual-build-cjs-loads))
    scripts/population-floor.mjs   (imported by scripts/check-dual-build-cjs-loads.mjs)
    scripts/check-sourcemap-no-sources-content.mjs   (No published source map embeds source text (pnpm check:sourcemap-no-sources-content))
    scripts/check-lean-entry-closure.mjs   (The lean engine entry loads no forbidden or unlisted package (pnpm check:lean-entry-closure))
```

**After:** exit 0. The gate prints: `all 20 build input(s) ... and all
11 script(s) the build-core job's own steps run (5 named by a step, 6
only imported by one) are covered by core ... no literal core entry
beside them is a leftover`.

**Self-test.** 131 assertions, exit 0. The new battery (10) has a floor
of 30, which is the count it registers. The roster floor goes from 9 to
10.

On fixtures, battery (10) checks each of these:
- A guard row dropped from `core:` goes red, naming the path and the
step.
- A guard step added with no row goes red, whether it reaches its script
through an alias or names it directly.
- An imported module dropped from `core:` goes red.
- An alias chain is followed to its script.
- A commented-out line requires nothing.
- A guard that is also a build input needs only its one row.
- A row whose step is gone is reported as a leftover, including in a
directory that holds only a guard.
- A script whose closure cannot be computed is refused.

Over the real tree, it checks:
- The five step-run scripts and `population-floor.mjs` are each covered
by their own literal row.
- With the lean-entry row dropped, `main()` returns 1.
- The two recognizer bounds below are pinned to the real job.

The scratch tree that the report-path cases run in now stages the
guards' closures. Without that, a "returns 1" case could pass only
because the build subject refused.

**Ablation, run once on the real script and then restored.** The step
loop in `buildGuardsOf` was mutated to read nothing, through
`scripts/ablation-replace.mjs`: anchor 1 → 0, blob `bfb0ec815a` →
`2f03c5416d`.
- `--self-test`: exit 1, with 27 of 131 assertions failing.
  - 24 of battery (10)'s 30 failed.
- Three real-tree cases in (5) and (9) that read the same verdict also
failed.
- Six cases in (10) held. Five of them do not depend on the derivation:
the anchor, the control, the two bound pins, and the shared-row case.
- The sixth is the report-path `main() === 1`, which the leftover
direction below satisfied. Its sibling, which asserts the exact finding,
failed.
- The production gate: exit 1 as well, but in the reverse direction.
With no population derived, the five rows became leftovers. So the rows
are held to the steps from both sides, and an emptied derivation cannot
pass silently.
- Restore: the blob equals HEAD `bfb0ec815a`, `git diff HEAD` is empty,
and the marker count is 0.

## Known bounds (stated in the header, two of them pinned in the
self-test)

- **Package-scoped scripts are not followed.** This means a script that
a package's own manifest runs, via `pnpm --filter PKG SCRIPT`. Build
Core's only such step today is the spec's `analyze`, which runs a file
inside `packages/spec`, and `packages/**` covers that. Pinned.
- **Local composite actions are not followed.** Build Core's only one is
setup-pnpm. Its `run:` steps name no repository script, and every job in
every workflow runs it. Pinned.
- **`pnpm install`'s lifecycle is not a guard.** `prepare` runs
`scripts/setup-git-hooks.mjs`, but every job installs, including the
unfiltered lint.yml jobs, so a change to it already runs at PR time.
- **Some extensions are not read.** A script in `.cjs` or `.ts` is not
seen. This is the same as `check:self-test-wired`, because the
recognizer is shared.
- **Data files are not scripts.** A data file a guard reads is not
derived.

## Acceptance notes

- **The dual-build baseline is not covered.**
`scripts/dual-build-cjs-loads.baseline.json` is a shrink-only ledger
that `check:dual-build-cjs-loads` reads. A diff confined to it still
skips Build Core (control row above). Closing that needs either a
declared data-input list or reading the gate's own declaration, which
goes beyond this card's "guard scripts". There is one such baseline
today. Noted, not filed.
- **A pre-existing comment overstates when Build Core runs.**
`scripts/check-dual-build-cjs-loads.mjs` (its dispatch-gates declaration
note) says Build Core is "a required context that runs on every PR".
Build Core is path-scoped by `core:`, so that was not true before this
PR either. Its operative claim, that a card touching `packages/**` still
gets Build Core, holds. This PR does not make the sentence false, and
the file is outside this card's surface. Noted, not edited.
- **Comments this PR corrects.** These sentences were made false by the
new population and are corrected here:
- ci.yml's build-input comment ("a literal entry beside them covers none
(a leftover)")
  - the parity header's leftover sentence
  - the parity header's known-bounds sentence

## Local verification

All readings at `aa0b21ae2c`.
- **Derived gate families.** `node scripts/pm/dispatch-gates.mjs --repo
objectstack-ai/objectstack --commands` (no paths) derives 58 commands,
the same 58 the seat derived at dispatch.
  - 53 exit 0.
- The four dist-reading guards (`check:dts-closure`,
`check:dual-build-cjs-loads`, `check:lean-entry-closure`,
`check:sourcemap-no-sources-content`) pass their `--self-test` legs, and
their real legs exit 3 PREREQUISITE NOT MET because there is no `dist/`
here. NOT MEASURED locally, reason: no build. This PR's ci.yml change
schedules Build Core, which runs them.
- `check:pm-dispatch-gates` exits 0: 1976 cases, and the battery took
791.9s.
- `--ran` reconciliation: 58 derived, 54 run, 4 NOT-MEASURED (the exit-3
four), 0 unrun.
- **Roster gates.** Those whose roster sits under a directory this diff
touches: `check-platform-checklist-watchdog`,
`check-published-list-mirrors`, `check:engine-double-contract` and
`check:i18n-stale-fill` exit 0. `check:published-readme-exports` exits
3: NOT MEASURED, it needs built `.d.ts`.
- **ESLint.** Narrowed to the changed lintable files and run with
`--no-inline-config`.
- That is one file, `scripts/check-ci-filter-parity.mjs`; ci.yml matches
no `files` glob in `eslint.config.mjs`.
  - JSON output: 1 file, 0 errors, 0 warnings.
- The config has no `parserOptions.project`, so linting is not
type-aware and this diff cannot move any untouched file's result.
- **The edited script's own tests.** No `*.test.*` in `scripts/` or in
the root package names `check-ci-filter-parity`. The three hits
elsewhere (`packages/types`, `packages/objectql`, `packages/runtime`)
mention it in comments only.

No changeset: workflow and repo-gate code only, nothing any package
publishes (`skip-changeset`).

---
_Generated by [Claude
Code](https://claude.ai/code/session_01UtnxvdiN376GF3sgXwAw4d)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/m 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.

2 participants