From 04e05e62e34732bdf3bd868d3db0f6c924f7aaf6 Mon Sep 17 00:00:00 2001 From: Ada Date: Sat, 19 Sep 2026 18:07:15 +0200 Subject: [PATCH 1/2] ci: the scaffold's devDependencies are checked against the engine release its pin resolves to (#34) The generated devDependencies are only right while they match the engine's: a scaffolded app builds the engine's browser bundles from them. They once fell a whole major behind with nothing noticing. scripts/check-engine-parity.mjs reads what tasks/createPackageJson.mjs generates, resolves the newest published neo.mjs its pin allows, and fails on any range both sides declare that differs. It runs in CI on push and pull request, and it is green at today's pin: all 43 shared devDependencies match neo.mjs@12.1.0. --- .github/workflows/engine-parity.yml | 27 ++++++++++ .npmignore | 1 + package.json | 2 +- scripts/check-engine-parity.mjs | 79 ++++++++++++++++++++++++++++ scripts/test-check-engine-parity.mjs | 39 ++++++++++++++ 5 files changed, 147 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/engine-parity.yml create mode 100644 scripts/check-engine-parity.mjs create mode 100644 scripts/test-check-engine-parity.mjs 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..1ae1f72 --- /dev/null +++ b/scripts/check-engine-parity.mjs @@ -0,0 +1,79 @@ +#!/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, and they are only right while they match + * the engine's: a scaffolded app builds the engine's browser bundles (parse5, marked, mermaid, monaco, highlight.js) + * from them, into the engine's own package. They once fell a whole major behind with nothing noticing. + * + * 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.'); From f84a394f411e012b6712c55c6da93e0e653bc489 Mon Sep 17 00:00:00 2001 From: Ada Date: Sat, 19 Sep 2026 18:59:52 +0200 Subject: [PATCH 2/2] docs(ci): the parity check states the bundle-consumption question as open, not as its reason (#37) --- scripts/check-engine-parity.mjs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/scripts/check-engine-parity.mjs b/scripts/check-engine-parity.mjs index 1ae1f72..6dd5d1b 100644 --- a/scripts/check-engine-parity.mjs +++ b/scripts/check-engine-parity.mjs @@ -3,9 +3,10 @@ * @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, and they are only right while they match - * the engine's: a scaffolded app builds the engine's browser bundles (parse5, marked, mermaid, monaco, highlight.js) - * from them, into the engine's own package. They once fell a whole major behind with nothing noticing. + * `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