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
@@ -6,10 +6,17 @@ export const COMMAND_LIFECYCLES = new Set(['oneshot', 'server', 'watch', 'intera
6
6
  export const LONG_RUNNING_LIFECYCLES = new Set(['server', 'watch', 'interactive', 'browser', 'background']);
7
7
  export const COMMAND_RUN_POLICIES = new Set(['agent_allowed', 'requires_explicit_user_request', 'manual_only']);
8
8
  export const COMMANDS_CONFIG_RELATIVE_PATH = '.mustflow/config/commands.toml';
9
+ export const COMMANDS_CONFIG_DIRECTORY_RELATIVE_PATH = '.mustflow/config';
9
10
  export const MUSTFLOW_CONFIG_RELATIVE_PATH = '.mustflow/config/mustflow.toml';
11
+ const COMMAND_INCLUDE_DIRECTORY = 'commands';
12
+ const COMMAND_INCLUDE_ALLOWED_TOP_LEVEL_KEYS = new Set(['intents', 'resources']);
13
+ const WINDOWS_RESERVED_PATH_SEGMENTS = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?$/iu;
10
14
  export function isRecord(value) {
11
15
  return typeof value === 'object' && value !== null && !Array.isArray(value);
12
16
  }
17
+ function hasOwn(table, key) {
18
+ return Object.prototype.hasOwnProperty.call(table, key);
19
+ }
13
20
  export function resolveMustflowConfigPath(projectRoot, relativePath) {
14
21
  return path.join(projectRoot, ...relativePath.split('/'));
15
22
  }
@@ -18,6 +25,119 @@ export function readMustflowOwnedTomlFile(projectRoot, relativePath) {
18
25
  maxBytes: 256 * 1024,
19
26
  }));
20
27
  }
