Skip to content

feat(cli): carry the computed advisory lists on every os build --json failure exit - #12079

Merged
os-zhuang merged 2 commits into
mainfrom
claude/issue-11772-build-json-failure-warnings
Aug 25, 2026
Merged

os-zhuang merged 2 commits into
mainfrom
claude/issue-11772-build-json-failure-warnings

Conversation

@os-zhuang

@os-zhuang os-zhuang commented Aug 25, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #11772

Maintainer ruling 2026-08-25 (5404971104, verbatim 「其他接受」): option 1 — every emitJson(...) failure exit in compile.ts carries the advisory lists the run has already computed, so warnings means the same thing on every exit and the text face's — re-run with --json for the full list pointer is true regardless of which later gate fails. Option 2 (payload shape depends on how far the run got) rejected; option 3 (weaken the pointer) dead.

Re-derived at origin/main c804f0ca5 — the card's table was short by three

The dispatch flagged that the ruling's list of exits was not guaranteed exhaustive. Enumerated from source rather than from the card: compile.ts has 10 emitJson call sites — 9 failure exits and 1 success payload. The filing card's table listed six failure exits. The three it missed:

  • the protocol parse failure (step 3, reports under errors, not error);
  • the --no-runtime-bundle refusal (step 4b);
  • the bottom catch-all (catch (error)), whose payload is reached by any throw — including the artifact write.

The catch-all is the one worth calling out: the four lists were block-scoped consts inside the try, so they were not merely unpublished there, they were out of scope. That exit is reached with everything computed (e.g. an unwritable artifact path), which is exactly the scenario the truncation pointer promises to serve.

What each exit carries now

warnings is present on all 10 payloads. Contents are what the run had computed at that point — never more:

exit (step) existing keys warnings before after
strict-body: missing body (2b) issues absent []
protocol parse failure (3) errors absent []
author-time rules failed (3b) issues ruleAdvisories unchanged
capability provider preflight failed (3c) issues absent rule + capability
access matrix drift (3e) changes absent rule + key + capability
docs validation failed (3f) issues absent all four
--no-runtime-bundle refusal (4b) error absent all four
runtime bundle failed (4b) error absent all four
thrown / caught (bottom) error absent what the run reached
success many all four unchanged

Carrying, not computing. Each list stays computed at the step that owns it; the change only hoists the four bindings out of the try and adds one warningsSoFar() ordering site. No computation moved earlier, so no failure path pays for work it had not already done — and the two exits that run before any advisory step report [] because that is the truth about that run, not because the tree is clean. Per the dispatch's instruction, exits where a list is genuinely not computed yet are reported, not forced: that is the - cells above, and it is why 3c carries two lists rather than four.

