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.
- package/README.md +40 -3
- package/dist/cli/commands/docs.js +86 -2
- package/dist/cli/commands/script-pack.js +9 -0
- package/dist/cli/i18n/en.js +180 -2
- package/dist/cli/i18n/es.js +180 -2
- package/dist/cli/i18n/fr.js +180 -2
- package/dist/cli/i18n/hi.js +180 -2
- package/dist/cli/i18n/ko.js +180 -2
- package/dist/cli/i18n/zh.js +180 -2
- package/dist/cli/lib/repo-map.js +27 -6
- package/dist/cli/lib/run-root-trust.js +15 -1
- package/dist/cli/lib/script-pack-registry.js +275 -6
- package/dist/cli/lib/validation/index.js +2 -2
- package/dist/cli/lib/validation/primitives.js +4 -1
- package/dist/cli/script-packs/code-change-impact.js +172 -0
- package/dist/cli/script-packs/code-dependency-graph.js +181 -0
- 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-env-contract.js +156 -0
- package/dist/cli/script-packs/repo-related-files.js +161 -0
- package/dist/cli/script-packs/repo-secret-risk-scan.js +147 -0
- package/dist/core/change-impact.js +383 -0
- package/dist/core/change-verification.js +32 -5
- package/dist/core/code-outline.js +460 -79
- package/dist/core/config-chain.js +595 -0
- package/dist/core/config-loading.js +121 -4
- package/dist/core/dependency-graph.js +490 -0
- package/dist/core/env-contract.js +450 -0
- package/dist/core/export-diff.js +359 -0
- package/dist/core/line-endings.js +26 -13
- package/dist/core/public-json-contracts.js +126 -0
- package/dist/core/reference-drift.js +388 -0
- package/dist/core/related-files.js +493 -0
- package/dist/core/route-outline.js +964 -0
- package/dist/core/script-pack-suggestions.js +131 -5
- package/dist/core/secret-risk-scan.js +440 -0
- package/dist/core/source-anchors.js +13 -1
- package/package.json +1 -1
- package/schemas/README.md +44 -6
- package/schemas/change-impact-report.schema.json +150 -0
- package/schemas/code-outline-report.schema.json +1 -1
- package/schemas/code-symbol-read-report.schema.json +64 -4
- package/schemas/commands.schema.json +12 -0
- package/schemas/config-chain-report.schema.json +187 -0
- package/schemas/dependency-graph-report.schema.json +149 -0
- package/schemas/env-contract-report.schema.json +203 -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/schemas/secret-risk-scan-report.schema.json +152 -0
- package/templates/default/common/.mustflow/config/commands.toml +21 -0
- package/templates/default/i18n.toml +21 -9
- package/templates/default/locales/en/.mustflow/docs/agent-workflow.md +1 -1
- package/templates/default/locales/en/.mustflow/skills/INDEX.md +8 -2
- package/templates/default/locales/en/.mustflow/skills/architecture-deepening-review/SKILL.md +28 -11
- package/templates/default/locales/en/.mustflow/skills/astro-code-change/SKILL.md +71 -27
- package/templates/default/locales/en/.mustflow/skills/cross-agent-session-reference/SKILL.md +146 -0
- package/templates/default/locales/en/.mustflow/skills/dependency-upgrade-review/SKILL.md +3 -1
- package/templates/default/locales/en/.mustflow/skills/github-contribution-quality-gate/SKILL.md +48 -11
- package/templates/default/locales/en/.mustflow/skills/javascript-code-change/SKILL.md +15 -13
- package/templates/default/locales/en/.mustflow/skills/node-code-change/SKILL.md +16 -14
- package/templates/default/locales/en/.mustflow/skills/routes.toml +21 -9
- package/templates/default/locales/en/.mustflow/skills/security-privacy-review/SKILL.md +3 -1
- package/templates/default/locales/en/.mustflow/skills/test-suite-performance-review/SKILL.md +314 -0
- package/templates/default/locales/en/.mustflow/skills/typescript-code-change/SKILL.md +13 -10
- package/templates/default/manifest.toml +15 -1
|
@@ -0,0 +1,595 @@
|
|
|
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 CONFIG_CHAIN_PACK_ID = 'repo';
|
|
6
|
+
export const CONFIG_CHAIN_SCRIPT_ID = 'config-chain';
|
|
7
|
+
export const CONFIG_CHAIN_SCRIPT_REF = `${CONFIG_CHAIN_PACK_ID}/${CONFIG_CHAIN_SCRIPT_ID}`;
|
|
8
|
+
const DEFAULT_MAX_FILE_BYTES = 256 * 1024;
|
|
9
|
+
const DEFAULT_MAX_CONFIGS = 120;
|
|
10
|
+
const MAX_ISSUES = 50;
|
|
11
|
+
const CONFIG_FILE_NAMES = [
|
|
12
|
+
'package.json',
|
|
13
|
+
'tsconfig.json',
|
|
14
|
+
'tsconfig.base.json',
|
|
15
|
+
'tsconfig.build.json',
|
|
16
|
+
'eslint.config.js',
|
|
17
|
+
'eslint.config.mjs',
|
|
18
|
+
'eslint.config.cjs',
|
|
19
|
+
'eslint.config.ts',
|
|
20
|
+
'.eslintrc',
|
|
21
|
+
'.eslintrc.json',
|
|
22
|
+
'.prettierrc',
|
|
23
|
+
'.prettierrc.json',
|
|
24
|
+
'prettier.config.js',
|
|
25
|
+
'vite.config.js',
|
|
26
|
+
'vite.config.ts',
|
|
27
|
+
'vitest.config.js',
|
|
28
|
+
'vitest.config.ts',
|
|
29
|
+
'tailwind.config.js',
|
|
30
|
+
'tailwind.config.ts',
|
|
31
|
+
'jest.config.js',
|
|
32
|
+
'jest.config.ts',
|
|
33
|
+
'playwright.config.ts',
|
|
34
|
+
'playwright.config.js',
|
|
35
|
+
'astro.config.mjs',
|
|
36
|
+
'svelte.config.js',
|
|
37
|
+
'.mustflow/config/commands.toml',
|
|
38
|
+
'.mustflow/config/mustflow.toml',
|
|
39
|
+
];
|
|
40
|
+
const IGNORED_DIRECTORIES = [
|
|
41
|
+
'.git',
|
|
42
|
+
'.mustflow/cache',
|
|
43
|
+
'.mustflow/state',
|
|
44
|
+
'node_modules',
|
|
45
|
+
'dist',
|
|
46
|
+
'build',
|
|
47
|
+
'coverage',
|
|
48
|
+
'.next',
|
|
49
|
+
'.turbo',
|
|
50
|
+
];
|
|
51
|
+
const ERROR_CODES = new Set([
|
|
52
|
+
'config_chain_path_outside_root',
|
|
53
|
+
'config_chain_unreadable_path',
|
|
54
|
+
'config_chain_parse_error',
|
|
55
|
+
'config_chain_max_configs_exceeded',
|
|
56
|
+
]);
|
|
57
|
+
function normalizeRelativePath(value) {
|
|
58
|
+
return value.replace(/\\/gu, '/').replace(/^\.\/+/u, '') || '.';
|
|
59
|
+
}
|
|
60
|
+
function sha256Tagged(buffer) {
|
|
61
|
+
return `sha256:${createHash('sha256').update(buffer).digest('hex')}`;
|
|
62
|
+
}
|
|
63
|
+
function makeFinding(code, severity, pathValue, message) {
|
|
64
|
+
return { code, severity, path: pathValue, message };
|
|
65
|
+
}
|
|
66
|
+
function pushIssue(issues, issue) {
|
|
67
|
+
if (issues.length < MAX_ISSUES) {
|
|
68
|
+
issues.push(issue);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
function configKindForPath(relativePath) {
|
|
72
|
+
const name = path.basename(relativePath).toLowerCase();
|
|
73
|
+
if (name === 'package.json') {
|
|
74
|
+
return 'package_json';
|
|
75
|
+
}
|
|
76
|
+
if (/^tsconfig(?:\..*)?\.json$/u.test(name)) {
|
|
77
|
+
return 'tsconfig';
|
|
78
|
+
}
|
|
79
|
+
if (name.includes('eslint')) {
|
|
80
|
+
return 'eslint';
|
|
81
|
+
}
|
|
82
|
+
if (name.includes('prettier') || name === '.prettierrc') {
|
|
83
|
+
return 'prettier';
|
|
84
|
+
}
|
|
85
|
+
if (name.includes('vitest')) {
|
|
86
|
+
return 'vitest';
|
|
87
|
+
}
|
|
88
|
+
if (name.includes('vite')) {
|
|
89
|
+
return 'vite';
|
|
90
|
+
}
|
|
91
|
+
if (name.includes('tailwind')) {
|
|
92
|
+
return 'tailwind';
|
|
93
|
+
}
|
|
94
|
+
if (name.includes('jest')) {
|
|
95
|
+
return 'jest';
|
|
96
|
+
}
|
|
97
|
+
if (name.includes('playwright')) {
|
|
98
|
+
return 'playwright';
|
|
99
|
+
}
|
|
100
|
+
if (relativePath.startsWith('.mustflow/config/')) {
|
|
101
|
+
return 'mustflow';
|
|
102
|
+
}
|
|
103
|
+
return 'other';
|
|
104
|
+
}
|
|
105
|
+
function configFormatForPath(relativePath) {
|
|
106
|
+
const name = path.basename(relativePath).toLowerCase();
|
|
107
|
+
const extension = path.extname(name);
|
|
108
|
+
if (name === '.eslintrc' || name === '.prettierrc') {
|
|
109
|
+
return 'jsonc';
|
|
110
|
+
}
|
|
111
|
+
if (extension === '.json') {
|
|
112
|
+
return name.startsWith('tsconfig') ? 'jsonc' : 'json';
|
|
113
|
+
}
|
|
114
|
+
if (['.js', '.mjs', '.cjs'].includes(extension)) {
|
|
115
|
+
return 'javascript';
|
|
116
|
+
}
|
|
117
|
+
if (['.ts', '.mts', '.cts'].includes(extension)) {
|
|
118
|
+
return 'typescript';
|
|
119
|
+
}
|
|
120
|
+
if (['.yml', '.yaml'].includes(extension)) {
|
|
121
|
+
return 'yaml';
|
|
122
|
+
}
|
|
123
|
+
if (extension === '.toml') {
|
|
124
|
+
return 'toml';
|
|
125
|
+
}
|
|
126
|
+
return 'unknown';
|
|
127
|
+
}
|
|
128
|
+
function isConfigFile(relativePath) {
|
|
129
|
+
const normalized = normalizeRelativePath(relativePath);
|
|
130
|
+
const name = path.basename(normalized);
|
|
131
|
+
return (CONFIG_FILE_NAMES.includes(normalized) ||
|
|
132
|
+
CONFIG_FILE_NAMES.includes(name) ||
|
|
133
|
+
/^tsconfig(?:\..*)?\.json$/u.test(name));
|
|
134
|
+
}
|
|
135
|
+
function isIgnoredDirectory(relativePath) {
|
|
136
|
+
const normalized = normalizeRelativePath(relativePath);
|
|
137
|
+
return IGNORED_DIRECTORIES.some((directory) => normalized === directory || normalized.startsWith(`${directory}/`));
|
|
138
|
+
}
|
|
139
|
+
function normalizeTargetPath(projectRoot, targetPath) {
|
|
140
|
+
const absolutePath = path.resolve(process.cwd(), targetPath);
|
|
141
|
+
ensureInside(projectRoot, absolutePath);
|
|
142
|
+
return {
|
|
143
|
+
absolutePath,
|
|
144
|
+
relativePath: normalizeRelativePath(path.relative(projectRoot, absolutePath)),
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
function targetKind(absolutePath) {
|
|
148
|
+
if (!existsSync(absolutePath)) {
|
|
149
|
+
return { exists: false, kind: 'missing' };
|
|
150
|
+
}
|
|
151
|
+
const stats = lstatSync(absolutePath);
|
|
152
|
+
if (stats.isFile()) {
|
|
153
|
+
return { exists: true, kind: 'file' };
|
|
154
|
+
}
|
|
155
|
+
if (stats.isDirectory()) {
|
|
156
|
+
return { exists: true, kind: 'directory' };
|
|
157
|
+
}
|
|
158
|
+
return { exists: true, kind: 'other' };
|
|
159
|
+
}
|
|
160
|
+
function addCandidate(candidates, findings, issues, policy, candidate) {
|
|
161
|
+
if (candidates.has(candidate.relativePath)) {
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
if (candidates.size >= policy.max_configs) {
|
|
165
|
+
if (!findings.some((finding) => finding.code === 'config_chain_max_configs_exceeded')) {
|
|
166
|
+
const message = `Config-chain matched more than ${policy.max_configs} config files; remaining files were skipped.`;
|
|
167
|
+
pushIssue(issues, message);
|
|
168
|
+
findings.push(makeFinding('config_chain_max_configs_exceeded', 'medium', candidate.relativePath, message));
|
|
169
|
+
}
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
candidates.set(candidate.relativePath, candidate);
|
|
173
|
+
}
|
|
174
|
+
function collectConfigFilesFromDirectory(projectRoot, absoluteDirectory, candidates, findings, issues, policy) {
|
|
175
|
+
const relativeDirectory = normalizeRelativePath(path.relative(projectRoot, absoluteDirectory));
|
|
176
|
+
if (isIgnoredDirectory(relativeDirectory)) {
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
let entries;
|
|
180
|
+
try {
|
|
181
|
+
ensureInsideWithoutSymlinks(projectRoot, absoluteDirectory);
|
|
182
|
+
entries = readdirSync(absoluteDirectory, { withFileTypes: true });
|
|
183
|
+
}
|
|
184
|
+
catch (error) {
|
|
185
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
186
|
+
pushIssue(issues, `${relativeDirectory}: ${message}`);
|
|
187
|
+
findings.push(makeFinding('config_chain_unreadable_path', 'high', relativeDirectory, message));
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
for (const entry of entries) {
|
|
191
|
+
const absoluteEntry = path.join(absoluteDirectory, entry.name);
|
|
192
|
+
const relativeEntry = normalizeRelativePath(path.relative(projectRoot, absoluteEntry));
|
|
193
|
+
if (entry.isDirectory()) {
|
|
194
|
+
collectConfigFilesFromDirectory(projectRoot, absoluteEntry, candidates, findings, issues, policy);
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
if (entry.isFile() && isConfigFile(relativeEntry)) {
|
|
198
|
+
addCandidate(candidates, findings, issues, policy, {
|
|
199
|
+
absolutePath: absoluteEntry,
|
|
200
|
+
relativePath: relativeEntry,
|
|
201
|
+
source: 'discovered',
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
function addParentConfigCandidates(projectRoot, targetPath, targetKindValue, candidates, findings, issues, policy) {
|
|
207
|
+
const startRelativeDirectory = targetKindValue === 'directory' ? targetPath : normalizeRelativePath(path.dirname(targetPath));
|
|
208
|
+
const startAbsoluteDirectory = path.join(projectRoot, ...startRelativeDirectory.split('/').filter((segment) => segment !== '.'));
|
|
209
|
+
let current = path.resolve(startAbsoluteDirectory);
|
|
210
|
+
while (current.startsWith(projectRoot)) {
|
|
211
|
+
for (const name of CONFIG_FILE_NAMES) {
|
|
212
|
+
if (name.includes('/')) {
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
const absolutePath = path.join(current, name);
|
|
216
|
+
if (!existsSync(absolutePath)) {
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
const relativePath = normalizeRelativePath(path.relative(projectRoot, absolutePath));
|
|
220
|
+
addCandidate(candidates, findings, issues, policy, { absolutePath, relativePath, source: 'parent' });
|
|
221
|
+
}
|
|
222
|
+
const parent = path.dirname(current);
|
|
223
|
+
if (parent === current || path.relative(projectRoot, parent).startsWith('..')) {
|
|
224
|
+
break;
|
|
225
|
+
}
|
|
226
|
+
current = parent;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
function stripJsonComments(text) {
|
|
230
|
+
let result = '';
|
|
231
|
+
let inString = false;
|
|
232
|
+
let quote = '';
|
|
233
|
+
let escaped = false;
|
|
234
|
+
let index = 0;
|
|
235
|
+
while (index < text.length) {
|
|
236
|
+
const current = text[index] ?? '';
|
|
237
|
+
const next = text[index + 1] ?? '';
|
|
238
|
+
if (inString) {
|
|
239
|
+
result += current;
|
|
240
|
+
if (escaped) {
|
|
241
|
+
escaped = false;
|
|
242
|
+
}
|
|
243
|
+
else if (current === '\\') {
|
|
244
|
+
escaped = true;
|
|
245
|
+
}
|
|
246
|
+
else if (current === quote) {
|
|
247
|
+
inString = false;
|
|
248
|
+
}
|
|
249
|
+
index += 1;
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
if (current === '"' || current === "'") {
|
|
253
|
+
inString = true;
|
|
254
|
+
quote = current;
|
|
255
|
+
result += current;
|
|
256
|
+
index += 1;
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
if (current === '/' && next === '/') {
|
|
260
|
+
while (index < text.length && text[index] !== '\n') {
|
|
261
|
+
index += 1;
|
|
262
|
+
}
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
if (current === '/' && next === '*') {
|
|
266
|
+
index += 2;
|
|
267
|
+
while (index < text.length && !(text[index] === '*' && text[index + 1] === '/')) {
|
|
268
|
+
index += 1;
|
|
269
|
+
}
|
|
270
|
+
index += 2;
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
result += current;
|
|
274
|
+
index += 1;
|
|
275
|
+
}
|
|
276
|
+
return result.replace(/,\s*([}\]])/gu, '$1');
|
|
277
|
+
}
|
|
278
|
+
function parseJsonLike(text, format) {
|
|
279
|
+
if (format !== 'json' && format !== 'jsonc') {
|
|
280
|
+
return { value: null, error: 'Dynamic or non-JSON config files are not executed by config-chain.' };
|
|
281
|
+
}
|
|
282
|
+
try {
|
|
283
|
+
return { value: JSON.parse(stripJsonComments(text)), error: null };
|
|
284
|
+
}
|
|
285
|
+
catch (error) {
|
|
286
|
+
return { value: null, error: error instanceof Error ? error.message : String(error) };
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
function stringArray(value) {
|
|
290
|
+
if (!Array.isArray(value)) {
|
|
291
|
+
return [];
|
|
292
|
+
}
|
|
293
|
+
return value.filter((entry) => typeof entry === 'string');
|
|
294
|
+
}
|
|
295
|
+
function objectValue(value, key) {
|
|
296
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value[key] : undefined;
|
|
297
|
+
}
|
|
298
|
+
function objectKeys(value) {
|
|
299
|
+
return value && typeof value === 'object' && !Array.isArray(value)
|
|
300
|
+
? Object.keys(value).sort((left, right) => left.localeCompare(right))
|
|
301
|
+
: [];
|
|
302
|
+
}
|
|
303
|
+
function packageWorkspaces(value) {
|
|
304
|
+
const raw = objectValue(value, 'workspaces');
|
|
305
|
+
if (Array.isArray(raw)) {
|
|
306
|
+
return stringArray(raw);
|
|
307
|
+
}
|
|
308
|
+
return stringArray(objectValue(raw, 'packages'));
|
|
309
|
+
}
|
|
310
|
+
function tsconfigReferences(value) {
|
|
311
|
+
const references = objectValue(value, 'references');
|
|
312
|
+
if (!Array.isArray(references)) {
|
|
313
|
+
return [];
|
|
314
|
+
}
|
|
315
|
+
return references
|
|
316
|
+
.map((entry) => objectValue(entry, 'path'))
|
|
317
|
+
.filter((entry) => typeof entry === 'string');
|
|
318
|
+
}
|
|
319
|
+
function resolveRelativeConfig(projectRoot, fromRelativePath, specifier) {
|
|
320
|
+
if (!specifier.startsWith('./') && !specifier.startsWith('../')) {
|
|
321
|
+
return null;
|
|
322
|
+
}
|
|
323
|
+
const fromDirectory = path.dirname(path.join(projectRoot, ...fromRelativePath.split('/')));
|
|
324
|
+
const base = path.resolve(fromDirectory, specifier);
|
|
325
|
+
const candidates = [base, `${base}.json`, path.join(base, 'tsconfig.json')];
|
|
326
|
+
for (const candidate of candidates) {
|
|
327
|
+
try {
|
|
328
|
+
ensureInside(projectRoot, candidate);
|
|
329
|
+
if (existsSync(candidate) && lstatSync(candidate).isFile()) {
|
|
330
|
+
return normalizeRelativePath(path.relative(projectRoot, candidate));
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
catch {
|
|
334
|
+
return null;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
return null;
|
|
338
|
+
}
|
|
339
|
+
function summarizeConfig(kind, value) {
|
|
340
|
+
switch (kind) {
|
|
341
|
+
case 'package_json': {
|
|
342
|
+
const scripts = objectKeys(objectValue(value, 'scripts'));
|
|
343
|
+
const workspaces = packageWorkspaces(value);
|
|
344
|
+
return [
|
|
345
|
+
...scripts.slice(0, 6).map((script) => `script:${script}`),
|
|
346
|
+
...workspaces.slice(0, 6).map((workspace) => `workspace:${workspace}`),
|
|
347
|
+
];
|
|
348
|
+
}
|
|
349
|
+
case 'tsconfig': {
|
|
350
|
+
const compilerOptions = objectKeys(objectValue(value, 'compilerOptions'));
|
|
351
|
+
const include = stringArray(objectValue(value, 'include'));
|
|
352
|
+
const exclude = stringArray(objectValue(value, 'exclude'));
|
|
353
|
+
return [
|
|
354
|
+
...compilerOptions.slice(0, 8).map((option) => `compilerOption:${option}`),
|
|
355
|
+
...include.slice(0, 4).map((entry) => `include:${entry}`),
|
|
356
|
+
...exclude.slice(0, 4).map((entry) => `exclude:${entry}`),
|
|
357
|
+
];
|
|
358
|
+
}
|
|
359
|
+
default:
|
|
360
|
+
return objectKeys(value).slice(0, 8).map((key) => `key:${key}`);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
function inspectConfigCandidate(projectRoot, candidate, policy, findings, issues) {
|
|
364
|
+
const kind = configKindForPath(candidate.relativePath);
|
|
365
|
+
const format = configFormatForPath(candidate.relativePath);
|
|
366
|
+
const dynamic = !['json', 'jsonc'].includes(format);
|
|
367
|
+
const edges = [];
|
|
368
|
+
const extraCandidates = [];
|
|
369
|
+
let buffer = null;
|
|
370
|
+
try {
|
|
371
|
+
buffer = readFileInsideWithoutSymlinks(projectRoot, candidate.absolutePath, { maxBytes: policy.max_file_bytes });
|
|
372
|
+
}
|
|
373
|
+
catch (error) {
|
|
374
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
375
|
+
pushIssue(issues, `${candidate.relativePath}: ${message}`);
|
|
376
|
+
findings.push(makeFinding('config_chain_unreadable_path', 'high', candidate.relativePath, message));
|
|
377
|
+
}
|
|
378
|
+
const text = buffer?.toString('utf8') ?? '';
|
|
379
|
+
const parsed = buffer ? parseJsonLike(text, format) : { value: null, error: 'File could not be read.' };
|
|
380
|
+
const parseable = parsed.error === null;
|
|
381
|
+
if (dynamic && buffer) {
|
|
382
|
+
findings.push(makeFinding('config_chain_dynamic_config', 'low', candidate.relativePath, `${candidate.relativePath} is ${format}; config-chain records it without executing code.`));
|
|
383
|
+
}
|
|
384
|
+
else if (!parseable) {
|
|
385
|
+
findings.push(makeFinding('config_chain_parse_error', 'high', candidate.relativePath, parsed.error ?? 'Parse failed.'));
|
|
386
|
+
}
|
|
387
|
+
const value = parseable ? parsed.value : null;
|
|
388
|
+
const extendsValue = typeof objectValue(value, 'extends') === 'string' ? String(objectValue(value, 'extends')) : null;
|
|
389
|
+
const references = tsconfigReferences(value);
|
|
390
|
+
if (extendsValue) {
|
|
391
|
+
const resolved = resolveRelativeConfig(projectRoot, candidate.relativePath, extendsValue);
|
|
392
|
+
edges.push({
|
|
393
|
+
from_path: candidate.relativePath,
|
|
394
|
+
to_path: resolved,
|
|
395
|
+
kind: 'extends',
|
|
396
|
+
specifier: extendsValue,
|
|
397
|
+
resolved: resolved !== null,
|
|
398
|
+
});
|
|
399
|
+
if (resolved) {
|
|
400
|
+
extraCandidates.push({
|
|
401
|
+
absolutePath: path.join(projectRoot, ...resolved.split('/')),
|
|
402
|
+
relativePath: resolved,
|
|
403
|
+
source: 'discovered',
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
else {
|
|
407
|
+
findings.push(makeFinding('config_chain_external_reference', 'low', candidate.relativePath, `${candidate.relativePath} extends external config ${extendsValue}.`));
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
for (const reference of references) {
|
|
411
|
+
const resolved = resolveRelativeConfig(projectRoot, candidate.relativePath, reference);
|
|
412
|
+
edges.push({
|
|
413
|
+
from_path: candidate.relativePath,
|
|
414
|
+
to_path: resolved,
|
|
415
|
+
kind: 'reference',
|
|
416
|
+
specifier: reference,
|
|
417
|
+
resolved: resolved !== null,
|
|
418
|
+
});
|
|
419
|
+
if (resolved) {
|
|
420
|
+
extraCandidates.push({
|
|
421
|
+
absolutePath: path.join(projectRoot, ...resolved.split('/')),
|
|
422
|
+
relativePath: resolved,
|
|
423
|
+
source: 'discovered',
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
else {
|
|
427
|
+
findings.push(makeFinding('config_chain_missing_reference', 'medium', candidate.relativePath, `${candidate.relativePath} references ${reference}, but config-chain could not resolve it inside the root.`));
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
for (const workspace of packageWorkspaces(value)) {
|
|
431
|
+
edges.push({
|
|
432
|
+
from_path: candidate.relativePath,
|
|
433
|
+
to_path: null,
|
|
434
|
+
kind: 'workspace',
|
|
435
|
+
specifier: workspace,
|
|
436
|
+
resolved: false,
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
return {
|
|
440
|
+
entry: {
|
|
441
|
+
path: candidate.relativePath,
|
|
442
|
+
kind,
|
|
443
|
+
format,
|
|
444
|
+
source: candidate.source,
|
|
445
|
+
sha256: buffer ? sha256Tagged(buffer) : null,
|
|
446
|
+
size_bytes: buffer?.byteLength ?? null,
|
|
447
|
+
parseable,
|
|
448
|
+
dynamic,
|
|
449
|
+
package_name: typeof objectValue(value, 'name') === 'string' ? String(objectValue(value, 'name')) : null,
|
|
450
|
+
workspaces: packageWorkspaces(value),
|
|
451
|
+
scripts: objectKeys(objectValue(value, 'scripts')),
|
|
452
|
+
extends: extendsValue ? [extendsValue] : [],
|
|
453
|
+
references,
|
|
454
|
+
include: stringArray(objectValue(value, 'include')),
|
|
455
|
+
exclude: stringArray(objectValue(value, 'exclude')),
|
|
456
|
+
compiler_options: objectKeys(objectValue(value, 'compilerOptions')),
|
|
457
|
+
summary: summarizeConfig(kind, value),
|
|
458
|
+
},
|
|
459
|
+
edges,
|
|
460
|
+
extraCandidates,
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
function configChainStatus(findings) {
|
|
464
|
+
if (findings.some((finding) => ERROR_CODES.has(finding.code))) {
|
|
465
|
+
return 'error';
|
|
466
|
+
}
|
|
467
|
+
if (findings.some((finding) => ['medium', 'high', 'critical'].includes(finding.severity))) {
|
|
468
|
+
return 'failed';
|
|
469
|
+
}
|
|
470
|
+
return 'passed';
|
|
471
|
+
}
|
|
472
|
+
function createInputHash(policy, targets, configs, edges, findings) {
|
|
473
|
+
return sha256Tagged(JSON.stringify({
|
|
474
|
+
policy,
|
|
475
|
+
targets,
|
|
476
|
+
configs: configs.map((config) => ({
|
|
477
|
+
path: config.path,
|
|
478
|
+
sha256: config.sha256,
|
|
479
|
+
kind: config.kind,
|
|
480
|
+
format: config.format,
|
|
481
|
+
parseable: config.parseable,
|
|
482
|
+
})),
|
|
483
|
+
edges,
|
|
484
|
+
findings: findings.map((finding) => ({ code: finding.code, path: finding.path })),
|
|
485
|
+
}));
|
|
486
|
+
}
|
|
487
|
+
export function inspectConfigChain(projectRoot, options) {
|
|
488
|
+
const root = path.resolve(projectRoot);
|
|
489
|
+
const policy = {
|
|
490
|
+
max_file_bytes: options.maxFileBytes ?? DEFAULT_MAX_FILE_BYTES,
|
|
491
|
+
max_configs: options.maxConfigs ?? DEFAULT_MAX_CONFIGS,
|
|
492
|
+
config_names: [...CONFIG_FILE_NAMES],
|
|
493
|
+
ignored_directories: [...IGNORED_DIRECTORIES],
|
|
494
|
+
};
|
|
495
|
+
const targets = [];
|
|
496
|
+
const candidates = new Map();
|
|
497
|
+
const configs = [];
|
|
498
|
+
const edges = [];
|
|
499
|
+
const findings = [];
|
|
500
|
+
const issues = [];
|
|
501
|
+
for (const targetPath of options.paths) {
|
|
502
|
+
let absolutePath;
|
|
503
|
+
let relativePath;
|
|
504
|
+
try {
|
|
505
|
+
const normalized = normalizeTargetPath(root, targetPath);
|
|
506
|
+
absolutePath = normalized.absolutePath;
|
|
507
|
+
relativePath = normalized.relativePath;
|
|
508
|
+
ensureInsideWithoutSymlinks(root, absolutePath, { allowMissingLeaf: true });
|
|
509
|
+
}
|
|
510
|
+
catch (error) {
|
|
511
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
512
|
+
pushIssue(issues, message);
|
|
513
|
+
targets.push({ input: targetPath, path: targetPath, exists: null, kind: 'unknown' });
|
|
514
|
+
findings.push(makeFinding('config_chain_path_outside_root', 'high', targetPath, message));
|
|
515
|
+
continue;
|
|
516
|
+
}
|
|
517
|
+
let existence;
|
|
518
|
+
try {
|
|
519
|
+
existence = targetKind(absolutePath);
|
|
520
|
+
}
|
|
521
|
+
catch (error) {
|
|
522
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
523
|
+
pushIssue(issues, `${relativePath}: ${message}`);
|
|
524
|
+
targets.push({ input: targetPath, path: relativePath, exists: null, kind: 'unknown' });
|
|
525
|
+
findings.push(makeFinding('config_chain_unreadable_path', 'high', relativePath, message));
|
|
526
|
+
continue;
|
|
527
|
+
}
|
|
528
|
+
targets.push({ input: targetPath, path: relativePath, exists: existence.exists, kind: existence.kind });
|
|
529
|
+
if (existence.kind === 'file' && isConfigFile(relativePath)) {
|
|
530
|
+
addCandidate(candidates, findings, issues, policy, { absolutePath, relativePath, source: 'target' });
|
|
531
|
+
}
|
|
532
|
+
if (existence.kind === 'directory') {
|
|
533
|
+
collectConfigFilesFromDirectory(root, absolutePath, candidates, findings, issues, policy);
|
|
534
|
+
}
|
|
535
|
+
if (existence.kind === 'file' || existence.kind === 'directory') {
|
|
536
|
+
addParentConfigCandidates(root, relativePath, existence.kind, candidates, findings, issues, policy);
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
let cursor = 0;
|
|
540
|
+
while (cursor < candidates.size) {
|
|
541
|
+
const candidate = [...candidates.values()][cursor];
|
|
542
|
+
cursor += 1;
|
|
543
|
+
if (!candidate) {
|
|
544
|
+
continue;
|
|
545
|
+
}
|
|
546
|
+
const result = inspectConfigCandidate(root, candidate, policy, findings, issues);
|
|
547
|
+
configs.push(result.entry);
|
|
548
|
+
edges.push(...result.edges);
|
|
549
|
+
for (const extraCandidate of result.extraCandidates) {
|
|
550
|
+
addCandidate(candidates, findings, issues, policy, extraCandidate);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
for (const target of targets) {
|
|
554
|
+
for (const config of configs) {
|
|
555
|
+
if (target.kind !== 'file' && target.kind !== 'directory') {
|
|
556
|
+
continue;
|
|
557
|
+
}
|
|
558
|
+
const targetDirectory = target.kind === 'directory' ? target.path : normalizeRelativePath(path.dirname(target.path));
|
|
559
|
+
const configDirectory = normalizeRelativePath(path.dirname(config.path));
|
|
560
|
+
if (targetDirectory === configDirectory || targetDirectory.startsWith(`${configDirectory}/`) || configDirectory === '.') {
|
|
561
|
+
edges.push({
|
|
562
|
+
from_path: target.path,
|
|
563
|
+
to_path: config.path,
|
|
564
|
+
kind: 'parent_config',
|
|
565
|
+
specifier: config.path,
|
|
566
|
+
resolved: true,
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
const sortedConfigs = configs.sort((left, right) => left.path.localeCompare(right.path));
|
|
572
|
+
const sortedEdges = edges.sort((left, right) => left.from_path.localeCompare(right.from_path) ||
|
|
573
|
+
left.kind.localeCompare(right.kind) ||
|
|
574
|
+
(left.to_path ?? '').localeCompare(right.to_path ?? '') ||
|
|
575
|
+
left.specifier.localeCompare(right.specifier));
|
|
576
|
+
const status = configChainStatus(findings);
|
|
577
|
+
return {
|
|
578
|
+
schema_version: '1',
|
|
579
|
+
command: 'script-pack',
|
|
580
|
+
pack_id: CONFIG_CHAIN_PACK_ID,
|
|
581
|
+
script_id: CONFIG_CHAIN_SCRIPT_ID,
|
|
582
|
+
script_ref: CONFIG_CHAIN_SCRIPT_REF,
|
|
583
|
+
action: 'inspect',
|
|
584
|
+
status,
|
|
585
|
+
ok: status === 'passed',
|
|
586
|
+
mustflow_root: root,
|
|
587
|
+
policy,
|
|
588
|
+
input_hash: createInputHash(policy, targets, sortedConfigs, sortedEdges, findings),
|
|
589
|
+
targets,
|
|
590
|
+
configs: sortedConfigs,
|
|
591
|
+
edges: sortedEdges,
|
|
592
|
+
findings,
|
|
593
|
+
issues,
|
|
594
|
+
};
|
|
595
|
+
}
|