Skip to content

Commit eb20c33

Browse files
fix(cli): one verdict sentence for the nine CLI-owned rule ids and the undeclared-handlers boot line; the reasoning moves behind os explain (#22905)
Fixes #22841 Clause-②: no The `domain:cli` half of the one-line-verdict work: the nine rule ids `packages/cli` produces now print one verdict sentence of at most 200 characters, `os explain RULE_ID` carries the reasoning they used to print, and the undeclared-handlers `[action-governance]` boot line is one line. Rule ids, severities, paths, boot-line levels and payloads, and accept/refuse behaviour are unchanged. ## What changed - **`packages/cli/src/lint/hook-body-lowering.ts`** (`os lint`): the four `hook-body/*` messages are one sentence each, and their remedy moves to the finding's `fix` (printed as `→ fix:` under `--fix`, carried as `fix` in `--json`). The not-lowerable remedy is still chosen per case: inline a module-scope name, or, for a host-only global such as `Intl`, a string handler ref or a validation rule. `hook-body/bundled-fallback` quotes the extractor's own verdict clause, the first line of its refusal up to its first ` — ` or full stop, without the `[hook-body-extract] ORIGIN: ` lead the finding's subject already carries. A word-boundary bound marks any further cut with `…`. `os build` still prints the whole refusal line for every bundled handler, and `--strict-body` prints the full diagnostic. - **`packages/cli/src/utils/collect-docs.ts`** (`os build`, `os validate`, `os lint`): the four `docs/*` messages are one sentence ending in the remedy. A `DocIssue` has no separate fix channel, so the remedy stays in the sentence. A message no longer repeats the finding's `path`, because every printer already leads with it. Lists of files, packages and directories quote the first three and count the rest; the `tags:` spelling a doc wrote is quoted up to 48 characters. - **`packages/cli/src/utils/picklist-references.ts`**: the `picklist-reference-unverified` notice is one sentence and quotes at most two outside dependencies. Its `hint` is unchanged. - **`packages/lint/src/rule-explanations.ts`**: nine new `RULE_EXPLANATIONS` entries, one per id, in a section for the ids the CLI produces. ⛔ No existing entry changed: the only removed lines are two lines of the module header, which now names the CLI ids' holding test. No second registry. - **`packages/objectql/src/action-governance.ts`**: the undeclared-handlers line is one line of 192 characters (was 628). It keeps naming the three sources it read and states the 404 as expected, not measured. The ADR-0110 D3 citation and the "if it IS declared, report it rather than dropping a registration that may be serving traffic" branch move into the source comment at the call site: a boot-log line has no rule id, so it has no `RULE_EXPLANATIONS` key. The dead-button line is unchanged (163 characters, already `STATEMENT; FIX`), so `ACTIONABLE_BOOT_CLASSES` in `format.ts` needs no change. ## Census, before → after Every finding the CLI's suites fire, through a scratch `--import` preload that patches `Array.prototype.push` and records each finding-shaped object whose `rule` is one of the nine. Ten files ran: `hook-body-lowering`, `lint-hook-rules-reach-handler-hooks`, `lint-handwritten-checks-package-fold`, the four `collect-docs*`, `picklist-references`, `picklist-reference-doors` and `explain-rule-id`. Messages are deduped per process and counted by message length. - BEFORE: the three producers restored from `45e206e498` into the working tree only, each proven equal to its BASE blob by hash. After the run they were restored to HEAD (blob = HEAD, `git diff HEAD` empty). - AFTER: `d6c01cf32c`; the three producers' blobs are identical at the final head. | rule id | producer | severity | before: n · chars | after: n · chars | fix (after) | |---|---|---|---|---|---| | `hook-body/not-lowerable` | `lint/hook-body-lowering.ts` | error | 8 · 565–661 | 8 · 165–178 | ≤ 152 | | `hook-body/bundled-fallback` | `lint/hook-body-lowering.ts` | warning | 6 · 324–947 | 6 · 109–171 | 142 | | `hook-body/unparseable` | `lint/hook-body-lowering.ts` | warning | 2 · 469 | 2 · 157 | 101 | | `hook-body/extraction-failed` | `lint/hook-body-lowering.ts` | warning | 2 · 331 | 2 · 140 | 111 | | `docs/uncollected-directory` | `utils/collect-docs.ts` | warning | 19 · 433–558 | 17 · 124–183 | (in the sentence) | | `docs/duplicate-name` | `utils/collect-docs.ts` | error | 3 · 101–330 | 3 · 101–167 | (in the sentence) | | `docs/nav-target` | `utils/collect-docs.ts` | error | 10 · 263–299 | 9 · 147–184 | (in the sentence) | | `docs/frontmatter-tags` | `utils/collect-docs.ts` | warning | 5 · 233–285 | 5 · 135–195 | (in the sentence) | | `picklist-reference-unverified` | `utils/picklist-references.ts` | info | 5 · 240–245 | 5 · 173–175 | hint 125, unchanged | Rows over 200 after: **0**. Fewer distinct messages after for two ids because shortened messages that no longer repeat the path collapse to the same text. **Every refusal the extractor can throw** (the extractor is unchanged from `45e206e498`, and today's extractor PRs are on `main`). I built one hook per refusal, with `toString` pinned to its source: 14 forbidden constructs and 7 parameter refusals. Each ran under a short hook name and a 43-character one, through the BASE `hook-body-lowering.ts` and the HEAD one. `hook-body/bundled-fallback` went from 296–1,011 to 113–199, with 0 over 200. The 14 forbidden constructs cut cleanly at the verdict clause (e.g. `` hook 'enrich_lead' is bundled, not shipped as a metadata-only body: `ctx.dispatch.scope` is not reachable from a sandboxed body ``). `require()` and the parameter refusals reach the bound and end in `…`, still naming the construct. **`[action-governance]`** (message string, without the logger prefix and the JSON `count`/`handlers` tail; the dispatch's "about 790" is the printed record): undeclared handlers 628 → 192; unbound declarations 163 → 163 (unchanged). ## Where `os explain` reads these entries, and the coverage check `rule-explanations.test.ts` holds every key to a string constant the `@objectstack/lint` root barrel exports, or to `FUNCTIONAL_COMPLETENESS_RULES`, which it imports from the producing package. It would refuse the nine CLI ids. `@objectstack/lint` cannot import `@objectstack/cli`, which depends on it. So the check is extended rather than weakened, in two halves: - the lint test declares the nine ids as an exact list: a key that is neither a lint id nor listed fails, and so does a listed id with no entry; - a new block in `packages/cli/test/explain-rule-id.test.ts` fires each of the nine from its real producer. It holds the fired set equal to the explained ids no lint rule produces, and holds each verdict and each fix to one line of at most 200 characters. It also checks that each `rule:` pointer names its entry. ## Verification (final head `baa4d20e8b`, which merges `origin/main` `63739521d8`) Every heavy run went through `scripts/pm/os-verify-lock.sh`, with each part's exit captured in its own log. - `@objectstack/lint`: typecheck exit 0 (test layer OK; `--listFiles` counts `rule-explanations.test.ts` once); `vitest run`: Test Files 137 passed (137), Tests 6498 passed (6498). - `@objectstack/objectql`: typecheck exit 0; `vitest run --project local`: Test Files 401 passed (401), Tests 7817 passed (7817). - `@objectstack/cli`: typecheck exit 0 (`--listFiles`: `test/explain-rule-id.test.ts` in `tsconfig.test.json`; both touched `src/` tests in `tsconfig.json`); `vitest run --project unit`: Test Files 283 passed (283), Tests 4267 passed (4267). The `integration` tier is declared to CI. The one integration file naming these ids, `test/picklist-reference-doors.test.ts`, ran: 1 file, 11 tests. - Readers of the boot lines: objectql's five files naming them (5 files, 59 tests, run at `ab2394a6db`; all inside the full objectql run above), `packages/runtime/src/action-governance-scope-divergence.test.ts` (1 / 6), and cli `format.boot-warning-classes` + `boot-log-capture` (2 / 34). - Nightly-tier e2e files naming these ids or the `[action-governance]` lines (`OS_TEST_TIERS=nightly`): `build-json-advisory-parity`, `build-json-failure-warnings`, `validate-json-failure-warnings`, `rule-line-explain-pointer`, `serve-boot-diagnostics-noise-budget`, `serve-boot-diagnostics`, `serve-app-runtime-hooks`: Test Files 7 passed (7), Tests 37 passed (37). - **Reverse verification:** the BEFORE census leg ran the edited suites against the BASE producers. Predicted exactly four red, the four text pins this PR moves; observed exactly those four (190 tests, 4 failed). At HEAD all 190 pass. - **Ablation of the new 200-character bound**, from committed `ab2394a6db`, via `scripts/ablation-replace.mjs` plus a shell trap restoring HEAD by absolute path. I put `docs/nav-target`'s old remedy sentence back (anchor ×1 → ×0, blob `f9b2cc6775` → `5b83912577`). Predicted exactly one red, the bound pin, because the nav-target suite's own pins still match. Observed `Tests 1 failed | 21 passed (22)`, the bound pin reading 264. Restored to blob `f9b2cc6775` = HEAD, `git diff HEAD` empty. - ESLint, narrowed and proven: `eslint --no-inline-config --format json` over the 9 changed `.ts` files reports 9 files, 0 errors, 0 warnings. The population is from `eslint.config.mjs` (`**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` plus the `packages/**` blocks). The config never enables type-aware linting (no `parserOptions.project`), so no untouched file's verdict can move. - Gates: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` with no paths, at `baa4d20e8b`, derives 69: the dispatch's 62, plus `check:cli-test-child-env`, `check:engine-double-contract`, `check:objectql-double-limit`, `check:query-options-erasure`, `check:type-check-coverage`, `check:type-check-debt` and `check:where-matcher`, which the touched test files pull in. They ran sequentially, one log each, exit captured before any pipe. 66 exited 0 on the first pass. Three refused on a prerequisite and exited 0 once it was met: - `check-engine-split-ratio --days 90` (exit 2, shallow clone): passed after `git fetch --shallow-since=2026-07-06`; - `check:dual-build-cjs-loads` and `check:i18n-coverage` (exit 3, PREREQUISITE NOT MET, package dists absent): passed after `check:type-check-debt --re-measure`, later in the same run, built them. `check-i18n-coverage: OK (13 config(s), 621 baselined untranslated string(s), none new)`. `--ran`: `Run reconciliation — 69 derived, 69 run, 0 NOT-MEASURED, 0 UNRUN.` CI was not awaited. ## Acceptance notes - **The other mentions of these ids are not producers.** `docs/duplicate-name` in `packages/runtime/src/artifact-collections.ts` and `docs/nav-target` in `packages/rest/src/meta-item-read-gate.ts`, `packages/spec/src/ui/app.zod.ts` (and `app-doc-nav-item.test.ts`, `meta-app-nav-doc-audience.test.ts`) all sit in doc comments; the only producers are the three CLI files above. - **No pointer on two printers' `rule:` lines.** `os build` and `os validate` print a doc finding's `rule:` line (`printDocIssueErrors` in `format.ts`, the doc-warning loop in `compile.ts`, the warning list in `validate.ts`), and the picklist notice printer prints `[rule] message` with `→ hint`. Neither appends `explainPointer`, so the new entries are reached through `os explain RULE_ID` and its listing, and `os lint`'s `rule:` line, which does print the pointer. A doc finding also has no `fix` channel. Both are printer changes outside this card's file surface; carrier: none. - **The bundled-fallback verdict reads the extractor's prose.** It takes the first clause of the refusal line, so a structured short reason on `HookBodyExtractionError` (`extract-hook-body.ts`, the producer) would remove that cut. Not a defect today: every current refusal is measured above; carrier: none. - **A third `[action-governance]` line**, the inventory-SKIPPED notice in `packages/objectql/src/plugin.ts`, is 482 characters. It is not one of the two lines this card names, and still says "REFUSED at dispatch", which the inventory line itself was earlier corrected to stop asserting; carrier: none. - `picklist-reference-unknown`, the refusal beside `picklist-reference-unverified` and not one of the nine, quotes up to ten declared lists. With ten long names it can pass 200 characters (333 on a constructed input; not measured on a fired finding). - The card body places the `hook-body/*` messages in `extract-hook-body.ts`; they are composed in `hook-body-lowering.ts`, as the seat's note on the card already says. - NOT MEASURED: whether `objectstack-ai/objectui` matches any of these texts. Code search is refused in this session (repo-scoped). These are CLI terminal and server boot-log texts, and the changeset says to match on `rule` and `path`. --- _Generated by [Claude Code](https://claude.ai/code/session_01VoSxBQujKLZKPwK2u5ehQ6)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9963322 commit eb20c33

11 files changed

Lines changed: 524 additions & 83 deletions
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
"@objectstack/cli": patch
3+
"@objectstack/lint": patch
4+
"@objectstack/objectql": patch
5+
---
6+
7+
fix(cli): the nine rule ids the CLI produces print one verdict sentence each, `os explain <rule-id>` carries their reasoning, and the undeclared-handlers boot line is one line
8+
9+
Clause-②: no
10+
11+
- **One verdict sentence of at most 200 characters.** Measured on every finding the CLI's suites fire (message length; before → after):
12+
- `os lint`'s hook-body rules: `hook-body/not-lowerable` 565–661 → 165–178, `hook-body/bundled-fallback` 324–947 → 109–171, `hook-body/unparseable` 469 → 157, `hook-body/extraction-failed` 331 → 140. Over every refusal the extractor can throw (14 forbidden constructs and 7 parameter refusals), `hook-body/bundled-fallback` runs 296–1,011 → 113–199: the verdict quotes the extractor's own verdict clause, cut to fit and marked `…` when cut.
13+
- package docs (`os build`, `os validate`, `os lint`): `docs/uncollected-directory` 433–558 → 124–183, `docs/nav-target` 263–299 → 147–184, `docs/frontmatter-tags` 233–285 → 135–195, `docs/duplicate-name` 330 → 167 (its within-package arm is unchanged at 101). A doc finding no longer repeats its path, which every printer already leads with; lists of files, packages and directories name the first three and count the rest.
14+
- `picklist-reference-unverified` (the `os validate` / `os build` notice): 240–245 → 173–175, naming at most two outside dependencies and counting the rest.
15+
- **A `fix:` line for the hook-body rules.** Their remedy moves out of the message into the finding's `fix`, at most 200 characters: `os lint` prints it as `→ fix:` under `--fix`, and `os lint --json` now carries `fix` on these four ids' issues. The remedy is still chosen per case: inline a module-scope name, or keep a check on a host-only global such as `Intl` in a string handler ref or a validation rule. A doc finding carries no separate fix, so its sentence ends with the remedy; the picklist notice's `hint` is unchanged.
16+
- **`os explain <rule-id>` carries the rest.** Each of the nine ids gains a `RULE_EXPLANATIONS` entry in `@objectstack/lint`, the one table `os explain` reads, and `os lint`'s `rule:` line now points at it. They hold what the messages stopped saying: why a bundled handler is a change of deployment shape and how to bundle on purpose, what the sandbox cannot run and where `os build` prints the full refusal, why an instrument limit is not a verdict, where package docs are read from and why the collector never guesses, why a doc name may appear once, what a doc navigation item can open, how doc tags are read, and why a reference into a dependency outside the stack is not judged.
17+
- **The `[action-governance]` boot line for registered handlers with no declaration** (`os dev`, `os serve`, and any host that boots ObjectQL) is one line of 192 characters, where it was 628: it names the three sources it read, states the 404 as expected rather than measured, and says to declare each with `defineAction`. Its structured `count` / `handlers` tail is unchanged, and the dead-button line beside it is unchanged.
18+
- **Unchanged:** every rule id, severity and `path`, the boot lines' level and payload, and what each check accepts or refuses. A tool that matched on this message text should match on `rule` and `path` instead.

‎packages/cli/src/lint/hook-body-lowering.test.ts‎

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -123,20 +123,21 @@ describe('checkHookBodyLowering', () => {
123123
// The wrong-remedy failure is worse than none: there is nothing to inline
124124
// `Intl` from, so an author (or a code-writing model) told to inline it
125125
// lands on a second broken shape. The remedy sentence is chosen from the
126-
// refusal's own `nodeOnlyIdentifiers`, never re-derived here.
126+
// refusal's own `nodeOnlyIdentifiers`, never re-derived here. [#22161]
127+
// The remedy is the finding's `fix:` line; the verdict stays one sentence.
127128
const [issue] = checkHookBodyLowering({ hooks: [nodeOnlyGlobalHook] });
128-
expect(issue.message).toContain('provided by the Node host');
129-
expect(issue.message).toContain('cannot be inlined');
130-
expect(issue.message).toContain('string handler ref');
131-
expect(issue.message).toContain('validation rule');
132-
expect(issue.message).not.toContain('Inline the value(s) into the handler');
129+
expect(issue.fix).toContain('provided by the Node host');
130+
expect(issue.fix).toContain('cannot be inlined');
131+
expect(issue.fix).toContain('string handler ref');
132+
expect(issue.fix).toContain('validation rule');
133+
expect(issue.fix).not.toContain('Inline the value(s) into the handler');
133134
});
134135

135136
it('leaves the module-scope remedy exactly as it was', () => {
136137
// The reverse leg: the sentence commit ada3834ad wrote must not have been rewritten for
137138
// every author by a branch added for one new sub-case.
138139
const [issue] = checkHookBodyLowering({ hooks: [freeIdentifierHook] });
139-
expect(issue.message).toContain('Inline the value(s) into the handler, or reach them through `ctx`.');
140+
expect(issue.fix).toBe('Inline the value(s) into the handler, or reach them through `ctx`.');
140141
expect(issue.message).not.toContain('provided by the Node host');
141142
});
142143
});

