Files
ECC/scripts/ci/validate-carrier-closure.js

229 lines
10 KiB
JavaScript

#!/usr/bin/env node
/**
* Carrier runtime-closure validator.
*
* Carriers are read-only file projections. Nothing here executes a planned
* script: literal require/import/export dependencies are parsed 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 ts = require('typescript');
function isAbsoluteSpecifier(value) {
return value.startsWith('/') || value.startsWith('\\\\') || /^[A-Za-z]:[\\/]/.test(value) || /^file:/i.test(value);
}
/** Parse without evaluating source, and reject incomplete or invalid syntax. */
function scanSource(rawSource) {
const source = ts.createSourceFile('carrier.js', String(rawSource || ''),
ts.ScriptTarget.Latest, true, ts.ScriptKind.JS);
if (source.parseDiagnostics.length) {
const diagnostic = source.parseDiagnostics[0];
const error = new SyntaxError(ts.flattenDiagnosticMessageText(diagnostic.messageText, ' '));
error.position = diagnostic.start;
throw error;
}
return source;
}
/** Extract literal dependencies and report dynamic expressions without execution. */
function extractReferences(rawSource) {
const source = scanSource(rawSource);
const specifiers = new Set(), imports = new Set(), packages = new Set(), dynamic = new Set();
const record = (value, viaImport) => {
if (value.startsWith('.') || isAbsoluteSpecifier(value)) {
specifiers.add(value);
if (viaImport) imports.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]);
}
};
const identifier = (node, name) => ts.isIdentifier(node) && node.text === name;
const member = (node, receiver, name) => ts.isPropertyAccessExpression(node)
&& identifier(node.expression, receiver) && node.name.text === name;
const literal = node => ts.isStringLiteralLike(node) ? node.text : null;
const staticJoin = node => {
if (!ts.isCallExpression(node) || !member(node.expression, 'path', 'join')
|| !node.arguments.length || !identifier(node.arguments[0], '__dirname')) return null;
const parts = node.arguments.slice(1).map(literal);
return parts.every(value => value !== null) ? `./${parts.join('/')}` : null;
};
const visit = node => {
if ((ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) && node.moduleSpecifier) {
record(node.moduleSpecifier.text, true);
} else if (ts.isCallExpression(node)) {
const viaImport = node.expression.kind === ts.SyntaxKind.ImportKeyword;
if (viaImport || identifier(node.expression, 'require') || member(node.expression, 'module', 'require')) {
const argument = node.arguments[0];
if (argument) {
const value = literal(argument);
const joined = viaImport ? null : staticJoin(argument);
if (value !== null) record(value, viaImport);
else if (joined !== null) record(joined, false);
else dynamic.add(argument.getText(source));
}
}
}
ts.forEachChild(node, visit);
};
visit(source);
return { specifiers: [...specifiers], imports: [...imports], packages: [...packages], dynamic: [...dynamic] };
}
function candidateDestinations(destinationPath, specifier, exact) {
if (exact && !isAbsoluteSpecifier(specifier)) {
// ESM resolves file URLs: query/fragment change identity, not the file path.
// Node rejects encoded separators; never normalize them into a safe path.
if (/%2f|%5c/i.test(specifier)) return { base: specifier, candidates: null, reason: 'invalid-url-separator' };
try { specifier = decodeURIComponent(specifier.split(/[?#]/)[0]); }
catch (_error) { return { base: specifier, candidates: null, reason: 'invalid-url-encoding' }; }
}
if (isAbsoluteSpecifier(specifier)) return { base: specifier, candidates: null, reason: 'outside-carrier-tree' };
if (specifier.includes('\\')) return { base: specifier, candidates: null, reason: 'non-portable-separator' };
const base = path.posix.join(path.posix.dirname(destinationPath), specifier);
if (base === '..' || base.startsWith('../')) return { base, candidates: null, reason: 'outside-carrier-tree' };
const candidates = exact
? [base]
: [base, `${base}.js`, `${base}.json`, `${base}/index.js`, `${base}/index.json`];
return { base, candidates, reason: null };
}
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) {
let references;
try { references = extractReferences(read(file)); }
catch (error) {
report.failures.push({ target: carrier.target, layout: carrier.layout.id, profileId,
file: file.destinationPath, source: file.sourcePath || null, reason: 'parse-error', diagnostic: error.message });
continue;
}
const { specifiers, imports, packages, dynamic } = references;
const exactOnly = new Set(imports);
const isModule = file.destinationPath.endsWith('.mjs');
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 exact = isModule || exactOnly.has(specifier);
const { base, candidates, reason } = candidateDestinations(file.destinationPath, specifier, exact);
if (!candidates) {
report.failures.push({ ...location, specifier, resolved: base, reason });
} 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',
limitations: ['Dynamic dependency expressions and arbitrary aliases are warnings or outside this literal-dependency gate.',
'Parsing does not execute scripts or certify provider-native runtime behavior.'],
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 };