auditor-lambda 0.3.41 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/dist/cli/dispatch.js +5 -1
  2. package/dist/cli/prompts.d.ts +19 -0
  3. package/dist/cli/prompts.js +95 -0
  4. package/dist/cli/steps.d.ts +1 -1
  5. package/dist/cli.js +398 -78
  6. package/dist/extractors/analyzers/css.d.ts +2 -0
  7. package/dist/extractors/analyzers/css.js +101 -0
  8. package/dist/extractors/analyzers/html.d.ts +2 -0
  9. package/dist/extractors/analyzers/html.js +92 -0
  10. package/dist/extractors/analyzers/merge.d.ts +14 -0
  11. package/dist/extractors/analyzers/merge.js +85 -0
  12. package/dist/extractors/analyzers/python.d.ts +2 -0
  13. package/dist/extractors/analyzers/python.js +104 -0
  14. package/dist/extractors/analyzers/registry.d.ts +33 -0
  15. package/dist/extractors/analyzers/registry.js +100 -0
  16. package/dist/extractors/analyzers/resourceUrl.d.ts +7 -0
  17. package/dist/extractors/analyzers/resourceUrl.js +25 -0
  18. package/dist/extractors/analyzers/sql.d.ts +2 -0
  19. package/dist/extractors/analyzers/sql.js +19 -0
  20. package/dist/extractors/analyzers/treeSitter.d.ts +34 -0
  21. package/dist/extractors/analyzers/treeSitter.js +111 -0
  22. package/dist/extractors/analyzers/types.d.ts +53 -0
  23. package/dist/extractors/analyzers/types.js +1 -0
  24. package/dist/extractors/analyzers/typescript.d.ts +2 -0
  25. package/dist/extractors/analyzers/typescript.js +257 -0
  26. package/dist/extractors/disposition.js +8 -1
  27. package/dist/extractors/graph.d.ts +1 -0
  28. package/dist/extractors/graph.js +167 -1
  29. package/dist/extractors/graphPythonImports.d.ts +15 -0
  30. package/dist/extractors/graphPythonImports.js +36 -0
  31. package/dist/extractors/pathPatterns.d.ts +6 -0
  32. package/dist/extractors/pathPatterns.js +8 -0
  33. package/dist/io/artifacts.d.ts +13 -1
  34. package/dist/io/artifacts.js +19 -3
  35. package/dist/mcp/server.js +3 -3
  36. package/dist/orchestrator/advance.d.ts +20 -0
  37. package/dist/orchestrator/advance.js +61 -2
  38. package/dist/orchestrator/dependencyMap.js +27 -0
  39. package/dist/orchestrator/edgeReasoning.d.ts +39 -0
  40. package/dist/orchestrator/edgeReasoning.js +125 -0
  41. package/dist/orchestrator/executors.js +11 -1
  42. package/dist/orchestrator/graphEnrichmentExecutor.d.ts +29 -0
  43. package/dist/orchestrator/graphEnrichmentExecutor.js +196 -0
  44. package/dist/orchestrator/internalExecutors.d.ts +10 -1
  45. package/dist/orchestrator/internalExecutors.js +89 -11
  46. package/dist/orchestrator/localCommands.js +6 -25
  47. package/dist/orchestrator/nextStep.js +2 -0
  48. package/dist/orchestrator/reviewPackets.d.ts +37 -4
  49. package/dist/orchestrator/reviewPackets.js +93 -46
  50. package/dist/orchestrator/runtimeValidation.js +4 -31
  51. package/dist/orchestrator/scope.d.ts +62 -0
  52. package/dist/orchestrator/scope.js +227 -0
  53. package/dist/orchestrator/state.js +2 -0
  54. package/dist/reporting/synthesis.d.ts +37 -2
  55. package/dist/reporting/synthesis.js +95 -16
  56. package/dist/reporting/synthesisNarrativePrompt.d.ts +7 -0
  57. package/dist/reporting/synthesisNarrativePrompt.js +60 -0
  58. package/dist/reporting/workBlocks.d.ts +2 -10
  59. package/dist/supervisor/operatorHandoff.d.ts +1 -1
  60. package/dist/supervisor/operatorHandoff.js +26 -16
  61. package/dist/supervisor/sessionConfig.d.ts +8 -1
  62. package/dist/supervisor/sessionConfig.js +22 -1
  63. package/dist/types/analyzerCapability.d.ts +16 -0
  64. package/dist/types/analyzerCapability.js +1 -0
  65. package/dist/types/auditScope.d.ts +43 -0
  66. package/dist/types/auditScope.js +14 -0
  67. package/dist/types/synthesisNarrative.d.ts +7 -0
  68. package/dist/types/synthesisNarrative.js +5 -0
  69. package/dist/types.d.ts +2 -19
  70. package/dist/validation/artifacts.js +9 -0
  71. package/dist/validation/sessionConfig.js +24 -1
  72. package/docs/contracts.md +10 -3
  73. package/package.json +4 -2
  74. package/schemas/analyzer_capability.schema.json +47 -0
  75. package/schemas/audit_findings.schema.json +141 -0
  76. package/schemas/finding.schema.json +2 -1
  77. package/schemas/graph_bundle.schema.json +5 -0
  78. package/schemas/scope.schema.json +46 -0
