Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion capabilities.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"$comment": "GENERATED from src/map/capabilities.ts by scripts/emit-capabilities.mjs — do not edit by hand. The single versioned definition of what the input-flow map can describe, vendored by the reachability recipe schema/validator and by the server that binds coordinates into rules.",
"version": "1.0.0",
"version": "1.1.0",
"sinkKinds": [
"db",
"fs",
Expand Down Expand Up @@ -58,5 +58,15 @@
"server",
"route-param",
"unknown"
],
"invocationKinds": [
"call",
"member",
"construct"
],
"invocationResolutions": [
"direct",
"factory",
"reexport"
]
}
2 changes: 2 additions & 0 deletions scripts/emit-capabilities.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ const manifest = {
autoPromotableConfidence: stringOf('AUTO_PROMOTABLE_CONFIDENCE'),
attributions: arrayOf('ATTRIBUTIONS'),
addressSpaces: arrayOf('ADDRESS_SPACES'),
invocationKinds: arrayOf('INVOCATION_KINDS'),
invocationResolutions: arrayOf('INVOCATION_RESOLUTIONS'),
};

const out = join(root, 'capabilities.json');
Expand Down
16 changes: 16 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,22 @@ async function runMap(args: ParsedArgs): Promise<number> {
`established at ("exact-local" and "transformed-local" are proven; "imported", "heuristic" and ` +
`"unknown" are not).`,
);
const invoked = map.apiInvocations ?? [];
if (invoked.length > 0) {
const c = map.coverage as unknown as Record<string, number>;
const dependency = c.callsDependency ?? 0;
const ambiguous = c.callsAmbiguous ?? 0;
// Resolver quality, NOT "share of all calls": local helpers are excluded from both terms, because
// declining to attribute `res.json()` to a package is a correct answer rather than a miss.
const denominator = dependency + ambiguous;
const quality = denominator > 0 ? Math.round((100 * dependency) / denominator) : 100;
console.error(
`patchstack: ${invoked.length} dependency API call(s) resolved across ${new Set(invoked.map((i) => i.package)).size} package(s) ` +
`from ${c.callsTotal ?? 0} call site(s) — ${quality}% of dependency-candidate receivers resolved ` +
`(${c.callsLocal ?? 0} local, ${ambiguous} ambiguous). Positive evidence only: absence here never ` +
`means an API is not called.`,
);
}
const imported = map.imports ?? [];
if (imported.length > 0) {
// The unmodelled count is the honest headline: it is how much of the dependency surface this map
Expand Down
28 changes: 24 additions & 4 deletions src/map/bindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,15 @@ export interface Bindings {
resolve(name: string): string | undefined;
/** For `import { saveOrder as write }`, maps the local name back to the EXPORTED name. */
exportNameOf(name: string): string | undefined;
/**
* HOW a name reached its package: `direct` from an import/require declaration, `factory` through a
* traced call chain (`const db = createClient(...)`, `const conn = pool.promise()`).
*
* Recorded rather than inferred because the API inventory reports it as evidence: "this call is on a
* value we followed through a factory" is a weaker claim than "this call is on an imported binding",
* and a consumer deciding what to act on needs to tell them apart.
*/
originOf(name: string): 'direct' | 'factory' | undefined;
imports: Set<string>;
locals: Set<string>;
}
Expand All @@ -23,8 +32,13 @@ export function buildModuleBindings(sf: any, ts: TsModule): Bindings {
const declared = new Set<string>(); // every name declared in this file
const exportNames = new Map<string, string>(); // local alias → exported name
const imports = new Set<string>();
const origins = new Map<string, 'direct' | 'factory'>();

const record = (local: string, mod: string) => { nameToModule.set(local, mod); imports.add(mod); };
const record = (local: string, mod: string, origin: 'direct' | 'factory' = 'direct') => {
nameToModule.set(local, mod);
imports.add(mod);
if (!origins.has(local)) origins.set(local, origin);
};
const declareBound = (nameNode: any) => {
if (ts.isIdentifier(nameNode)) declared.add(nameNode.text);
else if (ts.isObjectBindingPattern(nameNode) || ts.isArrayBindingPattern(nameNode)) {
Expand Down Expand Up @@ -69,7 +83,7 @@ export function buildModuleBindings(sf: any, ts: TsModule): Bindings {
const root = callee ? rootIdentifier(callee, ts) : undefined;
if (root) {
pending.push([decl.name.text, root]);
if (nameToModule.has(root)) record(decl.name.text, nameToModule.get(root)!);
if (nameToModule.has(root)) record(decl.name.text, nameToModule.get(root)!, 'factory');
}
}
}
Expand All @@ -96,12 +110,18 @@ export function buildModuleBindings(sf: any, ts: TsModule): Bindings {
for (let i = 0; i < 4; i++) {
let changed = false;
for (const [name, root] of pending) {
if (!nameToModule.has(name) && nameToModule.has(root)) { record(name, nameToModule.get(root)!); changed = true; }
if (!nameToModule.has(name) && nameToModule.has(root)) { record(name, nameToModule.get(root)!, 'factory'); changed = true; }
}
if (!changed) break;
}
const locals = new Set([...declared].filter((n) => !nameToModule.has(n)));
return { resolve: (name: string) => nameToModule.get(name), exportNameOf: (name: string) => exportNames.get(name), imports, locals };
return {
resolve: (name: string) => nameToModule.get(name),
exportNameOf: (name: string) => exportNames.get(name),
originOf: (name: string) => origins.get(name),
imports,
locals,
};
}

// Root identifier of what a function body returns (`return createClient(…)` → "createClient"), for
Expand Down
20 changes: 19 additions & 1 deletion src/map/capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
* member is breaking and bumps the MAJOR, because a consumer pinned to the old list will keep emitting a
* value that can no longer match.
*/
export const CAPABILITY_VERSION = '1.0.0';
export const CAPABILITY_VERSION = '1.1.0';

/** Sink families the extractor recognizes. A dangerous OPERATION, not a package. */
export const SINK_KINDS = ['db', 'fs', 'http', 'exec', 'eval'] as const;
Expand Down Expand Up @@ -57,6 +57,22 @@ export const PROVEN_CONFIDENCE_TIERS = ['exact-local', 'transformed-local'] as c
/** The single tier eligible for automatic promotion to blocking (subject to the server's own gates). */
export const AUTO_PROMOTABLE_CONFIDENCE = 'exact-local';

/**
* Shapes of a recognized call to a dependency's API, for the invocation inventory. Narrower than it looks:
* these are the three syntactic forms whose RECEIVER we can resolve, not a taxonomy of call syntax.
*/
export const INVOCATION_KINDS = ['call', 'member', 'construct'] as const;

/**
* How the value being called was traced back to its package — evidence, not decoration. `direct` is an
* imported binding; `factory` followed a traced call chain (`const db = createClient(...)`); `reexport`
* crossed one hop into a local module that re-exports a dependency value.
*
* A consumer weighing whether to act on an invocation needs this: "called on an imported binding" and
* "called on something we followed two steps" are different strengths of the same claim.
*/
export const INVOCATION_RESOLUTIONS = ['direct', 'factory', 'reexport'] as const;

/** How a sink's package was established. Absent attribution is deliberately not a member: see `Sink`. */
export const ATTRIBUTIONS = ['import', 'global', 'inferred'] as const;

Expand All @@ -74,6 +90,8 @@ export const CAPABILITY_MANIFEST = {
autoPromotableConfidence: AUTO_PROMOTABLE_CONFIDENCE,
attributions: ATTRIBUTIONS,
addressSpaces: ADDRESS_SPACES,
invocationKinds: INVOCATION_KINDS,
invocationResolutions: INVOCATION_RESOLUTIONS,
} as const;

/**
Expand Down
60 changes: 60 additions & 0 deletions src/map/extract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { createModuleGraph } from './module-graph.js';
import { isProvenFlow } from './coordinates.js';
import { extractFromFile } from './entries.js';
import { collectFileImports, createImportInventory, readPathAliases, scanFileImports } from './imports.js';
import { collectInvocations, createInvocationInventory } from './invocations.js';

// Framework-AGNOSTIC input-flow extractor. It doesn't gate on a specific stack — it walks any JS/TS
// source and applies recognizer tables for (1) entry points, (2) inputs, (3) sinks, so it generalizes
Expand Down Expand Up @@ -40,14 +41,19 @@ export async function extractInputMap(cwd: string, ts: TsModule, options: Extrac
const stats: WalkStats = { discovered: 0, unwalked: 0 };
const files = collectSources(cwd, boundary, { followOutside: options.followSymlinks }, [], new Set(), stats);
const imports = createImportInventory(readPathAliases(cwd));
const invocations = createInvocationInventory();
let parsed = 0;
let preFiltered = 0;
let importScanFailures = 0;
let sourceBytes = 0;
const calls = { total: 0, dependency: 0, local: 0, ambiguous: 0 };
const startedAt = Date.now();

for (const file of files) {
try {
const text = readFileSync(file, 'utf8');
const relFile = relative(cwd, file);
sourceBytes += text.length;
// Imports are collected from EVERY file, entry point or not: the data layer of an AI-built app
// usually lives in a file with no handler in it, so a pre-filtered file is exactly where the
// interesting dependency is imported.
Expand All @@ -66,6 +72,14 @@ export async function extractInputMap(cwd: string, ts: TsModule, options: Extrac
imports.add(relFile, collectFileImports(sf, ts), true);
const ctx = { file, owner: relFile, graph };
// The ctx reaches helper summaries too, so a same-file helper using an imported client resolves.
// The invocation inventory rides the parse we already did for sinks — no second pass, which is what
// makes it cheap enough to ship before deciding whether to parse more files.
const called = collectInvocations(sf, ts, bindings, ctx);
invocations.add(called.invocations);
calls.total += called.counts.total;
calls.dependency += called.counts.dependency;
calls.local += called.counts.local;
calls.ambiguous += called.counts.ambiguous;
const localSinks = collectLocalSinks(sf, ts, bindings, ctx);
for (const ep of extractFromFile(sf, ts, localSinks, bindings, ctx)) {
// A FILE-BASED route handler carries its URL path in its location, not in the code, so derive
Expand Down Expand Up @@ -124,11 +138,38 @@ export async function extractInputMap(cwd: string, ts: TsModule, options: Extrac
notes.push(`The import inventory is INCOMPLETE: ${failed.length} file(s) could not be read, ${importScanFailures} could not be scanned, and ${stats.unwalked} path(s) could not be walked at all (unreadable directory, broken link, or a symlink leaving the project). A package may therefore be imported without appearing in \`imports\`. Do not read a package's absence as "not imported" while coverage.importsComplete is false.`);
}

const invocationList = invocations.list();
if (invocationList.length > 0 || parsed > 0) {
notes.push(`\`apiInvocations\` records ${invocationList.length} dependency API call(s) resolved from the ${parsed} parsed file(s). POSITIVE EVIDENCE ONLY: a package missing from it may still have its API called — see coverage.apiInventoryLimitations.`);
}

// Node-only and best-effort: the map runs in a build, but the runtime this file belongs to must stay
// edge-safe, so nothing here may assume `process`.
//
// Two numbers, because the obvious one is not the one a performance decision needs. `memoryUsage().rss`
// is the resident size AT THIS MOMENT — after extraction, with the walk's garbage possibly already
// collected — so calling it a peak would overstate what it measures. `resourceUsage().maxRSS` is a real
// high-water mark, but for the whole process (it includes loading TypeScript), so it is an upper bound on
// the cost of running `map` rather than extraction's own peak. Both are reported and both are labelled
// for what they are.
let rssBytes;
let peakRssBytes;
try {
if (typeof process !== 'undefined') {
if (typeof process.memoryUsage === 'function') rssBytes = process.memoryUsage().rss;
if (typeof process.resourceUsage === 'function') {
const maxRssKb = process.resourceUsage().maxRSS; // kilobytes, per Node's docs
if (typeof maxRssKb === 'number' && maxRssKb > 0) peakRssBytes = maxRssKb * 1024;
}
}
} catch { /* not available */ }

return {
version: 3,
framework: detectFramework(cwd),
endpoints,
imports: importList,
apiInvocations: invocationList,
coverage: {
adapter: 'agnostic-v1',
filesDiscovered: stats.discovered,
Expand All @@ -137,6 +178,25 @@ export async function extractInputMap(cwd: string, ts: TsModule, options: Extrac
filesSkipped: failed.length,
pathsUnwalked: stats.unwalked,
importsComplete,
apiInvocations: invocationList.length,
callsTotal: calls.total,
callsDependency: calls.dependency,
callsLocal: calls.local,
callsAmbiguous: calls.ambiguous,
sourceBytes,
analysisMs: Date.now() - startedAt,
...(rssBytes !== undefined ? { rssBytes } : {}),
...(peakRssBytes !== undefined ? { peakRssBytes } : {}),
// The list IS the partiality statement. There is deliberately no `apiInventoryComplete`: parsing
// every file would raise recall without removing any of these, so no parsing budget could make an
// absent invocation mean "not called".
apiInventoryLimitations: [
'only files carrying an entry-point signal are parsed, so a call in any other file is unseen',
'a computed callee or property (`client[name]()`) cannot be resolved to an API',
'a dynamic import()/require() with a non-literal specifier is not traced',
'reflection, generated code, and syntax that fails to parse are invisible',
'a receiver reached through more than one local hop, or through dependency injection, is not followed',
],
roots: ['.'],
notes,
},
Expand Down
Loading
Loading