Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/20583-lint-json-define-stack-conversions.md
Original file line number Diff line number Diff line change
@@ -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
23 changes: 22 additions & 1 deletion packages/cli/src/commands/lint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -945,14 +945,35 @@ 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)}`);
}

// 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<string, unknown>, {
onConversionNotice: (n) => conversionNotices.push(n),
});
Expand Down
58 changes: 58 additions & 0 deletions packages/cli/test/stack-conversion-record-door.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<string, string> = {
// 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 +
Expand Down Expand Up @@ -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);
Expand Down
Loading