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,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
|
|
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 =
|
|
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
|
|
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),
|