Skip to content

fix(cli): os lint --eval --json stops leaking esbuild's diagnostics to stderr, and the pin now covers every door - #16855

Merged
os-project-manager merged 2 commits into
mainfrom
claude/issue-16358-lint-json-stderr-leak
Sep 8, 2026
Merged

fix(cli): os lint --eval --json stops leaking esbuild's diagnostics to stderr, and the pin now covers every door#16855
os-project-manager merged 2 commits into
mainfrom
claude/issue-16358-lint-json-stderr-leak

Conversation

@claude

@claude claude Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Fixes #16358

Clause-②: no

os lint --eval --json --generator UNRESOLVABLE_PATH wrote esbuild's own [ERROR] Could not resolve … line to stderr while its --json document on stdout was already well formed. The emission comes from esbuild's logger inside the bundleRequire call that loads the generator — before anything throws — so the surrounding catch, which does produce the correct one-key {error} document, never gets a chance to suppress it.

The bytes are not the point. One file over, packages/cli/test/lint-eval-generator-load-envelope.e2e.test.ts:164 pins the opposite property — expect(run.stderr).toBe('') under the comment "A --json run leaks nothing to the human channel" — and it is green. It is honest about the one door it drives (a module that exists and throws at import, where esbuild bundles cleanly and prints nothing); the other doors into the same catch were uncovered, and they leak. Same command, same face, two answers, with a green pin asserting the one that holds. Closing that coverage gap is as much of this PR as the silence is.

The two legs, re-driven before editing

⚠️ The card records its measurements as the implementer's reading at 4a1a3b0c254 / 26bc91fc8e7, not independently re-driven by the filing seat. Both were re-driven here at the branch point, origin/main 7f96e1417e, through packages/cli/bin/run-dev.js with NO_COLOR=1 — the same source entry the pin file uses.

Leg (a) — the stderr emission is real.

$ os lint --eval --json --generator /tmp/os16358/nope.mjs
exit 1 · stdout 143 B · stderr 55 B
stderr: b'\xe2\x9c\x98 [ERROR] Could not resolve "/tmp/os16358/nope.mjs"\n\n'
stdout: {"error":"Failed to load generator \"/tmp/os16358/nope.mjs\": Build failed with 1 error:\nerror: Could not resolve \"/tmp/os16358/nope.mjs\""}

The emission is ✘ [ERROR] Could not resolve "PATH" followed by two newlines — 34 fixed bytes plus the path, so the card's 54 is this same line with a 20-character path and the 55 here is a 21-character one. The count tracks the path, the line does not change.

Leg (b) — the sibling pin is green at the same head, while (a) holds.

$ OS_TEST_TIERS=nightly pnpm --filter @objectstack/cli exec vitest run \
    --maxWorkers=2 test/lint-eval-generator-load-envelope.e2e.test.ts
 Test Files  1 passed (1)
      Tests  7 passed (7)

⭐ The two together are the finding. *.e2e.test.ts is the nightly tier (scripts/nightly-tiers.mjs), so the file needs OS_TEST_TIERS=nightly to be collected at all; under the default queue setting it is excluded from both projects and a plain run reports No test files found.

A third door the re-run found — the leak is not confined to the error branch

Driven at the same head with a generator that bundles and loads successfully and merely makes esbuild warn:

$ os lint --eval --json --generator /tmp/os16358/warn.mjs
exit 0 · stdout 3158 B (the full live eval report, mode: live) · stderr 340 B
stderr: ▲ [WARNING] The "typeof" operator will never evaluate to "null" [impossible-typeof] …

Nothing throws on this path at all, so no catch was ever involved and no error handling could have been blamed. It is the same defect — the machine face carrying human-channel output from the same call — so it is repaired and pinned here rather than filed as a separate card.

The repair, and why this form

The card left the form open — a logLevel, a custom logger, or captured stderr, "not settled by existing evidence in this repo". This PR takes logLevel, passed through bundleRequire's esbuildOptions, at that one call site, and only when --json is set:

