Skip to content

Commit ba5927f

Browse files
feat(spec,cli)!: one stack authoring shape — os validate / os build refuse a default export defineStack did not build (#20460)
Fixes #20367 Clause-②: yes Implements ruling B on #20367 (`Ruling-ref: 5869334748`): **one authoring shape**. `os validate` and `os build` now refuse a default export that no stack producer built, and `composeStacks` refuses an input no producer built. The six `STACK_*` cross-field refusals (capability, cross-reference, namespace prefix, single app, hierarchy scope, trigger capability) therefore reach both doors by construction, not only when the author happened to call `defineStack()`. ## What changed **spec (`@objectstack/spec`)** - `src/stack-provenance.ts` (new): a non-enumerable, non-writable `Symbol.for('objectstack.stack.provenance')` mark. The precedent is `data/filter-subtree-provenance.ts`. Stamping is internal to the two producers. The one published predicate is `hasStackProvenance(value)`, re-exported from `stack.zod.ts`. - `stack.zod.ts`: `defineStack` stamps its return value in both strict and `strict: false` mode. `composeStacks` stamps its artifact at every arity. Its step 0 refuses unbuilt inputs with `STACK_PROVENANCE_MISSING` (422), naming each refused input. This runs first, ahead of the single-input early return. The cross-field validators are unchanged and stay inside the producer. - `api/error-code-ledger.zod.ts`: registers `STACK_PROVENANCE_MISSING` under `@objectstack/spec` (emitted by `composeStacks`) and under `@objectstack/cli` (emitted by the doors). One condition, two emitters, following the `ENVIRONMENT_NOT_FOUND` precedent. The `:1323-1328` comment is left as written. The family is now raised by both doors through provenance. - ADR-0087 semantic migration entry `stack-config-default-export-unbuilt-refused`, plus the regenerated `registry.ts`, `api-surface/root.json`, `export-origins/root.json` and two reference docs pages. **cli (`@objectstack/cli`)** - `utils/config.ts`: `loadConfig` reads the mark off the default export before the named-export merge, which is a spread and drops the mark. It exposes the result as `LoadedConfig.stackProvenance`. - `utils/stack-provenance-refusal.ts` (new): `refuseUnbuiltStack` throws a `STACK_PROVENANCE_MISSING` / 422 error with the prescription to wrap the export in `defineStack(...)`. - `commands/validate.ts` and `commands/compile.ts`: step 1a, directly after load and ahead of every other judgement. The throw lands in each command's existing catch-all, so the `--json` envelope keeps its shape: `valid`/`success`, `error`, `code`, `warnings`, `conversions`. This is the same envelope a `defineStack` refusal raised at load already reaches. PR #20391's step 2c is on `main`, and this step sits above it in the same `try`, feeding the same catch-all envelope. - `test/validate-build-gate-parity.test.ts`: the roster gains a row for `refuseUnbuiltStack` in `SHARED_NON_REGISTRY_GATES`. - Retired the plain-object door in three places: the plugin README template in `create.ts`, the `generate.ts` field-type comment, and the `environment-routing.mdx` sentence about spread configs. ## Premise test (ruled to be run first) The premise was that host-shaped exports (a `plugins` list of instantiated plugin objects, i.e. the `os serve` / `os migrate` host configs) do not go through these two doors. Measured on this tree, it holds for those host configs: - The only plain-object host configs in the repo are CLI test fixtures for `os serve`, `os migrate` and `os doctor`. None of them runs `os validate`, `os build` or `os dev`, and every one of them passes unchanged in the unit, integration and nightly tiers. - The one host-shaped config that does reach both doors is `examples/app-showcase`, which is authored through `defineStack`. It carries the mark and passes both doors. - A plain host-shaped export can mechanically reach the doors. The ablation leg below shows it passed `os build` at exit 0 before this change. It is now refused like any other unbuilt export, and the prescribed fix works: `defineStack({ manifest, plugins: [instance] })` is accepted by both doors. That row is pinned. ## Pins (`test/stack-provenance-door.test.ts`, integration tier, both doors, `code` + exit) | default export | answer at `os validate --json` and `os build --json` | |:--|:--| | defective stack (`requires: ['no-such-capability']`) as `defineStack({ … })` | `STACK_CAPABILITY_UNKNOWN`, exit 1 | | the SAME stack as a plain object | `STACK_PROVENANCE_MISSING`, exit 1; the prescription's first sentence is asserted; `os build` writes no artifact | | host-shaped plain object | `STACK_PROVENANCE_MISSING`, exit 1 | | host-shaped `defineStack({ … })` | accepted, exit 0 | | spread copy `{ ...defineStack(…), api: {} }` | `STACK_PROVENANCE_MISSING`, exit 1 | | `defineStack` + named export `onEnable` | accepted, exit 0 (the mark is read before the merge) | `packages/spec/src/stack-provenance.test.ts` pins the rest: - both producers stamp, in every mode and at every arity; - the mark is not visible through `Object.keys`, `JSON.stringify` or the strict schema; - `false` for a literal, a spread copy, an assign copy, a JSON copy or a structured clone; - `composeStacks` refuses first, names every input and refuses a lone input; - both ledger rows exist. **Examples: the echo from route D does not reproduce.** Measured with the built CLI at the merged head: | example | `os validate` | `os build` | |:--|:--|:--| | `examples/app-crm` | exit 0, `valid: true` | exit 0, `success: true` | | `examples/app-todo` | exit 0, `valid: true` | exit 0, `success: true` | | `examples/app-multi-package` | exit 0, `valid: true` | exit 0, `success: true` | | `examples/app-showcase` | exit 0, `valid: true` | exit 0, `success: true` | The build door is also held in CI: each example's `build` script is `objectstack build`, and the root `turbo run build` ran all four green (`Tasks: 73 successful, 73 total`). ## Verification record - **Ablation.** Committed first, then mutated through `scripts/ablation-replace.mjs`: `if (loaded.stackProvenance) return;` became `return;`, landing confirmed by anchor 1 → 0 and blob `5ce32a82` → `d78f0948`. The door test's plain-object rows went red (4 failed; under the mutation the plain defective stack answered `code: undefined` at exit 0 and the plain host shape built at exit 0, which is the original defect). The file was restored byte-identical: its blob equals the `HEAD` blob and `git diff HEAD` is empty. The green leg is the full integration run at `HEAD`. - **`@objectstack/spec`:** `vitest run`, 602 files, 17349 passed. - **`@objectstack/cli` unit:** 233 files, 3336 passed. - **`@objectstack/cli` integration:** 62 files, 533 passed, 1 skipped. - **`@objectstack/cli` nightly e2e tier** (`OS_TEST_TIERS=nightly`): the 18 files this change reddened or touched are green, 96 + 57 + 6 tests across three runs. The first full nightly run (17 red files) is what named them. - **Other consumers:** the `composeStacks` test inputs in `@objectstack/metadata`, `@objectstack/runtime`, `@objectstack/plugin-dev` and `@objectstack/plugin-security` are green (1 + 3 + 1 + 1 files). Filter direction: `composeStacks` / `os validate` / `os build` callers found by `git grep` over `packages/**`. No non-test caller of `composeStacks` exists outside `stack.zod.ts`; every other hit is a comment. - **Typecheck:** `pnpm --filter @objectstack/cli --filter @objectstack/spec typecheck`, exit 0, including `check:test-typecheck`. - **Generated artifacts:** `pnpm --filter @objectstack/spec check:generated --fix` rewrote only the three it proved stale (api-surface, export-origins, docs). All 15 were green on re-check. - **Gates:** `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` derived 123 commands at head `0360658`. 122 exited 0. One is NOT MEASURED: `node scripts/check-plugin-teardown-shape.mjs --self-test` exited 3 on the shallow clone, because its pinned fixture commit is unreachable; it is checker-health only. `--ran` reconciliation: 123 accounted, 122 run, 1 NOT-MEASURED. Four gates first exited 3 for lack of a full build (`check:skill-examples`, `check:dual-build-cjs-loads`, `check:i18n-coverage`, `check:type-check-debt`). They were re-run after the full build and exited 0. ## Fixture census The dispatch estimated "63 CLI test files". The instrument used here is the suite's own reds after the change, across all three tiers, plus `git grep` for `composeStacks(` inputs: - CLI: 3 unit, 11 integration and 17 nightly e2e files red, plus the re-judged `requires-retired-capability.e2e.test.ts`. Each fixture was rewritten to `defineStack(…)` with spec linked into the temp dir through the new `test/helpers/define-stack-fixture.ts`. - spec: 4 test files red. Their hand-built inputs are now a built stack mutated after `defineStack` returned: the mark survives in-place mutation, so this is the reachable route to the composer guards. - Other packages: 3 test files with plain `composeStacks` inputs. Fixtures for `os serve`, `os migrate`, `os doctor` and `os lint` were not rewritten. Those doors are outside the ruled surface and do not refuse; this is the host-config premise above. ## Acceptance notes - **Pin re-judged: `requires-retired-capability.e2e.test.ts`.** An unknown `requires` token is now the producer's `STACK_CAPABILITY_UNKNOWN`, exit 1, at both doors. The retired token's spec-owned prescription is asserted verbatim in the refusal message, and the typo hint is asserted to appear exactly once, for the misspelled token. - **Pins re-judged: the conversions and `--strict` pins.** These are `validate-json-failure-conversions.e2e`, `build-json-failure-conversions.e2e` and the #11301 cell of `validate-json-strict-exit.e2e`. `defineStack` applies every ADR-0087 D2 conversion itself at load, in either mode, and reports it on stderr. That leaves the doors' own step-2 sink nothing to convert on any accepted config, so `conversions` is `[]` on every exit and `--strict` does not fail on a retiring conversion. This was already true for every `defineStack` config before this PR. The pins now assert `[]` plus the live notice on the producer's stderr line, matched by conversion id and path. The loss is raised as an open question in the report, not fixed here. - **`os dev`** compiles through `os build`, so it refuses an unbuilt export when it compiles. `os serve`, `os migrate`, `os lint` and `os generate` still load unmarked configs, because the ruling scopes the refusal to `os validate` and `os build`. The `generate.ts` comment now says so rather than claiming the door is gone everywhere. - **Fixture spelling.** Fixtures whose content is the door's to judge use `defineStack(…, { strict: false })`: schema errors the door must report, rule findings, the conversion fixtures. Valid fixtures use strict `defineStack`. - **Landing site vs the declared surface.** Three additions go beyond the claim's file surface: the ADR-0087 semantic entry and its generated `registry.ts` region (the `registered` disposition the breaking changeset needs), test inputs in three other packages, and the stale comment in `capability-preflight.test.ts`. Each is required by the change itself. - **Serial constraint.** PR #20391 is on `main`. `main` was merged into this branch at `6e3e546` before this PR opened. --- _Generated by [Claude Code](https://claude.ai/code/session_01RTkKf8Dn5F4mepiZZfWoxH)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7e36a3c commit ba5927f

61 files changed

Lines changed: 1463 additions & 265 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/cli": minor
4+
---
5+
6+
**BREAKING — one authoring shape for a stack config.** `objectstack validate` and `objectstack build` now refuse a config whose default export was not built by `defineStack(...)` (either mode) or `composeStacks(...)`, with `STACK_PROVENANCE_MISSING` and exit 1, right after the config loads and before any other check. `composeStacks` refuses an input no producer built the same way.
7+
8+
Why: the stack family's cross-field refusals (`STACK_CAPABILITY_UNKNOWN`, `STACK_CROSS_REFERENCE_INVALID`, `STACK_NAMESPACE_PREFIX_INVALID`, `STACK_SINGLE_APP_VIOLATION`, `STACK_HIERARCHY_SCOPE_CAPABILITY_REQUIRED`, `STACK_TRIGGER_CAPABILITY_REQUIRED`) run inside `defineStack` only. The same defective stack exported as a plain object passed both commands at exit 0, and `objectstack build` shipped it. Re-running those refusals on whatever the config exports cannot fix that: a built stack carries each bound action twice, so the re-run refuses every correct project that has one. So the commands check who BUILT the export instead.
9+
10+
- `@objectstack/spec`: `defineStack` and `composeStacks` stamp a non-enumerable `Symbol.for` provenance mark on what they return. The mark is invisible to the schema, to `Object.keys` and to `JSON.stringify`, so no compiled artifact changes. New export: `hasStackProvenance(value)` — `true` only for a value one of the two producers returned. New registered error code: `STACK_PROVENANCE_MISSING` (422), raised by `composeStacks` for an unbuilt input.
11+
- `@objectstack/cli`: `loadConfig` reads the mark off the default export before merging named exports into it (the merge is a spread, which drops the mark), and exposes it as `LoadedConfig.stackProvenance`. `objectstack validate` / `objectstack build` refuse on `false` through their existing error path: under `--json`, `error` + `code: 'STACK_PROVENANCE_MISSING'`. The envelope has no new fields. `objectstack dev` compiles through `objectstack build`, so it refuses the same way when it compiles. `objectstack serve`, `objectstack migrate`, `objectstack lint` and `objectstack generate` load configs exactly as before.
12+
13+
**Migration** — FROM a plain-object (or copied) default export TO the value `defineStack` returns:
14+
15+
```ts
16+
// FROM
17+
export default {
18+
manifest: { id: 'com.example.app', namespace: 'app', version: '1.0.0', type: 'app', name: 'App' },
19+
objects: [/* … */],
20+
};
21+
// or: export default { ...defineStack({ … }), api: { … } };
22+
23+
// TO
24+
import { defineStack } from '@objectstack/spec';
25+
26+
export default defineStack({
27+
manifest: { id: 'com.example.app', namespace: 'app', version: '1.0.0', type: 'app', name: 'App' },
28+
objects: [/* … */],
29+
// every stack key inside the call — `api`, `plugins`, `requires`, …
30+
});
31+
```
32+
33+
One-line fix: wrap the export in `defineStack(...)`, and move any key spread onto a copy into the call. For compositions, wrap each input: `composeStacks([defineStack({ … }), …])`. Once wrapped, a config that used to pass can now fail with one of the family's own codes. Those findings were always there; the plain export hid them. Fix each one as its message says. Host-style configs whose `plugins` hold plugin instances are covered by the same rule, and the same wrap fixes them (`defineStack` accepts plugin instances). A project already exporting `defineStack(...)` or `composeStacks([...])` of `defineStack` inputs is unaffected.
34+
35+
Clause-②: yes (narrowing)
36+
37+
<!-- adr-0087: registered stack-config-default-export-unbuilt-refused -->

‎content/docs/api/environment-routing.mdx‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -72,9 +72,10 @@ declared stack key, so `defineStack()` rejects it as an unrecognized key.
7272
<Callout type="info">
7373
`api` is a declared top-level field on `ObjectStackDefinitionSchema`, so it
7474
survives `defineStack`'s strict parsing — you can pass it directly inside the
75-
`defineStack({ ... })` call as shown above. (Older stacks that instead spread
76-
it onto the exported config object, e.g. `export default { ...stack, api: {...} }`,
77-
still work the same way.) The CLI reads the resolved value from the exported
75+
`defineStack({ ... })` call as shown above — and keep it there. Spreading it onto
76+
a copy of the built stack instead, e.g. `export default { ...stack, api: {...} }`,
77+
exports an object `defineStack` did not return, which `os validate` and `os build`
78+
refuse (`STACK_PROVENANCE_MISSING`). The CLI reads the resolved value from the exported
7879
config (`config.api`) when registering the REST and dispatcher plugins — but it
7980
reads it *after* the boot result has been merged in, which is why the standalone
8081
path forwards the boot builder's scoping decision rather than the author's.

‎content/docs/references/api/contract.mdx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ const result = ApiErrorSchema.parse(data);
2828

2929
| Property | Type | Required | Description |
3030
| :--- | :--- | :--- | :--- |
31-
| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +321 more>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) |
31+
| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +322 more>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) |
3232
| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) |
3333
| **message** | `string` | ✅ | Readable error message |
3434
| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. |
@@ -335,6 +335,7 @@ const result = ApiErrorSchema.parse(data);
335335
* `STACK_CROSS_REFERENCE_INVALID`
336336
* `STACK_HIERARCHY_SCOPE_CAPABILITY_REQUIRED`
337337
* `STACK_NAMESPACE_PREFIX_INVALID`
338+
* `STACK_PROVENANCE_MISSING`
338339
* `STACK_SCHEMA_INVALID`
339340
* `STACK_SINGLE_APP_VIOLATION`
340341
* `STACK_TRIGGER_CAPABILITY_REQUIRED`

