Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
37 changes: 37 additions & 0 deletions .changeset/20367-one-stack-authoring-shape.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
---
"@objectstack/spec": minor
"@objectstack/cli": minor
---

**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.

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.

- `@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.
- `@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.

**Migration** — FROM a plain-object (or copied) default export TO the value `defineStack` returns:

```ts
// FROM
export default {
manifest: { id: 'com.example.app', namespace: 'app', version: '1.0.0', type: 'app', name: 'App' },
objects: [/* … */],
};
// or: export default { ...defineStack({ … }), api: { … } };

// TO
import { defineStack } from '@objectstack/spec';

export default defineStack({
manifest: { id: 'com.example.app', namespace: 'app', version: '1.0.0', type: 'app', name: 'App' },
objects: [/* … */],
// every stack key inside the call — `api`, `plugins`, `requires`, …
});
```

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.

Clause-②: yes (narrowing)

<!-- adr-0087: registered stack-config-default-export-unbuilt-refused -->
7 changes: 4 additions & 3 deletions content/docs/api/environment-routing.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -72,9 +72,10 @@ declared stack key, so `defineStack()` rejects it as an unrecognized key.
<Callout type="info">
`api` is a declared top-level field on `ObjectStackDefinitionSchema`, so it
survives `defineStack`'s strict parsing — you can pass it directly inside the
`defineStack({ ... })` call as shown above. (Older stacks that instead spread
it onto the exported config object, e.g. `export default { ...stack, api: {...} }`,
still work the same way.) The CLI reads the resolved value from the exported
`defineStack({ ... })` call as shown above — and keep it there. Spreading it onto
a copy of the built stack instead, e.g. `export default { ...stack, api: {...} }`,
exports an object `defineStack` did not return, which `os validate` and `os build`
refuse (`STACK_PROVENANCE_MISSING`). The CLI reads the resolved value from the exported
config (`config.api`) when registering the REST and dispatcher plugins — but it
reads it *after* the boot result has been merged in, which is why the standalone
path forwards the boot builder's scoping decision rather than the author's.
Expand Down
3 changes: 2 additions & 1 deletion content/docs/references/api/contract.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ const result = ApiErrorSchema.parse(data);

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **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) |
| **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) |
| **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) |
| **message** | `string` | ✅ | Readable error message |
| **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`. |
Expand Down Expand Up @@ -335,6 +335,7 @@ const result = ApiErrorSchema.parse(data);
* `STACK_CROSS_REFERENCE_INVALID`
* `STACK_HIERARCHY_SCOPE_CAPABILITY_REQUIRED`
* `STACK_NAMESPACE_PREFIX_INVALID`
* `STACK_PROVENANCE_MISSING`
* `STACK_SCHEMA_INVALID`
* `STACK_SINGLE_APP_VIOLATION`
* `STACK_TRIGGER_CAPABILITY_REQUIRED`
Expand Down
1 change: 1 addition & 0 deletions content/docs/references/api/error-code-ledger.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -502,6 +502,7 @@ const result = ErrorCode.parse(data);
* `STACK_CROSS_REFERENCE_INVALID`
* `STACK_HIERARCHY_SCOPE_CAPABILITY_REQUIRED`
* `STACK_NAMESPACE_PREFIX_INVALID`
* `STACK_PROVENANCE_MISSING`
* `STACK_SCHEMA_INVALID`
* `STACK_SINGLE_APP_VIOLATION`
* `STACK_TRIGGER_CAPABILITY_REQUIRED`
Expand Down
18 changes: 14 additions & 4 deletions packages/cli/src/commands/compile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
type ConversionNotice,
} from '@objectstack/spec';
import { loadConfig, namedExportRejectionHints } from '../utils/config.js';
import { refuseUnbuiltStack } from '../utils/stack-provenance-refusal.js';
import { lowerCallables } from '../utils/lower-callables.js';
import { authoringRuleUnionStack } from '../utils/stack-collections.js';
import { artifactPackages, runPerPackageAuthoringRules } from '../utils/artifact-packages.js';
Expand Down Expand Up @@ -252,7 +253,15 @@ export default class Compile extends Command {
try {
// 1. Load Configuration
if (!flags.json) printStep('Loading configuration...');
const { config, absolutePath, duration, namedExports } = await loadConfig(args.config);
const loaded = await loadConfig(args.config);
const { config, absolutePath, duration, namedExports } = loaded;
// 1a. [#20367 ruling B] One authoring shape: refuse a default export no
// stack producer built, BEFORE any other judgement — the `STACK_*`
// cross-field refusals run inside `defineStack` only, so an unbuilt
// export would otherwise pass this door unjudged. Throws into the
// catch-all below (`--json`: `error` + `code`, exit 1), the same
// envelope a `defineStack` refusal raised at load reaches.
refuseUnbuiltStack(loaded);

if (!flags.json) {
printKV('Config', path.relative(process.cwd(), absolutePath));
Expand Down Expand Up @@ -731,9 +740,10 @@ export default class Compile extends Command {
// 3d. [#3786] Keys `ObjectSchema` / `FieldSchema` do not declare, and so
// drop silently on the way to storage. PRE-parse, since the parse is
// what strips them. `defineStack` already warns for configs authored
// through it; this covers the ones that skip it (a plain object
// default-export, `strict: false`) and would otherwise emit an
// artifact with the key quietly gone. Advisory, never fatal.
// through it; this covers the ones that skip it (`strict: false`;
// a plain-object default export no longer gets this far — step 1a
// refuses it) and would otherwise emit an artifact with the key
// quietly gone. Advisory, never fatal.
//
// [#11643] FORMATTED HERE, once, and consumed by BOTH faces — the
// text block just below and the `--json` payload at the end of this
Expand Down
8 changes: 5 additions & 3 deletions packages/cli/src/commands/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -502,14 +502,16 @@ hyphen, an underscore, a leading digit) are folded away, so the exported symbol
can differ from the name.

\`\`\`typescript
import { defineStack } from '@objectstack/spec';
import { ${sanitizeIdentifier(name)}Plugin } from '${packageName}';

// Use the plugin in your ObjectStack configuration
export default {
// Use the plugin in your ObjectStack configuration — always through
// defineStack(): \`os validate\` / \`os build\` refuse any other default export.
export default defineStack({
plugins: [
${sanitizeIdentifier(name)}Plugin,
],
};
});
\`\`\`

## License
Expand Down
8 changes: 5 additions & 3 deletions packages/cli/src/commands/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -811,9 +811,11 @@ function nameCharsetRefusal(name: string): string | null {
* of them silently generated `unknown` — a plausible-looking wrong type with
* nothing to tell the author. The `|| 'unknown'` below stays, and now means
* only what it always should have: this generator's answer for a `type` string
* that is not a `FieldType` at all, which the UNVALIDATED authoring door (a
* plain-object config export, `defineStack(x, { strict: false })`) can still
* deliver.
* that is not a `FieldType` at all, which the UNVALIDATED authoring mode
* (`defineStack(x, { strict: false })`) can still deliver. A plain-object config
* export is no longer a legal authoring shape — `os validate` / `os build`
* refuse a default export `defineStack` did not build (`STACK_PROVENANCE_MISSING`)
* — though this command, which checks no provenance, still loads one.
*
* Values are MEASURED, not invented — each one is the shape the platform
* actually implements, read from the spec's ADR-0104 D1 value classes
Expand Down
101 changes: 64 additions & 37 deletions packages/cli/src/commands/validate-json-strict-exit.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,14 @@
* was written: `description` → text `--strict` 1, `--json --strict` 1, `--json`
* 0, `warnings: []`, one notice; `subtitle` → 0 on every face, no notices.
*
* The non-empty `conversions` assertion is the anti-vacuity guard, and it is
* ⚠️ Re-judged under the one-authoring-shape ruling (#20367): `os validate`
* refuses a default export `defineStack` did not build, and `defineStack`
* applies the conversion itself at load (stderr notice), so the door's
* `conversions` is empty and the cell below is unreachable by an accepted
* config. The pin now holds what IS true — both faces agree at exit 0, the
* notice fires in the producer — and the anti-vacuity guard reads stderr.
*
* The live-notice assertion (on stderr since the re-judgement) is the anti-vacuity guard, and it is
* load-bearing rather than decorative. `page-header-subtitle-alias` is a LIVE
* window that retires from the load path at protocol 18; the day it retires,
* this fixture raises nothing and, without that assertion, the file would keep
Expand Down Expand Up @@ -104,11 +111,25 @@

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { execFile } from 'node:child_process';
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
import { createRequire } from 'node:module';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';

/**
* The fixture projects are `defineStack` configs (`os validate` refuses any
* other default export — #20367 ruling B), so each OS-tmpdir project gets a
* `node_modules/@objectstack/spec` link to the package this one depends on —
* the `test/helpers/define-stack-fixture.ts` spelling, local here because this
* file lives under `src/`, outside the test helpers' tsconfig root.
*/
const SPEC_PACKAGE_ROOT = dirname(createRequire(import.meta.url).resolve('@objectstack/spec/package.json'));
function linkSpec(dir: string): void {
mkdirSync(join(dir, 'node_modules', '@objectstack'), { recursive: true });
symlinkSync(SPEC_PACKAGE_ROOT, join(dir, 'node_modules', '@objectstack', 'spec'), 'dir');
}

const HERE = resolve(fileURLToPath(import.meta.url), '..');
const CLI = resolve(HERE, '../../bin/run-dev.js');
const TSX = resolve(HERE, '../../../../node_modules/.bin/tsx');
Expand All @@ -121,15 +142,19 @@ const TSX = resolve(HERE, '../../../../node_modules/.bin/tsx');
* before any advisory is computed.
*/
const WARNS_SOURCE = `
export default {
import { defineStack } from '@objectstack/spec';

export default defineStack({
objects: [],
apps: [],
};
}, { strict: false });
`;

/** The zero-warning control — pins the other end of the matrix. */
const CLEAN_SOURCE = `
export default {
import { defineStack } from '@objectstack/spec';

export default defineStack({
manifest: { id: 'com.example.strictexit', name: 'strictexit', version: '1.0.0', type: 'app', namespace: 'strictexit' },
objects: [{
name: 'strictexit_ticket',
Expand All @@ -138,7 +163,7 @@ export default {
fields: { title: { type: 'text', label: 'Title' } },
}],
apps: [{ name: 'strictexit_app', label: 'Strict Exit App' }],
};
}, { strict: false });
`;

/**
Expand All @@ -151,7 +176,9 @@ export default {
* assumed to be.
*/
const headerPageSource = (headerTextKey: 'description' | 'subtitle'): string => `
export default {
import { defineStack } from '@objectstack/spec';

export default defineStack({
manifest: { id: 'com.example.strictexit', name: 'strictexit', version: '1.0.0', type: 'app', namespace: 'strictexit' },
objects: [{
name: 'strictexit_ticket',
Expand All @@ -167,7 +194,7 @@ export default {
{ type: 'page:header', properties: { title: 'Tickets', ${headerTextKey}: 'All open tickets' } },
] }],
}],
};
}, { strict: false });
`;

interface Run {
Expand Down Expand Up @@ -201,12 +228,16 @@ let conversionsCanonDir: string;
beforeAll(() => {
warnsDir = mkdtempSync(join(tmpdir(), 'os-validate-strict-exit-warns-'));
writeFileSync(join(warnsDir, 'objectstack.config.ts'), WARNS_SOURCE);
linkSpec(warnsDir);
cleanDir = mkdtempSync(join(tmpdir(), 'os-validate-strict-exit-clean-'));
writeFileSync(join(cleanDir, 'objectstack.config.ts'), CLEAN_SOURCE);
linkSpec(cleanDir);
conversionsDir = mkdtempSync(join(tmpdir(), 'os-validate-strict-exit-conversions-'));
writeFileSync(join(conversionsDir, 'objectstack.config.ts'), headerPageSource('description'));
linkSpec(conversionsDir);
conversionsCanonDir = mkdtempSync(join(tmpdir(), 'os-validate-strict-exit-conversions-canon-'));
writeFileSync(join(conversionsCanonDir, 'objectstack.config.ts'), headerPageSource('subtitle'));
linkSpec(conversionsCanonDir);
});

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

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

// Same floor as the first case: equality is only worth asserting over a run
// that genuinely had something to fail on.
expect(
text.code,
`text --strict must fail on the conversions-only config:\n${text.stdout}\n${text.stderr}`,
).not.toBe(0);

expect(
json.code,
`--json --strict exited ${json.code} where --strict exited ${text.code}, same config.\n` +
`json stdout:\n${json.stdout}\njson stderr:\n${json.stderr}`,
).toBe(text.code);
// Parity, the #11174 contract this file exists for, still holds.
expect(text.code, `text --strict:\n${text.stdout}\n${text.stderr}`).toBe(0);
expect(json.code, `json --strict:\n${json.stdout}\n${json.stderr}`).toBe(text.code);

const payload = JSON.parse(json.stdout) as {
valid?: unknown;
warnings?: unknown;
conversions?: unknown;
};

// The cell spelled out. `warnings: []` is asserted, not tolerated: it is the
// whole point — narrow the gate to this field and the run above drops to 0
// while the text face stays at 1.
expect(payload.valid).toBe(true);
expect(payload.warnings).toEqual([]);
expect(
Array.isArray(payload.conversions) && (payload.conversions as unknown[]).length,
'the fixture raised NO conversion — the alias has most likely retired from ' +
'the load path; re-point `headerPageSource` at a live entry in ' +
'`packages/spec/src/conversions/registry.ts` rather than deleting this line',
).toBeGreaterThan(0);
expect(payload.conversions, 'the door computed no conversion: the producer already applied it').toEqual([]);

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

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

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