Skip to content

Commit 89b96df

Browse files
committed
fix(cli): os init writes a lint script into all three scaffold templates
`packages/create-objectstack`'s blank template declares `lint` and ships a CI workflow that runs `pnpm lint`; the three script maps in `os init` declared `validate` and no `lint`, so the two scaffolders emitted different projects and only one of them could run that workflow. Adds `lint: 'objectstack lint'` after `validate` in all three maps, and a pin that derives the required script set from the workflow the on-ramp template ships, so the next divergence reddens instead of being discovered. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8
1 parent c930f85 commit 89b96df

5 files changed

Lines changed: 186 additions & 0 deletions

File tree

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
"@objectstack/cli": patch
3+
---
4+
5+
`os init` / `os create` now write a `lint` script into every scaffolded project, matching what `npx create-objectstack` already emits.
6+
7+
The two scaffolders had diverged. `npx create-objectstack` copies a template that declares `dev`, `start`, `build`, `validate`, `lint` and `typecheck`, and ships a CI workflow that runs `pnpm validate`, `pnpm lint` and `pnpm typecheck`. The three script maps in `os init` each declared `validate` and no `lint`, so a project scaffolded through `os init` that adopted that workflow — the documented next step — failed its first push with `Command "lint" not found`.
8+
9+
`objectstack lint` is not a second spelling of `objectstack validate`. Both run the shared authoring-rule engine, but only `lint` reaches the hook-body lowering check, so `hook-body/not-lowerable` — a handler that has silently stopped lowering to a metadata-only body, a change of deployment shape from a refactor that looks like tidying — was unreachable from a project scaffolded this way.
10+
11+
The new entry sits after `validate` in each map, matching the template's order, and its value is `objectstack lint` on both sides. Existing projects are unaffected; add the script by hand to pick the check up:
12+
13+
```json
14+
"scripts": {
15+
"validate": "objectstack validate",
16+
"lint": "objectstack lint"
17+
}
18+
```
19+
20+
A pin now holds the two scaffolders equal on the scripts the shipped CI workflow runs, derived from that workflow rather than transcribed, so the next divergence is a red test instead of a discovery.

