Skip to content

fix(cli): one space after the generator-load separator, on both faces - #16873

Merged
os-project-manager merged 1 commit into
mainfrom
claude/issue-16359-empty-generator-double-space
Sep 8, 2026
Merged

fix(cli): one space after the generator-load separator, on both faces#16873
os-project-manager merged 1 commit into
mainfrom
claude/issue-16359-empty-generator-double-space

Conversation

@claude

@claude claude Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Fixes #16359

Clause-②: no

os lint --eval --generator "" refused with two spaces after our own separator:

Failed to load generator "":  is not a valid JS file

bundle-require composes its own refusal as FILEPATH + ' is not a valid JS file'. An empty
filepath contributes no characters, so that fragment arrives with a leading space and lands
against the space in our own ": " separator. Neither side is wrong alone, and the seam was
unreachable before PR #16341 — the truthiness guard skipped the whole load block, so nothing was
printed at all. A defect newly made reachable by a correct fix, not a regression it
introduced, and not a reason to touch that guard.

The decision this card exists to make — Route A, and why

Triage's binding criterion: 哪一种修法之后,空串与不可解析路径仍然经由同一扇门作答?

Route A — trim our own separator — is what landed. The composed message drops leading spaces
from the detail, so the separator carries exactly one:

const detail = `${error?.message || error}`.replace(/^ +/, '');
const msg = `Failed to load generator "${flags.generator}": ${detail}`;

That is the whole source change: one line becomes two. It reads the seam and never
flags.generator
, which is the property the criterion asks about. After it, the empty string
and an unresolvable path still reach the same try, the same catch, the same composition
expression, the same envelope, the same exit code and the same key set — the empty string stays
"a path that names no module", exactly as #16161 ruled. Measured, not asserted: see same door
below.

Route B — special-case the empty value — was rejected. It re-introduces the branch #16161
deliberately refused: a hand-written message for the empty value is the second refusal shape that
card exists to avoid. A Route B repair would satisfy every spacing assertion in the new pin and
still fail its the same door case, which is why that case is in the file.

Leading spaces only, deliberately not trimStart(): a detail that opens with a newline is a
different shape (our space, then a line break), not a doubled separator, and stays exactly as it
prints today.

验收 — evidence against each item

1. Re-driven by BYTES at the branch point, both faces

Not inherited from the card. Driven at 923caede80 through bin/run-dev.js with NO_COLOR=1,
read through od -c and cat -A:

run exit stdout stderr spaces after ":"
--generator "" 1 59 B 0 B 2
--json --generator "" 1 67 B 0 B 2
--generator UNRESOLVABLE_PATH 1 332 B 155 B 1
--json --generator UNRESOLVABLE_PATH 1 343 B 0 B 1
0000040   "   :           i   s       n   o   t       a       v   a   l     <- od -c, before
0000040   o   r       \   "   \   "   :       i   s       n   o   t         <- od -c, after

⇒ the card's transcription holds, and the defect is on both faces, which was a measurement
rather than an assumption: PR #16855 changed the --json path only, and the two faces do print
the same composed message. After the repair, exactly one space on both, and the stdout byte counts
drop by exactly one each (59→58, 67→66).

2. The exact bytes are pinned, not a satisfiable substring

packages/cli/test/lint-eval-generator-refusal-separator.test.ts asserts the whole composed
message
by equality on both faces:

expect(payload.error).toBe('Failed to load generator "": is not a valid JS file');
expect(humanLine(run, '…')).toBe('  ✗ Failed to load generator "": is not a valid JS file');

A double space fails both lines. Ablation: reverting only commands/lint.ts to its pre-fix
content and re-running the same command gives Tests 3 failed | 2 passed (5) — same file, same
five tests, three of them red. The two that stay green are the negative controls, which is the
predicted direction. Restoration proven by blob hash (ab85602c… before, mutated 94079798…,
restored ab85602c…), empty git diff HEAD and clean git status --porcelain.

3. Tier — this pin is queue tier, on purpose

The trap reproduced exactly as described. Naming the existing nightly file to
vitest run --project integration without OS_TEST_TIERS=nightly:

queue-default run of the NIGHTLY file -> exit 1
No test files found, exiting with code 1

— the same exit code a failing suite gives. Guarded against here by asserting the count, not
the colour: green run Test Files 1 passed (1) · Tests 5 passed (5); ablated run
Test Files 1 failed (1) · Tests 3 failed | 2 passed (5). Five collected in both.

The new pin avoids the trap rather than working around it. The nightly cut is by filename
(scripts/nightly-tiers.mjs) and the unit/integration cut is by behaviour
(packages/cli/vitest-tiers.ts), so a spawning file that carries no .e2e name is collected on
every PR and every merge-queue entry. Derived from the two classifiers on this tree:

queue:   integration=37 unit=186 | inIntegration=true  inUnit=false
nightly: integration=63 unit=1   | inIntegration=false inUnit=false

Why not the unit tier, as the dispatch asked to be told: a unit-tier pin cannot spawn, so it
would have to test a pure helper extracted out of runEval — a new exported surface created to
host a whitespace fix, asserting a function's return value instead of the bytes an operator sees.
The queue-tier file buys the same per-PR protection while pinning the real emitted bytes on the
real command, so the extraction buys nothing here. There are 14 precedent queue-tier spawning
files in packages/cli/test/.

4. Negative control — the non-empty unresolvable path, byte for byte

All four streams compared before/after with cmp:

human missing .out : BYTE-IDENTICAL (332B)      json missing .out : BYTE-IDENTICAL (343B)
human missing .err : BYTE-IDENTICAL (155B)      json missing .err : BYTE-IDENTICAL (0B)

Widened past the card's ask to every reachable load-failure class, driven through the same
entry on the pre-fix and post-fix source:

class exit message
esbuild BuildFailure (unresolvable path) 1 → 1 byte-identical
coded throw at import (FORBIDDEN / 403) 1 → 1 byte-identical
errno at import (ENOENT) 1 → 1 byte-identical
hand-thrown "must default-export a function" 1 → 1 byte-identical

None of them opens its detail with a space, so replace(/^ +/, '') is a no-op on all four. The
empty value is the only shape whose bytes move.

Same door, pinned: the third describe block asserts the empty string and the unresolvable path
answer with the same exit code, the same Object.keys(payload) (['error']) and the same
Failed to load generator "…": + non-space shape. That is the assertion Route B would fail.

5. Existing pins untouched

git diff --stat for test/lint-eval-generator-load-envelope.e2e.test.ts: no entry — the new
pin is a new file beside it, and none of that file's four stderr).toBe('') assertions is
weakened, rewritten or moved. Re-driven anyway, because the repair touches the string the --json
face emits: OS_TEST_TIERS=nightly … --project integration on that file gives
Test Files 1 passed (1) · Tests 9 passed (9).

6. Gates

content/docs/releases/ untouched; no test skipped, disabled or quarantined.

  • 58 derived gate families, 58 run, 0 UNRUN, 0 NOT-MEASURED
    node scripts/pm/dispatch-gates.mjs --ran … --repo objectstack-ai/objectstack reports
    ✓ 58 derived famil(ies) accounted for. Four first read PREREQUISITE NOT MET (exit 3/1, no
    dist/): check:dual-build-cjs-loads, check:i18n, check:i18n-coverage,
    check:i18n-walk-parity. That is NOT MEASURED, not red — cleared by building the CLI, then all
    four exit 0.
  • pnpm --filter @objectstack/cli typecheck → exit 0 (tsc --noEmit + check:test-typecheck;
    the shrink-only test-typecheck debt is unchanged at 3 files / 28 errors, so the new file adds
    none).
  • pnpm --filter @objectstack/cli exec vitest run --project unitTest Files 186 passed (186) · Tests 2555 passed | 6 expected fail (2561).
  • pnpm exec eslint . --no-inline-config → exit 0 over the whole repo, 6367 files, 0 errors,
    0 warnings, counted from --format json. No narrowing was needed, so none is claimed.
  • Control characters: check:nul-bytes green, plus a direct
    grep -naP '[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]' over all three changed files — no matches.

All test and gate figures above were taken at eb3336e2a1, the final commit on this branch.

Changeset

patch for @objectstack/cli — the message is composed in src/commands/lint.ts, which ships in
that package's dist. Not breaking, so no ADR-0087 disposition marker.

验收备注

Noted, not filed:

  • The same ": " + upstream-fragment seam exists in shape at the other three bundleRequire
    callers in this package (utils/config.ts, utils/scaffold-validate.ts, commands/serve.ts),
    but none of them can be reached with an empty filepath — the paths they pass are resolved config
    or scaffold paths, never raw flag text — so there is no reproducible defect to file. Scope here
    is this one call site.
  • os lint --eval --generator scores a generator returning { objects: [] } at 100/100, grade A,
    exit 0. Met while building fixtures; already documented as the consequence of a pinned property
    at packages/cli/src/lint/metadata-eval.ts, ⛔ not a defect and not re-filed.

Docs drift advisory — the reading

⛔ A READ, not a code change. content/docs/releases/ is untouched by this PR and stays untouched.

1. Re-derived on the tree the bot named, from a confirmed-clean worktree