@@ -0,0 +1,111 @@
1
+ import { createRequire } from "node:module";
2
+ import { dirname, join } from "node:path";
3
+ import { pathToFileURL } from "node:url";
4
+ const requireFromHere = createRequire(import.meta.url);
5
+ let modulePromise;
6
+ let initPromise;
7
+ const languageCache = new Map();
8
+ async function importParserModule(dependencyPath) {
9
+ const specifiers = [];
10
+ if (dependencyPath) {
11
+ try {
12
+ const manifest = requireFromHere(join(dependencyPath, "package.json"));
13
+ const entry = manifest.module ?? manifest.main ?? "index.js";
14
+ specifiers.push(pathToFileURL(join(dependencyPath, entry)).href);
15
+ }
16
+ catch {
17
+ // Fall through to the bare specifier.
18
+ }
19
+ }
20
+ specifiers.push("web-tree-sitter");
21
+ for (const specifier of specifiers) {
22
+ try {
23
+ const mod = (await import(specifier));
24
+ const resolved = (mod.Parser ? mod : mod.default);
25
+ if (resolved?.Parser && resolved.Language) {
26
+ return resolved;
27
+ }
28
+ }
29
+ catch {
30
+ // Try the next specifier.
31
+ }
32
+ }
33
+ return undefined;
34
+ }
35
+ async function getModule(dependencyPath) {
36
+ if (!modulePromise) {
37
+ modulePromise = importParserModule(dependencyPath);
38
+ }
39
+ return modulePromise;
40
+ }
41
+ async function ensureInit(parserModule) {
42
+ if (!initPromise) {
43
+ initPromise = parserModule.Parser.init()
44
+ .then(() => true)
45
+ .catch(() => false);
46
+ }
47
+ return initPromise;
48
+ }
49
+ function resolveGrammarPath(grammar) {
50
+ // tree-sitter-wasms ships prebuilt grammars under out/tree-sitter-<lang>.wasm.
51
+ try {
52
+ return requireFromHere.resolve(`tree-sitter-wasms/out/tree-sitter-${grammar}.wasm`);
53
+ }
54
+ catch {
55
+ // Fall back to locating the package root, then the file under out/.
56
+ }
57
+ try {
58
+ const pkg = requireFromHere.resolve("tree-sitter-wasms/package.json");
59
+ return join(dirname(pkg), "out", `tree-sitter-${grammar}.wasm`);
60
+ }
61
+ catch {
62
+ return undefined;
63
+ }
64
+ }
65
+ async function loadLanguage(parserModule, grammar) {
66
+ if (languageCache.has(grammar)) {
67
+ return languageCache.get(grammar) ?? undefined;
68
+ }
69
+ const grammarPath = resolveGrammarPath(grammar);
70
+ if (!grammarPath) {
71
+ languageCache.set(grammar, null);
72
+ return undefined;
73
+ }
74
+ try {
75
+ const language = await parserModule.Language.load(grammarPath);
76
+ languageCache.set(grammar, language);
77
+ return language;
78
+ }
79
+ catch {
80
+ languageCache.set(grammar, null);
81
+ return undefined;
82
+ }
83
+ }
84
+ /**
85
+ * Obtain a parser bound to `grammar` (e.g. "python", "html", "css"), or
86
+ * `undefined` if web-tree-sitter or the grammar wasm cannot be loaded.
87
+ */
88
+ export async function getTreeSitterParser(grammar, dependencyPath) {
89
+ const parserModule = await getModule(dependencyPath);
90
+ if (!parserModule)
91
+ return undefined;
92
+ if (!(await ensureInit(parserModule)))
93
+ return undefined;
94
+ const language = await loadLanguage(parserModule, grammar);
95
+ if (!language)
96
+ return undefined;
97
+ try {
98
+ const parser = new parserModule.Parser();
99
+ parser.setLanguage(language);
100
+ return parser;
101
+ }
102
+ catch {
103
+ return undefined;
104
+ }
105
+ }
106
+ /** Test seam: reset the memoised runtime/grammar caches. */
107
+ export function __resetTreeSitterForTests() {
108
+ modulePromise = undefined;
109
+ initPromise = undefined;
110
+ languageCache.clear();
111
+ }
@@ -0,0 +1,53 @@
1
+ import type { GraphEdge, RouteEdge, AnalyzerSetting } from "@audit-tools/shared";
2
+ import type { FileDisposition } from "@audit-tools/shared";
3
+ import type { RepoManifest } from "../../types.js";
4
+ /**
5
+ * The compiler/parser graph seam (Phase 5.0). A `LanguageAnalyzer` enriches the
6
+ * deterministic regex floor with edges derived from a real parser/compiler. Each
7
+ * analyzer is optional: its dependency resolves from the audited repo's
8
+ * node_modules, a shared version-keyed cache, or not at all — and when it cannot
9
+ * resolve, the orchestrator simply keeps the regex floor.
10
+ */
11
+ export interface AnalyzerOutput {
12
+ edges: GraphEdge[];
13
+ routes?: RouteEdge[];
14
+ }
15
+ export interface AnalyzerContext {
16
+ /** Absolute repository root. */
17
+ root: string;
18
+ repoManifest: RepoManifest;
19
+ disposition?: FileDisposition;
20
+ /** Repo-relative, audit-included file paths (the analyzer's working set). */
21
+ includedFiles: string[];
22
+ /** graphLookupKey(path) → repo-relative path, for resolving targets. */
23
+ pathLookup: Map<string, string>;
24
+ /** Resolved npm package directory for this analyzer's dependency, if any. */
25
+ dependencyPath?: string;
26
+ }
27
+ export interface LanguageAnalyzer {
28
+ /** Stable id; also the `analyzers.<id>` session-config key. */
29
+ id: string;
30
+ /** Optional npm dependency spec ("name" or "name@range") this analyzer needs. */
31
+ dependency?: string;
32
+ /** Whether this analyzer can contribute edges for the given repo-relative file. */
33
+ supports(file: string): boolean;
34
+ /** Analyze the supported subset of `files` and return enrichment edges/routes. */
35
+ analyze(files: string[], context: AnalyzerContext): Promise<AnalyzerOutput> | AnalyzerOutput;
36
+ }
37
+ /** How an analyzer's dependency resolved (or why it will not run). */
38
+ export type AnalyzerResolution = "repo" | "cache" | "installed" | "absent" | "skip" | "not_applicable";
39
+ /**
40
+ * Deterministic pre-install resolution for one analyzer. Computed without
41
+ * mutating anything (no install), so the conversation-first CLI can decide
42
+ * whether to propose an install before the executor runs.
43
+ */
44
+ export interface AnalyzerPlanEntry {
45
+ id: string;
46
+ dependency?: string;
47
+ setting: AnalyzerSetting;
48
+ resolution: AnalyzerResolution;
49
+ /** Resolved package directory when resolution is repo/cache. */
50
+ path?: string;
51
+ /** Count of in-scope files the analyzer supports. */
52
+ supportedCount: number;
53
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,2 @@
1
+ import type { LanguageAnalyzer } from "./types.js";
2
+ export declare const typescriptAnalyzer: LanguageAnalyzer;
@@ -0,0 +1,257 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { dirname, isAbsolute, join, relative, resolve } from "node:path";
3
+ import { pathToFileURL } from "node:url";
4
+ import { graphEdge, normalizeGraphPath, resolveCandidate } from "../graphPathUtils.js";
5
+ import { TS_CALL_EDGE_CONFIDENCE, TS_EXTENDS_EDGE_CONFIDENCE, TS_IMPLEMENTS_EDGE_CONFIDENCE, TS_IMPORT_EDGE_CONFIDENCE, TS_REEXPORT_EDGE_CONFIDENCE, } from "./merge.js";
6
+ const SUPPORTED_EXTENSIONS = [
7
+ ".ts",
8
+ ".tsx",
9
+ ".mts",
10
+ ".cts",
11
+ ".js",
12
+ ".jsx",
13
+ ".mjs",
14
+ ".cjs",
15
+ ];
16
+ function supports(file) {
17
+ const lower = normalizeGraphPath(file).toLowerCase();
18
+ if (lower.endsWith(".d.ts"))
19
+ return false;
20
+ return SUPPORTED_EXTENSIONS.some((extension) => lower.endsWith(extension));
21
+ }
22
+ /**
23
+ * Load the TypeScript compiler module. Prefers the dependency resolved from the
24
+ * audited repo / shared cache (so its tsconfig + version semantics match);
25
+ * falls back to the bundled `typescript` so the analyzer still works when the
26
+ * caller did not pin a path.
27
+ */
28
+ async function loadTypescript(dependencyPath) {
29
+ if (dependencyPath) {
30
+ try {
31
+ const manifest = JSON.parse(readFileSync(join(dependencyPath, "package.json"), "utf8"));
32
+ const mainPath = resolve(dependencyPath, manifest.main ?? "index.js");
33
+ const mod = (await import(pathToFileURL(mainPath).href));
34
+ return (mod.default ?? mod);
35
+ }
36
+ catch {
37
+ // Fall through to the bundled compiler.
38
+ }
39
+ }
40
+ const mod = (await import("typescript"));
41
+ return (mod.default ?? mod);
42
+ }
43
+ function loadCompilerOptions(ts, root) {
44
+ let options = {};
45
+ try {
46
+ const configPath = ts.findConfigFile(root, (path) => ts.sys.fileExists(path), "tsconfig.json");
47
+ if (configPath) {
48
+ const read = ts.readConfigFile(configPath, (path) => ts.sys.readFile(path));
49
+ const parsed = ts.parseJsonConfigFileContent(read.config ?? {}, ts.sys, dirname(configPath));
50
+ options = parsed.options;
51
+ }
52
+ }
53
+ catch {
54
+ options = {};
55
+ }
56
+ // Force a lenient, emit-free, JS-aware program: we only want resolution + the
57
+ // checker, never diagnostics or output.
58
+ return {
59
+ ...options,
60
+ allowJs: true,
61
+ checkJs: false,
62
+ noEmit: true,
63
+ skipLibCheck: true,
64
+ skipDefaultLibCheck: true,
65
+ declaration: false,
66
+ composite: false,
67
+ incremental: false,
68
+ };
69
+ }
70
+ /** Map an absolute file path back to its canonical audit-included repo path. */
71
+ function mapToIncluded(state, absolutePath) {
72
+ const normalizedAbsolute = isAbsolute(absolutePath)
73
+ ? absolutePath
74
+ : resolve(state.root, absolutePath);
75
+ const relativePath = normalizeGraphPath(relative(state.root, normalizedAbsolute));
76
+ if (relativePath.startsWith("..") || isAbsolute(relativePath)) {
77
+ return undefined;
78
+ }
79
+ return resolveCandidate(relativePath, state.context.pathLookup);
80
+ }
81
+ function resolveSpecifierTarget(state, specifier, containingFile) {
82
+ const resolved = state.ts.resolveModuleName(specifier, containingFile, state.options, state.ts.sys);
83
+ const target = resolved.resolvedModule;
84
+ if (!target ||
85
+ target.isExternalLibraryImport ||
86
+ target.resolvedFileName.endsWith(".d.ts")) {
87
+ return undefined;
88
+ }
89
+ return mapToIncluded(state, target.resolvedFileName);
90
+ }
91
+ /** Follow import aliases to the symbol's real declaration source file. */
92
+ function resolveSymbolToIncluded(state, symbol) {
93
+ if (!symbol)
94
+ return undefined;
95
+ let resolved = symbol;
96
+ if (resolved.flags & state.ts.SymbolFlags.Alias) {
97
+ try {
98
+ resolved = state.checker.getAliasedSymbol(resolved);
99
+ }
100
+ catch {
101
+ // Keep the un-aliased symbol on failure.
102
+ }
103
+ }
104
+ for (const declaration of resolved.declarations ?? []) {
105
+ const fileName = declaration.getSourceFile().fileName;
106
+ if (fileName.endsWith(".d.ts"))
107
+ continue;
108
+ const included = mapToIncluded(state, fileName);
109
+ if (included)
110
+ return included;
111
+ }
112
+ return undefined;
113
+ }
114
+ function collectFileEdges(state, sourceFile, fromPath, imports, references, calls) {
115
+ const ts = state.ts;
116
+ const callTargets = new Set();
117
+ const recordCall = (target) => {
118
+ if (!target || target === fromPath || callTargets.has(target))
119
+ return;
120
+ callTargets.add(target);
121
+ calls.push(graphEdge({
122
+ from: fromPath,
123
+ to: target,
124
+ kind: "ts-call",
125
+ confidence: TS_CALL_EDGE_CONFIDENCE,
126
+ reason: `TypeScript checker resolved a cross-file call into '${target}'.`,
127
+ }));
128
+ };
129
+ const visitHeritage = (node) => {
130
+ for (const clause of node.heritageClauses ?? []) {
131
+ const isExtends = clause.token === ts.SyntaxKind.ExtendsKeyword;
132
+ for (const typeNode of clause.types) {
133
+ const target = resolveSymbolToIncluded(state, state.checker.getSymbolAtLocation(typeNode.expression));
134
+ if (!target || target === fromPath)
135
+ continue;
136
+ references.push(graphEdge({
137
+ from: fromPath,
138
+ to: target,
139
+ kind: isExtends ? "ts-extends" : "ts-implements",
140
+ confidence: isExtends
141
+ ? TS_EXTENDS_EDGE_CONFIDENCE
142
+ : TS_IMPLEMENTS_EDGE_CONFIDENCE,
143
+ reason: `TypeScript ${isExtends ? "extends" : "implements"} heritage resolves to '${target}'.`,
144
+ }));
145
+ }
146
+ }
147
+ };
148
+ const visit = (node) => {
149
+ if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) {
150
+ const target = resolveSpecifierTarget(state, node.moduleSpecifier.text, sourceFile.fileName);
151
+ if (target && target !== fromPath) {
152
+ imports.push(graphEdge({
153
+ from: fromPath,
154
+ to: target,
155
+ kind: "ts-import",
156
+ confidence: TS_IMPORT_EDGE_CONFIDENCE,
157
+ reason: `TypeScript resolved import '${node.moduleSpecifier.text}' to '${target}'.`,
158
+ }));
159
+ }
160
+ }
161
+ else if (ts.isExportDeclaration(node) &&
162
+ node.moduleSpecifier &&
163
+ ts.isStringLiteral(node.moduleSpecifier)) {
164
+ const target = resolveSpecifierTarget(state, node.moduleSpecifier.text, sourceFile.fileName);
165
+ if (target && target !== fromPath) {
166
+ imports.push(graphEdge({
167
+ from: fromPath,
168
+ to: target,
169
+ kind: "ts-reexport",
170
+ confidence: TS_REEXPORT_EDGE_CONFIDENCE,
171
+ reason: `TypeScript resolved re-export '${node.moduleSpecifier.text}' to '${target}'.`,
172
+ }));
173
+ }
174
+ }
175
+ else if (ts.isImportEqualsDeclaration(node) &&
176
+ ts.isExternalModuleReference(node.moduleReference) &&
177
+ ts.isStringLiteral(node.moduleReference.expression)) {
178
+ const target = resolveSpecifierTarget(state, node.moduleReference.expression.text, sourceFile.fileName);
179
+ if (target && target !== fromPath) {
180
+ imports.push(graphEdge({
181
+ from: fromPath,
182
+ to: target,
183
+ kind: "ts-import",
184
+ confidence: TS_IMPORT_EDGE_CONFIDENCE,
185
+ reason: `TypeScript resolved import-equals to '${target}'.`,
186
+ }));
187
+ }
188
+ }
189
+ else if (ts.isCallExpression(node)) {
190
+ if (node.expression.kind === ts.SyntaxKind.ImportKeyword &&
191
+ node.arguments[0] &&
192
+ ts.isStringLiteral(node.arguments[0])) {
193
+ const target = resolveSpecifierTarget(state, node.arguments[0].text, sourceFile.fileName);
194
+ if (target && target !== fromPath) {
195
+ imports.push(graphEdge({
196
+ from: fromPath,
197
+ to: target,
198
+ kind: "ts-import",
199
+ confidence: TS_IMPORT_EDGE_CONFIDENCE,
200
+ reason: `TypeScript resolved dynamic import to '${target}'.`,
201
+ }));
202
+ }
203
+ }
204
+ else {
205
+ recordCall(resolveSymbolToIncluded(state, state.checker.getSymbolAtLocation(node.expression)));
206
+ }
207
+ }
208
+ else if (ts.isClassDeclaration(node) ||
209
+ ts.isClassExpression(node) ||
210
+ ts.isInterfaceDeclaration(node)) {
211
+ visitHeritage(node);
212
+ }
213
+ ts.forEachChild(node, visit);
214
+ };
215
+ ts.forEachChild(sourceFile, visit);
216
+ }
217
+ async function analyze(files, context) {
218
+ if (files.length === 0)
219
+ return { edges: [] };
220
+ let ts;
221
+ try {
222
+ ts = await loadTypescript(context.dependencyPath);
223
+ }
224
+ catch {
225
+ return { edges: [] };
226
+ }
227
+ try {
228
+ const root = resolve(context.root);
229
+ const options = loadCompilerOptions(ts, root);
230
+ const rootNames = files.map((file) => resolve(root, file));
231
+ const program = ts.createProgram({ rootNames, options });
232
+ const checker = program.getTypeChecker();
233
+ const state = { ts, options, checker, context, root };
234
+ const imports = [];
235
+ const references = [];
236
+ const calls = [];
237
+ for (const sourceFile of program.getSourceFiles()) {
238
+ if (sourceFile.isDeclarationFile)
239
+ continue;
240
+ const fromPath = mapToIncluded(state, sourceFile.fileName);
241
+ if (!fromPath)
242
+ continue;
243
+ collectFileEdges(state, sourceFile, fromPath, imports, references, calls);
244
+ }
245
+ return { edges: [...imports, ...references, ...calls] };
246
+ }
247
+ catch {
248
+ // Any compiler failure degrades cleanly to the regex floor.
249
+ return { edges: [] };
250
+ }
251
+ }
252
+ export const typescriptAnalyzer = {
253
+ id: "typescript",
254
+ dependency: "typescript@5",
255
+ supports,
256
+ analyze,
257
+ };
@@ -1,9 +1,16 @@
1
- import { isNodeModulesOrGit, isBuildOutput, isVendorPath, isBinaryArtifact, isLicensePath, isLockfilePath, isLogPath, isDocPath, isGeneratedPath, isAuditArtifactPath, isGeneratedTestArtifactPath, isGeneratedInstallArtifactPath, isExamplesOrFixturesPath, normalizeExtractorPath, } from "./pathPatterns.js";
1
+ import { isNodeModulesOrGit, isTmpPath, isBuildOutput, isVendorPath, isBinaryArtifact, isLicensePath, isLockfilePath, isLogPath, isDocPath, isGeneratedPath, isAuditArtifactPath, isGeneratedTestArtifactPath, isGeneratedInstallArtifactPath, isExamplesOrFixturesPath, normalizeExtractorPath, } from "./pathPatterns.js";
2
2
  function inferDisposition(path) {
3
3
  const normalized = normalizeExtractorPath(path);
4
4
  if (isNodeModulesOrGit(normalized)) {
5
5
  return { path, status: "excluded", reason: "node_modules or .git excluded by convention." };
6
6
  }
7
+ if (isTmpPath(normalized)) {
8
+ return {
9
+ path,
10
+ status: "excluded",
11
+ reason: "Temporary/bundled artifact directory (.tmp) excluded by convention.",
12
+ };
13
+ }
7
14
  if (isBuildOutput(normalized)) {
8
15
  return { path, status: "generated", reason: "Build output path." };
9
16
  }
@@ -5,5 +5,6 @@ export interface BuildGraphBundleOptions {
5
5
  fileContents?: Record<string, string>;
6
6
  externalAnalyzerResults?: ExternalAnalyzerResults;
7
7
  }
8
+ export declare function buildPathLookup(repoManifest: RepoManifest, dispositionMap: Map<string, FileDisposition["files"][number]["status"]>): Map<string, string>;
8
9
  export declare function buildGraphBundleFromFs(repoManifest: RepoManifest, root: string, disposition?: FileDisposition, options?: Pick<BuildGraphBundleOptions, "externalAnalyzerResults">): Promise<GraphBundle>;
9
10
  export declare function buildGraphBundle(repoManifest: RepoManifest, disposition?: FileDisposition, options?: BuildGraphBundleOptions): GraphBundle;
@@ -134,7 +134,7 @@ function shouldReadForGraph(file) {
134
134
  isMavenPomPath(normalized) ||
135
135
  isPyprojectPath(normalized)));
