Skip to content

Commit ed6f734

Browse files
objectstack-fleet[bot]hotlongclaude
authored
fix(cli): os lint --json reports the ADR-0087 conversions defineStack applied (#20617)
Part of #20583 Clause-②: no ## What this lands: location 1 only `os lint --json` now folds `LoadedConfig.stackConversions` into its `conversions` list, right after `loadConfig`: the same one-line fold `validate.ts` / `compile.ts` make at their step 1b since PR #20579. `defineStack` converts at load, so the config `os lint` received was already canonical and its own `normalizeStackInput` pass found nothing of the default export to convert. The notice reached stderr alone. - `os lint` keeps accepting exactly what it accepts today. There is no `refuseUnbuiltStack` here and none is implied; the one-shape rule is not extended to this command. An unbuilt default export carries no record, so `stackConversionsOf` answers an empty list for it and the command's own pass converts it as before. - One conversion is listed once. The producer's output is canonical wherever it converted, so the pass finds only what no producer saw: an unbuilt export, or a key merged from a named export. Pinned by the exactly-one rows below. - No new `--json` key, and `passed`, `issues`, the counts and the exit code do not move. The text face prints the folded notice in its warning block, as `os build` does. ## What this leaves: location 2 is a fork, not shipped A `defineStack` that converts and then refuses still reports `conversions: []` on `os validate --json`, `os build --json` and `os lint --json` (the third door, measured below). The dispatch's hypothesis was that the CLI can recover those conversions without a spec edit. Measured, it cannot recover them; it can only reconstruct them: 1. **Capturing the producer's stderr lines during `loadConfig` loses notices.** `warnConversionNotice` is warn-once per process, keyed on conversion id, path, from and to. The record is not subject to that warn-once. - Measured at `eb4b17c346`: `composeStacks([defineStack(A), defineStack(B)])`, where both carry `page:header` `description` at `pages[0]`. The record carries 2 notices, and stderr carries 1 line. - In the refusing variant (B adds `requires: ['no-such-capability']`), the one stderr line belongs to A, whose `defineStack` did not refuse. B's own notice was suppressed. - The line also lacks the `surface`, `toMajor`, `message` and `code` fields, so the structured notice would have to be re-derived from prose. The conversion types document that prose as derived, never the source of truth. 2. **Recomputing through `normalizeStackInput` on the authored argument is a second conversion pass.** The CLI would shim `defineStack` in every config load, or re-load the module in authored-source mode, and rerun the pass when the call refuses. The `stackConversionsOf` TSDoc rules this out: a door never runs a second pass to reconstruct the record. It would also copy the producer's record formula (input record plus pass notices) into the CLI, where it drifts. 3. The spec exposes no other channel: `warnConversionNotice` and its warn-once set are module-private, and the refusal errors (`StackRefusalError` subclasses) carry `issues` only. So the only channel that is not a workaround is a spec change: the refusal carries the notices it applied. `packages/spec` belongs to the spec seat under this dispatch, so no spec edit is made here. The dev report carries the fork, with options. ## Measurements (CLI from source, `bin/run-dev.js`) | run | before (`eb4b17c346`) | after (`ca74de14aa`) | |:--|:--|:--| | `os lint --json`, the card's `page:header` `description` case | exit 0, `conversions: []`, 1 stderr line | exit 0, `conversions` = the one `page-header-subtitle-alias` notice, 1 stderr line | | `os validate --json`, convert-then-refuse (`requires: ['no-such-capability']`) | exit 1, `STACK_CAPABILITY_UNKNOWN`, `conversions: []` | unchanged (location 2) | | `os build --json`, the same config | exit 1, `STACK_CAPABILITY_UNKNOWN`, `conversions: []` | unchanged (location 2) | | `os lint --json`, the same config | not measured | exit 1, `STACK_CAPABILITY_UNKNOWN`, `conversions: []` (location 2, third door) | ## Tests - `packages/cli/test/stack-conversion-record-door.test.ts` gains an `os lint --json` block. It covers the card's plain case, the record across the named-export spread, `composeStacks`, a key merged from a named export (the pass converts it once, with no producer stderr line), and the canonical control. Every non-empty row asserts exactly one entry. This file is in the per-PR `integration` tier. The existing `lint-conversion-notices.e2e.test.ts` is `*.e2e.*` and runs nightly only, which is why the new rows are not in it. - At `ca74de14aa`: the `unit` tier passed 234 of 234 files (3342 tests), and the door file passed 15 of 15 tests. `pnpm --filter @objectstack/cli typecheck` exits 0, and the door file is in the test-layer program (`--listFilesOnly`). `lint-conversion-notices.e2e.test.ts` under `OS_TEST_TIERS=nightly` passed 6 of 6 at `a80b61dad0`. Its unbuilt `export default` fixtures still lint and convert through the pass. - Ablation, run from the committed state `a80b61dad0` through `scripts/ablation-replace.mjs` (WRAP mode, with a trap). Deleting the fold line took the anchor count from 1 to 0 on disk (blob `37bf1203bff7` to `95dcca7f308a`). - Exactly the three record-dependent lint rows went red (plain, record across spread, composeStacks). The named-export-pass row, the lint control and all 10 validate / build / strict rows stayed green: 3 failed, 12 passed. - Restore was proven: the blob equals HEAD (`37bf1203bff7`) and `git diff HEAD` is empty. `lint.ts` is loaded from `src/` by the child, so no `dist/` sits on the measured path. ## Gates (at `ca74de14aa`, after merging `origin/main`) - `dispatch-gates --commands`: 63 commands, all exit 0. `dispatch-gates --ran` reconciles 63 derived, 63 run, 0 not measured, 0 unrun, each with its exit code. The first runs of `check:dual-build-cjs-loads` and `check:i18n-coverage` answered PREREQUISITE NOT MET (exit 3) before a full build. Both were rerun green after `pnpm turbo run build --filter=!@objectstack/docs`. - The artifact-roster rows: 36 of 39 exit 0. `check-closing-target-claim`, `check-partof-closing-keyword` and `check-single-claim-paths` need PR context and are rerun against this PR. - `pnpm lint` (the repo-wide `eslint . --no-inline-config`) exits 0 in 29s. - `node scripts/check-issue-citations.mjs --base origin/main` exits 0 (1 citation, resolves). **Declared narrowing — verification ran UNLOCKED.** `scripts/pm/os-verify-lock.sh` could not take the shared verify lock on this host: no usable `flock`. The shared verify lock is declared Linux-only (`flock` is util-linux, and a stock macOS does not ship it), so the command below was run directly, without the lock — a declared narrowing, not a silent one. No serialization guarantee held for this run, nor for any sibling agent in this container while it ran. ## Acceptance notes - **Location 2 stays open on the card.** This PR says `Part of`, so merging it leaves the card open for the fork above. - **`os lint`'s text face now prints the producer's notice** in its warning block for a `defineStack` config with a retiring spelling, in the same wording as `os build`. `check:i18n-coverage` runs `os lint` over the 13 example configs and stays green. - **Host-only reading, not a defect:** on macOS, `test/published-subpath-console.pin.test.ts` and `test/published-subpath-hook-body.pin.test.ts` fail 5 assertions when `TMPDIR` is the `/var/folders/...` symlink. The resolver answers the `/private/var/...` realpath. With `TMPDIR` set to its realpath, both pass 29 of 29. CI runs on Linux. Carrier: none. - Carried over from the card, not filed: `content/docs/deployment/cli.mdx`'s "Warnings checked" list for `os validate` names no conversion notices. --- _Generated by [Claude Code](https://claude.ai/code/session_local_1d2a197c-c20e-4e90-9be8-413d4d432289)_ --------- Co-authored-by: Jack Zhuang <50353452+hotlong@users.noreply.github.com> Co-authored-by: Claude <noreply@anthropic.com>
1 parent c1d8051 commit ed6f734

3 files changed

Lines changed: 93 additions & 1 deletion

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@objectstack/cli": patch
3+
---
4+
5+
**`objectstack lint --json` now reports the ADR-0087 conversions `defineStack` applied, as `objectstack validate` and `objectstack build` already do.**
6+
7+
`defineStack` rewrites a deprecated metadata spelling to its canonical shape when the config loads and prints one `defineStack: PATH: 'OLD' → 'NEW' (converted at load; conversion 'ID', retires in protocol N)` line on stderr. `objectstack lint` filled its `conversions` list only from its own conversion pass over the loaded config, which a `defineStack` default export hands over already converted. So `--json` answered `conversions: []` for a config carrying a retiring spelling, such as `description` on a `page:header` component, and the notice reached stderr alone.
8+
9+
`objectstack lint` now adds the conversions the stack producer recorded on the default export to that list right after the config loads, and its own pass still reports what the producer never saw: an unbuilt default export, or a key merged in from a named export of the config module. Each conversion is listed once. The text face prints the same notices in its warning block.
10+
11+
What `objectstack lint` accepts does not change: it still lints an unbuilt default export (a plain object literal), whose conversions come from its own pass as before. The exit code, `passed`, `issues` and the counts are unchanged, because a conversion notice is not a lint finding.
12+
13+
Clause-②: no

‎packages/cli/src/commands/lint.ts‎

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -945,14 +945,35 @@ export default class Lint extends Command {
945945
const conversionNotices: ConversionNotice[] = [];
946946

947947
try {
948-
const { config, absolutePath } = await loadConfig(configPath);
948+
const loaded = await loadConfig(configPath);
949+
const { config, absolutePath } = loaded;
950+
// [#20583] The ADR-0087 D2 conversions the stack PRODUCER applied — the
951+
// fold `os validate` / `os build` make at their step 1b, one command over.
952+
// `defineStack` converts at load (either mode), so the `config` this
953+
// command received is already canonical and the pass below has nothing
954+
// of the default export left to convert: without this fold `conversions`
955+
// read `[]` on every `defineStack` config carrying a retiring spelling,
956+
// and the notice reached stderr alone. Read by `loadConfig` off the
957+
// default export before its named-export merge (`stackConversionsOf`,
958+
// beside the provenance mark). ⛔ Folded, never recomputed.
959+
//
960+
// ⛔ NOT the one-authoring-shape rule: there is no `refuseUnbuiltStack`
961+
// here, and none is implied. An unbuilt default export carries no record,
962+
// so `stackConversionsOf` answers `[]` for it and the pass below converts
963+
// it exactly as before — this command accepts what it accepted, and only
964+
// the conversions it reports grow. The two sources do not overlap: the
965+
// producer's output is canonical wherever it converted, so the pass finds
966+
// only what no producer saw — an unbuilt export, or a key `loadConfig`
967+
// merged from a NAMED export after the producer ran.
968+
conversionNotices.push(...loaded.stackConversions);
949969

950970
if (!flags.json) {
951971
printInfo(`Config: ${chalk.white(absolutePath)}`);
952972
}
953973

954974
// The ADR-0087 D2 conversion layer runs here, inside `normalizeStackInput`
955975
// — it always did. Passing the sink is what makes the rewrites SAYABLE.
976+
// After the fold above, what it can still find is what no producer saw.
956977
const normalized = normalizeStackInput(config as Record<string, unknown>, {
957978
onConversionNotice: (n) => conversionNotices.push(n),
958979
});

‎packages/cli/test/stack-conversion-record-door.test.ts‎

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,16 @@
3131
* pass cannot both report one conversion. And `os validate --strict` exits 1 on
3232
* both faces for the retiring spelling, 0 for the control.
3333
*
34+
* ## `os lint --json` — the same rows, plus the plain case (#20583)
35+
*
36+
* `os lint` reads the same `LoadedConfig.stackConversions` and folds it the
37+
* same way, so it carries every row above, plus the plain case the card
38+
* measured: one `defineStack` default export, no named export, which answered
39+
* `conversions: []` with the notice on stderr alone. ⛔ Only the reporting
40+
* moves on this door: it does not take the one-authoring-shape rule, so an
41+
* unbuilt default export still lints and still converts through the command's
42+
* own pass (`lint-conversion-notices.e2e.test.ts` pins that half).
43+
*
3444
* `page-header-subtitle-alias` is the live conversion driven here (`description`
3545
* on a `page:header` component, canonical `subtitle`). The day it retires from
3646
* the load path the non-empty rows go red; re-point the fixture at a live entry
@@ -130,6 +140,8 @@ const stackBody = (ns: string, headerKey: 'description' | 'subtitle' | null) =>
130140
const IMPORT = `import { composeStacks, defineStack } from '@objectstack/spec';\n\n`;
131141

132142
const FIXTURES: Record<string, string> = {
143+
// The plain case: one `defineStack` default export, no named export.
144+
plain: IMPORT + `export default defineStack(${stackBody('pln', 'description')});\n`,
133145
// The record, across `loadConfig`'s named-export spread.
134146
recordAcrossSpread:
135147
IMPORT +
@@ -207,6 +219,52 @@ for (const command of ['validate', 'build'] as const) {
207219
});
208220
}
209221

222+
describe("os lint --json — the producer's conversion record reaches `conversions` (#20583)", () => {
223+
// `os lint` folds the same record at the same point — right after
224+
// `loadConfig` — and keeps its own pass for what no producer saw. It does NOT
225+
// take the one-authoring-shape rule: an unbuilt default export still lints
226+
// and still converts through the pass (`lint-conversion-notices.e2e.test.ts`
227+
// pins that half on a plain object-literal export).
228+
const lint = (label: string) => runCli(['lint', '--json'], dirs[label]);
229+
const producerLine = "conversion 'page-header-subtitle-alias'";
230+
231+
it('the plain case: one `defineStack` default export carrying the retiring spelling', async () => {
232+
const run = await lint('plain');
233+
const p = payloadOf(run, 'lint plain') as Payload & { passed?: boolean };
234+
expect(run.code, run.stdout + run.stderr).toBe(0);
235+
expect(p.passed).toBe(true);
236+
expectExactly(p, [THE_NOTICE], 'lint plain');
237+
// The producer's stderr line stays, and stays ONE: the payload now carries
238+
// the notice, and the door adds no second stderr line for it.
239+
expect(run.stderr.split(producerLine).length - 1, 'one stderr line for the one conversion').toBe(1);
240+
}, 180_000);
241+
242+
it('defineStack + a named export: the record is read off the default before the named-export spread', async () => {
243+
const run = await lint('recordAcrossSpread');
244+
expect(run.code, run.stdout + run.stderr).toBe(0);
245+
expectExactly(payloadOf(run, 'lint recordAcrossSpread'), [THE_NOTICE], 'lint recordAcrossSpread');
246+
}, 180_000);
247+
248+
it('composeStacks: the composed stack carries the record of the input that applied the conversion', async () => {
249+
const run = await lint('composed');
250+
expect(run.code, run.stdout + run.stderr).toBe(0);
251+
expectExactly(payloadOf(run, 'lint composed'), [THE_NOTICE], 'lint composed');
252+
}, 180_000);
253+
254+
it("a key merged from a named export: the command's own pass still converts it — once", async () => {
255+
const run = await lint('namedExportPass');
256+
expect(run.code, run.stdout + run.stderr).toBe(0);
257+
expectExactly(payloadOf(run, 'lint namedExportPass'), [THE_NOTICE], 'lint namedExportPass');
258+
expect(run.stderr).not.toContain(producerLine);
259+
}, 180_000);
260+
261+
it('control: the canonical spelling converts nothing — `[]`', async () => {
262+
const run = await lint('canonical');
263+
expect(run.code, run.stdout + run.stderr).toBe(0);
264+
expectExactly(payloadOf(run, 'lint canonical'), [], 'lint canonical');
265+
}, 180_000);
266+
});
267+
210268
describe('os validate --strict — a conversion the producer applied fails it, on both faces', () => {
211269
it('the retiring spelling: exit 1 on the text face and under --json', async () => {
212270
const text = await runCli(['validate', '--strict'], dirs.recordAcrossSpread);

0 commit comments

Comments
 (0)