Skip to content

Commit 5d551e3

Browse files
committed
test(cli): pin the blank starter's wiring to os init's, and its chain
A unit parity pin reads every expected line off the CLI's own render (TEMPLATES.app, SCAFFOLD_WIRED_BARRELS, SCAFFOLD_WIRED_REQUIRES) and holds create-objectstack's blank config and barrels to it. A per-PR integration pin drives `npm create objectstack` -> `os g object` (control) -> `os g flow` -> `os validate` and reads `Logic: 1 Flows`. The blank config imports three connector packages; they become CLI devDependencies so the scaffolded project resolves them and CI builds them in this suite's closure. Claude-Session: https://claude.ai/code/session_01UYBdGBzWSrAMzpW8ah3GbP Co-authored-by: Claude <noreply@anthropic.com>
1 parent 00e6146 commit 5d551e3

4 files changed

Lines changed: 317 additions & 0 deletions

File tree

‎packages/cli/package.json‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,9 @@
132132
"better-sqlite3": "^13.0.3"
133133
},
134134
"devDependencies": {
135+
"@objectstack/connector-mcp": "workspace:*",
136+
"@objectstack/connector-openapi": "workspace:*",
137+
"@objectstack/connector-rest": "workspace:*",
135138
"@objectstack/driver-turso": "workspace:*",
136139
"@objectstack/plugin-dev": "workspace:*",
137140
"@oclif/plugin-help": "^6.2.58",
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* PIN (#20333) — `npm create objectstack` → `os g flow` → `os validate`
5+
* counts the flow, with `os g object` as the control.
6+
*
7+
* ## What was measured before the fix
8+
*
9+
* On `origin/main` `c74de10a9`, a project scaffolded by the on-ramp's real
10+
* `bin/` entry, then `os g object order_line` and `os g flow order_line`:
11+
*
12+
* os g object exit 0, "Reaches the stack" (objects was always wired)
13+
* os g flow exit 0, "Not wired: … is not part of the stack"
14+
* os validate exit 0, `Data: 2 Objects` and `Logic: 0 Flows`
15+
*
16+
* The blank starter's config imported `./src/objects` alone, so the flow was
17+
* written and never loaded. The control is what makes the red readable: the
18+
* same chain counted the generated object, so a 0 for the flow is the wiring
19+
* and not a harness that counts nothing.
20+
*
21+
* ## The chain, through the real commands
22+
*
23+
* node create-objectstack/bin/create-objectstack.js my-app --skip-install --skip-skills
24+
* os g object order_line → exit 0, reaches the stack
25+
* os g flow order_line → exit 0, reaches the stack, no wiring lines to add
26+
* os validate → exit 0, `Data: 2 Objects`, `Logic: 1 Flows`
27+
*
28+
* Asserted: exit statuses, the named subjects, the absence of the wiring
29+
* lines `os g` prints for a scaffold that did not arrive (code an author
30+
* pastes), and the counts `os validate` prints. Prose is not pinned. The item
31+
* names are read off the generator roster, not written down.
32+
*
33+
* ## Why here, why a child process, and why this file is NOT named `.e2e`
34+
*
35+
* `os g` and `os validate` are this package's commands, and it already
36+
* depends on `create-objectstack`, so `@objectstack/cli#test`'s `^build`
37+
* builds the on-ramp's `dist/` — the tree its `bin/` copies from. The bin and
38+
* the blank template are declared cross-package inputs of this package
39+
* (scripts/cross-package-test-inputs.mjs, mirrored into turbo.json).
40+
*
41+
* The project lives under this package's `node_modules`, so the scaffolded
42+
* config's imports resolve to workspace copies without an install. Besides
43+
* `@objectstack/spec`, the blank config imports three connector packages;
44+
* they are this package's devDependencies for that reason alone, which is
45+
* also what puts them in this suite's build closure.
46+
*
47+
* An exit status is the contract, and `process.exit` inside a vitest worker
48+
* is not one, so the commands are spawned (the `integration` project). The
49+
* name keeps it in the per-PR run. The structural half — the blank config
50+
* wires exactly what `os init` wires — is `create-objectstack-wiring-parity.test.ts`.
51+
*/
52+
53+
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
54+
import { execFile } from 'node:child_process';
55+
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
56+
import { join, resolve } from 'node:path';
57+
import { fileURLToPath } from 'node:url';
58+
import { GENERATOR_SCAFFOLD_TARGETS } from '../src/commands/generate.js';
59+
import { childEnv } from './helpers/serve-process.js';
60+
61+
const HERE = resolve(fileURLToPath(import.meta.url), '..');
62+
const CLI = resolve(HERE, '../bin/run-dev.js');
63+
const TSX = resolve(HERE, '../../../node_modules/.bin/tsx');
64+
65+
// One `resolve(HERE, …)` call per line: `check:cross-package-test-inputs`
66+
// reconstructs this read by SOURCE SCAN.
67+
const ON_RAMP_BIN = resolve(HERE, '../../..', 'packages/create-objectstack/bin/create-objectstack.js');
68+
69+
/** One plain-node scaffold, then three oclif + tsx cold starts, sequential. */
70+
const RUN_TIMEOUT_MS = 240_000;
71+
72+
const PROJECT = 'my-app';
73+
const NS = 'my_app';
74+
const STEM = 'order_line';
75+
76+
interface Run {
77+
code: number;
78+
stdout: string;
79+
stderr: string;
80+
}
81+
82+
function run(file: string, args: string[], cwd: string): Promise<Run> {
83+
return new Promise((resolvePromise) => {
84+
execFile(
85+
file,
86+
args,
87+
{ cwd, maxBuffer: 8 * 1024 * 1024, env: childEnv({ NO_COLOR: '1' }) },
88+
(err, stdout, stderr) => {
89+
resolvePromise({
90+
// `err.code` is the real exit status; a signalled child has none and
91+
// is reported as 1, never as 0.
92+
code: err
93+
? typeof (err as { code?: unknown }).code === 'number'
94+
? (err as unknown as { code: number }).code
95+
: 1
96+
: 0,
97+
stdout: String(stdout),
98+
stderr: String(stderr),
99+
});
100+
},
101+
);
102+
});
103+
}
104+
105+
const os = (args: string[], cwd: string) => run(TSX, [CLI, ...args], cwd);
106+
const out = (r: Run) => r.stdout + r.stderr;
107+
108+
const target = (type: string) => {
109+
const t = GENERATOR_SCAFFOLD_TARGETS.find((g) => g.type === type);
110+
if (!t) throw new Error(`no '${type}' generator in the roster`);
111+
return t;
112+
};
113+
const OBJECT = target('object');
114+
const FLOW = target('flow');
115+
116+
let root: string;
117+
let project: string;
118+
let scaffold: Run;
119+
let genObject: Run;
120+
let genFlow: Run;
121+
let validate: Run;
122+
123+
beforeAll(async () => {
124+
root = mkdtempSync(join(HERE, '..', 'node_modules', '.create-objectstack-reach-'));
125+
project = join(root, PROJECT);
126+
scaffold = await run(process.execPath, [ON_RAMP_BIN, PROJECT, '--skip-install', '--skip-skills'], root);
127+
// Sequential on purpose: the object first, so the flow binds to something
128+
// declared, and cold starts in a container several agents share.
129+
genObject = await os(['g', 'object', STEM], project);
130+
genFlow = await os(['g', 'flow', STEM], project);
131+
validate = await os(['validate'], project);
132+
}, RUN_TIMEOUT_MS);
133+
134+
afterAll(() => {
135+
if (root) rmSync(root, { recursive: true, force: true });
136+
});
137+
138+
describe('[#20333] `npm create objectstack` → `os g flow` → `os validate`', () => {
139+
it('the on-ramp scaffolded the project, under the namespace this file assumes', () => {
140+
expect(scaffold.code, out(scaffold)).toBe(0);
141+
expect(readFileSync(join(project, 'objectstack.config.ts'), 'utf-8')).toContain(`namespace: '${NS}'`);
142+
});
143+
144+
it('CONTROL: `os g object` reaches the stack', () => {
145+
expect(genObject.code, out(genObject)).toBe(0);
146+
expect(genObject.stdout).toContain(`'${OBJECT.itemName(STEM, NS)}'`);
147+
expect(genObject.stdout).not.toContain(`import * as ${OBJECT.stackKey}`);
148+
});
149+
150+
it('`os g flow` reaches the stack, with no wiring lines to add', () => {
151+
expect(genFlow.code, out(genFlow)).toBe(0);
152+
expect(genFlow.stdout).toContain(`'${FLOW.itemName(STEM, NS)}'`);
153+
expect(genFlow.stdout).not.toContain(`import * as ${FLOW.stackKey}`);
154+
// Nor a `requires` line: the starter already declares what a flow runs on.
155+
expect(genFlow.stdout).not.toContain('requires: [');
156+
});
157+
158+
it('`os validate` exits 0 and counts the generated flow beside the control', () => {
159+
expect(validate.code, out(validate)).toBe(0);
160+
// The starter's own object plus the generated one.
161+
expect(validate.stdout).toMatch(/\bData: 2 Objects\b/);
162+
expect(validate.stdout).toMatch(/\bLogic: 1 Flows\b/);
163+
});
164+
});
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* PIN (#20333) — `npm create objectstack`'s blank starter wires exactly the
5+
* barrels `os init` wires, in the lines `os init` renders.
6+
*
7+
* ## The defect
8+
*
9+
* The blank starter's `objectstack.config.ts` imported `./src/objects` alone.
10+
* `os g view|action|flow|dashboard|app|skill` wrote a scaffold and a barrel
11+
* that nothing imported, and `os validate` exited 0 with `Logic: 0 Flows`.
12+
* `os init` had the same config and was fixed by wiring every generator
13+
* barrel (#20215): `SCAFFOLD_WIRED_BARRELS`, derived from the generator
14+
* roster, rendered through `exportsOf` over `export {};` barrels, with
15+
* `SCAFFOLD_WIRED_REQUIRES` in `requires`.
16+
*
17+
* ## Why a parity pin, and why it lives in this package
18+
*
19+
* `create-objectstack` cannot import that roster. The dependency edge runs
20+
* the other way (this package depends on it for `created-summary`), and the
21+
* npx entry point must not pull the CLI's closure — the same boundary
22+
* `scripts/sync-scaffold-emission-policy.mjs` documents. Its template is a
23+
* static file tree copied byte for byte, so its wiring is a COPY of what
24+
* `os init` renders. A copy with nothing binding it to the source is a second
25+
* wiring rule, and it decays: the next generator added to the roster would be
26+
* wired by `os init` and silently not by the on-ramp.
27+
*
28+
* This file is the binding. Every expected value below is READ off the CLI —
29+
* `TEMPLATES.app`'s rendered config and barrels, and the two exported
30+
* rosters — and none is written down here, so a roster change, a renderer
31+
* change or a hand edit of the template reddens this file until the two
32+
* scaffolders agree again. Only this package can call the renderer; the
33+
* template side is a static file, so reading it IS reading its producer.
34+
*
35+
* What is compared is code, verbatim: the barrel imports, the `exportsOf`
36+
* helper, the stack-key lines, the `requires` tokens, and each empty barrel's
37+
* bytes. The prose comments around them are not compared — the starter keeps
38+
* its own explanation of `automation` (its connectors need it too).
39+
*
40+
* The behaviour this wiring buys is pinned through the real commands in
41+
* `create-objectstack-stack-reach.test.ts` (`npm create objectstack` →
42+
* `os g flow` → `os validate`).
43+
*/
44+
45+
import { describe, expect, it } from 'vitest';
46+
import { existsSync, readFileSync } from 'node:fs';
47+
import { join, resolve } from 'node:path';
48+
import { fileURLToPath } from 'node:url';
49+
import { SCAFFOLD_WIRED_BARRELS, SCAFFOLD_WIRED_REQUIRES, TEMPLATES } from '../src/commands/init.js';
50+
51+
const HERE = resolve(fileURLToPath(import.meta.url), '..');
52+
53+
// One `resolve(HERE, …)` call per line: `check:cross-package-test-inputs`
54+
// reconstructs these reads by SOURCE SCAN. The blank template is declared as a
55+
// cross-package input of `@objectstack/cli` (scripts/cross-package-test-inputs.mjs,
56+
// mirrored into turbo.json), so a template-only diff re-runs this file.
57+
const BLANK = resolve(HERE, '../../create-objectstack/src/templates/blank');
58+
59+
const blankConfig = readFileSync(join(BLANK, 'objectstack.config.ts'), 'utf8');
60+
const initConfig = TEMPLATES.app.configContent('my-app', 'my_app');
61+
62+
/** Every line of `text` matching `re`, sorted — order is not the contract. */
63+
const linesMatching = (text: string, re: RegExp): string[] =>
64+
text.split('\n').filter((line) => re.test(line)).sort();
65+
66+
/** `import * as <key> from './src/<dir>';` — one per wired barrel. */
67+
const BARREL_IMPORT = /^import \* as \w+ from '\.\/src\/[\w-]+';$/;
68+
/** ` <key>: exportsOf(<key>),` — one per wired barrel, inside `defineStack`. */
69+
const STACK_KEY = /^\s+\w+: exportsOf\(\w+\),$/;
70+
/** The helper both configs read their barrels through. */
71+
const HELPER = /^const exportsOf = /;
72+
73+
/** A barrel whose only code is `export {};` — comments aside. */
74+
const isEmptyBarrel = (source: string): boolean =>
75+
source
76+
.split('\n')
77+
.filter((line) => line.trim() !== '' && !line.trimStart().startsWith('//'))
78+
.join('\n') === 'export {};';
79+
80+
describe('[#20333] the roster this file reads is the real one', () => {
81+
it('wires more than objects, and includes flows', () => {
82+
// Vacuity guard: an empty or objects-only roster would make every
83+
// comparison below agree with the defect.
84+
expect(SCAFFOLD_WIRED_BARRELS.length).toBeGreaterThan(1);
85+
expect(SCAFFOLD_WIRED_BARRELS.map((b) => b.stackKey)).toContain('flows');
86+
expect(SCAFFOLD_WIRED_REQUIRES.length).toBeGreaterThan(0);
87+
});
88+
89+
it('the patterns below find one line per wired barrel in the CLI render', () => {
90+
expect(linesMatching(initConfig, BARREL_IMPORT)).toHaveLength(SCAFFOLD_WIRED_BARRELS.length);
91+
expect(linesMatching(initConfig, STACK_KEY)).toHaveLength(SCAFFOLD_WIRED_BARRELS.length);
92+
expect(linesMatching(initConfig, HELPER)).toHaveLength(1);
93+
});
94+
});
95+
96+
describe('[#20333] the blank config wires what `os init` wires, in its lines', () => {
97+
it('imports every wired barrel, and nothing else under ./src', () => {
98+
expect(linesMatching(blankConfig, BARREL_IMPORT)).toEqual(linesMatching(initConfig, BARREL_IMPORT));
99+
});
100+
101+
it('reads the barrels through the same `exportsOf` helper', () => {
102+
expect(linesMatching(blankConfig, HELPER)).toEqual(linesMatching(initConfig, HELPER));
103+
});
104+
105+
it('hands every wired barrel to its stack key', () => {
106+
expect(linesMatching(blankConfig, STACK_KEY)).toEqual(linesMatching(initConfig, STACK_KEY));
107+
});
108+
109+
it('declares every capability the scaffolds need to run', () => {
110+
const declared = [...blankConfig.matchAll(/^\s+requires: \[([^\]]*)\],$/gm)];
111+
expect(declared, 'exactly one top-level `requires` line').toHaveLength(1);
112+
const tokens = declared[0][1].split(',').map((t) => t.trim().replace(/^'|'$/g, ''));
113+
// A superset, not an equality: the starter's connectors need `automation`
114+
// whether or not a scaffold does.
115+
for (const token of SCAFFOLD_WIRED_REQUIRES) expect(tokens, token).toContain(token);
116+
});
117+
});
118+
119+
describe('[#20333] the blank template ships every wired barrel', () => {
120+
const barrels = SCAFFOLD_WIRED_BARRELS.map((b) => {
121+
const file = `${b.dir}/index.ts`;
122+
const render = TEMPLATES.app.srcFiles[file];
123+
return { ...b, file, init: render ? render('my-app', 'my_app') : undefined };
124+
});
125+
126+
it.each(barrels.map((b) => [b.file, b] as const))('%s exists', (_file, b) => {
127+
expect(existsSync(join(BLANK, b.file))).toBe(true);
128+
});
129+
130+
const empty = barrels.filter((b) => b.init !== undefined && isEmptyBarrel(b.init));
131+
132+
it('`os init` writes an empty barrel for every wired directory but objects', () => {
133+
// Vacuity guard for the byte comparison below.
134+
expect(empty.map((b) => b.stackKey).sort())
135+
.toEqual(SCAFFOLD_WIRED_BARRELS.map((b) => b.stackKey).filter((k) => k !== 'objects').sort());
136+
});
137+
138+
it.each(empty.map((b) => [b.file, b] as const))('%s is byte-identical to the one `os init` writes', (_file, b) => {
139+
expect(readFileSync(join(BLANK, b.file), 'utf8')).toBe(b.init);
140+
});
141+
});

‎pnpm-lock.yaml‎

Lines changed: 9 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)