#!/usr/bin/env node /** * Compare npm lockfile versions for P2P stack packages against optional local * Holepunch clones (same root as sync-holepunch-clones.mjs). * * Writes: * docs/audit/holepunch-lockfile-drift.json * docs/audit/holepunch-lockfile-drift-summary.ndjson (one row per package) * * Env: * BARE_OS_HOLEPUNCH_CLONES_ROOT — default ~/dev/pearcli/holepunch-repos/holepunchto_repos * BARE_OS_HOLEPUNCH_LOCKFILE_DRIFT_TIER1_FAIL=1 — fail when a tier1 lockfile package * has semverMismatchWithClone=true (uses docs/audit/holepunch-drift-repos.json tier1Repos) * * Config: docs/audit/holepunch-drift-repos.json * lockfilePackages: [{ "cloneName": "protomux", "lockKey": "protomux" }, ...] * If omitted, defaults are derived from suggestedCriticalRepos + common wire deps. */ import fs from 'node:fs' import path from 'node:path' import process from 'node:process' import { fileURLToPath } from 'node:url' const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') const lockPath = path.join(root, 'package-lock.json') const driftConfigPath = path.join(root, 'docs/audit/holepunch-drift-repos.json') const outJson = path.join(root, 'docs/audit/holepunch-lockfile-drift.json') const outNd = path.join(root, 'docs/audit/holepunch-lockfile-drift-summary.ndjson') const defaultClones = path.join( process.env.HOME || '', 'dev/pearcli/holepunch-repos/holepunchto_repos' ) const clonesRoot = String( process.env.BARE_OS_HOLEPUNCH_CLONES_ROOT || defaultClones ).trim() /** @type {{ cloneName: string, lockKey: string }[]} */ const DEFAULT_LOCK_PACKAGES = [ { cloneName: 'protomux', lockKey: 'protomux' }, { cloneName: 'hyperdrive', lockKey: 'hyperdrive' }, { cloneName: 'hypercore', lockKey: 'hypercore' }, { cloneName: 'hyperswarm', lockKey: 'hyperswarm' }, { cloneName: 'udx-native', lockKey: 'udx-native' }, { cloneName: 'corestore', lockKey: 'corestore' }, { cloneName: 'compact-encoding', lockKey: 'compact-encoding' }, { cloneName: 'secret-stream', lockKey: '@hyperswarm/secret-stream' } ] /** * @param {Record} lock * @param {string} key npm package name (e.g. protomux or @hyperswarm/secret-stream) */ function lockedVersion(lock, key) { const k = 'node_modules/' + key const p = lock.packages && lock.packages[k] if (!p || typeof p !== 'object') return '' return String(/** @type {{ version?: string }} */ (p).version || '').trim() } function loadLockfilePackages() { if (!fs.existsSync(driftConfigPath)) return DEFAULT_LOCK_PACKAGES try { const j = JSON.parse(fs.readFileSync(driftConfigPath, 'utf8')) if (Array.isArray(j.lockfilePackages) && j.lockfilePackages.length) { return j.lockfilePackages .map((row) => { if (!row || typeof row !== 'object') return null const r = /** @type {{ cloneName?: string, lockKey?: string }} */ (row) const cn = String(r.cloneName || '').trim() const lk = String(r.lockKey || r.cloneName || '').trim() if (!cn || !lk) return null return { cloneName: cn, lockKey: lk } }) .filter(Boolean) } if (Array.isArray(j.suggestedCriticalRepos)) { return j.suggestedCriticalRepos .map((n) => { const name = String(n || '').trim() if (!name) return null return { cloneName: name, lockKey: name } }) .filter(Boolean) } } catch { /* fall through */ } return DEFAULT_LOCK_PACKAGES } /** * @returns {Set} */ function loadTier1RepoSet() { if (!fs.existsSync(driftConfigPath)) return new Set() try { const j = JSON.parse(fs.readFileSync(driftConfigPath, 'utf8')) if (!Array.isArray(j.tier1Repos)) return new Set() return new Set( j.tier1Repos .map((v) => String(v || '').trim()) .filter(Boolean) ) } catch { return new Set() } } function main() { if (!fs.existsSync(lockPath)) { console.error('report-holepunch-lockfile-drift: missing', lockPath) process.exit(1) } const lock = JSON.parse(fs.readFileSync(lockPath, 'utf8')) const pkgs = loadLockfilePackages() const tier1Repos = loadTier1RepoSet() const strictTier1Fail = String(process.env.BARE_OS_HOLEPUNCH_LOCKFILE_DRIFT_TIER1_FAIL || '') .trim() .toLowerCase() === '1' || String(process.env.BARE_OS_HOLEPUNCH_LOCKFILE_DRIFT_TIER1_FAIL || '') .trim() .toLowerCase() === 'true' const atMs = Date.now() const rootExists = fs.existsSync(clonesRoot) /** @type {Record[]} */ const rows = [] for (const { cloneName, lockKey } of pkgs) { const lv = lockedVersion(lock, lockKey) const cloneDir = path.join(clonesRoot, cloneName) const pj = path.join(cloneDir, 'package.json') let cloneVersion = '' if (fs.existsSync(pj)) { try { const p = JSON.parse(fs.readFileSync(pj, 'utf8')) cloneVersion = String(p.version || '').trim() } catch { cloneVersion = '' } } const semverMismatch = lv && cloneVersion && lv !== cloneVersion ? true : false const tier1 = tier1Repos.has(cloneName) rows.push({ cloneName, lockKey, lockfileVersion: lv || null, clonePath: cloneDir, cloneExists: fs.existsSync(cloneDir), clonePackageJsonVersion: cloneVersion || null, semverMismatchWithClone: semverMismatch, tier1 }) } const tier1MismatchRows = rows.filter( (r) => r.tier1 && r.semverMismatchWithClone ) const report = { schema: 1, atMs, lockfilePath: path.relative(root, lockPath), clonesRoot, clonesRootExists: rootExists, driftConfigPath: path.relative(root, driftConfigPath), note: 'lockfileVersion is the hoisted workspace entry in package-lock.json (lockfile v3). clonePackageJsonVersion is the local clone’s package.json when present; mismatch is informational until you bump npm deps or refresh clones.', strictTier1Fail, tier1MismatchCount: tier1MismatchRows.length, rows } fs.mkdirSync(path.dirname(outJson), { recursive: true }) fs.writeFileSync(outJson, JSON.stringify(report, null, 2) + '\n') const ndLines = rows.map((r) => JSON.stringify({ type: 'bare_os_holepunch_lockfile_drift', schema: 1, atMs, clonesRoot, ...r }) ) fs.writeFileSync(outNd, ndLines.join('\n') + (ndLines.length ? '\n' : '')) const dashPath = path.join(root, 'docs/audit/holepunch-lockfile-drift-dashboard.md') const dashLines = [ '# Holepunch lockfile drift dashboard', '', `_Generated at \`${new Date(atMs).toISOString()}\`. Regenerate with \`node scripts/report-holepunch-lockfile-drift.mjs\` (see [scripts/README.md](../../scripts/README.md))._`, '', '| Clone (package) | Tier1 | Lockfile version | Clone `package.json` version | Semver mismatch vs clone |', '| --- | --- | --- | --- | --- |' ] for (const r of rows) { dashLines.push( '| ' + [ r.cloneName, r.tier1 ? 'yes' : 'no', r.lockfileVersion ?? '—', r.clonePackageJsonVersion ?? '—', r.semverMismatchWithClone ? 'yes' : 'no' ].join(' | ') + ' |' ) } dashLines.push( '', '**Note:** `lockfileVersion` is the hoisted workspace entry in `package-lock.json` (lockfile v3). `clonePackageJsonVersion` is the local mirror’s `package.json` when present; mismatches are informational unless `BARE_OS_HOLEPUNCH_LOCKFILE_DRIFT_TIER1_FAIL=1`. Workspace packages (`bare-os-booter`, `bare-os-protocol`, `bare-os-seeder`) may nest a newer major (for example **`compact-encoding@3.3.0`**) while the root hoist stays on **2.x** for dependents that have not moved.', '', `**Tier1 strict mode:** ${strictTier1Fail ? 'enabled' : 'disabled'} · tier1 mismatches: ${tier1MismatchRows.length}.`, '', '**Upgrade workflow hint:** expected churn often appears first in `udx-native` and `blind-peering`; when they drift, refresh local clones, review lockfile bumps, then regenerate this dashboard and `holepunch-runtime-compat.json` together before release gating.' ) fs.writeFileSync(dashPath, dashLines.join('\n') + '\n') console.log( 'report-holepunch-lockfile-drift: wrote', path.relative(root, outJson), 'packages=', rows.length ) if (strictTier1Fail && tier1MismatchRows.length) { console.error( `report-holepunch-lockfile-drift: tier1 mismatch strict mode failed (${tier1MismatchRows.length} mismatches)` ) process.exit(1) } } main()