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
27 changes: 27 additions & 0 deletions .github/workflows/engine-parity.yml
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions .npmignore
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
80 changes: 80 additions & 0 deletions scripts/check-engine-parity.mjs
Original file line number Diff line number Diff line change
@@ -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.`)
}
}
39 changes: 39 additions & 0 deletions scripts/test-check-engine-parity.mjs
Original file line number Diff line number Diff line change
@@ -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.');
Loading