mustflow 2.75.2 → 2.85.4

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 (70) hide show
  1. package/README.md +40 -3
  2. package/dist/cli/commands/docs.js +86 -2
  3. package/dist/cli/commands/script-pack.js +9 -0
  4. package/dist/cli/i18n/en.js +180 -2
  5. package/dist/cli/i18n/es.js +180 -2
  6. package/dist/cli/i18n/fr.js +180 -2
  7. package/dist/cli/i18n/hi.js +180 -2
  8. package/dist/cli/i18n/ko.js +180 -2
  9. package/dist/cli/i18n/zh.js +180 -2
  10. package/dist/cli/lib/repo-map.js +27 -6
  11. package/dist/cli/lib/run-root-trust.js +15 -1
  12. package/dist/cli/lib/script-pack-registry.js +275 -6
  13. package/dist/cli/lib/validation/index.js +2 -2
  14. package/dist/cli/lib/validation/primitives.js +4 -1
  15. package/dist/cli/script-packs/code-change-impact.js +172 -0
  16. package/dist/cli/script-packs/code-dependency-graph.js +181 -0
  17. package/dist/cli/script-packs/code-export-diff.js +160 -0
  18. package/dist/cli/script-packs/code-outline.js +33 -5
  19. package/dist/cli/script-packs/code-route-outline.js +155 -0
  20. package/dist/cli/script-packs/docs-reference-drift.js +150 -0
  21. package/dist/cli/script-packs/repo-config-chain.js +163 -0
  22. package/dist/cli/script-packs/repo-env-contract.js +156 -0
  23. package/dist/cli/script-packs/repo-related-files.js +161 -0
  24. package/dist/cli/script-packs/repo-secret-risk-scan.js +147 -0
  25. package/dist/core/change-impact.js +383 -0
  26. package/dist/core/change-verification.js +32 -5
  27. package/dist/core/code-outline.js +460 -79
  28. package/dist/core/config-chain.js +595 -0
  29. package/dist/core/config-loading.js +121 -4
  30. package/dist/core/dependency-graph.js +490 -0
  31. package/dist/core/env-contract.js +450 -0
  32. package/dist/core/export-diff.js +359 -0
  33. package/dist/core/line-endings.js +26 -13
  34. package/dist/core/public-json-contracts.js +126 -0
  35. package/dist/core/reference-drift.js +388 -0
  36. package/dist/core/related-files.js +493 -0
  37. package/dist/core/route-outline.js +964 -0
  38. package/dist/core/script-pack-suggestions.js +131 -5
  39. package/dist/core/secret-risk-scan.js +440 -0
  40. package/dist/core/source-anchors.js +13 -1
  41. package/package.json +1 -1
  42. package/schemas/README.md +44 -6
  43. package/schemas/change-impact-report.schema.json +150 -0
  44. package/schemas/code-outline-report.schema.json +1 -1
  45. package/schemas/code-symbol-read-report.schema.json +64 -4
  46. package/schemas/commands.schema.json +12 -0
  47. package/schemas/config-chain-report.schema.json +187 -0
  48. package/schemas/dependency-graph-report.schema.json +149 -0
  49. package/schemas/env-contract-report.schema.json +203 -0
  50. package/schemas/export-diff-report.schema.json +220 -0
  51. package/schemas/reference-drift-report.schema.json +166 -0
  52. package/schemas/related-files-report.schema.json +145 -0
  53. package/schemas/route-outline-report.schema.json +200 -0
  54. package/schemas/secret-risk-scan-report.schema.json +152 -0
  55. package/templates/default/common/.mustflow/config/commands.toml +21 -0
  56. package/templates/default/i18n.toml +21 -9
  57. package/templates/default/locales/en/.mustflow/docs/agent-workflow.md +1 -1
  58. package/templates/default/locales/en/.mustflow/skills/INDEX.md +8 -2
  59. package/templates/default/locales/en/.mustflow/skills/architecture-deepening-review/SKILL.md +28 -11
  60. package/templates/default/locales/en/.mustflow/skills/astro-code-change/SKILL.md +71 -27
  61. package/templates/default/locales/en/.mustflow/skills/cross-agent-session-reference/SKILL.md +146 -0
  62. package/templates/default/locales/en/.mustflow/skills/dependency-upgrade-review/SKILL.md +3 -1
  63. package/templates/default/locales/en/.mustflow/skills/github-contribution-quality-gate/SKILL.md +48 -11
  64. package/templates/default/locales/en/.mustflow/skills/javascript-code-change/SKILL.md +15 -13
  65. package/templates/default/locales/en/.mustflow/skills/node-code-change/SKILL.md +16 -14
  66. package/templates/default/locales/en/.mustflow/skills/routes.toml +21 -9
  67. package/templates/default/locales/en/.mustflow/skills/security-privacy-review/SKILL.md +3 -1
  68. package/templates/default/locales/en/.mustflow/skills/test-suite-performance-review/SKILL.md +314 -0
  69. package/templates/default/locales/en/.mustflow/skills/typescript-code-change/SKILL.md +13 -10
  70. package/templates/default/manifest.toml +15 -1