‎packages/cli/src/lint/hook-body-lowering.ts‎

Lines changed: 77 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -117,10 +117,46 @@ export const UNPARSEABLE_BODY_RULE = 'hook-body/unparseable';
117117
/** The rule name when the extraction instrument itself threw (kind `unknown`). */
118118
export const EXTRACTION_FAILED_RULE = 'hook-body/extraction-failed';
119119

120-
const DELIBERATE_BUNDLE_REMEDY =
121-
'If a bundled closure is what you want, say so: give the hook an explicit `body`, ' +
122-
'or move the function into the top-level `functions:` map and reference it by name ' +
123-
'(`handler: \'<fn_name>\'`) — neither is lowered to a metadata body.';
120+
// [#22161] Each finding prints one verdict sentence of at most this many
121+
// characters and one `fix:` line; the reasoning — why a bundle is a change of
122+
// deployment shape, the two ways to bundle on purpose, why an instrument limit
123+
// is not a verdict — is each rule's `RULE_EXPLANATIONS` entry in
124+
// `@objectstack/lint`, behind `os explain <rule-id>`.
125+
const VERDICT_LIMIT = 200;
126+
127+
/** How many names a verdict quotes before it counts the rest. */
128+
const MAX_QUOTED_NAMES = 3;
129+
130+
const nameList = (names: readonly string[]): string =>
131+
names.length <= MAX_QUOTED_NAMES
132+
? names.join(', ')
133+
: `${names.slice(0, MAX_QUOTED_NAMES).join(', ')} and ${names.length - MAX_QUOTED_NAMES} more`;
134+
135+
/** `text` cut to `limit` characters at a word boundary, marked `…` when it was cut. */
136+
function bounded(text: string, limit: number = VERDICT_LIMIT): string {
137+
if (text.length <= limit) return text;
138+
const cut = text.slice(0, limit - 1);
139+
const space = cut.lastIndexOf(' ');
140+
return `${space > limit / 2 ? cut.slice(0, space) : cut}…`;
141+
}
142+
143+
/**
144+
* The extractor's verdict on a refused handler, as one clause: the first line
145+
* of its refusal without the `[hook-body-extract] <origin>: ` lead the
146+
* finding's own subject already carries, cut where the verdict clause ends.
147+
*
148+
* The extractor writes each refusal verdict first — what the sandbox cannot
149+
* supply, then ` — ` and why, the remedy and the bundled alternative — so its
150+
* first ` — ` (or first full stop) ends the verdict. Nothing the cut drops is
151+
* lost: `os build` prints the whole first line for every bundled handler, and
152+
* `os build --strict-body` the full diagnostic.
153+
*/
154+
function refusalVerdict(firstLine: string, originLabel: string): string {
155+
const lead = `[hook-body-extract] ${originLabel}: `;
156+
const reason = firstLine.startsWith(lead) ? firstLine.slice(lead.length) : firstLine;
157+
const ends = [reason.indexOf(' — '), reason.indexOf('. ')].filter((i) => i > 0);
158+
return ends.length > 0 ? reason.slice(0, Math.min(...ends)) : reason.replace(/\.\s*$/, '');
159+
}
124160