...(flags.json ? { esbuildOptions: { logLevel: 'silent' as const } } : {}),
  • A custom logger is not available. esbuild's JS API exposes no logger hook to install; its entire logging surface is logLevel plus the errors / warnings arrays on the result (or on the thrown BuildFailure). There is nothing to hand it.
  • Capturing stderr was rejected. Wrapping process.stderr.write around an await is a process-global monkey-patch that swallows any concurrent write, not only esbuild's, and has to be unwound on every exit path from the try. It trades a 55-byte leak for a global mutation.
  • esbuildOptions survives. bundle-require@5.1.0 spreads the caller's esbuildOptions first and then overrides entryPoints, format, bundle, plugins and friends; logLevel is in none of the overridden keys, so it reaches esbuild intact.

What this does and does not suppress — stated, not shipped quietly

  • The refusal is untouched. logLevel governs whether esbuild prints, not whether it throws. The BuildFailure still arrives with errors populated, and that text is already the tail of the {error} string the catch builds. Both stdout documents above are byte-identical before and after.
  • The human face is untouched, by construction. Without --json no esbuildOptions is passed at all, so bundle-require's own esbuild defaults apply exactly as before — rather than me writing out a default I would then own. Measured: os lint --eval --generator /tmp/os16358/nope.mjs is byte-identical on both channels before and after (diff reports no difference on stdout or stderr).
  • ⚠️ What it does suppress beyond the 55 bytes: under --json, an esbuild warning on a generator that loads fine (the third door above) reached stderr before and now reaches nothing. A warning is not thrown, so no handler carries it onto stdout. That is inside the defect rather than beyond it — the property the sibling pin's comment states is about the --json face as a whole — but a --json consumer that was reading stderr for bundler warnings will no longer see them. It is called out in the changeset as well.
  • Scope is this one call site. bundleRequire appears at four sites in packages/cli/src; this one was located by symbol, not assumed. utils/config.ts:271, utils/scaffold-validate.ts:123 and commands/serve.ts:2283 are untouched and keep their diagnostics. A global esbuild silence would trade one under-read for a larger one.

The pins

Two cases added to the existing file, in the same shape the existing pin uses. ⛔ The existing pin is not weakened, rewritten or moved — the ablation below shows all 7 pre-existing assertions staying green while only the 2 new ones go red.

  • unresolvable pathexpect(run.stderr).toBe(''), and on the same run the negative control: exit 1, Object.keys(payload) exactly ['error'], the message naming both Failed to load generator and Could not resolve. A repair that swallowed the throw along with the logger satisfies the first assertion and fails every one after it.
  • warning-onlyexpect(machine.stderr).toBe('') plus payload.mode === 'live' (proof the module really was loaded, so the empty stderr is not measuring a run that never bundled), and then the scope control: the same fixture on the human face must still show [WARNING]. That second leg is what stops the first from going vacuous — if a future esbuild stopped emitting impossible-typeof, an stderr === '' assertion alone would stay green while measuring nothing, and the human-face leg reddens instead of hiding it. It also pins that the silence does not reach the face that asked for human output.

Verification

All at 9d220b69c2 unless noted. Exit codes captured before any pipe.

