mustflow 2.75.2 → 2.85.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/README.md +40 -3
  2. package/dist/cli/commands/docs.js +86 -2
  3. package/dist/cli/commands/script-pack.js +9 -0
  4. package/dist/cli/i18n/en.js +180 -2
  5. package/dist/cli/i18n/es.js +180 -2
  6. package/dist/cli/i18n/fr.js +180 -2
  7. package/dist/cli/i18n/hi.js +180 -2
  8. package/dist/cli/i18n/ko.js +180 -2
  9. package/dist/cli/i18n/zh.js +180 -2
  10. package/dist/cli/lib/repo-map.js +27 -6
  11. package/dist/cli/lib/run-root-trust.js +15 -1
  12. package/dist/cli/lib/script-pack-registry.js +275 -6
  13. package/dist/cli/lib/validation/index.js +2 -2
  14. package/dist/cli/lib/validation/primitives.js +4 -1
  15. package/dist/cli/script-packs/code-change-impact.js +172 -0
  16. package/dist/cli/script-packs/code-dependency-graph.js +181 -0
  17. package/dist/cli/script-packs/code-export-diff.js +160 -0
  18. package/dist/cli/script-packs/code-outline.js +33 -5
  19. package/dist/cli/script-packs/code-route-outline.js +155 -0
  20. package/dist/cli/script-packs/docs-reference-drift.js +150 -0
  21. package/dist/cli/script-packs/repo-config-chain.js +163 -0
  22. package/dist/cli/script-packs/repo-env-contract.js +156 -0
  23. package/dist/cli/script-packs/repo-related-files.js +161 -0
  24. package/dist/cli/script-packs/repo-secret-risk-scan.js +147 -0
  25. package/dist/core/change-impact.js +383 -0
  26. package/dist/core/change-verification.js +32 -5
  27. package/dist/core/code-outline.js +460 -79
  28. package/dist/core/config-chain.js +595 -0
  29. package/dist/core/config-loading.js +121 -4
  30. package/dist/core/dependency-graph.js +490 -0
  31. package/dist/core/env-contract.js +450 -0
  32. package/dist/core/export-diff.js +359 -0
  33. package/dist/core/line-endings.js +26 -13
  34. package/dist/core/public-json-contracts.js +126 -0
  35. package/dist/core/reference-drift.js +388 -0
  36. package/dist/core/related-files.js +493 -0
  37. package/dist/core/route-outline.js +964 -0
  38. package/dist/core/script-pack-suggestions.js +131 -5
  39. package/dist/core/secret-risk-scan.js +440 -0
  40. package/dist/core/source-anchors.js +13 -1
  41. package/package.json +1 -1
  42. package/schemas/README.md +44 -6
  43. package/schemas/change-impact-report.schema.json +150 -0
  44. package/schemas/code-outline-report.schema.json +1 -1
  45. package/schemas/code-symbol-read-report.schema.json +64 -4
  46. package/schemas/commands.schema.json +12 -0
  47. package/schemas/config-chain-report.schema.json +187 -0
  48. package/schemas/dependency-graph-report.schema.json +149 -0
  49. package/schemas/env-contract-report.schema.json +203 -0
  50. package/schemas/export-diff-report.schema.json +220 -0
  51. package/schemas/reference-drift-report.schema.json +166 -0
  52. package/schemas/related-files-report.schema.json +145 -0
  53. package/schemas/route-outline-report.schema.json +200 -0
  54. package/schemas/secret-risk-scan-report.schema.json +152 -0
  55. package/templates/default/common/.mustflow/config/commands.toml +21 -0
  56. package/templates/default/i18n.toml +21 -9
  57. package/templates/default/locales/en/.mustflow/docs/agent-workflow.md +1 -1
  58. package/templates/default/locales/en/.mustflow/skills/INDEX.md +8 -2
  59. package/templates/default/locales/en/.mustflow/skills/architecture-deepening-review/SKILL.md +28 -11
  60. package/templates/default/locales/en/.mustflow/skills/astro-code-change/SKILL.md +71 -27
  61. package/templates/default/locales/en/.mustflow/skills/cross-agent-session-reference/SKILL.md +146 -0
  62. package/templates/default/locales/en/.mustflow/skills/dependency-upgrade-review/SKILL.md +3 -1
  63. package/templates/default/locales/en/.mustflow/skills/github-contribution-quality-gate/SKILL.md +48 -11
  64. package/templates/default/locales/en/.mustflow/skills/javascript-code-change/SKILL.md +15 -13
  65. package/templates/default/locales/en/.mustflow/skills/node-code-change/SKILL.md +16 -14
  66. package/templates/default/locales/en/.mustflow/skills/routes.toml +21 -9
  67. package/templates/default/locales/en/.mustflow/skills/security-privacy-review/SKILL.md +3 -1
  68. package/templates/default/locales/en/.mustflow/skills/test-suite-performance-review/SKILL.md +314 -0
  69. package/templates/default/locales/en/.mustflow/skills/typescript-code-change/SKILL.md +13 -10
  70. package/templates/default/manifest.toml +15 -1
