import type { RuleContext, RuleDeclaration, RuleResult } from "../core/types/rule.types.ts"; import { accumulate, isWordCharacter } from "../core/predicates/token.predicate.ts"; import { AUTHORED_ROOTS } from "../core/constants/path.constants.ts"; import { Buffer } from "node:buffer"; import type { Finding } from "../core/types/segment.types.ts"; import { endOfQuoted } from "../core/predicates/comment.predicate.ts"; import { underRoots } from "../core/filters/scope.filter.ts"; const COMPOSERS: ReadonlySet = new Set(["within", "withinSurface", "surfacePath", "slotText", "inSurface"]); const FILESYSTEM_CALLS: ReadonlySet = new Set([ "join", "resolve", "existsSync", "readFileSync", "readdirSync", "writeFileSync", "statSync", "mkdirSync", "rmdirSync", "renameSync", "walk", "readSource", ]); const MODULE_SUFFIX = ".ts"; const DECIDE = "a path spelled in source is a claim about a tree this package does not own — it resolves onto the " + "consuming project or onto nothing, differently in every consumer, and the failure surfaces as a check " + "that silently governs the wrong files or refuses citations that are true. Every root resolves through " + "the parameter surface, which computes its prefix from its own file location, so the literal becomes an " + "argument to a composer or a declared slot rather than a value a reader trusts"; const ENUMERATORS: ReadonlySet = new Set(["readdirSync", "readdir", "walk"]); const RECURSIVE_FLAG = "recursive"; const ESCAPING_ROOTS: ReadonlySet = new Set(["repoRoot", "REPO_ROOT", "projectRoot"]); const NARROWERS: ReadonlySet = new Set(["surfacePrefix", "surfacePath", "surfaceRoot"]); const ENUMERATION_DECIDE = "a RECURSIVE enumeration rooted at a path that resolves outside the declared surface prefix lists a tree " + "this package does not own — in a consumer that tree is the whole host repository, including everything " + "its dependencies install, so the call reads correct and costs minutes rather than milliseconds and the " + "failure presents as a run that hangs rather than as anything a verdict reports. The discriminator is " + "ENUMERATION rather than the root: resolving a NAMED path through the same root walks nothing and is the " + "correct form, which is why the safe uses far outnumber the hazardous one and a scan for the root alone " + "reports mostly noise. Narrow the enumeration to the surface's own prefix and prepend it to each entry, so " + "the listing covers exactly what this package declares"; const OPEN = "("; const CLOSE = ")"; const lineOf = function lineOf(source: string, index: number): number { return source.slice(0, index).split("\n").length; }; const argumentsOf = function argumentsOf(source: string, openAt: number): string { let depth = 0; let cursor = openAt; do { const character = source.charAt(cursor); depth += (character === OPEN ? 1 : 0) - (character === CLOSE ? 1 : 0); cursor += 1; } while (depth > 0 && cursor < source.length); return depth === 0 ? source.slice(openAt + 1, cursor - 1) : ""; }; const nameEndingAt = function nameEndingAt(source: string, openAt: number): string { let start = openAt; while (start > 0 && isWordCharacter(source.charAt(start - 1))) { start -= 1; } return source.slice(start, openAt); }; interface Enumeration { readonly line: number; readonly root: string; } const QUOTES: ReadonlySet = new Set(['"', "'", "`"]); const enumerationAt = function enumerationAt(source: string, openAt: number): Enumeration[] { if (!ENUMERATORS.has(nameEndingAt(source, openAt))) { return []; } const tokens = accumulate(argumentsOf(source, openAt), isWordCharacter); const escaping = tokens.find((token) => ESCAPING_ROOTS.has(token)); const narrowed = tokens.some((token) => NARROWERS.has(token)); if (!tokens.includes(RECURSIVE_FLAG) || escaping === undefined || narrowed) { return []; } return [{ line: lineOf(source, openAt), root: escaping }]; }; export const unboundedEnumerations = function unboundedEnumerations(source: string): Enumeration[] { const out: Enumeration[] = []; let cursor = 0; while (cursor < source.length) { const character = source.charAt(cursor); if (QUOTES.has(character)) { cursor = endOfQuoted(source, cursor + 1, character); } else { out.push(...(character === OPEN ? enumerationAt(source, cursor) : [])); cursor += 1; } } return out; }; const isPathShaped = function isPathShaped(value: string): boolean { if (value.length === 0) { return false; } if (value.endsWith(MODULE_SUFFIX)) { return false; } if (Buffer.isEncoding(value)) { return false; } return !value.includes(" "); }; const ESCAPE = "\\"; const TEMPLATE_QUOTE = "`"; const closingQuote = function closingQuote(source: string, start: number, quote: string): number { let cursor = start; let char = source.charAt(cursor); const endsLine = (at: string): boolean => at === "\n" && quote !== TEMPLATE_QUOTE; while (cursor < source.length && char !== quote && !endsLine(char)) { cursor += char === ESCAPE ? 2 : 1; char = source.charAt(cursor); } return char === quote && cursor < source.length ? cursor : -1; }; const BLANKS: ReadonlySet = new Set([" ", "\n"]); const MEMBER_ACCESS = "."; const callBefore = function callBefore(source: string, quoteAt: number): string { let cursor = quoteAt - 1; while (cursor >= 0 && BLANKS.has(source.charAt(cursor))) { cursor -= 1; } if (source.charAt(cursor) !== OPEN) { return ""; } const name = nameEndingAt(source, cursor); return source.charAt(cursor - name.length - 1) === MEMBER_ACCESS ? "" : name; }; interface Quoted { readonly open: number; readonly close: number; readonly value: string; } const SCANNED_QUOTES: ReadonlySet = new Set(['"', "'"]); const quotedLiterals = function quotedLiterals(source: string): Quoted[] { const out: Quoted[] = []; let cursor = 0; while (cursor < source.length) { const char = source.charAt(cursor); const close = SCANNED_QUOTES.has(char) ? closingQuote(source, cursor + 1, char) : cursor; if (close === -1) { cursor = source.length; } else if (close === cursor) { cursor += 1; } else { out.push({ close, open: cursor, value: source.slice(cursor + 1, close) }); cursor = close + 1; } } return out; }; const rawPathFinding = function rawPathFinding(path: string, source: string, literal: Quoted, composer: string): Finding { return { actual: literal.value, expected: null, healed: false, line: lineOf(source, literal.close), locus: literal.value, path, remediation: { action: "declare", decide: DECIDE, deterministic: false, from: literal.value, target: path, to: null, }, rule: "literal/rawPath", stack: [ { check: "shape", resolved: "path" }, { check: "composer", resolved: composer.length === 0 ? "none" : composer }, ], }; }; interface LiteralScan { readonly findings: Finding[]; readonly literals: number; } const scanLiterals = function scanLiterals(path: string, source: string): LiteralScan { const shaped = quotedLiterals(source).filter((literal) => isPathShaped(literal.value)); const findings = shaped.flatMap((literal) => { const composer = callBefore(source, literal.open); const raw = FILESYSTEM_CALLS.has(composer) && !COMPOSERS.has(composer); return raw ? [rawPathFinding(path, source, literal, composer)] : []; }); return { findings, literals: shaped.length }; }; export const rule: RuleDeclaration = { check(context: RuleContext): RuleResult { const findings: Finding[] = []; const scoped = underRoots(context.paths, AUTHORED_ROOTS); let literals = 0; for (const path of scoped) { const source = context.read(path); const scan = scanLiterals(path, source); literals += scan.literals; for (const found of unboundedEnumerations(source)) { findings.push({ actual: `a recursive enumeration rooted at ${found.root}, which resolves outside the declared surface prefix`, expected: "an enumeration narrowed to the surface's own prefix", healed: false, line: found.line, locus: found.root, path, remediation: { action: "declare", decide: ENUMERATION_DECIDE, deterministic: false, from: found.root, target: path, to: null, }, rule: "literal/unboundedEnumeration", stack: [ { check: "call", resolved: "recursive enumeration" }, { check: "root", resolved: found.root }, { check: "narrowed", resolved: "no" }, ], }); } findings.push(...scan.findings); } return { derivations: { literals, reached: scoped, skippedAsOutsideAuthoredRoots: context.paths.filter((path) => !scoped.includes(path)), }, findings, healed: [], }; }, extensions: [".ts"], heals: false, invariant: "a path-shaped string literal in authored source is composed through the parameter surface", jurisdiction: "all", kinds: ["rawPath", "unboundedEnumeration"], stage: "structure", };