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
31 changes: 30 additions & 1 deletion src/map/extract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,20 @@ import { relative } from 'node:path';
import type { SiteInputMap, Endpoint, TsModule } from './types.js';
import { guessScriptKind } from './ast.js';
import { buildModuleBindings } from './bindings.js';
import { collectSources, detectDeploymentShapes, detectFramework, hasEntrySignal, type WalkStats } from './sources.js';
import { collectSources, componentScript, detectDeploymentShapes, detectFramework, hasEntrySignal, isComponentFile, type WalkStats } from './sources.js';
import { classifyServerSurface, surfaceNote } from './surface.js';
import { functionNameFromPath, routeFromFilePath } from './routes.js';
import { collectLocalSinks } from './sinks.js';
import { boundUnprovenFlows } from './flows.js';
import { createModuleGraph } from './module-graph.js';
import { isProvenFlow } from './coordinates.js';
import { extractFromFile } from './entries.js';
import { collectFileImports, countUnresolvableImports, createImportInventory, readPathAliases, scanFileImports } from './imports.js';
import { collectInvocations, createInvocationInventory } from './invocations.js';

const MAX_DEPENDENCY_INPUT_FLOWS_PER_MAP = 500;
// Unproven flows across the whole map; see `boundUnprovenFlows`.
const MAX_UNPROVEN_FLOWS_PER_MAP = 1000;

