mustflow 2.84.0 → 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 (48) hide show
  1. package/README.md +11 -2
  2. package/dist/cli/commands/script-pack.js +4 -0
  3. package/dist/cli/i18n/en.js +79 -0
  4. package/dist/cli/i18n/es.js +79 -0
  5. package/dist/cli/i18n/fr.js +79 -0
  6. package/dist/cli/i18n/hi.js +79 -0
  7. package/dist/cli/i18n/ko.js +79 -0
  8. package/dist/cli/i18n/zh.js +79 -0
  9. package/dist/cli/lib/repo-map.js +27 -6
  10. package/dist/cli/lib/run-root-trust.js +15 -1
  11. package/dist/cli/lib/script-pack-registry.js +114 -0
  12. package/dist/cli/lib/validation/index.js +2 -2
  13. package/dist/cli/lib/validation/primitives.js +4 -1
  14. package/dist/cli/script-packs/code-change-impact.js +172 -0
  15. package/dist/cli/script-packs/code-dependency-graph.js +181 -0
  16. package/dist/cli/script-packs/repo-env-contract.js +156 -0
  17. package/dist/cli/script-packs/repo-secret-risk-scan.js +147 -0
  18. package/dist/core/change-impact.js +383 -0
  19. package/dist/core/change-verification.js +32 -5
  20. package/dist/core/config-loading.js +121 -4
  21. package/dist/core/dependency-graph.js +490 -0
  22. package/dist/core/env-contract.js +450 -0
  23. package/dist/core/line-endings.js +26 -13
  24. package/dist/core/public-json-contracts.js +51 -0
  25. package/dist/core/route-outline.js +57 -5
  26. package/dist/core/script-pack-suggestions.js +21 -1
  27. package/dist/core/secret-risk-scan.js +440 -0
  28. package/package.json +1 -1
  29. package/schemas/README.md +16 -0
  30. package/schemas/change-impact-report.schema.json +150 -0
  31. package/schemas/commands.schema.json +12 -0
  32. package/schemas/dependency-graph-report.schema.json +149 -0
  33. package/schemas/env-contract-report.schema.json +203 -0
  34. package/schemas/secret-risk-scan-report.schema.json +152 -0
  35. package/templates/default/i18n.toml +16 -10
  36. package/templates/default/locales/en/.mustflow/skills/INDEX.md +7 -2
  37. package/templates/default/locales/en/.mustflow/skills/architecture-deepening-review/SKILL.md +28 -11
  38. package/templates/default/locales/en/.mustflow/skills/astro-code-change/SKILL.md +71 -27
  39. package/templates/default/locales/en/.mustflow/skills/cross-agent-session-reference/SKILL.md +23 -8
  40. package/templates/default/locales/en/.mustflow/skills/dependency-upgrade-review/SKILL.md +3 -1
  41. package/templates/default/locales/en/.mustflow/skills/github-contribution-quality-gate/SKILL.md +48 -11
  42. package/templates/default/locales/en/.mustflow/skills/javascript-code-change/SKILL.md +15 -13
  43. package/templates/default/locales/en/.mustflow/skills/node-code-change/SKILL.md +16 -14
  44. package/templates/default/locales/en/.mustflow/skills/routes.toml +15 -9
  45. package/templates/default/locales/en/.mustflow/skills/security-privacy-review/SKILL.md +3 -1
  46. package/templates/default/locales/en/.mustflow/skills/test-suite-performance-review/SKILL.md +314 -0
  47. package/templates/default/locales/en/.mustflow/skills/typescript-code-change/SKILL.md +13 -10
  48. package/templates/default/manifest.toml +8 -1
