mirror of
https://github.com/affaan-m/ECC.git
synced 2026-10-15 04:35:29 +02:00
318 lines
13 KiB
JavaScript
318 lines
13 KiB
JavaScript
#!/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 };
|