From a80b61dad02ea99863e147eb878c6d8bbeb25400 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <50353452+hotlong@users.noreply.github.com> Date: Tue, 29 Sep 2026 16:04:12 +0800 Subject: [PATCH 1/2] fix(cli): os lint --json folds the ADR-0087 conversions defineStack applied into conversions os lint filled conversions only from its own normalizeStackInput pass over the loaded config, which a defineStack default export hands over already canonical, so the notice reached stderr alone. Fold LoadedConfig.stackConversions right after loadConfig, the step 1b fold os validate / os build make. No one-authoring-shape refusal is added: an unbuilt export carries no record and converts through the pass as before. Claude-Session: https://claude.ai/code/session_local_1d2a197c-c20e-4e90-9be8-413d4d432289 Co-authored-by: Claude --- packages/cli/src/commands/lint.ts | 23 +++++++- .../test/stack-conversion-record-door.test.ts | 58 +++++++++++++++++++ 2 files changed, 80 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/commands/lint.ts b/packages/cli/src/commands/lint.ts index 0b36206419d..37bf1203bff 100644 --- a/packages/cli/src/commands/lint.ts +++ b/packages/cli/src/commands/lint.ts @@ -945,7 +945,27 @@ export default class Lint extends Command { const conversionNotices: ConversionNotice[] = []; try { - const { config, absolutePath } = await loadConfig(configPath); + const loaded = await loadConfig(configPath); + const { config, absolutePath } = loaded; + // [#20583] The ADR-0087 D2 conversions the stack PRODUCER applied — the + // fold `os validate` / `os build` make at their step 1b, one command over. + // `defineStack` converts at load (either mode), so the `config` this + // command received is already canonical and the pass below has nothing + // of the default export left to convert: without this fold `conversions` + // read `[]` on every `defineStack` config carrying a retiring spelling, + // and the notice reached stderr alone. Read by `loadConfig` off the + // default export before its named-export merge (`stackConversionsOf`, + // beside the provenance mark). ⛔ Folded, never recomputed. + // + // ⛔ NOT the one-authoring-shape rule: there is no `refuseUnbuiltStack` + // here, and none is implied. An unbuilt default export carries no record, + // so `stackConversionsOf` answers `[]` for it and the pass below converts + // it exactly as before — this command accepts what it accepted, and only + // the conversions it reports grow. The two sources do not overlap: the + // producer's output is canonical wherever it converted, so the pass finds + // only what no producer saw — an unbuilt export, or a key `loadConfig` + // merged from a NAMED export after the producer ran. + conversionNotices.push(...loaded.stackConversions); if (!flags.json) { printInfo(`Config: ${chalk.white(absolutePath)}`); @@ -953,6 +973,7 @@ export default class Lint extends Command { // The ADR-0087 D2 conversion layer runs here, inside `normalizeStackInput` // — it always did. Passing the sink is what makes the rewrites SAYABLE. + // After the fold above, what it can still find is what no producer saw. const normalized = normalizeStackInput(config as Record, { onConversionNotice: (n) => conversionNotices.push(n), }); diff --git a/packages/cli/test/stack-conversion-record-door.test.ts b/packages/cli/test/stack-conversion-record-door.test.ts index decfd6fa134..7ffaf14dcf4 100644 --- a/packages/cli/test/stack-conversion-record-door.test.ts +++ b/packages/cli/test/stack-conversion-record-door.test.ts @@ -31,6 +31,16 @@ * pass cannot both report one conversion. And `os validate --strict` exits 1 on * both faces for the retiring spelling, 0 for the control. * + * ## `os lint --json` — the same rows, plus the plain case (#20583) + * + * `os lint` reads the same `LoadedConfig.stackConversions` and folds it the + * same way, so it carries every row above, plus the plain case the card + * measured: one `defineStack` default export, no named export, which answered + * `conversions: []` with the notice on stderr alone. ⛔ Only the reporting + * moves on this door: it does not take the one-authoring-shape rule, so an + * unbuilt default export still lints and still converts through the command's + * own pass (`lint-conversion-notices.e2e.test.ts` pins that half). + * * `page-header-subtitle-alias` is the live conversion driven here (`description` * on a `page:header` component, canonical `subtitle`). The day it retires from * 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) => const IMPORT = `import { composeStacks, defineStack } from '@objectstack/spec';\n\n`; const FIXTURES: Record = { + // The plain case: one `defineStack` default export, no named export. + plain: IMPORT + `export default defineStack(${stackBody('pln', 'description')});\n`, // The record, across `loadConfig`'s named-export spread. recordAcrossSpread: IMPORT + @@ -207,6 +219,52 @@ for (const command of ['validate', 'build'] as const) { }); } +describe("os lint --json — the producer's conversion record reaches `conversions` (#20583)", () => { + // `os lint` folds the same record at the same point — right after + // `loadConfig` — and keeps its own pass for what no producer saw. It does NOT + // take the one-authoring-shape rule: an unbuilt default export still lints + // and still converts through the pass (`lint-conversion-notices.e2e.test.ts` + // pins that half on a plain object-literal export). + const lint = (label: string) => runCli(['lint', '--json'], dirs[label]); + const producerLine = "conversion 'page-header-subtitle-alias'"; + + it('the plain case: one `defineStack` default export carrying the retiring spelling', async () => { + const run = await lint('plain'); + const p = payloadOf(run, 'lint plain') as Payload & { passed?: boolean }; + expect(run.code, run.stdout + run.stderr).toBe(0); + expect(p.passed).toBe(true); + expectExactly(p, [THE_NOTICE], 'lint plain'); + // The producer's stderr line stays, and stays ONE: the payload now carries + // the notice, and the door adds no second stderr line for it. + expect(run.stderr.split(producerLine).length - 1, 'one stderr line for the one conversion').toBe(1); + }, 180_000); + + it('defineStack + a named export: the record is read off the default before the named-export spread', async () => { + const run = await lint('recordAcrossSpread'); + expect(run.code, run.stdout + run.stderr).toBe(0); + expectExactly(payloadOf(run, 'lint recordAcrossSpread'), [THE_NOTICE], 'lint recordAcrossSpread'); + }, 180_000); + + it('composeStacks: the composed stack carries the record of the input that applied the conversion', async () => { + const run = await lint('composed'); + expect(run.code, run.stdout + run.stderr).toBe(0); + expectExactly(payloadOf(run, 'lint composed'), [THE_NOTICE], 'lint composed'); + }, 180_000); + + it("a key merged from a named export: the command's own pass still converts it — once", async () => { + const run = await lint('namedExportPass'); + expect(run.code, run.stdout + run.stderr).toBe(0); + expectExactly(payloadOf(run, 'lint namedExportPass'), [THE_NOTICE], 'lint namedExportPass'); + expect(run.stderr).not.toContain(producerLine); + }, 180_000); + + it('control: the canonical spelling converts nothing — `[]`', async () => { + const run = await lint('canonical'); + expect(run.code, run.stdout + run.stderr).toBe(0); + expectExactly(payloadOf(run, 'lint canonical'), [], 'lint canonical'); + }, 180_000); +}); + describe('os validate --strict — a conversion the producer applied fails it, on both faces', () => { it('the retiring spelling: exit 1 on the text face and under --json', async () => { const text = await runCli(['validate', '--strict'], dirs.recordAcrossSpread); From f7968d9359eb71054cbf5d802b9a52274136661a Mon Sep 17 00:00:00 2001 From: Jack Zhuang <50353452+hotlong@users.noreply.github.com> Date: Tue, 29 Sep 2026 16:12:18 +0800 Subject: [PATCH 2/2] chore(changeset): os lint --json reports the conversions defineStack applied Claude-Session: https://claude.ai/code/session_local_1d2a197c-c20e-4e90-9be8-413d4d432289 Co-authored-by: Claude --- .../20583-lint-json-define-stack-conversions.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 .changeset/20583-lint-json-define-stack-conversions.md diff --git a/.changeset/20583-lint-json-define-stack-conversions.md b/.changeset/20583-lint-json-define-stack-conversions.md new file mode 100644 index 00000000000..2c0c7e546e9 --- /dev/null +++ b/.changeset/20583-lint-json-define-stack-conversions.md @@ -0,0 +1,13 @@ +--- +"@objectstack/cli": patch +--- + +**`objectstack lint --json` now reports the ADR-0087 conversions `defineStack` applied, as `objectstack validate` and `objectstack build` already do.** + +`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. + +`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. + +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. + +Clause-②: no