auditor-lambda 0.6.9 → 0.6.11

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.
@@ -4,9 +4,12 @@ import { posix } from "node:path";
4
4
  import { buildDispositionMap, isAuditExcludedStatus } from "./disposition.js";
5
5
  import { extractChromeExtensionManifestEdges, extractHtmlResourceEdges, } from "./browserExtension.js";
6
6
  import { extractCargoWorkspaceMemberEdges, extractGoWorkspaceModuleEdges, extractMavenModuleEdges, extractPackageEntrypointEdges, extractPackageScriptEdges, extractPyprojectTestpathLinks, extractTypescriptProjectReferenceEdges, extractWorkspacePackageEdges, extractYamlPathReferenceEdges, isCargoManifestPath, isGoWorkspaceManifestPath, isMavenPomPath, isPyprojectPath, } from "./graphManifestEdges.js";
7
- import { graphEdge, graphLookupKey, normalizeGraphPath, resolveCandidate, } from "./graphPathUtils.js";
8
- import { extractPythonImportEdges, isPythonSourcePath, } from "./graphPythonImports.js";
7
+ import { graphEdge, graphLookupKey, isPytestConftestPath, normalizeGraphPath, resolveCandidate, resolveReferenceLiteral, resolveSpecifier, SOURCE_EXTENSIONS, STRING_LITERAL_PATTERN, } from "./graphPathUtils.js";
8
+ import { extractPythonImportEdges } from "./graphPythonImports.js";
9
9
  import { isTestPath, normalizeExtractorPath } from "./pathPatterns.js";
10
+ import { extractConventionalRouteEvidence, extractFrameworkRouteEvidence, extractRegisteredRouteEvidence, fallbackRouteEdge, uniqueSortedRoutes, } from "./graphRoutes.js";
11
+ import { extractBoundedSuiteEdges, extractJsonSchemaReferenceEdges, extractSchemaContractTestEdges, } from "./graphSuites.js";
12
+ import { extractTestSourceEdges } from "./graphTestSources.js";
10
13
  const MAX_GRAPH_SOURCE_BYTES = 512 * 1024;
