From 8b7537aad6799177063641004428c1bc6d0b3a70 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 23:15:53 +0000 Subject: [PATCH 1/5] fix(cli): os build's text face renders every author-time advisory its summary line counts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The advisory block was printed at step 3b, before step 3b-ii appended the per-package survivors to the same `ruleAdvisories` binding the summary line counts. On a multi-package stack that made `N author-time warning(s) — see above` point at a list strictly shorter than N. Claude-Session: https://claude.ai/code/session_01DvvamiacK328idtBYJBxV3 Co-authored-by: Claude --- packages/cli/src/commands/compile.ts | 73 ++++- .../build-text-face-advisory-count.test.ts | 296 ++++++++++++++++++ 2 files changed, 361 insertions(+), 8 deletions(-) create mode 100644 packages/cli/test/build-text-face-advisory-count.test.ts diff --git a/packages/cli/src/commands/compile.ts b/packages/cli/src/commands/compile.ts index eb05c5ea3d1..5d282be6807 100644 --- a/packages/cli/src/commands/compile.ts +++ b/packages/cli/src/commands/compile.ts @@ -172,6 +172,48 @@ export default class Compile extends Command { ...navGroupWarnings, ...permissionSetCollisionWarnings, ]; + // [#18780] ONE rendering of the author-time advisory block, from the + // COMPLETE list — hoisted here for the same reason the lists above are. + // + // The block used to be printed inline at step 3b, BEFORE step 3b-ii + // appended the per-package survivors to `ruleAdvisories`. So on a + // multi-package stack this command's summary line counted a set strictly + // larger than the one it pointed at. Measured on + // `examples/app-multi-package` at 17.4.0: `⚠ 4 author-time warning(s) — + // see above` standing over a list of THREE, while `--json` carried all + // four and `os validate` — which renders its advisory list once, at the + // end, after the same per-package append — printed all four (#18769). The + // reader is sent back up to find a warning that was never printed, and the + // direction reads as "I must have missed it". + // + // ⛔ THE COUNT IS NOT THE SIDE THAT MOVES. #11529 settled that axis one + // list over: the summary counts the whole set and the PRINTER names what + // it withheld, because a count quietly shrunk to match a short list is the + // false-clean direction — it deletes a finding from the text face of the + // command that ships, while `--json` and `os validate` keep reporting it. + // So the list grows to the count. + // + // ⛔ AND IT STAYS ONE PRINTER CALL. A second `printAuthoringAdvisories` + // for the survivors alone would hand the 50-entry cap a second budget and + // its truncation notice a second, partial total — two locally-honest + // notices for one list, which is #11529's defect wearing its own fix. + // + // Deferring the call is what the guard below is for: every text face that + // used to be DOWNSTREAM of the old inline site flushes the block itself, + // so both author-time rule failures still print their advisories ahead of + // their error list, and the catch-all still prints them when a rule throws + // inside the per-package pass — the one window between the two sites. + let advisoriesPrinted = false; + const printAdvisoriesOnce = (): void => { + if (advisoriesPrinted || flags.json || ruleAdvisories.length === 0) return; + advisoriesPrinted = true; + console.log(''); + // #11529 — rendered by ONE printer, which also names the remainder when + // the list is cut. The loop used to sit inline here and stop dead at 50 + // with no notice, so a truncated report read exactly like a complete + // one. See `printAuthoringAdvisories` for the measurement. + printAuthoringAdvisories(ruleAdvisories); + }; // [#12125] The ADR-0087 D2 conversion notices, hoisted for the SAME reason // and under the SAME ruling as the four lists above — one field over. The // notices were computed at step 2 (below) and reached the terminal SUCCESS @@ -366,14 +408,6 @@ export default class Compile extends Command { const { errors: ruleErrors, advisories } = splitBySeverity(findings); ruleAdvisories = advisories; - if (ruleAdvisories.length > 0 && !flags.json) { - console.log(''); - // #11529 — rendered by ONE printer, which also names the remainder when - // the list is cut. The loop used to sit inline here and stop dead at 50 - // with no notice, so a truncated report read exactly like a complete - // one. See `printAuthoringAdvisories` for the measurement. - printAuthoringAdvisories(ruleAdvisories); - } if (ruleErrors.length > 0) { // Every failing rule reports at once — see the note in `validate.ts`. if (flags.json) { @@ -384,6 +418,11 @@ export default class Compile extends Command { ); this.exit(1); } + // [#18780] This exit is UPSTREAM of the per-package append below — a + // union-level `error` refuses before that pass runs — so the list + // flushed here is the union's alone, byte-for-byte what this face + // printed when the call sat inline above. + printAdvisoriesOnce(); console.log(''); printError(`Author-time rules failed (${ruleErrors.length} issue${ruleErrors.length > 1 ? 's' : ''})`); // [#11642] `--json` on this same exit publishes every one of them as @@ -465,6 +504,10 @@ export default class Compile extends Command { ); this.exit(1); } + // [#18780] Downstream of the append, so this flush carries the + // per-package advisories too — the same list `warningsSoFar()` has + // published on this exit's `--json` twin since #11772. + printAdvisoriesOnce(); console.log(''); printError( `Author-time rules failed inside the artifact's packages (${perPackageErrors.length} issue${perPackageErrors.length > 1 ? 's' : ''})`, @@ -473,6 +516,12 @@ export default class Compile extends Command { this.exit(1); } } + // [#18780] The continuing path — and the only one the summary line at + // the foot of this command is reachable from. `ruleAdvisories` is + // complete here on BOTH shapes: a stack with `packages[]` has just had + // the survivors appended, and one without skips the block entirely and + // arrives with the union list the summary already counted. + printAdvisoriesOnce(); // 3b-bis. [#14553] Navigation contributions whose `group` names no group // in the target app. RUNS ON EVERY BUILD, artifact or not — the block @@ -946,6 +995,14 @@ export default class Compile extends Command { await emitJson({ success: false, error: error.message, ...errorCodeFields(error), warnings: warningsSoFar(), conversions: conversionNotices }, 0, { compact: true }); this.exit(1); } + // [#18780] The one window the three flushes above do not cover: a throw + // between step 3b's split and step 3b-ii's append — a rule throwing + // inside the per-package pass. The inline call this replaced had already + // rendered the union list by then, so flushing here keeps that path's + // output rather than shortening it. Every other throw on this face is + // downstream of a flush and the guard makes this a no-op; a throw + // upstream of step 3b finds the list empty and renders nothing. + printAdvisoriesOnce(); // [#15547] `resolveConfigPath()` already wrote its refusal and hint lines // to stderr before throwing, so this face has nothing left to render — // and `this.error()` below is NOT a no-op for it: it re-renders the same diff --git a/packages/cli/test/build-text-face-advisory-count.test.ts b/packages/cli/test/build-text-face-advisory-count.test.ts new file mode 100644 index 00000000000..0659a26e31f --- /dev/null +++ b/packages/cli/test/build-text-face-advisory-count.test.ts @@ -0,0 +1,296 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #18780 — `os build`'s TEXT face counted per-package advisories it never + * printed: `⚠ 4 author-time warning(s) — see above` standing over a list of 3. + * + * `compile.ts` rendered the advisory block at step 3b, inline, straight off the + * union rule run. Step 3b-ii then appended the per-package survivors to the + * SAME `ruleAdvisories` binding, and the summary line at the foot of the + * command counts that binding. So on a multi-package stack the count was the + * complete set and the list was the union's alone, and the sentence pointing at + * it — "see above" — sent the reader back up to find a warning that had never + * been printed. Measured on `examples/app-multi-package` at 17.4.0, exit 0: + * + * os build 3 advisory entries · `⚠ 4 author-time warning(s)` + * os build --json warnings: 4 <- the count was right + * os validate 4 advisory entries (#18769) + * + * The direction matters: the count was RIGHT and the list was SHORT, which + * reads as "I must have missed it" rather than as a defect in the tool. + * + * ## WHAT THESE PINS ASSERT — an equality, not a number + * + * Not "four warnings printed". The three numbers are read from ONE run of the + * command and compared to each other: the integer in the summary line, the + * count of advisory entries actually rendered above it, and the length of the + * `--json` payload's `warnings`. A future fixture that raises a different + * number of advisories keeps passing; a face that counts a set it did not print + * cannot. That is the invariant the sentence "see above" states. + * + * ⚠️ The cap is the one legal gap between the count and the list — #11529 kept + * the 50-entry cap and made the printer NAME the remainder. So the equality is + * asserted together with the absence of that notice: on a fixture this small + * the two numbers must agree outright, and if a future change ever truncates + * here the notice's absence fails first rather than the equality silently + * meaning something else. + * + * ## The lit control + * + * The multi-package fixture is asserted to REACH the per-package pass and to + * raise at least one finding there, before any equality is read — a fixture + * that raises none makes the equality hold for the reason the defect survived + * in the first place. The single-package control shows the opposite end: the + * per-package prefix is not something the command emits unconditionally, and + * the equality holds there on a shape that never had the defect. + * + * ## Tier + * + * SPAWNS the CLI ⇒ INTEGRATION tier by `packages/cli/vitest-tiers.ts`' + * predicate (`childProcess` + `helperCliOrTsx`). No `.e2e` segment in the name, + * so by the orthogonal NIGHTLY cut it is a QUEUE-tier file — the combination + * `validate-per-package-authoring-parity.test.ts` already tiers as deliberate. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { execFile } from 'node:child_process'; +import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { CLI, TSX, childEnv } from './helpers/serve-process.js'; + +interface Run { + code: number; + stdout: string; + stderr: string; +} + +function runCli(args: string[], cwd: string): Promise { + return new Promise((resolvePromise) => { + execFile( + TSX, + [CLI, ...args], + { cwd, maxBuffer: 16 * 1024 * 1024, env: childEnv({ NO_COLOR: '1' }) }, + (err, stdout, stderr) => { + resolvePromise({ + code: err ? (typeof (err as { code?: unknown }).code === 'number' ? (err as unknown as { code: number }).code : 1) : 0, + stdout: String(stdout), + stderr: String(stderr), + }); + }, + ); + }); +} + +function payloadOf(run: Run, label: string): Record { + try { + return JSON.parse(run.stdout) as Record; + } catch { + throw new Error(`${label}: stdout was not one JSON document (exit ${run.code})\n${run.stdout}\n${run.stderr}`); + } +} + +/** Drop SGR sequences so an assertion reads the words, not chalk's opinion. */ +const stripAnsi = (s: string) => s.replace(/\[[0-9;]*m/g, ''); + +/** + * The summary line's integer — the number the command claims is "above". + * Anchored on the whole sentence, not on the digits, so a different `… (s)` + * tally elsewhere in the output cannot be mistaken for it. + */ +const SUMMARY_LINE = /(\d+) author-time warning\(s\) — see above/; + +/** + * How many advisory entries were actually RENDERED. `printAuthoringAdvisories` + * closes every entry with a four-space `rule: at ` line; + * `printAuthoringRuleErrors` — the GATING printer — indents its own by six, so + * the exact indent is what tells an advisory from an error here rather than a + * substring of either one's prose. + */ +const renderedAdvisoryCount = (out: string): number => + stripAnsi(out).split('\n').filter((l) => /^ {4}rule: /.test(l)).length; + +/** #11529's truncation notice — the one legal reason the two numbers may differ. */ +const TRUNCATION_NOTICE = /and \d+ more author-time warning\(s\) not shown/; + +/** The `where` prefix `runPerPackageAuthoringRules` puts on every finding it raises. */ +const PER_PACKAGE_WHERE = /^package '[^']+' — /; + +const perPackageWarnings = (warnings: unknown[]): string[] => + warnings + .filter((w): w is { where: string } => typeof (w as { where?: unknown })?.where === 'string') + .map((w) => w.where) + .filter((where) => PER_PACKAGE_WHERE.test(where)); + +/** + * Two packages sharing a namespace, mirroring `examples/app-multi-package`: an + * App package owning `bc_account` and publishing the navigation container, plus + * a module owning `bc_order` whose `account` lookup points at the sibling's + * object — legal under ADR-0130 §1.5, and what makes the per-package pass + * produce a survivor at all. + */ +const CONFIG_MULTI = ` +const coreManifest = { + id: 'com.example.bcount.core', name: 'bcount core', namespace: 'bc', + version: '1.0.0', type: 'app', engines: { protocol: '^17' }, +}; +const coreObjects = [{ + name: 'bc_account', label: 'Account', pluralLabel: 'Accounts', sharingModel: 'private', + fields: { + name: { name: 'name', type: 'text', label: 'Account Name', required: true }, + industry: { name: 'industry', type: 'text', label: 'Industry' }, + }, +}]; +const coreApps = [{ + name: 'bc_crm', label: 'BC CRM', + navigation: [{ + id: 'sales_group', type: 'group', label: 'Sales', + children: [{ id: 'nav_accounts', type: 'object', objectName: 'bc_account', label: 'Accounts' }], + }], +}]; + +const ordersManifest = { + id: 'com.example.bcount.orders', name: 'bcount orders', namespace: 'bc', + version: '1.0.0', type: 'module', engines: { protocol: '^17' }, + dependencies: { 'com.example.bcount.core': '^1.0.0' }, +}; +const ordersObjects = [{ + name: 'bc_order', label: 'Order', pluralLabel: 'Orders', sharingModel: 'private', + fields: { + name: { name: 'name', type: 'text', label: 'Order Number', required: true }, + account: { name: 'account', type: 'lookup', label: 'Account', reference: 'bc_account' }, + }, +}]; + +export default { + manifest: coreManifest, + objects: [...ordersObjects, ...coreObjects], + apps: [...coreApps], + packages: [ + { manifest: { ...ordersManifest, objects: ordersObjects } }, + { manifest: { ...coreManifest, objects: coreObjects, apps: coreApps } }, + ], +}; +`; + +/** + * The CONTROL: one package, no `packages[]`. `artifactPackages` returns `[]`, + * the per-package pass is skipped, and the count has always equalled the list — + * which is why every existing text-face assertion in this package stayed green + * through the defect. + */ +const CONFIG_SINGLE = ` +export default { + manifest: { + id: 'com.example.bcsingle', name: 'bcsingle', namespace: 'bs', + version: '1.0.0', type: 'app', engines: { protocol: '^17' }, + }, + objects: [{ + name: 'bs_thing', label: 'Thing', pluralLabel: 'Things', sharingModel: 'private', + fields: { + name: { name: 'name', type: 'text', label: 'Name', required: true }, + unused: { name: 'unused', type: 'text', label: 'Unused' }, + }, + }], + apps: [{ + name: 'bs_app', label: 'BS App', + navigation: [{ id: 'nav_things', type: 'object', objectName: 'bs_thing', label: 'Things' }], + }], +}; +`; + +const dirs = { multi: '', single: '' }; + +function plant(config: string): string { + const dir = mkdtempSync(join(tmpdir(), 'os-bcount-')); + mkdirSync(join(dir, 'src'), { recursive: true }); + writeFileSync(join(dir, 'objectstack.config.ts'), config, 'utf8'); + writeFileSync( + join(dir, 'package.json'), + JSON.stringify({ name: 'bcount-fixture', private: true, type: 'module' }, null, 2), + 'utf8', + ); + return dir; +} + +describe("#18780 — `os build`'s text face prints every advisory its summary line counts", () => { + let multiText: Run; + let multiJson: Run; + + beforeAll(async () => { + dirs.multi = plant(CONFIG_MULTI); + dirs.single = plant(CONFIG_SINGLE); + multiText = await runCli(['build'], dirs.multi); + multiJson = await runCli(['build', '--json'], dirs.multi); + }, 180_000); + + afterAll(() => { + for (const dir of Object.values(dirs)) if (dir) rmSync(dir, { recursive: true, force: true }); + }); + + it('the fixture reaches the per-package pass and raises a survivor there', () => { + // Asserted BEFORE any equality is read. A fixture that never reaches the + // pass makes every assertion below hold for the reason the defect survived + // the existing parity file: both sides agreeing about an empty set. + expect(multiText.code, `${multiText.stdout}\n${multiText.stderr}`).toBe(0); + expect(multiText.stdout).toContain('Running author-time rules per package (2)'); + expect(multiJson.code, `${multiJson.stdout}\n${multiJson.stderr}`).toBe(0); + const perPackage = perPackageWarnings(payloadOf(multiJson, 'os build --json').warnings as unknown[]); + expect(perPackage.length).toBeGreaterThan(0); + }); + + it('the summary line counts exactly what the list above it renders', () => { + // THE PIN. On `origin/main` ad1f94e8ec this read `summary 4, rendered 3`. + const out = stripAnsi(multiText.stdout); + const summary = out.match(SUMMARY_LINE); + expect(summary, `no summary line in:\n${out}`).not.toBeNull(); + + // The cap is the one legal gap between the two, and it announces itself. + // Asserting its absence first is what lets the equality below be read as + // "the list is complete" rather than "the list is capped at some number". + expect(out).not.toMatch(TRUNCATION_NOTICE); + + expect(renderedAdvisoryCount(out), `summary said ${summary?.[1]}; rendered list:\n${out}`) + .toBe(Number(summary?.[1])); + }); + + it('…and the number it counts is the whole set the machine face publishes', () => { + // The third side of the triangle. Without it the two faces could agree with + // each other while both under-reporting what the run actually found — which + // is the shape #11727 measured one face over. + const out = stripAnsi(multiText.stdout); + const summary = out.match(SUMMARY_LINE); + const payloadWarnings = payloadOf(multiJson, 'os build --json').warnings as unknown[]; + const advisories = payloadWarnings.filter( + (w) => typeof (w as { where?: unknown })?.where === 'string', + ); + expect(Number(summary?.[1])).toBe(advisories.length); + }); + + it('the per-package survivor is one of the entries actually rendered', () => { + // The equality above is a count; this names the member. Without it a face + // that printed the union list twice would satisfy the arithmetic. + const out = stripAnsi(multiText.stdout); + const perPackage = perPackageWarnings(payloadOf(multiJson, 'os build --json').warnings as unknown[]); + for (const where of perPackage) { + expect(out, `this per-package finding rides \`--json\` and the text face never prints it:\n${where}`) + .toContain(where); + } + }); + + it('CONTROL — a single-package project renders no per-package entry, and still balances', () => { + // "Present" must be distinguishable from "always present". This shape never + // carried the defect, so it pins the other end: the prefix is not emitted + // unconditionally, and the equality is not a tautology of the assertion. + return runCli(['build'], dirs.single).then((run) => { + expect(run.code, `${run.stdout}\n${run.stderr}`).toBe(0); + const out = stripAnsi(run.stdout); + expect(out).not.toContain('Running author-time rules per package'); + expect(out.split('\n').some((l) => PER_PACKAGE_WHERE.test(l.replace(/^\s*⚠ /, '')))).toBe(false); + const summary = out.match(SUMMARY_LINE); + expect(summary, `no summary line in:\n${out}`).not.toBeNull(); + expect(out).not.toMatch(TRUNCATION_NOTICE); + expect(renderedAdvisoryCount(out)).toBe(Number(summary?.[1])); + }); + }, 180_000); +}); From 8c0a2081cfacc217f514eb6b48f1ea9275082acf Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 23:20:33 +0000 Subject: [PATCH 2/5] chore(changeset): os build text-face advisory count Claude-Session: https://claude.ai/code/session_01DvvamiacK328idtBYJBxV3 Co-authored-by: Claude --- .../18780-build-text-face-advisory-count.md | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 .changeset/18780-build-text-face-advisory-count.md diff --git a/.changeset/18780-build-text-face-advisory-count.md b/.changeset/18780-build-text-face-advisory-count.md new file mode 100644 index 00000000000..403b84c95b1 --- /dev/null +++ b/.changeset/18780-build-text-face-advisory-count.md @@ -0,0 +1,24 @@ +--- +"@objectstack/cli": patch +--- + +`os build`'s text face prints every author-time advisory its own summary line counts — the closing `N author-time warning(s) — see above` no longer stands over a shorter list (#18780). + +Clause-②: no + +`compile.ts` rendered the advisory block at step 3b, inline, straight off the union rule run. Step 3b-ii — the ADR-0130 D4 pass that runs the same rule table once per `packages[]` entry — then appended its survivors to the **same** `ruleAdvisories` binding, and the summary line at the foot of the command counts that binding. So on a multi-package project the count was the complete set and the printed list was the union's alone, and the sentence pointing at it sent the reader back up to find a warning that had never been printed. + +Measured at 17.4.0 on `examples/app-multi-package`, exit 0 on every face: + +``` +os build 3 advisory entries · ⚠ 4 author-time warning(s) — see above +os build --json warnings: 4 <- the count was already right +os validate 4 advisory entries <- since #18769 +``` + +- **The list moves, not the count.** #11529 settled this axis one list over: the summary counts the whole set and the printer NAMES what it withheld, because a count quietly shrunk to match a short list is the false-clean direction — it deletes a finding from the text face of the command that ships while `--json` and `os validate` keep reporting it. The fourth advisory now prints. +- **What an author sees change**: on a stack that declares `packages[]`, the advisory block is rendered after the `Running author-time rules per package (N)...` step line instead of before it, and it now carries the per-package findings — the ones whose `where` reads `package '' — …`. A stack with no `packages[]` is unchanged — measured on a single-package fixture, the before/after captures are 2038 bytes each and differ only in the `Build complete (Nms)` timer: its list was already complete, and the block still precedes every later step line. +- **Still ONE printer call.** The block is deferred to the point where the list is complete rather than printed twice, so the 50-entry cap and its `… and N more … not shown` notice keep judging one list. A second `printAuthoringAdvisories` for the survivors alone would have given the cap a second budget and the notice a second, partial total. +- **The author-time rule FAILURE faces keep their advisories.** A union-level failure exits before the per-package pass runs, so its block is byte-for-byte what it was; the per-package failure face now prints the per-package advisories too, which its own `--json` twin has published since #11772. + +No payload key, no exit code and no `--json` byte moves: `warnings` already carried all four, which is how the mismatch was measurable in the first place. From a6cc068a0907d172e1debcf04032732ea9e7c986 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 23:52:33 +0000 Subject: [PATCH 3/5] fix(cli): spell the pin's SGR escape as an escape, not a raw control byte `check:nul-bytes` rejects every raw ASCII control byte in a tracked text file. The pin's `stripAnsi` carried a literal ESC inside its regex literal, which renders as nothing in every reader and is precisely the class that gate exists to reject. The escape spelling is byte-identical at runtime. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01DvvamiacK328idtBYJBxV3 --- packages/cli/test/build-text-face-advisory-count.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/test/build-text-face-advisory-count.test.ts b/packages/cli/test/build-text-face-advisory-count.test.ts index 0659a26e31f..04074622ff6 100644 --- a/packages/cli/test/build-text-face-advisory-count.test.ts +++ b/packages/cli/test/build-text-face-advisory-count.test.ts @@ -91,7 +91,7 @@ function payloadOf(run: Run, label: string): Record { } /** Drop SGR sequences so an assertion reads the words, not chalk's opinion. */ -const stripAnsi = (s: string) => s.replace(/\[[0-9;]*m/g, ''); +const stripAnsi = (s: string) => s.replace(/\u001B\[[0-9;]*m/g, ''); /** * The summary line's integer — the number the command claims is "above". From 768e9feb5fb9481cd1fbc00a7f586f5913b7bb38 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 18 Sep 2026 00:02:54 +0000 Subject: [PATCH 4/5] fix(cli): classify the advisory once-guard in the closed gate-parity roster MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `validate-build-gate-parity.test.ts` has held a CLOSED roster since #18491: every bare-identifier call site in `compile.ts` and `validate.ts` must land in exactly one of `SHARED_NON_REGISTRY_GATES`, `BUILD_ONLY_GATES` or `NOT_A_GATE`. The once-guard introduced for the text-face fix was a new call site in `compile.ts` and was classified nowhere, so the file reddened twice — once on "every call site is classified", once on the parity gap it derives from the same set. Measured, not inferred: the roster and the classification assertion both predate this branch (present at its merge-base), and the name is absent from the roster there, so this red was carried by the two commits this branch started from rather than introduced by merging `main`. The guard is presentation: it decides WHEN `printAuthoringAdvisories` is called and nothing else. Every finding it renders is produced by `runAuthoringRules` and `runPerPackageAuthoringRules`, both already classified as gates, and the same list rides `--json` whether it runs or not — so it is a `NOT_A_GATE` row under the presentation reason, not a `BUILD_ONLY_GATES` row: `validate.ts` has nothing to wire, having no "see above" sentence to keep honest. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01DvvamiacK328idtBYJBxV3 --- .../cli/test/validate-build-gate-parity.test.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/packages/cli/test/validate-build-gate-parity.test.ts b/packages/cli/test/validate-build-gate-parity.test.ts index 16050b4e64d..6b77b8565b5 100644 --- a/packages/cli/test/validate-build-gate-parity.test.ts +++ b/packages/cli/test/validate-build-gate-parity.test.ts @@ -187,6 +187,19 @@ const NOT_A_GATE: Readonly> = { 'printWarning', 'printBulletList', 'printAuthoringAdvisories', + // [#18780] `compile.ts`' local once-guard around the line above it. It + // decides WHEN that printer is called — after the per-package pass has + // appended its survivors, so the closing `N author-time warning(s) — see + // above` stands over the list it counts — and nothing else. It reads no + // stack, reaches no verdict and can refuse nothing: every finding it + // renders was already produced by `runAuthoringRules` and + // `runPerPackageAuthoringRules`, both classified as gates above, and the + // identical list rides `--json` on every face whether this helper runs + // or not. ⛔ NOT a BUILD_ONLY_GATES row: there is nothing here for + // `validate.ts` to wire by hand, because that command already renders + // its advisory list once, at the end, and has no "see above" sentence to + // keep honest. + 'printAdvisoriesOnce', 'printAuthoringRuleErrors', 'printDocIssueErrors', 'printMetadataStats', From c2562dac95ed642891f2c91c1ac02f9ec3b9479d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 18 Sep 2026 00:25:43 +0000 Subject: [PATCH 5/5] docs(changeset): name both clocks the single-package capture differs in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The unchanged-face claim was measured again from a committed tree, with compile.ts reverted to its pre-fix blob for the before leg and the restore proven by hash. The two captures are 2038 bytes each, as recorded — but they differ in TWO fields, not one: `Build complete (Nms)` and `Load time: Nms`. Naming one of them made the sentence read as a stricter measurement than the one that was taken, and a reader reproducing it would have found a second difference the text does not account for. Both are clocks, so the claim the bullet makes is unchanged: on a stack with no `packages[]` this fix moves no rendered byte. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01DvvamiacK328idtBYJBxV3 --- .changeset/18780-build-text-face-advisory-count.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/18780-build-text-face-advisory-count.md b/.changeset/18780-build-text-face-advisory-count.md index 403b84c95b1..28c01f2ca7b 100644 --- a/.changeset/18780-build-text-face-advisory-count.md +++ b/.changeset/18780-build-text-face-advisory-count.md @@ -17,7 +17,7 @@ os validate 4 advisory entries <- since #18769 ``` - **The list moves, not the count.** #11529 settled this axis one list over: the summary counts the whole set and the printer NAMES what it withheld, because a count quietly shrunk to match a short list is the false-clean direction — it deletes a finding from the text face of the command that ships while `--json` and `os validate` keep reporting it. The fourth advisory now prints. -- **What an author sees change**: on a stack that declares `packages[]`, the advisory block is rendered after the `Running author-time rules per package (N)...` step line instead of before it, and it now carries the per-package findings — the ones whose `where` reads `package '' — …`. A stack with no `packages[]` is unchanged — measured on a single-package fixture, the before/after captures are 2038 bytes each and differ only in the `Build complete (Nms)` timer: its list was already complete, and the block still precedes every later step line. +- **What an author sees change**: on a stack that declares `packages[]`, the advisory block is rendered after the `Running author-time rules per package (N)...` step line instead of before it, and it now carries the per-package findings — the ones whose `where` reads `package '' — …`. A stack with no `packages[]` is unchanged — measured on a single-package fixture, the before/after captures are 2038 bytes each and differ only in the run's two clocks, `Load time: Nms` and `Build complete (Nms)`: its list was already complete, and the block still precedes every later step line. - **Still ONE printer call.** The block is deferred to the point where the list is complete rather than printed twice, so the 50-entry cap and its `… and N more … not shown` notice keep judging one list. A second `printAuthoringAdvisories` for the survivors alone would have given the cap a second budget and the notice a second, partial total. - **The author-time rule FAILURE faces keep their advisories.** A union-level failure exits before the per-package pass runs, so its block is byte-for-byte what it was; the per-package failure face now prints the per-package advisories too, which its own `--json` twin has published since #11772.