| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import { existsSync, readFileSync, statSync } from 'node:fs'; |
| import { isBuiltin } from 'node:module'; |
| import { dirname, extname, join, relative, resolve, sep } from 'node:path'; |
|
|
| |
| |
| const REGEX_PRECEDING_KEYWORDS = /(?:^|[^$\w.])(?:return|typeof|case|delete|void|in|of|new|instanceof|yield|await|do|else)\s*$/; |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export function stripComments(src) { |
| let out = ''; |
| let state = 'code'; |
| let inClass = false; |
| let lastSig = ''; |
| let i = 0; |
|
|
| const regexCanStart = () => |
| lastSig === '' || |
| '([{,;=:?!&|^~%*+-<>'.includes(lastSig) || |
| (lastSig === '/' ? false : /[$\w]/.test(lastSig) && REGEX_PRECEDING_KEYWORDS.test(out)); |
|
|
| while (i < src.length) { |
| const c = src[i]; |
| const n = src[i + 1]; |
| if (state === 'code') { |
| if (c === '/' && n === '/') { state = 'line'; i += 2; continue; } |
| if (c === '/' && n === '*') { state = 'block'; i += 2; continue; } |
| if (c === '/' && regexCanStart()) { state = 'regex'; inClass = false; out += c; i += 1; continue; } |
| if (c === "'") state = 'squote'; |
| else if (c === '"') state = 'dquote'; |
| else if (c === '`') state = 'template'; |
| if (!/\s/.test(c)) lastSig = c; |
| out += c; i += 1; continue; |
| } |
| if (state === 'line') { |
| if (c === '\n') { state = 'code'; out += c; } |
| i += 1; continue; |
| } |
| if (state === 'block') { |
| if (c === '*' && n === '/') { state = 'code'; i += 2; continue; } |
| if (c === '\n') out += c; |
| i += 1; continue; |
| } |
| if (state === 'regex') { |
| if (c === '\\') { out += c + (n ?? ''); i += 2; continue; } |
| if (c === '[') inClass = true; |
| else if (c === ']') inClass = false; |
| else if (c === '/' && !inClass) { state = 'code'; lastSig = '/'; out += c; i += 1; continue; } |
| else if (c === '\n') { state = 'code'; } |
| out += c; i += 1; continue; |
| } |
| |
| if (c === '\\') { out += c + (n ?? ''); i += 2; continue; } |
| if ((state === 'squote' && c === "'") || (state === 'dquote' && c === '"') || (state === 'template' && c === '`')) { |
| state = 'code'; |
| lastSig = c; |
| } |
| out += c; i += 1; |
| } |
| return out; |
| } |
|
|
| |
| |
| |
| |
| function isAllTypeNamedClause(clause) { |
| const inner = clause.trim(); |
| if (!inner.startsWith('{') || !inner.endsWith('}')) return false; |
| const bindings = inner.slice(1, -1).split(',').map((b) => b.trim()).filter(Boolean); |
| return bindings.length > 0 && bindings.every((b) => /^type\s/.test(b)); |
| } |
|
|
| |
| export function extractEdges(src) { |
| const staticSpecs = []; |
| const dynamicSpecs = []; |
| const requireSpecs = []; |
|
|
| |
| |
| for (const m of src.matchAll(/(?:^|;)[ \t]*import\s+(?!type\s)([^'";]*?)\bfrom\s*['"]([^'"]+)['"]/gms)) { |
| if (isAllTypeNamedClause(m[1])) continue; |
| staticSpecs.push(m[2]); |
| } |
| |
| for (const m of src.matchAll(/(?:^|;)[ \t]*import\s*['"]([^'"]+)['"]/gm)) { |
| staticSpecs.push(m[1]); |
| } |
| |
| |
| for (const m of src.matchAll(/(?:^|;)[ \t]*export\s+(?!type\b)(\*(?:\s+as\s+\w+)?|\{[^}]*\})\s*from\s*['"]([^'"]+)['"]/gms)) { |
| if (m[1].startsWith('{') && isAllTypeNamedClause(m[1])) continue; |
| staticSpecs.push(m[2]); |
| } |
| |
| for (const m of src.matchAll(/\bimport\(\s*['"]([^'"]+)['"]/g)) { |
| dynamicSpecs.push(m[1]); |
| } |
| |
| |
| for (const m of src.matchAll(/\brequire\(\s*['"]([^'"]+)['"]\s*\)/g)) { |
| requireSpecs.push(m[1]); |
| } |
| |
| |
| |
| |
| for (const m of src.matchAll(/\bcreateRequire\((?:[^()]|\([^()]*\))*\)\(\s*['"]([^'"]+)['"]\s*\)/g)) { |
| requireSpecs.push(m[1]); |
| } |
| return { staticSpecs, dynamicSpecs, requireSpecs }; |
| } |
|
|
| export function isBare(spec) { |
| return !spec.startsWith('.') && !spec.startsWith('/'); |
| } |
|
|
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| export function parseDockerfileCopy(src) { |
| const files = new Set(); |
| const directories = new Set(); |
| for (const m of src.matchAll(/^COPY\s+([^\n]+)$/gm)) { |
| const tokens = m[1].trim().split(/\s+/).filter((t) => !t.startsWith('--')); |
| if (tokens.length < 2) continue; |
| for (const arg of tokens.slice(0, -1)) { |
| if (arg.endsWith('/')) directories.add(arg.replace(/\/+$/, '')); |
| else files.add(arg); |
| } |
| } |
| return { files, directories }; |
| } |
|
|
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export function extractBundleMembers(src) { |
| return [...stripComments(src).matchAll(/script:\s*(["'`])([^"'`]+)\1/g)].map((m) => m[2]); |
| } |
|
|
| |
|
|
| |
| |
| export const NODE_SOURCE_EXTS = ['.mjs', '.cjs', '.js']; |
| const PLAIN_NODE_LOADABLE_EXTS = new Set([...NODE_SOURCE_EXTS, '.json']); |
| |
| |
| const TSX_EXT_CANDIDATES = ['.ts', '.mts', '.js', '.mjs', '.cjs']; |
| const TSX_INDEX_CANDIDATES = ['index.ts', 'index.js', 'index.mjs']; |
|
|
| |
| |
| |
| export function resolveNodeRelative(fromFile, relImport, exts = NODE_SOURCE_EXTS) { |
| const abs = resolve(dirname(fromFile), relImport); |
| if (existsSync(abs) && !statSync(abs).isDirectory()) return abs; |
| for (const ext of exts) { |
| if (existsSync(abs + ext)) return abs + ext; |
| } |
| return null; |
| } |
|
|
| |
| |
| |
| export function resolveTsxRelative(fromFile, spec) { |
| const base = resolve(dirname(fromFile), spec); |
| const candidates = [base, ...TSX_EXT_CANDIDATES.map((ext) => base + ext), ...TSX_INDEX_CANDIDATES.map((ix) => join(base, ix))]; |
| if (spec.endsWith('.js')) candidates.push(base.replace(/\.js$/, '.ts')); |
| if (spec.endsWith('.mjs')) candidates.push(base.replace(/\.mjs$/, '.mts')); |
| return candidates.find((p) => existsSync(p) && statSync(p).isFile()) ?? null; |
| } |
|
|
| |
| |
| |
| |
| export function collectRelativeImports(filePath) { |
| const src = stripComments(readFileSync(filePath, 'utf-8')); |
| const { staticSpecs, requireSpecs } = extractEdges(src); |
| const imports = new Set(); |
| for (const spec of [...staticSpecs, ...requireSpecs]) { |
| if (spec.startsWith('.')) imports.add(spec); |
| } |
| return imports; |
| } |
|
|
| |
| |
| |
| |
| export function collectRelativeRuntimeImports(filePath) { |
| const src = stripComments(readFileSync(filePath, 'utf-8')); |
| const { staticSpecs, dynamicSpecs, requireSpecs } = extractEdges(src); |
| const imports = new Set(); |
| for (const spec of [...staticSpecs, ...dynamicSpecs, ...requireSpecs]) { |
| if (spec.startsWith('.')) imports.add(spec); |
| } |
| return imports; |
| } |
|
|
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export function walkContainerGraph(rootFiles, contract) { |
| const parent = new Map(); |
| const visited = new Set(); |
| const queue = [...rootFiles]; |
| const violations = []; |
| const unresolved = []; |
| const hasTsx = contract.hasTsx !== false; |
|
|
| const chainOf = (file) => { |
| const chain = []; |
| for (let f = file; f; f = parent.get(f)) chain.unshift(relative(contract.repoRoot, f)); |
| return chain.join('\n -> '); |
| }; |
|
|
| const inside = (dirs, p) => dirs.some((d) => p.startsWith(d + sep)); |
|
|
| const followRelative = (file, spec) => { |
| const resolved = resolveTsxRelative(file, spec); |
| if (!resolved) { |
| unresolved.push(`'${spec}' imported from\n ${chainOf(file)}`); |
| return; |
| } |
| if (!hasTsx) { |
| |
| |
| |
| |
| const literal = resolve(dirname(file), spec); |
| if (resolved !== literal || !PLAIN_NODE_LOADABLE_EXTS.has(extname(resolved))) { |
| violations.push( |
| `'${spec}' resolves only under a tsx loader (extension guessing / TypeScript -> ${relative(contract.repoRoot, resolved)}), but this container runs plain node via\n ${chainOf(file)}`, |
| ); |
| return; |
| } |
| } |
| if (!inside(contract.copyRootDirs, resolved)) { |
| violations.push( |
| `'${spec}' resolves in the repo but OUTSIDE the container COPY set (${relative(contract.repoRoot, resolved)}) via\n ${chainOf(file)}`, |
| ); |
| return; |
| } |
| if (!visited.has(resolved) && !parent.has(resolved)) parent.set(resolved, file); |
| queue.push(resolved); |
| }; |
|
|
| const checkBare = (file, spec, how) => { |
| const pkg = spec.split('/').slice(0, spec.startsWith('@') ? 2 : 1).join('/'); |
| if (!isBuiltin(spec) && !contract.installedPackages.has(pkg)) { |
| violations.push(`'${spec}' ${how} via\n ${chainOf(file)}`); |
| } |
| }; |
|
|
| while (queue.length > 0) { |
| const file = queue.shift(); |
| if (visited.has(file)) continue; |
| visited.add(file); |
| if (extname(file) === '.json') continue; |
|
|
| const src = stripComments(readFileSync(file, 'utf-8')); |
| const { staticSpecs, dynamicSpecs, requireSpecs } = extractEdges(src); |
|
|
| for (const spec of staticSpecs) { |
| if (isBare(spec)) checkBare(file, spec, 'statically imported'); |
| else followRelative(file, spec); |
| } |
| for (const spec of requireSpecs) { |
| |
| |
| |
| |
| |
| if (isBare(spec)) checkBare(file, spec, 'require()d'); |
| else followRelative(file, spec); |
| } |
| for (const spec of dynamicSpecs) { |
| if (isBare(spec)) continue; |
| const resolved = resolveTsxRelative(file, spec); |
| if (resolved && inside(contract.dynamicRootDirs, resolved)) { |
| if (!visited.has(resolved) && !parent.has(resolved)) parent.set(resolved, file); |
| queue.push(resolved); |
| } |
| } |
| } |
|
|
| return { violations, unresolved, visited }; |
| } |
|
|