// 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 @@ -46,9 +49,11 @@ export async function extractInputMap(cwd: string, ts: TsModule, options: Extrac
const imports = createImportInventory(readPathAliases(cwd));
const invocations = createInvocationInventory();
let dependencyInputFlowCount = 0;
let unprovenFlowCount = 0;
let parsed = 0;
let preFiltered = 0;
let importScanFailures = 0;
let componentFiles = 0;
let unresolvableImports = 0;
let sourceBytes = 0;
const calls = { total: 0, dependency: 0, local: 0, ambiguous: 0 };
Expand All @@ -62,6 +67,17 @@ export async function extractInputMap(cwd: string, ts: TsModule, options: Extrac
const text = readFileSync(file, 'utf8');
const relFile = relative(cwd, file);
sourceBytes += text.length;
// A single-file component contributes its imports, not endpoints: only its script is JavaScript.
if (isComponentFile(file)) {
componentFiles++;
preFiltered++;
const script = componentScript(text, file);
const scanned = script === null ? null : scanFileImports(script, ts);
if (scanned === null) importScanFailures++;
else imports.add(relFile, scanned, false);
if (script !== null) unresolvableImports += countUnresolvableImports(script, ts);
continue;
}
// 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 Down Expand Up @@ -108,6 +124,12 @@ export async function extractInputMap(cwd: string, ts: TsModule, options: Extrac
ep.dependencyInputFlowsTruncated = true;
}
dependencyInputFlowCount += ep.dependencyInputFlows?.length ?? 0;
const bounded = boundUnprovenFlows(ep.flows, Math.max(0, MAX_UNPROVEN_FLOWS_PER_MAP - unprovenFlowCount));
if (bounded.truncated) {
ep.flows = bounded.flows;
ep.flowsTruncated = true;
}
unprovenFlowCount += ep.flows.filter((f) => !isProvenFlow(f.confidence)).length;
// A FILE-BASED route handler carries its URL path in its location, not in the code, so derive
// it here — without this a rule can only be param-pinned, never route-scoped (`when.path`).
if (ep.route === undefined && ep.entryKind === 'edge-function') {
Expand Down Expand Up @@ -184,13 +206,20 @@ export async function extractInputMap(cwd: string, ts: TsModule, options: Extrac
if (truncatedDependencyFlows > 0) {
notes.push(`${truncatedDependencyFlows} endpoint(s) had more dependency-input links than the bounded map carries; those endpoint records are marked dependencyInputFlowsTruncated.`);
}
const truncatedFlows = endpoints.filter((e) => e.flowsTruncated).length;
if (truncatedFlows > 0) {
notes.push(`${truncatedFlows} endpoint(s) had more unproven flows than the bounded map carries; those endpoint records are marked flowsTruncated. Every proven flow is kept.`);
}
if (unresolved > 0) {
notes.push(`${unresolved} endpoint(s) declare an input validator that could not be statically parsed — their inputs are UNKNOWN, not empty (marked inputsResolved: false).`);
}
const heuristicOnly = endpoints.filter((e) => e.sinks.length > 0 && e.inputs.length > 0 && !e.flows.some((f) => isProvenFlow(f.confidence))).length;
if (heuristicOnly > 0) {
notes.push(`${heuristicOnly} endpoint(s) have inputs and sinks but no proven data link — their flows say "may reach", not "does reach" (see each flow's confidence).`);
}
if (componentFiles > 0) {
notes.push(`${componentFiles} single-file component(s) (.vue, .svelte, .astro) were scanned for imports only. Code in their script blocks and Astro frontmatter can run on the server, but it is not analyzed for endpoints, inputs or sinks.`);
}
if (endpoints.length === 0) notes.push('No recognized server-side entry points found under the analyzed roots.');

const importList = imports.list();
Expand Down
34 changes: 31 additions & 3 deletions src/map/flows.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,36 @@
import type { AddressSpace, ApiInvocation, ArgumentRole, DependencyInputFlow, Flow, InputField, Limitation, Sink, TsModule } from './types.js';
import { bindingKey, calleeName, isValueRead, lineOf, rootIdentifier } from './ast.js';
import { REQ_SOURCES } from './inputs.js';
import { addressSpaceOf } from './coordinates.js';
import { addressSpaceOf, isProvenFlow } from './coordinates.js';
import { argumentRoleOf, CANDIDATE_FAMILIES } from './sinks.js';

const MAX_DEPENDENCY_INPUT_FLOWS_PER_ENDPOINT = 100;
/**
* Unproven flows are an inputs × sinks cross-product, so an endpoint with many fields and many sinks
* produces a map too large to ingest. Proven flows are not bounded: they are the evidence rules are
* built from, and each one stands for a read the code actually makes.
*/
const MAX_UNPROVEN_FLOWS_PER_ENDPOINT = 200;

/**
* Keep every proven flow and at most `limit` unproven ones, in their original order. `truncated` says
* whether any unproven flow was left out.
*/
export function boundUnprovenFlows(flows: Flow[], limit: number): { flows: Flow[]; truncated: boolean } {
let unproven = 0;
let truncated = false;
const kept = flows.filter((f) => {
if (isProvenFlow(f.confidence)) return true;
if (unproven >= limit) {
truncated = true;
return false;
}
unproven++;
return true;
});

return { flows: kept, truncated };
}

/** A tainted binding: the path prefix it stands for, and the request region it came from if known. */
/**
Expand Down Expand Up @@ -54,10 +80,12 @@ function spaceOfKey(key: string | undefined): AddressSpace | undefined {
// the rest as "may reach". Matching is per (address space, path): a read of `query.id` is not evidence
// about the body field `id`.
// Spread onto an endpoint: `flows`, plus `limitations` only when there are any (keeps the common case clean).
export function linkedFlows(body: any, params: any, inputs: InputField[], sinks: Sink[], ts: TsModule, invocations: ApiInvocation[] = []): { flows: Flow[]; limitations?: Limitation[]; dependencyInputFlows?: DependencyInputFlow[]; dependencyInputFlowsTruncated?: true } {
const { flows, limitations, dependencyInputFlows, dependencyInputFlowsTruncated } = linkFlows(body, params, inputs, sinks, ts, invocations);
export function linkedFlows(body: any, params: any, inputs: InputField[], sinks: Sink[], ts: TsModule, invocations: ApiInvocation[] = []): { flows: Flow[]; flowsTruncated?: true; limitations?: Limitation[]; dependencyInputFlows?: DependencyInputFlow[]; dependencyInputFlowsTruncated?: true } {
const { flows: linked, limitations, dependencyInputFlows, dependencyInputFlowsTruncated } = linkFlows(body, params, inputs, sinks, ts, invocations);
const { flows, truncated } = boundUnprovenFlows(linked, MAX_UNPROVEN_FLOWS_PER_ENDPOINT);
return {
flows,
...(truncated ? { flowsTruncated: true as const } : {}),
...(limitations.length > 0 ? { limitations } : {}),
...(dependencyInputFlows.length > 0 ? { dependencyInputFlows } : {}),
...(dependencyInputFlowsTruncated ? { dependencyInputFlowsTruncated: true as const } : {}),
Expand Down
41 changes: 40 additions & 1 deletion src/map/sources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,46 @@ export function detectFramework(cwd: string): string {
return 'unknown';
}

const isSourceFile = (name: string) => /\.(ts|tsx|mts|cts|js|jsx|mjs|cjs)$/.test(name) && !name.endsWith('.d.ts');
// Single-file components are source too: their script blocks import packages, and Astro frontmatter and
// SvelteKit/Nuxt component scripts can run on the server. They are scanned for imports only.
const isSourceFile = (name: string) => (/\.(ts|tsx|mts|cts|js|jsx|mjs|cjs)$/.test(name) && !name.endsWith('.d.ts')) || isComponentFile(name);

/** A `.vue`, `.svelte` or `.astro` single-file component. */
export const isComponentFile = (name: string) => /\.(vue|svelte|astro)$/.test(name);

/**
* The script of a single-file component, with everything else blanked to spaces so offsets and line
* numbers still point into the original file: every `<script>` block, plus an Astro file's `---`
* frontmatter. Null when the component cannot be read reliably — an unterminated `<script>` or
* frontmatter fence — because its imports are then unknown rather than absent.
*/
export function componentScript(text: string, file: string): string | null {
const keep: Array<[number, number]> = [];
if (file.endsWith('.astro')) {
const open = /^?\s*---[^\S\r\n]*\r?\n/.exec(text);
if (open) {
const start = open[0].length;
const close = /\r?\n---[^\S\r\n]*(\r?\n|$)/g;
close.lastIndex = start - 1;
const end = close.exec(text);
if (!end) return null;
if (end.index > start) keep.push([start, end.index]);
}
}
const opening = /<script\b[^>]*>/gi;
for (let m = opening.exec(text); m; m = opening.exec(text)) {
const start = m.index + m[0].length;
const closing = /<\/script\s*>/gi;
closing.lastIndex = start;
const end = closing.exec(text);
if (!end) return null;
keep.push([start, end.index]);
opening.lastIndex = end.index + end[0].length;
}
let out = text.replace(/[^\r\n]/g, ' ');
for (const [start, end] of keep) out = out.slice(0, start) + text.slice(start, end) + out.slice(end);
return out;
}

// Directories that never hold app source, so walking the whole project stays cheap. (We walk the whole
// project rather than `src` only: server entrypoints, route dirs and platform function dirs commonly
Expand Down
5 changes: 5 additions & 0 deletions src/map/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,11 @@ export interface Endpoint {
* consumer should use to pin a rule to a parameter. Empty when no link could be established.
*/
flows: Flow[];
/**
* More unproven flows (not `exact-local` / `transformed-local`) existed than the bounded map carries,
* and some were left out. Proven flows are never left out.
*/
flowsTruncated?: true;
/** Request inputs observed in arguments of dependency API calls. Positive evidence only: the call
* is not a modeled dangerous sink, and this field alone never authorizes an enforcing rule. */
dependencyInputFlows?: DependencyInputFlow[];
Expand Down
111 changes: 111 additions & 0 deletions tests/map/component-imports.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { describe, expect, it } from 'vitest';
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { buildInputMap } from '../../src/map/index.js';
import { componentScript } from '../../src/map/sources.js';

/**
* Single-file components import packages like any other module, and some of that code runs on the
* server. The import inventory must see those imports, or report that it could not; an inventory that
* never looked at them cannot claim to be complete.
*/
async function mapOf(files: Record<string, string>) {
const dir = mkdtempSync(path.join(tmpdir(), 'ps-components-'));
try {
writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name: 'x', dependencies: { 'sample-lib': '1', 'other-lib': '1' } }));
for (const [file, source] of Object.entries(files)) {
mkdirSync(path.join(dir, path.dirname(file)), { recursive: true });
writeFileSync(path.join(dir, file), source);
}
const { map } = await buildInputMap(dir, {});

return map!;
} finally {
rmSync(dir, { recursive: true, force: true });
}
}

