mustflow 2.75.1 → 2.84.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +31 -3
- package/dist/cli/commands/docs.js +86 -2
- package/dist/cli/commands/script-pack.js +5 -0
- package/dist/cli/i18n/en.js +101 -2
- package/dist/cli/i18n/es.js +101 -2
- package/dist/cli/i18n/fr.js +101 -2
- package/dist/cli/i18n/hi.js +101 -2
- package/dist/cli/i18n/ko.js +101 -2
- package/dist/cli/i18n/zh.js +101 -2
- package/dist/cli/lib/script-pack-registry.js +162 -7
- package/dist/cli/script-packs/code-export-diff.js +160 -0
- package/dist/cli/script-packs/code-outline.js +33 -5
- package/dist/cli/script-packs/code-route-outline.js +155 -0
- package/dist/cli/script-packs/docs-reference-drift.js +150 -0
- package/dist/cli/script-packs/repo-config-chain.js +163 -0
- package/dist/cli/script-packs/repo-related-files.js +161 -0
- package/dist/core/code-outline.js +527 -80
- package/dist/core/config-chain.js +595 -0
- package/dist/core/export-diff.js +359 -0
- package/dist/core/public-json-contracts.js +75 -0
- package/dist/core/reference-drift.js +388 -0
- package/dist/core/related-files.js +493 -0
- package/dist/core/route-outline.js +912 -0
- package/dist/core/script-pack-suggestions.js +111 -5
- package/dist/core/source-anchors.js +13 -1
- package/package.json +1 -1
- package/schemas/README.md +28 -5
- package/schemas/code-outline-report.schema.json +47 -1
- package/schemas/code-symbol-read-report.schema.json +64 -4
- package/schemas/config-chain-report.schema.json +187 -0
- package/schemas/export-diff-report.schema.json +220 -0
- package/schemas/reference-drift-report.schema.json +166 -0
- package/schemas/related-files-report.schema.json +145 -0
- package/schemas/route-outline-report.schema.json +200 -0
- package/templates/default/common/.mustflow/config/commands.toml +21 -0
- package/templates/default/i18n.toml +7 -1
- package/templates/default/locales/en/.mustflow/docs/agent-workflow.md +1 -1
- package/templates/default/locales/en/.mustflow/skills/INDEX.md +2 -1
- package/templates/default/locales/en/.mustflow/skills/cross-agent-session-reference/SKILL.md +131 -0
- package/templates/default/locales/en/.mustflow/skills/routes.toml +6 -0
- package/templates/default/manifest.toml +8 -1
|
@@ -0,0 +1,493 @@
|
|
|
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 RELATED_FILES_PACK_ID = 'repo';
|
|
6
|
+
export const RELATED_FILES_SCRIPT_ID = 'related-files';
|
|
7
|
+
export const RELATED_FILES_SCRIPT_REF = `${RELATED_FILES_PACK_ID}/${RELATED_FILES_SCRIPT_ID}`;
|
|
8
|
+
const DEFAULT_MAX_FILE_BYTES = 256 * 1024;
|
|
9
|
+
const DEFAULT_MAX_FILES = 1000;
|
|
10
|
+
const DEFAULT_MAX_CANDIDATES = 200;
|
|
11
|
+
const MAX_ISSUES = 50;
|
|
12
|
+
const SOURCE_EXTENSIONS = ['.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs'];
|
|
13
|
+
const RESOLVE_EXTENSIONS = [...SOURCE_EXTENSIONS, '.json'];
|
|
14
|
+
const RELATED_EXTENSIONS = [...RESOLVE_EXTENSIONS, '.d.ts', '.md', '.mdx', '.css', '.scss', '.sass', '.less'];
|
|
15
|
+
const IGNORED_DIRECTORIES = [
|
|
16
|
+
'.git',
|
|
17
|
+
'.mustflow/cache',
|
|
18
|
+
'.mustflow/state',
|
|
19
|
+
'node_modules',
|
|
20
|
+
'dist',
|
|
21
|
+
'build',
|
|
22
|
+
'coverage',
|
|
23
|
+
'.next',
|
|
24
|
+
'.turbo',
|
|
25
|
+
];
|
|
26
|
+
const CONFIG_FILE_PATTERNS = [
|
|
27
|
+
/^package\.json$/u,
|
|
28
|
+
/^tsconfig(?:\.[^.]+)?\.json$/u,
|
|
29
|
+
/^eslint\.config\.(?:js|mjs|cjs|ts)$/u,
|
|
30
|
+
/^\.eslintrc(?:\.(?:js|cjs|json|yaml|yml))?$/u,
|
|
31
|
+
/^vite\.config\.(?:js|mjs|cjs|ts|mts|cts)$/u,
|
|
32
|
+
/^vitest\.config\.(?:js|mjs|cjs|ts|mts|cts)$/u,
|
|
33
|
+
/^tailwind\.config\.(?:js|mjs|cjs|ts|mts|cts)$/u,
|
|
34
|
+
];
|
|
35
|
+
const ERROR_RELATED_FILES_CODES = new Set([
|
|
36
|
+
'related_files_path_outside_root',
|
|
37
|
+
'related_files_unreadable_path',
|
|
38
|
+
]);
|
|
39
|
+
function toPosixPath(value) {
|
|
40
|
+
return value.replace(/\\/gu, '/');
|
|
41
|
+
}
|
|
42
|
+
function normalizeRelativePath(value) {
|
|
43
|
+
return toPosixPath(value).replace(/^\.\/+/u, '') || '.';
|
|
44
|
+
}
|
|
45
|
+
function sha256Tagged(value) {
|
|
46
|
+
return `sha256:${createHash('sha256').update(value).digest('hex')}`;
|
|
47
|
+
}
|
|
48
|
+
function languageForPath(filePath) {
|
|
49
|
+
switch (path.extname(filePath).toLowerCase()) {
|
|
50
|
+
case '.ts':
|
|
51
|
+
case '.mts':
|
|
52
|
+
case '.cts':
|
|
53
|
+
return filePath.endsWith('.d.ts') ? 'other' : 'typescript';
|
|
54
|
+
case '.tsx':
|
|
55
|
+
return 'tsx';
|
|
56
|
+
case '.js':
|
|
57
|
+
return 'javascript';
|
|
58
|
+
case '.jsx':
|
|
59
|
+
return 'jsx';
|
|
60
|
+
case '.mjs':
|
|
61
|
+
return 'javascript-module';
|
|
62
|
+
case '.cjs':
|
|
63
|
+
return 'javascript-commonjs';
|
|
64
|
+
case '.json':
|
|
65
|
+
return 'json';
|
|
66
|
+
default:
|
|
67
|
+
return 'other';
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
function isSourceLanguage(language) {
|
|
71
|
+
return language !== 'json' && language !== 'other';
|
|
72
|
+
}
|
|
73
|
+
function isIgnoredDirectory(relativePath) {
|
|
74
|
+
const normalized = normalizeRelativePath(relativePath);
|
|
75
|
+
return IGNORED_DIRECTORIES.some((directory) => normalized === directory || normalized.startsWith(`${directory}/`));
|
|
76
|
+
}
|
|
77
|
+
function makeFinding(code, severity, pathValue, message) {
|
|
78
|
+
return { code, severity, path: pathValue, message };
|
|
79
|
+
}
|
|
80
|
+
function pushIssue(issues, issue) {
|
|
81
|
+
if (issues.length < MAX_ISSUES) {
|
|
82
|
+
issues.push(issue);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
function normalizeTargetPath(projectRoot, targetPath) {
|
|
86
|
+
const absolutePath = path.resolve(process.cwd(), targetPath);
|
|
87
|
+
ensureInside(projectRoot, absolutePath);
|
|
88
|
+
return {
|
|
89
|
+
absolutePath,
|
|
90
|
+
relativePath: normalizeRelativePath(path.relative(projectRoot, absolutePath)),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
function targetKind(absolutePath) {
|
|
94
|
+
if (!existsSync(absolutePath)) {
|
|
95
|
+
return { exists: false, kind: 'missing' };
|
|
96
|
+
}
|
|
97
|
+
const stats = lstatSync(absolutePath);
|
|
98
|
+
if (stats.isFile()) {
|
|
99
|
+
return { exists: true, kind: 'file' };
|
|
100
|
+
}
|
|
101
|
+
if (stats.isDirectory()) {
|
|
102
|
+
return { exists: true, kind: 'directory' };
|
|
103
|
+
}
|
|
104
|
+
return { exists: true, kind: 'other' };
|
|
105
|
+
}
|
|
106
|
+
function collectFilesFromDirectory(projectRoot, absoluteDirectory, candidates, findings, issues, policy, extensions) {
|
|
107
|
+
const relativeDirectory = normalizeRelativePath(path.relative(projectRoot, absoluteDirectory));
|
|
108
|
+
if (isIgnoredDirectory(relativeDirectory)) {
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
let entries;
|
|
112
|
+
try {
|
|
113
|
+
ensureInsideWithoutSymlinks(projectRoot, absoluteDirectory);
|
|
114
|
+
entries = readdirSync(absoluteDirectory, { withFileTypes: true });
|
|
115
|
+
}
|
|
116
|
+
catch (error) {
|
|
117
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
118
|
+
pushIssue(issues, `${relativeDirectory}: ${message}`);
|
|
119
|
+
findings.push(makeFinding('related_files_unreadable_path', 'high', relativeDirectory, message));
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
for (const entry of entries) {
|
|
123
|
+
if (candidates.length >= policy.max_files) {
|
|
124
|
+
const message = `Related-files scan matched more than ${policy.max_files} files; remaining files were skipped.`;
|
|
125
|
+
pushIssue(issues, `${relativeDirectory}: ${message}`);
|
|
126
|
+
if (!findings.some((finding) => finding.code === 'related_files_max_files_exceeded')) {
|
|
127
|
+
findings.push(makeFinding('related_files_max_files_exceeded', 'medium', relativeDirectory, message));
|
|
128
|
+
}
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
const absoluteEntry = path.join(absoluteDirectory, entry.name);
|
|
132
|
+
const relativeEntry = normalizeRelativePath(path.relative(projectRoot, absoluteEntry));
|
|
133
|
+
if (entry.isDirectory()) {
|
|
134
|
+
collectFilesFromDirectory(projectRoot, absoluteEntry, candidates, findings, issues, policy, extensions);
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
if (!entry.isFile() || !extensions.includes(path.extname(entry.name).toLowerCase())) {
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
candidates.push({ absolutePath: absoluteEntry, relativePath: relativeEntry, language: languageForPath(absoluteEntry) });
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
function collectCandidateFiles(projectRoot, findings, issues, policy, extensions) {
|
|
144
|
+
const candidates = [];
|
|
145
|
+
collectFilesFromDirectory(projectRoot, projectRoot, candidates, findings, issues, policy, extensions);
|
|
146
|
+
return candidates.slice(0, policy.max_files);
|
|
147
|
+
}
|
|
148
|
+
function lineNumberAtIndex(text, index) {
|
|
149
|
+
let line = 1;
|
|
150
|
+
let offset = 0;
|
|
151
|
+
while (offset < index) {
|
|
152
|
+
if (text.charCodeAt(offset) === 10) {
|
|
153
|
+
line += 1;
|
|
154
|
+
}
|
|
155
|
+
offset += 1;
|
|
156
|
+
}
|
|
157
|
+
return line;
|
|
158
|
+
}
|
|
159
|
+
function extractImportSpecifiers(text) {
|
|
160
|
+
const results = [];
|
|
161
|
+
const patterns = [
|
|
162
|
+
/\b(?:import|export)\s+(?:type\s+)?(?:[^'"]*?\s+from\s+)?['"](?<specifier>[^'"]+)['"]/gu,
|
|
163
|
+
/\bimport\s*\(\s*['"](?<specifier>[^'"]+)['"]\s*\)/gu,
|
|
164
|
+
/\brequire\s*\(\s*['"](?<specifier>[^'"]+)['"]\s*\)/gu,
|
|
165
|
+
];
|
|
166
|
+
for (const pattern of patterns) {
|
|
167
|
+
for (const match of text.matchAll(pattern)) {
|
|
168
|
+
const specifier = match.groups?.specifier;
|
|
169
|
+
if (specifier) {
|
|
170
|
+
results.push({ specifier, index: match.index ?? 0 });
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return results.sort((left, right) => left.index - right.index || left.specifier.localeCompare(right.specifier));
|
|
175
|
+
}
|
|
176
|
+
function isRelativeSpecifier(specifier) {
|
|
177
|
+
return specifier.startsWith('./') || specifier.startsWith('../');
|
|
178
|
+
}
|
|
179
|
+
function fileExists(absolutePath) {
|
|
180
|
+
try {
|
|
181
|
+
return lstatSync(absolutePath).isFile();
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
return false;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
function resolveRelativeImport(projectRoot, sourceAbsolutePath, specifier) {
|
|
188
|
+
if (!isRelativeSpecifier(specifier)) {
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
const base = path.resolve(path.dirname(sourceAbsolutePath), specifier);
|
|
192
|
+
const candidates = [
|
|
193
|
+
base,
|
|
194
|
+
...RESOLVE_EXTENSIONS.map((extension) => `${base}${extension}`),
|
|
195
|
+
...RESOLVE_EXTENSIONS.map((extension) => path.join(base, `index${extension}`)),
|
|
196
|
+
];
|
|
197
|
+
for (const candidate of candidates) {
|
|
198
|
+
try {
|
|
199
|
+
ensureInside(projectRoot, candidate);
|
|
200
|
+
if (fileExists(candidate)) {
|
|
201
|
+
return normalizeRelativePath(path.relative(projectRoot, candidate));
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
function readImportsForFile(projectRoot, candidate, policy, issues) {
|
|
211
|
+
if (!isSourceLanguage(candidate.language)) {
|
|
212
|
+
return [];
|
|
213
|
+
}
|
|
214
|
+
let buffer;
|
|
215
|
+
try {
|
|
216
|
+
buffer = readFileInsideWithoutSymlinks(projectRoot, candidate.absolutePath, { maxBytes: policy.max_file_bytes });
|
|
217
|
+
}
|
|
218
|
+
catch (error) {
|
|
219
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
220
|
+
pushIssue(issues, `${candidate.relativePath}: ${message}`);
|
|
221
|
+
return [];
|
|
222
|
+
}
|
|
223
|
+
const text = buffer.toString('utf8');
|
|
224
|
+
return extractImportSpecifiers(text).map((entry) => ({
|
|
225
|
+
specifier: entry.specifier,
|
|
226
|
+
line: lineNumberAtIndex(text, entry.index),
|
|
227
|
+
resolvedPath: resolveRelativeImport(projectRoot, candidate.absolutePath, entry.specifier),
|
|
228
|
+
}));
|
|
229
|
+
}
|
|
230
|
+
function candidateKey(candidate) {
|
|
231
|
+
return [
|
|
232
|
+
candidate.relationship,
|
|
233
|
+
candidate.path,
|
|
234
|
+
candidate.source_path,
|
|
235
|
+
candidate.target_path,
|
|
236
|
+
candidate.line ?? '',
|
|
237
|
+
].join('\0');
|
|
238
|
+
}
|
|
239
|
+
function addCandidate(candidates, next, policy, findings, issues) {
|
|
240
|
+
if (next.path === next.target_path && next.relationship !== 'config_parent' && next.relationship !== 'package_boundary') {
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
if (candidates.has(candidateKey(next))) {
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
if (candidates.size >= policy.max_candidates) {
|
|
247
|
+
if (!findings.some((finding) => finding.code === 'related_files_max_candidates_exceeded')) {
|
|
248
|
+
const message = `Related-files map found more than ${policy.max_candidates} candidates; remaining candidates were skipped.`;
|
|
249
|
+
pushIssue(issues, message);
|
|
250
|
+
findings.push(makeFinding('related_files_max_candidates_exceeded', 'medium', next.target_path, message));
|
|
251
|
+
}
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
candidates.set(candidateKey(next), next);
|
|
255
|
+
}
|
|
256
|
+
function isTestSibling(candidate, targetStem) {
|
|
257
|
+
const stem = path.basename(candidate).replace(/\.(?:d\.)?(?:ts|tsx|mts|cts|js|jsx|mjs|cjs)$/u, '');
|
|
258
|
+
return stem === `${targetStem}.test` || stem === `${targetStem}.spec`;
|
|
259
|
+
}
|
|
260
|
+
function isDocsSibling(candidate, targetStem) {
|
|
261
|
+
const stem = path.basename(candidate).replace(/\.(?:md|mdx)$/u, '');
|
|
262
|
+
return stem === targetStem;
|
|
263
|
+
}
|
|
264
|
+
function isStyleSibling(candidate, targetStem) {
|
|
265
|
+
const stem = path.basename(candidate).replace(/\.(?:module\.)?(?:css|scss|sass|less)$/u, '');
|
|
266
|
+
return stem === targetStem;
|
|
267
|
+
}
|
|
268
|
+
function isTypeSibling(candidate, targetStem) {
|
|
269
|
+
const basename = path.basename(candidate);
|
|
270
|
+
return basename === `${targetStem}.d.ts` || basename === `${targetStem}.types.ts`;
|
|
271
|
+
}
|
|
272
|
+
function relationshipForSibling(candidatePath, targetStem) {
|
|
273
|
+
if (isTestSibling(candidatePath, targetStem)) {
|
|
274
|
+
return 'sibling_test';
|
|
275
|
+
}
|
|
276
|
+
if (isDocsSibling(candidatePath, targetStem)) {
|
|
277
|
+
return 'sibling_docs';
|
|
278
|
+
}
|
|
279
|
+
if (isStyleSibling(candidatePath, targetStem)) {
|
|
280
|
+
return 'sibling_style';
|
|
281
|
+
}
|
|
282
|
+
if (isTypeSibling(candidatePath, targetStem)) {
|
|
283
|
+
return 'sibling_type';
|
|
284
|
+
}
|
|
285
|
+
return null;
|
|
286
|
+
}
|
|
287
|
+
function siblingConfidence(relationship) {
|
|
288
|
+
switch (relationship) {
|
|
289
|
+
case 'sibling_test':
|
|
290
|
+
return 0.78;
|
|
291
|
+
case 'sibling_type':
|
|
292
|
+
return 0.72;
|
|
293
|
+
case 'sibling_docs':
|
|
294
|
+
case 'sibling_style':
|
|
295
|
+
return 0.62;
|
|
296
|
+
default:
|
|
297
|
+
return 0.5;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
function addSiblingCandidates(target, allFiles, candidates, policy, findings, issues) {
|
|
301
|
+
if (target.kind !== 'file') {
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
const targetStem = path.basename(target.path).replace(/\.(?:d\.)?(?:ts|tsx|mts|cts|js|jsx|mjs|cjs|json)$/u, '');
|
|
305
|
+
for (const file of allFiles) {
|
|
306
|
+
if (file.relativePath === target.path) {
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
const relationship = relationshipForSibling(file.relativePath, targetStem);
|
|
310
|
+
if (!relationship) {
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
addCandidate(candidates, {
|
|
314
|
+
path: file.relativePath,
|
|
315
|
+
relationship,
|
|
316
|
+
confidence: siblingConfidence(relationship),
|
|
317
|
+
reason: `${file.relativePath} shares basename ${targetStem} with ${target.path}.`,
|
|
318
|
+
source_path: target.path,
|
|
319
|
+
target_path: target.path,
|
|
320
|
+
line: null,
|
|
321
|
+
}, policy, findings, issues);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
function isConfigFileName(name) {
|
|
325
|
+
return CONFIG_FILE_PATTERNS.some((pattern) => pattern.test(name));
|
|
326
|
+
}
|
|
327
|
+
function addConfigParentCandidates(projectRoot, target, candidates, policy, findings, issues) {
|
|
328
|
+
const startRelativeDirectory = target.kind === 'directory' ? target.path : normalizeRelativePath(path.dirname(target.path));
|
|
329
|
+
const startAbsoluteDirectory = path.join(projectRoot, ...startRelativeDirectory.split('/').filter((segment) => segment !== '.'));
|
|
330
|
+
let current = path.resolve(startAbsoluteDirectory);
|
|
331
|
+
while (current.startsWith(projectRoot)) {
|
|
332
|
+
let entries;
|
|
333
|
+
try {
|
|
334
|
+
entries = readdirSync(current, { withFileTypes: true });
|
|
335
|
+
}
|
|
336
|
+
catch {
|
|
337
|
+
break;
|
|
338
|
+
}
|
|
339
|
+
for (const entry of entries) {
|
|
340
|
+
if (!entry.isFile() || !isConfigFileName(entry.name)) {
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
const configPath = normalizeRelativePath(path.relative(projectRoot, path.join(current, entry.name)));
|
|
344
|
+
const relationship = entry.name === 'package.json' ? 'package_boundary' : 'config_parent';
|
|
345
|
+
addCandidate(candidates, {
|
|
346
|
+
path: configPath,
|
|
347
|
+
relationship,
|
|
348
|
+
confidence: relationship === 'package_boundary' ? 0.72 : 0.58,
|
|
349
|
+
reason: `${configPath} is a parent configuration file for ${target.path}.`,
|
|
350
|
+
source_path: target.path,
|
|
351
|
+
target_path: target.path,
|
|
352
|
+
line: null,
|
|
353
|
+
}, policy, findings, issues);
|
|
354
|
+
}
|
|
355
|
+
const parent = path.dirname(current);
|
|
356
|
+
if (parent === current || path.relative(projectRoot, parent).startsWith('..')) {
|
|
357
|
+
break;
|
|
358
|
+
}
|
|
359
|
+
current = parent;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
function createInputHash(policy, targets, candidates, findings, issues) {
|
|
363
|
+
return sha256Tagged(JSON.stringify({
|
|
364
|
+
policy,
|
|
365
|
+
targets,
|
|
366
|
+
candidates,
|
|
367
|
+
findings: findings.map((finding) => ({ code: finding.code, path: finding.path })),
|
|
368
|
+
issues,
|
|
369
|
+
}));
|
|
370
|
+
}
|
|
371
|
+
function relatedFilesStatus(findings) {
|
|
372
|
+
if (findings.some((finding) => ERROR_RELATED_FILES_CODES.has(finding.code))) {
|
|
373
|
+
return 'error';
|
|
374
|
+
}
|
|
375
|
+
return findings.length > 0 ? 'failed' : 'passed';
|
|
376
|
+
}
|
|
377
|
+
export function inspectRelatedFiles(projectRoot, options) {
|
|
378
|
+
const root = path.resolve(projectRoot);
|
|
379
|
+
const policy = {
|
|
380
|
+
max_file_bytes: options.maxFileBytes ?? DEFAULT_MAX_FILE_BYTES,
|
|
381
|
+
max_files: options.maxFiles ?? DEFAULT_MAX_FILES,
|
|
382
|
+
max_candidates: options.maxCandidates ?? DEFAULT_MAX_CANDIDATES,
|
|
383
|
+
extensions: [...RELATED_EXTENSIONS],
|
|
384
|
+
ignored_directories: [...IGNORED_DIRECTORIES],
|
|
385
|
+
};
|
|
386
|
+
const targets = [];
|
|
387
|
+
const findings = [];
|
|
388
|
+
const issues = [];
|
|
389
|
+
for (const targetPath of options.paths) {
|
|
390
|
+
let absolutePath;
|
|
391
|
+
let relativePath;
|
|
392
|
+
try {
|
|
393
|
+
const normalized = normalizeTargetPath(root, targetPath);
|
|
394
|
+
absolutePath = normalized.absolutePath;
|
|
395
|
+
relativePath = normalized.relativePath;
|
|
396
|
+
ensureInsideWithoutSymlinks(root, absolutePath, { allowMissingLeaf: true });
|
|
397
|
+
}
|
|
398
|
+
catch (error) {
|
|
399
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
400
|
+
pushIssue(issues, message);
|
|
401
|
+
targets.push({ input: targetPath, path: targetPath, exists: null, kind: 'unknown', language: 'other' });
|
|
402
|
+
findings.push(makeFinding('related_files_path_outside_root', 'high', targetPath, message));
|
|
403
|
+
continue;
|
|
404
|
+
}
|
|
405
|
+
let existence;
|
|
406
|
+
try {
|
|
407
|
+
existence = targetKind(absolutePath);
|
|
408
|
+
}
|
|
409
|
+
catch (error) {
|
|
410
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
411
|
+
pushIssue(issues, `${relativePath}: ${message}`);
|
|
412
|
+
targets.push({ input: targetPath, path: relativePath, exists: null, kind: 'unknown', language: 'other' });
|
|
413
|
+
findings.push(makeFinding('related_files_unreadable_path', 'high', relativePath, message));
|
|
414
|
+
continue;
|
|
415
|
+
}
|
|
416
|
+
targets.push({
|
|
417
|
+
input: targetPath,
|
|
418
|
+
path: relativePath,
|
|
419
|
+
exists: existence.exists,
|
|
420
|
+
kind: existence.kind,
|
|
421
|
+
language: languageForPath(absolutePath),
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
const sourceFiles = collectCandidateFiles(root, findings, issues, policy, SOURCE_EXTENSIONS);
|
|
425
|
+
const relatedFiles = collectCandidateFiles(root, findings, issues, policy, RELATED_EXTENSIONS);
|
|
426
|
+
const importMap = new Map();
|
|
427
|
+
const candidateMap = new Map();
|
|
428
|
+
for (const file of sourceFiles) {
|
|
429
|
+
importMap.set(file.relativePath, readImportsForFile(root, file, policy, issues));
|
|
430
|
+
}
|
|
431
|
+
for (const target of targets) {
|
|
432
|
+
if (target.kind !== 'file') {
|
|
433
|
+
addConfigParentCandidates(root, target, candidateMap, policy, findings, issues);
|
|
434
|
+
continue;
|
|
435
|
+
}
|
|
436
|
+
for (const reference of importMap.get(target.path) ?? []) {
|
|
437
|
+
if (reference.resolvedPath === null) {
|
|
438
|
+
continue;
|
|
439
|
+
}
|
|
440
|
+
addCandidate(candidateMap, {
|
|
441
|
+
path: reference.resolvedPath,
|
|
442
|
+
relationship: 'import',
|
|
443
|
+
confidence: 0.94,
|
|
444
|
+
reason: `${target.path} imports ${reference.specifier}.`,
|
|
445
|
+
source_path: target.path,
|
|
446
|
+
target_path: target.path,
|
|
447
|
+
line: reference.line,
|
|
448
|
+
}, policy, findings, issues);
|
|
449
|
+
}
|
|
450
|
+
for (const [sourcePath, references] of importMap.entries()) {
|
|
451
|
+
for (const reference of references) {
|
|
452
|
+
if (reference.resolvedPath !== target.path || sourcePath === target.path) {
|
|
453
|
+
continue;
|
|
454
|
+
}
|
|
455
|
+
addCandidate(candidateMap, {
|
|
456
|
+
path: sourcePath,
|
|
457
|
+
relationship: 'importer',
|
|
458
|
+
confidence: 0.9,
|
|
459
|
+
reason: `${sourcePath} imports ${target.path}.`,
|
|
460
|
+
source_path: sourcePath,
|
|
461
|
+
target_path: target.path,
|
|
462
|
+
line: reference.line,
|
|
463
|
+
}, policy, findings, issues);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
addSiblingCandidates(target, relatedFiles, candidateMap, policy, findings, issues);
|
|
467
|
+
addConfigParentCandidates(root, target, candidateMap, policy, findings, issues);
|
|
468
|
+
}
|
|
469
|
+
const candidates = [...candidateMap.values()].sort((left, right) => right.confidence - left.confidence ||
|
|
470
|
+
left.target_path.localeCompare(right.target_path) ||
|
|
471
|
+
left.relationship.localeCompare(right.relationship) ||
|
|
472
|
+
left.path.localeCompare(right.path) ||
|
|
473
|
+
(left.line ?? 0) - (right.line ?? 0));
|
|
474
|
+
const status = relatedFilesStatus(findings);
|
|
475
|
+
return {
|
|
476
|
+
schema_version: '1',
|
|
477
|
+
command: 'script-pack',
|
|
478
|
+
pack_id: RELATED_FILES_PACK_ID,
|
|
479
|
+
script_id: RELATED_FILES_SCRIPT_ID,
|
|
480
|
+
script_ref: RELATED_FILES_SCRIPT_REF,
|
|
481
|
+
action: 'map',
|
|
482
|
+
status,
|
|
483
|
+
ok: status === 'passed',
|
|
484
|
+
mustflow_root: root,
|
|
485
|
+
policy,
|
|
486
|
+
input_hash: createInputHash(policy, targets, candidates, findings, issues),
|
|
487
|
+
targets,
|
|
488
|
+
candidates,
|
|
489
|
+
truncated: findings.some((finding) => ['related_files_max_files_exceeded', 'related_files_max_candidates_exceeded'].includes(finding.code)),
|
|
490
|
+
findings,
|
|
491
|
+
issues,
|
|
492
|
+
};
|
|
493
|
+
}
|