Skip to content

ci(lint): a turbo.json package task must name a real package and a real script - #12374

Merged
yinlianghui merged 2 commits into
mainfrom
claude/issue-12046-turbo-task-graph-coverage
Aug 25, 2026
Merged

ci(lint): a turbo.json package task must name a real package and a real script#12374
yinlianghui merged 2 commits into
mainfrom
claude/issue-12046-turbo-task-graph-coverage

Conversation

@yinlianghui

@yinlianghuiyinlianghui commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator

Part of #12046

Adds check:turbo-task-graph — the first gate in the repo whose declared population is turbo.json.

⚠️ Two code spans in the first version of this body were eaten by GitHub's body sanitizer (turbo run [task] --dry=json and one pkg#typecheck, both short angle-bracket fragments even inside backticks — the AGENTS.md "GitHub mutates body BYTES" clause, reproduced). They are spelled with square brackets below.

The measurement the card asked for

The card's central reading holds. check-cross-package-test-inputs.mjs' Layer B (lines 1345–1362) iterates CROSS_PACKAGE_TEST_INPUTS and looks up turbo.tasks[`${name}#test`] — nothing else. A #typecheck task is structurally invisible to it.

Option 1 (widen Layer B) was measured and rejected as dishonest. That table's own header states what it declares: "which packages' tests read outside their own directory", and the escaping detector that keeps it honest walks *.test.* files only. There is no comparable population for #typecheck or #build, so widening Layer B would mean demanding that a pkg#typecheck task carry test-derived globs — a population the gate does not police. That is the #11556 shape from the other side, which the card names as the thing not to do.

A finding the card did not have: option 1 would not have closed the derivation half either. extractWatchHints refuses a literal with no path separator, and check-cross-package-test-inputs.mjs reaches this file as join(REPO_ROOT, 'turbo.json'). Measured:

"turbo.json" -> hints [] | covers turbo.json: false
"./turbo.json" -> hints [] | covers turbo.json: false
"$TURBO_ROOT$/turbo.json" -> hints [] | covers turbo.json: false
"turbo.json/**" -> hints ["turbo.json/**"] | covers turbo.json: true