const packages = (map: any): string[] => map.imports.map((i: any) => i.package).sort();
const site = (map: any, pkg: string) => map.imports.find((i: any) => i.package === pkg)?.sites?.[0];

describe('single-file components in the import inventory', () => {
it.each([
['src/components/Panel.vue', '<template><div /></template>\n<script setup lang="ts">\nimport { format } from \'sample-lib\';\n</script>\n'],
['src/components/Panel.svelte', '<script context="module">\nimport { format } from \'sample-lib\';\n</script>\n<div />\n'],
['src/pages/panel.astro', "---\nimport { format } from 'sample-lib';\nconst value = format(Astro.url);\n---\n<div>{value}</div>\n"],
])('records the imports of %s and stays complete', async (file, source) => {
const map = await mapOf({ [file]: source });

expect(packages(map)).toEqual(['sample-lib']);
expect(map.coverage.importsComplete).toBe(true);
});

it('reads every script block of a component', async () => {
const map = await mapOf({
'src/App.vue': "<script>\nimport a from 'sample-lib';\n</script>\n<script setup>\nimport b from 'other-lib';\n</script>\n",
});

expect(packages(map)).toEqual(['other-lib', 'sample-lib']);
});

it('reports lines in the component file', async () => {
const map = await mapOf({ 'src/App.vue': "<template>\n <div />\n</template>\n<script>\nimport a from 'sample-lib';\n</script>\n" });

expect(site(map, 'sample-lib')).toMatchObject({ file: path.join('src', 'App.vue'), line: 5 });
});

it('does not read imports written outside a script block', async () => {
const map = await mapOf({ 'src/App.vue': "<template>\n <p>import x from 'sample-lib'</p>\n</template>\n<script>\nexport default {};\n</script>\n" });

expect(packages(map)).toEqual([]);
});

it('marks the inventory incomplete when a component script cannot be delimited', async () => {
const map = await mapOf({ 'src/App.vue': "<script>\nimport a from 'sample-lib';\n" });

expect(map.coverage.importsComplete).toBe(false);
expect(map.coverage.importCoverageGaps.unscannableFiles).toBe(1);
});

it('marks the inventory incomplete for unterminated Astro frontmatter', async () => {
const map = await mapOf({ 'src/pages/x.astro': "---\nimport a from 'sample-lib';\n<div />\n" });

expect(map.coverage.importsComplete).toBe(false);
});

it('counts a computed import in a component script against completeness', async () => {
const map = await mapOf({ 'src/App.svelte': '<script>\nconst mod = await import(name);\n</script>\n' });

expect(map.coverage.importCoverageGaps.unresolvableImports).toBe(1);
expect(map.coverage.importsComplete).toBe(false);
});

it('notes that components are scanned for imports only', async () => {
const map = await mapOf({ 'src/App.vue': "<script>\nimport a from 'sample-lib';\n</script>\n" });

expect(map.coverage.notes.join(' ')).toMatch(/1 single-file component\(s\).*imports only/);
});

it('treats a skipped directory holding only components as holding source', async () => {
const map = await mapOf({ 'vendor/widgets/Panel.vue': "<script>\nimport a from 'sample-lib';\n</script>\n" });

expect(map.coverage.importsComplete).toBe(false);
expect(map.coverage.importCoverageGaps.skippedDirsWithSource).toEqual(['vendor']);
});
});

describe('component script extraction', () => {
it('keeps offsets aligned with the original file', () => {
const text = '<template>x</template>\n<script>\nimport a from "sample-lib";\n</script>\n';
const script = componentScript(text, 'App.vue')!;

expect(script).toHaveLength(text.length);
expect(script.indexOf('import a')).toBe(text.indexOf('import a'));
expect(script).not.toContain('template');
});

it('keeps empty Astro frontmatter readable', () => {
expect(componentScript('---\n---\n<div />\n', 'x.astro')).not.toBeNull();
});
});
Loading
Loading