Skip to content

build(turbo): build task hashes cover the root scripts each build runs (#21193) - #21199

Merged
hotlong merged 1 commit into
mainfrom
claude/issue-21193-build-inputs
Oct 1, 2026
Merged

hotlong merged 1 commit into
mainfrom
claude/issue-21193-build-inputs

Conversation

@hotlong

@hotlong hotlong commented Oct 1, 2026

Copy link
Copy Markdown
Contributor

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 remoteCache block) is untouched here.

What changes

turbo.json only, build task family only. Each build now declares the root files its build command loads, as task-level $TURBO_ROOT$/… inputs beside $TURBO_DEFAULT$:

task added inputs why it reads them
build (generic) scripts/tsup-drop-sources-content.mjs, scripts/check-dts-emitted.mjs, scripts/invoked-as.mjs the root tsup.config.ts (loaded with --config by 47 builds) and 19 package tsup configs import the first, 66 builds in all (it shapes every .map); 67 build commands end with node …/check-dts-emitted.mjs, which imports the third
@objectstack/spec#build the generic three, plus check-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.mjs gen:schema (build-schemas.ts) imports check-regen-pending.mjs to write the json-schema/ stamp; the build runs check-dts-references.mjs and check-dev-prereqs.mjs --stamp; the rest is their import closure
@objectstack/core#build, @objectstack/organizations#build, @objectstack/plugin-auth#build the generic three, plus check-dev-prereqs.mjs, build-input-hash.mjs, workspace-enumerator.mjs, js-comment-mask.mjs each build ends with check-dev-prereqs.mjs --stamp; build-input-hash.mjs computes the stamp bytes written into dist/
create-objectstack#build check-dts-emitted.mjs, invoked-as.mjs, sync-scaffold-emission-policy.mjs, sync-template-versions.mjs, packages/cli/src/commands/init.ts its first step reads the SCAFFOLD_* policy out of init.ts and stamps it into the template that tsup copies to dist/templates; it does not run the root tsup config, so tsup-drop-sources-content.mjs is not declared there

All 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: globalCacheInputs is byte-identical before and after. A root pkg#task key replaces the generic definition instead of merging with it (measured on turbo 2.11.5: an override carrying only inputs resolved to dependsOn: [] and outputs: []), so each override restates the generic dependsOn and outputs verbatim. Measured: across all 320 tasks of build test test:repo typecheck (231 with a command), every resolved task definition is identical before and after except inputs.

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 with git checkout HEAD -- FILE, and prove the restore by blob hash. Before = base ebdb6f2aca; after = 120faa2030. Turbo 2.11.5. Build Core has 72 build tasks; +docs means @objectstack/docs#build moved 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.