136
136
  }
137
- function buildPathLookup(repoManifest, dispositionMap) {
137
+ export function buildPathLookup(repoManifest, dispositionMap) {
138
138
  return new Map(repoManifest.files
139
139
  .filter((file) => {
140
140
  const status = dispositionMap.get(file.path);
@@ -783,6 +783,169 @@ function extractConventionalRouteEvidence(fromPath, content) {
783
783
  }
784
784
  return routes.length > 0 ? routes : [{ path: routePath, handler: fromPath }];
785
785
  }
786
+ // ---- Phase 4A: decorator / framework route detection ----
787
+ // Deterministic route patterns for NestJS, FastAPI, Flask, and Angular. These
788
+ // emit only the existing RouteEdge / route-handler-link shapes — no new
789
+ // planning-topology edge kinds. Each branch is gated on a framework marker so
790
+ // the patterns do not fire on unrelated decorators or object literals. An
791
+ // AST-based version can later move behind the analyzer seam; this is the
792
+ // regex floor for these frameworks.
793
+ const NEST_CONTROLLER_PATTERN = /@Controller\s*\(([\s\S]{0,200}?)\)/g;
794
+ const NEST_METHOD_DECORATOR_PATTERN = /@(Get|Post|Put|Patch|Delete|Options|Head|All)\s*\(\s*(?:["'`]([^"'`]*)["'`])?/g;
795
+ const PY_DECORATOR_METHOD_PATTERN = /@\s*[A-Za-z_]\w*\s*\.\s*(get|post|put|patch|delete|options|head|trace|websocket)\s*\(\s*["']([^"']+)["']/g;
796
+ const PY_ROUTE_DECORATOR_PATTERN = /@\s*[A-Za-z_]\w*\s*\.\s*(api_route|route)\s*\(\s*["']([^"']+)["']([\s\S]{0,200}?)\)/g;
797
+ const PY_METHODS_LIST_PATTERN = /methods\s*=\s*\[([^\]]*)\]/;
798
+ const PY_METHOD_LITERAL_PATTERN = /["']([A-Za-z]+)["']/g;
799
+ const ANGULAR_FILE_MARKER_PATTERN = /\b(?:RouterModule|provideRouter|loadChildren|loadComponent)\b|:\s*Routes\b/;
800
+ const ANGULAR_ROUTE_OBJECT_PATTERN = /\{[^{}]*?\bpath\s*:\s*["'`]([^"'`]*)["'`][^{}]*?\}/g;
801
+ const ANGULAR_ROUTE_KEY_PATTERN = /\b(?:component|loadChildren|loadComponent|redirectTo)\s*:/;
802
+ const ANGULAR_COMPONENT_PATTERN = /\b(?:component|loadComponent)\s*:\s*([A-Za-z_$][\w$]*)/;
803
+ const ANGULAR_LAZY_IMPORT_PATTERN = /\b(?:loadChildren|loadComponent)\s*:[\s\S]*?import\s*\(\s*["']([^"']+)["']\s*\)/;
804
+ const TS_LIKE_EXTENSION_PATTERN = /\.(?:ts|tsx|mts|cts|js|jsx|mjs|cjs)$/;
805
+ /** Join route segments (controller prefix + method path) into one clean path. */
806
+ function joinRouteSegments(...segments) {
807
+ return segments
808
+ .map((segment) => segment.trim().replace(/^\/+|\/+$/g, ""))
809
+ .filter((segment) => segment.length > 0)
810
+ .join("/");
811
+ }
812
+ /** Controller prefixes in document order, so each method can take the nearest. */
813
+ function nestControllerPrefixes(content) {
814
+ const prefixes = [];
815
+ NEST_CONTROLLER_PATTERN.lastIndex = 0;
816
+ for (const match of content.matchAll(NEST_CONTROLLER_PATTERN)) {
817
+ const arg = match[1] ?? "";
818
+ const pathProp = arg.match(/\bpath\s*:\s*["'`]([^"'`]*)["'`]/);
819
+ const firstString = arg.match(/["'`]([^"'`]*)["'`]/);
820
+ const prefix = pathProp?.[1] ?? firstString?.[1] ?? "";
821
+ prefixes.push({ index: match.index ?? 0, prefix });
822
+ }
823
+ return prefixes;
824
+ }
825
+ function collectNestRoutes(fromPath, content, routes) {
826
+ if (!content.includes("@Controller")) {
827
+ return;
828
+ }
829
+ const controllers = nestControllerPrefixes(content);
830
+ if (controllers.length === 0) {
831
+ return;
832
+ }
833
+ NEST_METHOD_DECORATOR_PATTERN.lastIndex = 0;
834
+ for (const match of content.matchAll(NEST_METHOD_DECORATOR_PATTERN)) {
835
+ const method = match[1];
836
+ if (!method)
837
+ continue;
838
+ const subPath = match[2] ?? "";
839
+ const at = match.index ?? 0;
840
+ let prefix = "";
841
+ for (const controller of controllers) {
842
+ if (controller.index <= at)
843
+ prefix = controller.prefix;
844
+ else
845
+ break;
846
+ }
847
+ routes.push({
848
+ path: normalizeRoutePath(joinRouteSegments(prefix, subPath)),
849
+ handler: fromPath,
850
+ method: method.toUpperCase(),
851
+ });
852
+ }
853
+ }
854
+ function pythonRouteMethods(args) {
855
+ const listMatch = args.match(PY_METHODS_LIST_PATTERN);
856
+ if (!listMatch?.[1])
857
+ return [];
858
+ PY_METHOD_LITERAL_PATTERN.lastIndex = 0;
859
+ return [...listMatch[1].matchAll(PY_METHOD_LITERAL_PATTERN)].map((method) => method[1].toUpperCase());
860
+ }
861
+ function collectPythonFrameworkRoutes(fromPath, content, routes) {
862
+ // FastAPI / Starlette: @app.get("/x"), @router.post("/y"), @router.websocket("/ws")
863
+ PY_DECORATOR_METHOD_PATTERN.lastIndex = 0;
864
+ for (const match of content.matchAll(PY_DECORATOR_METHOD_PATTERN)) {
865
+ const verb = match[1];
866
+ const routePath = match[2];
867
+ if (!verb || !routePath)
868
+ continue;
869
+ const method = verb.toUpperCase();
870
+ routes.push({
871
+ path: normalizeRoutePath(routePath),
872
+ handler: fromPath,
873
+ method: method === "WEBSOCKET" ? "WS" : method,
874
+ });
875
+ }
876
+ // FastAPI api_route + Flask route: @app.route("/x", methods=["GET","POST"])
877
+ PY_ROUTE_DECORATOR_PATTERN.lastIndex = 0;
878
+ for (const match of content.matchAll(PY_ROUTE_DECORATOR_PATTERN)) {
879
+ const routePath = match[2];
880
+ if (!routePath)
881
+ continue;
882
+ const methods = pythonRouteMethods(match[3] ?? "");
883
+ const path = normalizeRoutePath(routePath);
884
+ if (methods.length === 0) {
885
+ routes.push({ path, handler: fromPath, method: "GET" });
886
+ continue;
887
+ }
888
+ for (const method of methods) {
889
+ routes.push({ path, handler: fromPath, method });
890
+ }
891
+ }
892
+ }
893
+ function collectAngularRoutes(fromPath, content, pathLookup, calls, routes) {
894
+ if (!ANGULAR_FILE_MARKER_PATTERN.test(content)) {
895
+ return;
896
+ }
897
+ const bindings = extractImportBindings(fromPath, content, pathLookup);
898
+ ANGULAR_ROUTE_OBJECT_PATTERN.lastIndex = 0;
899
+ for (const match of content.matchAll(ANGULAR_ROUTE_OBJECT_PATTERN)) {
900
+ const body = match[0];
901
+ if (!ANGULAR_ROUTE_KEY_PATTERN.test(body)) {
902
+ continue;
903
+ }
904
+ const routePath = normalizeRoutePath(match[1] ?? "");
905
+ let handlerPath = fromPath;
906
+ let handlerExpression;
907
+ const lazyImport = body.match(ANGULAR_LAZY_IMPORT_PATTERN);
908
+ const component = body.match(ANGULAR_COMPONENT_PATTERN);
909
+ if (lazyImport?.[1]) {
910
+ const target = resolveSpecifier(fromPath, lazyImport[1], pathLookup) ??
911
+ resolveReferenceLiteral(fromPath, lazyImport[1], pathLookup);
912
+ if (target) {
913
+ handlerPath = target;
914
+ handlerExpression = lazyImport[1];
915
+ }
916
+ }
917
+ else if (component?.[1]) {
918
+ const binding = bindings.get(component[1]);
919
+ if (binding) {
920
+ handlerPath = binding.target;
921
+ handlerExpression = component[1];
922
+ }
923
+ }
924
+ routes.push({ path: routePath, handler: handlerPath });
925
+ if (handlerPath !== fromPath) {
926
+ calls.push(graphEdge({
927
+ from: fromPath,
928
+ to: handlerPath,
929
+ kind: "route-handler-link",
930
+ confidence: ROUTE_HANDLER_EDGE_CONFIDENCE,
931
+ reason: `Angular route '${routePath}' maps to '${handlerExpression ?? handlerPath}'.`,
932
+ }));
933
+ }
934
+ }
935
+ }
936
+ function extractFrameworkRouteEvidence(fromPath, content, pathLookup) {
937
+ const normalized = normalizeGraphPath(fromPath).toLowerCase();
938
+ const calls = [];
939
+ const routes = [];
940
+ if (normalized.endsWith(".py")) {
941
+ collectPythonFrameworkRoutes(fromPath, content, routes);
942
+ }
943
+ else if (TS_LIKE_EXTENSION_PATTERN.test(normalized)) {
944
+ collectNestRoutes(fromPath, content, routes);
945
+ collectAngularRoutes(fromPath, content, pathLookup, calls, routes);
946
+ }
947
+ return { calls, routes };
948
+ }
786
949
  function fallbackRouteEdge(filePath) {
787
950
  const normalized = filePath.toLowerCase();
788
951
  if (normalized.includes("api/") || normalized.includes("route")) {
@@ -1005,6 +1168,9 @@ export function buildGraphBundle(repoManifest, disposition, options = {}) {
1005
1168
  const registeredRoutes = extractRegisteredRouteEvidence(file.path, content, pathLookup);
1006
1169
  calls.push(...registeredRoutes.calls);
1007
1170
  fileRoutes.push(...registeredRoutes.routes);
1171
+ const frameworkRoutes = extractFrameworkRouteEvidence(file.path, content, pathLookup);
1172
+ calls.push(...frameworkRoutes.calls);
1173
+ fileRoutes.push(...frameworkRoutes.routes);
1008
1174
  }
1009
1175
  fileRoutes.push(...extractConventionalRouteEvidence(file.path, content));
1010
1176
  if (fileRoutes.length === 0) {
@@ -1,3 +1,18 @@
1
1
  import type { GraphEdge } from "@audit-tools/shared";
2
2
  export declare function isPythonSourcePath(path: string): boolean;
3
+ /**
4
+ * Resolve a single `import <spec>` module specifier to a repo file, or
5
+ * undefined. Shared with the tree-sitter Python analyzer so AST-extracted
6
+ * imports resolve to exactly the same targets as the regex floor.
7
+ */
8
+ export declare function resolvePythonImportTarget(fromPath: string, specifier: string, pathLookup: Map<string, string>): string | undefined;
9
+ /**
10
+ * Resolve a `from <module> import <names>` statement to repo files. Mirrors the
11
+ * floor: prefer submodule files (`module.name`), else the module itself. Shared
12
+ * with the tree-sitter Python analyzer.
13
+ */
14
+ export declare function resolvePythonFromImportTargets(fromPath: string, moduleSpecifier: string, importedNames: string[], pathLookup: Map<string, string>): Array<{
15
+ specifier: string;
16
+ target: string;
17
+ }>;
3
18
  export declare function extractPythonImportEdges(fromPath: string, content: string, pathLookup: Map<string, string>): GraphEdge[];