diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e02abb8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +CLAUDE.md +node_modules/ diff --git a/.tangled/workflows/dep-diff-microvm.yml b/.tangled/workflows/dep-diff-microvm.yml new file mode 100644 index 0000000..8820f5b --- /dev/null +++ b/.tangled/workflows/dep-diff-microvm.yml @@ -0,0 +1,23 @@ +# e18e dependency diff on the microvm engine — same logic as dep-diff-nixery.yml +when: + - event: ["push", "pull_request", "manual"] + branch: ["**"] + +engine: microvm +image: nixos + +clone: + depth: 50 + +dependencies: + - nodejs_22 + - git + +steps: + - name: install diff tooling + command: npm ci --prefix tools --no-audit --no-fund + + - name: dependency diff vs base + command: | + git fetch origin "${TANGLED_PR_TARGET_BRANCH:-main}" + node tools/dep-diff.mjs FETCH_HEAD diff --git a/.tangled/workflows/dep-diff-nixery.yml b/.tangled/workflows/dep-diff-nixery.yml new file mode 100644 index 0000000..6c84d9d --- /dev/null +++ b/.tangled/workflows/dep-diff-nixery.yml @@ -0,0 +1,23 @@ +# e18e dependency diff on the nixery engine +when: + - event: ["push", "pull_request", "manual"] + branch: ["**"] + +engine: nixery + +clone: + depth: 50 + +dependencies: + nixpkgs: + - nodejs_22 + - git + +steps: + - name: install diff tooling + command: npm ci --prefix tools --no-audit --no-fund + + - name: dependency diff vs base + command: | + git fetch origin "${TANGLED_PR_TARGET_BRANCH:-main}" + node tools/dep-diff.mjs FETCH_HEAD diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..40c430d --- /dev/null +++ b/package-lock.json @@ -0,0 +1,25 @@ +{ + "name": "spindle-dep-diff-fixture", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "spindle-dep-diff-fixture", + "dependencies": { + "semver": "^7.7.2" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..21a07a9 --- /dev/null +++ b/package.json @@ -0,0 +1,8 @@ +{ + "name": "spindle-dep-diff-fixture", + "private": true, + "description": "fixture for testing e18e dependency-diff logic on spindle CI", + "dependencies": { + "semver": "^7.7.2" + } +} diff --git a/tools/dep-diff.mjs b/tools/dep-diff.mjs new file mode 100644 index 0000000..48dca1e --- /dev/null +++ b/tools/dep-diff.mjs @@ -0,0 +1,281 @@ +#!/usr/bin/env node +// port of e18e/action-dependency-diff (by 43081j) for tangled spindle CI. +// same libs (lockparse, module-replacements, packumeta), same checks, but +// reads refs via `git show` and prints markdown to stdout instead of +// posting a github PR comment. +// +// usage: node tools/dep-diff.mjs [base-ref] [head-ref] +// base-ref defaults to origin/$TANGLED_PR_TARGET_BRANCH, else origin/main +// head-ref defaults to HEAD + +import {execFileSync} from 'node:child_process'; +import {parse as parseLockfile} from 'lockparse'; +import {all as allReplacements, resolveDocUrl} from 'module-replacements'; +import {getTrustLevel, getTrustLevelName, getTrustStatus} from 'packumeta'; + +const LOCKFILE = 'package-lock.json'; +// ponytail: action defaults hardcoded; make them flags if we ever tune them +const DEPENDENCY_THRESHOLD = 10; +const SIZE_THRESHOLD = 100_000; +const DUPLICATE_THRESHOLD = 1; + +const baseRef = + process.argv[2] ?? + `origin/${process.env.TANGLED_PR_TARGET_BRANCH || 'main'}`; +const headRef = process.argv[3] ?? 'HEAD'; + +function getFileFromRef(ref, path) { + try { + return execFileSync('git', ['show', `${ref}:${path}`], { + encoding: 'utf8', + stdio: 'pipe', + maxBuffer: 1024 * 1024 * 100 + }); + } catch { + return null; + } +} + +function computeDependencyVersions(lockFile) { + const result = new Map(); + for (const pkg of lockFile.packages) { + if (!pkg.name || !pkg.version) continue; + let set = result.get(pkg.name); + if (!set) result.set(pkg.name, (set = new Set())); + set.add(pkg.version); + } + return result; +} + +function directDeps(pkgJson) { + return new Map(Object.entries(pkgJson.dependencies ?? {})); +} + +const metaCache = new Map(); +async function fetchPackageMetadata(name, version) { + const key = `${name}@${version}`; + if (metaCache.has(key)) return metaCache.get(key); + let meta = null; + try { + const res = await fetch(`https://registry.npmjs.org/${name}/${version}`); + if (res.ok) meta = await res.json(); + } catch {} + metaCache.set(key, meta); + return meta; +} + +function formatBytes(bytes) { + if (bytes === 0) return '0 B'; + const abs = Math.abs(bytes); + const k = 1000; + const sizes = ['B', 'kB', 'MB', 'GB']; + const i = Math.floor(Math.log(abs) / Math.log(k)); + const v = parseFloat((abs / Math.pow(k, i)).toFixed(1)); + return `${bytes < 0 ? -v : v} ${sizes[i]}`; +} + +// --- checks, ported 1:1 from src/checks/* --- + +function scanForReplacements(messages, baseDeps, currentDeps) { + const rows = []; + for (const [name] of currentDeps) { + if (baseDeps.has(name)) continue; + const mapping = Object.values(allReplacements.mappings).find( + (m) => m.moduleName === name && m.type === 'module' + ); + if (!mapping) continue; + const replacement = allReplacements.replacements[mapping.replacements[0]]; + if (!replacement) continue; + switch (replacement.type) { + case 'removal': + case 'simple': + rows.push(`| ${name} | ${replacement.description} |`); + break; + case 'native': { + const url = resolveDocUrl(mapping.url ?? replacement.url); + rows.push( + `| ${name} | Use ${url ? `[${replacement.id}](${url})` : replacement.id} |` + ); + break; + } + case 'documented': { + const url = resolveDocUrl(mapping.url ?? replacement.url); + rows.push( + `| ${name} | Use ${url ? `[${replacement.replacementModule}](${url})` : replacement.replacementModule} |` + ); + break; + } + } + } + if (rows.length > 0) { + messages.push( + `## ⚠️ Recommended Package Replacements + +The following new packages or versions have community recommended replacements: + +| 📦 Package | 💡 Recommendation | +| --- | --- | +${rows.join('\n')} + +> These recommendations have been defined by the [e18e](https://e18e.dev) community.` + ); + } +} + +function scanForDependencyCount(messages, currentDeps, baseDeps) { + const count = (m) => + Array.from(m.values()).reduce((sum, v) => sum + v.size, 0); + const increase = count(currentDeps) - count(baseDeps); + console.error( + `dependency count: ${count(baseDeps)} -> ${count(currentDeps)}` + ); + if (increase >= DEPENDENCY_THRESHOLD) { + messages.push( + `## ⚠️ Dependency Count + +This change adds ${increase} new dependencies (${count(baseDeps)} → ${count(currentDeps)}), which exceeds the threshold of ${DEPENDENCY_THRESHOLD}.` + ); + } +} + +// ponytail: upstream renders collapsible dependency paths per duplicate; +// a flat version list is enough to know the check fired +function scanForDuplicates(messages, dependencyMap) { + const rows = []; + for (const [name, versions] of dependencyMap) { + if (versions.size > DUPLICATE_THRESHOLD) { + rows.push(`| ${name} | ${Array.from(versions).sort().join(', ')} |`); + } + } + if (rows.length > 0) { + messages.push( + `## ⚠️ Duplicate Dependencies (found: ${rows.length}, threshold: ${DUPLICATE_THRESHOLD}) + +| 📦 Package | 📋 Versions | +| --- | --- | +${rows.join('\n')}` + ); + } +} + +// ponytail: skips upstream's unsupported-optional-dependency filtering +// (platform-specific binaries like esbuild); add if the fixture grows any +async function scanForDependencySize(messages, currentDeps, baseDeps) { + const changed = []; + for (const [name, versions] of currentDeps) { + const base = baseDeps.get(name); + for (const v of versions) + if (!base?.has(v)) changed.push({name, version: v, sign: 1}); + } + for (const [name, versions] of baseDeps) { + const curr = currentDeps.get(name); + for (const v of versions) + if (!curr?.has(v)) changed.push({name, version: v, sign: -1}); + } + if (changed.length === 0) return; + + let totalSize = 0; + const rows = []; + for (const {name, version, sign} of changed) { + const meta = await fetchPackageMetadata(name, version); + const size = meta?.dist?.unpackedSize; + if (size === undefined) { + rows.push({label: `${name}@${version}`, size: null}); + } else { + totalSize += sign * size; + rows.push({label: `${name}@${version}`, size: sign * size}); + } + } + rows.sort((a, b) => Math.abs(b.size ?? 0) - Math.abs(a.size ?? 0)); + console.error(`total dependency size change: ${formatBytes(totalSize)}`); + + if (totalSize >= SIZE_THRESHOLD) { + messages.push( + `## 📊 Dependency Size Changes + +> ⚠️ This change adds ${formatBytes(totalSize)} of new dependencies, which exceeds the threshold of ${formatBytes(SIZE_THRESHOLD)}. + +| 📦 Package | 📏 Size | +| --- | --- | +${rows.map((r) => `| ${r.label} | ${r.size === null ? '_Unknown_' : formatBytes(r.size)} |`).join('\n')} + +**Total size change:** ${formatBytes(totalSize)}` + ); + } +} + +async function scanForProvenance(messages, currentDeps, baseDeps) { + const rows = []; + for (const [name, currentVersions] of currentDeps) { + const baseVersions = baseDeps.get(name); + if (!baseVersions || baseVersions.size === 0) continue; + if (currentVersions.isSubsetOf(baseVersions)) continue; + + const trustFor = async (versions) => { + const statuses = []; + for (const v of versions) { + const meta = await fetchPackageMetadata(name, v); + if (meta) statuses.push(getTrustStatus(meta)); + } + return statuses; + }; + const minTrust = (statuses) => { + let min = null; + for (const s of statuses) { + const level = getTrustLevel(s); + if (min === null || level < min.level) + min = {level, status: getTrustLevelName(s)}; + } + return min; + }; + + const base = minTrust(await trustFor(baseVersions)); + const curr = minTrust(await trustFor(currentVersions)); + if (base && curr && curr.level < base.level) { + rows.push(`| ${name} | ${base.status} | ${curr.status} |`); + } + } + if (rows.length > 0) { + messages.push( + `## ⚠️ Package Trust Level Decreased + +> ⚠️ Decreased trust levels may indicate a higher risk of supply chain attacks. + +| 📦 Package | 🔒 Before | 🔓 After | +| --- | --- | --- | +${rows.join('\n')}` + ); + } +} + +// --- main --- + +console.error(`comparing ${LOCKFILE} between ${baseRef} and ${headRef}`); + +const baseLockRaw = getFileFromRef(baseRef, LOCKFILE); +const headLockRaw = getFileFromRef(headRef, LOCKFILE); +if (!baseLockRaw || !headLockRaw) { + console.error(`missing ${LOCKFILE} in ${!baseLockRaw ? baseRef : headRef}`); + process.exit(1); +} +const basePkg = JSON.parse(getFileFromRef(baseRef, 'package.json') ?? '{}'); +const headPkg = JSON.parse(getFileFromRef(headRef, 'package.json') ?? '{}'); + +const baseLock = await parseLockfile(baseLockRaw, LOCKFILE, basePkg); +const headLock = await parseLockfile(headLockRaw, LOCKFILE, headPkg); +const baseDeps = computeDependencyVersions(baseLock); +const headDeps = computeDependencyVersions(headLock); + +const messages = []; +scanForReplacements(messages, directDeps(basePkg), directDeps(headPkg)); +scanForDependencyCount(messages, headDeps, baseDeps); +scanForDuplicates(messages, headDeps); +await scanForDependencySize(messages, headDeps, baseDeps); +await scanForProvenance(messages, headDeps, baseDeps); + +if (messages.length === 0) { + console.log(`# Dependency Diff\n\nNo findings for ${baseRef}...${headRef}.`); +} else { + console.log(`# Dependency Diff (${baseRef}...${headRef})\n`); + console.log(messages.join('\n\n')); +} diff --git a/tools/package-lock.json b/tools/package-lock.json new file mode 100644 index 0000000..3c5d43a --- /dev/null +++ b/tools/package-lock.json @@ -0,0 +1,36 @@ +{ + "name": "dep-diff-tools", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "dep-diff-tools", + "dependencies": { + "lockparse": "^0.5.2", + "module-replacements": "^3.0.0", + "packumeta": "^0.4.1" + } + }, + "node_modules/lockparse": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/lockparse/-/lockparse-0.5.2.tgz", + "integrity": "sha512-iu19W86kvrT2MLpvyXoIn/351Rb3PO7v7ljgFRQYj8tGpBRvNO9cZEI8x/7GGN+WN6BbrWpXc5CPrgPUjgX6Iw==", + "license": "MIT" + }, + "node_modules/module-replacements": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/module-replacements/-/module-replacements-3.0.0.tgz", + "integrity": "sha512-tHIZqde+RlyNRobAIjfcH5UIgIrEbZbDGRL6J/x+HERX/g8O9mrm0p6knJbsTXmQtDvZ+eFP+xfOP3/9jHk6YA==", + "license": "MIT" + }, + "node_modules/packumeta": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/packumeta/-/packumeta-0.4.1.tgz", + "integrity": "sha512-n+4VKW4+05duTeQP8BRUDke6rTHYqxqsrVyLEt0Y4Kmisp+ZL3JKREjaxv+ajSd9GpjZkJLY+zG1A0f1Jib21A==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + } + } +} diff --git a/tools/package.json b/tools/package.json new file mode 100644 index 0000000..f0b2d51 --- /dev/null +++ b/tools/package.json @@ -0,0 +1,10 @@ +{ + "name": "dep-diff-tools", + "private": true, + "type": "module", + "dependencies": { + "lockparse": "^0.5.2", + "module-replacements": "^3.0.0", + "packumeta": "^0.4.1" + } +}