Files
msd-core/scripts/generate-package-identity.cjs
Tom Boucher b0bd2f7a48 chore: move committed-generated-artifact freshness checks to lint:ci (#2000)
gsd-test's build leg runs the full 'npm run build' (which regenerates
capability-registry.cjs, loop-host-contract.cjs, package-identity.cjs, etc.),
so committed-freshness guards that lived in the unit suite were masked there:
gsd-test passed a stale-commit that CI's shard-1/3 test then red-flagged
(caught live on PR #1998). The mandated pre-push gate was green on a commit
CI correctly flagged.

Move the committed-state --check guards into a new 'lint:generated-sync'
script wired into lint:ci (the single orchestrated entry point the lint-tests
CI job already runs on a build:lib-only tree, so the committed artifacts are
checked without regeneration). gsd-test no longer contains these guards, so
it can no longer mask them.

- package.json: add lint:generated-sync (7 generators --check); wire into lint:ci.
- generate-package-identity.cjs: add --check mode (was the only generator
  without it); no-arg behaviour unchanged (still writes, as build expects).
- Remove the committed-freshness guards from the unit suite, keeping all
  behavioral/structural tests:
    - capability-registry.test.cjs: drop the --check describe.
    - loop-host-contract.test.cjs: drop the committed-file staleness test
      (keep the normalizeLineEndings unit test).
    - capability-matrix-sync.test.cjs: drop --check + byte-for-byte (keep the
      architectural content invariants: every cap appears, security ship:pre).
    - issue-844-manifest-version-sync.test.cjs: drop describe D (--check).
    - issue-498-package-identity.test.cjs: drop the drift-check test (keep
      behavioral module-export tests); drop the now-unused render import and
      its allow-test-rule exemption (allowlist ratcheted 175 -> 174).
2026-07-03 19:37:14 -04:00

151 lines
6.0 KiB
JavaScript

#!/usr/bin/env node
'use strict';
/**
* Single source for GSD's published-package coordinates (issue #498).
*
* `deriveIdentity(pkg)` is the pure core: it turns a parsed package.json into
* the coordinate record every consumer needs. The generated runtime module
* `gsd-core/bin/lib/package-identity.cjs` bakes those values at build
* time, because the installed tree carries only a synthetic
* `{"type":"commonjs"}` package.json (no `.name`) — so a runtime
* `require('package.json').name` resolves to `undefined` (the #378 bug this
* seam retires). Baking from package.json reconciles #378 (renames survive)
* with #2992 (the value is never an LLM runtime choice).
*/
/**
* Parse `owner/name` out of a package.json `repository.url`, stripping the
* `git+` prefix and `.git` suffix npm conventionally adds.
*/
function parseRepoSlug(repository) {
const url = typeof repository === 'string' ? repository : (repository && repository.url) || '';
const m = url.replace(/^git\+/, '').replace(/\.git$/, '').match(/github\.com[/:]([^/]+\/[^/]+)$/);
return m ? m[1] : '';
}
/**
* Pure: turn an npm package name into a filesystem-safe slug for cache filenames.
* Strips a leading `@`, replaces `/` with `-`, then collapses any run of
* characters that are NOT `[a-z0-9]` to a single `-`, and trims leading/trailing `-`.
*/
function slugifyPackageName(name) {
if (!name) return '';
return name
.replace(/^@/, '')
.replace(/\//g, '-')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
}
/**
* Pure: package.json object -> the package identity coordinates.
*/
function deriveIdentity(pkg = {}) {
const packageName = pkg.name || '';
const binName = pkg.bin ? Object.keys(pkg.bin)[0] || '' : '';
const repoSlug = parseRepoSlug(pkg.repository);
const repoUrl = repoSlug ? `https://github.com/${repoSlug}` : '';
const changelogRawUrl = repoSlug
? `https://raw.githubusercontent.com/${repoSlug}/main/CHANGELOG.md`
: '';
const cacheSlug = slugifyPackageName(packageName);
const updateCacheFileName = cacheSlug ? `gsd-update-check-${cacheSlug}.json` : 'gsd-update-check.json';
return { packageName, binName, repoSlug, repoUrl, changelogRawUrl, cacheSlug, updateCacheFileName };
}
/**
* Pure: format the `npx` fallback install command. Shape matches the literal
* the update workflow embeds: `npx -y --package=<pkg>@latest -- <bin>
* [--<runtime>] --<scope>`. The runtime flag is omitted when not supplied.
*
* This function is the canonical source — `render()` serializes it verbatim
* into the generated module, so the runtime copy can never drift from it.
*/
function formatManualInstall({ packageName, binName, scope, runtime } = {}) {
const runtimeFlag = runtime ? ` --${runtime}` : '';
return `npx -y --package=${packageName}@latest -- ${binName}${runtimeFlag} --${scope}`;
}
const GENERATED_HEADER =
'// @generated by scripts/generate-package-identity.cjs from package.json — DO NOT EDIT.\n' +
'// Single source for GSD package coordinates (issue #498). Regenerate with:\n' +
'// node scripts/generate-package-identity.cjs\n';
/**
* Render the generated runtime module text for a derived identity. Values are
* baked as literals; `formatManualInstall` is embedded by `.toString()` so the
* runtime command builder is byte-identical to the tested source above.
*/
function render(identity) {
const { packageName, binName, repoSlug, repoUrl, changelogRawUrl, cacheSlug, updateCacheFileName } = identity;
const j = (v) => JSON.stringify(v);
return (
GENERATED_HEADER +
"'use strict';\n\n" +
`const packageName = ${j(packageName)};\n` +
`const binName = ${j(binName)};\n` +
`const repoSlug = ${j(repoSlug)};\n` +
`const repoUrl = ${j(repoUrl)};\n` +
`const changelogRawUrl = ${j(changelogRawUrl)};\n` +
`const cacheSlug = ${j(cacheSlug)};\n` +
`const updateCacheFileName = ${j(updateCacheFileName)};\n\n` +
`${formatManualInstall.toString()}\n\n` +
'function manualInstallCommand(opts = {}) {\n' +
' return formatManualInstall({ packageName, binName, scope: opts.scope, runtime: opts.runtime });\n' +
'}\n\n' +
'module.exports = Object.freeze({\n' +
' packageName,\n' +
' // PACKAGE_NAME: back-compat alias for #516-era consumers. Baked here, so it\n' +
" // survives the installed tree's synthetic package.json (fixes the #378 undefined).\n" +
' PACKAGE_NAME: packageName,\n' +
' binName,\n' +
' repoSlug,\n' +
' repoUrl,\n' +
' changelogRawUrl,\n' +
' cacheSlug,\n' +
' updateCacheFileName,\n' +
' manualInstallCommand,\n' +
'});\n'
);
}
function main() {
const fs = require('node:fs');
const path = require('node:path');
const args = process.argv.slice(2);
const check = args.includes('--check');
const pkg = require(path.join(__dirname, '..', 'package.json'));
const out = path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'package-identity.cjs');
const rendered = render(deriveIdentity(pkg));
if (check) {
// Compare normalized content so a CRLF checkout (Windows, no .gitattributes
// eol rule) does not register as stale. Mirrors the in-process check the
// unit suite used to perform (issue #498) and the convention used by the
// other gen-* --check generators.
const norm = (s) => s.replace(/\r\n/g, '\n');
let committed = '';
try {
committed = fs.readFileSync(out, 'utf8');
} catch (e) {
if (e.code !== 'ENOENT') throw e;
}
if (norm(committed) !== norm(rendered)) {
process.stderr.write('package-identity.cjs is stale — run: node scripts/generate-package-identity.cjs\n');
return 1;
}
return 0;
}
fs.writeFileSync(out, rendered);
process.stdout.write(`wrote ${path.relative(path.join(__dirname, '..'), out)}\n`);
return 0;
}
if (require.main === module) {
const code = main();
if (typeof code === 'number' && code !== 0) process.exitCode = code;
}
module.exports = { deriveIdentity, parseRepoSlug, slugifyPackageName, formatManualInstall, render, main };