@@ -0,0 +1,450 @@
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 ENV_CONTRACT_PACK_ID = 'repo';
6
+ export const ENV_CONTRACT_SCRIPT_ID = 'env-contract';
7
+ export const ENV_CONTRACT_SCRIPT_REF = `${ENV_CONTRACT_PACK_ID}/${ENV_CONTRACT_SCRIPT_ID}`;
8
+ const DEFAULT_MAX_FILE_BYTES = 256 * 1024;
9
+ const DEFAULT_MAX_FILES = 1000;
10
+ const DEFAULT_MAX_KEYS = 300;
11
+ const MAX_ISSUES = 50;
12
+ const ENV_KEY_PATTERN = '[A-Za-z_][A-Za-z0-9_]*';
13
+ const SCAN_EXTENSIONS = [
14
+ '.ts',
15
+ '.tsx',
16
+ '.mts',
17
+ '.cts',
18
+ '.js',
19
+ '.jsx',
20
+ '.mjs',
21
+ '.cjs',
22
+ '.json',
23
+ '.toml',
24
+ '.yml',
25
+ '.yaml',
26
+ '.md',
27
+ '.mdx',
28
+ ];
29
+ const ENV_EXAMPLE_NAMES = [
30
+ '.env.example',
31
+ '.env.sample',
32
+ '.env.template',
33
+ '.env.defaults',
34
+ '.env.test.example',
35
+ '.env.local.example',
36
+ '.dev.vars.example',
37
+ ];
38
+ const SECRET_ENV_NAMES = ['.env', '.env.local', '.env.production', '.env.development', '.dev.vars'];
39
+ const IGNORED_DIRECTORIES = [
40
+ '.git',
41
+ '.mustflow/cache',
42
+ '.mustflow/state',
43
+ 'node_modules',
44
+ 'dist',
45
+ 'build',
46
+ 'coverage',
47
+ '.next',
48
+ '.turbo',
49
+ ];
50
+ const ERROR_CODES = new Set([
51
+ 'env_contract_path_outside_root',
52
+ 'env_contract_unreadable_path',
53
+ ]);
54
+ function normalizeRelativePath(value) {
55
+ return value.replace(/\\/gu, '/').replace(/^\.\/+/u, '') || '.';
56
+ }
57
+ function sha256Tagged(value) {
58
+ return `sha256:${createHash('sha256').update(value).digest('hex')}`;
59
+ }
60
+ function makeFinding(code, severity, pathValue, message, key) {
61
+ return key ? { code, severity, path: pathValue, message, key } : { code, severity, path: pathValue, message };
62
+ }
63
+ function pushIssue(issues, issue) {
64
+ if (issues.length < MAX_ISSUES) {
65
+ issues.push(issue);
66
+ }
67
+ }
68
+ function isIgnoredDirectory(relativePath) {
69
+ const normalized = normalizeRelativePath(relativePath);
70
+ return IGNORED_DIRECTORIES.some((directory) => normalized === directory || normalized.startsWith(`${directory}/`));
71
+ }
72
+ function isEnvExampleFile(relativePath) {
73
+ const name = path.basename(relativePath).toLowerCase();
74
+ return ENV_EXAMPLE_NAMES.includes(name);
75
+ }
76
+ function isSecretEnvFile(relativePath) {
77
+ const name = path.basename(relativePath).toLowerCase();
78
+ return SECRET_ENV_NAMES.includes(name);
79
+ }
80
+ function surfaceForPath(relativePath) {
81
+ if (isEnvExampleFile(relativePath)) {
82
+ return 'example';
83
+ }
84
+ const normalized = normalizeRelativePath(relativePath);
85
+ const extension = path.extname(normalized).toLowerCase();
86
+ if (!SCAN_EXTENSIONS.includes(extension)) {
87
+ return null;
88
+ }
89
+ if (normalized.startsWith('.github/workflows/') || ['.yml', '.yaml'].includes(extension)) {
90
+ return 'ci';
91
+ }
92
+ if (['.md', '.mdx'].includes(extension)) {
93
+ return 'docs';
94
+ }
95
+ if (['.json', '.toml'].includes(extension) || normalized.startsWith('.mustflow/config/')) {
96
+ return 'config';
97
+ }
98
+ return 'code';
99
+ }
100
+ function normalizeTargetPath(projectRoot, targetPath) {
101
+ const absolutePath = path.resolve(process.cwd(), targetPath);
102
+ ensureInside(projectRoot, absolutePath);
103
+ return {
104
+ absolutePath,
105
+ relativePath: normalizeRelativePath(path.relative(projectRoot, absolutePath)),
106
+ };
107
+ }
108
+ function targetKind(absolutePath) {
109
+ if (!existsSync(absolutePath)) {
110
+ return { exists: false, kind: 'missing' };
111
+ }
112
+ const stats = lstatSync(absolutePath);
113
+ if (stats.isFile()) {
114
+ return { exists: true, kind: 'file' };
115
+ }
116
+ if (stats.isDirectory()) {
117
+ return { exists: true, kind: 'directory' };
118
+ }
119
+ return { exists: true, kind: 'other' };
120
+ }
121
+ function addCandidate(candidates, findings, issues, policy, candidate) {
122
+ if (candidates.has(candidate.relativePath)) {
123
+ return;
124
+ }
125
+ if (candidates.size >= policy.max_files) {
126
+ if (!findings.some((finding) => finding.code === 'env_contract_max_files_exceeded')) {
127
+ const message = `Env-contract matched more than ${policy.max_files} files; remaining files were skipped.`;
128
+ pushIssue(issues, message);
129
+ findings.push(makeFinding('env_contract_max_files_exceeded', 'medium', candidate.relativePath, message));
130
+ }
131
+ return;
132
+ }
133
+ candidates.set(candidate.relativePath, candidate);
134
+ }
135
+ function collectFilesFromDirectory(projectRoot, absoluteDirectory, candidates, findings, issues, policy) {
136
+ const relativeDirectory = normalizeRelativePath(path.relative(projectRoot, absoluteDirectory));
137
+ if (isIgnoredDirectory(relativeDirectory)) {
138
+ return;
139
+ }
140
+ let entries;
141
+ try {
142
+ ensureInsideWithoutSymlinks(projectRoot, absoluteDirectory);
143
+ entries = readdirSync(absoluteDirectory, { withFileTypes: true });
144
+ }
145
+ catch (error) {
146
+ const message = error instanceof Error ? error.message : String(error);
147
+ pushIssue(issues, `${relativeDirectory}: ${message}`);
148
+ findings.push(makeFinding('env_contract_unreadable_path', 'high', relativeDirectory, message));
149
+ return;
150
+ }
151
+ for (const entry of entries) {
152
+ const absoluteEntry = path.join(absoluteDirectory, entry.name);
153
+ const relativeEntry = normalizeRelativePath(path.relative(projectRoot, absoluteEntry));
154
+ if (entry.isDirectory()) {
155
+ collectFilesFromDirectory(projectRoot, absoluteEntry, candidates, findings, issues, policy);
156
+ continue;
157
+ }
158
+ if (!entry.isFile()) {
159
+ continue;
160
+ }
161
+ const surface = surfaceForPath(relativeEntry);
162
+ if (surface) {
163
+ addCandidate(candidates, findings, issues, policy, { absolutePath: absoluteEntry, relativePath: relativeEntry, surface });
164
+ }
165
+ }
166
+ }
167
+ function lineNumberAtIndex(text, index) {
168
+ let line = 1;
169
+ let offset = 0;
170
+ while (offset < index) {
171
+ if (text.charCodeAt(offset) === 10) {
172
+ line += 1;
173
+ }
174
+ offset += 1;
175
+ }
176
+ return line;
177
+ }
178
+ function addReference(references, seen, key, candidate, line, kind) {
179
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/u.test(key)) {
180
+ return;
181
+ }
182
+ const reference = { key, path: candidate.relativePath, line, surface: candidate.surface, kind };
183
+ const dedupeKey = `${reference.key}\0${reference.path}\0${reference.line}\0${reference.kind}`;
184
+ if (seen.has(dedupeKey)) {
185
+ return;
186
+ }
187
+ seen.add(dedupeKey);
188
+ references.push(reference);
189
+ }
190
+ function collectRegexReferences(text, candidate, references, seen, pattern, kind) {
191
+ for (const match of text.matchAll(pattern)) {
192
+ const key = match.groups?.key;
193
+ if (key) {
194
+ addReference(references, seen, key, candidate, lineNumberAtIndex(text, match.index ?? 0), kind);
195
+ }
196
+ }
197
+ }
198
+ function collectDestructuredProcessEnv(text, candidate, references, seen) {
199
+ const pattern = /\{(?<body>[^}]+)\}\s*=\s*process\.env\b/gu;
200
+ for (const match of text.matchAll(pattern)) {
201
+ const body = match.groups?.body ?? '';
202
+ for (const entry of body.split(',')) {
203
+ const key = new RegExp(`^\\s*(?<key>${ENV_KEY_PATTERN})(?:\\s*:|\\s*$)`, 'u').exec(entry)?.groups?.key;
204
+ if (key) {
205
+ addReference(references, seen, key, candidate, lineNumberAtIndex(text, match.index ?? 0), 'process_env_destructure');
206
+ }
207
+ }
208
+ }
209
+ }
210
+ function collectEnvExampleReferences(text, candidate, references, seen) {
211
+ const lines = text.split(/\r?\n/u);
212
+ for (const [index, line] of lines.entries()) {
213
+ const match = new RegExp(`^\\s*(?:export\\s+)?(?<key>${ENV_KEY_PATTERN})\\s*=`, 'u').exec(line);
214
+ if (match?.groups?.key) {
215
+ addReference(references, seen, match.groups.key, candidate, index + 1, 'env_example');
216
+ }
217
+ }
218
+ }
219
+ function collectCiReferences(text, candidate, references, seen) {
220
+ collectRegexReferences(text, candidate, references, seen, /\$\{\{\s*secrets\.(?<key>[A-Z][A-Z0-9_]+)\s*\}\}/gu, 'ci_secret');
221
+ collectRegexReferences(text, candidate, references, seen, /\$\{\{\s*vars\.(?<key>[A-Z][A-Z0-9_]+)\s*\}\}/gu, 'ci_var');
222
+ const lines = text.split(/\r?\n/u);
223
+ for (const [index, line] of lines.entries()) {
224
+ const match = /^\s{2,}(?<key>[A-Z][A-Z0-9_]{2,})\s*:/u.exec(line);
225
+ if (match?.groups?.key) {
226
+ addReference(references, seen, match.groups.key, candidate, index + 1, 'ci_env');
227
+ }
228
+ }
229
+ }
230
+ function collectDocumentedReferences(text, candidate, references, seen) {
231
+ const pattern = /`(?<key>[A-Z][A-Z0-9_]{2,})`|\b(?<bare>[A-Z][A-Z0-9_]{3,})\b/gu;
232
+ for (const match of text.matchAll(pattern)) {
233
+ const key = match.groups?.key ?? match.groups?.bare;
234
+ if (key && key.includes('_')) {
235
+ addReference(references, seen, key, candidate, lineNumberAtIndex(text, match.index ?? 0), 'documented');
236
+ }
237
+ }
238
+ }
239
+ function collectCodeReferences(text, candidate, references, seen) {
240
+ collectRegexReferences(text, candidate, references, seen, /\bprocess\.env\.(?<key>[A-Za-z_][A-Za-z0-9_]*)\b/gu, 'process_env_dot');
241
+ collectRegexReferences(text, candidate, references, seen, /\bprocess\.env\s*\[\s*['"](?<key>[A-Za-z_][A-Za-z0-9_]*)['"]\s*\]/gu, 'process_env_bracket');
242
+ collectRegexReferences(text, candidate, references, seen, /\bBun\.env\.(?<key>[A-Za-z_][A-Za-z0-9_]*)\b/gu, 'bun_env_dot');
243
+ collectRegexReferences(text, candidate, references, seen, /\bimport\.meta\.env\.(?<key>[A-Za-z_][A-Za-z0-9_]*)\b/gu, 'import_meta_env');
244
+ collectDestructuredProcessEnv(text, candidate, references, seen);
245
+ }
246
+ function collectReferencesForFile(text, candidate) {
247
+ const references = [];
248
+ const seen = new Set();
249
+ if (candidate.surface === 'example') {
250
+ collectEnvExampleReferences(text, candidate, references, seen);
251
+ }
252
+ else if (candidate.surface === 'ci') {
253
+ collectCiReferences(text, candidate, references, seen);
254
+ collectCodeReferences(text, candidate, references, seen);
255
+ }
256
+ else if (candidate.surface === 'docs') {
257
+ collectDocumentedReferences(text, candidate, references, seen);
258
+ }
259
+ else {
260
+ collectCodeReferences(text, candidate, references, seen);
261
+ }
262
+ return references;
263
+ }
264
+ function isSecretLikeKey(key) {
265
+ return /(?:SECRET|TOKEN|PASSWORD|PASS|PRIVATE|CREDENTIAL|API_KEY|ACCESS_KEY|AUTH|SESSION|SIGNING|WEBHOOK)/u.test(key);
266
+ }
267
+ function isPublicLikeKey(key) {
268
+ return /^(?:PUBLIC_|VITE_|NEXT_PUBLIC_|NUXT_PUBLIC_|EXPO_PUBLIC_)/u.test(key);
269
+ }
270
+ function keyFromReferences(key, sources) {
271
+ const usedInCode = sources.some((source) => ['process_env_dot', 'process_env_bracket', 'process_env_destructure', 'bun_env_dot', 'import_meta_env'].includes(source.kind));
272
+ const declaredInExample = sources.some((source) => source.kind === 'env_example');
273
+ const referencedInCi = sources.some((source) => ['ci_secret', 'ci_var', 'ci_env'].includes(source.kind));
274
+ const documented = sources.some((source) => source.kind === 'documented');
275
+ return {
276
+ key,
277
+ used_in_code: usedInCode,
278
+ declared_in_example: declaredInExample,
279
+ referenced_in_ci: referencedInCi,
280
+ documented,
281
+ secret_like: isSecretLikeKey(key),
282
+ public_like: isPublicLikeKey(key),
283
+ source_count: sources.length,
284
+ sources,
285
+ };
286
+ }
287
+ function sortReference(left, right) {
288
+ return left.path.localeCompare(right.path) || left.line - right.line || left.kind.localeCompare(right.kind);
289
+ }
290
+ function buildKeys(references, findings, issues, policy) {
291
+ const byKey = new Map();
292
+ for (const reference of references) {
293
+ const entries = byKey.get(reference.key) ?? [];
294
+ entries.push(reference);
295
+ byKey.set(reference.key, entries);
296
+ }
297
+ const keys = [];
298
+ for (const [key, sources] of [...byKey.entries()].sort((left, right) => left[0].localeCompare(right[0]))) {
299
+ if (keys.length >= policy.max_keys) {
300
+ if (!findings.some((finding) => finding.code === 'env_contract_max_keys_exceeded')) {
301
+ const message = `Env-contract found more than ${policy.max_keys} keys; remaining keys were skipped.`;
302
+ pushIssue(issues, message);
303
+ findings.push(makeFinding('env_contract_max_keys_exceeded', 'medium', '.', message));
304
+ }
305
+ break;
306
+ }
307
+ keys.push(keyFromReferences(key, sources.sort(sortReference)));
308
+ }
309
+ for (const entry of keys) {
310
+ const firstPath = entry.sources[0]?.path ?? '.';
311
+ if (entry.used_in_code && !entry.declared_in_example && !entry.documented) {
312
+ findings.push(makeFinding('env_contract_missing_example', 'medium', firstPath, `${entry.key} is used in code but is not declared in env examples or docs.`, entry.key));
313
+ }
314
+ if (entry.declared_in_example && !entry.used_in_code && !entry.documented && !entry.referenced_in_ci) {
315
+ findings.push(makeFinding('env_contract_unused_example', 'low', firstPath, `${entry.key} is declared only in env examples.`, entry.key));
316
+ }
317
+ if (entry.secret_like && entry.public_like) {
318
+ findings.push(makeFinding('env_contract_secret_like_public_name', 'high', firstPath, `${entry.key} looks secret-like but uses a public env prefix.`, entry.key));
319
+ }
320
+ }
321
+ return keys;
322
+ }
323
+ function envContractStatus(findings) {
324
+ if (findings.some((finding) => ERROR_CODES.has(finding.code))) {
325
+ return 'error';
326
+ }
327
+ if (findings.some((finding) => ['medium', 'high', 'critical'].includes(finding.severity))) {
328
+ return 'failed';
329
+ }
330
+ return 'passed';
331
+ }
332
+ function createInputHash(policy, targets, keys, findings, issues) {
333
+ return sha256Tagged(JSON.stringify({
334
+ policy,
335
+ targets,
336
+ keys: keys.map((entry) => ({
337
+ key: entry.key,
338
+ source_count: entry.source_count,
339
+ flags: [entry.used_in_code, entry.declared_in_example, entry.referenced_in_ci, entry.documented],
340
+ })),
341
+ findings: findings.map((finding) => ({ code: finding.code, path: finding.path, key: finding.key })),
342
+ issues,
343
+ }));
344
+ }
345
+ function summarizeEnvContract(targets, fileCount, keys) {
346
+ return {
347
+ target_count: targets.length,
348
+ file_count: fileCount,
349
+ key_count: keys.length,
350
+ code_key_count: keys.filter((entry) => entry.used_in_code).length,
351
+ example_key_count: keys.filter((entry) => entry.declared_in_example).length,
352
+ documented_key_count: keys.filter((entry) => entry.documented).length,
353
+ ci_key_count: keys.filter((entry) => entry.referenced_in_ci).length,
354
+ };
355
+ }
356
+ function inspectEnvCandidate(projectRoot, candidate, policy, findings, issues) {
357
+ if (isSecretEnvFile(candidate.relativePath)) {
358
+ findings.push(makeFinding('env_contract_secret_file_skipped', 'low', candidate.relativePath, `${candidate.relativePath} was skipped to avoid reading real env values.`));
359
+ return [];
360
+ }
361
+ try {
362
+ const buffer = readFileInsideWithoutSymlinks(projectRoot, candidate.absolutePath, { maxBytes: policy.max_file_bytes });
363
+ return collectReferencesForFile(buffer.toString('utf8'), candidate);
364
+ }
365
+ catch (error) {
366
+ const message = error instanceof Error ? error.message : String(error);
367
+ pushIssue(issues, `${candidate.relativePath}: ${message}`);
368
+ findings.push(makeFinding('env_contract_unreadable_path', 'high', candidate.relativePath, message));
369
+ return [];
370
+ }
371
+ }
372
+ export function inspectEnvContract(projectRoot, options = {}) {
373
+ const root = path.resolve(projectRoot);
374
+ const policy = {
375
+ max_file_bytes: options.maxFileBytes ?? DEFAULT_MAX_FILE_BYTES,
376
+ max_files: options.maxFiles ?? DEFAULT_MAX_FILES,
377
+ max_keys: options.maxKeys ?? DEFAULT_MAX_KEYS,
378
+ extensions: [...SCAN_EXTENSIONS],
379
+ env_example_names: [...ENV_EXAMPLE_NAMES],
380
+ skipped_secret_names: [...SECRET_ENV_NAMES],
381
+ ignored_directories: [...IGNORED_DIRECTORIES],
382
+ };
383
+ const targetInputs = options.paths && options.paths.length > 0 ? options.paths : ['.'];
384
+ const targets = [];
385
+ const candidates = new Map();
386
+ const findings = [];
387
+ const issues = [];
388
+ for (const targetPath of targetInputs) {
389
+ let absolutePath;
390
+ let relativePath;
391
+ try {
392
+ const normalized = normalizeTargetPath(root, targetPath);
393
+ absolutePath = normalized.absolutePath;
394
+ relativePath = normalized.relativePath;
395
+ ensureInsideWithoutSymlinks(root, absolutePath, { allowMissingLeaf: true });
396
+ }
397
+ catch (error) {
398
+ const message = error instanceof Error ? error.message : String(error);
399
+ pushIssue(issues, message);
400
+ targets.push({ input: targetPath, path: targetPath, exists: null, kind: 'unknown' });
401
+ findings.push(makeFinding('env_contract_path_outside_root', 'high', targetPath, message));
402
+ continue;
403
+ }
404
+ let existence;
405
+ try {
406
+ existence = targetKind(absolutePath);
407
+ }
408
+ catch (error) {
409
+ const message = error instanceof Error ? error.message : String(error);
410
+ pushIssue(issues, `${relativePath}: ${message}`);
411
+ targets.push({ input: targetPath, path: relativePath, exists: null, kind: 'unknown' });
412
+ findings.push(makeFinding('env_contract_unreadable_path', 'high', relativePath, message));
413
+ continue;
414
+ }
415
+ targets.push({ input: targetPath, path: relativePath, exists: existence.exists, kind: existence.kind });
416
+ if (existence.kind === 'file') {
417
+ const surface = surfaceForPath(relativePath);
418
+ if (surface || isSecretEnvFile(relativePath)) {
419
+ addCandidate(candidates, findings, issues, policy, { absolutePath, relativePath, surface: surface ?? 'example' });
420
+ }
421
+ }
422
+ else if (existence.kind === 'directory') {
423
+ collectFilesFromDirectory(root, absolutePath, candidates, findings, issues, policy);
424
+ }
425
+ }
426
+ const references = [...candidates.values()].flatMap((candidate) => inspectEnvCandidate(root, candidate, policy, findings, issues));
427
+ const keys = buildKeys(references, findings, issues, policy);
428
+ const status = envContractStatus(findings);
429
+ const truncated = findings.some((finding) => ['env_contract_max_files_exceeded', 'env_contract_max_keys_exceeded'].includes(finding.code));
430
+ const summary = summarizeEnvContract(targets, candidates.size, keys);
431
+ return {
432
+ schema_version: '1',
433
+ command: 'script-pack',
434
+ pack_id: ENV_CONTRACT_PACK_ID,
435
+ script_id: ENV_CONTRACT_SCRIPT_ID,
436
+ script_ref: ENV_CONTRACT_SCRIPT_REF,
437
+ action: 'scan',
438
+ status,
439
+ ok: status === 'passed',
440
+ mustflow_root: root,
441
+ policy,
442
+ input_hash: createInputHash(policy, targets, keys, findings, issues),
443
+ targets,
444
+ summary,
445
+ keys,
446
+ truncated,
447
+ findings,
448
+ issues,
449
+ };
450
+ }
@@ -54,14 +54,17 @@ function detectLineEnding(buffer) {
54
54
  }
55
55
  let lfCount = 0;
56
56
  let crlfCount = 0;
57
- for (let index = 0; index < buffer.length; index += 1) {
58
- if (buffer[index] !== 0x0a) {
59
- continue;
57
+ let searchFrom = 0;
58
+ while (searchFrom < buffer.length) {
59
+ const index = buffer.indexOf(0x0a, searchFrom);
60
+ if (index === -1) {
61
+ break;
60
62
  }
61
63
  lfCount += 1;
62
64
  if (index > 0 && buffer[index - 1] === 0x0d) {
63
65
  crlfCount += 1;
64
66
  }
67
+ searchFrom = index + 1;
65
68
  }
66
69
  if (lfCount === 0) {
67
70
  return 'none';
@@ -72,20 +75,30 @@ function detectLineEnding(buffer) {
72
75
  return crlfCount === lfCount ? 'crlf' : 'mixed';
73
76
  }
74
77
  function normalizeLf(buffer) {
78
+ const firstCrIndex = buffer.indexOf(0x0d);
79
+ if (firstCrIndex === -1) {
80
+ return buffer;
81
+ }
75
82
  const bytes = Buffer.allocUnsafe(buffer.length);
83
+ let readIndex = 0;
76
84
  let writeIndex = 0;
77
- for (let index = 0; index < buffer.length; index += 1) {
78
- const byte = buffer[index];
79
- if (byte === 0x0d) {
80
- bytes[writeIndex] = 0x0a;
81
- writeIndex += 1;
82
- if (buffer[index + 1] === 0x0a) {
83
- index += 1;
84
- }
85
- continue;
85
+ let crIndex = firstCrIndex;
86
+ while (crIndex !== -1) {
87
+ if (crIndex > readIndex) {
88
+ buffer.copy(bytes, writeIndex, readIndex, crIndex);
89
+ writeIndex += crIndex - readIndex;
86
90
  }
87
- bytes[writeIndex] = byte;
91
+ bytes[writeIndex] = 0x0a;
88
92
  writeIndex += 1;
93
+ readIndex = crIndex + 1;
94
+ if (buffer[readIndex] === 0x0a) {
95
+ readIndex += 1;
96
+ }
97
+ crIndex = buffer.indexOf(0x0d, readIndex);
98
+ }
99
+ if (readIndex < buffer.length) {
100
+ buffer.copy(bytes, writeIndex, readIndex);
101
+ writeIndex += buffer.length - readIndex;
89
102
  }
90
103
  return bytes.subarray(0, writeIndex);
91
104
  }
@@ -248,6 +248,39 @@ const PUBLIC_JSON_SCHEMA_CONTRACTS = [
248
248
  '--json',
249
249
  ],
250
250
  },
251
+ {
252
+ id: 'dependency-graph-report',
253
+ schemaFile: 'dependency-graph-report.schema.json',
254
+ producer: 'mf script-pack run code/dependency-graph scan <path...> --json',
255
+ packaged: true,
256
+ documented: true,
257
+ installedCommand: [
258
+ 'mf',
259
+ 'script-pack',
260
+ 'run',
261
+ 'code/dependency-graph',
262
+ 'scan',
263
+ 'node_modules/mustflow/dist/cli/index.js',
264
+ '--json',
265
+ ],
266
+ },
267
+ {
268
+ id: 'change-impact-report',
269
+ schemaFile: 'change-impact-report.schema.json',
270
+ producer: 'mf script-pack run code/change-impact analyze [path...] --json',
271
+ packaged: true,
272
+ documented: true,
273
+ installedCommand: [
274
+ 'mf',
275
+ 'script-pack',
276
+ 'run',
277
+ 'code/change-impact',
278
+ 'analyze',
279
+ '--base',
280
+ 'HEAD',
281
+ '--json',
282
+ ],
283
+ },
251
284
  {
252
285
  id: 'code-symbol-read-report',
253
286
  schemaFile: 'code-symbol-read-report.schema.json',
@@ -363,6 +396,24 @@ const PUBLIC_JSON_SCHEMA_CONTRACTS = [
363
396
  ],
364
397
  expectedExitCodes: [0, 1],
365
398
  },
399
+ {
400
+ id: 'env-contract-report',
401
+ schemaFile: 'env-contract-report.schema.json',
402
+ producer: 'mf script-pack run repo/env-contract scan [path...] --json',
403
+ packaged: true,
404
+ documented: true,
405
+ installedCommand: ['mf', 'script-pack', 'run', 'repo/env-contract', 'scan', 'AGENTS.md', '--json'],
406
+ expectedExitCodes: [0, 1],
407
+ },
408
+ {
409
+ id: 'secret-risk-scan-report',
410
+ schemaFile: 'secret-risk-scan-report.schema.json',
411
+ producer: 'mf script-pack run repo/secret-risk-scan scan [path...] --json',
412
+ packaged: true,
413
+ documented: true,
414
+ installedCommand: ['mf', 'script-pack', 'run', 'repo/secret-risk-scan', 'scan', 'AGENTS.md', '--json'],
415
+ expectedExitCodes: [0, 1],
416
+ },
366
417
  {
367
418
  id: 'related-files-report',
368
419
  schemaFile: 'related-files-report.schema.json',
@@ -821,20 +821,72 @@ function extractNestjsLifecycleDecorator(line) {
821
821
  : null;
822
822
  }
823
823
  function extractNestjsHandlerName(line) {
824
- const match = /^\s*(?:public\s+|private\s+|protected\s+|async\s+|static\s+|readonly\s+|\*?\s*)*[$A-Z_a-z][$\w]*\s*\(/u.exec(line);
825
- if (!match) {
824
+ const signature = line.trimStart();
825
+ if (signature.length === 0) {
826
826
  return null;
827
827
  }
828
- const named = /(?<name>[$A-Z_a-z][$\w]*)\s*\(/u.exec(line);
829
- const name = named?.groups?.name;
830
- if (!name) {
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) {
831
842
  return null;
832
843
  }
844
+ const callStart = skipAsciiWhitespace(signature, nameToken.end);
845
+ if (signature.charAt(callStart) !== '(') {
846
+ return null;
847
+ }
848
+ const name = nameToken.value;
833
849
  if (/\b(?:if|for|while|switch|return|class|interface|function|new|throw|typeof|in|of)\b/u.test(name)) {
834
850
  return null;
835
851
  }
836
852
  return name;
837
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
+ }
838
890
  function extractNestjsRouteEntries(relativePath, language, contentSha256, lines, block) {
839
891
  const routes = [];
840
892
  let controllerLifecycle = [];