mustflow 1.30.0 → 1.31.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -2
- package/dist/cli/commands/run.js +221 -48
- package/dist/cli/commands/upgrade.js +65 -0
- package/dist/cli/commands/verify.js +79 -7
- package/dist/cli/i18n/en.js +12 -0
- package/dist/cli/i18n/es.js +12 -0
- package/dist/cli/i18n/fr.js +12 -0
- package/dist/cli/i18n/hi.js +12 -0
- package/dist/cli/i18n/ko.js +12 -0
- package/dist/cli/i18n/zh.js +12 -0
- package/dist/cli/index.js +27 -46
- package/dist/cli/lib/command-registry.js +5 -0
- package/dist/cli/lib/dashboard-html.js +1 -1
- package/dist/cli/lib/local-index.js +11 -8
- package/dist/cli/lib/reporter.js +6 -0
- package/dist/cli/lib/run-plan.js +20 -3
- package/dist/cli/lib/validation.js +110 -1
- package/dist/core/bounded-output.js +38 -0
- package/dist/core/change-classification.js +6 -2
- package/dist/core/change-verification.js +240 -6
- package/dist/core/check-issues.js +6 -0
- package/dist/core/command-contract-validation.js +20 -0
- package/dist/core/command-effects.js +13 -0
- package/dist/core/contract-lint.js +95 -1
- package/dist/core/dashboard-verification.js +8 -0
- package/dist/core/public-json-contracts.js +7 -0
- package/dist/core/run-performance-history.js +307 -0
- package/dist/core/run-profile.js +87 -0
- package/dist/core/run-receipt.js +171 -4
- package/dist/core/run-write-drift.js +18 -2
- package/dist/core/skill-route-alignment.js +90 -0
- package/dist/core/test-selection.js +224 -0
- package/dist/core/verification-decision-graph.js +67 -0
- package/dist/core/verification-scheduler.js +96 -2
- package/package.json +1 -1
- package/schemas/README.md +6 -2
- package/schemas/change-verification-report.schema.json +153 -3
- package/schemas/commands.schema.json +47 -1
- package/schemas/contract-lint-report.schema.json +51 -0
- package/schemas/dashboard-export.schema.json +273 -0
- package/schemas/explain-report.schema.json +2 -0
- package/schemas/run-receipt.schema.json +109 -0
- package/templates/default/common/.mustflow/config/commands.toml +1 -1
- package/templates/default/manifest.toml +1 -1
|
@@ -5,6 +5,17 @@ const SKILL_ROUTE_SOURCE_FILES = [
|
|
|
5
5
|
'.mustflow/docs/agent-workflow.md',
|
|
6
6
|
];
|
|
7
7
|
const MARKDOWN_TABLE_SEPARATOR_PATTERN = /^:?-{3,}:?$/u;
|
|
8
|
+
const BROAD_CATCH_ALL_TRIGGERS = new Set([
|
|
9
|
+
'any request',
|
|
10
|
+
'any task',
|
|
11
|
+
'any change',
|
|
12
|
+
'all requests',
|
|
13
|
+
'all tasks',
|
|
14
|
+
'all changes',
|
|
15
|
+
'every request',
|
|
16
|
+
'every task',
|
|
17
|
+
'everything',
|
|
18
|
+
]);
|
|
8
19
|
export const SKILL_INDEX_ROUTE_COLUMN_COUNT = 7;
|
|
9
20
|
export const SKILL_INDEX_SKILL_PATH_COLUMN_INDEX = 1;
|
|
10
21
|
export const SKILL_INDEX_VERIFICATION_INTENTS_COLUMN_INDEX = 5;
|
|
@@ -56,11 +67,90 @@ export function parseSkillIndexRoutes(content) {
|
|
|
56
67
|
}
|
|
57
68
|
return routes;
|
|
58
69
|
}
|
|
70
|
+
function normalizeRouteText(value) {
|
|
71
|
+
return value
|
|
72
|
+
.toLowerCase()
|
|
73
|
+
.replace(/`[^`]+`/gu, ' ')
|
|
74
|
+
.replace(/<[^>]+>/gu, ' ')
|
|
75
|
+
.replace(/[^a-z0-9]+/gu, ' ')
|
|
76
|
+
.trim()
|
|
77
|
+
.replace(/\s+/gu, ' ');
|
|
78
|
+
}
|
|
79
|
+
function collectRoutePairs(routes) {
|
|
80
|
+
const pairs = [];
|
|
81
|
+
for (let leftIndex = 0; leftIndex < routes.length; leftIndex += 1) {
|
|
82
|
+
for (let rightIndex = leftIndex + 1; rightIndex < routes.length; rightIndex += 1) {
|
|
83
|
+
const left = routes[leftIndex];
|
|
84
|
+
const right = routes[rightIndex];
|
|
85
|
+
if (left.skillPath !== right.skillPath) {
|
|
86
|
+
pairs.push({ left, right });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return pairs;
|
|
91
|
+
}
|
|
92
|
+
function routePairLabel(pair) {
|
|
93
|
+
return `${pair.left.skillPath} and ${pair.right.skillPath}`;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* mf:anchor core.skill-route-conflict-lint
|
|
97
|
+
* purpose: Keep skill routing warnings deterministic so broad or duplicate route rows are review candidates, not LLM guesses.
|
|
98
|
+
* search: skill route conflict, duplicate trigger, broad catch-all route
|
|
99
|
+
* invariant: Route conflict warnings are heuristic warnings only; missing routes and command-intent drift remain strict errors.
|
|
100
|
+
* risk: config
|
|
101
|
+
*/
|
|
102
|
+
export function findSkillRouteConflictWarnings(routes) {
|
|
103
|
+
const warnings = [];
|
|
104
|
+
const triggerToRoutes = new Map();
|
|
105
|
+
const surfaceToRoutes = new Map();
|
|
106
|
+
for (const route of routes) {
|
|
107
|
+
const trigger = normalizeRouteText(route.trigger);
|
|
108
|
+
const editScope = normalizeRouteText(route.editScope);
|
|
109
|
+
const risk = normalizeRouteText(route.risk);
|
|
110
|
+
const expectedOutput = normalizeRouteText(route.expectedOutput);
|
|
111
|
+
if (BROAD_CATCH_ALL_TRIGGERS.has(trigger)) {
|
|
112
|
+
warnings.push(`${route.skillPath} route uses broad catch-all trigger "${route.trigger}" that can shadow narrower skills`);
|
|
113
|
+
}
|
|
114
|
+
if (trigger) {
|
|
115
|
+
triggerToRoutes.set(trigger, [...(triggerToRoutes.get(trigger) ?? []), route]);
|
|
116
|
+
}
|
|
117
|
+
if (editScope && risk && expectedOutput) {
|
|
118
|
+
const surfaceKey = `${editScope}\n${risk}\n${expectedOutput}`;
|
|
119
|
+
surfaceToRoutes.set(surfaceKey, [...(surfaceToRoutes.get(surfaceKey) ?? []), route]);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
for (const pair of collectRoutePairs([...triggerToRoutes.values()].flatMap((matchingRoutes) => {
|
|
123
|
+
return matchingRoutes.length > 1 ? matchingRoutes : [];
|
|
124
|
+
}))) {
|
|
125
|
+
if (normalizeRouteText(pair.left.trigger) === normalizeRouteText(pair.right.trigger)) {
|
|
126
|
+
warnings.push(`${routePairLabel(pair)} have identical skill route trigger text`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
for (const pair of collectRoutePairs([...surfaceToRoutes.values()].flatMap((matchingRoutes) => {
|
|
130
|
+
return matchingRoutes.length > 1 ? matchingRoutes : [];
|
|
131
|
+
}))) {
|
|
132
|
+
const leftSurface = [
|
|
133
|
+
normalizeRouteText(pair.left.editScope),
|
|
134
|
+
normalizeRouteText(pair.left.risk),
|
|
135
|
+
normalizeRouteText(pair.left.expectedOutput),
|
|
136
|
+
].join('\n');
|
|
137
|
+
const rightSurface = [
|
|
138
|
+
normalizeRouteText(pair.right.editScope),
|
|
139
|
+
normalizeRouteText(pair.right.risk),
|
|
140
|
+
normalizeRouteText(pair.right.expectedOutput),
|
|
141
|
+
].join('\n');
|
|
142
|
+
if (leftSurface === rightSurface) {
|
|
143
|
+
warnings.push(`${routePairLabel(pair)} have duplicate edit scope, risk, and expected output route surface`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return [...new Set(warnings)].sort((left, right) => left.localeCompare(right));
|
|
147
|
+
}
|
|
59
148
|
function pluralize(count, singular, plural) {
|
|
60
149
|
return count === 1 ? singular : plural;
|
|
61
150
|
}
|
|
62
151
|
export function isSkillRouteAlignmentIssue(issue) {
|
|
63
152
|
return (issue.includes('.mustflow/skills/INDEX.md route') ||
|
|
153
|
+
issue.includes('.mustflow/skills/INDEX.md .mustflow/skills/') ||
|
|
64
154
|
issue.includes('.mustflow/skills/INDEX.md has duplicate route') ||
|
|
65
155
|
issue.endsWith(' is not listed in .mustflow/skills/INDEX.md'));
|
|
66
156
|
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { isRecord, readStringArray, resolveMustflowConfigPath, } from './config-loading.js';
|
|
3
|
+
import { readTomlFile } from './toml.js';
|
|
4
|
+
import { classifyVerificationCandidate, } from './verification-plan.js';
|
|
5
|
+
export const TEST_SELECTION_CONFIG_RELATIVE_PATH = '.mustflow/config/test-selection.toml';
|
|
6
|
+
function uniqueSorted(values) {
|
|
7
|
+
return [...new Set(values)].sort((left, right) => left.localeCompare(right));
|
|
8
|
+
}
|
|
9
|
+
function toPosixPath(value) {
|
|
10
|
+
return value.replace(/\\/g, '/');
|
|
11
|
+
}
|
|
12
|
+
function escapeRegExp(value) {
|
|
13
|
+
return value.replace(/[\\^$+?.()|[\]{}]/g, '\\$&');
|
|
14
|
+
}
|
|
15
|
+
function globToRegExp(pattern) {
|
|
16
|
+
const normalized = toPosixPath(pattern).replace(/^\/+/u, '');
|
|
17
|
+
let source = '^';
|
|
18
|
+
for (let index = 0; index < normalized.length;) {
|
|
19
|
+
if (normalized.startsWith('**', index)) {
|
|
20
|
+
source += '.*';
|
|
21
|
+
index += 2;
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
const char = normalized[index];
|
|
25
|
+
if (char === '*') {
|
|
26
|
+
source += '[^/]*';
|
|
27
|
+
index += 1;
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
source += escapeRegExp(char ?? '');
|
|
31
|
+
index += 1;
|
|
32
|
+
}
|
|
33
|
+
return new RegExp(`${source}$`, 'u');
|
|
34
|
+
}
|
|
35
|
+
function readStringField(table, key) {
|
|
36
|
+
const value = table[key];
|
|
37
|
+
return typeof value === 'string' && value.trim().length > 0 ? value : null;
|
|
38
|
+
}
|
|
39
|
+
function readRule(value) {
|
|
40
|
+
if (!isRecord(value) || !isRecord(value.match) || !isRecord(value.select)) {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
const id = readStringField(value, 'id');
|
|
44
|
+
const risk = readStringField(value, 'risk');
|
|
45
|
+
const reason = readStringField(value, 'reason');
|
|
46
|
+
const paths = readStringArray(value.match, 'paths');
|
|
47
|
+
const surfaces = readStringArray(value.match, 'surfaces');
|
|
48
|
+
const intent = readStringField(value.select, 'intent');
|
|
49
|
+
const fallbackIntent = readStringField(value.select, 'fallback_intent');
|
|
50
|
+
const testTargets = readStringArray(value.select, 'test_targets') ?? [];
|
|
51
|
+
if (!id || !risk || !reason || !paths || paths.length === 0 || !surfaces || surfaces.length === 0 || !intent || !fallbackIntent) {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
return {
|
|
55
|
+
id,
|
|
56
|
+
risk,
|
|
57
|
+
reason,
|
|
58
|
+
paths: uniqueSorted(paths),
|
|
59
|
+
surfaces: uniqueSorted(surfaces),
|
|
60
|
+
intent,
|
|
61
|
+
fallbackIntent,
|
|
62
|
+
testTargets: uniqueSorted(testTargets),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
function readRules(projectRoot) {
|
|
66
|
+
const configPath = resolveMustflowConfigPath(projectRoot, TEST_SELECTION_CONFIG_RELATIVE_PATH);
|
|
67
|
+
if (!existsSync(configPath)) {
|
|
68
|
+
return {
|
|
69
|
+
status: 'missing',
|
|
70
|
+
rules: [],
|
|
71
|
+
note: 'No project-declared test selection manifest exists; mustflow will not infer a user-project test subset.',
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
try {
|
|
75
|
+
const parsed = readTomlFile(configPath);
|
|
76
|
+
if (!isRecord(parsed) || parsed.schema_version !== '1' || !Array.isArray(parsed.rules)) {
|
|
77
|
+
return {
|
|
78
|
+
status: 'invalid',
|
|
79
|
+
rules: [],
|
|
80
|
+
note: 'Project-declared test selection manifest is invalid; run mf check --strict before relying on it.',
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
const rules = parsed.rules.map(readRule);
|
|
84
|
+
if (!rules.every((rule) => rule !== null)) {
|
|
85
|
+
return {
|
|
86
|
+
status: 'invalid',
|
|
87
|
+
rules: [],
|
|
88
|
+
note: 'Project-declared test selection manifest has invalid rule shapes; run mf check --strict before relying on it.',
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
return { status: 'loaded', rules };
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
return {
|
|
95
|
+
status: 'invalid',
|
|
96
|
+
rules: [],
|
|
97
|
+
note: 'Project-declared test selection manifest cannot be read; run mf check --strict before relying on it.',
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
function ruleMatchesClassification(rule, classification) {
|
|
102
|
+
const pathMatches = rule.paths.some((pattern) => globToRegExp(pattern).test(toPosixPath(classification.path)));
|
|
103
|
+
const surfaceMatches = rule.surfaces.includes(classification.surface.kind);
|
|
104
|
+
return pathMatches && surfaceMatches;
|
|
105
|
+
}
|
|
106
|
+
function matchingClassifications(rule, report) {
|
|
107
|
+
return report.classifications.filter((classification) => ruleMatchesClassification(rule, classification));
|
|
108
|
+
}
|
|
109
|
+
function selectReason(classifications) {
|
|
110
|
+
return (classifications.flatMap((classification) => classification.surface.validationReasons)[0] ??
|
|
111
|
+
'unknown_change');
|
|
112
|
+
}
|
|
113
|
+
function withDetail(candidate, detail) {
|
|
114
|
+
return {
|
|
115
|
+
...candidate,
|
|
116
|
+
detail: candidate.detail ?? detail,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
function commandAcceptsTestTargets(commandContract, intent) {
|
|
120
|
+
const rawIntent = commandContract.intents[intent];
|
|
121
|
+
return isRecord(rawIntent) && isRecord(rawIntent.selection) && rawIntent.selection.accepts_test_targets === true;
|
|
122
|
+
}
|
|
123
|
+
function appliedTestTargetsForCandidate(rule, commandContract, candidate) {
|
|
124
|
+
return candidate.status === 'runnable' && commandAcceptsTestTargets(commandContract, candidate.intent)
|
|
125
|
+
? rule.testTargets
|
|
126
|
+
: [];
|
|
127
|
+
}
|
|
128
|
+
function toReportCandidate(rule, reason, role, candidate, appliedTestTargets) {
|
|
129
|
+
return {
|
|
130
|
+
ruleId: rule.id,
|
|
131
|
+
reason,
|
|
132
|
+
intent: candidate.intent,
|
|
133
|
+
role,
|
|
134
|
+
status: candidate.status,
|
|
135
|
+
skipReason: candidate.reason,
|
|
136
|
+
detail: candidate.detail,
|
|
137
|
+
testTargets: rule.testTargets,
|
|
138
|
+
appliedTestTargets,
|
|
139
|
+
testTargetsApplied: appliedTestTargets.length > 0,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
export function createProjectTestSelectionPlan(projectRoot, classificationReport, commandContract) {
|
|
143
|
+
const loaded = readRules(projectRoot);
|
|
144
|
+
if (loaded.status !== 'loaded') {
|
|
145
|
+
const status = loaded.status;
|
|
146
|
+
return {
|
|
147
|
+
report: {
|
|
148
|
+
source: 'test-selection.toml',
|
|
149
|
+
status,
|
|
150
|
+
configPath: TEST_SELECTION_CONFIG_RELATIVE_PATH,
|
|
151
|
+
authority: TEST_SELECTION_CONFIG_RELATIVE_PATH,
|
|
152
|
+
commandAuthority: '.mustflow/config/commands.toml',
|
|
153
|
+
grantsCommandAuthority: false,
|
|
154
|
+
matches: [],
|
|
155
|
+
selected: [],
|
|
156
|
+
notes: [loaded.note],
|
|
157
|
+
},
|
|
158
|
+
candidates: [],
|
|
159
|
+
selectedCandidates: [],
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
const matches = [];
|
|
163
|
+
const candidates = [];
|
|
164
|
+
const selectedCandidates = [];
|
|
165
|
+
const selectedReportCandidates = [];
|
|
166
|
+
for (const rule of loaded.rules) {
|
|
167
|
+
const classifications = matchingClassifications(rule, classificationReport);
|
|
168
|
+
if (classifications.length === 0) {
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
const reason = selectReason(classifications);
|
|
172
|
+
matches.push({
|
|
173
|
+
ruleId: rule.id,
|
|
174
|
+
risk: rule.risk,
|
|
175
|
+
reason: rule.reason,
|
|
176
|
+
files: uniqueSorted(classifications.map((classification) => classification.path)),
|
|
177
|
+
surfaces: uniqueSorted(classifications.map((classification) => classification.surface.kind)),
|
|
178
|
+
intent: rule.intent,
|
|
179
|
+
fallbackIntent: rule.fallbackIntent,
|
|
180
|
+
testTargets: rule.testTargets,
|
|
181
|
+
});
|
|
182
|
+
const primary = withDetail(classifyVerificationCandidate(rule.intent, commandContract.intents[rule.intent]), `Project-declared test selection rule "${rule.id}".`);
|
|
183
|
+
const primaryTestTargets = appliedTestTargetsForCandidate(rule, commandContract, primary);
|
|
184
|
+
const primaryCandidate = { reason, candidate: primary, testTargets: primaryTestTargets };
|
|
185
|
+
candidates.push(primaryCandidate);
|
|
186
|
+
if (primary.status === 'runnable') {
|
|
187
|
+
selectedCandidates.push(primaryCandidate);
|
|
188
|
+
selectedReportCandidates.push(toReportCandidate(rule, reason, 'primary', primary, primaryTestTargets));
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
selectedReportCandidates.push(toReportCandidate(rule, reason, 'primary', primary, primaryTestTargets));
|
|
192
|
+
const fallback = withDetail(classifyVerificationCandidate(rule.fallbackIntent, commandContract.intents[rule.fallbackIntent]), `Fallback for project-declared test selection rule "${rule.id}".`);
|
|
193
|
+
const fallbackTestTargets = appliedTestTargetsForCandidate(rule, commandContract, fallback);
|
|
194
|
+
const fallbackCandidate = { reason, candidate: fallback, testTargets: fallbackTestTargets };
|
|
195
|
+
candidates.push(fallbackCandidate);
|
|
196
|
+
if (fallback.status === 'runnable') {
|
|
197
|
+
selectedCandidates.push(fallbackCandidate);
|
|
198
|
+
selectedReportCandidates.push(toReportCandidate(rule, reason, 'fallback', fallback, fallbackTestTargets));
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
const status = matches.length > 0 ? 'matched' : 'unmatched';
|
|
202
|
+
const notes = [
|
|
203
|
+
matches.length > 0
|
|
204
|
+
? 'Matched project-declared test selection rules are treated as a minimum selected set.'
|
|
205
|
+
: 'No project-declared test selection rules matched the current changed files.',
|
|
206
|
+
'Local index data and performance history may add suggestions later, but must not remove manifest-selected tests.',
|
|
207
|
+
'Test targets are passed only when the selected command intent declares selection.accepts_test_targets = true.',
|
|
208
|
+
];
|
|
209
|
+
return {
|
|
210
|
+
report: {
|
|
211
|
+
source: 'test-selection.toml',
|
|
212
|
+
status,
|
|
213
|
+
configPath: TEST_SELECTION_CONFIG_RELATIVE_PATH,
|
|
214
|
+
authority: TEST_SELECTION_CONFIG_RELATIVE_PATH,
|
|
215
|
+
commandAuthority: '.mustflow/config/commands.toml',
|
|
216
|
+
grantsCommandAuthority: false,
|
|
217
|
+
matches,
|
|
218
|
+
selected: selectedReportCandidates,
|
|
219
|
+
notes,
|
|
220
|
+
},
|
|
221
|
+
candidates,
|
|
222
|
+
selectedCandidates,
|
|
223
|
+
};
|
|
224
|
+
}
|
|
@@ -25,6 +25,63 @@ function readCommandEffects(rawIntent) {
|
|
|
25
25
|
concurrency: readString(effect, 'concurrency') ?? null,
|
|
26
26
|
}));
|
|
27
27
|
}
|
|
28
|
+
function readNonNegativeNumber(table, key) {
|
|
29
|
+
const value = table[key];
|
|
30
|
+
return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : null;
|
|
31
|
+
}
|
|
32
|
+
function readNonNegativeInteger(table, key) {
|
|
33
|
+
const value = table[key];
|
|
34
|
+
return Number.isInteger(value) && Number(value) >= 0 ? Number(value) : null;
|
|
35
|
+
}
|
|
36
|
+
function readCoverageHints(rawIntent) {
|
|
37
|
+
const covers = rawIntent.covers;
|
|
38
|
+
if (!isRecord(covers)) {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
return {
|
|
42
|
+
reasons: readStringArray(covers, 'reasons') ?? [],
|
|
43
|
+
surfaces: readStringArray(covers, 'surfaces') ?? [],
|
|
44
|
+
paths: readStringArray(covers, 'paths') ?? [],
|
|
45
|
+
contracts: readStringArray(covers, 'contracts') ?? [],
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
function readSelectionHints(rawIntent) {
|
|
49
|
+
const selection = rawIntent.selection;
|
|
50
|
+
if (!isRecord(selection)) {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
return {
|
|
54
|
+
coverage_level: readString(selection, 'coverage_level') ?? null,
|
|
55
|
+
coverage_confidence: readString(selection, 'coverage_confidence') ?? null,
|
|
56
|
+
accepts_changed_files: readString(selection, 'accepts_changed_files') ?? null,
|
|
57
|
+
fallback_intents: readStringArray(selection, 'fallback_intents') ?? [],
|
|
58
|
+
escalate_to: readStringArray(selection, 'escalate_to') ?? [],
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
function readCostHints(rawIntent) {
|
|
62
|
+
const cost = rawIntent.cost;
|
|
63
|
+
if (!isRecord(cost)) {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
return {
|
|
67
|
+
expected_seconds: readNonNegativeInteger(cost, 'expected_seconds'),
|
|
68
|
+
cold_start_seconds: readNonNegativeInteger(cost, 'cold_start_seconds'),
|
|
69
|
+
timeout_ratio_expectation: readNonNegativeNumber(cost, 'timeout_ratio_expectation'),
|
|
70
|
+
cost_tier: readString(cost, 'cost_tier') ?? null,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
function readRelationHints(rawIntent) {
|
|
74
|
+
const relations = rawIntent.relations;
|
|
75
|
+
if (!isRecord(relations)) {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
return {
|
|
79
|
+
subsumes: readStringArray(relations, 'subsumes') ?? [],
|
|
80
|
+
subsumed_by: readStringArray(relations, 'subsumed_by') ?? [],
|
|
81
|
+
requires_with: readStringArray(relations, 'requires_with') ?? [],
|
|
82
|
+
escalate_to: readStringArray(relations, 'escalate_to') ?? [],
|
|
83
|
+
};
|
|
84
|
+
}
|
|
28
85
|
function readCommandMetadata(commandContract, intent) {
|
|
29
86
|
if (!intent) {
|
|
30
87
|
return null;
|
|
@@ -44,6 +101,10 @@ function readCommandMetadata(commandContract, intent) {
|
|
|
44
101
|
required_after: readStringArray(rawIntent, 'required_after') ?? [],
|
|
45
102
|
network: readBoolean(rawIntent, 'network'),
|
|
46
103
|
destructive: readBoolean(rawIntent, 'destructive'),
|
|
104
|
+
covers: readCoverageHints(rawIntent),
|
|
105
|
+
selection: readSelectionHints(rawIntent),
|
|
106
|
+
cost: readCostHints(rawIntent),
|
|
107
|
+
relations: readRelationHints(rawIntent),
|
|
47
108
|
};
|
|
48
109
|
}
|
|
49
110
|
function candidateDecisionStatus(commandContract, candidate) {
|
|
@@ -85,6 +146,9 @@ function summarize(nodes, edgeCount) {
|
|
|
85
146
|
blocked: nodes.filter((node) => node.status === 'blocked').length,
|
|
86
147
|
manual_only: nodes.filter((node) => node.status === 'manual_only').length,
|
|
87
148
|
unknown: nodes.filter((node) => node.status === 'unknown').length,
|
|
149
|
+
selected: nodes.filter((node) => node.kind === 'command_candidate' && node.data.selectionState === 'selected').length,
|
|
150
|
+
not_selected: nodes.filter((node) => node.kind === 'command_candidate' && node.data.selectionState === 'not_selected')
|
|
151
|
+
.length,
|
|
88
152
|
gapCount: nodes.filter((node) => node.kind === 'gap').length,
|
|
89
153
|
};
|
|
90
154
|
}
|
|
@@ -146,6 +210,9 @@ export function createVerificationDecisionGraph(commandContract, requirements, c
|
|
|
146
210
|
candidateStatus: candidate.status,
|
|
147
211
|
skipReason: candidate.skipReason,
|
|
148
212
|
detail: candidate.detail,
|
|
213
|
+
candidateState: candidate.candidateState ?? null,
|
|
214
|
+
eligibilityState: candidate.eligibilityState ?? null,
|
|
215
|
+
selectionState: candidate.selectionState ?? null,
|
|
149
216
|
command,
|
|
150
217
|
},
|
|
151
218
|
});
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
1
3
|
import { commandEffectsConflict, normalizeCommandEffects, } from './command-effects.js';
|
|
2
4
|
function uniqueSorted(values) {
|
|
3
5
|
return [...new Set(values)].sort((left, right) => left.localeCompare(right));
|
|
@@ -13,6 +15,79 @@ function toScheduleEffect(effect) {
|
|
|
13
15
|
concurrency: effect.concurrency,
|
|
14
16
|
};
|
|
15
17
|
}
|
|
18
|
+
function isObject(value) {
|
|
19
|
+
return !!value && typeof value === 'object';
|
|
20
|
+
}
|
|
21
|
+
function readJsonFile(filePath) {
|
|
22
|
+
try {
|
|
23
|
+
return JSON.parse(readFileSync(filePath, 'utf8'));
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
function getUndeclaredWriteIntent(value) {
|
|
30
|
+
if (!isObject(value) || typeof value.intent !== 'string') {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
const writeDrift = value.write_drift;
|
|
34
|
+
if (!isObject(writeDrift) || writeDrift.has_undeclared_changes !== true) {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
return value.intent;
|
|
38
|
+
}
|
|
39
|
+
function resolveStateRelativePath(projectRoot, relativePath) {
|
|
40
|
+
if (typeof relativePath !== 'string' || relativePath.length === 0 || path.isAbsolute(relativePath)) {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
const normalized = relativePath.replaceAll('\\', '/');
|
|
44
|
+
if (!normalized.startsWith('.mustflow/state/runs/')) {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
const resolved = path.resolve(projectRoot, normalized);
|
|
48
|
+
const relative = path.relative(projectRoot, resolved);
|
|
49
|
+
if (relative.startsWith('..') || path.isAbsolute(relative)) {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
return resolved;
|
|
53
|
+
}
|
|
54
|
+
function readVerifyManifestUndeclaredWriteIntents(projectRoot, latest) {
|
|
55
|
+
const manifestPath = resolveStateRelativePath(projectRoot, latest.manifest_path);
|
|
56
|
+
if (!manifestPath) {
|
|
57
|
+
return new Set();
|
|
58
|
+
}
|
|
59
|
+
const manifest = readJsonFile(manifestPath);
|
|
60
|
+
if (!isObject(manifest) || manifest.command !== 'verify' || !Array.isArray(manifest.receipts)) {
|
|
61
|
+
return new Set();
|
|
62
|
+
}
|
|
63
|
+
const intents = new Set();
|
|
64
|
+
for (const entry of manifest.receipts) {
|
|
65
|
+
if (!isObject(entry)) {
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
const receiptPath = resolveStateRelativePath(projectRoot, entry.receipt_path);
|
|
69
|
+
if (!receiptPath) {
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
const intent = getUndeclaredWriteIntent(readJsonFile(receiptPath));
|
|
73
|
+
if (intent) {
|
|
74
|
+
intents.add(intent);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return intents;
|
|
78
|
+
}
|
|
79
|
+
function readLatestUndeclaredWriteIntents(projectRoot) {
|
|
80
|
+
const latestPath = path.join(projectRoot, '.mustflow', 'state', 'runs', 'latest.json');
|
|
81
|
+
const parsed = readJsonFile(latestPath);
|
|
82
|
+
const directIntent = getUndeclaredWriteIntent(parsed);
|
|
83
|
+
if (directIntent) {
|
|
84
|
+
return new Set([directIntent]);
|
|
85
|
+
}
|
|
86
|
+
if (!isObject(parsed) || parsed.command !== 'verify') {
|
|
87
|
+
return new Set();
|
|
88
|
+
}
|
|
89
|
+
return readVerifyManifestUndeclaredWriteIntents(projectRoot, parsed);
|
|
90
|
+
}
|
|
16
91
|
function entriesConflict(left, right) {
|
|
17
92
|
const conflicts = [];
|
|
18
93
|
for (const leftEffect of left.effects) {
|
|
@@ -33,7 +108,8 @@ function entriesConflict(left, right) {
|
|
|
33
108
|
function addEntryToBatches(batches, batchEntries, entry) {
|
|
34
109
|
for (let batchIndex = 0; batchIndex < batchEntries.length; batchIndex += 1) {
|
|
35
110
|
const existingEntries = batchEntries[batchIndex] ?? [];
|
|
36
|
-
const hasConflict =
|
|
111
|
+
const hasConflict = !entry.parallelEligible ||
|
|
112
|
+
existingEntries.some((existing) => !existing.parallelEligible || entriesConflict(entry, existing).length > 0);
|
|
37
113
|
if (hasConflict) {
|
|
38
114
|
continue;
|
|
39
115
|
}
|
|
@@ -55,14 +131,24 @@ function addEntryToBatches(batches, batchEntries, entry) {
|
|
|
55
131
|
});
|
|
56
132
|
}
|
|
57
133
|
export function createVerificationSchedule(projectRoot, commandContract, candidates) {
|
|
134
|
+
const latestUndeclaredWriteIntents = readLatestUndeclaredWriteIntents(projectRoot);
|
|
58
135
|
const runnableIntents = uniqueSorted(candidates
|
|
59
136
|
.filter((candidate) => candidate.status === 'runnable' && candidate.intent.length > 0)
|
|
60
137
|
.map((candidate) => candidate.intent));
|
|
61
138
|
const baseEntries = runnableIntents.map((intent) => {
|
|
62
139
|
const effects = normalizeCommandEffects(projectRoot, commandContract, intent).map(toScheduleEffect);
|
|
140
|
+
const hasExplicitEffects = effects.length > 0 && effects.every((effect) => effect.source === 'effects');
|
|
141
|
+
const hasUndeclaredWriteDrift = latestUndeclaredWriteIntents.has(intent);
|
|
142
|
+
const parallelEligible = hasExplicitEffects && !hasUndeclaredWriteDrift;
|
|
63
143
|
return {
|
|
64
144
|
intent,
|
|
65
145
|
status: 'runnable',
|
|
146
|
+
parallelEligible,
|
|
147
|
+
parallelReason: hasUndeclaredWriteDrift
|
|
148
|
+
? 'undeclared_write_drift'
|
|
149
|
+
: hasExplicitEffects
|
|
150
|
+
? 'explicit_effects'
|
|
151
|
+
: 'missing_explicit_effects',
|
|
66
152
|
effects,
|
|
67
153
|
locks: uniqueSorted(effects.map((effect) => effect.lock)),
|
|
68
154
|
conflicts: [],
|
|
@@ -82,11 +168,19 @@ export function createVerificationSchedule(projectRoot, commandContract, candida
|
|
|
82
168
|
}
|
|
83
169
|
return {
|
|
84
170
|
runner: 'serial_mf_run_receipts',
|
|
171
|
+
failurePolicy: {
|
|
172
|
+
mode: 'batch_boundary',
|
|
173
|
+
startedBatch: 'wait_for_completion',
|
|
174
|
+
nextBatch: 'stop_on_failure',
|
|
175
|
+
},
|
|
85
176
|
batches,
|
|
86
177
|
entries,
|
|
87
178
|
notes: [
|
|
88
179
|
'Batches explain resource compatibility for planning only.',
|
|
89
|
-
'
|
|
180
|
+
'Only entries backed by explicit effects are marked parallel eligible; writes fallback remains serial-only.',
|
|
181
|
+
...uniqueSorted(latestUndeclaredWriteIntents).map((intent) => `Latest receipt for ${intent} reported undeclared writes; it is not parallel eligible.`),
|
|
182
|
+
'If a future parallel batch has already started, let it finish and stop before the next batch on failure.',
|
|
183
|
+
'mf verify still executes copied commands serially and writes the latest run summary after the batch completes.',
|
|
90
184
|
],
|
|
91
185
|
};
|
|
92
186
|
}
|
package/package.json
CHANGED
package/schemas/README.md
CHANGED
|
@@ -9,9 +9,12 @@ Current schemas:
|
|
|
9
9
|
- `adapter-compatibility-report.schema.json`: output of `mf adapters status --json`
|
|
10
10
|
- `context-report.schema.json`: output of `mf context --json`
|
|
11
11
|
- `run-receipt.schema.json`: output of `mf run <intent> --json` and `.mustflow/state/runs/latest.json`,
|
|
12
|
-
including bounded declared-write drift metadata
|
|
12
|
+
including bounded declared-write drift metadata, a safe latest-run performance summary, and optional
|
|
13
|
+
structured phase timings and selection summaries
|
|
13
14
|
- `commands.schema.json`: parsed `.mustflow/config/commands.toml`
|
|
14
15
|
- `contract-lint-report.schema.json`: output of `mf contract-lint --json`
|
|
16
|
+
- `dashboard-export.schema.json`: bounded static export written by `mf dashboard --export-json <path>`,
|
|
17
|
+
including output policy, redaction and truncation metadata, and the dashboard harness report
|
|
15
18
|
- `classify-report.schema.json`: output of `mf classify --changed --json` and
|
|
16
19
|
`mf classify <path...> --json`
|
|
17
20
|
- `impact-report.schema.json`: output of `mf impact --changed --json` and
|
|
@@ -28,7 +31,8 @@ Current schemas:
|
|
|
28
31
|
- `verify-report.schema.json`: output of `mf verify --reason <event> --json`
|
|
29
32
|
- `change-verification-report.schema.json`: output of `mf verify --reason <event> --plan-only --json` and
|
|
30
33
|
`mf verify --from-plan <classify-report.json> --plan-only --json`, including the `decision_graph` that links
|
|
31
|
-
changed surfaces, classification reasons, command candidates, eligibility,
|
|
34
|
+
changed surfaces, classification reasons, command candidates, eligibility, selected or not-selected state,
|
|
35
|
+
effects, and gaps.
|
|
32
36
|
Local-index command-effect graphs are explanation-only and cannot grant command authority.
|
|
33
37
|
|
|
34
38
|
These schemas define stable, automation-facing fields. Human-readable command
|