diff --git a/package.json b/package.json index 18e4d0f0d..5ab3242fb 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "audit:complexity": "node scripts/audit-complexity.mjs", "audit:map": "node scripts/build-complexity-map.mjs", "audit:i18n-coverage": "node scripts/audit-i18n-coverage.mjs", + "audit:port-drift": "node scripts/audit-port-drift.mjs", "generate:dockerfiles": "node scripts/generate-dockerfiles.mjs", "setup:env": "node scripts/generate-env.mjs", "setup:secrets": "node scripts/setup-secrets.mjs", diff --git a/scripts/audit-port-drift.mjs b/scripts/audit-port-drift.mjs new file mode 100644 index 000000000..562cb7eb1 --- /dev/null +++ b/scripts/audit-port-drift.mjs @@ -0,0 +1,155 @@ +#!/usr/bin/env node +/** + * Audit port declarations: each services/*\/CLAUDE.md declares `## Port: + * NNNN`. Verify that the same port appears as a literal in the service's + * source code, and that no two services claim the same port. + * + * docs/PORT_SCHEMA.md is explicitly "partially aspirational" as of + * 2026-04-08, so the authoritative source is each service's own + * CLAUDE.md. This audit catches drift where a service moves its port in + * code but forgets to update its CLAUDE.md, or where two services + * accidentally claim the same number. + * + * Checks: + * 1. Parse `## Port: NNNN` from every `services/*\/CLAUDE.md`. + * 2. Grep the service directory (ignoring node_modules / dist / build / + * .turbo / __pycache__) for the literal port number. + * - Pass : declared port found in code. + * - Flag : not found — either a doc typo or outdated doc. + * 3. Cross-check: build a port → service map and flag collisions. + * + * Usage: + * node scripts/audit-port-drift.mjs + */ + +import { readdirSync, readFileSync, existsSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = join(__dirname, '..'); +const SERVICES_DIR = join(REPO_ROOT, 'services'); + +const SKIP_DIRS = new Set([ + 'node_modules', + 'dist', + 'build', + '.turbo', + '.svelte-kit', + '__pycache__', + '.venv', + 'venv', + '.next', + 'target', // rust +]); + +function parseCLAUDEPort(mdPath) { + const src = readFileSync(mdPath, 'utf8'); + const m = src.match(/^##\s+Port:\s+(\d{4,5})\s*$/m); + return m ? Number(m[1]) : null; +} + +/** True if any tracked source file under `dir` contains `port` as a + * digit-boundary match (avoids matching 3050 inside 30500, etc.). */ +function portAppearsIn(dir, port) { + const portStr = String(port); + const re = new RegExp(`\\b${portStr}\\b`); + function walk(d) { + for (const ent of readdirSync(d, { withFileTypes: true })) { + if (ent.name.startsWith('.') && ent.name !== '.env.example') continue; + if (SKIP_DIRS.has(ent.name)) continue; + const p = join(d, ent.name); + if (ent.isDirectory()) { + if (walk(p)) return true; + } else if (ent.isFile()) { + // Only scan source-ish files; skip binaries + lockfiles. + if (/\.(ts|tsx|js|mjs|cjs|go|py|env|env\.example|yaml|yml|toml|json|md)$/.test(ent.name)) { + try { + const src = readFileSync(p, 'utf8'); + if (re.test(src)) return true; + } catch { + // Unreadable file — skip. + } + } + } + } + return false; + } + return walk(dir); +} + +function audit() { + const entries = readdirSync(SERVICES_DIR, { withFileTypes: true }).filter((e) => e.isDirectory()); + + const declared = []; // { service, port, mdPath } + const missingCLAUDE = []; // service names + + for (const ent of entries) { + const mdPath = join(SERVICES_DIR, ent.name, 'CLAUDE.md'); + if (!existsSync(mdPath)) { + missingCLAUDE.push(ent.name); + continue; + } + const port = parseCLAUDEPort(mdPath); + if (port === null) continue; // service without a single-port concept (e.g. library service) + declared.push({ service: ent.name, port, mdPath }); + } + + const drifts = []; + for (const d of declared) { + const serviceDir = join(SERVICES_DIR, d.service); + const found = portAppearsIn(serviceDir, d.port); + if (!found) drifts.push(d); + } + + const byPort = new Map(); + for (const d of declared) { + if (!byPort.has(d.port)) byPort.set(d.port, []); + byPort.get(d.port).push(d.service); + } + const collisions = [...byPort.entries()].filter(([, list]) => list.length > 1); + + console.log(`\n── Port drift audit ───────────────────────────────────\n`); + console.log(`Services with CLAUDE.md Port declaration: ${declared.length}`); + console.log(`Services without CLAUDE.md: ${missingCLAUDE.length}`); + console.log(''); + + if (drifts.length === 0 && collisions.length === 0) { + console.log(`✓ No drift: every declared port appears in its own service's source.`); + console.log(`✓ No collisions: all ${declared.length} port assignments unique.`); + } + + if (drifts.length > 0) { + console.log(`✗ Drift: declared port not found in service source (${drifts.length}):\n`); + for (const d of drifts) { + console.log( + ` ${d.service} declared :${d.port} — not found in ${d.mdPath.slice(REPO_ROOT.length + 1)}` + ); + } + console.log(''); + } + + if (collisions.length > 0) { + console.log(`✗ Collisions: multiple services claim the same port (${collisions.length}):\n`); + for (const [port, list] of collisions) { + console.log(` :${port} ${list.join(', ')}`); + } + console.log(''); + } + + console.log(`Port map:\n`); + for (const d of declared.slice().sort((a, b) => a.port - b.port)) { + console.log(` :${d.port} ${d.service}`); + } + console.log(''); + + if (missingCLAUDE.length > 0) { + console.log(`Services without CLAUDE.md (no port declared):`); + for (const name of missingCLAUDE) console.log(` ${name}`); + console.log(''); + } + + // Report-only: never exit non-zero. This is diagnostic, not a gate. +} + +audit();