@@ -0,0 +1,147 @@
1
+ import { printUsageError, renderHelp } from '../lib/cli-output.js';
2
+ import { t } from '../lib/i18n.js';
3
+ import { formatCliOptionParseError, getParsedCliStringOption, hasCliOptionToken, hasParsedCliOption, parseCliOptions, } from '../lib/option-parser.js';
4
+ import { resolveMustflowRoot } from '../lib/project-root.js';
5
+ import { inspectSecretRiskScan, SECRET_RISK_SCAN_SCRIPT_REF } from '../../core/secret-risk-scan.js';
6
+ const SECRET_RISK_SCAN_OPTIONS = [
7
+ { name: '--json', kind: 'boolean' },
8
+ { name: '--max-files', kind: 'string' },
9
+ { name: '--max-file-bytes', kind: 'string' },
10
+ { name: '--max-findings', kind: 'string' },
11
+ ];
12
+ function parsePositiveInteger(value, option, lang) {
13
+ if (value === null) {
14
+ return { value: null };
15
+ }
16
+ if (!/^[1-9]\d*$/u.test(value)) {
17
+ return { value: null, error: t(lang, 'secretRiskScan.error.invalidPositiveInteger', { option, value }) };
18
+ }
19
+ const parsed = Number(value);
20
+ if (!Number.isSafeInteger(parsed)) {
21
+ return { value: null, error: t(lang, 'secretRiskScan.error.invalidPositiveInteger', { option, value }) };
22
+ }
23
+ return { value: parsed };
24
+ }
25
+ export function getRepoSecretRiskScanHelp(lang = 'en') {
26
+ return renderHelp({
27
+ usage: 'mf script-pack run repo/secret-risk-scan scan [path...] [options]',
28
+ summary: t(lang, 'secretRiskScan.help.summary'),
29
+ options: [
30
+ { label: '--max-files <count>', description: t(lang, 'secretRiskScan.help.option.maxFiles') },
31
+ { label: '--max-file-bytes <bytes>', description: t(lang, 'secretRiskScan.help.option.maxFileBytes') },
32
+ { label: '--max-findings <count>', description: t(lang, 'secretRiskScan.help.option.maxFindings') },
33
+ { label: '--json', description: t(lang, 'cli.option.json') },
34
+ { label: '-h, --help', description: t(lang, 'cli.option.help') },
35
+ ],
36
+ examples: [
37
+ 'mf script-pack run repo/secret-risk-scan scan --json',
38
+ 'mf script-pack run repo/secret-risk-scan scan src README.md --json',
39
+ 'mf script-pack run repo/secret-risk-scan scan .env.example docs --max-findings 50 --json',
40
+ ],
41
+ exitCodes: [
42
+ { label: '0', description: t(lang, 'secretRiskScan.help.exit.ok') },
43
+ { label: '1', description: t(lang, 'secretRiskScan.help.exit.fail') },
44
+ ],
45
+ }, lang);
46
+ }
47
+ function parseSecretRiskScanOptions(args, lang) {
48
+ const [action, ...rest] = args;
49
+ const parsed = parseCliOptions(rest, SECRET_RISK_SCAN_OPTIONS, { allowPositionals: true });
50
+ const json = hasParsedCliOption(parsed, '--json');
51
+ const maxFiles = parsePositiveInteger(getParsedCliStringOption(parsed, '--max-files'), '--max-files', lang);
52
+ const maxFileBytes = parsePositiveInteger(getParsedCliStringOption(parsed, '--max-file-bytes'), '--max-file-bytes', lang);
53
+ const maxFindings = parsePositiveInteger(getParsedCliStringOption(parsed, '--max-findings'), '--max-findings', lang);
54
+ if (action !== 'scan') {
55
+ return {
56
+ action: 'scan',
57
+ json,
58
+ paths: parsed.positionals,
59
+ maxFiles: maxFiles.value,
60
+ maxFileBytes: maxFileBytes.value,
61
+ maxFindings: maxFindings.value,
62
+ error: action ? t(lang, 'secretRiskScan.error.unknownAction', { action }) : t(lang, 'secretRiskScan.error.missingAction'),
63
+ };
64
+ }
65
+ if (parsed.error) {
66
+ return {
67
+ action,
68
+ json,
69
+ paths: parsed.positionals,
70
+ maxFiles: maxFiles.value,
71
+ maxFileBytes: maxFileBytes.value,
72
+ maxFindings: maxFindings.value,
73
+ error: formatCliOptionParseError(parsed.error, lang),
74
+ };
75
+ }
76
+ for (const candidate of [maxFiles, maxFileBytes, maxFindings]) {
77
+ if (candidate.error) {
78
+ return {
79
+ action,
80
+ json,
81
+ paths: parsed.positionals,
82
+ maxFiles: maxFiles.value,
83
+ maxFileBytes: maxFileBytes.value,
84
+ maxFindings: maxFindings.value,
85
+ error: candidate.error,
86
+ };
87
+ }
88
+ }
89
+ return {
90
+ action,
91
+ json,
92
+ paths: parsed.positionals,
93
+ maxFiles: maxFiles.value,
94
+ maxFileBytes: maxFileBytes.value,
95
+ maxFindings: maxFindings.value,
96
+ };
97
+ }
98
+ function renderSecretRiskScanSummary(report, lang) {
99
+ const lines = [
100
+ t(lang, 'secretRiskScan.title'),
101
+ `${t(lang, 'scriptPack.label.script')}: ${SECRET_RISK_SCAN_SCRIPT_REF}`,
102
+ `${t(lang, 'label.status')}: ${report.status}`,
103
+ `${t(lang, 'secretRiskScan.label.files')}: ${report.summary.file_count}`,
104
+ `${t(lang, 'secretRiskScan.label.findings')}: ${report.summary.finding_count}`,
105
+ `${t(lang, 'secretRiskScan.label.highOrCritical')}: ${report.summary.high_or_critical_count}`,
106
+ `${t(lang, 'secretRiskScan.label.skippedSecretFiles')}: ${report.summary.skipped_secret_file_count}`,
107
+ `${t(lang, 'secretRiskScan.label.truncated')}: ${report.truncated ? t(lang, 'value.yes') : t(lang, 'value.no')}`,
108
+ ];
109
+ if (report.findings.length > 0) {
110
+ lines.push(t(lang, 'secretRiskScan.label.findings'));
111
+ for (const finding of report.findings.slice(0, 40)) {
112
+ const line = finding.line ? `:${finding.line}` : '';
113
+ const detector = finding.detector ? ` ${finding.detector}` : '';
114
+ lines.push(`- ${finding.path}${line}: ${finding.code}${detector} (${finding.message})`);
115
+ }
116
+ }
117
+ if (report.issues.length > 0) {
118
+ lines.push(t(lang, 'secretRiskScan.label.issues'), ...report.issues.map((issue) => `- ${issue}`));
119
+ }
120
+ if (report.findings.length === 0 && report.issues.length === 0) {
121
+ lines.push(t(lang, 'secretRiskScan.clean'));
122
+ }
123
+ return lines.join('\n');
124
+ }
125
+ export function runRepoSecretRiskScanScript(args, reporter, lang = 'en') {
126
+ if (hasCliOptionToken(args, '--help', ['-h'])) {
127
+ reporter.stdout(getRepoSecretRiskScanHelp(lang));
128
+ return 0;
129
+ }
130
+ const options = parseSecretRiskScanOptions(args, lang);
131
+ if (options.error) {
132
+ printUsageError(reporter, options.error, 'mf script-pack run repo/secret-risk-scan --help', getRepoSecretRiskScanHelp(lang), lang);
133
+ return 1;
134
+ }
135
+ const report = inspectSecretRiskScan(resolveMustflowRoot(), {
136
+ paths: options.paths,
137
+ maxFiles: options.maxFiles ?? undefined,
138
+ maxFileBytes: options.maxFileBytes ?? undefined,
139
+ maxFindings: options.maxFindings ?? undefined,
140
+ });
141
+ if (options.json) {
142
+ reporter.stdout(JSON.stringify(report, null, 2));
143
+ return report.ok ? 0 : 1;
144
+ }
145
+ reporter.stdout(renderSecretRiskScanSummary(report, lang));
146
+ return report.ok ? 0 : 1;
147
+ }
@@ -0,0 +1,383 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { createHash } from 'node:crypto';
3
+ import { existsSync } from 'node:fs';
4
+ import path from 'node:path';
5
+ import { inspectDependencyGraph } from './dependency-graph.js';
6
+ export const CHANGE_IMPACT_PACK_ID = 'code';
7
+ export const CHANGE_IMPACT_SCRIPT_ID = 'change-impact';
8
+ export const CHANGE_IMPACT_SCRIPT_REF = `${CHANGE_IMPACT_PACK_ID}/${CHANGE_IMPACT_SCRIPT_ID}`;
9
+ const DEFAULT_BASE_REF = 'HEAD';
10
+ const DEFAULT_MAX_FILES = 200;
11
+ const DEFAULT_MAX_IMPACTS = 300;
12
+ const DEFAULT_MAX_FILE_BYTES = 256 * 1024;
13
+ const SOURCE_EXTENSIONS = ['.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs'];
14
+ function toPosixPath(value) {
15
+ return value.replace(/\\/gu, '/');
16
+ }
17
+ function normalizeRelativePath(value) {
18
+ return toPosixPath(value).replace(/^\.\/+/u, '') || '.';
19
+ }
20
+ function sha256Tagged(value) {
21
+ return `sha256:${createHash('sha256').update(value).digest('hex')}`;
22
+ }
23
+ function runGit(root, args) {
24
+ const result = spawnSync('git', [...args], {
25
+ cwd: root,
26
+ encoding: 'utf8',
27
+ stdio: ['ignore', 'pipe', 'pipe'],
28
+ windowsHide: true,
29
+ maxBuffer: 16 * 1024 * 1024,
30
+ });
31
+ return {
32
+ ok: result.status === 0,
33
+ stdout: result.stdout ?? '',
34
+ stderr: result.stderr ?? '',
35
+ status: result.status,
36
+ };
37
+ }
38
+ function isInsideGitWorktree(root) {
39
+ const result = runGit(root, ['rev-parse', '--is-inside-work-tree']);
40
+ return result.ok && result.stdout.trim() === 'true';
41
+ }
42
+ function makeFinding(code, severity, pathValue, message) {
43
+ return { code, severity, path: pathValue, message };
44
+ }
45
+ function surfaceForPath(relativePath) {
46
+ const normalized = normalizeRelativePath(relativePath);
47
+ const extension = path.extname(normalized).toLowerCase();
48
+ const basename = path.basename(normalized).toLowerCase();
49
+ if (normalized.startsWith('.mustflow/')) {
50
+ return 'workflow';
51
+ }
52
+ if (normalized.startsWith('templates/')) {
53
+ return 'template';
54
+ }
55
+ if (normalized.startsWith('schemas/') || basename.endsWith('.schema.json')) {
56
+ return 'schema';
57
+ }
58
+ if (normalized.startsWith('docs') || ['.md', '.mdx'].includes(extension)) {
59
+ return 'docs';
60
+ }
61
+ if (normalized.includes('/i18n/') || normalized.includes('/locales/') || basename.includes('i18n')) {
62
+ return 'i18n';
63
+ }
64
+ if (['package.json', 'bun.lock', 'package-lock.json', 'pnpm-lock.yaml', 'yarn.lock'].includes(normalized)) {
65
+ return 'package';
66
+ }
67
+ if (/^(?:tsconfig|eslint|vite|vitest|jest|playwright|astro|svelte|tailwind)\b/u.test(basename)) {
68
+ return 'config';
69
+ }
70
+ if (normalized.startsWith('tests/') || /\.test\.[cm]?[jt]sx?$/u.test(normalized)) {
71
+ return 'test';
72
+ }
73
+ if (SOURCE_EXTENSIONS.includes(extension)) {
74
+ return 'source';
75
+ }
76
+ return 'unknown';
77
+ }
78
+ function statusFromCode(code) {
79
+ if (code.startsWith('A')) {
80
+ return 'added';
81
+ }
82
+ if (code.startsWith('D')) {
83
+ return 'deleted';
84
+ }
85
+ if (code.startsWith('R')) {
86
+ return 'renamed';
87
+ }
88
+ if (code.startsWith('T')) {
89
+ return 'type_changed';
90
+ }
91
+ return 'modified';
92
+ }
93
+ function parseNameStatus(stdout) {
94
+ const files = [];
95
+ for (const line of stdout.split(/\r?\n/u)) {
96
+ if (!line.trim()) {
97
+ continue;
98
+ }
99
+ const [statusCode = 'M', firstPath = '', secondPath = ''] = line.split('\t');
100
+ const currentPath = normalizeRelativePath(secondPath || firstPath);
101
+ const previousPath = secondPath ? normalizeRelativePath(firstPath) : null;
102
+ files.push({
103
+ path: currentPath,
104
+ previous_path: previousPath,
105
+ status: statusFromCode(statusCode),
106
+ surface: surfaceForPath(currentPath),
107
+ });
108
+ }
109
+ return files;
110
+ }
111
+ function addUntrackedFiles(root, paths, files) {
112
+ const untracked = runGit(root, ['ls-files', '--others', '--exclude-standard', '--', ...paths]);
113
+ if (!untracked.ok) {
114
+ return;
115
+ }
116
+ const known = new Set(files.map((file) => file.path));
117
+ for (const line of untracked.stdout.split(/\r?\n/u)) {
118
+ const relativePath = normalizeRelativePath(line.trim());
119
+ if (!relativePath || relativePath === '.' || known.has(relativePath)) {
120
+ continue;
121
+ }
122
+ files.push({
123
+ path: relativePath,
124
+ previous_path: null,
125
+ status: 'untracked',
126
+ surface: surfaceForPath(relativePath),
127
+ });
128
+ }
129
+ }
130
+ function addImpact(root, impacts, candidate, policy, findings, issues) {
131
+ const normalized = normalizeRelativePath(candidate.path);
132
+ const key = `${candidate.relationship}:${candidate.source_path}:${normalized}`;
133
+ if (impacts.has(key)) {
134
+ return;
135
+ }
136
+ if (impacts.size >= policy.max_impacts) {
137
+ const message = `Change impact found more than ${policy.max_impacts} candidates; remaining candidates were skipped.`;
138
+ findings.push(makeFinding('change_impact_max_impacts_exceeded', 'high', normalized, message));
139
+ issues.push(message);
140
+ return;
141
+ }
142
+ impacts.set(key, {
143
+ ...candidate,
144
+ path: normalized,
145
+ exists: existsSync(path.join(root, ...normalized.split('/'))),
146
+ surface: surfaceForPath(normalized),
147
+ });
148
+ }
149
+ function siblingBase(relativePath) {
150
+ const directory = path.posix.dirname(normalizeRelativePath(relativePath));
151
+ const parsed = path.posix.parse(normalizeRelativePath(relativePath));
152
+ return directory === '.' ? parsed.name : `${directory}/${parsed.name}`;
153
+ }
154
+ function testCandidatesFor(relativePath) {
155
+ const base = siblingBase(relativePath);
156
+ const name = path.posix.basename(base);
157
+ return [
158
+ `${base}.test.ts`,
159
+ `${base}.test.js`,
160
+ `${base}.spec.ts`,
161
+ `${base}.spec.js`,
162
+ `tests/${name}.test.js`,
163
+ `tests/${name}.test.ts`,
164
+ `tests/cli/${name}.test.js`,
165
+ ];
166
+ }
167
+ function sourceCandidatesFor(relativePath) {
168
+ const normalized = normalizeRelativePath(relativePath);
169
+ return [
170
+ normalized.replace(/^tests\/cli\//u, 'src/cli/').replace(/\.test\.[cm]?[jt]sx?$/u, '.ts'),
171
+ normalized.replace(/^tests\//u, 'src/').replace(/\.test\.[cm]?[jt]sx?$/u, '.ts'),
172
+ ];
173
+ }
174
+ function addSurfaceImpacts(root, changedFile, impacts, policy, findings, issues) {
175
+ addImpact(root, impacts, {
176
+ path: changedFile.path,
177
+ relationship: 'changed_file',
178
+ confidence: 1,
179
+ reason: 'Changed file should be reviewed directly.',
180
+ source_path: changedFile.path,
181
+ }, policy, findings, issues);
182
+ if (changedFile.surface === 'source') {
183
+ for (const candidatePath of testCandidatesFor(changedFile.path)) {
184
+ addImpact(root, impacts, {
185
+ path: candidatePath,
186
+ relationship: 'sibling_test',
187
+ confidence: 0.65,
188
+ reason: 'Nearby test candidate for the changed source path.',
189
+ source_path: changedFile.path,
190
+ }, policy, findings, issues);
191
+ }
192
+ }
193
+ if (changedFile.surface === 'test') {
194
+ for (const candidatePath of sourceCandidatesFor(changedFile.path)) {
195
+ addImpact(root, impacts, {
196
+ path: candidatePath,
197
+ relationship: 'sibling_source',
198
+ confidence: 0.55,
199
+ reason: 'Likely source file covered by the changed test path.',
200
+ source_path: changedFile.path,
201
+ }, policy, findings, issues);
202
+ }
203
+ }
204
+ }
205
+ function addContractImpacts(root, changedFile, impacts, policy, findings, issues) {
206
+ const contractPaths = changedFile.surface === 'schema'
207
+ ? ['schemas/README.md', 'src/core/public-json-contracts.ts', 'tests/cli/schema.test.js']
208
+ : changedFile.surface === 'package' || changedFile.surface === 'template'
209
+ ? ['tests/cli/package.test.js', 'templates/default/manifest.toml']
210
+ : changedFile.surface === 'workflow'
211
+ ? ['.mustflow/config/commands.toml', 'tests/cli/authoring-skill-contracts.test.js']
212
+ : [];
213
+ for (const candidatePath of contractPaths) {
214
+ addImpact(root, impacts, {
215
+ path: candidatePath,
216
+ relationship: changedFile.surface === 'workflow' ? 'workflow_surface' : 'contract_test',
217
+ confidence: 0.7,
218
+ reason: `Contract surface related to changed ${changedFile.surface} file.`,
219
+ source_path: changedFile.path,
220
+ }, policy, findings, issues);
221
+ }
222
+ }
223
+ function addDependencyImpacts(root, changedFiles, impacts, policy, findings, issues) {
224
+ const sourcePaths = changedFiles
225
+ .filter((file) => file.surface === 'source' && file.status !== 'deleted')
226
+ .map((file) => file.path);
227
+ if (sourcePaths.length === 0) {
228
+ return;
229
+ }
230
+ const dependencyReport = inspectDependencyGraph(root, {
231
+ paths: sourcePaths,
232
+ maxDepth: 1,
233
+ maxFiles: policy.max_files,
234
+ maxFileBytes: policy.max_file_bytes,
235
+ maxNodes: policy.max_impacts,
236
+ maxEdges: policy.max_impacts,
237
+ });
238
+ if (!dependencyReport.ok) {
239
+ for (const issue of dependencyReport.issues) {
240
+ issues.push(`dependency-graph: ${issue}`);
241
+ }
242
+ }
243
+ const changedPathSet = new Set(sourcePaths);
244
+ for (const edge of dependencyReport.edges) {
245
+ if (!changedPathSet.has(edge.target_path) || changedPathSet.has(edge.source_path)) {
246
+ continue;
247
+ }
248
+ addImpact(root, impacts, {
249
+ path: edge.source_path,
250
+ relationship: 'imports_changed_file',
251
+ confidence: 0.8,
252
+ reason: `Imports changed file ${edge.target_path} via ${edge.specifier}.`,
253
+ source_path: edge.target_path,
254
+ }, policy, findings, issues);
255
+ }
256
+ }
257
+ function createScriptHints(changedFiles) {
258
+ const sourcePaths = changedFiles.filter((file) => file.surface === 'source').map((file) => file.path);
259
+ const hasDocsOrSchema = changedFiles.some((file) => file.surface === 'docs' || file.surface === 'schema');
260
+ const hints = [];
261
+ if (sourcePaths.length > 0) {
262
+ const pathPart = sourcePaths.map((entry) => JSON.stringify(entry)).join(' ');
263
+ hints.push({
264
+ script_ref: 'code/dependency-graph',
265
+ command: `mf script-pack run code/dependency-graph scan ${pathPart} --json`,
266
+ reason: 'Trace importers and direct dependencies for changed source files.',
267
+ confidence: 0.85,
268
+ });
269
+ hints.push({
270
+ script_ref: 'repo/related-files',
271
+ command: `mf script-pack run repo/related-files map ${pathPart} --json`,
272
+ reason: 'Map nearby source, tests, docs, and config files for changed source paths.',
273
+ confidence: 0.75,
274
+ });
275
+ }
276
+ if (hasDocsOrSchema) {
277
+ hints.push({
278
+ script_ref: 'docs/reference-drift',
279
+ command: 'mf script-pack run docs/reference-drift check README.md schemas/README.md --json',
280
+ reason: 'Check documented command, schema, path, and script-pack references after docs or schema changes.',
281
+ confidence: 0.7,
282
+ });
283
+ }
284
+ return hints;
285
+ }
286
+ function createVerificationHints(changedFiles) {
287
+ const surfaces = new Set(changedFiles.map((file) => file.surface));
288
+ const hints = [];
289
+ const add = (intent, reason, confidence) => {
290
+ if (!hints.some((hint) => hint.intent === intent)) {
291
+ hints.push({ intent, reason, confidence });
292
+ }
293
+ };
294
+ if (surfaces.has('source') || surfaces.has('test')) {
295
+ add('test_related', 'Changed source or tests need related behavior coverage.', 0.85);
296
+ add('build', 'Changed TypeScript or JavaScript source may affect compiled CLI output.', 0.7);
297
+ }
298
+ if (surfaces.has('schema') || surfaces.has('package') || surfaces.has('template')) {
299
+ add('test_release', 'Package, template, or public schema surfaces changed.', 0.85);
300
+ }
301
+ if (surfaces.has('docs') || surfaces.has('schema') || surfaces.has('workflow')) {
302
+ add('docs_validate_fast', 'Docs, schema docs, or workflow text changed.', 0.75);
303
+ }
304
+ if (surfaces.has('workflow') || surfaces.has('template')) {
305
+ add('mustflow_check', 'Workflow or install template surfaces need strict mustflow validation.', 0.8);
306
+ }
307
+ return hints;
308
+ }
309
+ function collectChangedFiles(root, policy, findings, issues) {
310
+ if (!isInsideGitWorktree(root)) {
311
+ const message = 'Git worktree is unavailable; change-impact returned an empty impact set.';
312
+ findings.push(makeFinding('change_impact_git_unavailable', 'low', '.', message));
313
+ issues.push(message);
314
+ return [];
315
+ }
316
+ const diffArgs = policy.head_ref
317
+ ? ['diff', '--name-status', '--diff-filter=ACMRTD', policy.base_ref, policy.head_ref, '--', ...policy.path_filters]
318
+ : ['diff', '--name-status', '--diff-filter=ACMRTD', policy.base_ref, '--', ...policy.path_filters];
319
+ const diff = runGit(root, diffArgs);
320
+ if (!diff.ok) {
321
+ const detail = diff.stderr.trim() || diff.stdout.trim() || `git diff exited with ${diff.status}`;
322
+ findings.push(makeFinding('change_impact_invalid_ref', 'high', '.', detail));
323
+ issues.push(detail);
324
+ return [];
325
+ }
326
+ const files = parseNameStatus(diff.stdout);
327
+ if (!policy.head_ref) {
328
+ addUntrackedFiles(root, policy.path_filters, files);
329
+ }
330
+ files.sort((left, right) => left.path.localeCompare(right.path));
331
+ if (files.length > policy.max_files) {
332
+ const message = `Change impact matched ${files.length} files; max_files is ${policy.max_files}.`;
333
+ findings.push(makeFinding('change_impact_max_files_exceeded', 'high', '.', message));
334
+ issues.push(message);
335
+ }
336
+ return files.slice(0, policy.max_files);
337
+ }
338
+ export function inspectChangeImpact(projectRoot, options = {}) {
339
+ const root = path.resolve(projectRoot);
340
+ const policy = {
341
+ base_ref: options.baseRef ?? DEFAULT_BASE_REF,
342
+ head_ref: options.headRef ?? null,
343
+ compare_worktree: options.headRef === undefined || options.headRef === null,
344
+ max_files: options.maxFiles ?? DEFAULT_MAX_FILES,
345
+ max_impacts: options.maxImpacts ?? DEFAULT_MAX_IMPACTS,
346
+ max_file_bytes: options.maxFileBytes ?? DEFAULT_MAX_FILE_BYTES,
347
+ path_filters: (options.paths ?? []).map(normalizeRelativePath),
348
+ };
349
+ const findings = [];
350
+ const issues = [];
351
+ const changedFiles = collectChangedFiles(root, policy, findings, issues);
352
+ const impacts = new Map();
353
+ for (const changedFile of changedFiles) {
354
+ addSurfaceImpacts(root, changedFile, impacts, policy, findings, issues);
355
+ addContractImpacts(root, changedFile, impacts, policy, findings, issues);
356
+ }
357
+ addDependencyImpacts(root, changedFiles, impacts, policy, findings, issues);
358
+ const status = findings.some((finding) => finding.severity === 'high' || finding.severity === 'critical')
359
+ ? 'failed'
360
+ : 'passed';
361
+ const inputHash = sha256Tagged(JSON.stringify({ policy, changedFiles }));
362
+ return {
363
+ schema_version: '1',
364
+ command: 'script-pack',
365
+ pack_id: CHANGE_IMPACT_PACK_ID,
366
+ script_id: CHANGE_IMPACT_SCRIPT_ID,
367
+ script_ref: CHANGE_IMPACT_SCRIPT_REF,
368
+ action: 'analyze',
369
+ status,
370
+ ok: status === 'passed',
371
+ mustflow_root: root,
372
+ policy,
373
+ input_hash: inputHash,
374
+ changed_files: changedFiles,
375
+ impacts: [...impacts.values()].sort((left, right) => right.confidence - left.confidence || left.path.localeCompare(right.path)),
376
+ script_hints: createScriptHints(changedFiles),
377
+ verification_hints: createVerificationHints(changedFiles),
378
+ truncated: findings.some((finding) => finding.code === 'change_impact_max_files_exceeded' ||
379
+ finding.code === 'change_impact_max_impacts_exceeded'),
380
+ findings,
381
+ issues,
382
+ };
383
+ }
@@ -1,6 +1,7 @@
1
1
  import { isRecord, readStringArray } from './config-loading.js';
2
2
  import { CHANGE_CLASSIFICATION_SURFACE_AUTHORITY, createPathTarget, } from './surface-decision-model.js';
3
3
  import { classifyVerificationCandidate, createVerificationPlan, } from './verification-plan.js';
4
+ import { evaluateCommandPreconditions } from './command-preconditions.js';
4
5
  import { createVerificationDecisionGraph, } from './verification-decision-graph.js';
5
6
  import { createVerificationSchedule, } from './verification-scheduler.js';
6
7
  import { createProjectTestSelectionPlan, } from './test-selection.js';
@@ -83,6 +84,22 @@ function readIntentCostExpectedSeconds(commandContract, intent) {
83
84
  const expectedSeconds = rawIntent.cost.expected_seconds;
84
85
  return Number.isInteger(expectedSeconds) && Number(expectedSeconds) >= 0 ? Number(expectedSeconds) : null;
85
86
  }
87
+ function intentHasPreconditions(commandContract, intent) {
88
+ const rawIntent = commandContract.intents[intent];
89
+ return isRecord(rawIntent) && Array.isArray(rawIntent.preconditions) && rawIntent.preconditions.length > 0;
90
+ }
91
+ function intentPreconditionsSatisfied(commandContract, projectRoot, intent, cache) {
92
+ if (!intentHasPreconditions(commandContract, intent)) {
93
+ return true;
94
+ }
95
+ const cached = cache.get(intent);
96
+ if (cached !== undefined) {
97
+ return cached;
98
+ }
99
+ const satisfied = evaluateCommandPreconditions(projectRoot, commandContract, intent).every((precondition) => precondition.status === 'satisfied');
100
+ cache.set(intent, satisfied);
101
+ return satisfied;
102
+ }
86
103
  function intentCoverageSignature(commandContract, intent) {
87
104
  const rawIntent = commandContract.intents[intent];
88
105
  if (!isRecord(rawIntent) || !isRecord(rawIntent.covers)) {
@@ -202,9 +219,10 @@ function minNumber(values) {
202
219
  }
203
220
  return minimum;
204
221
  }
205
- function selectVerificationCandidates(commandContract, candidates) {
222
+ function selectVerificationCandidates(commandContract, projectRoot, candidates) {
206
223
  const runnableCandidates = candidates.filter((candidate) => candidate.status === 'runnable' && candidate.intent.length > 0);
207
224
  const selectedIntents = new Set(runnableCandidates.map((candidate) => candidate.intent));
225
+ const preconditionCache = new Map();
208
226
  for (const candidate of runnableCandidates) {
209
227
  const isSubsumed = runnableCandidates.some((other) => {
210
228
  if (other.intent === candidate.intent) {
@@ -233,7 +251,16 @@ function selectVerificationCandidates(commandContract, candidates) {
233
251
  if (group.length < 2) {
234
252
  continue;
235
253
  }
236
- const costs = group.map((candidate) => readIntentCostExpectedSeconds(commandContract, candidate.intent));
254
+ const preconditionSatisfiedGroup = group.filter((candidate) => intentPreconditionsSatisfied(commandContract, projectRoot, candidate.intent, preconditionCache));
255
+ const selectableGroup = preconditionSatisfiedGroup.length > 0 ? preconditionSatisfiedGroup : group;
256
+ if (preconditionSatisfiedGroup.length > 0 && preconditionSatisfiedGroup.length < group.length) {
257
+ for (const candidate of group) {
258
+ if (!preconditionSatisfiedGroup.some((entry) => entry.intent === candidate.intent)) {
259
+ selectedIntents.delete(candidate.intent);
260
+ }
261
+ }
262
+ }
263
+ const costs = selectableGroup.map((candidate) => readIntentCostExpectedSeconds(commandContract, candidate.intent));
237
264
  if (costs.some((cost) => cost === null)) {
238
265
  continue;
239
266
  }
@@ -241,11 +268,11 @@ function selectVerificationCandidates(commandContract, candidates) {
241
268
  if (minCost === null) {
242
269
  continue;
243
270
  }
244
- const winners = group.filter((candidate) => readIntentCostExpectedSeconds(commandContract, candidate.intent) === minCost);
271
+ const winners = selectableGroup.filter((candidate) => readIntentCostExpectedSeconds(commandContract, candidate.intent) === minCost);
245
272
  if (winners.length !== 1) {
246
273
  continue;
247
274
  }
248
- for (const candidate of group) {
275
+ for (const candidate of selectableGroup) {
249
276
  if (candidate.intent !== winners[0]?.intent) {
250
277
  selectedIntents.delete(candidate.intent);
251
278
  }
@@ -316,7 +343,7 @@ export function createChangeVerificationReport(classificationReport, commandCont
316
343
  const selectedPlans = plansWithProjectTestSelection.map((plan) => ({
317
344
  ...plan,
318
345
  selectedCandidates: uniqueVerificationCandidates([
319
- ...selectVerificationCandidates(commandContract, plan.candidates),
346
+ ...selectVerificationCandidates(commandContract, projectRoot, plan.candidates),
320
347
  ...testSelectionPlan.selectedCandidates
321
348
  .filter((candidate) => candidate.reason === plan.requirement.reason)
322
349
  .map((candidate) => candidate.candidate),