/*
TypeScript-AST reachability machinery for kernel command source scanning
(RFC-1303). Extracted from registry-integrity.test.ts so both the test-time
ratchet and runtime validators (command.manifest.validate's ENV-INPUT-01
scan) share one implementation — comments, string literals, and template
text are structurally invisible, and import/re-export/destructured-dynamic
bindings resolve from real syntax, not regexes.
- Do not emit diagnostics — this module returns raw facts (slices, bindings, env reads); rule ids live in the consuming validator.
- createSourceScanner(workspaceRoot) returns a per-root scanner — parse/binding/defs caches are scoped to the factory so concurrent fixtures never cross-pollinate.
- RFC-1303: extracted from registry-integrity.test.ts — parsedFile/bindingsFor/topLevelDefs/resolveIdentSlice/executeSlice/handlerSlices verbatim, plus collectEnvReads for ENV-INPUT-01.
- RFC-1303 review: moved to werkstatt-shared/src/node/ (IO-01 — a sync AST scanner cannot take async io; this dir is the designated Node-only helper home). collectEnvReads now returns EnvReadResult with unresolved/truncated coverage stats so consumers can fail loud instead of silently under-scanning.
- RFC-1303: review REVIEW-CODE-2026-10-16-02 — scanner moves to werkstatt-shared/node (IO-01), workpieceEnv hashes into envDigest, saveCache tolerates KERNEL-META-01, ENV-INPUT-02 coverage notice, ratchet path-arg tightening
*/
import { createRequire } from "node:module";
import { existsSync, readFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import ts from "typescript";
export interface ParsedFile {
sf: ts.SourceFile;
text: string;
}
export interface BindingTarget {
/** Resolved file for relative/package specs; undefined for node builtins. */
file?: string;
/** Member name inside the target file; undefined = whole-module namespace. */
member?: string;
/** Raw specifier — retained for builtin (child_process/fs) detection. */
spec: string;
}
/** A resolved implementation location — pre-guard-analysis. */
export interface ResolvedNode {
file: string;
node: ts.Node;
}
export interface HandlerSlice {
file: string;
node: ts.Node;
/**
* True when every callsite that reached this slice sits inside a try block —
* a KERNEL-META-01 thrown here is swallowed by the caller, degrading output
* instead of crashing the command.
*/
guardedEntry: boolean;
}
const MAX_RESOLVE_DEPTH = 5;
const CALLEE_SKIP = new Set([
"if",
"for",
"while",
"switch",
"catch",
"return",
"await",
"new",
"function",
"typeof",
"throw",
"case",
"super",
"constructor",
"require",
]);
const MAX_SLICES = 64;
const MAX_SLICE_TEXT = 400_000;
/**
* Scan coverage report — populated by handlerSlices/collectEnvReads when the
* caller passes a stats object. `unresolved`: execute() could not be found;
* `truncated`: a depth/slice/size cap cut the walk short — the returned slice
* set is a lower bound of the real reachability closure (RFC-1303 REVIEW-04).
*/
export interface ScanStats {
unresolved: boolean;
truncated: boolean;
}
export interface EnvReadResult {
reads: Map;
stats: ScanStats;
}
export interface SourceScanner {
parsedFile: (file: string) => ParsedFile | undefined;
resolveModuleSpec: (spec: string, fromDir: string) => string | undefined;
bindingsFor: (file: string) => Map;
topLevelDefs: (file: string) => Map;
resolveIdentSlice: (
ident: string,
inFile: string,
depth: number,
visited: Set,
) => ResolvedNode | undefined;
unwrapExpr: (node: ts.Expression) => ts.Expression;
propNameText: (prop: ts.ObjectLiteralElementLike | ts.ClassElement) => string | undefined;
constStringValue: (file: string, ident: string) => string | undefined;
findCommandObject: (file: string, cmdName: string) => ts.ObjectLiteralExpression | undefined;
executeSlice: (file: string, cmdName: string) => ResolvedNode | undefined;
isFunctionBoundary: (n: ts.Node) => boolean;
isExpressionNode: (n: ts.Node) => n is ts.Expression;
returnValueExprs: (node: ts.Node) => ts.Expression[];
objectCandidatesFor: (
expr: ts.Expression,
file: string,
visited: Set,
depth: number,
) => ResolvedNode[];
memberBodyNode: (
candidate: ResolvedNode,
name: string,
visited: Set,
depth: number,
) => ResolvedNode | undefined;
deferredMemberNodes: (file: string, node: ts.Node) => Set;
handlerSlices: (file: string, cmdName: string, stats?: ScanStats) => HandlerSlice[];
isIoReceiver: (expr: ts.Expression) => boolean;
isGuarded: (node: ts.Node, root: ts.Node) => boolean;
collectEnvReads: (file: string, cmdName: string) => EnvReadResult;
}
/**
* RFC-1303: per-workspace-root AST scanner. Caches (parse, bindings, defs)
* live inside the factory so two scanners over different roots never share
* stale entries.
*/
export function createSourceScanner(workspaceRoot: string): SourceScanner {
const requireResolve = createRequire(import.meta.url);
const parseCache = new Map();
const parsedFile = (file: string): ParsedFile | undefined => {
if (parseCache.has(file)) return parseCache.get(file);
let parsed: ParsedFile | undefined;
try {
const text = readFileSync(file, "utf8");
parsed = {
text,
sf: ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS),
};
} catch {
parsed = undefined;
}
parseCache.set(file, parsed);
return parsed;
};
/** Resolve a module specifier to an existing .ts file, or undefined. */
const resolveModuleSpec = (spec: string, fromDir: string): string | undefined => {
const candidates: string[] = [];
if (spec.startsWith(".")) {
const base = resolve(fromDir, spec);
candidates.push(base, `${base}.ts`, `${base}.tsx`, join(base, "index.ts"));
} else if (!spec.startsWith("node:")) {
// Package specifiers — workspace exports map straight to src/*.ts.
try {
const r = requireResolve.resolve(spec);
if (r.endsWith(".ts")) candidates.push(r);
} catch {
// fall through to the workspace convention
}
const pm = /^@warpgogol\/([^/]+)(?:\/(.*))?$/.exec(spec);
if (pm) {
const pkgRoot = join(workspaceRoot, "packages", pm[1]);
const sub = pm[2] ?? "src/index";
candidates.push(
join(pkgRoot, "src", `${sub}.ts`),
join(pkgRoot, "src", sub, "index.ts"),
join(pkgRoot, "os", `${sub}.ts`),
join(pkgRoot, `${sub}.ts`),
join(pkgRoot, sub, "index.ts"),
);
}
}
return candidates.find((c) => c.endsWith(".ts") && existsSync(c));
};
/**
* Per-file binding index: local name → where it can be resolved.
* Covers static imports, namespace imports, destructured lazy imports
* (`const { x } = await import("./y.ts")`), `const m = await import("./y.ts")`
* namespace bindings, and re-export barrels.
*/
const bindingCache = new Map>();
const bindingsFor = (file: string): Map => {
const cached = bindingCache.get(file);
if (cached) return cached;
const map = new Map();
const pf = parsedFile(file);
if (!pf) {
bindingCache.set(file, map);
return map;
}
const dir = dirname(file);
const add = (local: string, spec: string, member?: string) => {
const list = map.get(local) ?? [];
const entry: BindingTarget = { spec, member, file: resolveModuleSpec(spec, dir) };
if (!list.some((e) => e.file === entry.file && e.member === entry.member && e.spec === spec))
list.push(entry);
map.set(local, list);
};
const asDynamicImport = (node: ts.Expression | undefined): ts.CallExpression | undefined => {
const unwrapped = node && ts.isAwaitExpression(node) ? node.expression : node;
if (
unwrapped &&
ts.isCallExpression(unwrapped) &&
unwrapped.expression.kind === ts.SyntaxKind.ImportKeyword &&
unwrapped.arguments[0] &&
ts.isStringLiteral(unwrapped.arguments[0])
)
return unwrapped;
if (
unwrapped &&
ts.isCallExpression(unwrapped) &&
ts.isIdentifier(unwrapped.expression) &&
unwrapped.expression.text === "require" &&
unwrapped.arguments[0] &&
ts.isStringLiteral(unwrapped.arguments[0])
)
return unwrapped;
return undefined;
};
for (const st of pf.sf.statements) {
if (
ts.isImportDeclaration(st) &&
ts.isStringLiteral(st.moduleSpecifier) &&
st.importClause &&
!st.importClause.isTypeOnly
) {
const spec = st.moduleSpecifier.text;
const clause = st.importClause;
if (clause.name) add(clause.name.text, spec, "default");
const nb = clause.namedBindings;
if (nb && ts.isNamespaceImport(nb)) add(nb.name.text, spec);
else if (nb && ts.isNamedImports(nb)) {
for (const el of nb.elements) {
if (el.isTypeOnly) continue;
add(el.name.text, spec, el.propertyName?.text ?? el.name.text);
}
}
continue;
}
if (
ts.isExportDeclaration(st) &&
st.moduleSpecifier &&
ts.isStringLiteral(st.moduleSpecifier)
) {
const spec = st.moduleSpecifier.text;
if (!st.exportClause) {
// export * from "spec" — wildcard frontier
add("*", spec);
} else if (ts.isNamespaceExport(st.exportClause)) {
add(st.exportClause.name.text, spec);
} else if (ts.isNamedExports(st.exportClause)) {
for (const el of st.exportClause.elements) {
if (el.isTypeOnly) continue;
// local name el.name resolves to member (propertyName ?? name) in target
add(el.name.text, spec, el.propertyName?.text ?? el.name.text);
}
}
continue;
}
}
// Destructured lazy imports live inside module-factory function bodies, so
// scan the whole tree for VariableDeclaration nodes, not just top-level
// statements.
const visit = (node: ts.Node) => {
if (ts.isVariableDeclaration(node)) {
const call = asDynamicImport(node.initializer);
if (call) {
const spec = (call.arguments[0] as ts.StringLiteral).text;
if (ts.isObjectBindingPattern(node.name)) {
for (const el of node.name.elements) {
if (!ts.isIdentifier(el.name)) continue;
const member =
el.propertyName && ts.isIdentifier(el.propertyName)
? el.propertyName.text
: el.name.text;
add(el.name.text, spec, member);
}
} else if (ts.isIdentifier(node.name)) {
add(node.name.text, spec); // const m = await import("x") — namespace
}
}
}
ts.forEachChild(node, visit);
};
visit(pf.sf);
bindingCache.set(file, map);
return map;
};
/**
* File-wide name → declaration index. Includes declarations nested inside
* module-factory bodies (`const wrapper = async () => …` handlers) — first
* occurrence wins, so a shadowing inner name can mask an outer one, which is
* acceptable for a structural scan.
*/
const topDefsCache = new Map>();
const topLevelDefs = (file: string): Map => {
const cached = topDefsCache.get(file);
if (cached) return cached;
const map = new Map();
const pf = parsedFile(file);
if (pf) {
const visit = (node: ts.Node) => {
if (ts.isFunctionDeclaration(node) && node.name && !map.has(node.name.text))
map.set(node.name.text, node);
if (
ts.isVariableDeclaration(node) &&
ts.isIdentifier(node.name) &&
!map.has(node.name.text)
)
map.set(node.name.text, node.initializer ?? node);
if (ts.isExportAssignment(node) && !map.has("default")) map.set("default", node.expression);
if (ts.isClassDeclaration(node) && node.name && !map.has(node.name.text))
map.set(node.name.text, node);
ts.forEachChild(node, visit);
};
visit(pf.sf);
}
topDefsCache.set(file, map);
return map;
};
/** The node to scan for a resolved top-level declaration. */
const scanTargetOf = (node: ts.Node): ts.Node => {
if (ts.isFunctionDeclaration(node) || ts.isFunctionExpression(node)) return node.body ?? node;
if (ts.isArrowFunction(node)) return node.body;
if (ts.isVariableDeclaration(node)) return node.initializer ?? node;
return node;
};
/** Resolve an identifier to the node implementing it (local or via imports). */
const resolveIdentSlice = (
ident: string,
inFile: string,
depth: number,
visited: Set,
): ResolvedNode | undefined => {
if (depth <= 0) {
depthCapHit = true;
return undefined;
}
const vkey = `${inFile}${ident}`;
if (visited.has(vkey)) return undefined;
visited.add(vkey);
if (!parsedFile(inFile)) return undefined;
const local = topLevelDefs(inFile).get(ident);
if (local) return { file: inFile, node: scanTargetOf(local) };
for (const t of bindingsFor(inFile).get(ident) ?? []) {
if (!t.file) continue;
const hit = resolveIdentSlice(t.member ?? ident, t.file, depth - 1, visited);
if (hit) return hit;
}
// export * frontiers
for (const t of bindingsFor(inFile).get("*") ?? []) {
if (!t.file) continue;
const hit = resolveIdentSlice(ident, t.file, depth - 1, visited);
if (hit) return hit;
}
return undefined;
};
/** Unwrap expression shells that don't change semantics. */
const unwrapExpr = (node: ts.Expression): ts.Expression => {
let cur = node;
for (;;) {
if (
ts.isParenthesizedExpression(cur) ||
ts.isAsExpression(cur) ||
ts.isSatisfiesExpression(cur) ||
ts.isNonNullExpression(cur)
) {
cur = cur.expression;
continue;
}
if (ts.isAwaitExpression(cur)) {
cur = cur.expression;
continue;
}
return cur;
}
};
/** String value of a property name, if statically known. */
const propNameText = (
prop: ts.ObjectLiteralElementLike | ts.ClassElement,
): string | undefined => {
const n = (prop as ts.PropertyAssignment | ts.MethodDeclaration | ts.ClassElement).name;
if (!n) return undefined;
if (ts.isIdentifier(n) || ts.isStringLiteral(n) || ts.isNumericLiteral(n)) return n.text;
return undefined;
};
/** Literal string value for `const X = "..."` initializers in a file. */
const constStringValue = (file: string, ident: string): string | undefined => {
const def = topLevelDefs(file).get(ident);
if (def && ts.isStringLiteral(def)) return def.text;
return undefined;
};
/** The ObjectLiteralExpression that carries `name: ""` in this file. */
const findCommandObject = (
file: string,
cmdName: string,
): ts.ObjectLiteralExpression | undefined => {
const pf = parsedFile(file);
if (!pf) return undefined;
let found: ts.ObjectLiteralExpression | undefined;
const visit = (node: ts.Node) => {
if (found) return;
if (
ts.isPropertyAssignment(node) &&
propNameText(node) === "name" &&
ts.isObjectLiteralExpression(node.parent)
) {
const init = unwrapExpr(node.initializer);
const value = ts.isStringLiteral(init)
? init.text
: ts.isIdentifier(init)
? constStringValue(file, init.text)
: undefined;
if (value === cmdName) found = node.parent;
}
ts.forEachChild(node, visit);
};
visit(pf.sf);
return found;
};
/** Map an `execute:` initializer expression to the node implementing it. */
const executeExprSlice = (
expr: ts.Expression,
file: string,
visited: Set,
): ResolvedNode | undefined => {
const e = unwrapExpr(expr);
// execute: runFoo
if (ts.isIdentifier(e)) return resolveIdentSlice(e.text, file, MAX_RESOLVE_DEPTH, visited);
// execute: ns.foo / (await import("./x.ts")).foo
if (ts.isPropertyAccessExpression(e)) {
const obj = unwrapExpr(e.expression);
if (ts.isCallExpression(obj) && obj.expression.kind === ts.SyntaxKind.ImportKeyword) {
const spec = obj.arguments[0];
if (spec && ts.isStringLiteral(spec)) {
const target = resolveModuleSpec(spec.text, dirname(file));
if (target) return resolveIdentSlice(e.name.text, target, MAX_RESOLVE_DEPTH, visited);
}
return undefined;
}
if (ts.isIdentifier(obj)) {
for (const t of bindingsFor(file).get(obj.text) ?? []) {
if (!t.file || t.member !== undefined) continue; // namespace bindings only
const hit = resolveIdentSlice(e.name.text, t.file, MAX_RESOLVE_DEPTH - 1, visited);
if (hit) return hit;
}
}
return { file, node: e };
}
// execute: async (...) => {...} / (...) => expr
if (ts.isArrowFunction(e) || ts.isFunctionExpression(e)) return { file, node: e.body ?? e };
// execute: factory(...) / conditional / other expression — scan it, callee
// expansion reaches the factory implementation through call identifiers.
return { file, node: e };
};
/** Locate the execute() implementation for `name: ""` in `file`. */
const executeSlice = (file: string, cmdName: string): ResolvedNode | undefined => {
const pf = parsedFile(file);
if (!pf) return undefined;
const obj = findCommandObject(file, cmdName);
if (!obj) return undefined;
const visited = new Set();
for (const prop of obj.properties) {
if (propNameText(prop) !== "execute") continue;
if (ts.isMethodDeclaration(prop) && prop.body) return { file, node: prop.body };
if (ts.isPropertyAssignment(prop)) return executeExprSlice(prop.initializer, file, visited);
}
return undefined;
};
/**
* Function-like boundaries whose inner `return` statements belong to the
* nested closure, not to the enclosing slice's function.
*/
const isFunctionBoundary = (n: ts.Node): boolean =>
ts.isFunctionDeclaration(n) ||
ts.isFunctionExpression(n) ||
ts.isArrowFunction(n) ||
ts.isMethodDeclaration(n) ||
ts.isGetAccessorDeclaration(n) ||
ts.isSetAccessorDeclaration(n) ||
ts.isConstructorDeclaration(n);
/** Narrow check for the expression shapes this scanner reasons about. */
const isExpressionNode = (n: ts.Node): n is ts.Expression =>
ts.isIdentifier(n) ||
ts.isCallExpression(n) ||
ts.isNewExpression(n) ||
ts.isObjectLiteralExpression(n) ||
ts.isConditionalExpression(n) ||
ts.isParenthesizedExpression(n) ||
ts.isAsExpression(n) ||
ts.isSatisfiesExpression(n) ||
ts.isNonNullExpression(n) ||
ts.isAwaitExpression(n) ||
ts.isPropertyAccessExpression(n);
/**
* Expressions a slice's function can evaluate to: top-level ReturnStatements
* (nested closures return to themselves, so their bodies are not descended),
* plus the node itself when the slice is an arrow-expression body
* (`=> ({...})`).
*/
const returnValueExprs = (node: ts.Node): ts.Expression[] => {
if (isExpressionNode(node)) return [node];
const out: ts.Expression[] = [];
const visit = (n: ts.Node) => {
if (n !== node && isFunctionBoundary(n)) return;
if (ts.isReturnStatement(n) && n.expression) out.push(n.expression);
ts.forEachChild(n, visit);
};
visit(node);
return out;
};
/**
* Object/class values an expression may evaluate to — follows local
* `const x = f()` initializers, imported/aliased consts, factory-call
* returns, `new` expressions, and conditional branches. Bounded by `depth`
* and `visited`; unresolvable receivers contribute nothing.
*/
const objectCandidatesFor = (
expr: ts.Expression,
file: string,
visited: Set,
depth: number,
): ResolvedNode[] => {
if (depth <= 0) return [];
const e = unwrapExpr(expr);
if (ts.isObjectLiteralExpression(e) || ts.isClassDeclaration(e) || ts.isClassExpression(e))
return [{ file, node: e }];
if (ts.isConditionalExpression(e))
return [
...objectCandidatesFor(e.whenTrue, file, visited, depth - 1),
...objectCandidatesFor(e.whenFalse, file, visited, depth - 1),
];
if ((ts.isCallExpression(e) || ts.isNewExpression(e)) && ts.isIdentifier(e.expression)) {
const res = resolveIdentSlice(e.expression.text, file, depth - 1, visited);
if (!res) return [];
// `new X()` resolves to the class itself; `f()` to the returned values.
if (ts.isNewExpression(e)) return [res];
return returnValueExprs(res.node).flatMap((rv) =>
objectCandidatesFor(rv, res.file, visited, depth - 1),
);
}
if (ts.isIdentifier(e)) {
const local = topLevelDefs(file).get(e.text);
if (local) {
if (isFunctionBoundary(local)) {
const body = (local as ts.FunctionLikeDeclaration).body ?? (local as ts.Node);
return returnValueExprs(body).flatMap((rv) =>
objectCandidatesFor(rv, file, visited, depth - 1),
);
}
if (isExpressionNode(local)) return objectCandidatesFor(local, file, visited, depth - 1);
return [];
}
const res = resolveIdentSlice(e.text, file, depth - 1, visited);
if (!res) return [];
// resolveIdentSlice hands back a function body (Block/SourceFile) or a
// variable initializer (Expression) — normalize both to value candidates.
if (ts.isBlock(res.node) || ts.isModuleBlock(res.node) || ts.isSourceFile(res.node))
return returnValueExprs(res.node).flatMap((rv) =>
objectCandidatesFor(rv, res.file, visited, depth - 1),
);
if (isExpressionNode(res.node))
return objectCandidatesFor(res.node, res.file, visited, depth - 1);
return [res];
}
return [];
};
/**
* Body of member `name` on an object-literal/class candidate. Function-valued
* members return the implementation body; aliased members (`{ health: impl }`,
* `{ health }` shorthand) resolve through the binding index; other property
* initializers are eager values — returned as-is so they stay scannable.
*/
const memberBodyNode = (
candidate: ResolvedNode,
name: string,
visited: Set,
depth: number,
): ResolvedNode | undefined => {
const { file, node } = candidate;
const members: readonly (ts.ObjectLiteralElementLike | ts.ClassElement)[] | undefined =
ts.isObjectLiteralExpression(node)
? node.properties
: ts.isClassDeclaration(node) || ts.isClassExpression(node)
? node.members
: undefined;
if (!members) return undefined;
for (const m of members) {
if (propNameText(m) !== name) continue;
if (
ts.isMethodDeclaration(m) ||
ts.isGetAccessorDeclaration(m) ||
ts.isSetAccessorDeclaration(m)
)
return { file, node: m.body ?? m };
if (ts.isPropertyDeclaration(m)) {
const init = m.initializer && unwrapExpr(m.initializer);
if (init && (ts.isArrowFunction(init) || ts.isFunctionExpression(init)))
return { file, node: init.body ?? init };
return undefined;
}
if (ts.isShorthandPropertyAssignment(m)) {
return resolveIdentSlice(m.name.text, file, depth - 1, visited);
}
if (ts.isPropertyAssignment(m)) {
const init = unwrapExpr(m.initializer);
if (ts.isArrowFunction(init) || ts.isFunctionExpression(init))
return { file, node: init.body ?? init };
if (ts.isIdentifier(init))
return resolveIdentSlice(init.text, file, depth - 1, visited) ?? { file, node: init };
return { file, node: m.initializer };
}
}
return undefined;
};
/**
* Function-valued members of object literals the slice's function RETURNS.
* Creating a closure does not execute it — a returned method becomes reachable
* only when a caller invokes it (tracked via member-level callee resolution).
* This keeps factory folds (`resolveAdapter` → `createGitHubPagesAdapter`)
* from attributing every adapter method's mutations to a command that only
* calls `adapter.health()`.
*/
const deferredMemberNodes = (file: string, node: ts.Node): Set => {
const out = new Set();
const markObject = (obj: ts.ObjectLiteralExpression) => {
for (const p of obj.properties) {
if (
ts.isMethodDeclaration(p) ||
ts.isGetAccessorDeclaration(p) ||
ts.isSetAccessorDeclaration(p)
)
out.add(p);
else if (ts.isPropertyAssignment(p)) {
const init = unwrapExpr(p.initializer);
if (ts.isArrowFunction(init) || ts.isFunctionExpression(init)) out.add(init);
}
}
};
const seenExprs = new Set();
const visited = new Set();
const addValue = (expr: ts.Expression | undefined, depth: number) => {
const e = expr && unwrapExpr(expr);
if (!e || depth <= 0 || seenExprs.has(e)) return;
seenExprs.add(e);
if (ts.isObjectLiteralExpression(e)) markObject(e);
else if (ts.isConditionalExpression(e)) {
addValue(e.whenTrue, depth - 1);
addValue(e.whenFalse, depth - 1);
} else if (ts.isIdentifier(e)) {
// `return adapter` — local const or imported binding.
const local = topLevelDefs(file).get(e.text);
if (local && isExpressionNode(local)) addValue(local, depth - 1);
else {
const res = resolveIdentSlice(e.text, file, depth - 1, visited);
if (res && ts.isObjectLiteralExpression(res.node)) markObject(res.node);
}
}
};
for (const rv of returnValueExprs(node)) addValue(rv, MAX_RESOLVE_DEPTH);
return out;
};
/**
* execute() body plus its reachable callees — local defs AND import-resolved
* (static, destructured dynamic, namespace, re-export barrels). Bounded by a
* visited set, a resolution depth cap, and a total-slice cap. Callee slices
* inherit "guarded" from their callsite: when every path into a callee passes
* through a try block, a KERNEL-META-01 thrown inside degrades output rather
* than crashing the command.
*/
// Set whenever resolveIdentSlice exhausts MAX_RESOLVE_DEPTH — the slice
// walk then under-covers the real closure. Reset per handlerSlices call;
// the scanner is synchronous so a factory-scoped flag is safe.
let depthCapHit = false;
const handlerSlices = (file: string, cmdName: string, stats?: ScanStats): HandlerSlice[] => {
if (stats) {
stats.unresolved = false;
stats.truncated = false;
}
depthCapHit = false;
const root = executeSlice(file, cmdName);
if (!root) {
if (stats) stats.unresolved = true;
return [];
}
const rootSlice: HandlerSlice = { ...root, guardedEntry: false };
const slices: HandlerSlice[] = [rootSlice];
// key → still reachable only via guarded callsites
const seen = new Map([[`${root.file}:${root.node.getStart()}`, false]]);
const visited = new Set();
const queue: HandlerSlice[] = [rootSlice];
let oversizeSkip = false;
while (queue.length > 0 && slices.length < MAX_SLICES * 2) {
const cur = queue.pop()!;
const pf = parsedFile(cur.file);
if (!pf) continue;
if (cur.node.getEnd() - cur.node.getStart() > MAX_SLICE_TEXT) {
oversizeSkip = true;
continue;
}
// Returned-object methods are closures handed to the caller — the call
// itself never runs them, so they stay out of this slice's callee set;
// member calls (`adapter.health()`) pull them back in precisely.
const deferred = deferredMemberNodes(cur.file, cur.node);
const push = (res: ResolvedNode | undefined, callsiteGuarded: boolean) => {
if (!res) return;
const key = `${res.file}:${res.node.getStart()}`;
const prev = seen.get(key);
if (prev === false) return; // already reached unguarded — strongest claim
if (prev === true && callsiteGuarded) return; // still guarded-only
// First sighting, or a guarded-only callee now reached unguarded —
// (re)queue with the new guard status.
seen.set(key, callsiteGuarded);
slices.push({ ...res, guardedEntry: callsiteGuarded });
queue.push(slices[slices.length - 1]);
};
const visit = (node: ts.Node) => {
if (slices.length >= MAX_SLICES * 2) return;
if (deferred.has(node)) return;
if (ts.isCallExpression(node) || ts.isNewExpression(node)) {
const callsiteGuarded = cur.guardedEntry || isGuarded(node, cur.node);
const callee = node.expression;
if (ts.isIdentifier(callee)) {
if (!CALLEE_SKIP.has(callee.text))
push(
resolveIdentSlice(callee.text, cur.file, MAX_RESOLVE_DEPTH, visited),
callsiteGuarded,
);
} else if (ts.isPropertyAccessExpression(callee)) {
const obj = unwrapExpr(callee.expression);
if (
ts.isCallExpression(obj) &&
obj.expression.kind === ts.SyntaxKind.ImportKeyword &&
obj.arguments[0] &&
ts.isStringLiteral(obj.arguments[0])
) {
// (await import("./x.ts")).member(...)
const target = resolveModuleSpec(obj.arguments[0].text, dirname(cur.file));
if (target)
push(
resolveIdentSlice(callee.name.text, target, MAX_RESOLVE_DEPTH, visited),
callsiteGuarded,
);
} else if (ts.isIdentifier(obj)) {
// ns.member(...) via namespace import / const m = await import(...)
for (const t of bindingsFor(cur.file).get(obj.text) ?? []) {
if (!t.file || t.member !== undefined) continue;
push(
resolveIdentSlice(callee.name.text, t.file, MAX_RESOLVE_DEPTH - 1, visited),
callsiteGuarded,
);
}
// x.member(...) where x is a local ident bound to a factory or
// object value — resolve the invoked member body across every
// object the binding may denote (`const adapter = resolveAdapter()`
// → `adapter.health()` reaches each returned adapter's `health`).
const localInit = topLevelDefs(cur.file).get(obj.text);
const valueVisited = new Set();
const seedExprs: { file: string; expr: ts.Expression }[] = [];
if (localInit && isExpressionNode(localInit))
seedExprs.push({ file: cur.file, expr: localInit });
// `import { adapter }` — member-bound import, not a namespace.
for (const t of bindingsFor(cur.file).get(obj.text) ?? []) {
if (!t.file || t.member === undefined) continue;
const res = resolveIdentSlice(t.member, t.file, MAX_RESOLVE_DEPTH - 1, visited);
if (res && isExpressionNode(res.node))
seedExprs.push({ file: res.file, expr: res.node });
}
for (const seed of seedExprs) {
for (const cand of objectCandidatesFor(
seed.expr,
seed.file,
valueVisited,
MAX_RESOLVE_DEPTH,
)) {
push(
memberBodyNode(cand, callee.name.text, valueVisited, MAX_RESOLVE_DEPTH),
callsiteGuarded,
);
}
}
}
}
}
ts.forEachChild(node, visit);
};
visit(cur.node);
}
if (stats && (depthCapHit || oversizeSkip || queue.length > 0)) stats.truncated = true;
return slices;
};
/** True when the call's receiver expression refers to the injected io port. */
const isIoReceiver = (expr: ts.Expression): boolean => {
const e = unwrapExpr(expr);
if (ts.isIdentifier(e)) return e.text === "io";
if (ts.isPropertyAccessExpression(e)) return e.name.text === "io"; // context.io.* / ctx.io.*
return false;
};
/**
* True when the node sits inside a `try { }` block within the scanned slice —
* a swallowed KERNEL-META-01 degrades output instead of crashing the command
* (best-effort telemetry-style writes), which is a weaker defect class than an
* unguarded mutation that propagates the throw.
*/
const isGuarded = (node: ts.Node, root: ts.Node): boolean => {
let cur: ts.Node | undefined = node.parent;
while (cur && cur !== root) {
if (
ts.isTryStatement(cur) &&
node.getStart() >= cur.tryBlock.getStart() &&
node.getEnd() <= cur.tryBlock.getEnd()
)
return true;
cur = cur.parent;
}
return false;
};
// ---------------------------------------------------------------------------
// RFC-1303: env-read collection for ENV-INPUT-01
// ---------------------------------------------------------------------------
const isProcessEnv = (expr: ts.Expression): boolean =>
ts.isPropertyAccessExpression(expr) &&
expr.name.text === "env" &&
ts.isIdentifier(unwrapExpr(expr.expression)) &&
(unwrapExpr(expr.expression) as ts.Identifier).text === "process";
/** True when `node` is the assignment target of `process.env.X = …` (a write, not a read). */
const isEnvWriteTarget = (node: ts.Node): boolean =>
node.parent !== undefined &&
ts.isBinaryExpression(node.parent) &&
node.parent.left === node &&
node.parent.operatorToken.kind === ts.SyntaxKind.EqualsToken;
const envReadsInNode = (
file: string,
node: ts.Node,
out: Map,
) => {
const pf = parsedFile(file);
if (!pf) return;
const lineOf = (n: ts.Node) => pf.sf.getLineAndCharacterOfPosition(n.getStart(pf.sf)).line + 1;
const record = (name: string, n: ts.Node) => {
if (!out.has(name)) out.set(name, { file, line: lineOf(n) });
};
const visit = (n: ts.Node) => {
// process.env.NAME
if (ts.isPropertyAccessExpression(n) && isProcessEnv(n.expression) && !isEnvWriteTarget(n))
record(n.name.text, n);
// process.env["NAME"]
if (
ts.isElementAccessExpression(n) &&
isProcessEnv(n.expression) &&
n.argumentExpression &&
ts.isStringLiteral(n.argumentExpression) &&
!isEnvWriteTarget(n)
)
record(n.argumentExpression.text, n);
// const { A, B: c } = process.env
if (
ts.isVariableDeclaration(n) &&
ts.isObjectBindingPattern(n.name) &&
n.initializer &&
isProcessEnv(unwrapExpr(n.initializer))
) {
for (const el of n.name.elements) {
const envName =
el.propertyName && ts.isIdentifier(el.propertyName)
? el.propertyName.text
: ts.isIdentifier(el.name)
? el.name.text
: undefined;
if (envName) record(envName, el);
}
}
ts.forEachChild(n, visit);
};
visit(node);
};
/**
* RFC-1303: every `process.env.*` name reachable from the command's
* execute() slice — the input set ENV-INPUT-01 validates against the
* declared `envInputs`.
*/
const collectEnvReads = (file: string, cmdName: string): EnvReadResult => {
const stats: ScanStats = { unresolved: false, truncated: false };
const reads = new Map();
for (const slice of handlerSlices(file, cmdName, stats)) {
envReadsInNode(slice.file, slice.node, reads);
}
return { reads, stats };
};
return {
parsedFile,
resolveModuleSpec,
bindingsFor,
topLevelDefs,
resolveIdentSlice,
unwrapExpr,
propNameText,
constStringValue,
findCommandObject,
executeSlice,
isFunctionBoundary,
isExpressionNode,
returnValueExprs,
objectCandidatesFor,
memberBodyNode,
deferredMemberNodes,
handlerSlices,
isIoReceiver,
isGuarded,
collectEnvReads,
};
}