@@ -0,0 +1,964 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { lstatSync, readdirSync } from 'node:fs';
3
+ import path from 'node:path';
4
+ import { ensureInside, ensureInsideWithoutSymlinks, readFileInsideWithoutSymlinks } from './safe-filesystem.js';
5
+ export const CODE_ROUTE_OUTLINE_SCRIPT_ID = 'route-outline';
6
+ export const CODE_ROUTE_OUTLINE_SCRIPT_REF = `code/${CODE_ROUTE_OUTLINE_SCRIPT_ID}`;
7
+ const DEFAULT_MAX_FILE_BYTES = 1024 * 1024;
8
+ const DEFAULT_MAX_FILES = 200;
9
+ const ROUTE_OUTLINE_EXTENSIONS = ['.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs', '.rs'];
10
+ const ROUTE_METHODS = [
11
+ 'get',
12
+ 'post',
13
+ 'put',
14
+ 'patch',
15
+ 'delete',
16
+ 'options',
17
+ 'head',
18
+ 'all',
19
+ 'any',
20
+ 'use',
21
+ 'route',
22
+ 'nest',
23
+ 'merge',
24
+ 'fallback',
25
+ ];
26
+ const AXUM_ROUTER_METHODS = ['route', 'nest', 'merge', 'fallback'];
27
+ const AXUM_HTTP_METHODS = ['get', 'post', 'put', 'patch', 'delete', 'options', 'head', 'any'];
28
+ const NESTJS_CONTROLLER_METHODS = ['get', 'post', 'put', 'patch', 'delete', 'options', 'head', 'all'];
29
+ const NESTJS_LIFECYCLE_DECORATORS = ['useGuards', 'useInterceptors', 'usePipes', 'useFilters'];
30
+ const LIFECYCLE_METHODS = [
31
+ 'guard',
32
+ 'resolve',
33
+ 'derive',
34
+ 'use',
35
+ 'decorate',
36
+ 'onBeforeHandle',
37
+ 'beforeHandle',
38
+ 'onRequest',
39
+ 'onAfterHandle',
40
+ 'onError',
41
+ ];
42
+ const IGNORED_DIRECTORIES = [
43
+ '.git',
44
+ '.mustflow/cache',
45
+ '.mustflow/state',
46
+ 'node_modules',
47
+ 'dist',
48
+ 'build',
49
+ 'coverage',
50
+ '.next',
51
+ '.turbo',
52
+ ];
53
+ const ERROR_CODES = new Set([
54
+ 'code_route_outline_path_outside_root',
55
+ 'code_route_outline_unreadable_path',
56
+ 'code_route_outline_file_too_large',
57
+ 'code_route_outline_max_files_exceeded',
58
+ ]);
59
+ function normalizeRelativePath(value) {
60
+ return value.replace(/\\/gu, '/').replace(/^\.\/+/u, '') || '.';
61
+ }
62
+ function sha256Tagged(buffer) {
63
+ return `sha256:${createHash('sha256').update(buffer).digest('hex')}`;
64
+ }
65
+ function languageForPath(filePath) {
66
+ switch (path.extname(filePath).toLowerCase()) {
67
+ case '.ts':
68
+ case '.mts':
69
+ case '.cts':
70
+ return 'typescript';
71
+ case '.tsx':
72
+ return 'tsx';
73
+ case '.js':
74
+ return 'javascript';
75
+ case '.jsx':
76
+ return 'jsx';
77
+ case '.mjs':
78
+ return 'javascript-module';
79
+ case '.cjs':
80
+ return 'javascript-commonjs';
81
+ case '.rs':
82
+ return 'rust';
83
+ default:
84
+ return null;
85
+ }
86
+ }
87
+ function isIgnoredDirectory(relativePath) {
88
+ const normalized = normalizeRelativePath(relativePath);
89
+ return IGNORED_DIRECTORIES.some((directory) => normalized === directory || normalized.startsWith(`${directory}/`));
90
+ }
91
+ function makeFinding(code, severity, pathValue, message) {
92
+ return { code, severity, path: pathValue, message };
93
+ }
94
+ function addMaxFilesFinding(findings, issues, policy, pathValue) {
95
+ if (findings.some((finding) => finding.code === 'code_route_outline_max_files_exceeded')) {
96
+ return;
97
+ }
98
+ const message = `Route outline matched more than ${policy.max_files} files; remaining files were skipped.`;
99
+ issues.push(`${pathValue}: ${message}`);
100
+ findings.push(makeFinding('code_route_outline_max_files_exceeded', 'high', pathValue, message));
101
+ }
102
+ function normalizeTargetPath(projectRoot, targetPath) {
103
+ const absolutePath = path.resolve(process.cwd(), targetPath);
104
+ ensureInside(projectRoot, absolutePath);
105
+ return {
106
+ absolutePath,
107
+ relativePath: normalizeRelativePath(path.relative(projectRoot, absolutePath)),
108
+ };
109
+ }
110
+ function collectCandidatesFromDirectory(projectRoot, absoluteDirectory, candidates, findings, issues, policy) {
111
+ const relativeDirectory = normalizeRelativePath(path.relative(projectRoot, absoluteDirectory));
112
+ if (isIgnoredDirectory(relativeDirectory)) {
113
+ return;
114
+ }
115
+ let entries;
116
+ try {
117
+ ensureInsideWithoutSymlinks(projectRoot, absoluteDirectory);
118
+ entries = readdirSync(absoluteDirectory, { withFileTypes: true });
119
+ }
120
+ catch (error) {
121
+ const message = error instanceof Error ? error.message : String(error);
122
+ issues.push(`${relativeDirectory}: ${message}`);
123
+ findings.push(makeFinding('code_route_outline_unreadable_path', 'high', relativeDirectory, message));
124
+ return;
125
+ }
126
+ for (const entry of entries) {
127
+ if (candidates.length >= policy.max_files) {
128
+ addMaxFilesFinding(findings, issues, policy, relativeDirectory);
129
+ return;
130
+ }
131
+ const absoluteEntry = path.join(absoluteDirectory, entry.name);
132
+ const relativeEntry = normalizeRelativePath(path.relative(projectRoot, absoluteEntry));
133
+ if (entry.isDirectory()) {
134
+ collectCandidatesFromDirectory(projectRoot, absoluteEntry, candidates, findings, issues, policy);
135
+ continue;
136
+ }
137
+ if (!entry.isFile()) {
138
+ continue;
139
+ }
140
+ const language = languageForPath(absoluteEntry);
141
+ if (language) {
142
+ candidates.push({ absolutePath: absoluteEntry, relativePath: relativeEntry, language });
143
+ }
144
+ }
145
+ }
146
+ function collectSourceCandidates(projectRoot, options, policy, findings, issues) {
147
+ const candidates = [];
148
+ for (const targetPath of options.paths) {
149
+ let absolutePath;
150
+ let relativePath;
151
+ try {
152
+ const normalized = normalizeTargetPath(projectRoot, targetPath);
153
+ absolutePath = normalized.absolutePath;
154
+ relativePath = normalized.relativePath;
155
+ ensureInsideWithoutSymlinks(projectRoot, absolutePath);
156
+ }
157
+ catch (error) {
158
+ const message = error instanceof Error ? error.message : String(error);
159
+ issues.push(message);
160
+ findings.push(makeFinding('code_route_outline_path_outside_root', 'high', targetPath, message));
161
+ continue;
162
+ }
163
+ let stats;
164
+ try {
165
+ stats = lstatSync(absolutePath);
166
+ }
167
+ catch (error) {
168
+ const message = error instanceof Error ? error.message : String(error);
169
+ issues.push(`${relativePath}: ${message}`);
170
+ findings.push(makeFinding('code_route_outline_unreadable_path', 'high', relativePath, message));
171
+ continue;
172
+ }
173
+ if (stats.isDirectory()) {
174
+ collectCandidatesFromDirectory(projectRoot, absolutePath, candidates, findings, issues, policy);
175
+ continue;
176
+ }
177
+ if (!stats.isFile()) {
178
+ findings.push(makeFinding('code_route_outline_unsupported_file', 'low', relativePath, `${relativePath} is not a regular source file.`));
179
+ continue;
180
+ }
181
+ const language = languageForPath(absolutePath);
182
+ if (!language) {
183
+ findings.push(makeFinding('code_route_outline_unsupported_file', 'low', relativePath, `${relativePath} is not a supported route source file.`));
184
+ continue;
185
+ }
186
+ candidates.push({ absolutePath, relativePath, language });
187
+ }
188
+ if (candidates.length > policy.max_files) {
189
+ const overflow = candidates.length - policy.max_files;
190
+ findings.push(makeFinding('code_route_outline_max_files_exceeded', 'high', '.', `Route outline matched ${candidates.length} files; max_files is ${policy.max_files}. ${overflow} files were skipped.`));
191
+ }
192
+ return candidates.slice(0, policy.max_files);
193
+ }
194
+ function frameworkEvidence(text) {
195
+ const evidence = new Set();
196
+ if (/(?:from\s+['"]hono['"]|new\s+Hono\b|createFactory\s*\()/u.test(text)) {
197
+ evidence.add('hono');
198
+ }
199
+ if (/(?:from\s+['"]elysia['"]|new\s+Elysia\b|\bt\.(?:Object|String|Number|Boolean)\s*\()/u.test(text)) {
200
+ evidence.add('elysia');
201
+ }
202
+ if (/(?:use\s+axum::|\baxum::Router\b|\bRouter::new\s*\(|\brouting::\{[^}]*\bget\b)/u.test(text)) {
203
+ evidence.add('axum');
204
+ }
205
+ if (/(?:from\s+['"]@nestjs\/(?:common|core)['"]|@Controller\s*\(|@Get\s*\(|@UseGuards\s*\(|NestFactory\.create\s*\()/u.test(text)) {
206
+ evidence.add('nestjs');
207
+ }
208
+ return [...evidence].sort((left, right) => left.localeCompare(right));
209
+ }
210
+ function stripStringsAndComments(line) {
211
+ return line
212
+ .replace(/\/\/.*$/u, '')
213
+ .replace(/"(?:\\.|[^"\\])*"/gu, '""')
214
+ .replace(/'(?:\\.|[^'\\])*'/gu, "''")
215
+ .replace(/`(?:\\.|[^`\\])*`/gu, '``');
216
+ }
217
+ function countCharacterDelta(line, open, close) {
218
+ let delta = 0;
219
+ for (const character of stripStringsAndComments(line)) {
220
+ if (character === open) {
221
+ delta += 1;
222
+ }
223
+ else if (character === close) {
224
+ delta -= 1;
225
+ }
226
+ }
227
+ return delta;
228
+ }
229
+ function findChainStartLine(lines, routeIndex) {
230
+ let start = routeIndex;
231
+ const minIndex = Math.max(0, routeIndex - 24);
232
+ let index = routeIndex - 1;
233
+ while (index >= minIndex) {
234
+ const trimmed = (lines[index] ?? '').trim();
235
+ if (trimmed.length === 0 || trimmed.endsWith(';')) {
236
+ break;
237
+ }
238
+ if (/^(?:\.|const\b|let\b|var\b|export\b|return\b)|new\s+Elysia\b|new\s+Hono\b|Router::new\s*\(/u.test(trimmed)) {
239
+ start = index;
240
+ index -= 1;
241
+ continue;
242
+ }
243
+ break;
244
+ }
245
+ return start + 1;
246
+ }
247
+ function findChainEndLine(lines, routeIndex) {
248
+ let balance = 0;
249
+ let sawRouteCall = false;
250
+ const maxIndex = Math.min(lines.length, routeIndex + 40);
251
+ let index = routeIndex;
252
+ while (index < maxIndex) {
253
+ const line = lines[index] ?? '';
254
+ if (ROUTE_METHODS.some((method) => new RegExp(`\\.\\s*${method}\\s*\\(`, 'u').test(line))) {
255
+ sawRouteCall = true;
256
+ }
257
+ balance += countCharacterDelta(line, '(', ')');
258
+ if (sawRouteCall && balance <= 0 && /[);]\s*$/u.test(line.trim())) {
259
+ return index + 1;
260
+ }
261
+ index += 1;
262
+ }
263
+ return routeIndex + 1;
264
+ }
265
+ function lineContainsRouteMethod(line) {
266
+ for (const method of ROUTE_METHODS) {
267
+ if (new RegExp(`\\.\\s*${method}\\s*\\(`, 'u').test(line)) {
268
+ return method;
269
+ }
270
+ }
271
+ return null;
272
+ }
273
+ function lineContainsAxumRouterMethod(line) {
274
+ for (const method of AXUM_ROUTER_METHODS) {
275
+ if (new RegExp(`\\.\\s*${method}\\s*\\(`, 'u').test(line)) {
276
+ return method;
277
+ }
278
+ }
279
+ return null;
280
+ }
281
+ function extractRoutePath(statement, method) {
282
+ const match = new RegExp(`\\.\\s*${method}\\s*\\(\\s*(?<quote>['"\`])(?<path>(?:\\\\.|(?!\\k<quote>)[^\\\\])*)\\k<quote>`, 'u').exec(statement);
283
+ if (!match?.groups?.path) {
284
+ return null;
285
+ }
286
+ const routePath = match.groups.path.replace(/\\(['"`\\/])/gu, '$1');
287
+ return routePath.includes('${') ? null : routePath;
288
+ }
289
+ function extractRustStringRoutePath(statement, method) {
290
+ const match = new RegExp(`\\.\\s*${method}\\s*\\(\\s*"(?<path>(?:\\\\.|[^"\\\\])*)"`, 'u').exec(statement);
291
+ if (!match?.groups?.path) {
292
+ return null;
293
+ }
294
+ return match.groups.path.replace(/\\"/gu, '"').replace(/\\\\/gu, '\\');
295
+ }
296
+ function offsetForStatementLine(lines, lineOffset) {
297
+ if (lineOffset <= 0) {
298
+ return 0;
299
+ }
300
+ return lines.slice(0, lineOffset).join('\n').length + 1;
301
+ }
302
+ function findMatchingParenEnd(value, openParenIndex) {
303
+ let depth = 0;
304
+ let quote = null;
305
+ let escaped = false;
306
+ let index = openParenIndex;
307
+ while (index < value.length) {
308
+ const character = value[index];
309
+ if (quote) {
310
+ if (escaped) {
311
+ escaped = false;
312
+ }
313
+ else if (character === '\\') {
314
+ escaped = true;
315
+ }
316
+ else if (character === quote) {
317
+ quote = null;
318
+ }
319
+ index += 1;
320
+ continue;
321
+ }
322
+ if (character === '"' || character === "'" || character === '`') {
323
+ quote = character;
324
+ index += 1;
325
+ continue;
326
+ }
327
+ if (character === '(') {
328
+ depth += 1;
329
+ index += 1;
330
+ continue;
331
+ }
332
+ if (character === ')') {
333
+ depth -= 1;
334
+ if (depth === 0) {
335
+ return index + 1;
336
+ }
337
+ }
338
+ index += 1;
339
+ }
340
+ return value.length;
341
+ }
342
+ function extractMethodCallSegment(statement, statementLines, method, routeLineOffset) {
343
+ const searchStart = offsetForStatementLine(statementLines, routeLineOffset);
344
+ const callMatch = new RegExp(`\\.\\s*${method}\\s*\\(`, 'u').exec(statement.slice(searchStart));
345
+ if (!callMatch) {
346
+ return { value: statement, startOffset: 0 };
347
+ }
348
+ const startOffset = searchStart + callMatch.index;
349
+ const openParenIndex = startOffset + callMatch[0].lastIndexOf('(');
350
+ const endOffset = findMatchingParenEnd(statement, openParenIndex);
351
+ return { value: statement.slice(startOffset, endOffset), startOffset };
352
+ }
353
+ function extractLifecycle(statementBeforeRoute) {
354
+ const values = [];
355
+ for (const lifecycle of LIFECYCLE_METHODS) {
356
+ if (new RegExp(`\\.\\s*${lifecycle}\\s*\\(`, 'u').test(statementBeforeRoute)) {
357
+ values.push(lifecycle);
358
+ }
359
+ }
360
+ return values;
361
+ }
362
+ function lineNumberForOffset(statement, startLine, offset) {
363
+ return startLine + statement.slice(0, offset).split('\n').length - 1;
364
+ }
365
+ function extractLeadingRustExpressionName(value) {
366
+ const match = /^\s*(?<name>[A-Za-z_]\w*(?:::[A-Za-z_]\w*)*)\b/u.exec(value);
367
+ return match?.groups?.name ?? null;
368
+ }
369
+ function extractAxumAdapterHandler(statement, method) {
370
+ const callStart = new RegExp(`\\.\\s*${method}\\s*\\(`, 'u').exec(statement);
371
+ if (!callStart) {
372
+ return null;
373
+ }
374
+ const afterCall = statement.slice(callStart.index + callStart[0].length);
375
+ if (method === 'fallback' || method === 'merge') {
376
+ return extractLeadingRustExpressionName(afterCall);
377
+ }
378
+ const commaIndex = afterCall.indexOf(',');
379
+ if (commaIndex === -1) {
380
+ return null;
381
+ }
382
+ return extractLeadingRustExpressionName(afterCall.slice(commaIndex + 1));
383
+ }
384
+ function extractAxumRouteEntries(relativePath, language, contentSha256, lines, index, method) {
385
+ const chainStartLine = findChainStartLine(lines, index);
386
+ const chainEndLine = findChainEndLine(lines, index);
387
+ const statementLines = lines.slice(chainStartLine - 1, chainEndLine);
388
+ const statement = statementLines.join('\n');
389
+ const routeLineOffset = index - (chainStartLine - 1);
390
+ const callSegment = extractMethodCallSegment(statement, statementLines, method, routeLineOffset);
391
+ const routePath = extractRustStringRoutePath(callSegment.value, method);
392
+ const signature = compactSignature(lines, chainStartLine, chainEndLine);
393
+ if (method !== 'route') {
394
+ const handlerName = extractAxumAdapterHandler(callSegment.value, method);
395
+ return [
396
+ {
397
+ id: `${relativePath}:${index + 1}:axum:${method}:${routePath ?? '<dynamic>'}`,
398
+ path: relativePath,
399
+ language,
400
+ framework: 'axum',
401
+ method,
402
+ route_path: routePath,
403
+ line: index + 1,
404
+ chain_start_line: chainStartLine,
405
+ chain_end_line: chainEndLine,
406
+ handler_line: index + 1,
407
+ handler_name: handlerName,
408
+ lifecycle: [],
409
+ signature,
410
+ content_sha256: contentSha256,
411
+ },
412
+ ];
413
+ }
414
+ const entries = [];
415
+ const methodPattern = new RegExp(`\\b(?<method>${AXUM_HTTP_METHODS.join('|')})\\s*\\(\\s*(?<handler>[A-Za-z_]\\w*(?:::[A-Za-z_]\\w*)*)`, 'gu');
416
+ for (const match of callSegment.value.matchAll(methodPattern)) {
417
+ const httpMethod = match.groups?.method;
418
+ const handlerName = match.groups?.handler;
419
+ if (!httpMethod || !handlerName) {
420
+ continue;
421
+ }
422
+ const handlerLine = lineNumberForOffset(statement, chainStartLine, callSegment.startOffset + (match.index ?? 0));
423
+ const routeKey = routePath ?? '<dynamic>';
424
+ entries.push({
425
+ id: `${relativePath}:${handlerLine}:axum:${httpMethod}:${routeKey}:${handlerName}`,
426
+ path: relativePath,
427
+ language,
428
+ framework: 'axum',
429
+ method: httpMethod,
430
+ route_path: routePath,
431
+ line: index + 1,
432
+ chain_start_line: chainStartLine,
433
+ chain_end_line: chainEndLine,
434
+ handler_line: handlerLine,
435
+ handler_name: handlerName,
436
+ lifecycle: [],
437
+ signature,
438
+ content_sha256: contentSha256,
439
+ });
440
+ }
441
+ if (entries.length > 0) {
442
+ return entries;
443
+ }
444
+ const handlerName = extractAxumAdapterHandler(callSegment.value, method);
445
+ return [
446
+ {
447
+ id: `${relativePath}:${index + 1}:axum:${method}:${routePath ?? '<dynamic>'}`,
448
+ path: relativePath,
449
+ language,
450
+ framework: 'axum',
451
+ method,
452
+ route_path: routePath,
453
+ line: index + 1,
454
+ chain_start_line: chainStartLine,
455
+ chain_end_line: chainEndLine,
456
+ handler_line: index + 1,
457
+ handler_name: handlerName,
458
+ lifecycle: [],
459
+ signature,
460
+ content_sha256: contentSha256,
461
+ },
462
+ ];
463
+ }
464
+ function frameworkBindings(lines) {
465
+ const bindings = new Map();
466
+ for (const line of lines) {
467
+ const honoMatch = /\b(?:export\s+)?(?:const|let|var)\s+(?<name>[$A-Z_a-z][$\w]*)\s*=\s*new\s+Hono\b/u.exec(line);
468
+ if (honoMatch?.groups?.name) {
469
+ bindings.set(honoMatch.groups.name, 'hono');
470
+ }
471
+ const elysiaMatch = /\b(?:export\s+)?(?:const|let|var)\s+(?<name>[$A-Z_a-z][$\w]*)\s*=\s*new\s+Elysia\b/u.exec(line);
472
+ if (elysiaMatch?.groups?.name) {
473
+ bindings.set(elysiaMatch.groups.name, 'elysia');
474
+ }
475
+ }
476
+ return bindings;
477
+ }
478
+ function routeReceiver(line, method) {
479
+ const match = new RegExp(`\\b(?<receiver>[$A-Z_a-z][$\\w]*)\\s*\\.\\s*${method}\\s*\\(`, 'u').exec(line);
480
+ return match?.groups?.receiver ?? null;
481
+ }
482
+ function inferFramework(statement, fileEvidence, bindings, receiver) {
483
+ if (receiver) {
484
+ const boundFramework = bindings.get(receiver);
485
+ if (boundFramework) {
486
+ return boundFramework;
487
+ }
488
+ }
489
+ if (/new\s+Elysia\b|\.(?:guard|resolve|derive|decorate|onBeforeHandle|beforeHandle|onAfterHandle|onError)\s*\(/u.test(statement)) {
490
+ return 'elysia';
491
+ }
492
+ if (/new\s+Hono\b|from\s+['"]hono['"]/u.test(statement)) {
493
+ return 'hono';
494
+ }
495
+ if (/\bRouter::new\s*\(|\brouting::\{|\b(?:get|post|put|patch|delete|options|head|any)\s*\(/u.test(statement)) {
496
+ return 'axum';
497
+ }
498
+ if (/@(?:Controller|Get|Post|Put|Patch|Delete|Options|Head|All)\b/u.test(statement)) {
499
+ return 'nestjs';
500
+ }
501
+ if (fileEvidence.length === 1) {
502
+ return fileEvidence[0] ?? 'unknown';
503
+ }
504
+ return 'unknown';
505
+ }
506
+ function compactSignature(lines, startLine, endLine) {
507
+ const parts = [];
508
+ const maxIndex = Math.min(endLine, startLine + 5);
509
+ let index = startLine - 1;
510
+ while (index < maxIndex) {
511
+ const trimmed = (lines[index] ?? '').trim();
512
+ if (trimmed.length > 0) {
513
+ parts.push(trimmed);
514
+ }
515
+ index += 1;
516
+ }
517
+ const signature = parts.join(' ').replace(/\s+/gu, ' ');
518
+ return signature.length > 240 ? `${signature.slice(0, 237)}...` : signature;
519
+ }
520
+ function extractRoutesFromFile(relativePath, language, contentSha256, text) {
521
+ const evidence = frameworkEvidence(text);
522
+ if (evidence.length === 0) {
523
+ return [];
524
+ }
525
+ const lines = text.split(/\r\n|\r|\n/u);
526
+ const routes = [];
527
+ const bindings = frameworkBindings(lines);
528
+ const nestjsBlocks = evidence.includes('nestjs') ? findNestjsControllerBlocks(lines) : [];
529
+ for (const block of nestjsBlocks) {
530
+ routes.push(...extractNestjsRouteEntries(relativePath, language, contentSha256, lines, block));
531
+ }
532
+ for (const [index, line] of lines.entries()) {
533
+ if (language === 'rust') {
534
+ const axumMethod = lineContainsAxumRouterMethod(line);
535
+ if (!axumMethod) {
536
+ continue;
537
+ }
538
+ routes.push(...extractAxumRouteEntries(relativePath, language, contentSha256, lines, index, axumMethod));
539
+ continue;
540
+ }
541
+ const method = lineContainsRouteMethod(line);
542
+ if (!method) {
543
+ continue;
544
+ }
545
+ if (nestjsBlocks.some((block) => index + 1 >= block.startLine && index + 1 <= block.endLine)) {
546
+ continue;
547
+ }
548
+ const chainStartLine = findChainStartLine(lines, index);
549
+ const chainEndLine = findChainEndLine(lines, index);
550
+ const statementLines = lines.slice(chainStartLine - 1, chainEndLine);
551
+ const statement = statementLines.join('\n');
552
+ const routeLineOffset = index - (chainStartLine - 1);
553
+ const statementBeforeRoute = statementLines.slice(0, routeLineOffset).join('\n');
554
+ const routePath = extractRoutePath(statement, method);
555
+ const framework = inferFramework(statement, evidence, bindings, routeReceiver(line, method));
556
+ const routeKey = routePath ?? '<dynamic>';
557
+ routes.push({
558
+ id: `${relativePath}:${index + 1}:${framework}:${method}:${routeKey}`,
559
+ path: relativePath,
560
+ language,
561
+ framework,
562
+ method,
563
+ route_path: routePath,
564
+ line: index + 1,
565
+ chain_start_line: chainStartLine,
566
+ chain_end_line: chainEndLine,
567
+ handler_line: index + 1,
568
+ handler_name: null,
569
+ lifecycle: extractLifecycle(statementBeforeRoute),
570
+ signature: compactSignature(lines, chainStartLine, chainEndLine),
571
+ content_sha256: contentSha256,
572
+ });
573
+ }
574
+ return routes;
575
+ }
576
+ function createInputHash(policy, files, routes, findings) {
577
+ const inputState = {
578
+ policy,
579
+ files: files.map((file) => ({
580
+ path: file.path,
581
+ sha256: file.sha256,
582
+ size_bytes: file.size_bytes,
583
+ route_count: file.route_count,
584
+ })),
585
+ routes: routes.map((route) => ({
586
+ id: route.id,
587
+ path: route.path,
588
+ framework: route.framework,
589
+ method: route.method,
590
+ route_path: route.route_path,
591
+ line: route.line,
592
+ })),
593
+ input_errors: findings
594
+ .filter((finding) => ERROR_CODES.has(finding.code))
595
+ .map((finding) => ({ code: finding.code, path: finding.path })),
596
+ };
597
+ return sha256Tagged(JSON.stringify(inputState));
598
+ }
599
+ function outlineStatus(findings) {
600
+ return findings.some((finding) => ERROR_CODES.has(finding.code))
601
+ ? 'error'
602
+ : findings.length > 0
603
+ ? 'failed'
604
+ : 'passed';
605
+ }
606
+ export function inspectRouteOutline(projectRoot, options) {
607
+ const root = path.resolve(projectRoot);
608
+ const policy = {
609
+ max_file_bytes: options.maxFileBytes ?? DEFAULT_MAX_FILE_BYTES,
610
+ max_files: options.maxFiles ?? DEFAULT_MAX_FILES,
611
+ extensions: [...ROUTE_OUTLINE_EXTENSIONS],
612
+ ignored_directories: [...IGNORED_DIRECTORIES],
613
+ };
614
+ const files = [];
615
+ const routes = [];
616
+ const findings = [];
617
+ const issues = [];
618
+ const candidates = collectSourceCandidates(root, options, policy, findings, issues);
619
+ for (const candidate of candidates) {
620
+ let buffer;
621
+ try {
622
+ buffer = readFileInsideWithoutSymlinks(root, candidate.absolutePath, { maxBytes: policy.max_file_bytes });
623
+ }
624
+ catch (error) {
625
+ const message = error instanceof Error ? error.message : String(error);
626
+ const code = message.includes('exceeds maximum size')
627
+ ? 'code_route_outline_file_too_large'
628
+ : 'code_route_outline_unreadable_path';
629
+ issues.push(`${candidate.relativePath}: ${message}`);
630
+ findings.push(makeFinding(code, 'high', candidate.relativePath, message));
631
+ continue;
632
+ }
633
+ const contentSha256 = sha256Tagged(buffer);
634
+ const text = buffer.toString('utf8');
635
+ const fileRoutes = extractRoutesFromFile(candidate.relativePath, candidate.language, contentSha256, text);
636
+ routes.push(...fileRoutes);
637
+ files.push({
638
+ kind: 'source_file',
639
+ path: candidate.relativePath,
640
+ language: candidate.language,
641
+ framework_evidence: frameworkEvidence(text),
642
+ sha256: contentSha256,
643
+ size_bytes: buffer.byteLength,
644
+ line_count: text.split(/\r\n|\r|\n/u).length,
645
+ route_count: fileRoutes.length,
646
+ });
647
+ }
648
+ const status = outlineStatus(findings);
649
+ const sortedRoutes = routes.sort((left, right) => left.path.localeCompare(right.path) || left.line - right.line);
650
+ return {
651
+ schema_version: '1',
652
+ command: 'script-pack',
653
+ pack_id: 'code',
654
+ script_id: CODE_ROUTE_OUTLINE_SCRIPT_ID,
655
+ script_ref: CODE_ROUTE_OUTLINE_SCRIPT_REF,
656
+ action: 'scan',
657
+ status,
658
+ ok: status === 'passed',
659
+ mustflow_root: root,
660
+ policy,
661
+ input_hash: createInputHash(policy, files, sortedRoutes, findings),
662
+ files,
663
+ routes: sortedRoutes,
664
+ findings,
665
+ issues,
666
+ };
667
+ }
668
+ function findNestjsControllerBlocks(lines) {
669
+ const blocks = [];
670
+ let index = 0;
671
+ while (index < lines.length) {
672
+ const line = lines[index] ?? '';
673
+ if (!/@Controller\s*\(/.test(line)) {
674
+ index += 1;
675
+ continue;
676
+ }
677
+ const controllerPath = extractNestjsControllerPath(line);
678
+ if (controllerPath === null) {
679
+ index += 1;
680
+ continue;
681
+ }
682
+ const classLine = findNextClassDeclaration(lines, index + 1);
683
+ if (classLine === null) {
684
+ index += 1;
685
+ continue;
686
+ }
687
+ const endLine = findClassBodyEnd(lines, classLine);
688
+ if (endLine === null) {
689
+ index += 1;
690
+ continue;
691
+ }
692
+ const className = extractClassName(lines[classLine] ?? '') ?? '<anonymous>';
693
+ blocks.push({
694
+ startLine: index + 1,
695
+ endLine: endLine + 1,
696
+ className,
697
+ controllerPath,
698
+ });
699
+ index = endLine + 1;
700
+ }
701
+ return blocks;
702
+ }
703
+ function extractNestjsControllerPath(decoratorLine) {
704
+ const callStart = /@Controller\s*\(/.exec(decoratorLine);
705
+ if (!callStart) {
706
+ return null;
707
+ }
708
+ const openParenIndex = callStart.index + callStart[0].lastIndexOf('(');
709
+ const endIndex = findMatchingParenEnd(decoratorLine, openParenIndex);
710
+ if (endIndex <= openParenIndex) {
711
+ return null;
712
+ }
713
+ const argument = decoratorLine.slice(openParenIndex + 1, endIndex - 1).trim();
714
+ if (argument.length === 0) {
715
+ return '';
716
+ }
717
+ const quoted = argument.match(/^(['"])((?:\\.|(?!\1)[^\\])*)\1/u);
718
+ if (quoted?.[2] !== undefined) {
719
+ return quoted[2].replace(/\\(['"`\\/])/gu, '$1');
720
+ }
721
+ const optionsMatch = argument.match(/path\s*:\s*(['"])((?:\\.|(?!\2)[^\\])*)\2/u);
722
+ if (optionsMatch?.[2] !== undefined) {
723
+ return optionsMatch[2].replace(/\\(['"`\\/])/gu, '$1');
724
+ }
725
+ return null;
726
+ }
727
+ function findNextClassDeclaration(lines, fromIndex) {
728
+ let index = fromIndex;
729
+ while (index < lines.length) {
730
+ const trimmed = (lines[index] ?? '').trim();
731
+ if (trimmed.length === 0) {
732
+ index += 1;
733
+ continue;
734
+ }
735
+ if (/\b(?:export\s+)?(?:abstract\s+)?class\s+[$A-Z_a-z][$\w]*/u.test(trimmed)) {
736
+ return index;
737
+ }
738
+ if (/@[A-Z]\w*/u.test(trimmed)) {
739
+ index += 1;
740
+ continue;
741
+ }
742
+ return null;
743
+ }
744
+ return null;
745
+ }
746
+ function findClassBodyEnd(lines, classLine) {
747
+ const openBraceIndex = (lines[classLine] ?? '').indexOf('{');
748
+ if (openBraceIndex === -1) {
749
+ return null;
750
+ }
751
+ let depth = 0;
752
+ let index = classLine;
753
+ while (index < lines.length) {
754
+ depth += countCharacterDelta(lines[index] ?? '', '{', '}');
755
+ if (depth <= 0) {
756
+ return index;
757
+ }
758
+ index += 1;
759
+ }
760
+ return null;
761
+ }
762
+ function extractClassName(classLine) {
763
+ const match = /\bclass\s+(?<name>[$A-Z_a-z][$\w]*)/u.exec(classLine);
764
+ return match?.groups?.name ?? null;
765
+ }
766
+ function extractNestjsMethodPath(decoratorLine) {
767
+ const callStart = /(?:^|\s)@(?<method>Get|Post|Put|Patch|Delete|Options|Head|All)\s*\(/.exec(decoratorLine);
768
+ if (!callStart) {
769
+ return null;
770
+ }
771
+ const openParenIndex = callStart.index + callStart[0].lastIndexOf('(');
772
+ const endIndex = findMatchingParenEnd(decoratorLine, openParenIndex);
773
+ if (endIndex <= openParenIndex) {
774
+ return null;
775
+ }
776
+ const argument = decoratorLine.slice(openParenIndex + 1, endIndex - 1).trim();
777
+ if (argument.length === 0) {
778
+ return '';
779
+ }
780
+ const quoted = argument.match(/^(['"])((?:\\.|(?!\1)[^\\])*)\1/u);
781
+ if (quoted?.[2] !== undefined) {
782
+ return quoted[2].replace(/\\(['"`\\/])/gu, '$1');
783
+ }
784
+ const pathMatch = argument.match(/path\s*:\s*(['"])((?:\\.|(?!\2)[^\\])*)\2/u);
785
+ if (pathMatch?.[2] !== undefined) {
786
+ return pathMatch[2].replace(/\\(['"`\\/])/gu, '$1');
787
+ }
788
+ return null;
789
+ }
790
+ function joinNestjsRoutePath(controllerPath, methodPath) {
791
+ const left = controllerPath.replace(/\/+$/u, '');
792
+ const right = methodPath.replace(/^\/+/u, '');
793
+ if (left.length === 0) {
794
+ return right.length === 0 ? '/' : `/${right}`;
795
+ }
796
+ if (right.length === 0) {
797
+ return `/${left}`;
798
+ }
799
+ return `/${left}/${right}`;
800
+ }
801
+ function mergeNestjsLifecycle(left, right) {
802
+ const values = [];
803
+ const seen = new Set();
804
+ for (const value of [...left, ...right]) {
805
+ if (seen.has(value)) {
806
+ continue;
807
+ }
808
+ seen.add(value);
809
+ values.push(value);
810
+ }
811
+ return values;
812
+ }
813
+ function extractNestjsLifecycleDecorator(line) {
814
+ const match = /@(?<decorator>UseGuards|UseInterceptors|UsePipes|UseFilters)\b/u.exec(line);
815
+ if (!match?.groups?.decorator) {
816
+ return null;
817
+ }
818
+ const lifecycle = `${match.groups.decorator.charAt(0).toLowerCase()}${match.groups.decorator.slice(1)}`;
819
+ return NESTJS_LIFECYCLE_DECORATORS.includes(lifecycle)
820
+ ? lifecycle
821
+ : null;
822
+ }
823
+ function extractNestjsHandlerName(line) {
824
+ const signature = line.trimStart();
825
+ if (signature.length === 0) {
826
+ return null;
827
+ }
828
+ let offset = signature.startsWith('*') ? 1 : 0;
829
+ offset = skipAsciiWhitespace(signature, offset);
830
+ while (true) {
831
+ const next = readIdentifier(signature, offset);
832
+ if (next === null || !isNestjsHandlerModifier(next.value)) {
833
+ break;
834
+ }
835
+ offset = skipAsciiWhitespace(signature, next.end);
836
+ }
837
+ if (signature.charAt(offset) === '*') {
838
+ offset = skipAsciiWhitespace(signature, offset + 1);
839
+ }
840
+ const nameToken = readIdentifier(signature, offset);
841
+ if (nameToken === null) {
842
+ return null;
843
+ }
844
+ const callStart = skipAsciiWhitespace(signature, nameToken.end);
845
+ if (signature.charAt(callStart) !== '(') {
846
+ return null;
847
+ }
848
+ const name = nameToken.value;
849
+ if (/\b(?:if|for|while|switch|return|class|interface|function|new|throw|typeof|in|of)\b/u.test(name)) {
850
+ return null;
851
+ }
852
+ return name;
853
+ }
854
+ function skipAsciiWhitespace(text, offset) {
855
+ let index = offset;
856
+ while (index < text.length) {
857
+ const code = text.charCodeAt(index);
858
+ if (code !== 9 && code !== 10 && code !== 11 && code !== 12 && code !== 13 && code !== 32) {
859
+ break;
860
+ }
861
+ index += 1;
862
+ }
863
+ return index;
864
+ }
865
+ function readIdentifier(text, offset) {
866
+ const first = text.charCodeAt(offset);
867
+ if (!isIdentifierStart(first)) {
868
+ return null;
869
+ }
870
+ let end = offset + 1;
871
+ while (end < text.length && isIdentifierPart(text.charCodeAt(end))) {
872
+ end += 1;
873
+ }
874
+ return { value: text.slice(offset, end), end };
875
+ }
876
+ function isIdentifierStart(code) {
877
+ return code === 36 || code === 95 || (code >= 65 && code <= 90) || (code >= 97 && code <= 122);
878
+ }
879
+ function isIdentifierPart(code) {
880
+ return isIdentifierStart(code) || (code >= 48 && code <= 57);
881
+ }
882
+ function isNestjsHandlerModifier(value) {
883
+ return (value === 'public' ||
884
+ value === 'private' ||
885
+ value === 'protected' ||
886
+ value === 'async' ||
887
+ value === 'static' ||
888
+ value === 'readonly');
889
+ }
890
+ function extractNestjsRouteEntries(relativePath, language, contentSha256, lines, block) {
891
+ const routes = [];
892
+ let controllerLifecycle = [];
893
+ let method = null;
894
+ let methodPath = null;
895
+ let methodLifecycle = [];
896
+ let handlerName = null;
897
+ let decoratorLocalLine = 0;
898
+ let handlerLocalLine = 0;
899
+ const relativeLine = (localLine) => block.startLine + localLine;
900
+ const commit = () => {
901
+ if (method === null) {
902
+ return;
903
+ }
904
+ const fullPath = joinNestjsRoutePath(block.controllerPath, methodPath === null ? '' : methodPath);
905
+ const signature = compactSignature(lines, relativeLine(decoratorLocalLine), relativeLine(handlerLocalLine));
906
+ const lifecycle = mergeNestjsLifecycle(controllerLifecycle, methodLifecycle);
907
+ const resolvedHandler = handlerName ?? '<anonymous>';
908
+ routes.push({
909
+ id: `${relativePath}:${relativeLine(decoratorLocalLine)}:nestjs:${method}:${fullPath || '<root>'}`,
910
+ path: relativePath,
911
+ language,
912
+ framework: 'nestjs',
913
+ method,
914
+ route_path: fullPath,
915
+ line: relativeLine(decoratorLocalLine),
916
+ chain_start_line: block.startLine,
917
+ chain_end_line: relativeLine(handlerLocalLine),
918
+ handler_line: relativeLine(handlerLocalLine),
919
+ handler_name: resolvedHandler,
920
+ lifecycle,
921
+ signature,
922
+ content_sha256: contentSha256,
923
+ });
924
+ };
925
+ const beginMethod = (nextMethod, nextMethodPath, localLine) => {
926
+ commit();
927
+ method = nextMethod;
928
+ methodPath = nextMethodPath;
929
+ methodLifecycle = [];
930
+ handlerName = null;
931
+ decoratorLocalLine = localLine;
932
+ handlerLocalLine = localLine;
933
+ };
934
+ const blockLines = lines.slice(block.startLine - 1, block.endLine);
935
+ let index = 0;
936
+ while (index < blockLines.length) {
937
+ const line = blockLines[index] ?? '';
938
+ const decoratorMatch = /@(?<method>Get|Post|Put|Patch|Delete|Options|Head|All)\s*\(/.exec(line);
939
+ if (decoratorMatch?.groups?.method) {
940
+ beginMethod(decoratorMatch.groups.method.toLowerCase(), extractNestjsMethodPath(line), index);
941
+ index += 1;
942
+ continue;
943
+ }
944
+ const lifecycle = extractNestjsLifecycleDecorator(line);
945
+ if (method === null) {
946
+ if (lifecycle !== null) {
947
+ controllerLifecycle = mergeNestjsLifecycle(controllerLifecycle, [lifecycle]);
948
+ }
949
+ index += 1;
950
+ continue;
951
+ }
952
+ if (lifecycle !== null) {
953
+ methodLifecycle = mergeNestjsLifecycle(methodLifecycle, [lifecycle]);
954
+ }
955
+ const name = extractNestjsHandlerName(line);
956
+ if (name !== null) {
957
+ handlerName = name;
958
+ handlerLocalLine = index;
959
+ }
960
+ index += 1;
961
+ }
962
+ commit();
963
+ return routes;
964
+ }