file direct readers build moved, before build moved, after expected test / test:repo / typecheck moved, before → after
scripts/tsup-drop-sources-content.mjs 66 0/72 72/72 +docs exact 1/1/0 → 70/6/75
scripts/sync-scaffold-emission-policy.mjs 1 0/72 6/72 exact 2/1/0 → 7/1/7
packages/cli/src/commands/init.ts 2 5/72 (no create-objectstack#build) 6/72 exact 8/5/7 → 8/5/7
scripts/check-dev-prereqs.mjs 4 0/72 71/72 +docs exact 1/1/0 → 70/6/75
scripts/check-regen-pending.mjs 1 0/72 71/72 +docs exact 1/1/0 → 70/6/75
scripts/check-dts-emitted.mjs 67 0/72 72/72 +docs exact 1/1/0 → 70/6/75
scripts/check-dts-references.mjs 1 0/72 71/72 +docs exact 1/1/0 → 70/6/75
scripts/invoked-as.mjs 67 0/72 72/72 +docs exact 2/1/0 → 70/6/75
scripts/build-input-hash.mjs 4 0/72 71/72 +docs exact 1/1/0 → 70/6/75
scripts/workspace-enumerator.mjs 4 0/72 71/72 +docs exact 1/1/0 → 70/6/75
scripts/js-comment-mask.mjs 4 0/72 71/72 +docs exact 13/2/0 → 71/6/75
scripts/regen-artifacts.mjs 1 0/72 71/72 +docs exact 1/1/0 → 70/6/75
scripts/git-env.mjs 1 0/72 71/72 +docs exact 2/1/0 → 70/6/75
scripts/import-prerequisite.mjs 1 0/72 71/72 +docs exact 1/1/0 → 70/6/75
scripts/cli-build-prerequisite.mjs 1 0/72 71/72 +docs exact 2/1/0 → 70/6/75
scripts/ts-parse.mjs 1 0/72 71/72 +docs exact 1/1/0 → 70/6/75
scripts/sync-template-versions.mjs 1 0/72 6/72 exact 2/1/0 → 8/1/7
control: root tsup.config.ts global 72/72 +docs 72/72 +docs unchanged 73/6/79 → 73/6/79
control: packages/create-objectstack/tsup.config.ts 1 6/72 6/72 exact 7/5/8 → 7/5/8
negative control: scripts/check-turbo-task-graph.mjs 0 0/72 0/72 nothing new 1/1/0 → 1/1/0

The 71/72 rows: @objectstack/sdui-parser#build is 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, then SCAFFOLD_TYPESCRIPT_RANGE in init.ts edited from ^5.3.0 to ^5.9.0 and run again. Before (ebdb6f2aca): cache hit, suppressing logs d14ac0ba80366056, and dist/templates/blank/package.json still 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 hash b336dcf35aaf1381 with ^5.3.0 again.

Before/after plan diff (this commit itself)

turbo run build --dry=json (same hashes as the four-task run, 80/80 identical) from ebdb6f2aca to 120faa2030:

  • Input keys: the 67 generic build tasks with a command gain exactly the three generic files; spec gains its 14; core, organizations and plugin-auth gain their 7; create-objectstack gains its 5. Zero input keys are removed or re-hashed on any task, so $TURBO_DEFAULT$ keeps the default file set. Zero test, test:repo or typecheck tasks gain or lose an input key. @objectstack/docs#build inputs are unchanged.
  • Hashes: 73/73 build tasks with a command move once (the definition changed; docs through its ^build edge). This is the one-time "every build hash moves once" the stage-1 option A priced.
  • Test and typecheck: 70/73 test, 5/6 test:repo and 75/79 typecheck hashes move once. That is turbo folding each dependency task's hash into the dependent's hash along dependsOn: ["^build"], not a declaration on those tasks. The 8 that do not move are exactly the tasks with zero task dependencies (spec, sdui-parser and vitest-filter-preflight test; spec#test:repo; refd-timer-testkit, sdui-parser, spec and vitest-filter-preflight typecheck).

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.ts global control. The difference is the zero-dependency tasks, which a globalDependencies entry would also have moved.

Gates

Derived with node scripts/pm/dispatch-gates.mjs --commands at 120faa2030. 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 parse turbo.json or read the declared files, all exit 0: check:cross-package-test-inputs, check-ci-filter-parity (--self-test and the run), check:examples-live-imports, check-dev-prereqs.mjs --self-test (it reads globalDependencies, which is unchanged) and check:scaffold-emission-policy.

Labelled skip-changeset: turbo.json is repo-root configuration and ships in no package.

Acceptance notes

  • The declared set is wider than the card's table, on the card's own rule. Beyond the five named rows, it adds 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-parse and sync-template-versions. Before this change each one moved 0/72 builds, the same as the named rows. build-input-hash.mjs decides the stamp bytes written into dist/ and json-schema/.
  • The generic key over-reaches on five tasks at the declaration level only. @objectstack/cli#build (tsc) and the four example-*#build tasks (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#build is left alone. Its build runs the spec gen:schema and gen:docs, whose out-of-package imports were measured to be a subset of the spec closure above. It reaches them through its ^build edge to @objectstack/spec#build, the same edge that already carries packages/spec/scripts/** to it. Every spec-closure row shows +docs.
  • Not declarable as a turbo input: the spec build's authorable-surface check anchors on merge-base(HEAD, origin/main), which is git state, and it is verdict-only (from the stage-1 audit).
  • Nothing keeps these lists honest. The test side has 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.
  • Scheduling, not hashing, at PR time. ci.yml's core filter matches none of scripts/**, the root tsup.config.ts or turbo.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 leaves ci.yml alone by constraint. The Type Check lane still executes the new build definitions through typecheck's ^build edge.

Generated by Claude Code

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>
@github-actions github-actions Bot added the size/s label Oct 1, 2026
@hotlong hotlong added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Oct 1, 2026
@hotlong hotlong self-assigned this Oct 1, 2026
@hotlong
hotlong marked this pull request as ready for review October 1, 2026 16:15
@hotlong
hotlong added this pull request to the merge queue Oct 1, 2026
Merged via the queue into main with commit b42e034 Oct 1, 2026
32 checks passed
@hotlong
hotlong deleted the claude/issue-21193-build-inputs branch October 1, 2026 16:48
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/s skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

1 participant