|
| 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 | +}); |
0 commit comments