Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
20 changes: 20 additions & 0 deletions .changeset/init-scaffold-lint-script-parity.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
"@objectstack/cli": patch
---

`os init` / `os create` now write a `lint` script into every scaffolded project, matching what `npx create-objectstack` already emits.

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

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

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:

```json
"scripts": {
"validate": "objectstack validate",
"lint": "objectstack lint"
}
```

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.
3 changes: 3 additions & 0 deletions packages/cli/src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -547,6 +547,7 @@ export const TEMPLATES: Record<string, {
start: 'objectstack compile && objectstack serve',
build: 'objectstack compile',
validate: 'objectstack validate',
lint: 'objectstack lint',
typecheck: 'tsc --noEmit',
},
configContent: (name: string, namespace: string) => `import { defineStack } from '@objectstack/spec';
Expand Down Expand Up @@ -633,6 +634,7 @@ export default ${toCamelCase(namespace)}Item;
scripts: {
build: 'objectstack compile',
validate: 'objectstack validate',
lint: 'objectstack lint',
test: 'vitest run',
typecheck: 'tsc --noEmit',
},
Expand Down Expand Up @@ -706,6 +708,7 @@ export default ${toCamelCase(namespace)}Item;
scripts: {
build: 'objectstack compile',
validate: 'objectstack validate',
lint: 'objectstack lint',
typecheck: 'tsc --noEmit',
},
configContent: (name: string, namespace: string) => `import { defineStack } from '@objectstack/spec';
Expand Down
153 changes: 153 additions & 0 deletions packages/cli/test/scaffold-ci-script-parity.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* PIN — every scaffolder emits a project that can run the on-ramp's CI workflow.
*
* ## The defect this exists for (#16350)
*
* Two scaffolders write a new project's `package.json`: `npx create-objectstack`
* copies `packages/create-objectstack/src/templates/blank/`, and `os create` /
* `os init` render one of the `TEMPLATES` maps in `src/commands/init.ts`. #16330
* added a `lint` script to the template and a `pnpm lint` step to the workflow it
* ships — and did not touch `init.ts`, whose THREE script maps each declared
* `validate` and no `lint`. The two script sets diverged inside a single PR, and
* the divergence went unnoticed because nothing held them equal.
*
* The harm is not hypothetical and not cosmetic. The template's
* `.github/workflows/ci.yml` is the CI a scaffolded project starts with, and the
* docs point an `os init` user at it; a project scaffolded through `init.ts` that
* copies that workflow dies on `Command "lint" not found` on its first push. Nor
* is `lint` a second spelling of `validate`: both call `runAuthoringRules`, but
* `checkHookBodyLowering` is imported by `src/commands/lint.ts` and by nothing
* else (`git grep hook-body-lowering -- packages` returns that one import and the
* rule's own test), so `hook-body/not-lowerable` is reachable from `pnpm lint`
* alone.
*
* ## What is asserted, and why nothing here is transcribed
*
* The required script set is DERIVED from the workflow the on-ramp ships — the
* `pnpm <script>` steps it runs — not written down here. A test that listed
* `['validate', 'lint', 'typecheck']` would go green on the tree where the
* workflow grew a fourth step and only one scaffolder followed, which is the
* exact state this file exists to catch. For the same reason the expected VALUE
* of each script is read off the template's own `package.json` rather than
* spelled out.
*
* ## The half this does NOT duplicate
*
* The `template-ci-workflow` pin, in the `create-objectstack` package, already
* holds the workflow against the TEMPLATE's own `package.json`. That pin is
* package-local by construction — it cannot see `init.ts` — and its failure text
* says so in words: add the script to the template AND to the other scaffolder,
* naming this package's `src/commands/init.ts`. This file is the other half of
* that sentence, and the two together close the loop in both directions.
*
* ## Scope — why only the workflow's scripts, and not the whole map
*
* The two sides differ elsewhere ON PURPOSE, so whole-map equality is the wrong
* assertion: `init.ts`'s `app` map spells `start` as `objectstack compile &&
* objectstack serve` (with the reasoning in a comment beside it) where the
* template says `objectstack start`, its `build` runs `objectstack compile` where
* the template names the `objectstack build` alias, and the `plugin` / `empty`
* templates scaffold a metadata package with no server to run at all. The
* workflow's step list is the subset on which the two sides make the same promise
* to the same user, and on that subset there is currently no accepted exception —
* so this pin carries no exemption ledger, and adding one should be a decision
* somebody argues for rather than a row somebody appends.
*/

import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { parse as parseYaml } from 'yaml';
import { TEMPLATES } from '../src/commands/init.js';

const HERE = resolve(fileURLToPath(import.meta.url), '..');

// One `resolve(HERE, ...)` call per line and nothing split across lines:
// `check:cross-package-test-inputs` reconstructs these reads by SOURCE SCAN, and
// a spelling it cannot parse leaves the glob declared and held by nothing. Both
// are declared for `@objectstack/cli` in scripts/cross-package-test-inputs.mjs
// and mirrored into turbo.json.
const ON_RAMP_TEMPLATE_PKG = resolve(HERE, '../../..', 'packages/create-objectstack/src/templates/blank/package.json');
const ON_RAMP_WORKFLOW = resolve(HERE, '../../..', 'packages/create-objectstack/src/templates/blank/.github/workflows/ci.yml');

interface WorkflowStep {
uses?: string;
run?: string;
}

/**
* The project scripts the on-ramp's CI workflow runs, in file order.
*
* `pnpm <word>` where `<word>` is not a pnpm builtin is a script run — the same
* reading the template-side pin takes of the same file, so the two halves cannot
* disagree about what the workflow asks for.
*/
function workflowScripts(): string[] {
const workflow = parseYaml(readFileSync(ON_RAMP_WORKFLOW, 'utf8')) as {
jobs?: Record<string, { steps?: WorkflowStep[] }>;
};
const out: string[] = [];
for (const job of Object.values(workflow.jobs ?? {})) {
for (const step of job.steps ?? []) {
if (!step.run) continue;
for (const line of step.run.split('\n')) {
const m = /^\s*pnpm(?:\s+run)?\s+([a-z][a-z0-9:_-]*)/i.exec(line);
if (!m) continue;
const word = m[1];
if (word === 'install' || word === 'exec' || word === 'dlx') continue;
out.push(word);
}
}
}
return out;
}

const templateScripts = (
JSON.parse(readFileSync(ON_RAMP_TEMPLATE_PKG, 'utf8')) as { scripts: Record<string, string> }
).scripts;

const REQUIRED = workflowScripts();

describe('scaffolder script parity — `os init` emits what the on-ramp CI runs (#16350)', () => {
// The harvest is the whole assertion below, so an empty one would make every
// `it.each` case vacuously green — a parser or regex that stopped matching
// would read exactly like parity. Assert the reading fired before using it.
it('reads at least one project script off the on-ramp workflow', () => {
expect(
REQUIRED.length,
`no \`pnpm <script>\` step found in ${ON_RAMP_WORKFLOW} — the harvest below would be vacuous`,
).toBeGreaterThan(0);
});