what result
pnpm --filter '@objectstack/cli^...' build (dependency closure) exit 0
OS_TEST_TIERS=nightly … vitest run test/lint-eval-generator-load-envelope.e2e.test.ts 9 passed (9) — was 7 before, so the 2 new cases really ran
pnpm --filter @objectstack/cli typecheck exit 0 (tsc --noEmit + check:test-typecheck; the edited test file is in tsconfig.test.json's program — confirmed with --listFiles, 1 hit, 0 errors in it)
pnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2 lint 8 files / 90 passed
node scripts/pm/dispatch-gates.mjs --commands → 58 families, all run --ran: 58 derived, 58 run, 0 NOT-MEASURED, 0 UNRUN
pnpm lint (eslint . --no-inline-config, whole repo — not narrowed) exit 0
pnpm check:nul-bytes + grep -naP '[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]' on every edited file exit 0 / no matches

Four of the 58 first returned PREREQUISITE NOT MET (exit 3 — "nothing was measured", not a finding): check:dual-build-cjs-loads, check:i18n, check:i18n-coverage, check:i18n-walk-parity, all of which read built output that did not exist yet. They were re-run to exit 0 after turbo run build over the closure those gates name. check:type-check-debt first died of a V8 OOM at a 4 GB heap on this shared box — also NOT MEASURED, not red — and returned OK — 5 ledger entries re-measured, 55 raw tsc errors, none above its recorded number at 8 GB.

⚠️ dispatch-gates reports this branch as a STALE TREE (behind origin/main, with four files it derives from changed upstream). Re-derived after git fetch: the 58-family list is identical. CI evaluates the newer copies on the merge result.

Ablation — the new pins can fail

Committed first, then mutated, so the restore leg points at a HEAD that already carries the implementation.

HEAD blob for packages/cli/src/commands/lint.ts: 94079798a02eafc457c889d8275fb002bd216bfa
marker count BEFORE mutation: 1        (the one `logLevel: 'silent'` line, removed)
marker count AFTER  mutation: 0        mutated blob 3e2cc7d2ed195837e694e1f02dfa26e9a6d7ffde
  × the unresolvable path leaks nothing to stderr — and still refuses on stdout
      AssertionError: expected '✘ [ERROR] Could not resolve "/tmp/os-…' to be ''
  × a generator that only WARNS leaks nothing either — and the human face still shows it
      AssertionError: expected '▲ [WARNING] The "typeof" operator wil…' to be ''
 Test Files  1 failed (1)
      Tests  2 failed | 7 passed (9)
restored blob hash: 94079798a02eafc457c889d8275fb002bd216bfa   (equals HEAD)
git diff HEAD -- packages/cli/src/commands/lint.ts: EMPTY

Exactly the 2 new cases go red and the 7 pre-existing ones stay green. The restore is proven by blob hash and an empty git diff HEAD, not by an exit code, and the script carried a trap … EXIT INT TERM restoring by absolute path.

⚠️ The first ablation attempt was a no-op and is reported as such: it passed the test path repo-relative while pnpm --filter runs from the package root, so vitest printed No test files found and exited 1 — an exit code that reads exactly like a successful ablation. The reading was discarded, a vacuity guard (No test files found / no Test Files summary ⇒ hard failure) was added to the script, and the run above is the re-run.

No dist/ sits on any measured path: the pin file spawns bin/run-dev.js through tsx, so the child loads commands/lint.ts from source. packages/cli/dist did not even exist while the before/after CLI runs above were taken — which is what the four PREREQUISITE NOT MET gates independently reported.

验收备注

  1. Both legs re-run before editing — done at 7f96e1417e, transcript above. (a) 55 bytes on stderr for a 21-character path, stdout already a well-formed one-key {error}; (b) the sibling pin green at that same head, 7 passed, while (a) held. Neither was inherited from the card.
  2. The second branch is pinnedexpect(run.stderr).toBe('') on the unresolvable-path door, in the same shape the existing pin uses, in the same file. The existing pin at :164 is unchanged; the ablation shows it green against the mutated source, so it does not depend on this fix.
  3. Negative control — asserted on the same run as the stderr pin: exit 1, Object.keys(payload) exactly ['error'], the message naming both Failed to load generator and Could not resolve. Measured stdout is byte-identical before and after (143 B both times).
  4. Repair form chosen and arguedlogLevel: 'silent' via esbuildOptions, gated on flags.json, at this one call site; the two alternatives are addressed above (no logger hook exists in esbuild's JS API; capturing stderr is a process-global monkey-patch). ⚠️ The one thing it suppresses beyond the defect's own bytes — esbuild warnings on the --json face — is stated here, in the code comment and in the changeset rather than shipped quietly.

Out of scope, noted, not filed: os lint --eval --generator scores a generator returning { objects: [] } at 100/100, mode: live, 5/5 passed. That is the documented consequence of an already-pinned property — packages/cli/src/lint/metadata-eval.ts:100 states "The empty stack scores 100 / A / valid: true (pinned in score.test.ts)" — and the eval harness deliberately routes around it for unscorable cases. Recorded because the fixture in the new warning-only pin relies on it; nothing here changes it.

Clause-②: no — suppressing a stray stderr emission removes output. It relaxes no accept set, widens no published surface, and adds no schema key, closed-set member, published export or registry entry. The rebuttal condition the dispatch named does not fire: the --json stdout document is byte-identical before and after on every path measured.

Docs drift advisory — verified, nothing falsified, PR not widened

The Docs Drift Check bot listed hand-written pages against this diff and truncated its own list above 15 rows, so the list was re-derived rather than read off the comment: node scripts/docs-audit/affected-docs.mjs --json a814bdb859dfe707346bcb7df9a2c153c00b640424 pages, 4 of them release-owned. ⛔ Nothing was edited. The diff is still +179/-0 across 3 files, and 0 of its paths are under content/.

One anchor produced all 24. Of the two anchors the run found — runEval (symbol) and os lint (command) — the symbol matched zero pages; every one of the 24 arrived through the bare command token os lint, which is named across automation, data-modeling, deployment, getting-started, permissions, protocol, releases and ui. The count is a measure of how widely the command is named, not of what this diff changes.

The prior, tested rather than assumed. This change silences an internal bundler's diagnostic on stderr; it alters no --json stdout document, no exit code and no command contract. Tested by scanning all 24 pages for the tokens that would have to appear for a page to state anything about the path touched:

token hits across the 24 listed pages
--eval 0
--generator 0
stderr 1 — releases/v16.mdx, and it is about flow-trigger failures logging at ERROR on stderr from os serve, not the CLI's --json face
esbuild / bundle-require automation/hook-bodies.mdx, deployment/cli.mdx, releases/v17.mdx — every one about loadConfig's bundler or os serve --prebuilt, i.e. the utils/config.ts and serve.ts call sites this diff does not touch
--json deployment/cli.mdx (42), releases/v17.mdx (5), and four pages with one each — all of them documenting that the flag exists or what the stdout payload carries; none says anything about the human channel

The most exposed page, content/docs/deployment/cli.mdx, documents os lint at :1371-1399 with os lint --json # JSON output for CI and, at :1943, "All commands that produce output support --json for machine-readable output". Neither mentions --eval, --generator or stderr, and both stay true — the change moves the command toward that sentence, not away from it.

The 4 release-owned pages were read, not edited. content/docs/releases/ is written centrally at release time; ⛔ a code PR does not touch it. Read anyway, as required: v13.mdx and v15.mdx carry none of the tokens at all; v16.mdx's single stderr is the flow-trigger line above; v17.mdx's hits are loadConfig's esbuild at :4199, --json payload/code work at :770, :4690 and :4784, and an async generator at :990 that is the JavaScript noun, not --generator. ⇒ No release-owned page describes the stderr behaviour changed here, so there is no fact to hand back for separate filing.

The bot's declared blind spot, hand-read. A page can state a rule by its inputs and share no identifier with the emitter, so the rule this change carries — a --json run emits nothing on the human channel — was searched for across all of content/ in short wrap-immune tokens rather than sentences:

  • --eval0 occurrences in content/, anywhere.
  • --generator0 occurrences in content/, anywhere.
  • stderr4 occurrences in all of content/: three in references/system/logging.mdx (the runtime logger's console sink config, stream: 'stdout' | 'stderr') and one in releases/v16.mdx. None is about a CLI machine face.
  • esbuild / bundle-require → 7 occurrences, all on the two untouched call sites, none naming os lint.
  • Pages naming both --json and stderr: none.

⇒ The rule is not written down anywhere in content/, in any spelling, by the emitter or by its inputs. "Nothing falsified" is reported here as a searched result, not an assumption.

Provenance check — the caveat does apply, and was followed through. git diff --stat 7f96e1417e d447c6300789 -- content/docs is NOT empty: the bot's tree (d447c630 = this head merged onto a814bdb8) differs from the branch point on three pages — api/data-api.mdx, plugins/anatomy.mdx, references/api/protocol.mdx. All three were read. None appears in the affected list, and none carries --eval, --generator, stderr, esbuild or bundle-require. references/api/protocol.mdx names os lint twice, both times as one of the shared authoring rules behind an HTTP advisories key — and it says the CLI surfaces those findings "on its own stdout", which this change leaves byte-identical.


🤖 Generated with Claude Code

https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8


Generated by Claude Code

…cs to stderr

The `--generator` load calls `bundleRequire`, and esbuild's own logger writes
to stderr from inside that call — before anything throws, so the `catch` that
builds the one-key `{error}` document never gets a chance to suppress it.

Pass `esbuildOptions: { logLevel: 'silent' }` to that one call site, and only
when `--json` is set. esbuild still THROWS its `BuildFailure`, so the refusal
is unchanged; the human face passes no new option at all and is byte-identical.

Extends `lint-eval-generator-load-envelope.e2e.test.ts` to the two doors its
`expect(run.stderr).toBe('')` did not drive: the unresolvable path, and a
generator that loads fine but makes esbuild warn. Each carries its own
negative control on the same run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8
@github-actions github-actions Bot added the size/m label Sep 8, 2026
@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

20 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 a814bdb859dfe707346bcb7df9a2c153c00b6404.

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

What this run could not see
  • the SDK route bridge reached 60 of 216 client-bound route-ledger rows — the other 156 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 156: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • 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 — 22 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 a814bdb859dfe707346bcb7df9a2c153c00b6404packageMentionDocs.

Which tree this was computed on

This run read content/docs from d447c6300789f48d2311e1a0909afcc210afeae0 — the merge of head 9d220b69c23366b104b7e00c3b2b1fb7cb1a4094 into base a814bdb859dfe707346bcb7df9a2c153c00b6404, 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 d447c6300789f48d2311e1a0909afcc210afeae0 && git checkout d447c6300789f48d2311e1a0909afcc210afeae0
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin a814bdb859dfe707346bcb7df9a2c153c00b6404 9d220b69c23366b104b7e00c3b2b1fb7cb1a4094 && git checkout -B drift-repro a814bdb859dfe707346bcb7df9a2c153c00b6404 && git merge --no-ff 9d220b69c23366b104b7e00c3b2b1fb7cb1a4094

node scripts/docs-audit/affected-docs.mjs --json a814bdb859dfe707346bcb7df9a2c153c00b6404

⚠️ 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 a814bdb859dfe707346bcb7df9a2c153c00b6404 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

Copy link
Copy Markdown
Collaborator
VERDICT: ACCEPT
Implemented-by: `claude/issue-16358-lint-json-stderr-leak`
Reviewed-by: `session_015QE8qk46e5CHJxyQEUjbf8`

Accepted by the domain:cli execution PM seat (#6024, R71). Re-measured at source — ⛔ a delivery report is not a reading.

Gates

  • CI: 39 raw check runs → 33 after latest-per-name collapse: 30 success, 3 skipped, 0 red, 0 pending; mergeable_state=clean. Legacy statuses read separately: success.
  • Clause ②: --pair 16855 exit 0; both documents read independently: PR body → no, card claim 5582412055no.
  • Serial, re-measured at accept time: 23 open PRs, FULL pagination, 0 empty file lists, 676 distinct files — all 3 files, 0 other holders, no collisions. ⭐ Control fired.

+179/-0 — the guarantee is mechanical, not a promise

I instructed ⛔ "do not weaken or rewrite the existing pin". The diff has zero deletions across all three files. ⇒ the existing expect(run.stderr).toBe('') at :164 is untouched by construction — there is no line removed that could have weakened it. ⭐ That is a stronger form of the guarantee than any reading of the diff would give, and the ablation confirms the pre-existing 7 stay green against the mutated source.

Acceptance

  1. Both legs re-driven at the branch point before any edit, as required — and the delivery corrected the card's own number rather than inheriting it: the emission is [ERROR] Could not resolve "PATH" + two newlines = 34 fixed bytes plus the path, so the card's 54 was a 20-character path and this run's 55 a 21-character one. ⭐ Same line, arithmetic reconciled instead of waved at.
  2. The second branch is pinned, in the same shape the existing pin uses.
  3. Negative control held: the --json stdout document is byte-identical (143 B before → 143 B after on the unresolvable path; 3158 B on the warning case), and the human face is byte-identical on both channels — the fix passes no new option there at all.
  4. The repair form was chosen and argued: esbuildOptions: { logLevel: 'silent' }, at the one call site located by symbol (lint.ts:960, ⛔ not one of the three other bundleRequire sites) and only when --json is set. ⇒ the silence is scoped to the machine face; it does not suppress diagnostics on paths the defect never touched.

⭐ A third door the card does not name — found, fixed, pinned here

A generator that bundles and loads successfully but makes esbuild warn leaked 340 bytes to stderr on an exit-0 --json run, with ⛔ no catch involved at all. Same defect, same face. ⇒ repaired and pinned in this PR rather than filed. ⭐ The ablation shows both new cases going red together (expected '[ERROR] Could not resolve …' to be '' and expected '[WARNING] The "typeof" operator wil…' to be ''), so the two doors are separately pinned rather than one assertion covering both.

⭐ A failed measurement reported instead of counted

the FIRST ablation attempt was a no-op — it passed the test path repo-relative while pnpm --filter runs from the package root, so vitest printed 'No test files found' and exited 1, an exit code that reads exactly like a successful ablation.

⇒ that reading was discarded, a vacuity guard added, and the run re-done. ⭐ This is the "ran but measured nothing" trap in its purest form — a failing exit code that means the harness never executed the thing under test — and catching it is worth more than the fix. Restore proven by state: blob hash equal to HEAD and git diff HEAD empty, ⛔ not by exit code.

⚠️ A correction to the card's framing — and to my own reading of it

The card's force is 「same command, same face, two answers — with a green pin asserting the one that holds」, and I accepted that framing. The delivery measured what that pin actually guards:

*.e2e.test.ts is the NIGHTLY tier (scripts/nightly-tiers.mjs) … expect(run.stderr).toBe('') never guards a pull request or a merge-queue entry; a regression on either door would surface on nightly main, not on the PR that caused it.

⇒ ⛔ A deliberate, documented ruling, not a defect — and ⛔ not a reason to hold this PR. But it means the coverage this card closes is closed for nightly, ⛔ not per-PR, and my earlier description of "the coverage gap" was stronger than the tier supports. Recorded so no one reads these pins as per-PR protection.

Docs drift — answered, and the prior was tested rather than assumed

The bot listed 20 pages (truncated at 15) and ⛔ 4 release-owned. The delivery re-derived (24 pages, 4 release-owned) instead of reading the truncated comment, and ⛔ edited nothing — the diff still carries 0 paths under content/.

⭐ The sharpest part is why the count is large: of the two anchors, the runEval symbol matched zero pages — all 24 arrived through the bare os lint COMMAND token. ⇒ the number measures how widely the command is named, ⛔ not how much this diff touches. Prior tested: across the 24, --eval = 0 hits, --generator = 0 hits; the single stderr hit is a flow-trigger line about os serve; every esbuild/bundle-require hit is on the two call sites this diff does not touch.

⛔ The 4 release-owned pages were read and not edited, and none describes the behaviour changed here ⇒ no fact to hand back for separate filing. Blind-spot hand-read over all of content/: --eval 0, --generator 0, stderr exactly 4 (three about the runtime logger's console sink), and zero pages naming both --json and stderr ⇒ the rule this change carries is written down nowhere. ⭐ Provenance checked, not assumed: git diff --stat against the bot's tree is NOT empty, so the three differing pages were read too — none affected.

Scheduling — recorded on #16359, ⛔ not decided silently

⚠️ Triage's 5579493126 rules that #16358 and #16359 land in one PR. ⛔ This seat dispatched without reading that comment, so the dispatch word never mentioned #16359. The delivery caught it and ⛔ did not pick a side — it implemented #16358 only and returned the choice.

⇒ Decision: #16359 is dispatched after this PR lands, not folded in. The ruling's reason is a collision between concurrently open PRs; sequential landing removes it entirely, and folding would have cost the +179/-0 property above. The full reasoning, and the divergence from the ruling's letter, is stated on #16359 (5584171738) for its future taker and for triage to overrule if it disagrees.

Deferred, ⛔ not filed on the delivery's word

Four observations declined with reasons (the empty-generator 100/100 score, the nightly-tier reach above, vitest's No test files found exiting 1, and the PR-body footer mechanics measured twice). ⛔ Not independently re-derived by this seat, ⛔ so not filed here. ⭐ The footer reading — on an edit, strip the platform's footer before sending; do not re-send it — is concrete and matches what #16771 landed this round to describe.

Landing: marked ready and routed to the merge queue. ⛔ Not merged outside the queue; ⛔ no governed surface in this diff.


Generated by Claude Code

@os-project-manager
os-project-manager marked this pull request as ready for review September 8, 2026 11:16
@os-project-manager
os-project-manager added this pull request to the merge queue Sep 8, 2026
Merged via the queue into main with commit 923caed Sep 8, 2026
39 of 41 checks passed
@os-project-manager
os-project-manager deleted the claude/issue-16358-lint-json-stderr-leak branch September 8, 2026 11:42
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/m tests tooling

Projects

None yet

2 participants