diff --git a/.github/workflows/engine-parity.yml b/.github/workflows/engine-parity.yml new file mode 100644 index 0000000..6e356c4 --- /dev/null +++ b/.github/workflows/engine-parity.yml @@ -0,0 +1,27 @@ +name: Engine parity + +# The scaffold's generated devDependencies must match the engine release its `neo.mjs` pin resolves to, read from npm. +# See scripts/check-engine-parity.mjs for the scope and why the engine is never read from a checkout. +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + parity: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-node@v6 + with: + node-version: '24' + + - name: Parity check contract + run: node scripts/test-check-engine-parity.mjs + + - name: Generated devDependencies match the pinned engine release + run: node scripts/check-engine-parity.mjs diff --git a/.npmignore b/.npmignore index 011bf01..278acd0 100644 --- a/.npmignore +++ b/.npmignore @@ -3,6 +3,7 @@ .github/ISSUE_ARCHIVE .github/RELEASE_NOTES .github/.sync-metadata.json +scripts # Original content of the .gitignore file # See http://help.github.com/ignore-files/ for more about ignoring files. diff --git a/package.json b/package.json index f01d670..2a78fd2 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "scripts" : { "ai:mcp-server-github-workflow": "node ./node_modules/neo.mjs/ai/mcp/server/github-workflow/mcp-stdio.mjs -c ./ai/mcp/server/github-workflow/config.json", "neo-app" : "node ./tasks/neo-app.mjs", - "test" : "echo \"Error: no test specified\" && exit 1" + "test" : "node scripts/test-check-engine-parity.mjs && node scripts/check-engine-parity.mjs" }, "bin" : { "neo-app": "./tasks/neo-app.mjs" diff --git a/scripts/check-engine-parity.mjs b/scripts/check-engine-parity.mjs new file mode 100644 index 0000000..6dd5d1b --- /dev/null +++ b/scripts/check-engine-parity.mjs @@ -0,0 +1,80 @@ +#!/usr/bin/env node +/** + * @summary Fails when a devDependency the scaffold generates differs from the engine release its `neo.mjs` pin resolves + * to. + * + * `tasks/createPackageJson.mjs` writes every scaffolded app's devDependencies, which advertise the engine's own ranges. + * They once fell a whole major behind with nothing noticing. Whether a scaffolded app's bundle scripts build from these + * hoisted copies, or from the engine's nested ones after its `postinstall`, is not settled here (#34's release-gate + * run reports it): the declared ranges should agree either way. + * + * Scope: every devDependency BOTH sides declare, compared exactly. The engine is read from npm, never from a checkout, + * because a scaffolded app installs the published package and a checkout can be ahead of it. The pin decides which + * release: the newest published version its range allows. A package only the scaffold declares has no engine + * counterpart to drift from, so it is listed and not compared. + */ + +import {execFileSync} from 'node:child_process'; +import {realpathSync} from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import process from 'node:process'; +import {fileURLToPath} from 'node:url'; +import createPackageJson from '../tasks/createPackageJson.mjs'; + +/** + * @summary The package.json the scaffold writes, captured without touching a disk. + * @returns {Object} + */ +export function generatedPackageJson() { + let packageJson = null; + + createPackageJson.init('parity-check', 'parity-check', {writeFileSync: (file, text) => {packageJson = JSON.parse(text)}}, os, path); + + return packageJson +} + +/** + * @summary Compares the scaffold's devDependencies with the engine's. + * @param {Object} scaffold The generated devDependencies + * @param {Object} engine The engine release's devDependencies + * @returns {{drift: Object[], scaffoldOnly: String[]}} `drift` holds `{name, scaffold, engine}` for each differing range + */ +export function findDrift(scaffold, engine) { + const names = Object.keys(scaffold); + + return { + drift : names.filter(name => name in engine && scaffold[name] !== engine[name]) + .map(name => ({name, scaffold: scaffold[name], engine: engine[name]})), + scaffoldOnly: names.filter(name => !(name in engine)) + } +} + +/** + * @param {...String} args + * @returns {*} the parsed `npm view … --json` answer + */ +const npmView = (...args) => JSON.parse(execFileSync('npm', ['view', ...args, '--json'], {encoding: 'utf8'})); + +if (process.argv[1] && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))) { + const + packageJson = generatedPackageJson(), + range = packageJson.dependencies['neo.mjs'], + // One matching version answers as a string, several as an ascending list + version = [].concat(npmView(`neo.mjs@${range}`, 'version')).at(-1), + {drift, scaffoldOnly} = findDrift(packageJson.devDependencies, npmView(`neo.mjs@${version}`, 'devDependencies')); + + if (scaffoldOnly.length > 0) { + console.log(`check-engine-parity: not compared, no engine counterpart: ${scaffoldOnly.join(', ')}`) + } + + if (drift.length > 0) { + console.error(`check-engine-parity: ${drift.length} generated devDependencies differ from neo.mjs@${version} ` + + `(the newest release \`${range}\` allows):`); + drift.forEach(({name, scaffold, engine}) => console.error(` ${name} scaffold ${scaffold} engine ${engine}`)); + console.error('Move them in tasks/createPackageJson.mjs to the engine\'s values.'); + process.exitCode = 1 + } else { + console.log(`check-engine-parity: every devDependency the scaffold shares with neo.mjs@${version} matches.`) + } +} diff --git a/scripts/test-check-engine-parity.mjs b/scripts/test-check-engine-parity.mjs new file mode 100644 index 0000000..1dda489 --- /dev/null +++ b/scripts/test-check-engine-parity.mjs @@ -0,0 +1,39 @@ +#!/usr/bin/env node +/** + * @summary Contract checks for the engine-parity check: it reads what the scaffold really generates, reports a planted + * divergence, stays quiet on a match, and lists what it does not compare. + */ + +import assert from 'node:assert/strict'; +import {findDrift, generatedPackageJson} from './check-engine-parity.mjs'; + +// ── It reads the package.json the scaffold writes ────────────────────────────────────────────────── +{ + const packageJson = generatedPackageJson(); + + assert.match(packageJson.dependencies['neo.mjs'], /^\^\d+\.\d+\.\d+$/, 'the engine pin is read from the generator'); + assert.ok(Object.keys(packageJson.devDependencies).length > 10, 'and so are the devDependencies'); +} + +// ── A planted divergence is reported, a match is not ─────────────────────────────────────────────── +{ + const engine = {marked: '^18.0.13', 'monaco-editor': '0.56.0', sass: '^1.104.1'}; + + assert.deepEqual(findDrift({marked: '^18.0.13', 'monaco-editor': '0.56.0'}, engine).drift, [], 'matching ranges pass'); + + assert.deepEqual(findDrift({marked: '^17.0.4', 'monaco-editor': '0.56.0'}, engine).drift, + [{name: 'marked', scaffold: '^17.0.4', engine: '^18.0.13'}], 'a range a major behind is drift'); + + assert.deepEqual(findDrift({'monaco-editor': '^0.56.0'}, engine).drift, + [{name: 'monaco-editor', scaffold: '^0.56.0', engine: '0.56.0'}], 'an exact pin widened to a caret is drift too'); +} + +// ── What only the scaffold declares is listed, never compared ────────────────────────────────────── +{ + const {drift, scaffoldOnly} = findDrift({chromadb: '^3.3.1', marked: '^18.0.13'}, {marked: '^18.0.13'}); + + assert.deepEqual(drift, []); + assert.deepEqual(scaffoldOnly, ['chromadb']) +} + +console.log('check-engine-parity: contract arms green.');