The advisory's own run declared a provenance defect ("That checkout carried uncommitted changes,
so the commit above does not fully identify what was read."
), so its list was treated as a prompt,
not as the reading. Re-derived in a separate worktree at eb3336e2a1 with origin/main merged in,
git status --porcelain empty before the run:

computedOn: { head: 609e9bcc064ff6a09d13d451808a23f51e046702,
              headParents: [ eb3336e2a105301159471c68724a8969a68df98d,
                             b37f0b1767a129b5cf50ddd2102a8027039f5abe ],
              diffBase:    b37f0b1767a129b5cf50ddd2102a8027039f5abe,
              dirty:       false }

Same two parents as the bot's merge, so the same tree; different merge sha only because it is a
different merge commit. dirty: false — the thing the bot's run could not say.

Real count: 24 docs — 20 hand-written + 4 release-owned. The bot's numbers reproduce exactly;
there is no under-report on this PR (the sibling's 28-vs-33 gap does not recur here). Two anchors,
runEval (symbol) and os lint (command); anchorlessChanges empty; 1 changed package.

2. The four release-owned pages — read, and nothing is falsified

releases/v13.mdx, v15.mdx, v16.mdx, v17.mdx. No fact in any of them is falsified by this
diff, and there is nothing to hand back for a docs-only PR or an issue.
They were listed on the
os lint command anchor alone: v15 names os lint zero times; v13 twice (the
security-role-word rule, and errors gating os compile); v16 once (built-in i18n gaps); v17
eight times (react pages, functional completeness, an unparseable source, hook bodies, label
warnings). runEval appears in none of the four; --eval, --generator and the refusal string
appear in none of the four.
This is the over-inclusion the tool's own header describes — a page
that names the command need not document the changed surface.

3. Token sweep over all of content/ — the concrete falsifier is absent

token hits in content/
Failed to load generator 0
is not a valid JS file 0
--generator 0
--eval 0
os lint 67

The two-space spelling searched for on its own, as asked, since a single-space search would
miss it: ": is not a valid JS file"0; ": is not"0. The one-space form
": is not a valid JS file"0 as well.

Zeros paired with controls sharing the failing queries' vocabulary, because a zero from one
phrasing is not a zero from the area: generator12 hits, not a valid5,
os lint --7, lint --8. The sweep reaches content/, so the zeros are readings
and not silence. Every one of the 12 generator hits is something else (an SBOM generator, a
grant generator, a REST route generator, a QR field, an async generator); none of the 7
os lint -- hits documents --eval or --generator.

Repo-wide, is not a valid JS file and Failed to load generator occur only in
packages/cli/src/commands/lint.ts, this PR's new pin, this PR's changeset, and one pre-existing
negative assertion in test/lint-generator-requires-eval.e2e.test.ts (not.toContain('Failed to load generator'), unaffected by spacing — re-driven under OS_TEST_TIERS=nightly anyway:
Test Files 1 passed (1) · Tests 7 passed (7)).

No page reproduces the old two-space spelling as expected output. Nothing in content/ is
falsified by this change.

4. The advisory's own blind spot, answered by hand

The advisory cannot list a page that states a rule by its inputs, because such a page shares no
identifier with the emitter that implements it — and this diff is emitter-side, so the anchor
list structurally could not cover it. The token sweep in (3) is what covers it: the emitted string
itself, in every spelling this change could have moved, is absent from content/ entirely.

⚠️ One observation while sweeping, not filed and not a defect: content/docs/deployment/cli.mdx
lists the os lint flags as --score, --fix, --strict, --json and documents neither --eval
nor --generator. That is an undocumented capability, not an over-claimed one — the opposite
direction from Prime Directive #10 — and it predates this diff, which neither widens nor narrows
what those flags accept. Successor: whoever next edits that page's os lint section.


Generated by Claude Code

`os lint --eval --generator ""` refused with `Failed to load generator "":  is
not a valid JS file` — two spaces. `bundle-require` composes its own message as
`${filepath} is not a valid JS file`, so an empty filepath contributes nothing
and the fragment's leading space lands against the space in our own `": "`
separator. Neither side is wrong alone, and the seam was unreachable before the
truthiness-guard repair: that guard skipped the whole load block.

The composed message now drops leading spaces from the detail, so the separator
carries exactly one. This reads the seam and never `flags.generator`, so the
empty string still answers through the door an unresolvable path already
answers through, and every detail that does not open with a space is
byte-identical.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels 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 b37f0b1767a129b5cf50ddd2102a8027039f5abe.

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 b37f0b1767a129b5cf50ddd2102a8027039f5abepackageMentionDocs.

Which tree this was computed on

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

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

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

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