Repository navigation
build(turbo): build task hashes cover the root scripts each build runs (#21193) - #21199
Merged
Merged
Conversation
The build task family hashed only its package's own files, so an edit to a root script a build executes moved no build hash and the cache replayed output built from the old script. Declare each build's out-of-package reads as task inputs, taken from the static import closure of what the build command runs: - every build: tsup-drop-sources-content.mjs (imported by the root and 21 package tsup configs), check-dts-emitted.mjs and its import invoked-as.mjs; - spec, core, organizations, plugin-auth: check-dev-prereqs.mjs --stamp and its imports (build-input-hash.mjs decides the stamp bytes in dist); - spec: also gen:schema's check-regen-pending.mjs closure and check-dts-references.mjs with ts-parse.mjs; - create-objectstack: sync-scaffold-emission-policy.mjs, sync-template-versions.mjs and the policy source packages/cli/src/commands/init.ts, which the first build step stamps into the template tsup copies to dist/templates. Task-level inputs, not globalDependencies: globalCacheInputs and every task's resolved definition other than inputs are unchanged. Claude-Session: https://claude.ai/code/session_57c17198-e47f-48f8-9242-3fc94c651c85 Co-authored-by: Claude <noreply@anthropic.com>
hotlong
marked this pull request as ready for review
October 1, 2026 16:15
This was referenced Oct 1, 2026
This was referenced Oct 1, 2026
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #21193
Clause-②: no. Turbo hash declarations change no contract's acceptance or rejection behaviour.
This is finding F1 of the stage-1 report on #21186, the precondition for that card's stage 2. #21186 stays open; its draft PR #21191 (the
remoteCacheblock) is untouched here.What changes
turbo.jsononly,buildtask family only. Each build now declares the root files its build command loads, as task-level$TURBO_ROOT$/…inputs beside$TURBO_DEFAULT$:build(generic)scripts/tsup-drop-sources-content.mjs,scripts/check-dts-emitted.mjs,scripts/invoked-as.mjstsup.config.ts(loaded with--configby 47 builds) and 19 package tsup configs import the first, 66 builds in all (it shapes every.map); 67 build commands end withnode …/check-dts-emitted.mjs, which imports the third@objectstack/spec#buildcheck-dts-references.mjs,ts-parse.mjs,check-regen-pending.mjs,regen-artifacts.mjs,git-env.mjs,import-prerequisite.mjs,cli-build-prerequisite.mjs,check-dev-prereqs.mjs,build-input-hash.mjs,workspace-enumerator.mjs,js-comment-mask.mjsgen:schema(build-schemas.ts) importscheck-regen-pending.mjsto write thejson-schema/stamp; the build runscheck-dts-references.mjsandcheck-dev-prereqs.mjs --stamp; the rest is their import closure@objectstack/core#build,@objectstack/organizations#build,@objectstack/plugin-auth#buildcheck-dev-prereqs.mjs,build-input-hash.mjs,workspace-enumerator.mjs,js-comment-mask.mjscheck-dev-prereqs.mjs --stamp;build-input-hash.mjscomputes the stamp bytes written intodist/create-objectstack#buildcheck-dts-emitted.mjs,invoked-as.mjs,sync-scaffold-emission-policy.mjs,sync-template-versions.mjs,packages/cli/src/commands/init.tsSCAFFOLD_*policy out ofinit.tsand stamps it into the template that tsup copies todist/templates; it does not run the root tsup config, sotsup-drop-sources-content.mjsis not declared thereAll paths above are under
scripts/unless spelled in full. The rule for "is it an input": a file Node loads while the build command runs. That is the static import closure of every script the command executes and every tsup config it loads, measured by walking the relative imports, not by reading names. Each of these files can change a build's output bytes (tsup-drop-sources-content.mjs,build-input-hash.mjs,check-regen-pending.mjs,sync-scaffold-emission-policy.mjs,init.ts) or its pass/fail verdict (all of them, at minimum by failing to load).Not
globalDependencies:globalCacheInputsis byte-identical before and after. A rootpkg#taskkey replaces the generic definition instead of merging with it (measured on turbo 2.11.5: an override carrying onlyinputsresolved todependsOn: []andoutputs: []), so each override restates the genericdependsOnandoutputsverbatim. Measured: across all 320 tasks ofbuild test test:repo typecheck(231 with a command), every resolved task definition is identical before and after exceptinputs.Probe (the #21186 stage-1 method, re-run)
Method: append one comment line to the file, run
turbo run build test test:repo typecheck --dry=json, compare every task hash with the unedited plan, restore withgit checkout HEAD -- FILE, and prove the restore by blob hash. Before = baseebdb6f2aca; after =120faa2030. Turbo 2.11.5. Build Core has 72 build tasks;+docsmeans@objectstack/docs#buildmoved too. Expected = the direct readers' build tasks plus every build downstream of them in the task graph. "Exact" means the moved set equals the expected set task for task, not only in count.scripts/tsup-drop-sources-content.mjsscripts/sync-scaffold-emission-policy.mjspackages/cli/src/commands/init.tscreate-objectstack#build)scripts/check-dev-prereqs.mjsscripts/check-regen-pending.mjsscripts/check-dts-emitted.mjsscripts/check-dts-references.mjsscripts/invoked-as.mjsscripts/build-input-hash.mjsscripts/workspace-enumerator.mjsscripts/js-comment-mask.mjsscripts/regen-artifacts.mjsscripts/git-env.mjsscripts/import-prerequisite.mjsscripts/cli-build-prerequisite.mjsscripts/ts-parse.mjsscripts/sync-template-versions.mjstsup.config.tspackages/create-objectstack/tsup.config.tsscripts/check-turbo-task-graph.mjsThe 71/72 rows:
@objectstack/sdui-parser#buildis the only build that is not downstream of@objectstack/spec#build, and it does not load those files.End to end, on the real cache.
turbo run build --filter=create-objectstack --only, thenSCAFFOLD_TYPESCRIPT_RANGEininit.tsedited from^5.3.0to^5.9.0and run again. Before (ebdb6f2aca):cache hit, suppressing logs d14ac0ba80366056, anddist/templates/blank/package.jsonstill says^5.3.0. After (120faa2030):cache miss, executing 78b5115918e44c53, and the dist template says^5.9.0. Restored, the next run is a hit on the original hashb336dcf35aaf1381with^5.3.0again.Before/after plan diff (this commit itself)
turbo run build --dry=json(same hashes as the four-task run, 80/80 identical) fromebdb6f2acato120faa2030:$TURBO_DEFAULT$keeps the default file set. Zero test, test:repo or typecheck tasks gain or lose an input key.@objectstack/docs#buildinputs are unchanged.^buildedge). This is the one-time "every build hash moves once" the stage-1 option A priced.dependsOn: ["^build"], not a declaration on those tasks. The 8 that do not move are exactly the tasks with zero task dependencies (spec,sdui-parserandvitest-filter-preflighttest;spec#test:repo;refd-timer-testkit,sdui-parser,specandvitest-filter-preflighttypecheck).The same mechanism explains the probe's test column: after this change, an edit to a spec-closure script moves 70/6/75, against 73/6/79 for the root
tsup.config.tsglobal control. The difference is the zero-dependency tasks, which aglobalDependenciesentry would also have moved.Gates
Derived with
node scripts/pm/dispatch-gates.mjs --commandsat120faa2030. All 9 ran and exited 0:check-closing-keyword-parity(plus its--self-test),check-comment-mask-corpus,check:driver-memory-census,check:gitlink-declared,check:nul-bytes,check:refd-timer-probe,check:turbo-task-graph(43 package-scoped and 9 generic tasks judged),check:watch-hint-literal.dispatch-gates --ran: 9 derived, 9 run, 0 NOT-MEASURED, 0 unrun. No guard asserts build inputs. I also ran the other gates that parseturbo.jsonor read the declared files, all exit 0:check:cross-package-test-inputs,check-ci-filter-parity(--self-testand the run),check:examples-live-imports,check-dev-prereqs.mjs --self-test(it readsglobalDependencies, which is unchanged) andcheck:scaffold-emission-policy.Labelled
skip-changeset:turbo.jsonis repo-root configuration and ships in no package.Acceptance notes
check-dts-references.mjs(the spec build runs it; it was in the stage-1 option A list but not in the card's table) and the import closure of every script:invoked-as,build-input-hash,workspace-enumerator,js-comment-mask,regen-artifacts,git-env,import-prerequisite,cli-build-prerequisite,ts-parseandsync-template-versions. Before this change each one moved 0/72 builds, the same as the named rows.build-input-hash.mjsdecides the stamp bytes written intodist/andjson-schema/.@objectstack/cli#build(tsc) and the fourexample-*#buildtasks (objectstack build) load none of the three generic files. Each is downstream of@objectstack/spec#build, which does load them, so their moved set is the same either way (the probe rows are exact). Splitting them out would take five more overrides for no hash difference.@objectstack/docs#buildis left alone. Its build runs the specgen:schemaandgen:docs, whose out-of-package imports were measured to be a subset of the spec closure above. It reaches them through its^buildedge to@objectstack/spec#build, the same edge that already carriespackages/spec/scripts/**to it. Every spec-closure row shows+docs.merge-base(HEAD, origin/main), which is git state, and it is verdict-only (from the stage-1 audit).check:cross-package-test-inputs, but no gate compares a build's declared inputs with its import closure. A new relative import in any of these scripts reopens this gap silently. Carrier: none.ci.yml'scorefilter matches none ofscripts/**, the roottsup.config.tsorturbo.json(picomatch 4.0.5 against the checked-in list). So a PR confined to these files, this one included, skips Build Core and its build-time gates (for example sourcemap-no-sources-content) until the merge queue, where the filter defaults to true. This PR leavesci.ymlalone by constraint. The Type Check lane still executes the new build definitions throughtypecheck's^buildedge.Generated by Claude Code