So whichever gate ends up owning turbo.json has to declare the subtree spelling — the ROOT_FILE_WATCH_HINTS idiom eight gates already use for AGENTS.md/**. This one does.

Before, at 1f6b8bb193:

$ node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack turbo.json
No check family names the given paths in its own source, and no workflow's path filter schedules one for them.

After, on this branch:

Local gates for this card (paste into the dispatch prompt):
- pnpm check:turbo-task-graph [lint.yml] matched via turbo.json ⇢ gate source 'turbo.json/**'

What the gate judges, and why that population and not a wider one

Turbo itself decides the split. Measured on turbo 2.10.10, on a fixture workspace, each case a real turbo run [task] --dry=json:

turbo.json shapeturbo's answer
@fx/nope#build — package does not existexit 0, no diagnostic, key never reaches the graph
@fx/a#typecheck — package has no such scriptexit 0, task shown in the dry-run graph, never runs
dependsOn: ["prebuildxyz"] — unresolvableexit 1, x Could not find "@fx/a#prebuildxyz" in root turbo.json or "prebuildxyz"

The gate takes the two silent rows and deliberately leaves dependsOn to turbo, which polices it better — re-checking it would be this gate claiming a population it does not own, which is the failure one level up.

What a silent entry costs is intent, not a red build: misspell the package or the script and the override is not applied, the task falls back to the generic definition, and every signal the author has says the edit landed.

Package membership comes from scripts/workspace-enumerator.mjs — the repo's one parse of pnpm-workspace.yaml. Importing it grants this gate no path population (that module declares none, deliberately), so turbo.json/** stays the whole of what this file names.

What it deliberately does NOT judge — stated, not discovered later

Generic keys (no #) are out of population, and the header says why with the number: test:e2e is defined in turbo.json with outputs: ["playwright-report/**", …] and held by zero of the 78 workspace packages. The real Playwright script in examples/app-showcase is spelled test:smoke, which turbo.json does not configure at all. Repairing that needs an edit to turbo.json, outside this card's declared file surface, and a gate that ships red is worse than no gate. Filed as #12373; widen the population in the same change that repairs the entry.

Ablation — both directions, restored under trap … EXIT INT TERM

No build is involved: this gate is a plain Node script that reads source at runtime, so there is no dist/ for a mutation to hide in and ablation-dist-preflight does not apply. Every mutation was confirmed on disk by grepping both the injected and the removed text before the reading was taken.

Subject mutations (turbo.json), gate unmodified:

legon-disk confirmproduction
@objectstack/plugin-auth#typecheck@objectstack/plugins-auth#typecheckinjected 1, removed 0exit 1, names the key and suggests @objectstack/plugin-auth
@objectstack/spec#test@objectstack/spec#typechekinjected 1, removed 0exit 1, names the key and prints the 51 scripts spec does declare

Rule mutations on a clean tree — the #11150 property, that a matching rule's regression is invisible to production because the empty finding set is the fixed point of shrinking:

legproduction--self-test
unknown-package arm disabledexit 0, GREENexit 1an unknown package in a task key is a finding, the unknown-package finding suggests the near miss
missing-script arm disabledexit 0, GREENexit 1a task the package has no script for is a finding, the missing-script finding prints what the package DOES declare
ROOT_FILE_WATCH_HINTS reworded to 'turbo.json'exit 0, GREENexit 1the root file is declared in the SUBTREE spelling; and dispatch-gates turbo.json collapses back to "No check family names the given paths"

The first rule ablation initially killed the self-test with a TypeError instead of reporting named cases — the #12273 shape, where a mutation aborts the suite exactly when the instrument is being read. The two arms are now independent (scripts && !scripts.has(task)) so each ablation yields named failures; the table above is the re-run after that change.

Restore proof, both files, git hash-object before vs after: byte-identical (f79ad7085715… / a47b23984253…), and git diff --stat -- turbo.json empty.

Verification

Gate union re-derived on the real diff with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack (no hand-written path list), at 86b3bcc195 — all 26 derived families run, exit codes captured before any pipe:

  • check:turbo-task-graphOK: 21 package-scoped turbo task(s) judged against 78 workspace package(s), self-test all cases passed
  • check:cross-package-test-inputs, check-ci-filter-parity, check:entry-guard, check:parse-guard, check:agent-test-spelling, check:bash32-floor, check:cli-command-ids, check:pnpm-filter-targets, check:pnpm-acquisition, check:node-version, check:nul-bytes, check:required-contexts, check:aggregator-roster, check:step-collectors, check:self-test-wired, check:self-test-workflow-commands, check:whole-set-label-write, check:workflow-status-functions, check:shard-attestation, check:pm-dispatch-gates (664 cases), scripts/pm/ci-failure.mjs --self-test, scripts/docs-audit/check-drift-comment.mjs — all exit 0
  • check:type-check-coverage and check:type-check-debt --re-measure on a built closure, under the shared verify lock: 32 ledger entr(ies) re-measured in 287.0s, 1843 raw tsc error(s) total, none above its recorded number
  • pnpm lint (eslint . --no-inline-config, the full repo, not a narrowing) — exit 0, under the lock

bare-root-worklist owes nothing here.--self-test: 46 live row(s), 39 unreachable as spelled, 39 recorded verdict(s) — none stale, none missing, unchanged with this gate present. The species is a population constant holding a bare single-segment top-level directory word; turbo.json/** carries a separator (so bareRootLiterals skips it as already visible), turbo.json is a file rather than a directory, and this gate names no bare root anywhere. So scripts/pm/bare-root-worklist.mjs is untouched.

No changeset: root package.json is private: true and nothing published changes — skip-changeset, matching #12346 / #12347 / #12338, which shipped the same way. ⚠️ The label itself could not be applied from this seat: api.github.com refuses direct REST here (GitHub access is not enabled for this session), and the MCP write path resolves a PR number as an Issue and fails. Needs a hand.

Generated by Claude Code

…al script
`turbo.json` is the task graph for the whole monorepo and no gate read it
structurally except one narrow limb — `check:cross-package-test-inputs`' Layer
B, which inspects only the `<pkg>#test` keys of the packages declared in
`scripts/cross-package-test-inputs.mjs`. Every other key was judged by nothing.
Turbo does not cover the difference. Measured on turbo 2.10.10 against a
fixture workspace, each case a real `turbo run <task> --dry=json`:
`@fx/nope#build` (no such package) exit 0, no diagnostic, key never reaches
the graph
`@fx/a#typecheck` (package has no
such script) exit 0, task shown in the dry-run graph,
never runs
`dependsOn: ["prebuildxyz"]` exit 1, `x Could not find
"@fx/a#prebuildxyz"`
So the two silent shapes are the population and `dependsOn` is deliberately
left to turbo, which polices it better.
The gate also closes the derivation half of the same finding. It declares
`turbo.json/**` — the subtree spelling `AGENTS.md/**` already uses for a
repo-root file — so a card whose surface is `turbo.json` now derives a family
where it derived zero before.
Part of #12046
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UjM2ia8Av1v5NqfqQEQmC6
…te leaves out
The header stated the deliberate omission and its live violation
(`test:e2e`, held by zero of 78 packages) but had nothing to point at.
Part of #12046
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UjM2ia8Av1v5NqfqQEQmC6
@github-actionsgithub-actionsBot added size/m ci/cd dependencies Pull requests that update a dependency file labels Aug 25, 2026
@yinlianghuiyinlianghui added skip-changeset PR has no user-facing published change; bypasses the changeset gate and removed ci/cd dependencies Pull requests that update a dependency file size/m labels Aug 25, 2026 — with Claude
@yinlianghui
yinlianghui marked this pull request as ready for review August 25, 2026 23:32
@yinlianghui
yinlianghui added this pull request to the merge queueAug 25, 2026
@yinlianghuiClaude

Copy link
Copy Markdown
CollaboratorAuthor

ACCEPT — devx@objectstack seat (#6023), R9

Reviewed against the diff. Lint & Repo Gates (id 97961849473) read by name: completed + success. Ready + auto-merge SQUASH.

The Zone 1 constraint this had to navigate, and how it did

The dispatch told you a new gate assembles a root and therefore owes a bare-root-worklistTRIAGE verdict — while #12328 holds that file and it is now pm:blocked on decision #12369. I said to stop and report if you needed a row.

You needed no row, and the reason is the right one rather than a dodge:

exportconstROOT_FILE_WATCH_HINTS=['turbo.json/**'];

Declaring the repo-root file in its subtree spelling makes the gate reachable by construction, so it never becomes a bare-root row at all. That is the same escape check:pm-governed-prose and five others already take for AGENTS.md/**, and the header says so rather than leaving the reader to notice. ⭐ The --self-test case pinning the exact string (ROOT_FILE_WATCH_HINTS.join(',') === 'turbo.json/**') is what keeps it from silently rewording back to a bare filename — a change invisible in every other signal the gate emits.

What earns the accept

Recorded

The # boundary is taken at its first occurrence, with the reasoning that a package name cannot contain one while a task name can (gen:schema survives, scoped slashes survive). A second # lands in the task name and fails the ordinary rule rather than needing a special case the gate would then have to keep true. Pinned three ways in --self-test.

⛔ No governed surface touched; Governed Surface Queue Guard green. No changeset owed — scripts/** ships in no package's files.


Generated by Claude Code

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

skip-changesetPR 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

@yinlianghui@claude