#!/usr/bin/env node /** * Carrier runtime-closure validator. * * Carriers are read-only file projections. Nothing here executes a planned * script: every relative module specifier is extracted statically and checked * against the planned file set of the carrier it would ship in. * * The static require-graph extraction technique is adapted from community * PR #2788 (affaan-m/ECC). The staged load smoke from that work -- actually * loading entry scripts from inside a materialized carrier -- is deliberately * not ported here, and neither is command pruning. */ 'use strict'; const fs = require('node:fs'); const path = require('node:path'); const { builtinModules } = require('node:module'); const { planContextCarrier } = require('../lib/context-carriers'); const { loadContextRegistry } = require('../lib/context-pack-registry'); const { DEFAULT_REPO_ROOT } = require('../lib/context-profile-support'); const PROFILES = Object.freeze(['lean@1', 'full@1']); const BUILTINS = new Set(builtinModules); const SCRIPT_EXTENSIONS = new Set(['.js', '.cjs', '.mjs']); const MARK = '\u0000'; const SLOT = `${MARK}(\\d+)${MARK}`; const CALL_PATTERN = new RegExp(`\\b(?:require|import)\\s*\\(\\s*${SLOT}\\s*\\)`, 'g'); const FROM_PATTERN = new RegExp(`\\bfrom\\s*${SLOT}`, 'g'); const SIDE_EFFECT_PATTERN = new RegExp(`\\bimport\\s*${SLOT}`, 'g'); const DIRNAME_JOIN_PATTERN = new RegExp( `\\brequire\\s*\\(\\s*path\\.join\\s*\\(\\s*__dirname\\s*((?:,\\s*${SLOT}\\s*)+)\\)\\s*\\)`, 'g'); const DYNAMIC_PATTERN = /\b(?:require|import)\s*\(\s*((?:[^()]|\([^()]*\))+)\)/g; const STATIC_JOIN_PATTERN = new RegExp(`^path\\.join\\s*\\(\\s*__dirname\\s*(?:,\\s*${SLOT}\\s*)+\\)$`); const SLOT_PATTERN = new RegExp(SLOT, 'g'); // Closes an interpolation; no keyword pattern can match across it. const CLOSE = `${MARK}${MARK}`; const ESCAPE_PATTERN = /\\(?:u\{([0-9a-fA-F]+)\}|u([0-9a-fA-F]{4})|x([0-9a-fA-F]{2})|(\r\n|[\s\S]))/g; const SIMPLE_ESCAPES = Object.freeze({ n: '\n', r: '\r', t: '\t', b: '\b', f: '\f', v: '\v', 0: '\0' }); const LINE_CONTINUATIONS = new Set(['\n', '\r', '\r\n', '\u2028', '\u2029']); const REGEX_PRECEDES = /(?:[([{,;:=!&|?+\-*/%~^<>]|\b(?:return|typeof|case|in|of|new|delete|void|instanceof|do|else|yield|await))$/; /** Cook a raw string or template chunk the way the JavaScript parser would. */ function decodeEscapes(raw) { return raw.replace(ESCAPE_PATTERN, (whole, braced, unicode, hex, other) => { if (braced !== undefined) { const codePoint = parseInt(braced, 16); return codePoint <= 0x10ffff ? String.fromCodePoint(codePoint) : whole; } if (unicode !== undefined) return String.fromCharCode(parseInt(unicode, 16)); if (hex !== undefined) return String.fromCharCode(parseInt(hex, 16)); if (LINE_CONTINUATIONS.has(other)) return ''; return SIMPLE_ESCAPES[other] ?? other; }); } function isAbsoluteSpecifier(value) { return value.startsWith('/') || value.startsWith('\\\\') || /^[A-Za-z]:[\\/]/.test(value) || /^file:/i.test(value); } function readRegexLiteral(source, start) { let index = start + 1; let inClass = false; while (index < source.length) { const char = source[index]; if (char === '\\') { index += 2; continue; } if (char === '\n') return index; if (char === '[') inClass = true; else if (char === ']') inClass = false; else if (char === '/' && !inClass) return index + 1; index += 1; } return index; } /** * Reduce source to a code skeleton in which every comment is gone and every * string literal is replaced by an indexed slot. A `require` shape written * inside a comment, a string, or a regex therefore cannot be mistaken for a * dependency, while `${...}` interpolations stay code. */ function scanSource(rawSource) { const source = String(rawSource || ''); const literals = []; const stack = []; let code = ''; let index = 0; const slot = value => { code += `${MARK}${literals.push(decodeEscapes(value)) - 1}${MARK}`; }; const frame = () => (stack.length ? stack[stack.length - 1] : null); while (index < source.length) { const char = source[index]; const next = source[index + 1]; const open = frame(); if (open && open.type === 'template') { if (char === '\\') { open.text += source.substr(index, 2); index += 2; continue; } if (char === '`') { stack.pop(); slot(open.text); index += 1; continue; } if (char === '$' && next === '{') { slot(open.text); open.text = ''; stack.push({ type: 'interpolation', depth: 0 }); index += 2; continue; } open.text += char; index += 1; continue; } if (char === '/' && next === '/') { while (index < source.length && source[index] !== '\n') index += 1; continue; } if (char === '/' && next === '*') { index = source.indexOf('*/', index + 2); index = index === -1 ? source.length : index + 2; continue; } if (char === '/' && REGEX_PRECEDES.test(code.trimEnd())) { index = readRegexLiteral(source, index); code += ' '; continue; } if (char === '\'' || char === '"') { let cursor = index + 1; let value = ''; while (cursor < source.length && source[cursor] !== char && source[cursor] !== '\n') { if (source[cursor] === '\\') { value += source.substr(cursor, 2); cursor += 2; continue; } value += source[cursor]; cursor += 1; } slot(value); index = source[cursor] === char ? cursor + 1 : cursor; continue; } if (char === '`') { stack.push({ type: 'template', text: '' }); index += 1; continue; } if (open && open.type === 'interpolation') { if (char === '{') open.depth += 1; else if (char === '}') { if (open.depth === 0) { stack.pop(); index += 1; code += CLOSE; continue; } open.depth -= 1; } } code += char; index += 1; } if (frame() && frame().type === 'template') slot(frame().text); return { code, literals }; } function collect(pattern, code, handler) { pattern.lastIndex = 0; let match = pattern.exec(code); while (match !== null) { handler(match); match = pattern.exec(code); } } function renderExpression(text, literals) { return text.replace(SLOT_PATTERN, (_whole, slotIndex) => `'${literals[Number(slotIndex)]}'`).split(CLOSE).join(' ').trim(); } /** * Extract the relative module specifiers a script declares, plus the * non-literal `require`/`import` arguments that cannot be resolved without * running the code. */ function extractReferences(rawSource) { const { code, literals } = scanSource(rawSource); const specifiers = new Set(); const packages = new Set(); const dynamic = new Set(); const literalAt = match => literals[Number(match[1])]; for (const pattern of [CALL_PATTERN, FROM_PATTERN, SIDE_EFFECT_PATTERN]) { collect(pattern, code, match => { const value = literalAt(match); if (value.startsWith('.') || isAbsoluteSpecifier(value)) specifiers.add(value); else if (!value.startsWith('node:') && !BUILTINS.has(value.split('/')[0])) { packages.add(value.startsWith('@') ? value.split('/').slice(0, 2).join('/') : value.split('/')[0]); } }); } collect(DIRNAME_JOIN_PATTERN, code, match => { const segments = []; collect(SLOT_PATTERN, match[1], slotMatch => segments.push(literals[Number(slotMatch[1])])); specifiers.add(`./${segments.join('/')}`); }); collect(DYNAMIC_PATTERN, code, match => { const argument = match[1].trim(); if (new RegExp(`^${SLOT}$`).test(argument) || STATIC_JOIN_PATTERN.test(argument)) return; dynamic.add(renderExpression(argument, literals)); }); return { specifiers: [...specifiers], packages: [...packages], dynamic: [...dynamic] }; } function candidateDestinations(destinationPath, specifier) { if (isAbsoluteSpecifier(specifier)) return { base: specifier, candidates: null }; const base = path.posix.join(path.posix.dirname(destinationPath), specifier.replace(/\\/g, '/')); if (base === '..' || base.startsWith('../')) return { base, candidates: null }; const candidates = destinationPath.endsWith('.mjs') ? [base] : [base, `${base}.js`, `${base}.cjs`, `${base}.mjs`, `${base}.json`, `${base}/index.js`, `${base}/index.json`]; return { base, candidates }; } function sourceReader(repoRoot) { const cache = new Map(); return file => { if (file.kind === 'generated') return file.content; if (!cache.has(file.sourcePath)) { cache.set(file.sourcePath, fs.readFileSync(path.join(repoRoot, ...file.sourcePath.split('/')), 'utf8')); } return cache.get(file.sourcePath); }; } function inspectProjection(carrier, profileId, read, report) { const planned = new Set(carrier.files.map(file => file.destinationPath)); const scripts = carrier.files.filter(file => SCRIPT_EXTENSIONS.has(path.posix.extname(file.destinationPath))); for (const file of scripts) { const { specifiers, packages, dynamic } = extractReferences(read(file)); const location = { target: carrier.target, layout: carrier.layout.id, profileId, file: file.destinationPath, source: file.sourcePath || null, }; for (const specifier of specifiers) { report.specifierCount += 1; const { base, candidates } = candidateDestinations(file.destinationPath, specifier); if (!candidates) { report.failures.push({ ...location, specifier, resolved: base, reason: 'outside-carrier-tree' }); } else if (candidates.some(candidate => planned.has(candidate))) { report.resolvedCount += 1; } else { report.failures.push({ ...location, specifier, resolved: base, reason: 'unplanned-target' }); } } for (const expression of dynamic) { report.dynamicCount += 1; report.warnings.push({ ...location, kind: 'dynamic', expression }); } for (const name of packages) { report.packageCount += 1; report.warnings.push({ ...location, kind: 'package', package: name }); } } report.plannedFileCount += carrier.files.length; report.scriptCount += scripts.length; } function validate(repoRoot = DEFAULT_REPO_ROOT) { const registry = loadContextRegistry({ repoRoot }); const read = sourceReader(repoRoot); const report = { plannedFileCount: 0, scriptCount: 0, specifierCount: 0, resolvedCount: 0, dynamicCount: 0, packageCount: 0, failures: [], warnings: [], }; const layouts = []; const unsupportedTargets = []; for (const target of registry.targets) { let supported = null; for (const profileId of PROFILES) { const carrier = planContextCarrier({ repoRoot, profileId, target }); if (!carrier.layout) break; supported = { target, layout: carrier.layout.id, skillRoot: carrier.layout.skillRoot }; inspectProjection(carrier, profileId, read, report); } if (supported) layouts.push(supported); else unsupportedTargets.push(target); } return { schemaVersion: 'ecc.carrier-closure.v1', status: report.failures.length ? 'failure' : 'success', profiles: [...PROFILES], layouts, unsupportedTargets, projectionCount: layouts.length * PROFILES.length, registryDigest: registry.registryDigest, loadSmoke: 'deferred', lineage: 'static require-graph technique adapted from community PR #2788', ...report, }; } function uniqueLines(entries, render) { return [...new Set(entries.map(render))]; } function main(args = process.argv.slice(2)) { try { for (const arg of args) { if (arg !== '--json') throw new Error(`Unknown argument: ${arg}`); } const result = validate(); if (args.includes('--json')) { console.log(JSON.stringify(result, null, 2)); } else { for (const line of uniqueLines(result.warnings, w => (w.kind === 'package' ? `Package import, not shipped in carriers: ${w.file} -> ${w.package}` : `Dynamic require, not resolvable statically: ${w.file} -> ${w.expression}`))) { console.warn(line); } for (const line of uniqueLines(result.failures, f => `${f.layout} ${f.profileId}: ${f.file} -> ${f.specifier} (${f.reason})`)) { console.error(line); } console.log(`Carrier closure ${result.status}: ${result.layouts.length} layouts x ${result.profiles.length} profiles = ${result.projectionCount} projections, ${result.plannedFileCount} planned files, ${result.scriptCount} scripts, ${result.resolvedCount}/${result.specifierCount} relative specifiers resolved, ${result.failures.length} escapes, ${result.dynamicCount} dynamic requires, ${result.packageCount} package imports. Load smoke: deferred.`); } return result.status === 'success' ? 0 : 1; } catch (error) { console.error(`Carrier closure validation failed: ${error.message}`); return 1; } } if (require.main === module) process.exitCode = main(); module.exports = { extractReferences, main, scanSource, validate };