// The template declaring what its own workflow runs is pinned next door, in
// create-objectstack. Re-stated here only as the precondition for reading the
// expected VALUES off it: an undeclared script would give `undefined` on both
// sides, and `undefined === undefined` is a pass.
it.each(REQUIRED)('the on-ramp template declares `%s`, so a value exists to compare against', (script) => {
expect(Object.keys(templateScripts)).toContain(script);
});

describe.each(Object.keys(TEMPLATES))('os init -t %s', (key) => {
const scripts = TEMPLATES[key].scripts;

it.each(REQUIRED)('declares `%s`', (script) => {
expect(
Object.keys(scripts),
`the on-ramp's CI workflow runs \`pnpm ${script}\`, but \`os init -t ${key}\` emits no such ` +
'script. A project scaffolded this way that adopts that workflow — the documented next ' +
`step — fails its first push with \`Command "${script}" not found\`. Add it to the map in ` +
'packages/cli/src/commands/init.ts (or drop the step from the template workflow).',
).toContain(script);
});

it.each(REQUIRED)('runs the same command as the on-ramp for `%s`', (script) => {
expect(
scripts[script],
`\`${script}\` runs different commands depending on which scaffolder the reader followed`,
).toBe(templateScripts[script]);
});
});
});
9 changes: 9 additions & 0 deletions scripts/cross-package-test-inputs.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -554,6 +554,15 @@ export const CROSS_PACKAGE_TEST_INPUTS = {
// same and replays a cached green over it.
'packages/create-objectstack/bin/create-objectstack.js',
'packages/create-objectstack/src/templates/blank/package.json',
// The CI workflow that same template ships, READ by
// test/scaffold-ci-script-parity.test.ts (#16350). That pin DERIVES the
// scripts a scaffolded project must declare from this workflow's `pnpm
// <script>` steps, so a step added or renamed there changes what the pin
// requires of `init.ts`'s three template maps — the divergence #16330
// created (`lint` added on the template side only) is exactly what it
// catches, and without the declaration `@objectstack/cli#test` would hash
// the same across a workflow-only diff and replay a cached green over it.
'packages/create-objectstack/src/templates/blank/.github/workflows/ci.yml',
// The generator that ties those two to this package's own constants, and
// the third entry of the mention shape on this package — settled the way
// check-nul-bytes.mjs above is. It earns the declaration on the merits
Expand Down
1 change: 1 addition & 0 deletions turbo.json
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@
"$TURBO_ROOT$/packages/create-objectstack/src/templates/blank/pnpm-workspace.yaml",
"$TURBO_ROOT$/packages/create-objectstack/bin/create-objectstack.js",
"$TURBO_ROOT$/packages/create-objectstack/src/templates/blank/package.json",
"$TURBO_ROOT$/packages/create-objectstack/src/templates/blank/.github/workflows/ci.yml",
"$TURBO_ROOT$/scripts/sync-scaffold-emission-policy.mjs",
"$TURBO_ROOT$/packages/drivers/driver-sql/src/sql-driver.ts",
"$TURBO_ROOT$/packages/drivers/driver-sql/src/schema-drift.ts",
Expand Down
Loading