#!/usr/bin/env node // PR declarations are unversioned; releases require assembled guides and no pending files. import { execFileSync } from 'node:child_process'; import { basename, posix } from 'node:path'; import { argv, cwd, exit, stderr, stdout } from 'node:process'; import { fileURLToPath } from 'node:url'; import matter from 'gray-matter'; export function parseChangesFrontmatter(src) { if (!/^---\s*\n[\s\S]*?\n---/.test(src)) return { ok: false, reason: 'missing frontmatter block' }; try { const { data } = matter(src); if (Array.isArray(data?.changes)) return { ok: true, changes: data.changes }; return { ok: false, reason: 'changes key must contain an array' }; } catch { return { ok: false, reason: 'malformed YAML frontmatter' }; } } export function parseVersion(spec) { const match = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/.exec(spec); if (!match) throw new Error(`unparseable version "${spec}"`); const rc = match[4] ? /^rc\.(\d+)$/.exec(match[4]) : null; return { major: Number(match[1]), minor: Number(match[2]), patch: Number(match[3]), rc: rc ? Number(rc[1]) : null, }; } export function versionSegment(version) { return version.rc === null ? `${version.major}.${version.minor}` : `${version.major}.${version.minor}.${version.patch}-rc.${version.rc}`; } export function comparePrecedence(left, right) { for (const field of ['major', 'minor', 'patch']) if (left[field] !== right[field]) return left[field] - right[field]; if (left.rc === right.rc) return 0; if (left.rc === null) return 1; if (right.rc === null) return -1; return left.rc - right.rc; } export function transitionLabel(prev, head) { return `${versionSegment(prev)}-to-${versionSegment(head)}`; } const PENDING = 'upgrade-instructions/pending/'; const PENDING_INSTRUCTIONS = /^upgrade-instructions\/pending\/[^/]+\/(app|extension)\/instructions\.md$/; const PUBLISHED_ROOT = 'skills/prisma-8/upgrading/'; const PUBLISHED_DIRECTORY = new RegExp(`^(${PUBLISHED_ROOT}(?:app|extension)/upgrades/[^/]+)/`); function publishedGuide(audience, transition) { return `${PUBLISHED_ROOT}${audience}/upgrades/${transition}/instructions.md`; } const COVERED_DIRECTORIES = [ { audience: 'app', directories: ['examples/'] }, { audience: 'extension', directories: ['packages/3-extensions/', 'packages/3-targets/*/9-facade/'], }, ]; function directoryPattern(directory) { return new RegExp(`^${directory.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replaceAll('*', '[^/]+')}`); } function isUnderDirectory(path, directory) { return directoryPattern(directory).test(path); } function gitWith(options, repoRoot, ...args) { return execFileSync('git', args, { cwd: repoRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], ...options, }); } function git(repoRoot, ...args) { return gitWith({}, repoRoot, ...args); } function tryGit(repoRoot, ...args) { try { return git(repoRoot, ...args).trim(); } catch { return null; } } function readFile(repoRoot, ref, path) { try { return git(repoRoot, 'show', `${ref}:${path}`); } catch { return null; } } /** The files under the pending and published instruction directories, the only ones the check reads. */ function tree(repoRoot, ref) { return new Map( git(repoRoot, 'ls-tree', '-r', '-z', ref, '--', PENDING, PUBLISHED_ROOT) .split('\0') .filter(Boolean) .map((entry) => { const tab = entry.indexOf('\t'); return [entry.slice(tab + 1), entry.slice(0, tab).split(' ')[0]]; }), ); } /** * The changed files under the covered and published directories, the only ones the check reads. * The listing grows with the change, so it is read without the default 1 MiB limit on output. */ function changedPaths(repoRoot, prev, head) { const pathspecs = [ ...COVERED_DIRECTORIES.flatMap(({ directories }) => directories), PUBLISHED_ROOT, ].map((directory) => `:(glob)${directory}**`); return gitWith( { maxBuffer: Number.POSITIVE_INFINITY }, repoRoot, 'diff', '--no-renames', '--name-only', '-z', prev, head, '--', ...pathspecs, ) .split('\0') .filter(Boolean); } const DEPENDENCY_MAPS = [ 'dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies', ]; const BIOME_CONFIG_NAMES = new Set(['biome.json', 'biome.jsonc']); const CONTRACT_ARTEFACT_NAMES = new Set(['contract.json', 'contract.d.ts']); function isTestInfrastructurePath(path) { const name = basename(path); return ( name === 'coverage.config.json' || /^vitest\.config\.[cm]?[jt]s$/.test(name) || /(^|\/)tests?(\/|$)/.test(path) || /\.(test|spec)(-d)?\.[cm]?[jt]sx?$/.test(name) ); } function canonicalJson(value) { if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`; if (value !== null && typeof value === 'object') return `{${Object.keys(value) .sort() .map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`) .join(',')}}`; return JSON.stringify(value); } function normalizeManifest(text, ignoreOwnVersion, ignoreScripts) { const parsed = JSON.parse(text); if (ignoreOwnVersion && typeof parsed.version === 'string') parsed.version = ''; for (const map of DEPENDENCY_MAPS) { const deps = parsed[map]; if (deps !== null && typeof deps === 'object' && !Array.isArray(deps)) parsed[map] = Object.fromEntries(Object.keys(deps).map((name) => [name, ''])); } if (ignoreScripts) delete parsed.scripts; return canonicalJson(parsed); } export function manifestShapeIgnoringVersions(text, ignoreOwnVersion = false) { return normalizeManifest(text, ignoreOwnVersion, false); } export function blankExtensionVersionStamps(text) { return text .replace(/("targetId":\s*"[^"]*",\s*"version":\s*")[^"]*(")/g, '$1$2') .replace(/(readonly targetId:\s*'[^']*';\s*readonly version:\s*')[^']*(')/g, '$1$2'); } function isTranslationIrrelevant(repoRoot, prev, head, path) { if (isTestInfrastructurePath(path)) return true; const before = readFile(repoRoot, prev, path); const after = readFile(repoRoot, head, path); if (before === null || after === null) return false; if (basename(path) === 'package.json') { try { return normalizeManifest(before, false, true) === normalizeManifest(after, false, true); } catch { return false; } } if (BIOME_CONFIG_NAMES.has(basename(path))) { const blankSchema = (text) => text.replace(/"\$schema"\s*:\s*"[^"]*"/g, '"$schema":""'); return blankSchema(before) === blankSchema(after); } if (CONTRACT_ARTEFACT_NAMES.has(basename(path))) return blankExtensionVersionStamps(before) === blankExtensionVersionStamps(after); return false; } function validateInstructions(repoRoot, head, files, path, violations) { const raw = readFile(repoRoot, head, path); const regular = /^100(?:644|755)$/.test(files.get(path) ?? ''); const parsed = regular && raw !== null ? parseChangesFrontmatter(raw) : { ok: false, reason: 'instructions file missing or not a regular file' }; if (!parsed.ok) { violations.push({ rule: 'instructions-format', path, reason: parsed.reason }); return; } for (const change of parsed.changes) { if (change === null || typeof change !== 'object' || !Object.hasOwn(change, 'script')) continue; const script = change.script; const invalid = typeof script !== 'string' || !script || script.includes('\\') || script.startsWith('/') || /^[a-z][a-z0-9+.-]*:/i.test(script) || script.split('/').includes('..'); const target = invalid ? null : posix.join(posix.dirname(path), script); if (invalid || !/^100(?:644|755)$/.test(files.get(target) ?? '')) violations.push({ rule: 'script-reference', path, script: String(script), reason: 'script must reference an existing regular file within this instruction directory', }); } } function resolveDefaultPrev(repoRoot, mode, head) { if (mode === 'dev') return head; if (mode === 'pr') { for (const ref of ['origin/main', 'main']) if (tryGit(repoRoot, 'rev-parse', '--verify', `${ref}^{commit}`)) return ref; throw new Error( '--mode pr default --prev requires origin/main or main; pass --prev explicitly', ); } const tag = previousReleaseTag(repoRoot, head); if (tag) return tag; throw new Error( '--mode publish default --prev requires a prior release tag reachable from --head (dev/beta and the head release excluded); pass --prev explicitly', ); } function previousReleaseTag(repoRoot, head) { const headVersion = parseVersion( JSON.parse(git(repoRoot, 'show', `${head}:package.json`)).version, ); const tags = git(repoRoot, 'tag', '--merged', head, '--list', 'v[0-9]*') .split('\n') .filter((tag) => /^v\d+\.\d+\.\d+(?:-rc\.\d+)?$/.test(tag)); const prior = tags.filter( (tag) => comparePrecedence(parseVersion(tag.slice(1)), headVersion) < 0, ); if (prior.length) { const args = prior.flatMap((tag) => ['--match', tag]); const tag = tryGit(repoRoot, 'describe', '--abbrev=0', '--tags', ...args, head); if (tag) return tag; } return null; } export function parseArgs(args) { const out = { mode: 'pr', head: 'HEAD', prev: null, json: false }; for (let i = 0; i < args.length; i++) { const arg = args[i]; if (['--mode', '--head', '--prev'].includes(arg)) { const value = args[++i]; if (!value || value.startsWith('-')) throw new Error(`${arg} requires a value`); out[arg.slice(2)] = value; } else if (arg === '--json') out.json = true; else if (arg === '--help' || arg === '-h') out.help = true; else throw new Error(`unknown argument "${arg}"`); } if (!['pr', 'publish', 'dev'].includes(out.mode)) throw new Error('--mode must be "pr", "publish" or "dev"'); return out; } export function runCheck({ repoRoot, head = 'HEAD', prev, mode = 'pr' }) { if (!['pr', 'publish', 'dev'].includes(mode)) throw new Error(`invalid mode "${mode}"`); head = git(repoRoot, 'rev-parse', '--verify', `${head}^{commit}`).trim(); prev = prev ?? resolveDefaultPrev(repoRoot, mode, head); prev = git(repoRoot, 'rev-parse', '--verify', `${prev}^{commit}`).trim(); const headVersion = parseVersion( JSON.parse(git(repoRoot, 'show', `${head}:package.json`)).version, ); const prevVersion = parseVersion( JSON.parse(git(repoRoot, 'show', `${prev}:package.json`)).version, ); const precedence = comparePrecedence(headVersion, prevVersion); if (precedence < 0) throw new Error( `head ${versionSegment(headVersion)} is behind prev ${versionSegment(prevVersion)} (reversed range)`, ); const release = mode === 'publish' || (mode === 'pr' && precedence > 0); const files = tree(repoRoot, head); const baseFiles = tree(repoRoot, prev); const changed = changedPaths(repoRoot, prev, head); const pending = [...files.keys()].filter((path) => path.startsWith(PENDING)); const declarations = pending.filter((path) => PENDING_INSTRUCTIONS.test(path)); const validate = new Set([ ...declarations, ...changed.flatMap((path) => { const directory = PUBLISHED_DIRECTORY.exec(path)?.[1]; const guide = directory ? `${directory}/instructions.md` : null; return guide && files.has(guide) ? [guide] : []; }), ]); const violations = []; const releaseBase = release && mode === 'pr' ? previousReleaseTag(repoRoot, head) : null; const releaseFrom = releaseBase ? parseVersion(JSON.parse(git(repoRoot, 'show', `${releaseBase}:package.json`)).version) : prevVersion; const transition = release ? transitionLabel(releaseFrom, headVersion) : null; if (release) { for (const path of pending) violations.push({ rule: 'pending-release', path, reason: 'assemble and archive all pending files before release', }); for (const { audience } of COVERED_DIRECTORIES) validate.add(publishedGuide(audience, transition)); } else if (mode === 'pr') { for (const { audience, directories } of COVERED_DIRECTORIES) { const relevant = changed.filter( (path) => directories.some((directory) => isUnderDirectory(path, directory)) && !isTranslationIrrelevant(repoRoot, prev, head, path), ); if ( relevant.length && !declarations.some( (path) => PENDING_INSTRUCTIONS.exec(path)[1] === audience && !baseFiles.has(path), ) ) { violations.push({ rule: 'per-pr-declaration', path: `${PENDING}/${audience}/instructions.md`, reason: `diff in ${directories.join(', ')} requires a new declaration relative to --prev; inherited or modified files do not count`, sampleDiffPaths: relevant.slice(0, 5), }); } } } for (const path of validate) validateInstructions(repoRoot, head, files, path, violations); return { ok: violations.length === 0, mode, release, headSegment: versionSegment(headVersion), prevSegment: versionSegment(prevVersion), coverageChain: transition ? [transition] : [], violations, }; } export function main(args = argv.slice(2), repoRoot = cwd()) { try { const parsed = parseArgs(args); if (parsed.help) { stdout.write( 'Usage: check-upgrade-coverage [--mode pr|publish|dev] [--head ] [--prev ] [--json]\nPR checks new pending declarations, or release completeness on a version bump.\nPublish always checks release completeness; dev validates pending work without blocking publication.\nDefault prev: origin/main or main (pr), prior release tag reachable from head (publish), head (dev).\n', ); return 0; } const result = runCheck({ repoRoot, ...parsed }); if (parsed.json) stdout.write(`${JSON.stringify(result, null, 2)}\n`); else if (!result.ok) { stderr.write( `check-upgrade-coverage: ${result.violations.length} violation(s) (${result.prevSegment} → ${result.headSegment})\n`, ); for (const v of result.violations) stderr.write( ` [${v.rule}] ${v.path}: ${v.reason}${v.script ? ` (${v.script})` : ''}\n${(v.sampleDiffPaths ?? []).map((p) => ` ${p}\n`).join('')}`, ); stderr.write( 'See skills-contrib/record-upgrade-instructions/SKILL.md for authoring and skills-contrib/publish-npm-version/SKILL.md for assembly.\n', ); } return result.ok ? 0 : 1; } catch (err) { stderr.write(`check-upgrade-coverage: ${err.message}\n`); return 2; } } if (argv[1] && fileURLToPath(import.meta.url) === argv[1]) exit(main());