Order now lives at one site that the success payload reads too (os validate --json's order minus its trailing structuralWarnings), so member order cannot drift between exits — the same "one list cannot drift from itself" idiom #11643 and #11727 used one list over.

Pin evidence — per exit, and failing without the behaviour

packages/cli/test/build-json-failure-warnings.e2e.test.ts (11 assertions). Each failure exit is driven by a fixture carrying a known advisory of each class, and the payload is asserted to carry exactly the classes the run had reached — no fewer and no more. The "no more" half is what distinguishes option 1 from a change that hoisted the computations earlier:

3c capability preflight  { rule: 1, doc: 0, key: 0, cap: 1 }   <- key/doc not computed yet
3e access-matrix drift   { rule: 1, doc: 0, key: 1, cap: 1 }   <- doc not computed yet
3f package docs          { rule: 1, doc: 1, key: 1, cap: 1 }   <- the card's headline
4b --no-runtime-bundle   { rule: 1, doc: 1, key: 1, cap: 1 }
catch-all (EISDIR)       { rule: 1, doc: 1, key: 1, cap: 1 }
3b author-time rules     { rule: 1, doc: 0, key: 0, cap: 0 }   <- regression guard

The 3b row is a REGRESSION GUARD, not red-before evidence, and is named as such in the file: that exit already published warnings: ruleAdvisories, and at 3b warningsSoFar() is exactly [...ruleAdvisories]. Its cap: 0 corrected a wrong first draft of my own — the fixture declares the unknown capability token, but the #3366 preflight is one step later, so the hint does not exist yet.

Ablation — revert only packages/cli/src/commands/compile.ts to c804f0ca5, keep the pins, run. Mutation and restore each confirmed on disk with anchored greps in both directions before the run (warnings: warningsSoFar() 10 → 0 → 10; the one-line four-list spread 0 → 1 → 0), and the script carries a trap … EXIT INT TERM restore:

MUTATION_CONFIRMED_ON_DISK   Tests  9 failed | 2 passed (11)
RESTORE_CONFIRMED_ON_DISK    Tests  11 passed (11)

The 9 red are the 7 behavioural failure-exit pins plus both structural pins. The 2 green in both states are the ones named in advance: the 3b regression guard, and the instrument's own positive control.

No dist/ on the measured path, and it is proven, not asserted. These pins drive packages/cli/bin/run-dev.js, whose header states it is "the SOURCE entry point — same CLI, run from src/ through tsx, used by this repo's gates and e2e suites so they do not depend on packages/cli/dist having been built". packages/cli/dist was absent for the whole ablation, so the child could not have resolved through it, and the mutation went red with no rebuild. (The contrast case is live in this same package: serve-node-env-production-default.e2e.test.ts deliberately drives the built bin/run.js, and it fails with command serve not found on a tree where packages/cli has not been built — see below.)

The instrument produces a positive before its negative is trusted. The exhaustiveness pin extracts every emitJson payload literal by brace matching; it is first run over synthetic input containing one payload with warnings and one without, and asserted to find both and flag exactly the one missing it. A "no payload lacks warnings" pass from an extractor that finds nothing would otherwise read the same.

Clause ② — assessed against what was built: yes

The --json machine contract widens on the failure exits. A consumer that branches on warnings being absent from a failure payload sees a different shape after this change. needs:contract-review is hung on this PR and on the card; ⛔ this seat does not clear it.

The changeset names which exits gain the field and what a consumer keying off its absence must do instead — read success === false for "is this a failure payload", and ⛔ never read warnings: [] on a failure payload as "this tree raises no advisories" (it means the run stopped before those advisories were computed).

Checks — each gate's own verdict line

Gate family re-derived from the actual change set with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack (the script derives its own change set; 21 gates over 4 paths).

All quoted from each gate's own verdict line, at 8c7bf1249 (the final commit; git rev-parse --short HEAD), with exit codes captured before any pipe.

Full-repo lint — the standing ask, run whole rather than narrowed:

LINT_EXIT=0     eslint . --no-inline-config

Package checks

TYPECHECK_EXIT=0   tsc --noEmit  (@objectstack/cli)
CLI_TEST_EXIT=0    @objectstack/cli — Test Files 178 passed (178) · Tests 2025 passed (2025)
✓ check:cli-test-child-env: 32 spawner source(s) among 90 under packages/cli/test/**; no new bulk
  process.env copy reaches a spawned child (0 baselined, ⛔ SHRINK-ONLY; 2 deliberate site(s) pinned)
✓ check-nul-bytes: OK (scanned 6667 text file(s); no raw ASCII control bytes)
✓ check:test-source-alias OK — 72 packages with tests scanned; 61 registered as still resolving a
  workspace dep through `dist/`
✓ check:type-source-resolution OK — 93 tsc program(s) across 77 packages scanned
✓ check:published-files — 69 publishable package(s) of 78 workspace member(s) declare a `files` whitelist
✓ check:slot-lookup ratchet holds: 107 unswept site(s) in 25 file(s), none new
✓ check:cross-package-test-inputs — OK: 16 package(s) read outside themselves, all declared
✓ check-ci-filter-parity — OK: all 96 declared cross-package glob(s) (81 unique) are covered
✓ check:plugin-teardown-shape: 63 Plugin implementation(s) across 4648 source(s); baseline fully burned down
✓ check:changeset-gate-self-tests — 212 + 116 assertions over real temp git repos
✓ check-adr-0087-registration: this PR adds no declared-breaking changeset (1 non-breaking changeset seen)
✓ check-changeset-no-major: This diff introduces no `major` bump
✓ check-empty-changeset: No empty-frontmatter changeset introduced by this diff
✓ check:objectui-changeset — objectui-range --self-test: all checks passed
✓ release-rehearsal-clone --self-test passed
✓ check-i18n-bundles: OK (9 package(s) — all bundles in sync, no undeclared authoring keys)
✓ check-i18n-coverage: OK (12 config(s), 657 baselined untranslated string(s), none new)
✓ check-type-check-coverage --re-measure: OK — 32 ledger entr(ies) re-measured in 708.0s,
  1898 raw tsc error(s) total, none above its recorded number

⛔ Two gates initially refused rather than failed, and refuse ≠ pass — so neither is reported from that state. Both were missing-build preconditions that CI satisfies and this worktree did not, and both were driven to a real measurement before being quoted above:

  • check:i18n-coverage first printed COULD NOT MEASURE — 1 of 12 config(s) failed to lint (@objectstack/connector-mcp had no build output), with its own note that the result "says NOTHING about whether any declared label went untranslated". Building that one package turned it into the OK (12 config(s) … none new) line above.
  • check:type-check-debt first threw --re-measure cannot run: 1 workspace dependenc(ies) … have no built type entry point on disk -- @objectstack/service-knowledge. Building it produced the re-measured OK line above.

⛔ The TEST_DEBT / type-check-debt ledger was not touched (#11788 owns it this round). The gate notes @objectstack/plugin-approvals could be lowered by 1; that is left for its owner rather than absorbed here.

The one test failure I saw, and why it is not this change. serve-node-env-production-default.e2e.test.ts failed with command serve not found while packages/cli/dist was absent. That file's own header states it spawns the shipped bin/run.js and that "bin/run.js plus a genuinely built dist/ is the only shape" it works in. Building @objectstack/cli made it pass 3/3 — so it was this worktree's missing build, measured, not assumed, and independent of compile.ts.

Out-of-scope finding

⛔ Left for the reviewer, per the dispatch: this PR stays draft, auto-merge is not enabled, and needs:contract-review is not cleared by this seat — the chain clears it and records its verdict on the card.


Generated by Claude Code


Generated by Claude Code

…n` failure exit (#11772)

`warnings` lived on the terminal success payload only (plus `ruleAdvisories`
alone on the author-time-rules failure), while the text face prints its
advisory blocks — the #11529 author-time advisories at 3b and the #3786
undeclared authoring-key findings at 3d — several gates earlier, each ending
in `— re-run with --json for the full list`. A build that then failed at a
later gate emitted that gate's failure payload, and none of those carried the
list: the remedy the notice named returned a payload without the withheld
entries in it (the #11643 / #11391 "the remedy named is unreachable" shape).

Maintainer ruling 2026-08-25, option 1 of three: every `emitJson` failure exit
carries the lists the run has ALREADY COMPUTED, so `warnings` means the same
thing on every exit. Option 2 (shape depends on how far the run got) and
option 3 (weaken the pointer) were both rejected.

Nine failure exits, three more than the filing card's table listed — it missed
the protocol-parse exit, the `--no-runtime-bundle` refusal and the bottom
catch-all. Every one now reads a single `warningsSoFar()` site, which the
success payload reads too, so the member order (`os validate --json`'s, minus
its trailing `structuralWarnings`) cannot drift between exits.

Carrying, not computing: each list stays computed at the step that owns it, so
an exit upstream of a step reports that list empty rather than paying for a
computation it had not already done.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019siH5jDmk5hrayvfyojUqR
@github-actions

github-actions Bot commented Aug 25, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/cli, touching 1 documentable anchor(s).

17 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 0e0bf8049dfcef2ac29c43cfa15f9963c3b921a0.

⛔ 4 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 23 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 0e0bf8049dfcef2ac29c43cfa15f9963c3b921a0 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 395ef9ecfd5adbbdbcbdb60557ff6a4cb622b272 — the merge of head 6da672d43dd14e8623eba1dbefc1c1cec9a3b44b into base 0e0bf8049dfcef2ac29c43cfa15f9963c3b921a0, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 395ef9ecfd5adbbdbcbdb60557ff6a4cb622b272 && git checkout 395ef9ecfd5adbbdbcbdb60557ff6a4cb622b272
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 0e0bf8049dfcef2ac29c43cfa15f9963c3b921a0 6da672d43dd14e8623eba1dbefc1c1cec9a3b44b && git checkout -B drift-repro 0e0bf8049dfcef2ac29c43cfa15f9963c3b921a0 && git merge --no-ff 6da672d43dd14e8623eba1dbefc1c1cec9a3b44b

node scripts/docs-audit/affected-docs.mjs --json 0e0bf8049dfcef2ac29c43cfa15f9963c3b921a0

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 0e0bf8049dfcef2ac29c43cfa15f9963c3b921a0 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…type (#11772)

`Test Core (2/6)` failed on `packages/lint/src/authoring-rule-wiring.test.ts:291`
-- "os build imports no unratcheted symbol from @objectstack/lint":

    AssertionError: compile.ts imports AuthoringFinding from @objectstack/lint
    directly. Register the rule in AUTHORING_RULES, or add the symbol to
    LINT_IMPORT_RATCHET with a reason.
      expected [ 'AuthoringFinding' ] to deeply equal []

WHY THIS CARD TRIPPED IT. Hoisting the four advisory lists out of the `try` so
every `emitJson` failure exit can read `warningsSoFar()` turned one inferred
binding into a declared one. Before the hoist the list arrived destructured --
`const { errors: ruleErrors, advisories: ruleAdvisories } = splitBySeverity(...)`
-- and its type was inferred, so no name was imported. A `let` declared ahead of
the assignment needs a written type, and the first spelling reached for the
underlying finding type, adding `AuthoringFinding` to compile.ts's import list.
The #4409 import scan reads that list and had never been told about the symbol.

`import type` does not escape the scan, and that is deliberate: `lintImportsIn()`
matches `/import\s+(?:type\s+)?\{([^}]*)\}\s*from\s*['"]@objectstack\/lint['"]/g`
and then strips a per-name `type ` prefix, so both the statement-level and the
inline modifier are seen. The guard is asking which SYMBOLS this command file
names, not which of them survive to runtime.

THE FIX. Bind the annotation to the function that produces the value:

    let ruleAdvisories: ReturnType<typeof splitBySeverity>['advisories'] = [];

`splitBySeverity` is already an import this file makes and already carries a
LINT_IMPORT_RATCHET entry ("Pure partition of a finding list into gating vs
advisory. Carries no rule identity at all."), so no symbol is added and no
exemption is widened. It resolves to exactly the same type the old annotation
spelled -- `AuthoringFinding[]`, the function is sync so no `Awaited` is
involved -- and `tsc --noEmit` is clean.

WHY THIS IS NOT A GATE BYPASS. Nothing was added to LINT_IMPORT_RATCHET or to
AUTHORING_RULES, no assertion in authoring-rule-wiring.test.ts was touched, and
no test was skipped or relaxed. The ratchet's exemption set is byte-identical
before and after; the import list it scans is what shrank. The annotation is
strictly tighter than the one it replaces: it is now pinned to
`splitBySeverity`'s declared shape, so if that function's `advisories` member
ever changes type this binding follows it instead of silently disagreeing --
the same "one list cannot drift from itself" idiom this card applied to the
member ORDER of `warningsSoFar()`, now applied to the list's TYPE.

Behaviour is unchanged: type-only edit, no emitted JS differs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019siH5jDmk5hrayvfyojUqR

Copy link
Copy Markdown
Contributor Author

CI repair: Test Core (2/6) — the hoisted advisory list is now typed off splitBySeverity

8c7bf124 → 6da672d43. One file, type-only, no behaviour change.

The red

packages/lint/src/authoring-rule-wiring.test.ts:291 — "os build imports no unratcheted symbol from @objectstack/lint":

AssertionError: compile.ts imports AuthoringFinding from @objectstack/lint directly.
  Register the rule in AUTHORING_RULES, or add the symbol to LINT_IMPORT_RATCHET with a reason.
  expected [ 'AuthoringFinding' ] to deeply equal []

Why this card tripped it

Hoisting the four advisory lists out of the try so every emitJson failure exit can read warningsSoFar() turned one inferred binding into a declared one. Before the hoist the list arrived destructured — const { errors: ruleErrors, advisories: ruleAdvisories } = splitBySeverity(findings) — so its type was inferred and no name was imported. A let declared ahead of its assignment needs a written type, and the first spelling reached for the underlying finding type, putting AuthoringFinding on compile.ts's import list. The #4409 import scan reads that list and had never been told about the symbol.

import type does not escape the scan, and that is deliberate — lintImportsIn() is

/import\s+(?:type\s+)?\{([^}]*)\}\s*from\s*['"]@objectstack\/lint['"]/g

and then strips a per-name type prefix, so the statement-level and inline modifiers are both seen. The guard asks which symbols this command file names, not which survive to runtime.

The fix

let ruleAdvisories: ReturnType<typeof splitBySeverity>['advisories'] = [];

splitBySeverity is already imported here and already carries a LINT_IMPORT_RATCHET entry ("Pure partition of a finding list into gating vs advisory. Carries no rule identity at all."), so no symbol is added and no exemption is widened. It resolves to exactly what the old annotation spelled — AuthoringFinding[]; the function is sync, so no Awaited is involved.

It is also the tighter statement, and the same idiom this PR already applies one level over: the member order of warningsSoFar() lives at one site so it cannot drift from itself; the list's type is now pinned to the function that fills it, so a change to splitBySeverity's advisories member propagates here instead of silently disagreeing.

Not a gate bypass

Nothing added to LINT_IMPORT_RATCHET or AUTHORING_RULES; no assertion in authoring-rule-wiring.test.ts touched; nothing skipped or relaxed. The exemption set is byte-identical before and after — what shrank is the import list being scanned.

Readings

Red reproduced locally on 8c7bf124 first (REPRO_EXIT=1), then re-run on the fix. All exit codes captured before any pipe.

check before after
pnpm --filter @objectstack/lint test Test Files 1 failed (1) · Tests 1 failed | 25 passed (26) Test Files 81 passed (81) · Tests 2294 passed (2294)
pnpm --filter @objectstack/cli test — Test Files 178 passed (178) · Tests 2025 passed (2025)
pnpm --filter @objectstack/cli typecheck (tsc --noEmit) — exit 0, no diagnostics
eslint --no-inline-config on compile.ts — "errorCount":0,"warningCount":0

The two structural pins in build-json-failure-warnings.e2e.test.ts (const warningsSoFar = () => [ and the spread-order regex) are untouched by this edit and passed: vitest list --filesOnly collects exactly 178 files including test/build-json-failure-warnings.e2e.test.ts, and all 178 passed.

Measured, untouched: packages/cli/src/utils/scaffold-validate.ts also imports type AuthoringFinding from @objectstack/lint and is green — the guard scans only packages/cli/src/commands/{validate,compile,lint}.ts, so that file is outside its reach. Not this card's business; flagging it only so the asymmetry isn't read as an oversight here.

One environment difference worth stating rather than smoothing over: CI's shard reported 2288 passed | 5 skipped, the local run 2294 passed with no skips — the 5 skipped upstream ran and passed locally.

Generated by Claude Code


Generated by Claude Code

@os-zhuang
os-zhuang marked this pull request as ready for review August 25, 2026 11:50
@os-zhuang
os-zhuang merged commit d114d5e into main Aug 25, 2026
35 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-11772-build-json-failure-warnings branch August 25, 2026 11:50
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
… so an honest new exit cannot red `main` at night (objectstack-ai#18966)

Fixes objectstack-ai#18894

Clause-②: no

## What this changes

`packages/cli/test/build-json-failure-warnings.e2e.test.ts` and
`packages/cli/test/build-json-failure-conversions.e2e.test.ts` each
froze three integers read
off `packages/cli/src/commands/compile.ts` — 11 payload literals, 10
`success: false`,
1 `success: true`. **All six were correct**, and that is the defect: the
*shape*, not a stale
number. One honest new exit — the very thing these two files exist to
enforce — turns all six
red, and because both files are nightly-tier by NAME they turn red only
at night, hours after
the PR that did it has merged. That is objectstack-ai#18848's `priority:p1` pre-staged
on `compile.ts`.

Ported from the shape PR objectstack-ai#18880 landed on the sibling
`validate-json-failure-*` pair:

- the count is **derived** — `toHaveLength(callSites.length)` against a
word-boundary
`emitJson` call-site match over **the same masked source** the payload
extractor reads;
- the old integer is demoted to a **floor**,
`toBeGreaterThanOrEqual(10)`, which rots only in
  the exits-are-REMOVED direction;
- one **partition** assertion (`success: true` ×1, `success: false` =
`literals.length - 1`)
  replaces the two frozen counts.

⛔ No production file is touched. The diff is those two test files and
nothing else —
`compile.ts` is card objectstack-ai#18431's carrier (branch `ccdc9f5389`) and is left
alone.

## Premises, re-measured on `origin/main` before a line was written

Re-derived with the tests' OWN extractor against the masked `compile.ts`
at
`f112a740bd` (`origin/main` at branch point), ⛔ not inherited from the
card:

```
payload literals          11      the pins' 11 was correct
success: false            10      correct
success: true              1      correct
/\bemitJson\s*\(/g        11      symmetric with the extractor
```

**Collection, re-measured in this worktree** (`vitest list --filesOnly`,
`packages/cli`):
`OS_TEST_TIERS` unset ⇒ 267 files, **0** of these two among them, lit
control
`artifact-packages.test.ts` present; `=nightly` ⇒ 68 files, **both**
present, lit control
absent. Both files sit in the `integration` project (`--project unit`
collects 0 of them).

## ⚠️ The trap this card names — the verdict key, MEASURED not copied

**`compile.ts` spells its verdict key `success:`. `validate.ts` spells
it `valid:`.** Measured
over the 11 masked payload literals of `compile.ts`:

| needle | count |
|:--|--:|
| `success: false` | 10 |
| `success: true` | 1 |
| `valid: false` | **0** |
| `valid: true` | **0** |

A verbatim port of objectstack-ai#18880's two partition lines would have asserted over
an **empty
population**. The lines here read `success:`, and the reading is
recorded in the code comment
beside them so the next author does not have to re-derive it.

## The symmetry the derived form needs — every `emitJson` occurrence
enumerated

The looser `/\bemitJson\s*\(/g` can only be trusted equal to the
extractor's
`await emitJson(` needle if nothing else in the file matches it.
Enumerated with left context
on `compile.ts`, **13 raw occurrences**:

| where | reading |
|:--|:--|
| `:43` | `emitJson,` — an **import** from `../utils/format.js`. No
paren ⇒ matches neither side. |
| `:104`, `:694` | prose inside comments — the shared `maskComments`
blanks both (objectstack-ai#18520, applied to these two files by objectstack-ai#18924) |
| the other 10 | `await emitJson(` call sites, plus the multi-line ones
⇒ 11 payload literals |

⭐ `emitJson` is **IMPORTED here, never declared** — there is no
`function emitJson(` in
`compile.ts` (grep: zero hits for `function emitJson` / `const
emitJson`), so the looser
pattern cannot over-count and red the pin by construction. Raw and
masked agree: 11 literals,
11 call sites, both ways. That agreement is a **reading, not a law**,
and the derived equality
is exactly where a future file that broke it would report.

## The floor's anchor — 10, and why not 11

objectstack-ai#18880 chose "the population the governing ruling was made over", not
today's count. The
equivalent anchor here is the commit that landed each file's ruling,
read out of git:

| file | ruling | landed by | count pinned that day |
|:--|:--|:--|--:|
| `build-json-failure-warnings.e2e.test.ts` | maintainer, 2026-08-25,
option 1 (objectstack-ai#11772) | `d114d5e140` (objectstack-ai#12079) | `toHaveLength(10)` — 9
failure + 1 success |
| `build-json-failure-conversions.e2e.test.ts` | the same 2026-08-25
ruling applied to `conversions` (objectstack-ai#12125) | `79cf692b0e` (objectstack-ai#12285) |
`toHaveLength(10)` — "all nine of its failure exits" + 1 |

Control for the anchor rule: the sibling
`validate-json-failure-warnings.e2e.test.ts` was
created at `33e81a5fe0` (objectstack-ai#12130) pinning `toHaveLength(6)`, and objectstack-ai#18880's
floor is `6`. Same
rule, same arithmetic. ⇒ **the floor here is 10.** Today's eleventh exit
is ADR-0130 D4's
per-package author-time rule leg (objectstack-ai#14439), which arrived *after* the
ruling — exactly what a
derived count absorbs and a frozen one trips over.

## Ablation battery — every leg's mutation proven on disk, every restore
proven by hash

Harness: a bash trap on EXIT, INT and TERM whose handler is the restore
function, absolute paths, restore is
`git checkout HEAD -- PATH` (never a bare `git checkout --`), verified
by comparing
`git hash-object` against the HEAD blob hash **and** by `git diff HEAD`
being empty. Each leg
prints the extractor's counts BEFORE and AFTER the mutation and a marker
grep, so a no-op leg
is refused rather than read as green. All runs `OS_TEST_TIERS=nightly`,
exit codes captured
before any pipe. No rebuild is needed or done: both files read
`compile.ts` from disk and the
CLI is loaded from `src/` through `bin/run-dev.js`, as each file's own
header states.

| leg | mutation to `compile.ts` | measured on disk after | THIS branch
| the frozen `11/10/1` |
|:--|:--|:--|:--|:--|
| 0 control | none | literals 11 · sites 11 | **GREEN** 7/7 | — |
| A | honest 12th exit carrying **both** keys | literals 12 · sites 12 |
**GREEN** 7/7 | **RED** — `expected [ …(12) ] to have a length of 11`
(both files) |
| B | 12th exit **missing** both keys | literals 12 · sites 12 | **RED**
— `an os build --json exit publishes no warnings` / `no conversions` | —
|
| C | 12th call site spelled **without `await`** | literals 11 · sites
**12** | **RED** — `an emitJson( call site the payload extractor could
not read: expected [ …(11) ] to have a length of 12` | **GREEN** 7/7 |
| D | two exits deleted (truncation) | literals 9 · sites 9 | **RED** —
`publishes fewer exits than the ruling above was made over: expected 9
to be greater than or equal to 10` | — |

⭐ **Leg C is the one that decides it.** A twelfth call site the payload
extractor cannot read
leaves the frozen integer **green** and reddens the derived equality.
The frozen form is not a
parallel option — it is strictly weaker. Leg A is the mirror: the honest
exit this contract
exists to welcome now stays green instead of reddening `main` at 20:44Z.

The "old" arms of A and C ran the pre-change files restored
worktree-only with
`git restore --source=f112a740bd -- PATH`, hashes printed and matched
against the BASE blobs,
then reset with `git checkout HEAD -- PATH`.

## `skip-changeset`, by measurement with controls both ways

`packages/cli` `files[]` is `["dist","README.md","CHANGELOG.md"]`;
`tsconfig.build.json` has
`rootDir: "src"`, `include: ["src"]` and an `exclude` that drops every
`*.test.ts`. Measured
against the freshly built `packages/cli/dist`:

| probe | direction | hits in `dist/` |
|:--|:--|--:|
| `an emitJson( call site the payload extractor could not read` | new,
this diff | **0** |
| `publishes fewer exits than the ruling above was made over` | new,
this diff | **0** |
| `ABLATION` | new, this diff | **0** |
| `access matrix drift` | positive control, lives in
`src/commands/compile.ts` | 1 |
| `warningsSoFar` | positive control | 2 |
| `runtime bundle failed` | positive control | 1 |
| `build-json-failure` | ⚠️ 1 hit — `dist/commands/compile.js:621`, a
**comment** compiled from `src/commands/compile.ts:698` that names the
test file. Not this diff's content; `compile.ts` is untouched. | 1 |

The positive controls prove the grep was reading `dist`. Nothing this
diff adds reaches any
published path from any released package ⇒ `skip-changeset`, label
applied on this PR.

## ⚠️ The adjacent axis — a POSITION, not a silent widening

Comment 5726117707 on the card asked for a judgement on the byte-exact
multi-line spread ORDER
pin, and warned its own probe was narrow. **Re-derived here with a
different predicate**
(`expect(SRC)` / `SRC.match` / `SRC.indexOf` across all four files),
which finds MORE than the
seat's word probe did — including one in
`validate-json-failure-conversions.e2e.test.ts`, where
the seat's probe scored 0 and recorded it as **未判**:

| file | line | what it binds byte-exactly |
|:--|--:|:--|
| `build-json-failure-warnings.e2e.test.ts` | `:536` `:537` | `const
warningsSoFar = () => [` , and the 4-member spread ORDER |
| `build-json-failure-conversions.e2e.test.ts` | `:450` | sink
declaration + blank line + `try {` adjacency |
| `validate-json-failure-warnings.e2e.test.ts` | `:571` `:572` | the
same two, 5 members |
| `validate-json-failure-conversions.e2e.test.ts` | `:486` | the same
adjacency ⇐ **the seat's 0 was 未判, not absent** |

**Judgement: named, measured, and left to the seat — deliberately NOT
done in this PR.**
Against the lane's standing three-rung ruling (claim 5725386686 on
objectstack-ai#18520):

- **Rung 1** — 「⛔ nightly-only 的 pin 永不绑一个逐 PR 兄弟已经绑着的字节精确拼写。」 Its
precondition is measured **FALSE**. Repo-wide, the only other reader of
those four list
identifiers is `build-json-advisory-parity.e2e.test.ts`, which is itself
**nightly-tier**
and compares with `new Set(...)` — order-blind by construction. No
per-PR sibling binds this
order behaviourally or textually. ⇒ rung 1 does not authorise deleting
it.
- **Rung 2** — 「⭐ nightly-only 的 pin 绑那条性质本身,从源码推导 —— 读出来的计数、分区、下限
—— ⛔ 永不绑一个写死的数。」 It governs frozen **numbers**. The order pin binds a
*spelling*.
⇒ rung 2 does not reach it; this PR discharges rung 2 for the counts,
which is this card.
- **Rung 3** — 「⭐ 一个读原始源码文本的读者,要么能把代码和散文分开,要么就不该对位置/顺序下
断言。」 Already satisfied: PR objectstack-ai#18924 put the shared `maskComments` in place
on both files. ⛔
  Not re-added here.

⇒ Under the ruling **as written and as measured**, the order pin in
these two files is
currently compliant and needs no change. What is *not* settled is a
rung-1 **extension**: may a
nightly-only pin bind a byte-exact spelling when **no** per-PR sibling
binds the property at
all? That is a new question, not an application of the existing three
rungs, and it sits
identically on the `validate-json-failure-*` pair, **which has no
carrier card**. Widening this
PR to answer it would convert half a family under a rule nobody has made
yet. Handed to the
`domain:cli` seat as a finding with dedupe words.

## Verification

Measured on `git rev-parse --short HEAD` = `0eb90c0a91`, in worktree
`/home/user/objectstack-issue-18894`, branched from `origin/main` at
`f112a740bd`.

| what | how | result |
|:--|:--|:--|
| the two files, BEFORE the edit | `OS_TEST_TIERS=nightly … --project
integration` on both files | **27 passed (27)** — the pins were correct,
as the card said |
| the two files, AFTER the edit | same | **27 passed (27)** |
| `packages/cli` WHOLE, default tier | `pnpm --filter @objectstack/cli
test` (bare `vitest run`, ⛔ no `--project`) | **267 files / 3485 tests
passed** |
| `packages/cli` WHOLE, nightly tier | `OS_TEST_TIERS=nightly pnpm
--filter @objectstack/cli test` | **68 files / 695 tests passed** — the
tier that actually collects these two files |
| typecheck | `pnpm --filter @objectstack/cli typecheck` | exit 0 (incl.
`check:test-typecheck`) |
| dependency closure + package build | `pnpm --filter
'@objectstack/cli^...' build` then `pnpm --filter @objectstack/cli
build` | exit 0 both — required, several pins refuse at load without it
|
| gate families | `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack`, then `--ran` with exit codes | 47 derived,
**46 run green**, **1 NOT MEASURED** |
| whole-repo lint | `pnpm lint` (`eslint . --no-inline-config`), NOT
narrowed | exit 0 |

**NOT MEASURED (1)**: `pnpm check:dual-build-cjs-loads` exits **3** —
`PREREQUISITE NOT MET —
this gate reads built output, and some package has no dist/`. It needs a
whole-repo `pnpm
build`; this worktree built only the `@objectstack/cli` closure. ⛔
Recorded as NOT MEASURED,
not as a pass and not as a red. CI's `Build Core` job builds the tree
and measures it there.
`dispatch-gates --ran` classified it from the recorded exit code rather
than from a claim.

Two derivation caveats stated rather than hidden: `dispatch-gates`
printed a **STALE TREE**
warning (`origin/main` moved 2 commits under this branch, touching
`scripts/check-adr-0087-registration.mjs` and
`scripts/check-single-claim-paths.mjs`); the
command list was re-derived after `git fetch origin main` and came back
**byte-identical**, so
the 47 stands. And the tool's own footer is quoted rather than
paraphrased: the 47 is not a
complete account of CI — 51 artifact-roster families, 11 wide-population
families, 13
pending-changeset families and 5 path-scheduled CI jobs are outside it
by construction.

## Acceptance notes

Noted, not filed — observations from the two files, none of them a
reproducible defect, a
contract violation, or a metadata-authoring trap:

- The `describe` blocks in both files are still titled with their
originating card numbers
(`objectstack-ai#11772`, `objectstack-ai#12125`) while the assertions inside them now answer objectstack-ai#18894
too. Cosmetic;
renaming a `describe` moves every `-t` filter that names it. Carrier:
whoever next opens
  these files for a behavioural change.
- `packages/cli/test/build-json-advisory-parity.e2e.test.ts` is
nightly-tier and is the only
other reader of the four advisory-list identifiers; its `new Set(...)`
comparisons are
deliberately order-blind, so the family has **no** order coverage in the
per-PR tier at all.
Recorded as the measurement behind the adjacent-axis position above, not
as a defect.
Carrier: the `domain:cli` seat, together with the rung-1 extension
question.

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

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

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

os build --json's failure payloads carry no warnings, so the truncation notice's --json pointer is a dead end when a later gate fails

2 participants