11
14
  const SOURCE_LANGUAGES = new Set([
12
15
  "typescript",
@@ -20,43 +23,6 @@ const SOURCE_LANGUAGES = new Set([
20
23
  "java",
21
24
  "csharp",
22
25
  ]);
23
- const SOURCE_EXTENSIONS = [
24
- ".ts",
25
- ".tsx",
26
- ".mts",
27
- ".cts",
28
- ".js",
29
- ".jsx",
30
- ".mjs",
31
- ".cjs",
32
- ".json",
33
- ".html",
34
- ".htm",
35
- ".yml",
36
- ".yaml",
37
- ".py",
38
- ".pyi",
39
- ".go",
40
- ".rs",
41
- ".java",
42
- ".cs",
43
- ];
44
- const TYPESCRIPT_TYPE_CONTRACT_EXTENSIONS = [
45
- ".ts",
46
- ".tsx",
47
- ".mts",
48
- ".cts",
49
- ];
50
- const PACKAGE_SCRIPT_SUITE_EXTENSIONS = [
51
- ".js",
52
- ".jsx",
53
- ".mjs",
54
- ".cjs",
55
- ".ts",
56
- ".tsx",
57
- ".mts",
58
- ".cts",
59
- ];
60
26
  const IMPORT_PATTERNS = [
61
27
  {
62
28
  pattern: /\bimport\s+(?:type\s+)?(?:[^"'()]*?\s+from\s+)?["']([^"']+)["']/g,
@@ -75,55 +41,13 @@ const IMPORT_PATTERNS = [
75
41
  kind: "commonjs",
76
42
  },
77
43
  ];
78
- const STRING_LITERAL_PATTERN = /["'`]([^"'`\r\n]{1,260})["'`]/g;
79
44
  const IMPORT_EDGE_CONFIDENCE = 0.95;
80
45
  const REFERENCE_EDGE_CONFIDENCE = 0.72;
81
46
  const RELATIVE_REFERENCE_EDGE_CONFIDENCE = 0.82;
82
- const TEST_SOURCE_EDGE_CONFIDENCE = 0.88;
83
47
  const CONFTEST_LINK_CONFIDENCE = 0.85;
84
48
  const ANALYZER_OWNERSHIP_EDGE_CONFIDENCE = 0.84;
85
- const JSON_SCHEMA_REF_EDGE_CONFIDENCE = 0.93;
86
- const SCHEMA_CONTRACT_TEST_EDGE_CONFIDENCE = 0.86;
87
- const SCHEMA_SUITE_EDGE_CONFIDENCE = 0.78;
88
- const GITHUB_WORKFLOW_SUITE_EDGE_CONFIDENCE = 0.78;
89
- const PACKAGE_SCRIPT_SUITE_EDGE_CONFIDENCE = 0.78;
90
- const TYPESCRIPT_TYPE_SUITE_EDGE_CONFIDENCE = 0.78;
91
- const PYTHON_TEST_UTIL_SUITE_EDGE_CONFIDENCE = 0.72;
92
- const PYTHON_TEST_UTIL_SEGMENT_NAMES = new Set(["utils", "helpers", "support"]);
93
- const ROUTE_HANDLER_EDGE_CONFIDENCE = 0.92;
94
49
  const CONTAINER_EDGE_CONFIDENCE = 0.25;
95
50
  const AUTH_SESSION_EDGE_CONFIDENCE = 0.55;
96
- const MAX_BOUNDED_SUITE_EDGE_FILES = 12;
97
- const MAX_BOUNDED_TYPE_SUITE_EDGE_FILES = 16;
98
- const MAX_TYPE_CONTRACT_SOURCE_BYTES = 64 * 1024;
99
- const TOP_LEVEL_TEST_SEGMENTS = new Set(["test", "tests", "spec", "specs"]);
100
- const COLOCATED_TEST_SEGMENTS = new Set([
101
- "__test__",
102
- "__tests__",
103
- "__spec__",
104
- "__specs__",
105
- "test",
106
- "tests",
107
- "spec",
108
- "specs",
109
- ]);
110
- const ROUTE_REGISTRATION_PATTERN = /\b(?:app|router|server|fastify)\s*\.\s*(get|post|put|patch|delete|del|options|head|all)\s*\(\s*["'`]([^"'`]+)["'`]\s*,\s*([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)?)/gi;
111
- const ROUTE_OBJECT_PATTERN = /\b(?:app|router|server|fastify)\s*\.\s*route\s*\(\s*\{([\s\S]{0,1200}?)\}\s*\)/gi;
112
- const ROUTE_METHOD_EXPORT_PATTERN = /\bexport\s+(?:async\s+)?(?:function|const)\s+(GET|POST|PUT|PATCH|DELETE|OPTIONS|HEAD)\b/g;
113
- const ROUTE_METHODS = new Set([
114
- "GET",
115
- "POST",
116
- "PUT",
117
- "PATCH",
118
- "DELETE",
119
- "OPTIONS",
120
- "HEAD",
121
- "ALL",
122
- ]);
123
- const IMPORT_BINDING_PATTERN = /\bimport\s+(?:type\s+)?([^;"'](?:[^;]*?))\s+from\s+["']([^"']+)["']/g;
124
- const REQUIRE_BINDING_PATTERN = /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*require\s*\(\s*["']([^"']+)["']\s*\)/g;
125
- const REQUIRE_DESTRUCTURING_PATTERN = /\b(?:const|let|var)\s*\{([^}]+)\}\s*=\s*require\s*\(\s*["']([^"']+)["']\s*\)/g;
126
- const IDENTIFIER_PATTERN = /^[A-Za-z_$][\w$]*$/;
127
51
  function shouldReadForGraph(file) {
128
52
  const normalized = normalizeGraphPath(file.path);
129
53
  return (file.size_bytes <= MAX_GRAPH_SOURCE_BYTES &&
@@ -142,23 +66,6 @@ export function buildPathLookup(repoManifest, dispositionMap) {
142
66
  })
143
67
  .map((file) => [graphLookupKey(file.path), file.path]));
144
68
  }
145
- function resolveSpecifier(fromPath, specifier, pathLookup) {
146
- if (!specifier.startsWith(".")) {
147
- return undefined;
148
- }
149
- const baseDir = posix.dirname(normalizeGraphPath(fromPath));
150
- return resolveCandidate(posix.join(baseDir, specifier), pathLookup);
151
- }
152
- function resolveReferenceLiteral(fromPath, literal, pathLookup) {
153
- const normalizedLiteral = normalizeGraphPath(literal);
154
- if (literal.startsWith(".")) {
155
- return resolveSpecifier(fromPath, literal, pathLookup);
156
- }
157
- if (!normalizedLiteral.includes("/")) {
158
- return undefined;
159
- }
160
- return resolveCandidate(normalizedLiteral, pathLookup);
161
- }
162
69
  function edgeSignature(edge) {
163
70
  return `${edge.from}\0${edge.to}\0${edge.kind ?? ""}`;
164
71
  }
@@ -241,103 +148,6 @@ function extractAnalyzerOwnershipEdges(externalAnalyzerResults, pathLookup) {
241
148
  }
242
149
  return edges;
243
150
  }
244
- function routeSignature(route) {
245
- return `${route.method ?? ""}\0${route.path}\0${route.handler}`;
246
- }
247
- function uniqueSortedRoutes(routes) {
248
- const deduped = new Map();
249
- for (const route of routes) {
250
- deduped.set(routeSignature(route), route);
251
- }
252
- return [...deduped.values()].sort((a, b) => a.path.localeCompare(b.path) ||
253
- a.handler.localeCompare(b.handler) ||
254
- (a.method ?? "").localeCompare(b.method ?? ""));
255
- }
256
- function normalizeRoutePath(routePath) {
257
- const trimmed = routePath.trim();
258
- if (trimmed === "*" || trimmed === "/*") {
259
- return trimmed;
260
- }
261
- const prefixed = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
262
- return prefixed.replace(/\/{2,}/g, "/");
263
- }
264
- function normalizeHttpMethod(method) {
265
- const upper = method.toUpperCase();
266
- return upper === "DEL" ? "DELETE" : upper;
267
- }
268
- function isIdentifier(value) {
269
- return typeof value === "string" && IDENTIFIER_PATTERN.test(value);
270
- }
271
- function addImportBinding(bindings, localName, binding) {
272
- if (isIdentifier(localName)) {
273
- bindings.set(localName, binding);
274
- }
275
- }
276
- function parseNamedImportLocal(rawName) {
277
- const normalized = rawName.trim().replace(/^type\s+/i, "").trim();
278
- if (!normalized) {
279
- return undefined;
280
- }
281
- const [, aliasedName] = normalized.split(/\s+as\s+/i);
282
- const localName = (aliasedName ?? normalized.split(/\s*:\s*/).at(-1) ?? "")
283
- .trim()
284
- .replace(/=.*$/, "")
285
- .trim();
286
- return isIdentifier(localName) ? localName : undefined;
287
- }
288
- function addNamedImportBindings(bindings, rawBindings, binding) {
289
- for (const rawName of rawBindings.split(",")) {
290
- addImportBinding(bindings, parseNamedImportLocal(rawName), binding);
291
- }
292
- }
293
- function extractImportBindings(fromPath, content, pathLookup) {
294
- const bindings = new Map();
295
- IMPORT_BINDING_PATTERN.lastIndex = 0;
296
- for (const match of content.matchAll(IMPORT_BINDING_PATTERN)) {
297
- const clause = match[1]?.trim();
298
- const specifier = match[2];
299
- if (!clause || !specifier)
300
- continue;
301
- const target = resolveSpecifier(fromPath, specifier, pathLookup);
302
- if (!target)
303
- continue;
304
- const binding = { target, specifier };
305
- const namespaceMatch = clause.match(/\*\s+as\s+([A-Za-z_$][\w$]*)/);
306
- addImportBinding(bindings, namespaceMatch?.[1], binding);
307
- const namedMatch = clause.match(/\{([^}]*)\}/);
308
- if (namedMatch?.[1]) {
309
- addNamedImportBindings(bindings, namedMatch[1], binding);
310
- }
311
- const defaultCandidate = clause
312
- .split(/[,{]/, 1)[0]
313
- ?.trim()
314
- .replace(/^type\s+/i, "");
315
- addImportBinding(bindings, defaultCandidate, binding);
316
- }
317
- REQUIRE_BINDING_PATTERN.lastIndex = 0;
318
- for (const match of content.matchAll(REQUIRE_BINDING_PATTERN)) {
319
- const localName = match[1];
320
- const specifier = match[2];
321
- if (!localName || !specifier)
322
- continue;
323
- const target = resolveSpecifier(fromPath, specifier, pathLookup);
324
- if (target) {
325
- addImportBinding(bindings, localName, { target, specifier });
326
- }
327
- }
328
- REQUIRE_DESTRUCTURING_PATTERN.lastIndex = 0;
329
- for (const match of content.matchAll(REQUIRE_DESTRUCTURING_PATTERN)) {
330
- const rawBindings = match[1];
331
- const specifier = match[2];
332
- if (!rawBindings || !specifier)
333
- continue;
334
- const target = resolveSpecifier(fromPath, specifier, pathLookup);
335
- if (target) {
336
- addNamedImportBindings(bindings, rawBindings, { target, specifier });
337
- }
338
- }
339
- return bindings;
340
- }
341
151
  function extractImportEdges(fromPath, content, pathLookup) {
342
152
  const edges = [];
343
153
  for (const { pattern, kind } of IMPORT_PATTERNS) {
@@ -416,636 +226,6 @@ function extractReferenceEdges(fromPath, content, pathLookup) {
416
226
  }
417
227
  return edges;
418
228
  }
419
- function isJsonSchemaPath(path) {
420
- return posix
421
- .basename(normalizeGraphPath(path))
422
- .toLowerCase()
423
- .endsWith(".schema.json");
424
- }
425
- function collectJsonSchemaRefs(value, refs) {
426
- if (Array.isArray(value)) {
427
- for (const item of value) {
428
- collectJsonSchemaRefs(item, refs);
429
- }
430
- return;
431
- }
432
- if (value === null || typeof value !== "object") {
433
- return;
434
- }
435
- for (const [key, item] of Object.entries(value)) {
436
- if (key === "$ref" && typeof item === "string" && item.trim().length > 0) {
437
- refs.add(item.trim());
438
- continue;
439
- }
440
- collectJsonSchemaRefs(item, refs);
441
- }
442
- }
443
- function resolveJsonSchemaRef(fromPath, ref, pathLookup) {
444
- const targetSpecifier = (ref.split("#", 1)[0] ?? "").trim();
445
- if (targetSpecifier.length === 0) {
446
- return undefined;
447
- }
448
- const normalizedSpecifier = normalizeGraphPath(targetSpecifier);
449
- if (normalizedSpecifier.startsWith("/") ||
450
- /^[a-z][a-z0-9+.-]*:/i.test(normalizedSpecifier)) {
451
- return undefined;
452
- }
453
- const baseDir = posix.dirname(normalizeGraphPath(fromPath));
454
- const candidate = targetSpecifier.startsWith(".") || !normalizedSpecifier.includes("/")
455
- ? posix.join(baseDir, normalizedSpecifier)
456
- : normalizedSpecifier;
457
- return resolveCandidate(candidate, pathLookup);
458
- }
459
- function extractJsonSchemaReferenceEdges(fromPath, content, pathLookup) {
460
- if (!isJsonSchemaPath(fromPath)) {
461
- return [];
462
- }
463
- let parsed;
464
- try {
465
- parsed = JSON.parse(content);
466
- }
467
- catch {
468
- return [];
469
- }
470
- const refs = new Set();
471
- collectJsonSchemaRefs(parsed, refs);
472
- const edges = [];
473
- for (const ref of refs) {
474
- const target = resolveJsonSchemaRef(fromPath, ref, pathLookup);
475
- if (!target || target === fromPath) {
476
- continue;
477
- }
478
- edges.push(graphEdge({
479
- from: fromPath,
480
- to: target,
481
- kind: "json-schema-ref",
482
- confidence: JSON_SCHEMA_REF_EDGE_CONFIDENCE,
483
- reason: `JSON Schema $ref '${ref}' resolves to '${target}'.`,
484
- }));
485
- }
486
- return edges;
487
- }
488
- function extractSchemaContractTestEdges(fromPath, content, pathLookup) {
489
- if (!isTestPath(normalizeExtractorPath(fromPath)) ||
490
- !/schema/i.test(fromPath) ||
491
- !/\.schema\.json/i.test(content)) {
492
- return [];
493
- }
494
- const literalBasenames = new Set();
495
- STRING_LITERAL_PATTERN.lastIndex = 0;
496
- for (const match of content.matchAll(STRING_LITERAL_PATTERN)) {
497
- const literal = match[1];
498
- if (!literal || !literal.toLowerCase().endsWith(".schema.json")) {
499
- continue;
500
- }
501
- literalBasenames.add(posix.basename(normalizeGraphPath(literal)).toLowerCase());
502
- }
503
- if (literalBasenames.size === 0 ||
504
- literalBasenames.size > MAX_BOUNDED_SUITE_EDGE_FILES) {
505
- return [];
506
- }
507
- const targets = [...new Set(pathLookup.values())]
508
- .filter((path) => {
509
- const normalized = normalizeGraphPath(path);
510
- return (isJsonSchemaPath(normalized) &&
511
- literalBasenames.has(posix.basename(normalized).toLowerCase()));
512
- })
513
- .sort((a, b) => a.localeCompare(b));
514
- if (targets.length > MAX_BOUNDED_SUITE_EDGE_FILES) {
515
- return [];
516
- }
517
- return targets.map((target) => graphEdge({
518
- from: fromPath,
519
- to: target,
520
- kind: "schema-contract-test-link",
521
- confidence: SCHEMA_CONTRACT_TEST_EDGE_CONFIDENCE,
522
- reason: `Schema contract test references '${posix.basename(target)}'.`,
523
- }));
524
- }
525
- function isGithubWorkflowPath(path) {
526
- const normalized = normalizeGraphPath(path).toLowerCase();
527
- return (normalized.startsWith(".github/workflows/") &&
528
- (normalized.endsWith(".yml") || normalized.endsWith(".yaml")));
529
- }
530
- function isTypescriptTypeContractPath(path, fileContents) {
531
- const normalized = normalizeGraphPath(path);
532
- const segments = normalized.split("/").filter(Boolean);
533
- if (!segments.includes("types") ||
534
- isTestPath(normalizeExtractorPath(normalized)) ||
535
- !TYPESCRIPT_TYPE_CONTRACT_EXTENSIONS.some((extension) => normalized.endsWith(extension))) {
536
- return false;
537
- }
538
- const content = fileContents[path];
539
- if (!content || content.length > MAX_TYPE_CONTRACT_SOURCE_BYTES) {
540
- return false;
541
- }
542
- return /\bexport\s+(?:declare\s+)?(?:interface|type|enum|const)\b/.test(content);
543
- }
544
- function packageScriptSuiteDirectories(graphEdges) {
545
- const directories = new Set();
546
- for (const edge of graphEdges) {
547
- if (edge.kind !== "package-script-link") {
548
- continue;
549
- }
550
- const directory = posix.dirname(normalizeGraphPath(edge.to));
551
- const basename = posix.basename(directory);
552
- if (basename === "scripts" || basename === "bin") {
553
- directories.add(directory);
554
- }
555
- }
556
- return directories;
557
- }
558
- function isPackageScriptSuitePath(path, suiteDirectories) {
559
- const normalized = normalizeGraphPath(path);
560
- return (suiteDirectories.has(posix.dirname(normalized)) &&
561
- PACKAGE_SCRIPT_SUITE_EXTENSIONS.some((extension) => normalized.endsWith(extension)));
562
- }
563
- function isPythonTestUtilSuitePath(path) {
564
- const normalized = normalizeGraphPath(path);
565
- if (!normalized.endsWith(".py"))
566
- return false;
567
- if (isPytestConftestPath(normalized))
568
- return false;
569
- const dir = posix.dirname(normalized);
570
- if (!PYTHON_TEST_UTIL_SEGMENT_NAMES.has(posix.basename(dir).toLowerCase()))
571
- return false;
572
- return isTestPath(normalizeExtractorPath(dir));
573
- }
574
- function extractBoundedSuiteEdges(pathLookup, fileContents, graphEdges) {
575
- const files = [...new Set(pathLookup.values())].sort((a, b) => a.localeCompare(b));
576
- const edges = [];
577
- const scriptSuiteDirectories = packageScriptSuiteDirectories(graphEdges);
578
- const addSuiteEdges = (params) => {
579
- const groups = new Map();
580
- for (const file of files) {
581
- if (!params.predicate(file)) {
582
- continue;
583
- }
584
- const directory = posix.dirname(normalizeGraphPath(file));
585
- const group = groups.get(directory) ?? [];
586
- group.push(file);
587
- groups.set(directory, group);
588
- }
589
- for (const [directory, group] of groups) {
590
- const maxFiles = params.maxFiles ?? MAX_BOUNDED_SUITE_EDGE_FILES;
591
- if (group.length < 2 ||
592
- group.length > maxFiles) {
593
- continue;
594
- }
595
- const suiteName = directory === "." ? "repository root" : directory;
596
- for (let index = 1; index < group.length; index++) {
597
- edges.push(graphEdge({
598
- from: group[index - 1],
599
- to: group[index],
600
- kind: params.kind,
601
- direction: "undirected",
602
- confidence: params.confidence,
603
- reason: `${params.label} suite '${suiteName}' groups ${group.length} related file(s).`,
604
- }));
605
- }
606
- }
607
- };
608
- addSuiteEdges({
609
- predicate: isJsonSchemaPath,
610
- kind: "schema-suite-link",
611
- confidence: SCHEMA_SUITE_EDGE_CONFIDENCE,
612
- label: "JSON Schema",
613
- });
614
- addSuiteEdges({
615
- predicate: isGithubWorkflowPath,
616
- kind: "github-workflow-suite-link",
617
- confidence: GITHUB_WORKFLOW_SUITE_EDGE_CONFIDENCE,
618
- label: "GitHub Actions workflow",
619
- });
620
- addSuiteEdges({
621
- predicate: (path) => isPackageScriptSuitePath(path, scriptSuiteDirectories),
622
- kind: "package-script-suite-link",
623
- confidence: PACKAGE_SCRIPT_SUITE_EDGE_CONFIDENCE,
624
- label: "Package script",
625
- });
626
- addSuiteEdges({
627
- predicate: (path) => isTypescriptTypeContractPath(path, fileContents),
628
- kind: "typescript-type-suite-link",
629
- confidence: TYPESCRIPT_TYPE_SUITE_EDGE_CONFIDENCE,
630
- label: "TypeScript type contract",
631
- maxFiles: MAX_BOUNDED_TYPE_SUITE_EDGE_FILES,
632
- });
633
- addSuiteEdges({
634
- predicate: isPythonTestUtilSuitePath,
635
- kind: "python-test-util-suite-link",
636
- confidence: PYTHON_TEST_UTIL_SUITE_EDGE_CONFIDENCE,
637
- label: "Python test utility",
638
- });
639
- return edges;
640
- }
641
- function importedHandlerBinding(handlerExpression, bindings) {
642
- const rootIdentifier = handlerExpression.split(".")[0];
643
- return rootIdentifier ? bindings.get(rootIdentifier) : undefined;
644
- }
645
- function addRouteEvidence(params) {
646
- const method = params.method ? normalizeHttpMethod(params.method) : undefined;
647
- if (method && !ROUTE_METHODS.has(method)) {
648
- return;
649
- }
650
- const handlerBinding = params.handlerExpression
651
- ? importedHandlerBinding(params.handlerExpression, params.bindings)
652
- : undefined;
653
- const handlerPath = handlerBinding?.target ?? params.fromPath;
654
- const route = {
655
- path: normalizeRoutePath(params.routePath),
656
- handler: handlerPath,
657
- };
658
- if (method) {
659
- route.method = method;
660
- }
661
- params.routes.push(route);
662
- if (handlerBinding && handlerPath !== params.fromPath) {
663
- params.calls.push(graphEdge({
664
- from: params.fromPath,
665
- to: handlerPath,
666
- kind: "route-handler-link",
667
- confidence: ROUTE_HANDLER_EDGE_CONFIDENCE,
668
- reason: `Route ${method ?? "handler"} '${route.path}' passes handler '${params.handlerExpression}' from '${handlerBinding.specifier}'.`,
669
- }));
670
- }
671
- }
672
- function extractRegisteredRouteEvidence(fromPath, content, pathLookup) {
673
- const bindings = extractImportBindings(fromPath, content, pathLookup);
674
- const calls = [];
675
- const routes = [];
676
- ROUTE_REGISTRATION_PATTERN.lastIndex = 0;
677
- for (const match of content.matchAll(ROUTE_REGISTRATION_PATTERN)) {
678
- const method = match[1];
679
- const routePath = match[2];
680
- const handlerExpression = match[3];
681
- if (!method || !routePath)
682
- continue;
683
- addRouteEvidence({
684
- fromPath,
685
- routes,
686
- calls,
687
- method,
688
- routePath,
689
- handlerExpression,
690
- bindings,
691
- });
692
- }
693
- ROUTE_OBJECT_PATTERN.lastIndex = 0;
694
- for (const match of content.matchAll(ROUTE_OBJECT_PATTERN)) {
695
- const body = match[1];
696
- if (!body)
697
- continue;
698
- const method = body.match(/\bmethod\s*:\s*["'`]([A-Za-z]+)["'`]/i)?.[1];
699
- const routePath = body.match(/\b(?:url|path)\s*:\s*["'`]([^"'`]+)["'`]/i)?.[1];
700
- const handlerExpression = body.match(/\bhandler\s*:\s*([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)?)/)?.[1];
701
- if (!routePath)
702
- continue;
703
- addRouteEvidence({
704
- fromPath,
705
- routes,
706
- calls,
707
- method,
708
- routePath,
709
- handlerExpression,
710
- bindings,
711
- });
712
- }
713
- return { calls, routes };
714
- }
715
- function stripSourceExtension(path) {
716
- const lowerPath = path.toLowerCase();
717
- const extension = SOURCE_EXTENSIONS.find((item) => lowerPath.endsWith(item));
718
- return extension ? path.slice(0, -extension.length) : path;
719
- }
720
- function nextRouteSegment(segment) {
721
- if (!segment || (segment.startsWith("(") && segment.endsWith(")"))) {
722
- return undefined;
723
- }
724
- const catchAll = segment.match(/^\[\.\.\.(.+)\]$/);
725
- if (catchAll?.[1]) {
726
- return `:${catchAll[1]}*`;
727
- }
728
- const dynamic = segment.match(/^\[(.+)\]$/);
729
- if (dynamic?.[1]) {
730
- return `:${dynamic[1]}`;
731
- }
732
- return segment;
733
- }
734
- function routePathFromSegments(segments) {
735
- const routeSegments = segments
736
- .map(nextRouteSegment)
737
- .filter((segment) => segment !== undefined);
738
- if (routeSegments.length === 0) {
739
- return undefined;
740
- }
741
- return normalizeRoutePath(routeSegments.join("/"));
742
- }
743
- function conventionalRoutePath(filePath) {
744
- const normalized = normalizeGraphPath(filePath);
745
- const parts = normalized.split("/").filter(Boolean);
746
- const lowerParts = parts.map((part) => part.toLowerCase());
747
- const fileName = lowerParts.at(-1);
748
- if (!fileName) {
749
- return undefined;
750
- }
751
- const appIndex = lowerParts.lastIndexOf("app");
752
- if (appIndex >= 0 && fileName.startsWith("route.")) {
753
- return routePathFromSegments(parts.slice(appIndex + 1, -1));
754
- }
755
- const pagesIndex = lowerParts.lastIndexOf("pages");
756
- const apiIndex = pagesIndex >= 0
757
- ? lowerParts.indexOf("api", pagesIndex + 1)
758
- : lowerParts.indexOf("api");
759
- if (apiIndex >= 0 && apiIndex < parts.length - 1) {
760
- const withoutExtension = stripSourceExtension(parts.at(-1) ?? "");
761
- return routePathFromSegments([...parts.slice(apiIndex, -1), withoutExtension]);
762
- }
763
- return undefined;
764
- }
765
- function extractConventionalRouteEvidence(fromPath, content) {
766
- const routePath = conventionalRoutePath(fromPath);
767
- if (!routePath) {
768
- return [];
769
- }
770
- const routes = [];
771
- if (content) {
772
- ROUTE_METHOD_EXPORT_PATTERN.lastIndex = 0;
773
- for (const match of content.matchAll(ROUTE_METHOD_EXPORT_PATTERN)) {
774
- const method = match[1];
775
- if (method) {
776
- routes.push({
777
- path: routePath,
778
- handler: fromPath,
779
- method,
780
- });
781
- }
782
- }
783
- }
784
- return routes.length > 0 ? routes : [{ path: routePath, handler: fromPath }];
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
- }
949
- function fallbackRouteEdge(filePath) {
950
- const normalized = filePath.toLowerCase();
951
- if (normalized.includes("api/") || normalized.includes("route")) {
952
- return {
953
- path: `/${filePath.replaceAll("/", "_")}`,
954
- handler: filePath,
955
- method: "GET",
956
- };
957
- }
958
- return undefined;
959
- }
960
- function stripKnownSourceExtension(path) {
961
- const lowerPath = path.toLowerCase();
962
- const extension = SOURCE_EXTENSIONS.find((item) => lowerPath.endsWith(item));
963
- if (!extension) {
964
- return undefined;
965
- }
966
- return path.slice(0, -extension.length);
967
- }
968
- function stripTestSuffix(pathWithoutExtension) {
969
- const stripped = pathWithoutExtension.replace(/[._-](?:test|spec)$/i, "");
970
- return stripped === pathWithoutExtension ? undefined : stripped;
971
- }
972
- function stripPythonTestPrefix(pathWithoutExtension) {
973
- const basename = posix.basename(pathWithoutExtension);
974
- const match = /^test[._-](.+)$/i.exec(basename);
975
- if (!match?.[1]) {
976
- return undefined;
977
- }
978
- const directory = posix.dirname(pathWithoutExtension);
979
- return directory === "." ? match[1] : posix.join(directory, match[1]);
980
- }
981
- function addTestSourceCandidatesForBase(basePath, candidates) {
982
- candidates.add(basePath);
983
- const parts = basePath.split("/").filter(Boolean);
984
- const topLevelSegment = parts[0]?.toLowerCase();
985
- if (topLevelSegment && TOP_LEVEL_TEST_SEGMENTS.has(topLevelSegment)) {
986
- const mirroredParts = parts.slice(1);
987
- if (mirroredParts.length > 0) {
988
- candidates.add(posix.join("src", ...mirroredParts));
989
- }
990
- }
991
- for (let index = 1; index < parts.length; index++) {
992
- if (COLOCATED_TEST_SEGMENTS.has(parts[index].toLowerCase())) {
993
- const colocatedParts = [
994
- ...parts.slice(0, index),
995
- ...parts.slice(index + 1),
996
- ];
997
- if (colocatedParts.length > 0) {
998
- candidates.add(posix.join(...colocatedParts));
999
- }
1000
- }
1001
- }
1002
- }
1003
- function testSourceCandidates(testPath) {
1004
- const normalizedPath = normalizeGraphPath(testPath);
1005
- const withoutExtension = stripKnownSourceExtension(normalizedPath);
1006
- if (!withoutExtension) {
1007
- return [];
1008
- }
1009
- const baseCandidates = new Set();
1010
- const withoutTestSuffix = stripTestSuffix(withoutExtension);
1011
- if (withoutTestSuffix) {
1012
- baseCandidates.add(withoutTestSuffix);
1013
- }
1014
- if (isPythonSourcePath(normalizedPath)) {
1015
- const withoutPythonPrefix = stripPythonTestPrefix(withoutExtension);
1016
- if (withoutPythonPrefix) {
1017
- baseCandidates.add(withoutPythonPrefix);
1018
- }
1019
- }
1020
- const candidates = new Set();
1021
- for (const basePath of baseCandidates) {
1022
- addTestSourceCandidatesForBase(basePath, candidates);
1023
- }
1024
- return [...candidates];
1025
- }
1026
- function extractTestSourceEdges(fromPath, pathLookup) {
1027
- if (!isTestPath(normalizeExtractorPath(fromPath))) {
1028
- return [];
1029
- }
1030
- const edges = [];
1031
- for (const candidate of testSourceCandidates(fromPath)) {
1032
- const target = resolveCandidate(candidate, pathLookup);
1033
- if (!target || isTestPath(normalizeExtractorPath(target))) {
1034
- continue;
1035
- }
1036
- edges.push(graphEdge({
1037
- from: fromPath,
1038
- to: target,
1039
- kind: "test-source-link",
1040
- confidence: TEST_SOURCE_EDGE_CONFIDENCE,
1041
- reason: `Test path naming maps to source path '${target}'.`,
1042
- }));
1043
- }
1044
- return edges;
1045
- }
1046
- function isPytestConftestPath(path) {
1047
- return posix.basename(normalizeGraphPath(path)).toLowerCase() === "conftest.py";
1048
- }
1049
229
  function extractPytestConftestLinks(pathLookup) {
1050
230
  const allPaths = [...new Set(pathLookup.values())];
1051
231
  const conftestPaths = allPaths.filter((p) => isPytestConftestPath(p));