28
+ function normalizeCommandIncludePath(rawPath) {
29
+ return rawPath.trim().replace(/\\/gu, '/');
30
+ }
31
+ function commandIncludePathIsUnsafe(rawPath) {
32
+ const normalized = normalizeCommandIncludePath(rawPath);
33
+ const segments = normalized.split('/').filter((segment) => segment.length > 0);
34
+ return (normalized.length === 0 ||
35
+ normalized.includes('\0') ||
36
+ normalized.startsWith('/') ||
37
+ path.win32.isAbsolute(rawPath) ||
38
+ path.posix.isAbsolute(normalized) ||
39
+ segments.some((segment) => segment === '.' || segment === '..' || WINDOWS_RESERVED_PATH_SEGMENTS.test(segment)) ||
40
+ !normalized.startsWith(`${COMMAND_INCLUDE_DIRECTORY}/`) ||
41
+ !normalized.endsWith('.toml'));
42
+ }
43
+ function readCommandIncludePathsFromParsed(root) {
44
+ if (!hasOwn(root, 'include')) {
45
+ return [];
46
+ }
47
+ if (!isRecord(root.include)) {
48
+ throw new Error(`[include] in ${COMMANDS_CONFIG_RELATIVE_PATH} must be a TOML table`);
49
+ }
50
+ const files = root.include.files;
51
+ if (!Array.isArray(files) || files.some((entry) => typeof entry !== 'string' || entry.trim().length === 0)) {
52
+ throw new Error(`[include].files in ${COMMANDS_CONFIG_RELATIVE_PATH} must be a string array`);
53
+ }
54
+ const normalizedPaths = [];
55
+ const seen = new Set();
56
+ for (const file of files) {
57
+ const normalized = normalizeCommandIncludePath(file);
58
+ if (commandIncludePathIsUnsafe(file)) {
59
+ throw new Error(`Command include path "${file}" must be a relative ${COMMAND_INCLUDE_DIRECTORY}/*.toml path under ${COMMANDS_CONFIG_DIRECTORY_RELATIVE_PATH}`);
60
+ }
61
+ if (seen.has(normalized)) {
62
+ throw new Error(`Duplicate command include path "${normalized}" in ${COMMANDS_CONFIG_RELATIVE_PATH}`);
63
+ }
64
+ seen.add(normalized);
65
+ normalizedPaths.push(normalized);
66
+ }
67
+ return normalizedPaths;
68
+ }
69
+ export function readCommandContractIncludePaths(projectRoot) {
70
+ const parsed = readMustflowOwnedTomlFile(projectRoot, COMMANDS_CONFIG_RELATIVE_PATH);
71
+ if (!isRecord(parsed)) {
72
+ throw new Error(`${COMMANDS_CONFIG_RELATIVE_PATH} must contain a TOML table`);
73
+ }
74
+ return readCommandIncludePathsFromParsed(parsed).map((includePath) => `${COMMANDS_CONFIG_DIRECTORY_RELATIVE_PATH}/${includePath}`);
75
+ }
76
+ function assertCommandIncludeTable(includePath, parsed) {
77
+ if (!isRecord(parsed)) {
78
+ throw new Error(`${COMMANDS_CONFIG_DIRECTORY_RELATIVE_PATH}/${includePath} must contain a TOML table`);
79
+ }
80
+ for (const key of Object.keys(parsed)) {
81
+ if (!COMMAND_INCLUDE_ALLOWED_TOP_LEVEL_KEYS.has(key)) {
82
+ throw new Error(`${COMMANDS_CONFIG_DIRECTORY_RELATIVE_PATH}/${includePath} may define only [intents] and [resources] tables`);
83
+ }
84
+ }
85
+ return parsed;
86
+ }
87
+ function mergeCommandSection(target, source, sectionName, sourceLabel, seenSources) {
88
+ if (source === undefined) {
89
+ return;
90
+ }
91
+ if (!isRecord(source)) {
92
+ throw new Error(`${sourceLabel} [${sectionName}] must be a TOML table`);
93
+ }
94
+ for (const [name, value] of Object.entries(source)) {
95
+ const existingSource = seenSources.get(name);
96
+ if (existingSource) {
97
+ throw new Error(`Duplicate command ${sectionName.slice(0, -1)} "${name}" in ${sourceLabel}; already defined in ${existingSource}`);
98
+ }
99
+ target[name] = value;
100
+ seenSources.set(name, sourceLabel);
101
+ }
102
+ }
103
+ export function readResolvedCommandContractToml(projectRoot) {
104
+ const parsed = readMustflowOwnedTomlFile(projectRoot, COMMANDS_CONFIG_RELATIVE_PATH);
105
+ if (!isRecord(parsed)) {
106
+ throw new Error(`${COMMANDS_CONFIG_RELATIVE_PATH} must contain a TOML table`);
107
+ }
108
+ if (hasOwn(parsed, 'intents') && !isRecord(parsed.intents)) {
109
+ throw new Error(`[intents] table in ${COMMANDS_CONFIG_RELATIVE_PATH} must be a TOML table`);
110
+ }
111
+ if (hasOwn(parsed, 'resources') && !isRecord(parsed.resources)) {
112
+ throw new Error(`[resources] table in ${COMMANDS_CONFIG_RELATIVE_PATH} must be a TOML table`);
113
+ }
114
+ const merged = { ...parsed };
115
+ const mergedIntents = {};
116
+ const mergedResources = {};
117
+ const intentSources = new Map();
118
+ const resourceSources = new Map();
119
+ const includePaths = readCommandIncludePathsFromParsed(parsed);
120
+ let sawIntentTable = hasOwn(parsed, 'intents');
121
+ mergeCommandSection(mergedResources, parsed.resources, 'resources', COMMANDS_CONFIG_RELATIVE_PATH, resourceSources);
122
+ mergeCommandSection(mergedIntents, parsed.intents, 'intents', COMMANDS_CONFIG_RELATIVE_PATH, intentSources);
123
+ for (const includePath of includePaths) {
124
+ const includeRelativePath = `${COMMANDS_CONFIG_DIRECTORY_RELATIVE_PATH}/${includePath}`;
125
+ const includeTable = assertCommandIncludeTable(includePath, readMustflowOwnedTomlFile(projectRoot, includeRelativePath));
126
+ sawIntentTable = sawIntentTable || hasOwn(includeTable, 'intents');
127
+ mergeCommandSection(mergedResources, includeTable.resources, 'resources', includeRelativePath, resourceSources);
128
+ mergeCommandSection(mergedIntents, includeTable.intents, 'intents', includeRelativePath, intentSources);
129
+ }
130
+ if (Object.keys(mergedResources).length > 0) {
131
+ merged.resources = mergedResources;
132
+ }
133
+ if (sawIntentTable) {
134
+ merged.intents = mergedIntents;
135
+ }
136
+ else {
137
+ delete merged.intents;
138
+ }
139
+ return merged;
140
+ }
21
141
  export function readMustflowConfig(projectRoot) {
22
142
  const parsed = readMustflowOwnedTomlFile(projectRoot, MUSTFLOW_CONFIG_RELATIVE_PATH);
23
143
  if (!isRecord(parsed)) {
@@ -30,10 +150,7 @@ export function readMustflowConfigIfExists(projectRoot) {
30
150
  return existsSync(configPath) ? readMustflowConfig(projectRoot) : undefined;
31
151
  }
32
152
  export function readCommandContract(projectRoot) {
33
- const parsed = readMustflowOwnedTomlFile(projectRoot, COMMANDS_CONFIG_RELATIVE_PATH);
34
- if (!isRecord(parsed)) {
35
- throw new Error(`${COMMANDS_CONFIG_RELATIVE_PATH} must contain a TOML table`);
36
- }
153
+ const parsed = readResolvedCommandContractToml(projectRoot);
37
154
  if (!isRecord(parsed.intents)) {
38
155
  throw new Error(`Missing [intents] table in ${COMMANDS_CONFIG_RELATIVE_PATH}`);
39
156
  }
@@ -0,0 +1,490 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { existsSync, lstatSync, readdirSync } from 'node:fs';
3
+ import path from 'node:path';
4
+ import { ensureInside, ensureInsideWithoutSymlinks, readFileInsideWithoutSymlinks } from './safe-filesystem.js';
5
+ export const DEPENDENCY_GRAPH_PACK_ID = 'code';
6
+ export const DEPENDENCY_GRAPH_SCRIPT_ID = 'dependency-graph';
7
+ export const DEPENDENCY_GRAPH_SCRIPT_REF = `${DEPENDENCY_GRAPH_PACK_ID}/${DEPENDENCY_GRAPH_SCRIPT_ID}`;
8
+ const DEFAULT_MAX_FILE_BYTES = 256 * 1024;
9
+ const DEFAULT_MAX_FILES = 1000;
10
+ const DEFAULT_MAX_DEPTH = 2;
11
+ const DEFAULT_MAX_NODES = 300;
12
+ const DEFAULT_MAX_EDGES = 800;
13
+ const MAX_ISSUES = 50;
14
+ const MAX_CYCLES = 20;
15
+ const SOURCE_EXTENSIONS = ['.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs'];
16
+ const RESOLVE_EXTENSIONS = [...SOURCE_EXTENSIONS, '.json'];
17
+ const IGNORED_DIRECTORIES = [
18
+ '.git',
19
+ '.mustflow/cache',
20
+ '.mustflow/state',
21
+ 'node_modules',
22
+ 'dist',
23
+ 'build',
24
+ 'coverage',
25
+ '.next',
26
+ '.turbo',
27
+ ];
28
+ const ERROR_CODES = new Set([
29
+ 'dependency_graph_path_outside_root',
30
+ 'dependency_graph_unreadable_path',
31
+ ]);
32
+ function toPosixPath(value) {
33
+ return value.replace(/\\/gu, '/');
34
+ }
35
+ function normalizeRelativePath(value) {
36
+ return toPosixPath(value).replace(/^\.\/+/u, '') || '.';
37
+ }
38
+ function sha256Tagged(value) {
39
+ return `sha256:${createHash('sha256').update(value).digest('hex')}`;
40
+ }
41
+ function languageForPath(filePath) {
42
+ switch (path.extname(filePath).toLowerCase()) {
43
+ case '.ts':
44
+ case '.mts':
45
+ case '.cts':
46
+ return filePath.endsWith('.d.ts') ? 'other' : 'typescript';
47
+ case '.tsx':
48
+ return 'tsx';
49
+ case '.js':
50
+ return 'javascript';
51
+ case '.jsx':
52
+ return 'jsx';
53
+ case '.mjs':
54
+ return 'javascript-module';
55
+ case '.cjs':
56
+ return 'javascript-commonjs';
57
+ case '.json':
58
+ return 'json';
59
+ default:
60
+ return 'other';
61
+ }
62
+ }
63
+ function isSourceLanguage(language) {
64
+ return language !== 'json' && language !== 'other';
65
+ }
66
+ function isIgnoredDirectory(relativePath) {
67
+ const normalized = normalizeRelativePath(relativePath);
68
+ return IGNORED_DIRECTORIES.some((directory) => normalized === directory || normalized.startsWith(`${directory}/`));
69
+ }
70
+ function makeFinding(code, severity, pathValue, message) {
71
+ return { code, severity, path: pathValue, message };
72
+ }
73
+ function pushIssue(issues, issue) {
74
+ if (issues.length < MAX_ISSUES) {
75
+ issues.push(issue);
76
+ }
77
+ }
78
+ function normalizeTargetPath(projectRoot, targetPath) {
79
+ const absolutePath = path.resolve(process.cwd(), targetPath);
80
+ ensureInside(projectRoot, absolutePath);
81
+ return {
82
+ absolutePath,
83
+ relativePath: normalizeRelativePath(path.relative(projectRoot, absolutePath)),
84
+ };
85
+ }
86
+ function targetKind(absolutePath) {
87
+ if (!existsSync(absolutePath)) {
88
+ return { exists: false, kind: 'missing' };
89
+ }
90
+ const stats = lstatSync(absolutePath);
91
+ if (stats.isFile()) {
92
+ return { exists: true, kind: 'file' };
93
+ }
94
+ if (stats.isDirectory()) {
95
+ return { exists: true, kind: 'directory' };
96
+ }
97
+ return { exists: true, kind: 'other' };
98
+ }
99
+ function collectFilesFromDirectory(projectRoot, absoluteDirectory, candidates, findings, issues, policy) {
100
+ const relativeDirectory = normalizeRelativePath(path.relative(projectRoot, absoluteDirectory));
101
+ if (isIgnoredDirectory(relativeDirectory)) {
102
+ return;
103
+ }
104
+ let entries;
105
+ try {
106
+ ensureInsideWithoutSymlinks(projectRoot, absoluteDirectory);
107
+ entries = readdirSync(absoluteDirectory, { withFileTypes: true });
108
+ }
109
+ catch (error) {
110
+ const message = error instanceof Error ? error.message : String(error);
111
+ pushIssue(issues, `${relativeDirectory}: ${message}`);
112
+ findings.push(makeFinding('dependency_graph_unreadable_path', 'high', relativeDirectory, message));
113
+ return;
114
+ }
115
+ for (const entry of entries) {
116
+ if (candidates.length >= policy.max_files) {
117
+ const message = `Dependency graph scan matched more than ${policy.max_files} files; remaining files were skipped.`;
118
+ pushIssue(issues, `${relativeDirectory}: ${message}`);
119
+ if (!findings.some((finding) => finding.code === 'dependency_graph_max_files_exceeded')) {
120
+ findings.push(makeFinding('dependency_graph_max_files_exceeded', 'medium', relativeDirectory, message));
121
+ }
122
+ return;
123
+ }
124
+ const absoluteEntry = path.join(absoluteDirectory, entry.name);
125
+ const relativeEntry = normalizeRelativePath(path.relative(projectRoot, absoluteEntry));
126
+ if (entry.isDirectory()) {
127
+ collectFilesFromDirectory(projectRoot, absoluteEntry, candidates, findings, issues, policy);
128
+ continue;
129
+ }
130
+ if (!entry.isFile() || !SOURCE_EXTENSIONS.includes(path.extname(entry.name).toLowerCase())) {
131
+ continue;
132
+ }
133
+ candidates.push({ absolutePath: absoluteEntry, relativePath: relativeEntry, language: languageForPath(absoluteEntry) });
134
+ }
135
+ }
136
+ function collectCandidateFiles(projectRoot, findings, issues, policy) {
137
+ const candidates = [];
138
+ collectFilesFromDirectory(projectRoot, projectRoot, candidates, findings, issues, policy);
139
+ return candidates.slice(0, policy.max_files);
140
+ }
141
+ function lineNumberAtIndex(text, index) {
142
+ let line = 1;
143
+ let offset = 0;
144
+ while (offset < index) {
145
+ if (text.charCodeAt(offset) === 10) {
146
+ line += 1;
147
+ }
148
+ offset += 1;
149
+ }
150
+ return line;
151
+ }
152
+ function extractImportSpecifiers(text) {
153
+ const results = [];
154
+ const patterns = [
155
+ {
156
+ kind: 'static_import',
157
+ pattern: /\bimport\s+(?:type\s+)?(?:[^'"]*?\s+from\s+)?['"](?<specifier>[^'"]+)['"]/gu,
158
+ },
159
+ {
160
+ kind: 'static_export',
161
+ pattern: /\bexport\s+(?:type\s+)?[^'"]*?\s+from\s+['"](?<specifier>[^'"]+)['"]/gu,
162
+ },
163
+ {
164
+ kind: 'dynamic_import',
165
+ pattern: /\bimport\s*\(\s*['"](?<specifier>[^'"]+)['"]\s*\)/gu,
166
+ },
167
+ {
168
+ kind: 'require',
169
+ pattern: /\brequire\s*\(\s*['"](?<specifier>[^'"]+)['"]\s*\)/gu,
170
+ },
171
+ ];
172
+ for (const { kind, pattern } of patterns) {
173
+ for (const match of text.matchAll(pattern)) {
174
+ const specifier = match.groups?.specifier;
175
+ if (specifier) {
176
+ results.push({ specifier, index: match.index ?? 0, kind });
177
+ }
178
+ }
179
+ }
180
+ return results.sort((left, right) => left.index - right.index || left.specifier.localeCompare(right.specifier));
181
+ }
182
+ function isRelativeSpecifier(specifier) {
183
+ return specifier.startsWith('./') || specifier.startsWith('../');
184
+ }
185
+ function fileExists(absolutePath) {
186
+ try {
187
+ return lstatSync(absolutePath).isFile();
188
+ }
189
+ catch {
190
+ return false;
191
+ }
192
+ }
193
+ function resolveRelativeImport(projectRoot, sourceAbsolutePath, specifier) {
194
+ if (!isRelativeSpecifier(specifier)) {
195
+ return null;
196
+ }
197
+ const base = path.resolve(path.dirname(sourceAbsolutePath), specifier);
198
+ const candidates = [
199
+ base,
200
+ ...RESOLVE_EXTENSIONS.map((extension) => `${base}${extension}`),
201
+ ...RESOLVE_EXTENSIONS.map((extension) => path.join(base, `index${extension}`)),
202
+ ];
203
+ for (const candidate of candidates) {
204
+ try {
205
+ ensureInside(projectRoot, candidate);
206
+ if (fileExists(candidate)) {
207
+ return normalizeRelativePath(path.relative(projectRoot, candidate));
208
+ }
209
+ }
210
+ catch {
211
+ return null;
212
+ }
213
+ }
214
+ return null;
215
+ }
216
+ function readImportsForFile(projectRoot, candidate, policy, issues) {
217
+ if (!isSourceLanguage(candidate.language)) {
218
+ return [];
219
+ }
220
+ let buffer;
221
+ try {
222
+ buffer = readFileInsideWithoutSymlinks(projectRoot, candidate.absolutePath, { maxBytes: policy.max_file_bytes });
223
+ }
224
+ catch (error) {
225
+ const message = error instanceof Error ? error.message : String(error);
226
+ pushIssue(issues, `${candidate.relativePath}: ${message}`);
227
+ return [];
228
+ }
229
+ const text = buffer.toString('utf8');
230
+ return extractImportSpecifiers(text).map((entry) => ({
231
+ specifier: entry.specifier,
232
+ line: lineNumberAtIndex(text, entry.index),
233
+ kind: entry.kind,
234
+ resolvedPath: resolveRelativeImport(projectRoot, candidate.absolutePath, entry.specifier),
235
+ }));
236
+ }
237
+ function edgeKey(edge) {
238
+ return [edge.source_path, edge.target_path, edge.kind, edge.line, edge.specifier].join('\0');
239
+ }
240
+ function addGraphFinding(findings, issues, code, pathValue, message) {
241
+ if (!findings.some((finding) => finding.code === code)) {
242
+ findings.push(makeFinding(code, 'medium', pathValue, message));
243
+ pushIssue(issues, message);
244
+ }
245
+ }
246
+ function addEdge(edges, edge, policy, findings, issues) {
247
+ if (edges.has(edgeKey(edge))) {
248
+ return;
249
+ }
250
+ if (edges.size >= policy.max_edges) {
251
+ addGraphFinding(findings, issues, 'dependency_graph_max_edges_exceeded', edge.source_path, `Dependency graph found more than ${policy.max_edges} edges; remaining edges were skipped.`);
252
+ return;
253
+ }
254
+ edges.set(edgeKey(edge), edge);
255
+ }
256
+ function targetSourceFiles(targets, sourceFiles) {
257
+ const sources = new Set(sourceFiles.map((file) => file.relativePath));
258
+ const selected = new Set();
259
+ for (const target of targets) {
260
+ if (target.kind === 'file' && sources.has(target.path)) {
261
+ selected.add(target.path);
262
+ continue;
263
+ }
264
+ if (target.kind !== 'directory') {
265
+ continue;
266
+ }
267
+ const prefix = target.path === '.' ? '' : `${target.path}/`;
268
+ for (const file of sourceFiles) {
269
+ if (prefix === '' || file.relativePath.startsWith(prefix)) {
270
+ selected.add(file.relativePath);
271
+ }
272
+ }
273
+ }
274
+ return [...selected].sort((left, right) => left.localeCompare(right));
275
+ }
276
+ function dependencyEdgesFor(sourcePath, importMap) {
277
+ return (importMap.get(sourcePath) ?? [])
278
+ .filter((reference) => reference.resolvedPath !== null)
279
+ .map((reference) => ({
280
+ source_path: sourcePath,
281
+ target_path: reference.resolvedPath ?? '',
282
+ specifier: reference.specifier,
283
+ line: reference.line,
284
+ kind: reference.kind,
285
+ }));
286
+ }
287
+ function buildIncludedGraph(startPaths, importMap, importerMap, policy, findings, issues) {
288
+ const queue = startPaths.map((sourcePath) => ({ sourcePath, depth: 0 }));
289
+ const nodeDepth = new Map();
290
+ const edgeMap = new Map();
291
+ for (const sourcePath of startPaths) {
292
+ nodeDepth.set(sourcePath, 0);
293
+ }
294
+ while (queue.length > 0) {
295
+ const current = queue.shift();
296
+ if (!current || current.depth >= policy.max_depth) {
297
+ continue;
298
+ }
299
+ for (const edge of dependencyEdgesFor(current.sourcePath, importMap)) {
300
+ addEdge(edgeMap, edge, policy, findings, issues);
301
+ if (!nodeDepth.has(edge.target_path)) {
302
+ if (nodeDepth.size >= policy.max_nodes) {
303
+ addGraphFinding(findings, issues, 'dependency_graph_max_nodes_exceeded', edge.target_path, `Dependency graph found more than ${policy.max_nodes} nodes; remaining nodes were skipped.`);
304
+ continue;
305
+ }
306
+ nodeDepth.set(edge.target_path, current.depth + 1);
307
+ queue.push({ sourcePath: edge.target_path, depth: current.depth + 1 });
308
+ }
309
+ }
310
+ }
311
+ for (const sourcePath of [...nodeDepth.keys()]) {
312
+ for (const edge of importerMap.get(sourcePath) ?? []) {
313
+ if (nodeDepth.has(edge.source_path)) {
314
+ addEdge(edgeMap, edge, policy, findings, issues);
315
+ continue;
316
+ }
317
+ if (nodeDepth.size >= policy.max_nodes) {
318
+ addGraphFinding(findings, issues, 'dependency_graph_max_nodes_exceeded', edge.source_path, `Dependency graph found more than ${policy.max_nodes} nodes; remaining nodes were skipped.`);
319
+ continue;
320
+ }
321
+ nodeDepth.set(edge.source_path, Math.max(1, (nodeDepth.get(sourcePath) ?? 0) + 1));
322
+ addEdge(edgeMap, edge, policy, findings, issues);
323
+ }
324
+ }
325
+ return {
326
+ nodeDepth,
327
+ edges: [...edgeMap.values()].sort((left, right) => left.source_path.localeCompare(right.source_path) ||
328
+ left.target_path.localeCompare(right.target_path) ||
329
+ left.line - right.line ||
330
+ left.kind.localeCompare(right.kind)),
331
+ };
332
+ }
333
+ function findCycles(edges) {
334
+ const adjacency = new Map();
335
+ for (const edge of edges) {
336
+ const targets = adjacency.get(edge.source_path) ?? [];
337
+ targets.push(edge.target_path);
338
+ adjacency.set(edge.source_path, targets);
339
+ }
340
+ for (const targets of adjacency.values()) {
341
+ targets.sort((left, right) => left.localeCompare(right));
342
+ }
343
+ const cycles = new Map();
344
+ const visit = (node, stack, seen) => {
345
+ if (cycles.size >= MAX_CYCLES) {
346
+ return;
347
+ }
348
+ const existingIndex = stack.indexOf(node);
349
+ if (existingIndex >= 0) {
350
+ const cycle = [...stack.slice(existingIndex), node];
351
+ const body = cycle.slice(0, -1);
352
+ const sorted = [...body].sort((left, right) => left.localeCompare(right));
353
+ cycles.set(sorted.join('\0'), cycle);
354
+ return;
355
+ }
356
+ if (seen.has(node)) {
357
+ return;
358
+ }
359
+ seen.add(node);
360
+ for (const next of adjacency.get(node) ?? []) {
361
+ visit(next, [...stack, node], seen);
362
+ }
363
+ };
364
+ for (const node of [...adjacency.keys()].sort((left, right) => left.localeCompare(right))) {
365
+ visit(node, [], new Set());
366
+ }
367
+ return [...cycles.values()];
368
+ }
369
+ function dependencyGraphStatus(findings) {
370
+ if (findings.some((finding) => ERROR_CODES.has(finding.code))) {
371
+ return 'error';
372
+ }
373
+ return findings.length > 0 ? 'failed' : 'passed';
374
+ }
375
+ function createInputHash(policy, targets, nodes, edges, findings, issues) {
376
+ return sha256Tagged(JSON.stringify({
377
+ policy,
378
+ targets,
379
+ nodes: nodes.map((node) => node.path),
380
+ edges: edges.map((edge) => [edge.source_path, edge.target_path, edge.kind, edge.line]),
381
+ findings: findings.map((finding) => ({ code: finding.code, path: finding.path })),
382
+ issues,
383
+ }));
384
+ }
385
+ export function inspectDependencyGraph(projectRoot, options) {
386
+ const root = path.resolve(projectRoot);
387
+ const policy = {
388
+ max_file_bytes: options.maxFileBytes ?? DEFAULT_MAX_FILE_BYTES,
389
+ max_files: options.maxFiles ?? DEFAULT_MAX_FILES,
390
+ max_depth: options.maxDepth ?? DEFAULT_MAX_DEPTH,
391
+ max_nodes: options.maxNodes ?? DEFAULT_MAX_NODES,
392
+ max_edges: options.maxEdges ?? DEFAULT_MAX_EDGES,
393
+ extensions: [...SOURCE_EXTENSIONS],
394
+ ignored_directories: [...IGNORED_DIRECTORIES],
395
+ };
396
+ const targets = [];
397
+ const findings = [];
398
+ const issues = [];
399
+ for (const targetPath of options.paths) {
400
+ let absolutePath;
401
+ let relativePath;
402
+ try {
403
+ const normalized = normalizeTargetPath(root, targetPath);
404
+ absolutePath = normalized.absolutePath;
405
+ relativePath = normalized.relativePath;
406
+ ensureInsideWithoutSymlinks(root, absolutePath, { allowMissingLeaf: true });
407
+ }
408
+ catch (error) {
409
+ const message = error instanceof Error ? error.message : String(error);
410
+ pushIssue(issues, message);
411
+ targets.push({ input: targetPath, path: targetPath, exists: null, kind: 'unknown', language: 'other' });
412
+ findings.push(makeFinding('dependency_graph_path_outside_root', 'high', targetPath, message));
413
+ continue;
414
+ }
415
+ try {
416
+ const existence = targetKind(absolutePath);
417
+ targets.push({
418
+ input: targetPath,
419
+ path: relativePath,
420
+ exists: existence.exists,
421
+ kind: existence.kind,
422
+ language: languageForPath(absolutePath),
423
+ });
424
+ }
425
+ catch (error) {
426
+ const message = error instanceof Error ? error.message : String(error);
427
+ pushIssue(issues, `${relativePath}: ${message}`);
428
+ targets.push({ input: targetPath, path: relativePath, exists: null, kind: 'unknown', language: 'other' });
429
+ findings.push(makeFinding('dependency_graph_unreadable_path', 'high', relativePath, message));
430
+ }
431
+ }
432
+ const sourceFiles = collectCandidateFiles(root, findings, issues, policy);
433
+ const sourceByPath = new Map(sourceFiles.map((file) => [file.relativePath, file]));
434
+ const importMap = new Map();
435
+ const importerMap = new Map();
436
+ for (const file of sourceFiles) {
437
+ const references = readImportsForFile(root, file, policy, issues);
438
+ importMap.set(file.relativePath, references);
439
+ for (const edge of dependencyEdgesFor(file.relativePath, importMap)) {
440
+ const importers = importerMap.get(edge.target_path) ?? [];
441
+ importers.push(edge);
442
+ importerMap.set(edge.target_path, importers);
443
+ }
444
+ }
445
+ const startPaths = targetSourceFiles(targets, sourceFiles);
446
+ const graph = buildIncludedGraph(startPaths, importMap, importerMap, policy, findings, issues);
447
+ const targetSet = new Set(startPaths);
448
+ const importCount = new Map();
449
+ const importerCount = new Map();
450
+ for (const edge of graph.edges) {
451
+ importCount.set(edge.source_path, (importCount.get(edge.source_path) ?? 0) + 1);
452
+ importerCount.set(edge.target_path, (importerCount.get(edge.target_path) ?? 0) + 1);
453
+ }
454
+ const nodes = [...graph.nodeDepth.entries()]
455
+ .map(([nodePath, depth]) => ({
456
+ path: nodePath,
457
+ language: sourceByPath.get(nodePath)?.language ?? languageForPath(nodePath),
458
+ target: targetSet.has(nodePath),
459
+ depth,
460
+ import_count: importCount.get(nodePath) ?? 0,
461
+ importer_count: importerCount.get(nodePath) ?? 0,
462
+ }))
463
+ .sort((left, right) => left.depth - right.depth || left.path.localeCompare(right.path));
464
+ const cycles = findCycles(graph.edges);
465
+ const status = dependencyGraphStatus(findings);
466
+ return {
467
+ schema_version: '1',
468
+ command: 'script-pack',
469
+ pack_id: DEPENDENCY_GRAPH_PACK_ID,
470
+ script_id: DEPENDENCY_GRAPH_SCRIPT_ID,
471
+ script_ref: DEPENDENCY_GRAPH_SCRIPT_REF,
472
+ action: 'scan',
473
+ status,
474
+ ok: status === 'passed',
475
+ mustflow_root: root,
476
+ policy,
477
+ input_hash: createInputHash(policy, targets, nodes, graph.edges, findings, issues),
478
+ targets,
479
+ nodes,
480
+ edges: graph.edges,
481
+ cycles,
482
+ truncated: findings.some((finding) => [
483
+ 'dependency_graph_max_files_exceeded',
484
+ 'dependency_graph_max_nodes_exceeded',
485
+ 'dependency_graph_max_edges_exceeded',
486
+ ].includes(finding.code)),
487
+ findings,
488
+ issues,
489
+ };
490
+ }