packages/cli/src/commands/init.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -547,6 +547,7 @@ export const TEMPLATES: Record<string, {
547547
start: 'objectstack compile && objectstack serve',
548548
build: 'objectstack compile',
549549
validate: 'objectstack validate',
550+
lint: 'objectstack lint',
550551
typecheck: 'tsc --noEmit',
551552
},
552553
configContent: (name: string, namespace: string) => `import { defineStack } from '@objectstack/spec';
@@ -633,6 +634,7 @@ export default ${toCamelCase(namespace)}Item;
633634
scripts: {
634635
build: 'objectstack compile',
635636
validate: 'objectstack validate',
637+
lint: 'objectstack lint',
636638
test: 'vitest run',
637639
typecheck: 'tsc --noEmit',
638640
},
@@ -706,6 +708,7 @@ export default ${toCamelCase(namespace)}Item;
706708
scripts: {
707709
build: 'objectstack compile',
708710
validate: 'objectstack validate',
711+
lint: 'objectstack lint',
709712
typecheck: 'tsc --noEmit',
710713
},
711714
configContent: (name: string, namespace: string) => `import { defineStack } from '@objectstack/spec';
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* PIN — every scaffolder emits a project that can run the on-ramp's CI workflow.
5+
*
6+
* ## The defect this exists for (#16350)
7+
*
8+
* Two scaffolders write a new project's `package.json`: `npx create-objectstack`
9+
* copies `packages/create-objectstack/src/templates/blank/`, and `os create` /
10+
* `os init` render one of the `TEMPLATES` maps in `src/commands/init.ts`. #16330
11+
* added a `lint` script to the template and a `pnpm lint` step to the workflow it
12+
* ships — and did not touch `init.ts`, whose THREE script maps each declared
13+
* `validate` and no `lint`. The two script sets diverged inside a single PR, and
14+
* the divergence went unnoticed because nothing held them equal.
15+
*
16+
* The harm is not hypothetical and not cosmetic. The template's
17+
* `.github/workflows/ci.yml` is the CI a scaffolded project starts with, and the
18+
* docs point an `os init` user at it; a project scaffolded through `init.ts` that
19+
* copies that workflow dies on `Command "lint" not found` on its first push. Nor
20+
* is `lint` a second spelling of `validate`: both call `runAuthoringRules`, but
21+
* `checkHookBodyLowering` is imported by `src/commands/lint.ts` and by nothing
22+
* else (`git grep hook-body-lowering -- packages` returns that one import and the
23+
* rule's own test), so `hook-body/not-lowerable` is reachable from `pnpm lint`
24+
* alone.
25+
*
26+
* ## What is asserted, and why nothing here is transcribed
27+
*
28+
* The required script set is DERIVED from the workflow the on-ramp ships — the
29+
* `pnpm <script>` steps it runs — not written down here. A test that listed
30+
* `['validate', 'lint', 'typecheck']` would go green on the tree where the
31+
* workflow grew a fourth step and only one scaffolder followed, which is the
32+
* exact state this file exists to catch. For the same reason the expected VALUE
33+
* of each script is read off the template's own `package.json` rather than
34+
* spelled out.
35+
*
36+
* ## The half this does NOT duplicate
37+
*
38+
* The `template-ci-workflow` pin, in the `create-objectstack` package, already
39+
* holds the workflow against the TEMPLATE's own `package.json`. That pin is
40+
* package-local by construction — it cannot see `init.ts` — and its failure text
41+
* says so in words: add the script to the template AND to the other scaffolder,
42+
* naming this package's `src/commands/init.ts`. This file is the other half of
43+
* that sentence, and the two together close the loop in both directions.
44+
*
45+
* ## Scope — why only the workflow's scripts, and not the whole map
46+
*
47+
* The two sides differ elsewhere ON PURPOSE, so whole-map equality is the wrong
48+
* assertion: `init.ts`'s `app` map spells `start` as `objectstack compile &&
49+
* objectstack serve` (with the reasoning in a comment beside it) where the
50+
* template says `objectstack start`, its `build` runs `objectstack compile` where
51+
* the template names the `objectstack build` alias, and the `plugin` / `empty`
52+
* templates scaffold a metadata package with no server to run at all. The
53+
* workflow's step list is the subset on which the two sides make the same promise
54+
* to the same user, and on that subset there is currently no accepted exception —
55+
* so this pin carries no exemption ledger, and adding one should be a decision
56+
* somebody argues for rather than a row somebody appends.
57+
*/
58+
59+
import { describe, it, expect } from 'vitest';
60+
import { readFileSync } from 'node:fs';
61+
import { resolve } from 'node:path';
62+
import { fileURLToPath } from 'node:url';
63+
import { parse as parseYaml } from 'yaml';
64+
import { TEMPLATES } from '../src/commands/init.js';
65+
66+
const HERE = resolve(fileURLToPath(import.meta.url), '..');
67+
68+
// One `resolve(HERE, ...)` call per line and nothing split across lines:
69+
// `check:cross-package-test-inputs` reconstructs these reads by SOURCE SCAN, and
70+
// a spelling it cannot parse leaves the glob declared and held by nothing. Both
71+
// are declared for `@objectstack/cli` in scripts/cross-package-test-inputs.mjs
72+
// and mirrored into turbo.json.
73+
const ON_RAMP_TEMPLATE_PKG = resolve(HERE, '../../..', 'packages/create-objectstack/src/templates/blank/package.json');
74+
const ON_RAMP_WORKFLOW = resolve(HERE, '../../..', 'packages/create-objectstack/src/templates/blank/.github/workflows/ci.yml');
75+
76+
interface WorkflowStep {
77+
uses?: string;
78+
run?: string;
79+
}
80+
81+
/**
82+
* The project scripts the on-ramp's CI workflow runs, in file order.
83+
*
84+
* `pnpm <word>` where `<word>` is not a pnpm builtin is a script run — the same
85+
* reading the template-side pin takes of the same file, so the two halves cannot
86+
* disagree about what the workflow asks for.
87+
*/
88+
function workflowScripts(): string[] {
89+
const workflow = parseYaml(readFileSync(ON_RAMP_WORKFLOW, 'utf8')) as {
90+
jobs?: Record<string, { steps?: WorkflowStep[] }>;
91+
};
92+
const out: string[] = [];
93+
for (const job of Object.values(workflow.jobs ?? {})) {
94+
for (const step of job.steps ?? []) {
95+
if (!step.run) continue;
96+
for (const line of step.run.split('\n')) {
97+
const m = /^\s*pnpm(?:\s+run)?\s+([a-z][a-z0-9:_-]*)/i.exec(line);
98+
if (!m) continue;
99+
const word = m[1];
100+
if (word === 'install' || word === 'exec' || word === 'dlx') continue;
101+
out.push(word);
102+
}
103+
}
104+
}
105+
return out;
106+
}
107+
108+
const templateScripts = (
109+
JSON.parse(readFileSync(ON_RAMP_TEMPLATE_PKG, 'utf8')) as { scripts: Record<string, string> }
110+
).scripts;
111+
112+
const REQUIRED = workflowScripts();
113+
114+
describe('scaffolder script parity — `os init` emits what the on-ramp CI runs (#16350)', () => {
115+
// The harvest is the whole assertion below, so an empty one would make every
116+
// `it.each` case vacuously green — a parser or regex that stopped matching
117+
// would read exactly like parity. Assert the reading fired before using it.
118+
it('reads at least one project script off the on-ramp workflow', () => {
119+
expect(
120+
REQUIRED.length,
121+
`no \`pnpm <script>\` step found in ${ON_RAMP_WORKFLOW} — the harvest below would be vacuous`,
122+
).toBeGreaterThan(0);
123+
});
124+
125+
// The template declaring what its own workflow runs is pinned next door, in
126+
// create-objectstack. Re-stated here only as the precondition for reading the
127+
// expected VALUES off it: an undeclared script would give `undefined` on both
128+
// sides, and `undefined === undefined` is a pass.
129+
it.each(REQUIRED)('the on-ramp template declares `%s`, so a value exists to compare against', (script) => {
130+
expect(Object.keys(templateScripts)).toContain(script);
131+
});
132+
133+
describe.each(Object.keys(TEMPLATES))('os init -t %s', (key) => {
134+
const scripts = TEMPLATES[key].scripts;
135+
136+
it.each(REQUIRED)('declares `%s`', (script) => {
137+
expect(
138+
Object.keys(scripts),
139+
`the on-ramp's CI workflow runs \`pnpm ${script}\`, but \`os init -t ${key}\` emits no such ` +
140+
'script. A project scaffolded this way that adopts that workflow — the documented next ' +
141+
`step — fails its first push with \`Command "${script}" not found\`. Add it to the map in ` +
142+
'packages/cli/src/commands/init.ts (or drop the step from the template workflow).',
143+
).toContain(script);
144+
});
145+
146+
it.each(REQUIRED)('runs the same command as the on-ramp for `%s`', (script) => {
147+
expect(
148+
scripts[script],
149+
`\`${script}\` runs different commands depending on which scaffolder the reader followed`,
150+
).toBe(templateScripts[script]);
151+
});
152+
});
153+
});