125161
const isPlainObject = (v: unknown): v is Record<string, unknown> =>
126162
typeof v === 'object' && v !== null && !Array.isArray(v);
@@ -157,8 +193,8 @@ function judge(fn: AnyFn, originLabel: string, path: string, face: LoweringFace)
157193
// send an author (or a code-writing model) after a second broken shape.
158194
const moduleScope = free.filter((n) => !nodeOnly.includes(n));
159195
const hostRemedy =
160-
`${nodeOnly.join(', ')} ${nodeOnly.length === 1 ? 'is' : 'are'} provided by the Node host ` +
161-
`and NOT by the sandbox, so ${nodeOnly.length === 1 ? 'it' : 'they'} cannot be inlined: ` +
196+
`${nameList(nodeOnly)} ${nodeOnly.length === 1 ? 'is' : 'are'} provided by the Node host, ` +
197+
`not by the sandbox, so ${nodeOnly.length === 1 ? 'it' : 'they'} cannot be inlined: ` +
162198
`keep the check in a string handler ref, or move it to a validation rule.`;
163199
const inlineRemedy =
164200
`Inline the value(s) into the handler, or reach them through \`ctx\`.`;
@@ -167,18 +203,17 @@ function judge(fn: AnyFn, originLabel: string, path: string, face: LoweringFace)
167203
? inlineRemedy
168204
: moduleScope.length === 0
169205
? hostRemedy
170-
: `${hostRemedy} ${moduleScope.join(', ')} ${moduleScope.length === 1 ? 'is' : 'are'} ` +
171-
`module-scope: inline ${moduleScope.length === 1 ? 'it' : 'them'} into the handler, ` +
206+
: `${hostRemedy} Inline ${nameList(moduleScope)} into the handler, ` +
172207
`or reach ${moduleScope.length === 1 ? 'it' : 'them'} through \`ctx\`.`;
173208
return {
174209
severity: 'error',
175210
rule: NOT_LOWERABLE_RULE,
176-
message:
177-
`${originLabel} cannot be lowered to a metadata-only body: it references ` +
178-
`${free.length === 1 ? 'the identifier' : 'identifiers'} ${free.join(', ')}, which ` +
179-
`${free.length === 1 ? 'is' : 'are'} not in scope inside the sandbox. The handler is ` +
180-
`BUNDLED instead, so this app is no longer shippable as pure metadata — a change of ` +
181-
`deployment shape, not of behaviour. ${remedy} ${DELIBERATE_BUNDLE_REMEDY}`,
211+
message: bounded(
212+
`${originLabel} cannot be lowered to a metadata-only body, so it is bundled (a change of ` +
213+
`deployment shape, not of behaviour): ${nameList(free)} ` +
214+
`${free.length === 1 ? 'is' : 'are'} not in scope inside the sandbox`,
215+
),
216+
fix: bounded(remedy),
182217
path,
183218
};
184219
}
@@ -192,18 +227,30 @@ function judge(fn: AnyFn, originLabel: string, path: string, face: LoweringFace)
192227
// something the sandbox cannot provide"), which asserts a verdict about
193228
// the handler. Kept distinct on purpose (commit ada3834ad): an instrument failure
194229
// must not read as a verdict about the author.
195-
const [rule, instrument] =
196-
kind === 'unparseable'
197-
? ([UNPARSEABLE_BODY_RULE, 'the extractor found no function body it could judge'] as const)
198-
: ([EXTRACTION_FAILED_RULE, 'the extraction instrument itself failed'] as const);
230+
if (kind === 'unparseable') {
231+
// The extractor's own line says only that it could not parse the body
232+
// and asks for a rewrite, which is this finding's verdict and fix.
233+
return {
234+
severity: 'warning',
235+
rule: UNPARSEABLE_BODY_RULE,
236+
message: bounded(
237+
`${originLabel} could not be analysed: the extractor found no function body it could judge, ` +
238+
`which is a limit of the instrument, not a verdict about the handler`,
239+
),
240+
fix: 'Rewrite it as a single arrow function or named function expression; until then `os build` bundles it.',
241+
path,
242+
};
243+
}
244+
// The thrown error is the only clue to what broke, so the verdict quotes
245+
// its first line, cut to the room the sentence leaves.
246+
const frame = (thrown: string): string =>
247+
`${originLabel} could not be analysed: the extraction instrument itself failed (${thrown}), ` +
248+
`which is not a verdict about the handler`;
199249
return {
200250
severity: 'warning',
201-
rule,
202-
message:
203-
`${originLabel} could not be analysed: ${instrument}. ${firstLine} ` +
204-
`This is a limit of the instrument, not a verdict about the handler — it does NOT mean ` +
205-
`a bundled closure was chosen; whether this body could ship as pure metadata is ` +
206-
`undetermined. \`os build\` still bundles it via the designed fallback.`,
251+
rule: EXTRACTION_FAILED_RULE,
252+
message: frame(bounded(firstLine, Math.max(40, VERDICT_LIMIT - frame('').length))),
253+
fix: 'Nothing to change in the handler: `os build` bundles it, and `os build --strict-body` prints the error in full.',
207254
path,
208255
};
209256
}
@@ -214,10 +261,12 @@ function judge(fn: AnyFn, originLabel: string, path: string, face: LoweringFace)
214261
return {
215262
severity: 'warning',
216263
rule: BUNDLED_FALLBACK_RULE,
217-
message:
218-
`${originLabel} is bundled rather than shipped as a metadata-only body. ${firstLine} ` +
219-
`This is the designed fallback — the body uses something the sandbox cannot provide — ` +
220-
`but the app is not pure metadata while it is here.`,
264+
message: bounded(
265+
`${originLabel} is bundled, not shipped as a metadata-only body: ${refusalVerdict(firstLine, originLabel)}`,
266+
),
267+
fix:
268+
'None, if the bundle is intended; to ship it as metadata, remove what the refusal names ' +
269+
'(`os build` prints the refusal and its remedy in full).',
221270
path,
222271
};
223272
}

