Skip to content

Commit f758cec

Browse files
os-zhuangclaude
andauthored
fix(spec,cli): govern the QA testing domain and enforce TestSuiteSchema at the os test load site (#6247) (#7255)
* fix(spec,cli): govern the QA testing domain and enforce TestSuiteSchema at the `os test` load site (#6247) #6247 filed `packages/spec/src/qa/testing.zod.ts` as declared-but-inert on a grep that scanned only `*Schema` identifiers. Every consumer here reads the TYPE names (`QA.TestSuite`, `QA.TestStep`, `QA.TestAction`), so the search matched nothing and a complete execution chain read as zero consumers: core's `TestRunner` + `HttpTestAdapter` (whose `action.type` switch labels ARE the `TestActionTypeSchema` values), published through `export * as QA`, driven by the shipped, documented CLI command `os test`. The 2026-08-07 retire ruling rested on that reading and was withdrawn on 2026-08-08 in favour of enforce. The real gap was narrower and genuine: the type was the contract and the schema had no `parse` site anywhere, so `os test` loaded suites with `JSON.parse(content) as QA.TestSuite` beside the author's own `// Should validate with Zod`. - `packages/spec/liveness/qa.json` — seed the ledger, governed via the same `SPEC_ONLY_SCHEMAS` override as `query`/`webhook`/`validation` (a QA suite is an authored file, not stack metadata). 4 live rows with file:line evidence into the runner, 5 dead recorded honestly; step/action/assertion keys sit below the one-level walk and are measured in the notes rather than fanned into rows the gate would not check. No `authorWarn` anywhere, deliberately: the lint walks stack collections and a QA suite belongs to no stack, so the flag would be a silent no-op inside the mechanism built to catch silent no-ops. - `packages/cli/src/commands/test.ts` — `loadTestSuite()` parses with `TestSuiteSchema.safeParse` at the load boundary and refuses a bad suite there, naming the file, listing the issues and quoting the expected shape. A refusal counts as one failed suite instead of killing the run. - pin `packages/cli/test/qa-suite-schema-load.test.ts` — the three shapes the cast admitted (missing `scenarios` → TypeError inside the runner; misspelled `steps` → scenario reports PASSED having executed nothing; bad `action.type` → dies mid-run after earlier steps wrote records) are now refused at load. Closes #6247 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Up7rAGwREEy754haLKVtZH * docs(cli): state that `os test` validates each suite before running it (#6247) The load-site parse is user-visible behaviour: a suite that does not match `TestSuiteSchema` is now refused before it runs, named, and counted as one failed suite while the rest of the glob continues. The `os test` section listed the flags and said nothing about what happens to a bad file. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Up7rAGwREEy754haLKVtZH --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8fbed3b commit f758cec

7 files changed

Lines changed: 328 additions & 7 deletions

File tree

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/cli": patch
4+
---
5+
6+
fix(spec,cli): govern the QA testing domain and enforce `TestSuiteSchema` at the `os test` load site (#6247)
7+
8+
`packages/spec/src/qa/testing.zod.ts` declares the Quality Protocol — test
9+
suites, scenarios, steps, actions, assertions — and had no liveness ledger, so
10+
the ADR-0049 enforce-or-remove machinery had never looked at it. #6247 filed it
11+
as **declared-but-inert on zero runtime consumers**, and that reading was wrong:
12+
the grep behind it matched only `*Schema` identifiers, while every consumer here
13+
reads the **type** names (`QA.TestSuite`, `QA.TestStep`, `QA.TestAction`). What
14+
it missed is a complete execution chain — core's `TestRunner` and
15+
`HttpTestAdapter` (whose `action.type` switch labels *are* the
16+
`TestActionTypeSchema` values), published through `export * as QA`, driven by the
17+
shipped, documented CLI command `os test`. The retire ruling that followed from
18+
the bad reading was withdrawn; this is the enforce leg.
19+
20+
**The real gap was narrower and genuine.** The type was the contract and the
21+
schema had no `parse` site anywhere in the platform: `os test` loaded suites with
22+
`JSON.parse(content) as QA.TestSuite`, next to the schema author's own
23+
`// Should validate with Zod`. A type assertion checks nothing at runtime, so a
24+
malformed suite failed late and in the wrong place — a missing `scenarios`
25+
TypeError'd inside the runner with no idea which file it came from, a misspelled
26+
`steps` key reported the scenario **passed** having executed nothing, and a bad
27+
`action.type` died in the HTTP adapter's `default:` branch mid-run, after earlier
28+
steps had already written records. `os test` now parses at the load boundary and
29+
refuses a bad suite there, naming the file, listing the issues and quoting the
30+
expected shape; a refusal counts as one failed suite rather than killing the run.
31+
Valid suites load and execute unchanged.
32+
33+
**`packages/spec/liveness/qa.json`** seeds the ledger, governed through the same
34+
`SPEC_ONLY_SCHEMAS` override as `query`/`webhook`/`validation` — a QA suite is a
35+
file an author writes, not stack metadata, so there is no registry to fold it
36+
onto and the override *is* its governance. Four live rows (`scenarios.id`,
37+
`.setup`, `.steps`, `.teardown`) carry `file:line` evidence into the runner;
38+
step, action and assertion keys sit below the gate's one-level walk and their
39+
measurements are recorded in the notes rather than fanned into rows the gate
40+
would not check. Five dead rows are recorded honestly, two of which go onto the
41+
enforce-or-remove worklist: `scenarios.tags` advertises filtering that `os test`
42+
has no flag to express, and `scenarios.requires` declares param/plugin
43+
preconditions nothing checks, so a suite naming an absent plugin runs anyway and
44+
fails later as an unexplained HTTP error. None is marked `authorWarn`, and the
45+
omission is deliberate — the author-side lint walks stack collections, a QA suite
46+
belongs to no stack, and a warn flag that can never be emitted would be a silent
47+
no-op inside the mechanism built to catch them.

content/docs/deployment/cli.mdx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1020,6 +1020,12 @@ os test --url http://localhost:4000 # Custom server URL
10201020
os test --token my-api-key # With authentication
10211021
```
10221022

1023+
Each file is validated against `TestSuiteSchema` **before it runs**. A suite that
1024+
does not match is refused at load time, naming the file and every offending path,
1025+
and counts as one failed suite — the rest of the glob still runs. This is what
1026+
stops a malformed suite from reporting success: a misspelled `steps` key used to
1027+
produce a scenario that passed having executed nothing.
1028+
10231029
#### `os doctor`
10241030

10251031
Checks your development environment and reports issues:

packages/cli/src/commands/test.ts

Lines changed: 69 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import path from 'path';
66
import fs from 'fs';
77
import { QA as CoreQA } from '@objectstack/core';
88
import * as QA from '@objectstack/spec/qa';
9+
import type { ZodError } from 'zod';
910

1011
/**
1112
* Resolve a glob-like pattern to matching file paths.
@@ -52,6 +53,59 @@ function resolveGlob(pattern: string): string[] {
5253
.filter(fullPath => fs.statSync(fullPath).isFile());
5354
}
5455

56+
/** The suite shape, quoted back at an author whose file did not match it. */
57+
const SUITE_SHAPE =
58+
'{ "name": string, "scenarios": [ { "id", "name", "steps": [ { "name", "action": { "type", "target" } } ] } ] }';
59+
60+
/**
61+
* Load and VALIDATE one Quality Protocol suite file.
62+
*
63+
* This used to be `JSON.parse(content) as QA.TestSuite`, carrying the schema
64+
* author's own `// Should validate with Zod`. The cast is the declared≠enforced
65+
* gap ADR-0049 names (#6247): `TestSuiteSchema` was declared, shipped and
66+
* documented, and had no `parse` site anywhere in the platform — the TYPE was
67+
* the contract the runner read, and a type assertion checks nothing at runtime.
68+
* What a bad file did instead of being refused: a missing `scenarios` TypeError'd
69+
* inside `TestRunner.runSuite` with no idea which file it came from; a misspelled
70+
* `steps` reported the scenario PASSED having executed nothing; a bad
71+
* `action.type` reached the HTTP adapter's `default:` branch mid-run, after the
72+
* earlier steps had already written records.
73+
*
74+
* So the parse happens HERE, at the boundary where the file name is still in
75+
* hand, and the error names the file, lists the issues and prescribes the shape.
76+
* Throws rather than exiting, so the caller keeps ownership of the run tally —
77+
* one broken suite is a failed suite, not a dead command.
78+
*/
79+
export function loadTestSuite(file: string): QA.TestSuite {
80+
const content = fs.readFileSync(file, 'utf-8');
81+
82+
let doc: unknown;
83+
try {
84+
doc = JSON.parse(content);
85+
} catch (e) {
86+
// A bare `Unexpected end of JSON input` names nothing; with a glob expanding
87+
// to a dozen files that is a message you have to bisect by hand.
88+
throw new Error(
89+
`${file} is not valid JSON: ${e instanceof Error ? e.message : String(e)}\n` +
90+
` A Quality Protocol suite is a JSON document shaped ${SUITE_SHAPE}`,
91+
);
92+
}
93+
94+
const result = QA.TestSuiteSchema.safeParse(doc);
95+
if (!result.success) {
96+
const issues = (result.error as ZodError).issues
97+
.map((issue) => ` ✗ ${issue.path.join('.') || '(root)'}: ${issue.message}`)
98+
.join('\n');
99+
throw new Error(
100+
`${file} is not a valid Quality Protocol suite (TestSuiteSchema):\n${issues}\n` +
101+
` Expected shape: ${SUITE_SHAPE}\n` +
102+
` Reference: content/docs/references/qa/testing.mdx`,
103+
);
104+
}
105+
106+
return result.data as QA.TestSuite;
107+
}
108+
55109
export default class Test extends Command {
56110
static override description = 'Run Quality Protocol test scenarios against a running server';
57111

@@ -93,12 +147,22 @@ export default class Test extends Command {
93147

94148
for (const file of testFiles) {
95149
console.log(`\n📄 Running suite: ${chalk.bold(path.basename(file))}`);
150+
151+
// Load and validate FIRST, and report a refusal on its own terms: a file
152+
// the schema rejects never had a chance to run, so folding it into the
153+
// run-failure branch below would report it as if the server had said no.
154+
let suite: QA.TestSuite;
155+
try {
156+
suite = loadTestSuite(file);
157+
} catch (e) {
158+
console.error(chalk.red(e instanceof Error ? e.message : String(e)));
159+
totalFailed++; // Count suite failure
160+
continue;
161+
}
162+
96163
try {
97-
const content = fs.readFileSync(file, 'utf-8');
98-
const suite = JSON.parse(content) as QA.TestSuite; // Should validate with Zod
99-
100164
const results = await runner.runSuite(suite);
101-
165+
102166
for (const result of results) {
103167
const icon = result.passed ? '✅' : '❌';
104168
console.log(` ${icon} Scenario: ${result.scenarioId} (${result.duration}ms)`);
@@ -117,7 +181,7 @@ export default class Test extends Command {
117181
}
118182
}
119183
} catch (e) {
120-
console.error(chalk.red(`Failed to load or run suite ${file}: ${e}`));
184+
console.error(chalk.red(`Failed to run suite ${file}: ${e}`));
121185
totalFailed++; // Count suite failure
122186
}
123187
}
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* PIN (#6247) — `os test` REFUSES a malformed `qa/*.test.json` at LOAD time.
5+
*
6+
* The load site used to be `JSON.parse(content) as QA.TestSuite`, with the
7+
* schema author's own `// Should validate with Zod` sitting next to it. A type
8+
* assertion is not a check: the cast made every JSON document on disk a
9+
* `TestSuite` as far as the compiler was concerned, and the first thing that
10+
* noticed otherwise was the runner, several layers down and with no idea which
11+
* file it came from. The three failure shapes that produced:
12+
*
13+
* - `{}` (no `scenarios`) → `TestRunner.runSuite` iterates `undefined` and
14+
* throws a TypeError attributed to the runner, not the file;
15+
* - `{ scenarios: [] }` with a typo'd key → the suite reports SUCCESS having
16+
* executed nothing, which is the dangerous one: a broken suite that passes
17+
* is indistinguishable from a green one in CI;
18+
* - a bad `action.type` → survives the load, survives the runner, and dies in
19+
* the HTTP adapter's `default:` branch mid-run, after the preceding steps
20+
* have already written records.
21+
*
22+
* An AI-authored suite hits all three routinely, which is exactly the
23+
* declared≠enforced gap ADR-0049 names: `TestSuiteSchema` was declared, shipped,
24+
* documented — and had no `parse` site anywhere in the platform.
25+
*
26+
* So the pin is on the LOAD boundary, not on the runner: a document that
27+
* `TestSuiteSchema` rejects must never reach `runSuite`, and the refusal must
28+
* name the file and the issues. Valid suites must still load unchanged.
29+
*/
30+
31+
import { describe, it, expect } from 'vitest';
32+
import { mkdtempSync, writeFileSync } from 'node:fs';
33+
import { tmpdir } from 'node:os';
34+
import { join } from 'node:path';
35+
import { loadTestSuite } from '../src/commands/test';
36+
37+
const dir = mkdtempSync(join(tmpdir(), 'os-qa-suite-'));
38+
39+
function suiteFile(name: string, body: string): string {
40+
const file = join(dir, name);
41+
writeFileSync(file, body, 'utf-8');
42+
return file;
43+
}
44+
45+
const VALID_SUITE = {
46+
name: 'crm smoke',
47+
scenarios: [
48+
{
49+
id: 'create-account',
50+
name: 'Create an account',
51+
steps: [
52+
{
53+
name: 'create',
54+
action: { type: 'create_record', target: 'accounts', payload: { name: 'Acme' } },
55+
assertions: [{ field: 'id', operator: 'not_null', expectedValue: null }],
56+
},
57+
],
58+
},
59+
],
60+
};
61+
62+
describe('os test — suite load is schema-checked (#6247)', () => {
63+
it('loads a valid suite unchanged', () => {
64+
const file = suiteFile('valid.test.json', JSON.stringify(VALID_SUITE));
65+
const suite = loadTestSuite(file);
66+
expect(suite.name).toBe('crm smoke');
67+
expect(suite.scenarios).toHaveLength(1);
68+
expect(suite.scenarios[0].steps[0].action.type).toBe('create_record');
69+
});
70+
71+
it('refuses a suite with no `scenarios` — the shape that TypeErrors inside the runner', () => {
72+
const file = suiteFile('no-scenarios.test.json', JSON.stringify({ name: 'empty' }));
73+
expect(() => loadTestSuite(file)).toThrow(/no-scenarios\.test\.json/);
74+
expect(() => loadTestSuite(file)).toThrow(/scenarios/);
75+
});
76+
77+
it('refuses a misspelled step key — the shape that SILENTLY passes', () => {
78+
// `stepz` instead of `steps`: the cast let this through and the scenario
79+
// reported PASSED having run nothing at all.
80+
const file = suiteFile(
81+
'typo.test.json',
82+
JSON.stringify({ name: 's', scenarios: [{ id: 'a', name: 'A', stepz: [] }] }),
83+
);
84+
expect(() => loadTestSuite(file)).toThrow(/typo\.test\.json/);
85+
});
86+
87+
it('refuses an unknown action type — the shape that dies mid-run after writes', () => {
88+
const bad = structuredClone(VALID_SUITE) as Record<string, any>;
89+
bad.scenarios[0].steps[0].action.type = 'summon_record';
90+
const file = suiteFile('bad-action.test.json', JSON.stringify(bad));
91+
expect(() => loadTestSuite(file)).toThrow(/bad-action\.test\.json/);
92+
});
93+
94+
it('names the offending file and the issues, and prescribes the shape', () => {
95+
const file = suiteFile('broken.test.json', JSON.stringify({ scenarios: 'not an array' }));
96+
let message = '';
97+
try {
98+
loadTestSuite(file);
99+
} catch (e) {
100+
message = e instanceof Error ? e.message : String(e);
101+
}
102+
expect(message).toContain('broken.test.json');
103+
expect(message).toContain('TestSuiteSchema');
104+
// Self-prescribing: the author is told what a suite looks like, not just
105+
// that theirs is wrong.
106+
expect(message).toContain('scenarios');
107+
});
108+
109+
it('refuses invalid JSON with the file named, rather than a bare SyntaxError', () => {
110+
const file = suiteFile('not-json.test.json', '{ "name": ');
111+
expect(() => loadTestSuite(file)).toThrow(/not-json\.test\.json/);
112+
});
113+
});

packages/spec/liveness/README.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -645,7 +645,14 @@ The governed set is `GOVERNED` at the top of `check-liveness.mts`. To add a type
645645
RecordDetailView had been gating the History tab on it the whole time (#2707).
646646
4. Add the type to `GOVERNED`; confirm the gate is green.
647647

648-
## Current state — 27 governed types (complete registry coverage)
648+
## Current state — 30 governed types (complete registry coverage)
649+
650+
> The table below carries 28 of the 30. `api` and `capability` are governed
651+
> (they are in `GOVERNED`, they have ledgers, the gate counts them) and were
652+
> added without a row here — the table fell behind its own registry, which is
653+
> the shape this file keeps warning about one level down. Filed rather than
654+
> back-filled from a guess: writing two Notes cells for changes somebody else
655+
> measured is exactly the fabrication the drill section forbids.
649656
650657
**The counting method for this table is the gate's own report**
651658
`check-liveness.mts --json`, `types.<type>.byStatus` — decided in #4488 after
@@ -698,6 +705,7 @@ for t, v in r['types'].items():
698705
| mapping | 14 || 0 || seeded 2026-08-01 (#4488) at 8/11 live; **0 dead since #4509** retired the three that were not. The import half (#2611) is loudly enforced — unsupported transforms/formats are 400s, `mode`/`upsertKey` default the request, the wizard picker renders `label`. RETIRED 17.0.0: `extractQuery` (authorWarn — "for export only" promised an export path no exporter implements) + `errorPolicy`/`batchSize`, which were dead AND **unwarnable** (schema defaults materialize at parse, so presence ≠ authored — `_authorWarnSkipped`, the non-boolean instance of the default(true) rule). That unwarnability is why they went out in the 17.0.0 window rather than after a deprecation cycle: removal was the only channel that could ever reach the author. Rows DELETED, not tombstoned — MappingSchema is strict, so the keys left the walked shape |
699706
| seed | 5 || 0 || seeded 2026-08-01 (#4488). Fully live via SeedLoaderService on both doors (boot/per-org replay + runtime-draft publish). `records` is the z.record walk boundary: the keys an author writes are the target object's fields, governed by that object's own definitions — recorded in the entry, not silently skipped |
700707
| translation | 17 || 2 || seeded 2026-08-01 (#4488) — after fixing the walker: the registered schema is a z.preprocess pipe (#3778 retired-dialect guard) whose transform side the unwrap always took, so the type was literally unwalkable. 10 of 11 groups live across spec resolvers, REST localization, objectui client resolvers and plugin-audit (whose composed-key `t()` calls make `messages` easy to mis-verify as dead). Dead 1 = `validationMessages` (authorWarn): nothing resolves it, and #3778's own legacy-key migration table steers `errors:` authors into it — a shipped false signpost, the capabilities.readOnly shape | **#4667**: `validationMessages` REMOVED (row deleted) — removed from the shared translationDataShape(), so it retired at BOTH doors at once, closing the item-only asymmetry #3778's original guard had. #3778's own `errors` guidance was rewritten in the same change: it had been steering authors INTO this dead group. |
708+
| qa | 4 | – | 5 | – | seeded 2026-08-10 (#6247) — **not a metadata type**: `TestSuiteSchema` is the FILE surface of the shipped `os test` command (`qa/*.test.json`), governed through the same `SPEC_ONLY_SCHEMAS` override as `query`/`webhook`/`validation`. It is in the table as the clearest worked example of a **false `dead` measurement**: #6247 reported the whole domain declared-but-inert on a grep that scanned only `*Schema` identifiers, and every consumer here reads the **type** names (`QA.TestSuite`, `QA.TestStep`, `QA.TestAction`) — so an entire execution chain (core's `TestRunner` + `HttpTestAdapter`, published via `export * as QA`, driven by a documented CLI command) read as zero consumers, and a retire ruling was issued on it before being withdrawn. The `evidenceScope` table one section up says no amount of specifier matching is sufficient for a negative claim; this is the same lesson for **identifier** matching. What was really wrong was narrower and real: the type was the contract and the schema had no `parse` site, so the CLI's `JSON.parse(content) as QA.TestSuite` cast admitted anything — ENFORCED in the same change (`TestSuiteSchema.safeParse` at the load site, pinned). Dead 5 = `name` (the file name is the suite identity; the CLI prints `path.basename`), `scenarios.name` (describe() says "for test reports"; every report carries `scenarioId` instead), `scenarios.description` (docs-shaped, kept), and the two on the enforce-or-remove worklist — `scenarios.tags` promises filtering that `os test`'s two flags cannot express, and `scenarios.requires` declares param/plugin preconditions nothing checks, so a suite naming a missing plugin runs anyway and fails as an unexplained HTTP error. Neither carries `authorWarn` and the omission is deliberate (`_authorWarnSkipped`): the lint walks stack **collections**, a QA suite is a loose file in no stack, so a warn flag here would emit nothing — a silent no-op inside the mechanism built to catch silent no-ops |
701709
| validation | 15 | 0 | 3 | 0 | seeded 2026-08-01 (#4488). The ADR-0020 carrier: the evaluator honors active/events/priority/severity/type/condition/message (the zod header's "only reads type/condition/…" prose is STALE — trust the ledger). Dead 3 = label/description/tags, declared governance metadata, kept unmarked. Union walk boundary recorded: only base + `script` keys walked; per-variant keys are governed by the evaluator's tests, not ledger rows. **No longer a registered metadata kind** — #4509 retired it under ADR-0088 (a standalone rule had no object-binding key and every variant is `.strict()`, so it bound to nothing and gated no write; a state machine authored that way saved cleanly and did nothing). The rule VOCABULARY is untouched and fully live via `object.validations[]`, so the ledger keeps governing it through the gate's spec-only override, alongside `webhook` and `query`. The contrast with the two bridges in the same batch is the point: enforce-or-remove picked ENFORCE where the feature existed and only the wiring was missing, and REMOVE where the shape itself could not carry the feature |
702710

703711
The `dead` set across types is the enforce-or-remove worklist (ADR-0049); every

0 commit comments

Comments
 (0)