auditor-lambda 0.6.9 → 0.6.10
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.
- package/dist/cli/auditStep.d.ts +63 -0
- package/dist/cli/auditStep.js +133 -0
- package/dist/cli/envelope.d.ts +47 -0
- package/dist/cli/envelope.js +64 -0
- package/dist/cli/reviewRun.d.ts +29 -0
- package/dist/cli/reviewRun.js +143 -0
- package/dist/cli.js +6 -319
- package/dist/extractors/graph.js +5 -825
- package/dist/extractors/graphPathUtils.d.ts +12 -0
- package/dist/extractors/graphPathUtils.js +58 -0
- package/dist/extractors/graphRoutes.d.ts +12 -0
- package/dist/extractors/graphRoutes.js +435 -0
- package/dist/extractors/graphSuites.d.ts +4 -0
- package/dist/extractors/graphSuites.js +246 -0
- package/dist/extractors/graphTestSources.d.ts +2 -0
- package/dist/extractors/graphTestSources.js +102 -0
- package/dist/providers/claudeCodeProvider.js +3 -2
- package/dist/providers/localSubprocessProvider.js +2 -2
- package/dist/providers/opencodeProvider.js +6 -22
- package/dist/providers/subprocessTemplateProvider.js +22 -9
- package/package.json +1 -1
|
@@ -3,3 +3,15 @@ export declare function normalizeGraphPath(path: string): string;
|
|
|
3
3
|
export declare function graphLookupKey(path: string): string;
|
|
4
4
|
export declare function resolveCandidate(candidate: string, pathLookup: Map<string, string>): string | undefined;
|
|
5
5
|
export declare function graphEdge(params: GraphEdge): GraphEdge;
|
|
6
|
+
/** Source file extensions the graph extractors read and reason about. */
|
|
7
|
+
export declare const SOURCE_EXTENSIONS: readonly [".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs", ".json", ".html", ".htm", ".yml", ".yaml", ".py", ".pyi", ".go", ".rs", ".java", ".cs"];
|
|
8
|
+
/** Matches any single/double/back-quoted string literal (bounded length). */
|
|
9
|
+
export declare const STRING_LITERAL_PATTERN: RegExp;
|
|
10
|
+
/** Resolve a relative import specifier to a repository path, if one exists. */
|
|
11
|
+
export declare function resolveSpecifier(fromPath: string, specifier: string, pathLookup: Map<string, string>): string | undefined;
|
|
12
|
+
/** Resolve a string literal (relative or repo-rooted) to a repository path. */
|
|
13
|
+
export declare function resolveReferenceLiteral(fromPath: string, literal: string, pathLookup: Map<string, string>): string | undefined;
|
|
14
|
+
/** True for `*.schema.json` files (JSON Schema documents). */
|
|
15
|
+
export declare function isJsonSchemaPath(path: string): boolean;
|
|
16
|
+
/** True for pytest `conftest.py` files. */
|
|
17
|
+
export declare function isPytestConftestPath(path: string): boolean;
|
|
@@ -73,3 +73,61 @@ export function graphEdge(params) {
|
|
|
73
73
|
direction: params.direction ?? "directed",
|
|
74
74
|
};
|
|
75
75
|
}
|
|
76
|
+
// ---- Cross-cluster shared helpers ----
|
|
77
|
+
// These are used by more than one graph extractor cluster (import/reference
|
|
78
|
+
// edges, routes, schemas, suites, test-source). They live here so each
|
|
79
|
+
// extractor module imports one implementation rather than re-forking it.
|
|
80
|
+
/** Source file extensions the graph extractors read and reason about. */
|
|
81
|
+
export const SOURCE_EXTENSIONS = [
|
|
82
|
+
".ts",
|
|
83
|
+
".tsx",
|
|
84
|
+
".mts",
|
|
85
|
+
".cts",
|
|
86
|
+
".js",
|
|
87
|
+
".jsx",
|
|
88
|
+
".mjs",
|
|
89
|
+
".cjs",
|
|
90
|
+
".json",
|
|
91
|
+
".html",
|
|
92
|
+
".htm",
|
|
93
|
+
".yml",
|
|
94
|
+
".yaml",
|
|
95
|
+
".py",
|
|
96
|
+
".pyi",
|
|
97
|
+
".go",
|
|
98
|
+
".rs",
|
|
99
|
+
".java",
|
|
100
|
+
".cs",
|
|
101
|
+
];
|
|
102
|
+
/** Matches any single/double/back-quoted string literal (bounded length). */
|
|
103
|
+
export const STRING_LITERAL_PATTERN = /["'`]([^"'`\r\n]{1,260})["'`]/g;
|
|
104
|
+
/** Resolve a relative import specifier to a repository path, if one exists. */
|
|
105
|
+
export function resolveSpecifier(fromPath, specifier, pathLookup) {
|
|
106
|
+
if (!specifier.startsWith(".")) {
|
|
107
|
+
return undefined;
|
|
108
|
+
}
|
|
109
|
+
const baseDir = posix.dirname(normalizeGraphPath(fromPath));
|
|
110
|
+
return resolveCandidate(posix.join(baseDir, specifier), pathLookup);
|
|
111
|
+
}
|
|
112
|
+
/** Resolve a string literal (relative or repo-rooted) to a repository path. */
|
|
113
|
+
export function resolveReferenceLiteral(fromPath, literal, pathLookup) {
|
|
114
|
+
const normalizedLiteral = normalizeGraphPath(literal);
|
|
115
|
+
if (literal.startsWith(".")) {
|
|
116
|
+
return resolveSpecifier(fromPath, literal, pathLookup);
|
|
117
|
+
}
|
|
118
|
+
if (!normalizedLiteral.includes("/")) {
|
|
119
|
+
return undefined;
|
|
120
|
+
}
|
|
121
|
+
return resolveCandidate(normalizedLiteral, pathLookup);
|
|
122
|
+
}
|
|
123
|
+
/** True for `*.schema.json` files (JSON Schema documents). */
|
|
124
|
+
export function isJsonSchemaPath(path) {
|
|
125
|
+
return posix
|
|
126
|
+
.basename(normalizeGraphPath(path))
|
|
127
|
+
.toLowerCase()
|
|
128
|
+
.endsWith(".schema.json");
|
|
129
|
+
}
|
|
130
|
+
/** True for pytest `conftest.py` files. */
|
|
131
|
+
export function isPytestConftestPath(path) {
|
|
132
|
+
return posix.basename(normalizeGraphPath(path)).toLowerCase() === "conftest.py";
|
|
133
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { GraphEdge, RouteEdge } from "@audit-tools/shared";
|
|
2
|
+
export declare function uniqueSortedRoutes(routes: RouteEdge[]): RouteEdge[];
|
|
3
|
+
export declare function extractRegisteredRouteEvidence(fromPath: string, content: string, pathLookup: Map<string, string>): {
|
|
4
|
+
calls: GraphEdge[];
|
|
5
|
+
routes: RouteEdge[];
|
|
6
|
+
};
|
|
7
|
+
export declare function extractConventionalRouteEvidence(fromPath: string, content: string | undefined): RouteEdge[];
|
|
8
|
+
export declare function extractFrameworkRouteEvidence(fromPath: string, content: string, pathLookup: Map<string, string>): {
|
|
9
|
+
calls: GraphEdge[];
|
|
10
|
+
routes: RouteEdge[];
|
|
11
|
+
};
|
|
12
|
+
export declare function fallbackRouteEdge(filePath: string): RouteEdge | undefined;
|
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
import { graphEdge, normalizeGraphPath, resolveReferenceLiteral, resolveSpecifier, SOURCE_EXTENSIONS, } from "./graphPathUtils.js";
|
|
2
|
+
const ROUTE_HANDLER_EDGE_CONFIDENCE = 0.92;
|
|
3
|
+
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;
|
|
4
|
+
const ROUTE_OBJECT_PATTERN = /\b(?:app|router|server|fastify)\s*\.\s*route\s*\(\s*\{([\s\S]{0,1200}?)\}\s*\)/gi;
|
|
5
|
+
const ROUTE_METHOD_EXPORT_PATTERN = /\bexport\s+(?:async\s+)?(?:function|const)\s+(GET|POST|PUT|PATCH|DELETE|OPTIONS|HEAD)\b/g;
|
|
6
|
+
const ROUTE_METHODS = new Set([
|
|
7
|
+
"GET",
|
|
8
|
+
"POST",
|
|
9
|
+
"PUT",
|
|
10
|
+
"PATCH",
|
|
11
|
+
"DELETE",
|
|
12
|
+
"OPTIONS",
|
|
13
|
+
"HEAD",
|
|
14
|
+
"ALL",
|
|
15
|
+
]);
|
|
16
|
+
const IMPORT_BINDING_PATTERN = /\bimport\s+(?:type\s+)?([^;"'](?:[^;]*?))\s+from\s+["']([^"']+)["']/g;
|
|
17
|
+
const REQUIRE_BINDING_PATTERN = /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*require\s*\(\s*["']([^"']+)["']\s*\)/g;
|
|
18
|
+
const REQUIRE_DESTRUCTURING_PATTERN = /\b(?:const|let|var)\s*\{([^}]+)\}\s*=\s*require\s*\(\s*["']([^"']+)["']\s*\)/g;
|
|
19
|
+
const IDENTIFIER_PATTERN = /^[A-Za-z_$][\w$]*$/;
|
|
20
|
+
function routeSignature(route) {
|
|
21
|
+
return `${route.method ?? ""}\0${route.path}\0${route.handler}`;
|
|
22
|
+
}
|
|
23
|
+
export function uniqueSortedRoutes(routes) {
|
|
24
|
+
const deduped = new Map();
|
|
25
|
+
for (const route of routes) {
|
|
26
|
+
deduped.set(routeSignature(route), route);
|
|
27
|
+
}
|
|
28
|
+
return [...deduped.values()].sort((a, b) => a.path.localeCompare(b.path) ||
|
|
29
|
+
a.handler.localeCompare(b.handler) ||
|
|
30
|
+
(a.method ?? "").localeCompare(b.method ?? ""));
|
|
31
|
+
}
|
|
32
|
+
function normalizeRoutePath(routePath) {
|
|
33
|
+
const trimmed = routePath.trim();
|
|
34
|
+
if (trimmed === "*" || trimmed === "/*") {
|
|
35
|
+
return trimmed;
|
|
36
|
+
}
|
|
37
|
+
const prefixed = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
|
|
38
|
+
return prefixed.replace(/\/{2,}/g, "/");
|
|
39
|
+
}
|
|
40
|
+
function normalizeHttpMethod(method) {
|
|
41
|
+
const upper = method.toUpperCase();
|
|
42
|
+
return upper === "DEL" ? "DELETE" : upper;
|
|
43
|
+
}
|
|
44
|
+
function isIdentifier(value) {
|
|
45
|
+
return typeof value === "string" && IDENTIFIER_PATTERN.test(value);
|
|
46
|
+
}
|
|
47
|
+
function addImportBinding(bindings, localName, binding) {
|
|
48
|
+
if (isIdentifier(localName)) {
|
|
49
|
+
bindings.set(localName, binding);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
function parseNamedImportLocal(rawName) {
|
|
53
|
+
const normalized = rawName.trim().replace(/^type\s+/i, "").trim();
|
|
54
|
+
if (!normalized) {
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
const [, aliasedName] = normalized.split(/\s+as\s+/i);
|
|
58
|
+
const localName = (aliasedName ?? normalized.split(/\s*:\s*/).at(-1) ?? "")
|
|
59
|
+
.trim()
|
|
60
|
+
.replace(/=.*$/, "")
|
|
61
|
+
.trim();
|
|
62
|
+
return isIdentifier(localName) ? localName : undefined;
|
|
63
|
+
}
|
|
64
|
+
function addNamedImportBindings(bindings, rawBindings, binding) {
|
|
65
|
+
for (const rawName of rawBindings.split(",")) {
|
|
66
|
+
addImportBinding(bindings, parseNamedImportLocal(rawName), binding);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
function extractImportBindings(fromPath, content, pathLookup) {
|
|
70
|
+
const bindings = new Map();
|
|
71
|
+
IMPORT_BINDING_PATTERN.lastIndex = 0;
|
|
72
|
+
for (const match of content.matchAll(IMPORT_BINDING_PATTERN)) {
|
|
73
|
+
const clause = match[1]?.trim();
|
|
74
|
+
const specifier = match[2];
|
|
75
|
+
if (!clause || !specifier)
|
|
76
|
+
continue;
|
|
77
|
+
const target = resolveSpecifier(fromPath, specifier, pathLookup);
|
|
78
|
+
if (!target)
|
|
79
|
+
continue;
|
|
80
|
+
const binding = { target, specifier };
|
|
81
|
+
const namespaceMatch = clause.match(/\*\s+as\s+([A-Za-z_$][\w$]*)/);
|
|
82
|
+
addImportBinding(bindings, namespaceMatch?.[1], binding);
|
|
83
|
+
const namedMatch = clause.match(/\{([^}]*)\}/);
|
|
84
|
+
if (namedMatch?.[1]) {
|
|
85
|
+
addNamedImportBindings(bindings, namedMatch[1], binding);
|
|
86
|
+
}
|
|
87
|
+
const defaultCandidate = clause
|
|
88
|
+
.split(/[,{]/, 1)[0]
|
|
89
|
+
?.trim()
|
|
90
|
+
.replace(/^type\s+/i, "");
|
|
91
|
+
addImportBinding(bindings, defaultCandidate, binding);
|
|
92
|
+
}
|
|
93
|
+
REQUIRE_BINDING_PATTERN.lastIndex = 0;
|
|
94
|
+
for (const match of content.matchAll(REQUIRE_BINDING_PATTERN)) {
|
|
95
|
+
const localName = match[1];
|
|
96
|
+
const specifier = match[2];
|
|
97
|
+
if (!localName || !specifier)
|
|
98
|
+
continue;
|
|
99
|
+
const target = resolveSpecifier(fromPath, specifier, pathLookup);
|
|
100
|
+
if (target) {
|
|
101
|
+
addImportBinding(bindings, localName, { target, specifier });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
REQUIRE_DESTRUCTURING_PATTERN.lastIndex = 0;
|
|
105
|
+
for (const match of content.matchAll(REQUIRE_DESTRUCTURING_PATTERN)) {
|
|
106
|
+
const rawBindings = match[1];
|
|
107
|
+
const specifier = match[2];
|
|
108
|
+
if (!rawBindings || !specifier)
|
|
109
|
+
continue;
|
|
110
|
+
const target = resolveSpecifier(fromPath, specifier, pathLookup);
|
|
111
|
+
if (target) {
|
|
112
|
+
addNamedImportBindings(bindings, rawBindings, { target, specifier });
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return bindings;
|
|
116
|
+
}
|
|
117
|
+
function importedHandlerBinding(handlerExpression, bindings) {
|
|
118
|
+
const rootIdentifier = handlerExpression.split(".")[0];
|
|
119
|
+
return rootIdentifier ? bindings.get(rootIdentifier) : undefined;
|
|
120
|
+
}
|
|
121
|
+
function addRouteEvidence(params) {
|
|
122
|
+
const method = params.method ? normalizeHttpMethod(params.method) : undefined;
|
|
123
|
+
if (method && !ROUTE_METHODS.has(method)) {
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
const handlerBinding = params.handlerExpression
|
|
127
|
+
? importedHandlerBinding(params.handlerExpression, params.bindings)
|
|
128
|
+
: undefined;
|
|
129
|
+
const handlerPath = handlerBinding?.target ?? params.fromPath;
|
|
130
|
+
const route = {
|
|
131
|
+
path: normalizeRoutePath(params.routePath),
|
|
132
|
+
handler: handlerPath,
|
|
133
|
+
};
|
|
134
|
+
if (method) {
|
|
135
|
+
route.method = method;
|
|
136
|
+
}
|
|
137
|
+
params.routes.push(route);
|
|
138
|
+
if (handlerBinding && handlerPath !== params.fromPath) {
|
|
139
|
+
params.calls.push(graphEdge({
|
|
140
|
+
from: params.fromPath,
|
|
141
|
+
to: handlerPath,
|
|
142
|
+
kind: "route-handler-link",
|
|
143
|
+
confidence: ROUTE_HANDLER_EDGE_CONFIDENCE,
|
|
144
|
+
reason: `Route ${method ?? "handler"} '${route.path}' passes handler '${params.handlerExpression}' from '${handlerBinding.specifier}'.`,
|
|
145
|
+
}));
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
export function extractRegisteredRouteEvidence(fromPath, content, pathLookup) {
|
|
149
|
+
const bindings = extractImportBindings(fromPath, content, pathLookup);
|
|
150
|
+
const calls = [];
|
|
151
|
+
const routes = [];
|
|
152
|
+
ROUTE_REGISTRATION_PATTERN.lastIndex = 0;
|
|
153
|
+
for (const match of content.matchAll(ROUTE_REGISTRATION_PATTERN)) {
|
|
154
|
+
const method = match[1];
|
|
155
|
+
const routePath = match[2];
|
|
156
|
+
const handlerExpression = match[3];
|
|
157
|
+
if (!method || !routePath)
|
|
158
|
+
continue;
|
|
159
|
+
addRouteEvidence({
|
|
160
|
+
fromPath,
|
|
161
|
+
routes,
|
|
162
|
+
calls,
|
|
163
|
+
method,
|
|
164
|
+
routePath,
|
|
165
|
+
handlerExpression,
|
|
166
|
+
bindings,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
ROUTE_OBJECT_PATTERN.lastIndex = 0;
|
|
170
|
+
for (const match of content.matchAll(ROUTE_OBJECT_PATTERN)) {
|
|
171
|
+
const body = match[1];
|
|
172
|
+
if (!body)
|
|
173
|
+
continue;
|
|
174
|
+
const method = body.match(/\bmethod\s*:\s*["'`]([A-Za-z]+)["'`]/i)?.[1];
|
|
175
|
+
const routePath = body.match(/\b(?:url|path)\s*:\s*["'`]([^"'`]+)["'`]/i)?.[1];
|
|
176
|
+
const handlerExpression = body.match(/\bhandler\s*:\s*([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)?)/)?.[1];
|
|
177
|
+
if (!routePath)
|
|
178
|
+
continue;
|
|
179
|
+
addRouteEvidence({
|
|
180
|
+
fromPath,
|
|
181
|
+
routes,
|
|
182
|
+
calls,
|
|
183
|
+
method,
|
|
184
|
+
routePath,
|
|
185
|
+
handlerExpression,
|
|
186
|
+
bindings,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
return { calls, routes };
|
|
190
|
+
}
|
|
191
|
+
function stripSourceExtension(path) {
|
|
192
|
+
const lowerPath = path.toLowerCase();
|
|
193
|
+
const extension = SOURCE_EXTENSIONS.find((item) => lowerPath.endsWith(item));
|
|
194
|
+
return extension ? path.slice(0, -extension.length) : path;
|
|
195
|
+
}
|
|
196
|
+
function nextRouteSegment(segment) {
|
|
197
|
+
if (!segment || (segment.startsWith("(") && segment.endsWith(")"))) {
|
|
198
|
+
return undefined;
|
|
199
|
+
}
|
|
200
|
+
const catchAll = segment.match(/^\[\.\.\.(.+)\]$/);
|
|
201
|
+
if (catchAll?.[1]) {
|
|
202
|
+
return `:${catchAll[1]}*`;
|
|
203
|
+
}
|
|
204
|
+
const dynamic = segment.match(/^\[(.+)\]$/);
|
|
205
|
+
if (dynamic?.[1]) {
|
|
206
|
+
return `:${dynamic[1]}`;
|
|
207
|
+
}
|
|
208
|
+
return segment;
|
|
209
|
+
}
|
|
210
|
+
function routePathFromSegments(segments) {
|
|
211
|
+
const routeSegments = segments
|
|
212
|
+
.map(nextRouteSegment)
|
|
213
|
+
.filter((segment) => segment !== undefined);
|
|
214
|
+
if (routeSegments.length === 0) {
|
|
215
|
+
return undefined;
|
|
216
|
+
}
|
|
217
|
+
return normalizeRoutePath(routeSegments.join("/"));
|
|
218
|
+
}
|
|
219
|
+
function conventionalRoutePath(filePath) {
|
|
220
|
+
const normalized = normalizeGraphPath(filePath);
|
|
221
|
+
const parts = normalized.split("/").filter(Boolean);
|
|
222
|
+
const lowerParts = parts.map((part) => part.toLowerCase());
|
|
223
|
+
const fileName = lowerParts.at(-1);
|
|
224
|
+
if (!fileName) {
|
|
225
|
+
return undefined;
|
|
226
|
+
}
|
|
227
|
+
const appIndex = lowerParts.lastIndexOf("app");
|
|
228
|
+
if (appIndex >= 0 && fileName.startsWith("route.")) {
|
|
229
|
+
return routePathFromSegments(parts.slice(appIndex + 1, -1));
|
|
230
|
+
}
|
|
231
|
+
const pagesIndex = lowerParts.lastIndexOf("pages");
|
|
232
|
+
const apiIndex = pagesIndex >= 0
|
|
233
|
+
? lowerParts.indexOf("api", pagesIndex + 1)
|
|
234
|
+
: lowerParts.indexOf("api");
|
|
235
|
+
if (apiIndex >= 0 && apiIndex < parts.length - 1) {
|
|
236
|
+
const withoutExtension = stripSourceExtension(parts.at(-1) ?? "");
|
|
237
|
+
return routePathFromSegments([...parts.slice(apiIndex, -1), withoutExtension]);
|
|
238
|
+
}
|
|
239
|
+
return undefined;
|
|
240
|
+
}
|
|
241
|
+
export function extractConventionalRouteEvidence(fromPath, content) {
|
|
242
|
+
const routePath = conventionalRoutePath(fromPath);
|
|
243
|
+
if (!routePath) {
|
|
244
|
+
return [];
|
|
245
|
+
}
|
|
246
|
+
const routes = [];
|
|
247
|
+
if (content) {
|
|
248
|
+
ROUTE_METHOD_EXPORT_PATTERN.lastIndex = 0;
|
|
249
|
+
for (const match of content.matchAll(ROUTE_METHOD_EXPORT_PATTERN)) {
|
|
250
|
+
const method = match[1];
|
|
251
|
+
if (method) {
|
|
252
|
+
routes.push({
|
|
253
|
+
path: routePath,
|
|
254
|
+
handler: fromPath,
|
|
255
|
+
method,
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
return routes.length > 0 ? routes : [{ path: routePath, handler: fromPath }];
|
|
261
|
+
}
|
|
262
|
+
// ---- Phase 4A: decorator / framework route detection ----
|
|
263
|
+
// Deterministic route patterns for NestJS, FastAPI, Flask, and Angular. These
|
|
264
|
+
// emit only the existing RouteEdge / route-handler-link shapes — no new
|
|
265
|
+
// planning-topology edge kinds. Each branch is gated on a framework marker so
|
|
266
|
+
// the patterns do not fire on unrelated decorators or object literals. An
|
|
267
|
+
// AST-based version can later move behind the analyzer seam; this is the
|
|
268
|
+
// regex floor for these frameworks.
|
|
269
|
+
const NEST_CONTROLLER_PATTERN = /@Controller\s*\(([\s\S]{0,200}?)\)/g;
|
|
270
|
+
const NEST_METHOD_DECORATOR_PATTERN = /@(Get|Post|Put|Patch|Delete|Options|Head|All)\s*\(\s*(?:["'`]([^"'`]*)["'`])?/g;
|
|
271
|
+
const PY_DECORATOR_METHOD_PATTERN = /@\s*[A-Za-z_]\w*\s*\.\s*(get|post|put|patch|delete|options|head|trace|websocket)\s*\(\s*["']([^"']+)["']/g;
|
|
272
|
+
const PY_ROUTE_DECORATOR_PATTERN = /@\s*[A-Za-z_]\w*\s*\.\s*(api_route|route)\s*\(\s*["']([^"']+)["']([\s\S]{0,200}?)\)/g;
|
|
273
|
+
const PY_METHODS_LIST_PATTERN = /methods\s*=\s*\[([^\]]*)\]/;
|
|
274
|
+
const PY_METHOD_LITERAL_PATTERN = /["']([A-Za-z]+)["']/g;
|
|
275
|
+
const ANGULAR_FILE_MARKER_PATTERN = /\b(?:RouterModule|provideRouter|loadChildren|loadComponent)\b|:\s*Routes\b/;
|
|
276
|
+
const ANGULAR_ROUTE_OBJECT_PATTERN = /\{[^{}]*?\bpath\s*:\s*["'`]([^"'`]*)["'`][^{}]*?\}/g;
|
|
277
|
+
const ANGULAR_ROUTE_KEY_PATTERN = /\b(?:component|loadChildren|loadComponent|redirectTo)\s*:/;
|
|
278
|
+
const ANGULAR_COMPONENT_PATTERN = /\b(?:component|loadComponent)\s*:\s*([A-Za-z_$][\w$]*)/;
|
|
279
|
+
const ANGULAR_LAZY_IMPORT_PATTERN = /\b(?:loadChildren|loadComponent)\s*:[\s\S]*?import\s*\(\s*["']([^"']+)["']\s*\)/;
|
|
280
|
+
const TS_LIKE_EXTENSION_PATTERN = /\.(?:ts|tsx|mts|cts|js|jsx|mjs|cjs)$/;
|
|
281
|
+
/** Join route segments (controller prefix + method path) into one clean path. */
|
|
282
|
+
function joinRouteSegments(...segments) {
|
|
283
|
+
return segments
|
|
284
|
+
.map((segment) => segment.trim().replace(/^\/+|\/+$/g, ""))
|
|
285
|
+
.filter((segment) => segment.length > 0)
|
|
286
|
+
.join("/");
|
|
287
|
+
}
|
|
288
|
+
/** Controller prefixes in document order, so each method can take the nearest. */
|
|
289
|
+
function nestControllerPrefixes(content) {
|
|
290
|
+
const prefixes = [];
|
|
291
|
+
NEST_CONTROLLER_PATTERN.lastIndex = 0;
|
|
292
|
+
for (const match of content.matchAll(NEST_CONTROLLER_PATTERN)) {
|
|
293
|
+
const arg = match[1] ?? "";
|
|
294
|
+
const pathProp = arg.match(/\bpath\s*:\s*["'`]([^"'`]*)["'`]/);
|
|
295
|
+
const firstString = arg.match(/["'`]([^"'`]*)["'`]/);
|
|
296
|
+
const prefix = pathProp?.[1] ?? firstString?.[1] ?? "";
|
|
297
|
+
prefixes.push({ index: match.index ?? 0, prefix });
|
|
298
|
+
}
|
|
299
|
+
return prefixes;
|
|
300
|
+
}
|
|
301
|
+
function collectNestRoutes(fromPath, content, routes) {
|
|
302
|
+
if (!content.includes("@Controller")) {
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
const controllers = nestControllerPrefixes(content);
|
|
306
|
+
if (controllers.length === 0) {
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
NEST_METHOD_DECORATOR_PATTERN.lastIndex = 0;
|
|
310
|
+
for (const match of content.matchAll(NEST_METHOD_DECORATOR_PATTERN)) {
|
|
311
|
+
const method = match[1];
|
|
312
|
+
if (!method)
|
|
313
|
+
continue;
|
|
314
|
+
const subPath = match[2] ?? "";
|
|
315
|
+
const at = match.index ?? 0;
|
|
316
|
+
let prefix = "";
|
|
317
|
+
for (const controller of controllers) {
|
|
318
|
+
if (controller.index <= at)
|
|
319
|
+
prefix = controller.prefix;
|
|
320
|
+
else
|
|
321
|
+
break;
|
|
322
|
+
}
|
|
323
|
+
routes.push({
|
|
324
|
+
path: normalizeRoutePath(joinRouteSegments(prefix, subPath)),
|
|
325
|
+
handler: fromPath,
|
|
326
|
+
method: method.toUpperCase(),
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
function pythonRouteMethods(args) {
|
|
331
|
+
const listMatch = args.match(PY_METHODS_LIST_PATTERN);
|
|
332
|
+
if (!listMatch?.[1])
|
|
333
|
+
return [];
|
|
334
|
+
PY_METHOD_LITERAL_PATTERN.lastIndex = 0;
|
|
335
|
+
return [...listMatch[1].matchAll(PY_METHOD_LITERAL_PATTERN)].map((method) => method[1].toUpperCase());
|
|
336
|
+
}
|
|
337
|
+
function collectPythonFrameworkRoutes(fromPath, content, routes) {
|
|
338
|
+
// FastAPI / Starlette: @app.get("/x"), @router.post("/y"), @router.websocket("/ws")
|
|
339
|
+
PY_DECORATOR_METHOD_PATTERN.lastIndex = 0;
|
|
340
|
+
for (const match of content.matchAll(PY_DECORATOR_METHOD_PATTERN)) {
|
|
341
|
+
const verb = match[1];
|
|
342
|
+
const routePath = match[2];
|
|
343
|
+
if (!verb || !routePath)
|
|
344
|
+
continue;
|
|
345
|
+
const method = verb.toUpperCase();
|
|
346
|
+
routes.push({
|
|
347
|
+
path: normalizeRoutePath(routePath),
|
|
348
|
+
handler: fromPath,
|
|
349
|
+
method: method === "WEBSOCKET" ? "WS" : method,
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
// FastAPI api_route + Flask route: @app.route("/x", methods=["GET","POST"])
|
|
353
|
+
PY_ROUTE_DECORATOR_PATTERN.lastIndex = 0;
|
|
354
|
+
for (const match of content.matchAll(PY_ROUTE_DECORATOR_PATTERN)) {
|
|
355
|
+
const routePath = match[2];
|
|
356
|
+
if (!routePath)
|
|
357
|
+
continue;
|
|
358
|
+
const methods = pythonRouteMethods(match[3] ?? "");
|
|
359
|
+
const path = normalizeRoutePath(routePath);
|
|
360
|
+
if (methods.length === 0) {
|
|
361
|
+
routes.push({ path, handler: fromPath, method: "GET" });
|
|
362
|
+
continue;
|
|
363
|
+
}
|
|
364
|
+
for (const method of methods) {
|
|
365
|
+
routes.push({ path, handler: fromPath, method });
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
function collectAngularRoutes(fromPath, content, pathLookup, calls, routes) {
|
|
370
|
+
if (!ANGULAR_FILE_MARKER_PATTERN.test(content)) {
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
const bindings = extractImportBindings(fromPath, content, pathLookup);
|
|
374
|
+
ANGULAR_ROUTE_OBJECT_PATTERN.lastIndex = 0;
|
|
375
|
+
for (const match of content.matchAll(ANGULAR_ROUTE_OBJECT_PATTERN)) {
|
|
376
|
+
const body = match[0];
|
|
377
|
+
if (!ANGULAR_ROUTE_KEY_PATTERN.test(body)) {
|
|
378
|
+
continue;
|
|
379
|
+
}
|
|
380
|
+
const routePath = normalizeRoutePath(match[1] ?? "");
|
|
381
|
+
let handlerPath = fromPath;
|
|
382
|
+
let handlerExpression;
|
|
383
|
+
const lazyImport = body.match(ANGULAR_LAZY_IMPORT_PATTERN);
|
|
384
|
+
const component = body.match(ANGULAR_COMPONENT_PATTERN);
|
|
385
|
+
if (lazyImport?.[1]) {
|
|
386
|
+
const target = resolveSpecifier(fromPath, lazyImport[1], pathLookup) ??
|
|
387
|
+
resolveReferenceLiteral(fromPath, lazyImport[1], pathLookup);
|
|
388
|
+
if (target) {
|
|
389
|
+
handlerPath = target;
|
|
390
|
+
handlerExpression = lazyImport[1];
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
else if (component?.[1]) {
|
|
394
|
+
const binding = bindings.get(component[1]);
|
|
395
|
+
if (binding) {
|
|
396
|
+
handlerPath = binding.target;
|
|
397
|
+
handlerExpression = component[1];
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
routes.push({ path: routePath, handler: handlerPath });
|
|
401
|
+
if (handlerPath !== fromPath) {
|
|
402
|
+
calls.push(graphEdge({
|
|
403
|
+
from: fromPath,
|
|
404
|
+
to: handlerPath,
|
|
405
|
+
kind: "route-handler-link",
|
|
406
|
+
confidence: ROUTE_HANDLER_EDGE_CONFIDENCE,
|
|
407
|
+
reason: `Angular route '${routePath}' maps to '${handlerExpression ?? handlerPath}'.`,
|
|
408
|
+
}));
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
export function extractFrameworkRouteEvidence(fromPath, content, pathLookup) {
|
|
413
|
+
const normalized = normalizeGraphPath(fromPath).toLowerCase();
|
|
414
|
+
const calls = [];
|
|
415
|
+
const routes = [];
|
|
416
|
+
if (normalized.endsWith(".py")) {
|
|
417
|
+
collectPythonFrameworkRoutes(fromPath, content, routes);
|
|
418
|
+
}
|
|
419
|
+
else if (TS_LIKE_EXTENSION_PATTERN.test(normalized)) {
|
|
420
|
+
collectNestRoutes(fromPath, content, routes);
|
|
421
|
+
collectAngularRoutes(fromPath, content, pathLookup, calls, routes);
|
|
422
|
+
}
|
|
423
|
+
return { calls, routes };
|
|
424
|
+
}
|
|
425
|
+
export function fallbackRouteEdge(filePath) {
|
|
426
|
+
const normalized = filePath.toLowerCase();
|
|
427
|
+
if (normalized.includes("api/") || normalized.includes("route")) {
|
|
428
|
+
return {
|
|
429
|
+
path: `/${filePath.replaceAll("/", "_")}`,
|
|
430
|
+
handler: filePath,
|
|
431
|
+
method: "GET",
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
return undefined;
|
|
435
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { GraphEdge } from "@audit-tools/shared";
|
|
2
|
+
export declare function extractJsonSchemaReferenceEdges(fromPath: string, content: string, pathLookup: Map<string, string>): GraphEdge[];
|
|
3
|
+
export declare function extractSchemaContractTestEdges(fromPath: string, content: string, pathLookup: Map<string, string>): GraphEdge[];
|
|
4
|
+
export declare function extractBoundedSuiteEdges(pathLookup: Map<string, string>, fileContents: Record<string, string>, graphEdges: GraphEdge[]): GraphEdge[];
|