‎packages/cli/src/utils/collect-docs.package-docs.test.ts‎

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,7 @@ describe('collectDocsFromSrc reads src/<pkg>/docs/ for a resolvable package', ()
200200

201201
// ── Clause 4: the #18428 warning stays, and says WHY ───────────────────────
202202
describe('the #18170 warning stays for a directory neither convention reads', () => {
203-
it('a stack with no packages[] gets the original sentence, unchanged', () => {
203+
it('a stack with no packages[] gets the single-package sentence', () => {
204204
writePackageDoc('sales', 'crm_index', 'MARKER-none');
205205
const { docs, packageDocs, issues } = collectDocsFromSrc(configPath);
206206

@@ -210,14 +210,12 @@ describe('the #18170 warning stays for a directory neither convention reads', ()
210210
expect(issues[0].severity).toBe('warning');
211211
expect(issues[0].rule).toBe('docs/uncollected-directory');
212212
expect(issues[0].path).toBe('src/sales/docs');
213-
// The exact pre-#18431 text, so a rewrite of the single-package message
214-
// fails here rather than in a customer's build log.
213+
// The exact text, so a rewrite of the single-package message fails here
214+
// rather than in a customer's build log. [#22161] One verdict sentence:
215+
// the reasoning it used to carry is `os explain docs/uncollected-directory`.
215216
expect(issues[0].message).toBe(
216-
'src/sales/docs/ holds 1 Markdown file(s) that were NOT collected: package docs are read from src/docs/ only'
217-
+ " (ADR-0046 §3.2), so these are absent from the artifact's `docs[]` and from every book that includes them."
218-
+ ' Move them into src/docs/ (doc names carry the package namespace prefix, so packages do not collide there),'
219-
+ ' declare them inline as `defineStack({ docs })`, or delete them if they are not package docs.'
220-
+ ' Found: crm_index.md',
217+
'1 Markdown file(s) NOT collected (crm_index.md) — package docs are read from src/docs/ only'
218+
+ ' (ADR-0046 §3.2); move them there',
221219
);
222220
});
223221

0 commit comments

Comments
 (0)