scripts/cross-package-test-inputs.mjs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -554,6 +554,15 @@ export const CROSS_PACKAGE_TEST_INPUTS = {
554554
// same and replays a cached green over it.
555555
'packages/create-objectstack/bin/create-objectstack.js',
556556
'packages/create-objectstack/src/templates/blank/package.json',
557+
// The CI workflow that same template ships, READ by
558+
// test/scaffold-ci-script-parity.test.ts (#16350). That pin DERIVES the
559+
// scripts a scaffolded project must declare from this workflow's `pnpm
560+
// <script>` steps, so a step added or renamed there changes what the pin
561+
// requires of `init.ts`'s three template maps — the divergence #16330
562+
// created (`lint` added on the template side only) is exactly what it
563+
// catches, and without the declaration `@objectstack/cli#test` would hash
564+
// the same across a workflow-only diff and replay a cached green over it.
565+
'packages/create-objectstack/src/templates/blank/.github/workflows/ci.yml',
557566
// The generator that ties those two to this package's own constants, and
558567
// the third entry of the mention shape on this package — settled the way
559568
// check-nul-bytes.mjs above is. It earns the declaration on the merits

turbo.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@
137137
"$TURBO_ROOT$/packages/create-objectstack/src/templates/blank/pnpm-workspace.yaml",
138138
"$TURBO_ROOT$/packages/create-objectstack/bin/create-objectstack.js",
139139
"$TURBO_ROOT$/packages/create-objectstack/src/templates/blank/package.json",
140+
"$TURBO_ROOT$/packages/create-objectstack/src/templates/blank/.github/workflows/ci.yml",
140141
"$TURBO_ROOT$/scripts/sync-scaffold-emission-policy.mjs",
141142
"$TURBO_ROOT$/packages/drivers/driver-sql/src/sql-driver.ts",
142143
"$TURBO_ROOT$/packages/drivers/driver-sql/src/schema-drift.ts",

0 commit comments

Comments
 (0)