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
|
@@ -14,7 +14,8 @@ function surface(kind, category, isPublicSurface, validationReasons, affectedCon
|
|
|
14
14
|
driftChecks,
|
|
15
15
|
};
|
|
16
16
|
}
|
|
17
|
-
const
|
|
17
|
+
const UNKNOWN_CHANGE_REASON = 'unknown_change';
|
|
18
|
+
const UNKNOWN_SURFACE = surface('unclassified_path', 'unknown', false, [UNKNOWN_CHANGE_REASON], ['unclassified repository path'], 'not_applicable', ['classification rule coverage']);
|
|
18
19
|
function rule(id, match, changeKinds, surfaceContract) {
|
|
19
20
|
return {
|
|
20
21
|
id,
|
|
@@ -80,7 +81,10 @@ export function listChangeClassificationRuleDescriptors() {
|
|
|
80
81
|
}));
|
|
81
82
|
}
|
|
82
83
|
export function listChangeClassificationValidationReasons() {
|
|
83
|
-
return uniqueSorted(
|
|
84
|
+
return uniqueSorted([
|
|
85
|
+
...CHANGE_CLASSIFICATION_RULES.flatMap((classificationRule) => classificationRule.surface.validationReasons),
|
|
86
|
+
...UNKNOWN_SURFACE.validationReasons,
|
|
87
|
+
]);
|
|
84
88
|
}
|
|
85
89
|
export function createChangeClassificationReport(source, relativePaths) {
|
|
86
90
|
const files = uniqueSorted(relativePaths.map(normalizeStatusPath).filter((filePath) => filePath.length > 0));
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
import { isRecord, readStringArray } from './config-loading.js';
|
|
1
2
|
import { CHANGE_CLASSIFICATION_SURFACE_AUTHORITY, createPathTarget, } from './surface-decision-model.js';
|
|
2
|
-
import { createVerificationPlan, } from './verification-plan.js';
|
|
3
|
+
import { classifyVerificationCandidate, createVerificationPlan, } from './verification-plan.js';
|
|
3
4
|
import { createVerificationDecisionGraph, } from './verification-decision-graph.js';
|
|
4
5
|
import { createVerificationSchedule, } from './verification-scheduler.js';
|
|
6
|
+
import { createProjectTestSelectionPlan, } from './test-selection.js';
|
|
5
7
|
export const CHANGE_VERIFICATION_SCHEMA_VERSION = '1';
|
|
6
8
|
function uniqueSorted(values) {
|
|
7
9
|
return [...new Set(values)].sort((left, right) => left.localeCompare(right));
|
|
@@ -9,6 +11,21 @@ function uniqueSorted(values) {
|
|
|
9
11
|
function uniqueUpdatePolicies(values) {
|
|
10
12
|
return uniqueSorted([...values].filter((value) => value !== 'not_applicable'));
|
|
11
13
|
}
|
|
14
|
+
const DECLARED_ESCALATION_SIGNALS = [
|
|
15
|
+
'public_api',
|
|
16
|
+
'release',
|
|
17
|
+
'package',
|
|
18
|
+
'packaging',
|
|
19
|
+
'security',
|
|
20
|
+
'selector',
|
|
21
|
+
'command contract',
|
|
22
|
+
'classifier',
|
|
23
|
+
'classification',
|
|
24
|
+
'verification planner',
|
|
25
|
+
'verification planning',
|
|
26
|
+
'machine-readable output contract',
|
|
27
|
+
'JSON schema',
|
|
28
|
+
];
|
|
12
29
|
function classificationsForReason(classificationReport, reason) {
|
|
13
30
|
return classificationReport.classifications.filter((classification) => classification.surface.validationReasons.includes(reason));
|
|
14
31
|
}
|
|
@@ -40,13 +57,209 @@ function createVerificationRequirement(classificationReport, reason) {
|
|
|
40
57
|
source: 'change_classification',
|
|
41
58
|
};
|
|
42
59
|
}
|
|
43
|
-
function
|
|
60
|
+
function candidateKey(reason, intent) {
|
|
61
|
+
return `${reason}\0${intent}`;
|
|
62
|
+
}
|
|
63
|
+
function readIntentRelationList(commandContract, intent, key) {
|
|
64
|
+
const rawIntent = commandContract.intents[intent];
|
|
65
|
+
if (!isRecord(rawIntent) || !isRecord(rawIntent.relations)) {
|
|
66
|
+
return [];
|
|
67
|
+
}
|
|
68
|
+
return readStringArray(rawIntent.relations, key) ?? [];
|
|
69
|
+
}
|
|
70
|
+
function readIntentSelectionList(commandContract, intent, key) {
|
|
71
|
+
const rawIntent = commandContract.intents[intent];
|
|
72
|
+
if (!isRecord(rawIntent) || !isRecord(rawIntent.selection)) {
|
|
73
|
+
return [];
|
|
74
|
+
}
|
|
75
|
+
return readStringArray(rawIntent.selection, key) ?? [];
|
|
76
|
+
}
|
|
77
|
+
function readIntentCostExpectedSeconds(commandContract, intent) {
|
|
78
|
+
const rawIntent = commandContract.intents[intent];
|
|
79
|
+
if (!isRecord(rawIntent) || !isRecord(rawIntent.cost)) {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
const expectedSeconds = rawIntent.cost.expected_seconds;
|
|
83
|
+
return Number.isInteger(expectedSeconds) && Number(expectedSeconds) >= 0 ? Number(expectedSeconds) : null;
|
|
84
|
+
}
|
|
85
|
+
function intentCoverageSignature(commandContract, intent) {
|
|
86
|
+
const rawIntent = commandContract.intents[intent];
|
|
87
|
+
if (!isRecord(rawIntent) || !isRecord(rawIntent.covers)) {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
const signature = {
|
|
91
|
+
contracts: uniqueSorted(readStringArray(rawIntent.covers, 'contracts') ?? []),
|
|
92
|
+
paths: uniqueSorted(readStringArray(rawIntent.covers, 'paths') ?? []),
|
|
93
|
+
reasons: uniqueSorted(readStringArray(rawIntent.covers, 'reasons') ?? []),
|
|
94
|
+
surfaces: uniqueSorted(readStringArray(rawIntent.covers, 'surfaces') ?? []),
|
|
95
|
+
};
|
|
96
|
+
if (signature.contracts.length === 0 &&
|
|
97
|
+
signature.paths.length === 0 &&
|
|
98
|
+
signature.reasons.length === 0 &&
|
|
99
|
+
signature.surfaces.length === 0) {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
return JSON.stringify(signature);
|
|
103
|
+
}
|
|
104
|
+
function fallbackIntentsForCandidate(commandContract, candidate) {
|
|
105
|
+
if (candidate.intent.length === 0 || candidate.status === 'runnable') {
|
|
106
|
+
return [];
|
|
107
|
+
}
|
|
108
|
+
return uniqueSorted([
|
|
109
|
+
...readIntentSelectionList(commandContract, candidate.intent, 'fallback_intents'),
|
|
110
|
+
...readIntentSelectionList(commandContract, candidate.intent, 'escalate_to'),
|
|
111
|
+
...readIntentRelationList(commandContract, candidate.intent, 'escalate_to'),
|
|
112
|
+
]);
|
|
113
|
+
}
|
|
114
|
+
function escalationIntentsForCandidate(commandContract, candidate) {
|
|
115
|
+
if (candidate.intent.length === 0) {
|
|
116
|
+
return [];
|
|
117
|
+
}
|
|
118
|
+
return uniqueSorted([
|
|
119
|
+
...readIntentSelectionList(commandContract, candidate.intent, 'escalate_to'),
|
|
120
|
+
...readIntentRelationList(commandContract, candidate.intent, 'escalate_to'),
|
|
121
|
+
]);
|
|
122
|
+
}
|
|
123
|
+
function expandCandidatesWithDeclaredFallbacks(commandContract, candidates) {
|
|
124
|
+
const hasRunnableCandidate = candidates.some((candidate) => candidate.status === 'runnable' && candidate.intent.length > 0);
|
|
125
|
+
if (hasRunnableCandidate) {
|
|
126
|
+
return candidates;
|
|
127
|
+
}
|
|
128
|
+
const existingIntents = new Set(candidates.map((candidate) => candidate.intent).filter((intent) => intent.length > 0));
|
|
129
|
+
const fallbackIntents = uniqueSorted(candidates.flatMap((candidate) => fallbackIntentsForCandidate(commandContract, candidate))).filter((intent) => !existingIntents.has(intent));
|
|
130
|
+
if (fallbackIntents.length === 0) {
|
|
131
|
+
return candidates;
|
|
132
|
+
}
|
|
133
|
+
return [
|
|
134
|
+
...candidates,
|
|
135
|
+
...fallbackIntents.map((intent) => {
|
|
136
|
+
const fallback = classifyVerificationCandidate(intent, commandContract.intents[intent]);
|
|
137
|
+
return {
|
|
138
|
+
...fallback,
|
|
139
|
+
detail: fallback.status === 'runnable' ? 'Declared fallback for unavailable verification intent.' : fallback.detail,
|
|
140
|
+
};
|
|
141
|
+
}),
|
|
142
|
+
];
|
|
143
|
+
}
|
|
144
|
+
function requirementNeedsDeclaredEscalation(requirement) {
|
|
145
|
+
if (requirement.surfaces.length > 1) {
|
|
146
|
+
return true;
|
|
147
|
+
}
|
|
148
|
+
const signalText = [
|
|
149
|
+
requirement.reason,
|
|
150
|
+
...requirement.affectedContracts,
|
|
151
|
+
...requirement.driftChecks,
|
|
152
|
+
...requirement.surfaces,
|
|
153
|
+
].join('\n').toLowerCase();
|
|
154
|
+
return DECLARED_ESCALATION_SIGNALS.some((signal) => signalText.includes(signal.toLowerCase()));
|
|
155
|
+
}
|
|
156
|
+
function expandCandidatesWithDeclaredEscalations(commandContract, requirement, candidates) {
|
|
157
|
+
if (!requirementNeedsDeclaredEscalation(requirement)) {
|
|
158
|
+
return candidates;
|
|
159
|
+
}
|
|
160
|
+
const existingIntents = new Set(candidates.map((candidate) => candidate.intent).filter((intent) => intent.length > 0));
|
|
161
|
+
const escalationIntents = uniqueSorted(candidates.flatMap((candidate) => escalationIntentsForCandidate(commandContract, candidate))).filter((intent) => !existingIntents.has(intent));
|
|
162
|
+
if (escalationIntents.length === 0) {
|
|
163
|
+
return candidates;
|
|
164
|
+
}
|
|
165
|
+
return [
|
|
166
|
+
...candidates,
|
|
167
|
+
...escalationIntents.map((intent) => {
|
|
168
|
+
const escalation = classifyVerificationCandidate(intent, commandContract.intents[intent]);
|
|
169
|
+
return {
|
|
170
|
+
...escalation,
|
|
171
|
+
detail: escalation.status === 'runnable'
|
|
172
|
+
? 'Declared escalation for a high-risk verification requirement.'
|
|
173
|
+
: escalation.detail,
|
|
174
|
+
};
|
|
175
|
+
}),
|
|
176
|
+
];
|
|
177
|
+
}
|
|
178
|
+
function intentExplicitlySubsumes(commandContract, broaderIntent, narrowerIntent) {
|
|
179
|
+
return readIntentRelationList(commandContract, broaderIntent, 'subsumes').includes(narrowerIntent);
|
|
180
|
+
}
|
|
181
|
+
function intentExplicitlySubsumedBy(commandContract, narrowerIntent, broaderIntent) {
|
|
182
|
+
return readIntentRelationList(commandContract, narrowerIntent, 'subsumed_by').includes(broaderIntent);
|
|
183
|
+
}
|
|
184
|
+
function intentRequiresCompanion(commandContract, intent) {
|
|
185
|
+
return readIntentRelationList(commandContract, intent, 'requires_with').length > 0;
|
|
186
|
+
}
|
|
187
|
+
function intentIsExplicitlySubsumed(commandContract, narrowerIntent, broaderIntent) {
|
|
188
|
+
return (intentExplicitlySubsumedBy(commandContract, narrowerIntent, broaderIntent) ||
|
|
189
|
+
intentExplicitlySubsumes(commandContract, broaderIntent, narrowerIntent));
|
|
190
|
+
}
|
|
191
|
+
function selectVerificationCandidates(commandContract, candidates) {
|
|
192
|
+
const runnableCandidates = candidates.filter((candidate) => candidate.status === 'runnable' && candidate.intent.length > 0);
|
|
193
|
+
const selectedIntents = new Set(runnableCandidates.map((candidate) => candidate.intent));
|
|
194
|
+
for (const candidate of runnableCandidates) {
|
|
195
|
+
const isSubsumed = runnableCandidates.some((other) => other.intent !== candidate.intent && intentIsExplicitlySubsumed(commandContract, candidate.intent, other.intent));
|
|
196
|
+
if (isSubsumed) {
|
|
197
|
+
selectedIntents.delete(candidate.intent);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
const costComparableCandidates = runnableCandidates.filter((candidate) => selectedIntents.has(candidate.intent) &&
|
|
201
|
+
!intentRequiresCompanion(commandContract, candidate.intent) &&
|
|
202
|
+
intentCoverageSignature(commandContract, candidate.intent) !== null &&
|
|
203
|
+
readIntentCostExpectedSeconds(commandContract, candidate.intent) !== null);
|
|
204
|
+
const costComparableGroups = new Map();
|
|
205
|
+
for (const candidate of costComparableCandidates) {
|
|
206
|
+
const signature = intentCoverageSignature(commandContract, candidate.intent);
|
|
207
|
+
if (signature === null) {
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
costComparableGroups.set(signature, [...(costComparableGroups.get(signature) ?? []), candidate]);
|
|
211
|
+
}
|
|
212
|
+
for (const group of costComparableGroups.values()) {
|
|
213
|
+
if (group.length < 2) {
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
const costs = group.map((candidate) => readIntentCostExpectedSeconds(commandContract, candidate.intent));
|
|
217
|
+
if (costs.some((cost) => cost === null)) {
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
const minCost = Math.min(...costs);
|
|
221
|
+
const winners = group.filter((candidate) => readIntentCostExpectedSeconds(commandContract, candidate.intent) === minCost);
|
|
222
|
+
if (winners.length !== 1) {
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
for (const candidate of group) {
|
|
226
|
+
if (candidate.intent !== winners[0]?.intent) {
|
|
227
|
+
selectedIntents.delete(candidate.intent);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
if (selectedIntents.size === 0 && runnableCandidates.length > 0) {
|
|
232
|
+
return runnableCandidates;
|
|
233
|
+
}
|
|
234
|
+
return runnableCandidates.filter((candidate) => selectedIntents.has(candidate.intent));
|
|
235
|
+
}
|
|
236
|
+
function uniqueVerificationCandidates(candidates) {
|
|
237
|
+
const byIntent = new Map();
|
|
238
|
+
for (const candidate of candidates) {
|
|
239
|
+
if (candidate.intent.length === 0 || byIntent.has(candidate.intent)) {
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
byIntent.set(candidate.intent, candidate);
|
|
243
|
+
}
|
|
244
|
+
return [...byIntent.values()].sort((left, right) => left.intent.localeCompare(right.intent));
|
|
245
|
+
}
|
|
246
|
+
function toChangeVerificationCandidate(reason, candidate, selectedCandidateKeys) {
|
|
247
|
+
const hasIntent = candidate.intent.length > 0;
|
|
248
|
+
const isMissingIntent = !hasIntent || candidate.reason === 'no_matching_intents';
|
|
249
|
+
const isEligible = hasIntent && candidate.status === 'runnable';
|
|
44
250
|
return {
|
|
45
251
|
reason,
|
|
46
252
|
intent: candidate.intent.length > 0 ? candidate.intent : null,
|
|
47
253
|
status: candidate.status,
|
|
48
254
|
skipReason: candidate.reason,
|
|
49
255
|
detail: candidate.detail,
|
|
256
|
+
candidateState: isMissingIntent ? 'gap' : 'candidate',
|
|
257
|
+
eligibilityState: isMissingIntent ? 'missing' : isEligible ? 'eligible' : 'ineligible',
|
|
258
|
+
selectionState: isEligible
|
|
259
|
+
? selectedCandidateKeys.has(candidateKey(reason, candidate.intent))
|
|
260
|
+
? 'selected'
|
|
261
|
+
: 'not_selected'
|
|
262
|
+
: 'not_applicable',
|
|
50
263
|
};
|
|
51
264
|
}
|
|
52
265
|
function gapForRequirement(requirement, candidates) {
|
|
@@ -62,17 +275,37 @@ function gapForRequirement(requirement, candidates) {
|
|
|
62
275
|
};
|
|
63
276
|
}
|
|
64
277
|
export function createChangeVerificationReport(classificationReport, commandContract, projectRoot) {
|
|
278
|
+
const testSelectionPlan = createProjectTestSelectionPlan(projectRoot, classificationReport, commandContract);
|
|
65
279
|
const requirements = classificationReport.summary.validationReasons.map((reason) => createVerificationRequirement(classificationReport, reason));
|
|
66
280
|
const plans = requirements.map((requirement) => ({
|
|
67
281
|
requirement,
|
|
68
|
-
candidates: createVerificationPlan(commandContract, requirement.reason).candidates,
|
|
282
|
+
candidates: expandCandidatesWithDeclaredEscalations(commandContract, requirement, expandCandidatesWithDeclaredFallbacks(commandContract, createVerificationPlan(commandContract, requirement.reason).candidates)),
|
|
283
|
+
}));
|
|
284
|
+
const plansWithProjectTestSelection = plans.map((plan) => {
|
|
285
|
+
const additionalCandidates = testSelectionPlan.candidates
|
|
286
|
+
.filter((candidate) => candidate.reason === plan.requirement.reason)
|
|
287
|
+
.filter((candidate) => !plan.candidates.some((existing) => existing.intent === candidate.candidate.intent))
|
|
288
|
+
.map((candidate) => candidate.candidate);
|
|
289
|
+
return additionalCandidates.length > 0
|
|
290
|
+
? { ...plan, candidates: [...plan.candidates, ...additionalCandidates] }
|
|
291
|
+
: plan;
|
|
292
|
+
});
|
|
293
|
+
const selectedPlans = plansWithProjectTestSelection.map((plan) => ({
|
|
294
|
+
...plan,
|
|
295
|
+
selectedCandidates: uniqueVerificationCandidates([
|
|
296
|
+
...selectVerificationCandidates(commandContract, plan.candidates),
|
|
297
|
+
...testSelectionPlan.selectedCandidates
|
|
298
|
+
.filter((candidate) => candidate.reason === plan.requirement.reason)
|
|
299
|
+
.map((candidate) => candidate.candidate),
|
|
300
|
+
]),
|
|
69
301
|
}));
|
|
70
|
-
const
|
|
71
|
-
const
|
|
302
|
+
const selectedCandidatePlans = selectedPlans.flatMap((plan) => plan.selectedCandidates);
|
|
303
|
+
const selectedCandidateKeys = new Set(selectedPlans.flatMap((plan) => plan.selectedCandidates.map((candidate) => candidateKey(plan.requirement.reason, candidate.intent))));
|
|
304
|
+
const schedule = createVerificationSchedule(projectRoot, commandContract, selectedCandidatePlans);
|
|
305
|
+
const candidates = plansWithProjectTestSelection.flatMap((plan) => plan.candidates.map((candidate) => toChangeVerificationCandidate(plan.requirement.reason, candidate, selectedCandidateKeys)));
|
|
72
306
|
const gaps = requirements
|
|
73
307
|
.map((requirement) => gapForRequirement(requirement, candidates))
|
|
74
308
|
.filter((gap) => gap !== null);
|
|
75
|
-
const schedule = createVerificationSchedule(projectRoot, commandContract, candidatePlans);
|
|
76
309
|
return {
|
|
77
310
|
schema_version: CHANGE_VERIFICATION_SCHEMA_VERSION,
|
|
78
311
|
source: classificationReport.source,
|
|
@@ -83,5 +316,6 @@ export function createChangeVerificationReport(classificationReport, commandCont
|
|
|
83
316
|
gaps,
|
|
84
317
|
schedule,
|
|
85
318
|
decision_graph: createVerificationDecisionGraph(commandContract, requirements, candidates, gaps, schedule),
|
|
319
|
+
test_selection: testSelectionPlan.report,
|
|
86
320
|
};
|
|
87
321
|
}
|
|
@@ -24,11 +24,17 @@ const CHECK_ISSUE_ID_RULES = [
|
|
|
24
24
|
['mustflow.contract_model.command_authority_field', /^Strict: \.mustflow\/config\/(?:changes|surfaces)\.toml .+ cannot define command authority; use \.mustflow\/config\/commands\.toml$/u],
|
|
25
25
|
['mustflow.contract_model.invalid_match_kind', /^Strict: \.mustflow\/config\/(?:changes|surfaces)\.toml rules\[\d+\]\.match\.kind must be "exact", "prefix", or "glob"; regular expressions are deferred$/u],
|
|
26
26
|
['mustflow.contract_model.invalid_shape', /^Strict: \.mustflow\/config\/(?:changes|surfaces)\.toml .+(?:must be|must define|is not allowed)/u],
|
|
27
|
+
['mustflow.test_selection.command_authority_field', /^Strict: \.mustflow\/config\/test-selection\.toml .+ cannot define command authority; use \.mustflow\/config\/commands\.toml$/u],
|
|
28
|
+
['mustflow.test_selection.unknown_command_intent', /^Strict: \.mustflow\/config\/test-selection\.toml .+ references unknown command intent "[^"]+"$/u],
|
|
29
|
+
['mustflow.test_selection.invalid_shape', /^Strict: \.mustflow\/config\/test-selection\.toml .+(?:must be|must define|is not allowed|references command intent "[^"]+" that is not configured)/u],
|
|
27
30
|
['mustflow.skill.procedure_only', /^Strict: \.mustflow\/skills\/[^/]+\/SKILL\.md metadata\.mustflow_kind must be "procedure"$/u],
|
|
28
31
|
['mustflow.skill.raw_command_block', /^Strict: \.mustflow\/skills\/[^/]+\/SKILL\.md contains a raw shell command block; reference command intents instead$/u],
|
|
29
32
|
['mustflow.skill.command_permission_claim', /^Strict: \.mustflow\/skills\/[^/]+\/SKILL\.md claims command execution permission; keep permissions in \.mustflow\/config\/commands\.toml$/u],
|
|
30
33
|
['mustflow.skill.unknown_command_intent', /^Strict: \.mustflow\/skills\/[^/]+\/SKILL\.md metadata\.command_intents references unknown command intent "[^"]+"$/u],
|
|
31
34
|
['mustflow.skill.index_route_unknown_command_intent', /^Strict: \.mustflow\/skills\/INDEX\.md route \.mustflow\/skills\/[^/]+\/SKILL\.md references command intent "[^"]+" not declared by the skill frontmatter$/u],
|
|
35
|
+
['mustflow.skill.index_route_broad_catch_all', /^Strict warning: \.mustflow\/skills\/INDEX\.md \.mustflow\/skills\/[^/]+\/SKILL\.md route uses broad catch-all trigger ".+" that can shadow narrower skills$/u],
|
|
36
|
+
['mustflow.skill.index_route_identical_trigger', /^Strict warning: \.mustflow\/skills\/INDEX\.md \.mustflow\/skills\/[^/]+\/SKILL\.md and \.mustflow\/skills\/[^/]+\/SKILL\.md have identical skill route trigger text$/u],
|
|
37
|
+
['mustflow.skill.index_route_duplicate_surface', /^Strict warning: \.mustflow\/skills\/INDEX\.md \.mustflow\/skills\/[^/]+\/SKILL\.md and \.mustflow\/skills\/[^/]+\/SKILL\.md have duplicate edit scope, risk, and expected output route surface$/u],
|
|
32
38
|
['mustflow.skill.resource_unknown_command_intent', /^Strict: \.mustflow\/skills\/[^/]+\/resources\.toml script [^\s]+ references unknown command intent "[^"]+"$/u],
|
|
33
39
|
['mustflow.source_anchor.invalid_format', /^Strict: source anchor .+ has invalid format:/u],
|
|
34
40
|
['mustflow.source_anchor.duplicate_id', /^Strict: source anchor id "[^"]+" is duplicated:/u],
|
|
@@ -120,6 +120,25 @@ function validateCommandIntentEffects(intentName, intent, issues) {
|
|
|
120
120
|
}
|
|
121
121
|
}
|
|
122
122
|
}
|
|
123
|
+
function validateCommandIntentSelection(intentName, intent, issues) {
|
|
124
|
+
if (!hasOwn(intent, 'selection')) {
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
if (!isRecord(intent.selection)) {
|
|
128
|
+
issues.push(commandContractIssue(`[commands.intents.${intentName}.selection] must be a TOML table`));
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
const selection = intent.selection;
|
|
132
|
+
validateStringField(selection, 'coverage_level', `[commands.intents.${intentName}.selection].coverage_level`, issues);
|
|
133
|
+
validateStringField(selection, 'coverage_confidence', `[commands.intents.${intentName}.selection].coverage_confidence`, issues);
|
|
134
|
+
validateStringField(selection, 'accepts_changed_files', `[commands.intents.${intentName}.selection].accepts_changed_files`, issues);
|
|
135
|
+
validateStringArrayField(selection, 'fallback_intents', `[commands.intents.${intentName}.selection].fallback_intents`, issues);
|
|
136
|
+
validateStringArrayField(selection, 'escalate_to', `[commands.intents.${intentName}.selection].escalate_to`, issues);
|
|
137
|
+
validateBooleanField(selection, 'accepts_test_targets', `[commands.intents.${intentName}.selection].accepts_test_targets`, issues);
|
|
138
|
+
if (selection.accepts_test_targets === true && intent.status === 'configured' && !Array.isArray(intent.argv)) {
|
|
139
|
+
issues.push(commandContractIssue(`[commands.intents.${intentName}.selection].accepts_test_targets requires argv command mode`));
|
|
140
|
+
}
|
|
141
|
+
}
|
|
123
142
|
function validateCommandIntent(intentName, intent, issues) {
|
|
124
143
|
if (!commandIntentNameIsSafe(intentName)) {
|
|
125
144
|
issues.push(commandContractIssue(`Intent ${intentName} name must contain only letters, numbers, underscores, and hyphens`));
|
|
@@ -129,6 +148,7 @@ function validateCommandIntent(intentName, intent, issues) {
|
|
|
129
148
|
validateAllowedStringField(intent, 'run_policy', `[commands.intents.${intentName}].run_policy`, COMMAND_RUN_POLICIES, issues);
|
|
130
149
|
validateAllowedStringField(intent, 'env_policy', `[commands.intents.${intentName}].env_policy`, COMMAND_ENV_POLICIES, issues);
|
|
131
150
|
validateStringArrayField(intent, 'env_allowlist', `[commands.intents.${intentName}].env_allowlist`, issues);
|
|
151
|
+
validateCommandIntentSelection(intentName, intent, issues);
|
|
132
152
|
if (intent.status !== 'configured') {
|
|
133
153
|
return;
|
|
134
154
|
}
|
|
@@ -70,6 +70,19 @@ function normalizeDeclaredEffect(projectRoot, commandContract, intentName, inten
|
|
|
70
70
|
if (!lock && paths.length === 0) {
|
|
71
71
|
throw new Error(`Command effect for intent ${intentName} must define path, paths, or lock`);
|
|
72
72
|
}
|
|
73
|
+
if (paths.length === 0) {
|
|
74
|
+
return [
|
|
75
|
+
{
|
|
76
|
+
intent: intentName,
|
|
77
|
+
source: 'effects',
|
|
78
|
+
access,
|
|
79
|
+
mode,
|
|
80
|
+
path: null,
|
|
81
|
+
lock: lock,
|
|
82
|
+
concurrency,
|
|
83
|
+
},
|
|
84
|
+
];
|
|
85
|
+
}
|
|
73
86
|
return paths.map((rawPath) => {
|
|
74
87
|
const normalizedPath = validateEffectPath(projectRoot, intent, rawPath);
|
|
75
88
|
return {
|
|
@@ -1,8 +1,11 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
1
3
|
import { COMMAND_LIFECYCLES, COMMAND_RUN_POLICIES, LONG_RUNNING_LIFECYCLES, isRecord, readPositiveInteger, readString, readStringArray, } from './config-loading.js';
|
|
2
|
-
import { evaluateCommandIntentEligibility } from './command-intent-eligibility.js';
|
|
4
|
+
import { evaluateCommandIntentEligibility, } from './command-intent-eligibility.js';
|
|
3
5
|
import { commandIntentHasBlockedShellBackgroundPattern, commandIntentHasCommandSource, commandIntentNameIsSafe, } from './command-contract-rules.js';
|
|
4
6
|
import { commandEffectsConflict, normalizeCommandEffects } from './command-effects.js';
|
|
5
7
|
import { listChangeClassificationValidationReasons } from './change-classification.js';
|
|
8
|
+
import { parseSkillIndexRoutes } from './skill-route-alignment.js';
|
|
6
9
|
const CONTRACT_LINT_SOURCE_FILES = [
|
|
7
10
|
'.mustflow/config/commands.toml',
|
|
8
11
|
'.mustflow/docs/agent-workflow.md',
|
|
@@ -41,9 +44,17 @@ const RELEASE_SENSITIVE_REASONS = new Set([
|
|
|
41
44
|
'release_risk',
|
|
42
45
|
'template_version_change',
|
|
43
46
|
]);
|
|
47
|
+
const COMMANDS_CONFIG_PATH = '.mustflow/config/commands.toml';
|
|
48
|
+
const SKILL_INDEX_PATH = '.mustflow/skills/INDEX.md';
|
|
49
|
+
const CHANGE_CLASSIFICATION_SOURCE_PATH = 'src/core/change-classification.ts';
|
|
50
|
+
const AGENT_WORKFLOW_PATH = '.mustflow/docs/agent-workflow.md';
|
|
44
51
|
function uniqueSorted(values) {
|
|
45
52
|
return [...new Set(values)].sort((left, right) => left.localeCompare(right));
|
|
46
53
|
}
|
|
54
|
+
function intersectSorted(left, right) {
|
|
55
|
+
const rightSet = new Set(right);
|
|
56
|
+
return uniqueSorted(left.filter((value) => rightSet.has(value)));
|
|
57
|
+
}
|
|
47
58
|
function readBoolean(intent, key) {
|
|
48
59
|
const value = intent[key];
|
|
49
60
|
return typeof value === 'boolean' ? value : null;
|
|
@@ -151,6 +162,87 @@ function collectRequiredAfterReasons(contract) {
|
|
|
151
162
|
}
|
|
152
163
|
return reasonToIntents;
|
|
153
164
|
}
|
|
165
|
+
function readSkillPathsByIntent(projectRoot) {
|
|
166
|
+
const skillPathsByIntent = new Map();
|
|
167
|
+
if (!projectRoot) {
|
|
168
|
+
return skillPathsByIntent;
|
|
169
|
+
}
|
|
170
|
+
const skillIndexPath = path.join(projectRoot, ...SKILL_INDEX_PATH.split('/'));
|
|
171
|
+
if (!existsSync(skillIndexPath)) {
|
|
172
|
+
return skillPathsByIntent;
|
|
173
|
+
}
|
|
174
|
+
const routes = parseSkillIndexRoutes(readFileSync(skillIndexPath, 'utf8'));
|
|
175
|
+
for (const route of routes) {
|
|
176
|
+
for (const intent of route.commandIntents) {
|
|
177
|
+
skillPathsByIntent.set(intent, [...(skillPathsByIntent.get(intent) ?? []), route.skillPath]);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return skillPathsByIntent;
|
|
181
|
+
}
|
|
182
|
+
function classifyReasonSource(reason, knownClassificationReasons, documentedVerificationReasons) {
|
|
183
|
+
if (knownClassificationReasons.includes(reason)) {
|
|
184
|
+
return 'classification';
|
|
185
|
+
}
|
|
186
|
+
if (documentedVerificationReasons.includes(reason)) {
|
|
187
|
+
return 'documented';
|
|
188
|
+
}
|
|
189
|
+
return 'required_after';
|
|
190
|
+
}
|
|
191
|
+
function buildRelatedDocs(source, relatedSkills) {
|
|
192
|
+
const docs = [COMMANDS_CONFIG_PATH];
|
|
193
|
+
if (source === 'classification') {
|
|
194
|
+
docs.push(CHANGE_CLASSIFICATION_SOURCE_PATH);
|
|
195
|
+
}
|
|
196
|
+
if (source === 'documented') {
|
|
197
|
+
docs.push(AGENT_WORKFLOW_PATH);
|
|
198
|
+
}
|
|
199
|
+
if (relatedSkills.length > 0) {
|
|
200
|
+
docs.push(SKILL_INDEX_PATH);
|
|
201
|
+
}
|
|
202
|
+
return uniqueSorted(docs);
|
|
203
|
+
}
|
|
204
|
+
function buildCoverageMatrix(reasonToIntents, knownClassificationReasons, documentedVerificationReasons, skillPathsByIntent) {
|
|
205
|
+
const matrixReasons = uniqueSorted([
|
|
206
|
+
...knownClassificationReasons,
|
|
207
|
+
...documentedVerificationReasons,
|
|
208
|
+
...reasonToIntents.keys(),
|
|
209
|
+
]);
|
|
210
|
+
return matrixReasons.map((reason) => {
|
|
211
|
+
const candidates = [...(reasonToIntents.get(reason) ?? [])].sort((left, right) => left.name.localeCompare(right.name));
|
|
212
|
+
const source = classifyReasonSource(reason, knownClassificationReasons, documentedVerificationReasons);
|
|
213
|
+
const intentNames = candidates.map((candidate) => candidate.name);
|
|
214
|
+
const relatedSkills = uniqueSorted(intentNames.flatMap((intent) => skillPathsByIntent.get(intent) ?? []));
|
|
215
|
+
const gaps = [];
|
|
216
|
+
if (candidates.length === 0) {
|
|
217
|
+
gaps.push('missing_required_after');
|
|
218
|
+
}
|
|
219
|
+
else if (!candidates.some((candidate) => candidate.runnable)) {
|
|
220
|
+
gaps.push('no_runnable_intent');
|
|
221
|
+
}
|
|
222
|
+
if (source === 'required_after') {
|
|
223
|
+
gaps.push('unknown_reason');
|
|
224
|
+
}
|
|
225
|
+
if (intentNames.length > 0 && intersectSorted(intentNames, [...skillPathsByIntent.keys()]).length === 0) {
|
|
226
|
+
gaps.push('no_related_skill_route');
|
|
227
|
+
}
|
|
228
|
+
return {
|
|
229
|
+
reason,
|
|
230
|
+
source,
|
|
231
|
+
intents: candidates.map((candidate) => {
|
|
232
|
+
const eligibility = evaluateCommandIntentEligibility(candidate.name, candidate.intent);
|
|
233
|
+
return {
|
|
234
|
+
intent: candidate.name,
|
|
235
|
+
status: eligibility.code,
|
|
236
|
+
runnable: eligibility.ok,
|
|
237
|
+
detail: eligibility.detail,
|
|
238
|
+
};
|
|
239
|
+
}),
|
|
240
|
+
gaps: uniqueSorted(gaps),
|
|
241
|
+
relatedSkills,
|
|
242
|
+
relatedDocs: buildRelatedDocs(source, relatedSkills),
|
|
243
|
+
};
|
|
244
|
+
});
|
|
245
|
+
}
|
|
154
246
|
function hasExplicitEffectMetadata(intent) {
|
|
155
247
|
return Array.isArray(intent.effects) && intent.effects.some((effect) => isRecord(effect));
|
|
156
248
|
}
|
|
@@ -200,6 +292,7 @@ function lintCoverage(contract, options, issues) {
|
|
|
200
292
|
const documentedVerificationReasons = [...DOCUMENTED_VERIFICATION_REASONS];
|
|
201
293
|
const knownReasons = new Set([...knownClassificationReasons, ...documentedVerificationReasons]);
|
|
202
294
|
const reasonToIntents = collectRequiredAfterReasons(contract);
|
|
295
|
+
const skillPathsByIntent = readSkillPathsByIntent(options.projectRoot);
|
|
203
296
|
const requiredAfterReasons = uniqueSorted(reasonToIntents.keys());
|
|
204
297
|
const runnableReasons = uniqueSorted([...reasonToIntents.entries()]
|
|
205
298
|
.filter(([, candidates]) => candidates.some((candidate) => candidate.runnable))
|
|
@@ -236,6 +329,7 @@ function lintCoverage(contract, options, issues) {
|
|
|
236
329
|
documentedVerificationReasons,
|
|
237
330
|
requiredAfterReasons,
|
|
238
331
|
runnableReasons,
|
|
332
|
+
matrix: buildCoverageMatrix(reasonToIntents, knownClassificationReasons, documentedVerificationReasons, skillPathsByIntent),
|
|
239
333
|
findings,
|
|
240
334
|
};
|
|
241
335
|
}
|
|
@@ -68,6 +68,11 @@ function createEmptyDashboardVerificationSnapshot(changedFiles) {
|
|
|
68
68
|
skipped: [],
|
|
69
69
|
schedule: {
|
|
70
70
|
runner: 'serial_mf_run_receipts',
|
|
71
|
+
failurePolicy: {
|
|
72
|
+
mode: 'batch_boundary',
|
|
73
|
+
startedBatch: 'wait_for_completion',
|
|
74
|
+
nextBatch: 'stop_on_failure',
|
|
75
|
+
},
|
|
71
76
|
batches: [],
|
|
72
77
|
entries: [],
|
|
73
78
|
notes: [],
|
|
@@ -104,6 +109,7 @@ export function createDashboardVerificationSnapshot(projectRoot, rawCommandContr
|
|
|
104
109
|
skipped,
|
|
105
110
|
schedule: {
|
|
106
111
|
runner: verificationReport.schedule.runner,
|
|
112
|
+
failurePolicy: verificationReport.schedule.failurePolicy,
|
|
107
113
|
batches: verificationReport.schedule.batches.map((batch) => ({
|
|
108
114
|
index: batch.index,
|
|
109
115
|
intents: batch.intents,
|
|
@@ -113,6 +119,8 @@ export function createDashboardVerificationSnapshot(projectRoot, rawCommandContr
|
|
|
113
119
|
entries: verificationReport.schedule.entries.map((entry) => ({
|
|
114
120
|
intent: entry.intent,
|
|
115
121
|
command: `mf run ${entry.intent}`,
|
|
122
|
+
parallelEligible: entry.parallelEligible,
|
|
123
|
+
parallelReason: entry.parallelReason,
|
|
116
124
|
locks: entry.locks,
|
|
117
125
|
effects: entry.effects.map((effect) => ({
|
|
118
126
|
access: effect.access,
|
|
@@ -46,6 +46,13 @@ const PUBLIC_JSON_SCHEMA_CONTRACTS = [
|
|
|
46
46
|
documented: true,
|
|
47
47
|
installedCommand: ['mf', 'contract-lint', '--json'],
|
|
48
48
|
},
|
|
49
|
+
{
|
|
50
|
+
id: 'dashboard-export',
|
|
51
|
+
schemaFile: 'dashboard-export.schema.json',
|
|
52
|
+
producer: 'mf dashboard --export-json <path>',
|
|
53
|
+
packaged: true,
|
|
54
|
+
documented: true,
|
|
55
|
+
},
|
|
49
56
|
{
|
|
50
57
|
id: 'classify-report',
|
|
51
58
|
schemaFile: 'classify-report.schema.json',
|