‎content/docs/references/api/error-code-ledger.mdx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -502,6 +502,7 @@ const result = ErrorCode.parse(data);
502502
* `STACK_CROSS_REFERENCE_INVALID`
503503
* `STACK_HIERARCHY_SCOPE_CAPABILITY_REQUIRED`
504504
* `STACK_NAMESPACE_PREFIX_INVALID`
505+
* `STACK_PROVENANCE_MISSING`
505506
* `STACK_SCHEMA_INVALID`
506507
* `STACK_SINGLE_APP_VIOLATION`
507508
* `STACK_TRIGGER_CAPABILITY_REQUIRED`

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

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
type ConversionNotice,
1515
} from '@objectstack/spec';
1616
import { loadConfig, namedExportRejectionHints } from '../utils/config.js';
17+
import { refuseUnbuiltStack } from '../utils/stack-provenance-refusal.js';
1718
import { lowerCallables } from '../utils/lower-callables.js';
1819
import { authoringRuleUnionStack } from '../utils/stack-collections.js';
1920
import { artifactPackages, runPerPackageAuthoringRules } from '../utils/artifact-packages.js';
@@ -252,7 +253,15 @@ export default class Compile extends Command {
252253
try {
253254
// 1. Load Configuration
254255
if (!flags.json) printStep('Loading configuration...');
255-
const { config, absolutePath, duration, namedExports } = await loadConfig(args.config);
256+
const loaded = await loadConfig(args.config);
257+
const { config, absolutePath, duration, namedExports } = loaded;
258+
// 1a. [#20367 ruling B] One authoring shape: refuse a default export no
259+
// stack producer built, BEFORE any other judgement — the `STACK_*`
260+
// cross-field refusals run inside `defineStack` only, so an unbuilt
261+
// export would otherwise pass this door unjudged. Throws into the
262+
// catch-all below (`--json`: `error` + `code`, exit 1), the same
263+
// envelope a `defineStack` refusal raised at load reaches.
264+
refuseUnbuiltStack(loaded);
256265

257266
if (!flags.json) {
258267
printKV('Config', path.relative(process.cwd(), absolutePath));
@@ -731,9 +740,10 @@ export default class Compile extends Command {
731740
// 3d. [#3786] Keys `ObjectSchema` / `FieldSchema` do not declare, and so
732741
// drop silently on the way to storage. PRE-parse, since the parse is
733742
// what strips them. `defineStack` already warns for configs authored
734-
// through it; this covers the ones that skip it (a plain object
735-
// default-export, `strict: false`) and would otherwise emit an
736-
// artifact with the key quietly gone. Advisory, never fatal.
743+
// through it; this covers the ones that skip it (`strict: false`;
744+
// a plain-object default export no longer gets this far — step 1a
745+
// refuses it) and would otherwise emit an artifact with the key
746+
// quietly gone. Advisory, never fatal.
737747
//
738748
// [#11643] FORMATTED HERE, once, and consumed by BOTH faces — the
739749
// text block just below and the `--json` payload at the end of this

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

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -502,14 +502,16 @@ hyphen, an underscore, a leading digit) are folded away, so the exported symbol
502502
can differ from the name.
503503
504504
\`\`\`typescript
505+
import { defineStack } from '@objectstack/spec';
505506
import { ${sanitizeIdentifier(name)}Plugin } from '${packageName}';
506507
507-
// Use the plugin in your ObjectStack configuration
508-
export default {
508+
// Use the plugin in your ObjectStack configuration — always through
509+
// defineStack(): \`os validate\` / \`os build\` refuse any other default export.
510+
export default defineStack({
509511
plugins: [
510512
${sanitizeIdentifier(name)}Plugin,
511513
],
512-
};
514+
});
513515
\`\`\`
514516
515517
## License

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

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -811,9 +811,11 @@ function nameCharsetRefusal(name: string): string | null {
811811
* of them silently generated `unknown` — a plausible-looking wrong type with
812812
* nothing to tell the author. The `|| 'unknown'` below stays, and now means
813813
* only what it always should have: this generator's answer for a `type` string
814-
* that is not a `FieldType` at all, which the UNVALIDATED authoring door (a
815-
* plain-object config export, `defineStack(x, { strict: false })`) can still
816-
* deliver.
814+
* that is not a `FieldType` at all, which the UNVALIDATED authoring mode
815+
* (`defineStack(x, { strict: false })`) can still deliver. A plain-object config
816+
* export is no longer a legal authoring shape — `os validate` / `os build`
817+
* refuse a default export `defineStack` did not build (`STACK_PROVENANCE_MISSING`)
818+
* — though this command, which checks no provenance, still loads one.
817819
*
818820
* Values are MEASURED, not invented — each one is the shape the platform
819821
* actually implements, read from the spec's ADR-0104 D1 value classes

‎packages/cli/src/commands/validate-json-strict-exit.e2e.test.ts‎

Lines changed: 64 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,14 @@
6464
* was written: `description` → text `--strict` 1, `--json --strict` 1, `--json`
6565
* 0, `warnings: []`, one notice; `subtitle` → 0 on every face, no notices.
6666
*
67-
* The non-empty `conversions` assertion is the anti-vacuity guard, and it is
67+
* ⚠️ Re-judged under the one-authoring-shape ruling (#20367): `os validate`
68+
* refuses a default export `defineStack` did not build, and `defineStack`
69+
* applies the conversion itself at load (stderr notice), so the door's
70+
* `conversions` is empty and the cell below is unreachable by an accepted
71+
* config. The pin now holds what IS true — both faces agree at exit 0, the
72+
* notice fires in the producer — and the anti-vacuity guard reads stderr.
73+
*
74+
* The live-notice assertion (on stderr since the re-judgement) is the anti-vacuity guard, and it is
6875
* load-bearing rather than decorative. `page-header-subtitle-alias` is a LIVE
6976
* window that retires from the load path at protocol 18; the day it retires,
7077
* this fixture raises nothing and, without that assertion, the file would keep
@@ -104,11 +111,25 @@
104111

105112
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
106113
import { execFile } from 'node:child_process';
107-
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
114+
import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
115+
import { createRequire } from 'node:module';
108116
import { tmpdir } from 'node:os';
109-
import { join, resolve } from 'node:path';
117+
import { dirname, join, resolve } from 'node:path';
110118
import { fileURLToPath } from 'node:url';
111119

120+
/**
121+
* The fixture projects are `defineStack` configs (`os validate` refuses any
122+
* other default export — #20367 ruling B), so each OS-tmpdir project gets a
123+
* `node_modules/@objectstack/spec` link to the package this one depends on —
124+
* the `test/helpers/define-stack-fixture.ts` spelling, local here because this
125+
* file lives under `src/`, outside the test helpers' tsconfig root.
126+
*/
127+
const SPEC_PACKAGE_ROOT = dirname(createRequire(import.meta.url).resolve('@objectstack/spec/package.json'));
128+
function linkSpec(dir: string): void {
129+
mkdirSync(join(dir, 'node_modules', '@objectstack'), { recursive: true });
130+
symlinkSync(SPEC_PACKAGE_ROOT, join(dir, 'node_modules', '@objectstack', 'spec'), 'dir');
131+
}
132+
112133
const HERE = resolve(fileURLToPath(import.meta.url), '..');
113134
const CLI = resolve(HERE, '../../bin/run-dev.js');
114135
const TSX = resolve(HERE, '../../../../node_modules/.bin/tsx');
@@ -121,15 +142,19 @@ const TSX = resolve(HERE, '../../../../node_modules/.bin/tsx');
121142
* before any advisory is computed.
122143
*/
123144
const WARNS_SOURCE = `
124-
export default {
145+
import { defineStack } from '@objectstack/spec';
146+
147+
export default defineStack({
125148
objects: [],
126149
apps: [],
127-
};
150+
}, { strict: false });
128151
`;
129152

130153
/** The zero-warning control — pins the other end of the matrix. */
131154
const CLEAN_SOURCE = `
132-
export default {
155+
import { defineStack } from '@objectstack/spec';
156+
157+
export default defineStack({
133158
manifest: { id: 'com.example.strictexit', name: 'strictexit', version: '1.0.0', type: 'app', namespace: 'strictexit' },
134159
objects: [{
135160
name: 'strictexit_ticket',
@@ -138,7 +163,7 @@ export default {
138163
fields: { title: { type: 'text', label: 'Title' } },
139164
}],
140165
apps: [{ name: 'strictexit_app', label: 'Strict Exit App' }],
141-
};
166+
}, { strict: false });
142167
`;
143168

144169
/**
@@ -151,7 +176,9 @@ export default {
151176
* assumed to be.
152177
*/
153178
const headerPageSource = (headerTextKey: 'description' | 'subtitle'): string => `
154-
export default {
179+
import { defineStack } from '@objectstack/spec';
180+
181+
export default defineStack({
155182
manifest: { id: 'com.example.strictexit', name: 'strictexit', version: '1.0.0', type: 'app', namespace: 'strictexit' },
156183
objects: [{
157184
name: 'strictexit_ticket',
@@ -167,7 +194,7 @@ export default {
167194
{ type: 'page:header', properties: { title: 'Tickets', ${headerTextKey}: 'All open tickets' } },
168195
] }],
169196
}],
170-
};
197+
}, { strict: false });
171198
`;
172199

173200
interface Run {
@@ -201,12 +228,16 @@ let conversionsCanonDir: string;
201228
beforeAll(() => {
202229
warnsDir = mkdtempSync(join(tmpdir(), 'os-validate-strict-exit-warns-'));
203230
writeFileSync(join(warnsDir, 'objectstack.config.ts'), WARNS_SOURCE);
231+
linkSpec(warnsDir);
204232
cleanDir = mkdtempSync(join(tmpdir(), 'os-validate-strict-exit-clean-'));
205233
writeFileSync(join(cleanDir, 'objectstack.config.ts'), CLEAN_SOURCE);
234+
linkSpec(cleanDir);
206235
conversionsDir = mkdtempSync(join(tmpdir(), 'os-validate-strict-exit-conversions-'));
207236
writeFileSync(join(conversionsDir, 'objectstack.config.ts'), headerPageSource('description'));
237+
linkSpec(conversionsDir);
208238
conversionsCanonDir = mkdtempSync(join(tmpdir(), 'os-validate-strict-exit-conversions-canon-'));
209239
writeFileSync(join(conversionsCanonDir, 'objectstack.config.ts'), headerPageSource('subtitle'));
240+
linkSpec(conversionsCanonDir);
210241
});
211242

212243
afterAll(() => {
@@ -261,46 +292,40 @@ describe('#11174 — --strict reaches the same exit status on both faces', () =>
261292
expect(json.code, `json --strict:\n${json.stdout}\n${json.stderr}`).toBe(0);
262293
}, 120_000);
263294

264-
it('conversions-only: the cell where --strict is decided by a collection the payload keeps OUT of `warnings`', async () => {
295+
it('conversions-only (ruling B): the PRODUCER consumes the conversion at load — both faces agree at exit 0', async () => {
296+
// Re-judged under the one-authoring-shape ruling (#20367). A config is now
297+
// always `defineStack(…)` output, and `defineStack` runs the D2 conversion
298+
// itself (either mode) and reports it on stderr, so the door's own
299+
// `normalizeStackInput` has nothing left to convert: the #11301 cell —
300+
// `{ valid: true, warnings: [], conversions: [...] }` at exit 1 — is no
301+
// longer reachable by a config the door accepts. ⚠️ Recorded, not endorsed:
302+
// `--strict` therefore does not gate on a retiring conversion for ANY
303+
// accepted config (it never did for a `defineStack` one); the PR reports
304+
// that as an open finding rather than widening this change to fix it.
265305
const text = await runCli(['validate', '--strict'], conversionsDir);
266306
const json = await runCli(['validate', '--json', '--strict'], conversionsDir);
267307

268-
// Same floor as the first case: equality is only worth asserting over a run
269-
// that genuinely had something to fail on.
270-
expect(
271-
text.code,
272-
`text --strict must fail on the conversions-only config:\n${text.stdout}\n${text.stderr}`,
273-
).not.toBe(0);
274-
275-
expect(
276-
json.code,
277-
`--json --strict exited ${json.code} where --strict exited ${text.code}, same config.\n` +
278-
`json stdout:\n${json.stdout}\njson stderr:\n${json.stderr}`,
279-
).toBe(text.code);
308+
// Parity, the #11174 contract this file exists for, still holds.
309+
expect(text.code, `text --strict:\n${text.stdout}\n${text.stderr}`).toBe(0);
310+
expect(json.code, `json --strict:\n${json.stdout}\n${json.stderr}`).toBe(text.code);
280311

281312
const payload = JSON.parse(json.stdout) as {
282313
valid?: unknown;
283314
warnings?: unknown;
284315
conversions?: unknown;
285316
};
286-
287-
// The cell spelled out. `warnings: []` is asserted, not tolerated: it is the
288-
// whole point — narrow the gate to this field and the run above drops to 0
289-
// while the text face stays at 1.
290317
expect(payload.valid).toBe(true);
291318
expect(payload.warnings).toEqual([]);
292-
expect(
293-
Array.isArray(payload.conversions) && (payload.conversions as unknown[]).length,
294-
'the fixture raised NO conversion — the alias has most likely retired from ' +
295-
'the load path; re-point `headerPageSource` at a live entry in ' +
296-
'`packages/spec/src/conversions/registry.ts` rather than deleting this line',
297-
).toBeGreaterThan(0);
319+
expect(payload.conversions, 'the door computed no conversion: the producer already applied it').toEqual([]);
298320

299-
// Separates "gates on --strict" from "fails whenever a conversion is seen".
300-
// The warnings fixture's own without-strict control cannot cover this: it
301-
// raises no conversions, so it passes under either behaviour.
302-
const loose = await runCli(['validate', '--json'], conversionsDir);
303-
expect(loose.code, `--json without --strict must stay 0:\n${loose.stdout}\n${loose.stderr}`).toBe(0);
321+
// Anti-vacuity: the conversion is LIVE — it fired, in the producer, on both
322+
// faces. The day `page-header-subtitle-alias` retires this goes red; re-point
323+
// `headerPageSource` at a live entry in `packages/spec/src/conversions/registry.ts`.
324+
for (const run of [text, json]) {
325+
expect(run.stderr, 'defineStack reported the conversion at load').toContain(
326+
"conversion 'page-header-subtitle-alias'",
327+
);
328+
}
304329
}, 120_000);
305330

306331
it('control: the same page under the CANONICAL key converts nothing and exits 0 on both faces', async () => {
@@ -315,6 +340,8 @@ describe('#11174 — --strict reaches the same exit status on both faces', () =>
315340
const payload = JSON.parse(json.stdout) as { warnings?: unknown; conversions?: unknown };
316341
expect(payload.warnings).toEqual([]);
317342
expect(payload.conversions).toEqual([]);
343+
// The discriminator's other half: the canonical key raises no notice anywhere.
344+
expect(json.stderr).not.toContain("conversion 'page-header-subtitle-alias'");
318345
}, 120_000);
319346

320347
it('control: without --strict, the same advisory-raising config still exits 0 under --json', async () => {

0 commit comments

Comments
 (0)