mustflow 2.23.0 → 2.24.2
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/adapters.js +11 -9
- package/dist/cli/commands/api.js +263 -113
- package/dist/cli/commands/check.js +11 -7
- package/dist/cli/commands/classify.js +16 -42
- package/dist/cli/commands/context.js +18 -31
- package/dist/cli/commands/contract-lint.js +12 -7
- package/dist/cli/commands/dashboard.js +65 -114
- package/dist/cli/commands/docs.js +43 -26
- package/dist/cli/commands/doctor.js +11 -7
- package/dist/cli/commands/evidence.js +642 -0
- package/dist/cli/commands/explain-verify.js +1 -59
- package/dist/cli/commands/explain.js +84 -36
- package/dist/cli/commands/handoff.js +13 -17
- package/dist/cli/commands/impact.js +14 -20
- package/dist/cli/commands/index.js +15 -9
- package/dist/cli/commands/init.js +56 -70
- package/dist/cli/commands/line-endings.js +15 -9
- package/dist/cli/commands/map.js +30 -42
- package/dist/cli/commands/next.js +300 -0
- package/dist/cli/commands/onboard.js +136 -0
- package/dist/cli/commands/run.js +47 -42
- package/dist/cli/commands/search.js +43 -69
- package/dist/cli/commands/status.js +9 -6
- package/dist/cli/commands/update.js +16 -10
- package/dist/cli/commands/upgrade.js +9 -6
- package/dist/cli/commands/verify/args.js +55 -249
- package/dist/cli/commands/verify.js +2 -1
- package/dist/cli/commands/version-sources.js +9 -6
- package/dist/cli/commands/version.js +9 -6
- package/dist/cli/commands/workspace.js +564 -0
- package/dist/cli/i18n/en.js +60 -1
- package/dist/cli/i18n/es.js +60 -1
- package/dist/cli/i18n/fr.js +60 -1
- package/dist/cli/i18n/hi.js +60 -1
- package/dist/cli/i18n/ko.js +60 -1
- package/dist/cli/i18n/zh.js +60 -1
- package/dist/cli/index.js +28 -25
- package/dist/cli/lib/agent-context.js +8 -9
- package/dist/cli/lib/command-registry.js +24 -0
- package/dist/cli/lib/dashboard-html/client-script.js +1 -1
- package/dist/cli/lib/local-index/database-path.js +5 -0
- package/dist/cli/lib/local-index/database-read.js +88 -0
- package/dist/cli/lib/local-index/effect-graph-read-model.js +112 -0
- package/dist/cli/lib/local-index/freshness.js +60 -0
- package/dist/cli/lib/local-index/index.js +12 -1866
- package/dist/cli/lib/local-index/path-surface-read-model.js +134 -0
- package/dist/cli/lib/local-index/populate.js +474 -0
- package/dist/cli/lib/local-index/schema.js +413 -0
- package/dist/cli/lib/local-index/search-read-model.js +533 -0
- package/dist/cli/lib/local-index/search-text.js +79 -0
- package/dist/cli/lib/option-parser.js +93 -0
- package/dist/cli/lib/repo-map.js +2 -2
- package/dist/cli/lib/run-plan.js +5 -22
- package/dist/core/change-verification.js +11 -5
- package/dist/core/command-effects.js +1 -3
- package/dist/core/command-intent-eligibility.js +14 -0
- package/dist/core/command-preconditions.js +8 -4
- package/dist/core/command-run-constraints.js +43 -0
- package/dist/core/public-json-contracts.js +57 -0
- package/dist/core/test-selection.js +8 -2
- package/dist/core/verification-plan.js +32 -4
- package/package.json +1 -1
- package/schemas/README.md +16 -0
- package/schemas/api-serve-response.schema.json +89 -0
- package/schemas/change-verification-report.schema.json +4 -1
- package/schemas/contract-lint-report.schema.json +1 -0
- package/schemas/evidence-report.schema.json +287 -0
- package/schemas/explain-report.schema.json +4 -0
- package/schemas/next-report.schema.json +121 -0
- package/schemas/onboard-commands-report.schema.json +100 -0
- package/schemas/workspace-command-catalog.schema.json +172 -0
- package/schemas/workspace-status.schema.json +141 -0
- package/schemas/workspace-verification-plan.schema.json +195 -0
- package/templates/default/manifest.toml +1 -1
|
@@ -0,0 +1,642 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { createClassifyOutput } from './classify.js';
|
|
3
|
+
import { createChangeVerificationReport, } from '../../core/change-verification.js';
|
|
4
|
+
import { readUtf8FileInsideWithoutSymlinks, writeJsonFileInsideWithoutSymlinks } from '../../core/safe-filesystem.js';
|
|
5
|
+
import { createVerificationPlanId } from '../../core/verification-plan-id.js';
|
|
6
|
+
import { isRecord, readCommandContract, readString, } from '../lib/command-contract.js';
|
|
7
|
+
import { printUsageError, renderHelp } from '../lib/cli-output.js';
|
|
8
|
+
import { t } from '../lib/i18n.js';
|
|
9
|
+
import { getParsedCliStringOption, hasCliOptionToken, hasParsedCliOption, parseCliOptions, } from '../lib/option-parser.js';
|
|
10
|
+
import { resolveMustflowRoot } from '../lib/project-root.js';
|
|
11
|
+
const EVIDENCE_SCHEMA_VERSION = '1';
|
|
12
|
+
const COMMAND_AUTHORITY = '.mustflow/config/commands.toml';
|
|
13
|
+
const LATEST_RUN_RELATIVE_PATH = '.mustflow/state/runs/latest.json';
|
|
14
|
+
const MAX_JSON_BYTES = 1024 * 1024;
|
|
15
|
+
const EVIDENCE_OPTIONS = [
|
|
16
|
+
{ name: '--json', kind: 'boolean' },
|
|
17
|
+
{ name: '--changed', kind: 'boolean' },
|
|
18
|
+
{ name: '--latest', kind: 'boolean' },
|
|
19
|
+
{ name: '--plan', kind: 'string' },
|
|
20
|
+
{ name: '--export', kind: 'string' },
|
|
21
|
+
];
|
|
22
|
+
const EVIDENCE_MISSING_VALUE_ERRORS = new Map([
|
|
23
|
+
['--plan', 'missing_plan_value'],
|
|
24
|
+
['--export', 'missing_export_value'],
|
|
25
|
+
]);
|
|
26
|
+
function getEvidenceHelp(lang = 'en') {
|
|
27
|
+
return renderHelp({
|
|
28
|
+
usage: 'mf evidence [--changed | --latest | --plan <path>] [options]',
|
|
29
|
+
summary: t(lang, 'evidence.help.summary'),
|
|
30
|
+
options: [
|
|
31
|
+
{ label: '--changed', description: t(lang, 'evidence.help.option.changed') },
|
|
32
|
+
{ label: '--latest', description: t(lang, 'evidence.help.option.latest') },
|
|
33
|
+
{ label: '--plan <path>', description: t(lang, 'evidence.help.option.plan') },
|
|
34
|
+
{ label: '--export <path>', description: t(lang, 'evidence.help.option.export') },
|
|
35
|
+
{ label: '--json', description: t(lang, 'cli.option.json') },
|
|
36
|
+
{ label: '-h, --help', description: t(lang, 'cli.option.help') },
|
|
37
|
+
],
|
|
38
|
+
examples: [
|
|
39
|
+
'mf evidence --changed',
|
|
40
|
+
'mf evidence --changed --json',
|
|
41
|
+
'mf evidence --latest --json',
|
|
42
|
+
'mf evidence --plan .mustflow/state/verification-plan.json --json',
|
|
43
|
+
'mf evidence --changed --export .mustflow/state/evidence.json',
|
|
44
|
+
],
|
|
45
|
+
exitCodes: [
|
|
46
|
+
{ label: '0', description: t(lang, 'evidence.help.exit.ok') },
|
|
47
|
+
{ label: '1', description: t(lang, 'evidence.help.exit.fail') },
|
|
48
|
+
],
|
|
49
|
+
}, lang);
|
|
50
|
+
}
|
|
51
|
+
function parseEvidenceArgs(args) {
|
|
52
|
+
const parsed = parseCliOptions(args, EVIDENCE_OPTIONS);
|
|
53
|
+
const json = hasParsedCliOption(parsed, '--json');
|
|
54
|
+
const changed = hasParsedCliOption(parsed, '--changed');
|
|
55
|
+
const latest = hasParsedCliOption(parsed, '--latest');
|
|
56
|
+
const planPath = getParsedCliStringOption(parsed, '--plan');
|
|
57
|
+
const exportPath = getParsedCliStringOption(parsed, '--export');
|
|
58
|
+
if (parsed.error) {
|
|
59
|
+
return { json, mode: 'changed', planPath, exportPath, error: mapEvidenceOptionParseError(parsed.error) };
|
|
60
|
+
}
|
|
61
|
+
const selectedModes = [changed, latest, planPath !== null].filter(Boolean).length;
|
|
62
|
+
if (selectedModes > 1) {
|
|
63
|
+
return { json, mode: 'changed', planPath, exportPath, error: 'conflicting_inputs' };
|
|
64
|
+
}
|
|
65
|
+
return {
|
|
66
|
+
json,
|
|
67
|
+
mode: latest ? 'latest' : planPath ? 'plan' : 'changed',
|
|
68
|
+
planPath,
|
|
69
|
+
exportPath,
|
|
70
|
+
error: null,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
function mapEvidenceOptionParseError(error) {
|
|
74
|
+
if (error.kind === 'missing_value') {
|
|
75
|
+
return EVIDENCE_MISSING_VALUE_ERRORS.get(error.option) ?? error.option;
|
|
76
|
+
}
|
|
77
|
+
return error.option.startsWith('-') ? error.option : `unexpected:${error.option}`;
|
|
78
|
+
}
|
|
79
|
+
function uniqueSorted(values) {
|
|
80
|
+
return [...new Set([...values].filter((value) => typeof value === 'string' && value.length > 0))]
|
|
81
|
+
.sort((left, right) => left.localeCompare(right));
|
|
82
|
+
}
|
|
83
|
+
function recordArray(value) {
|
|
84
|
+
return Array.isArray(value) ? value.filter(isRecord) : [];
|
|
85
|
+
}
|
|
86
|
+
function stringArray(value) {
|
|
87
|
+
return Array.isArray(value) ? uniqueSorted(value.filter((item) => typeof item === 'string')) : [];
|
|
88
|
+
}
|
|
89
|
+
function readStringArrayField(record, key) {
|
|
90
|
+
return stringArray(record[key]);
|
|
91
|
+
}
|
|
92
|
+
function readNumberField(record, key) {
|
|
93
|
+
const value = record[key];
|
|
94
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
|
95
|
+
}
|
|
96
|
+
function readBooleanField(record, key) {
|
|
97
|
+
return typeof record[key] === 'boolean' ? record[key] : null;
|
|
98
|
+
}
|
|
99
|
+
function commandForIntent(intent) {
|
|
100
|
+
return `mf run ${intent}`;
|
|
101
|
+
}
|
|
102
|
+
function toGapReport(gap) {
|
|
103
|
+
const record = isRecord(gap) ? gap : {};
|
|
104
|
+
return {
|
|
105
|
+
reason: readString(record, 'reason') ?? null,
|
|
106
|
+
intent: readString(record, 'intent') ?? null,
|
|
107
|
+
status: readString(record, 'status') ?? null,
|
|
108
|
+
files: readStringArrayField(record, 'files'),
|
|
109
|
+
surfaces: readStringArrayField(record, 'surfaces'),
|
|
110
|
+
detail: readString(record, 'detail') ?? readString(record, 'code') ?? 'Evidence item has no detail.',
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
function candidateIntent(candidate) {
|
|
114
|
+
if ('intent' in candidate && typeof candidate.intent === 'string') {
|
|
115
|
+
return candidate.intent.length > 0 ? candidate.intent : null;
|
|
116
|
+
}
|
|
117
|
+
return isRecord(candidate) ? readString(candidate, 'intent') ?? null : null;
|
|
118
|
+
}
|
|
119
|
+
function candidateReason(candidate) {
|
|
120
|
+
if ('reason' in candidate && typeof candidate.reason === 'string') {
|
|
121
|
+
return candidate.reason;
|
|
122
|
+
}
|
|
123
|
+
return isRecord(candidate) ? readString(candidate, 'reason') ?? null : null;
|
|
124
|
+
}
|
|
125
|
+
function candidateSelectionState(candidate) {
|
|
126
|
+
if ('selectionState' in candidate && typeof candidate.selectionState === 'string') {
|
|
127
|
+
return candidate.selectionState;
|
|
128
|
+
}
|
|
129
|
+
if ('selection_state' in candidate && typeof candidate.selection_state === 'string') {
|
|
130
|
+
return candidate.selection_state;
|
|
131
|
+
}
|
|
132
|
+
return isRecord(candidate) ? readString(candidate, 'selection_state') ?? readString(candidate, 'selectionState') ?? null : null;
|
|
133
|
+
}
|
|
134
|
+
function candidateSkipReason(candidate) {
|
|
135
|
+
if ('skipReason' in candidate && typeof candidate.skipReason === 'string') {
|
|
136
|
+
return candidate.skipReason;
|
|
137
|
+
}
|
|
138
|
+
if ('skip_reason' in candidate && typeof candidate.skip_reason === 'string') {
|
|
139
|
+
return candidate.skip_reason;
|
|
140
|
+
}
|
|
141
|
+
return isRecord(candidate) ? readString(candidate, 'skip_reason') ?? readString(candidate, 'skipReason') ?? null : null;
|
|
142
|
+
}
|
|
143
|
+
function requirementReason(requirement) {
|
|
144
|
+
if ('reason' in requirement && typeof requirement.reason === 'string') {
|
|
145
|
+
return requirement.reason;
|
|
146
|
+
}
|
|
147
|
+
return isRecord(requirement) ? readString(requirement, 'reason') ?? 'unknown' : 'unknown';
|
|
148
|
+
}
|
|
149
|
+
function requirementFiles(requirement) {
|
|
150
|
+
if ('files' in requirement && Array.isArray(requirement.files)) {
|
|
151
|
+
return stringArray(requirement.files);
|
|
152
|
+
}
|
|
153
|
+
return isRecord(requirement) ? readStringArrayField(requirement, 'files') : [];
|
|
154
|
+
}
|
|
155
|
+
function requirementSurfaces(requirement) {
|
|
156
|
+
if ('surfaces' in requirement && Array.isArray(requirement.surfaces)) {
|
|
157
|
+
return stringArray(requirement.surfaces);
|
|
158
|
+
}
|
|
159
|
+
return isRecord(requirement) ? readStringArrayField(requirement, 'surfaces') : [];
|
|
160
|
+
}
|
|
161
|
+
function requirementAffectedContracts(requirement) {
|
|
162
|
+
if ('affectedContracts' in requirement && Array.isArray(requirement.affectedContracts)) {
|
|
163
|
+
return stringArray(requirement.affectedContracts);
|
|
164
|
+
}
|
|
165
|
+
if (isRecord(requirement)) {
|
|
166
|
+
return uniqueSorted([
|
|
167
|
+
...readStringArrayField(requirement, 'affected_contracts'),
|
|
168
|
+
...readStringArrayField(requirement, 'affectedContracts'),
|
|
169
|
+
]);
|
|
170
|
+
}
|
|
171
|
+
return [];
|
|
172
|
+
}
|
|
173
|
+
function latestReceiptPathsForIntents(latest, intents) {
|
|
174
|
+
const selected = new Set(intents);
|
|
175
|
+
return uniqueSorted(latest.receipts
|
|
176
|
+
.filter((receipt) => receipt.intent !== null && selected.has(receipt.intent))
|
|
177
|
+
.map((receipt) => receipt.receipt_path));
|
|
178
|
+
}
|
|
179
|
+
function latestRequirementOutcome(latest, reason) {
|
|
180
|
+
const matching = latest.requirements.find((requirement) => requirement.reason === reason);
|
|
181
|
+
return matching?.outcome ?? null;
|
|
182
|
+
}
|
|
183
|
+
function createRequirementReport(input) {
|
|
184
|
+
const reason = requirementReason(input.requirement);
|
|
185
|
+
const matchingCandidates = input.candidates.filter((candidate) => candidateReason(candidate) === reason);
|
|
186
|
+
const candidateIntents = uniqueSorted(matchingCandidates.map(candidateIntent));
|
|
187
|
+
const selectedIntents = uniqueSorted(matchingCandidates
|
|
188
|
+
.filter((candidate) => candidateSelectionState(candidate) === 'selected')
|
|
189
|
+
.map(candidateIntent));
|
|
190
|
+
const skippedIntents = uniqueSorted(matchingCandidates
|
|
191
|
+
.filter((candidate) => candidateSkipReason(candidate) !== null || candidateSelectionState(candidate) === 'not_selected')
|
|
192
|
+
.map(candidateIntent));
|
|
193
|
+
const matchingGaps = input.gaps.filter((gap) => gap.reason === reason);
|
|
194
|
+
const latestOutcome = input.latest.applies_to_plan ? latestRequirementOutcome(input.latest, reason) : null;
|
|
195
|
+
const status = latestOutcome === 'verified'
|
|
196
|
+
? 'covered_by_latest'
|
|
197
|
+
: matchingGaps.length > 0
|
|
198
|
+
? 'blocked'
|
|
199
|
+
: selectedIntents.length > 0
|
|
200
|
+
? 'selected'
|
|
201
|
+
: 'unselected';
|
|
202
|
+
return {
|
|
203
|
+
reason,
|
|
204
|
+
file_count: requirementFiles(input.requirement).length,
|
|
205
|
+
files: requirementFiles(input.requirement),
|
|
206
|
+
surfaces: requirementSurfaces(input.requirement),
|
|
207
|
+
affected_contracts: requirementAffectedContracts(input.requirement),
|
|
208
|
+
candidate_intents: candidateIntents,
|
|
209
|
+
selected_intents: selectedIntents,
|
|
210
|
+
skipped_intents: skippedIntents,
|
|
211
|
+
status,
|
|
212
|
+
evidence_receipts: latestReceiptPathsForIntents(input.latest, selectedIntents),
|
|
213
|
+
gap_reasons: matchingGaps.map((gap) => gap.detail),
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
function selectedIntentsFromCandidates(candidates) {
|
|
217
|
+
return uniqueSorted(candidates
|
|
218
|
+
.filter((candidate) => candidateSelectionState(candidate) === 'selected')
|
|
219
|
+
.map(candidateIntent));
|
|
220
|
+
}
|
|
221
|
+
function createPlanFromReport(report, contract, classification, latest) {
|
|
222
|
+
const gaps = report.gaps.map(toGapReport);
|
|
223
|
+
const requirements = report.requirements.map((requirement) => createRequirementReport({
|
|
224
|
+
requirement,
|
|
225
|
+
candidates: report.candidates,
|
|
226
|
+
gaps,
|
|
227
|
+
latest,
|
|
228
|
+
}));
|
|
229
|
+
return {
|
|
230
|
+
source: 'changed',
|
|
231
|
+
status: classification.summary.fileCount === 0 ? 'no_changes' : 'available',
|
|
232
|
+
verification_plan_id: createVerificationPlanId(report, contract),
|
|
233
|
+
changed_file_count: classification.summary.fileCount,
|
|
234
|
+
changed_files: classification.files,
|
|
235
|
+
validation_reasons: classification.summary.validationReasons,
|
|
236
|
+
selected_intents: report.schedule.entries.map((entry) => entry.intent),
|
|
237
|
+
requirement_count: requirements.length,
|
|
238
|
+
gap_count: gaps.length,
|
|
239
|
+
requirements,
|
|
240
|
+
gaps,
|
|
241
|
+
test_selection: {
|
|
242
|
+
status: report.test_selection.status,
|
|
243
|
+
candidate_count: report.test_selection.matches.length,
|
|
244
|
+
selected_count: report.test_selection.selected.length,
|
|
245
|
+
note: report.test_selection.notes[0] ?? null,
|
|
246
|
+
},
|
|
247
|
+
issues: [],
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
function createChangedPlan(mustflowRoot, latest) {
|
|
251
|
+
let classification;
|
|
252
|
+
try {
|
|
253
|
+
classification = createClassifyOutput(mustflowRoot, 'changed', []);
|
|
254
|
+
}
|
|
255
|
+
catch (error) {
|
|
256
|
+
return unavailablePlan('changed', error);
|
|
257
|
+
}
|
|
258
|
+
let contract;
|
|
259
|
+
try {
|
|
260
|
+
contract = readCommandContract(mustflowRoot);
|
|
261
|
+
}
|
|
262
|
+
catch (error) {
|
|
263
|
+
return unavailablePlan('changed', error, classification);
|
|
264
|
+
}
|
|
265
|
+
try {
|
|
266
|
+
return createPlanFromReport(createChangeVerificationReport(classification, contract, mustflowRoot), contract, classification, latest);
|
|
267
|
+
}
|
|
268
|
+
catch (error) {
|
|
269
|
+
return unavailablePlan('changed', error, classification);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
function unavailablePlan(source, error, classification = null) {
|
|
273
|
+
return {
|
|
274
|
+
source,
|
|
275
|
+
status: 'unavailable',
|
|
276
|
+
verification_plan_id: null,
|
|
277
|
+
changed_file_count: classification?.summary.fileCount ?? null,
|
|
278
|
+
changed_files: classification?.files ?? [],
|
|
279
|
+
validation_reasons: classification?.summary.validationReasons ?? [],
|
|
280
|
+
selected_intents: [],
|
|
281
|
+
requirement_count: 0,
|
|
282
|
+
gap_count: 0,
|
|
283
|
+
requirements: [],
|
|
284
|
+
gaps: [],
|
|
285
|
+
test_selection: null,
|
|
286
|
+
issues: [error instanceof Error ? error.message : String(error)],
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
function createPlanFromFile(mustflowRoot, planPath, latest) {
|
|
290
|
+
let parsed;
|
|
291
|
+
try {
|
|
292
|
+
parsed = readJsonInsideRoot(mustflowRoot, planPath);
|
|
293
|
+
}
|
|
294
|
+
catch (error) {
|
|
295
|
+
return unavailablePlan('plan_file', error);
|
|
296
|
+
}
|
|
297
|
+
if (!isRecord(parsed)) {
|
|
298
|
+
return unavailablePlan('plan_file', new Error('Plan file must contain a JSON object.'));
|
|
299
|
+
}
|
|
300
|
+
const apiVerificationPlan = readString(parsed, 'command') === 'api verification-plan';
|
|
301
|
+
const requirements = recordArray(parsed.requirements);
|
|
302
|
+
const candidates = recordArray(parsed.candidates);
|
|
303
|
+
const gaps = recordArray(parsed.gaps).map(toGapReport);
|
|
304
|
+
const schedule = isRecord(parsed.schedule) ? parsed.schedule : null;
|
|
305
|
+
const testSelection = isRecord(parsed.test_selection) ? parsed.test_selection : null;
|
|
306
|
+
const classification = isRecord(parsed.classification) ? parsed.classification : null;
|
|
307
|
+
const selectedIntents = schedule
|
|
308
|
+
? uniqueSorted([
|
|
309
|
+
...readStringArrayField(schedule, 'selected_intents'),
|
|
310
|
+
...recordArray(schedule.entries).map((entry) => readString(entry, 'intent')),
|
|
311
|
+
])
|
|
312
|
+
: selectedIntentsFromCandidates(candidates);
|
|
313
|
+
const requirementReports = requirements.map((requirement) => createRequirementReport({
|
|
314
|
+
requirement,
|
|
315
|
+
candidates,
|
|
316
|
+
gaps,
|
|
317
|
+
latest,
|
|
318
|
+
}));
|
|
319
|
+
return {
|
|
320
|
+
source: 'plan_file',
|
|
321
|
+
status: 'available',
|
|
322
|
+
verification_plan_id: readString(parsed, 'verification_plan_id') ?? null,
|
|
323
|
+
changed_file_count: classification ? readNumberField(classification, 'file_count') : null,
|
|
324
|
+
changed_files: classification ? readStringArrayField(classification, 'files') : readStringArrayField(parsed, 'files'),
|
|
325
|
+
validation_reasons: classification ? readStringArrayField(classification, 'validation_reasons') : [],
|
|
326
|
+
selected_intents: selectedIntents,
|
|
327
|
+
requirement_count: requirementReports.length,
|
|
328
|
+
gap_count: gaps.length,
|
|
329
|
+
requirements: requirementReports,
|
|
330
|
+
gaps,
|
|
331
|
+
test_selection: testSelection
|
|
332
|
+
? {
|
|
333
|
+
status: readString(testSelection, 'status') ?? null,
|
|
334
|
+
candidate_count: readNumberField(testSelection, 'candidate_count') ?? readNumberField(testSelection, 'matches_count'),
|
|
335
|
+
selected_count: readNumberField(testSelection, 'selected_count'),
|
|
336
|
+
note: readString(testSelection, 'note') ?? null,
|
|
337
|
+
}
|
|
338
|
+
: null,
|
|
339
|
+
issues: apiVerificationPlan || requirements.length > 0 ? [] : ['Plan file is not a recognized mustflow verification-plan object.'],
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
function readJsonInsideRoot(projectRoot, relativePath) {
|
|
343
|
+
return JSON.parse(readUtf8FileInsideWithoutSymlinks(projectRoot, path.join(projectRoot, ...relativePath.split('/')), {
|
|
344
|
+
maxBytes: MAX_JSON_BYTES,
|
|
345
|
+
}));
|
|
346
|
+
}
|
|
347
|
+
function latestKind(parsed) {
|
|
348
|
+
if (readString(parsed, 'command') === 'run') {
|
|
349
|
+
return 'run_receipt';
|
|
350
|
+
}
|
|
351
|
+
if (readString(parsed, 'command') === 'verify' && readString(parsed, 'kind') === 'verify_run_summary') {
|
|
352
|
+
return 'verify_run_summary';
|
|
353
|
+
}
|
|
354
|
+
return 'unknown';
|
|
355
|
+
}
|
|
356
|
+
function readLatestReceipt(record) {
|
|
357
|
+
return {
|
|
358
|
+
intent: readString(record, 'intent') ?? null,
|
|
359
|
+
status: readString(record, 'status') ?? null,
|
|
360
|
+
skipped: readBooleanField(record, 'skipped') ?? false,
|
|
361
|
+
verification_plan_id: readString(record, 'verification_plan_id') ?? null,
|
|
362
|
+
receipt_path: readString(record, 'receipt_path') ?? null,
|
|
363
|
+
receipt_sha256: readString(record, 'receipt_sha256') ?? null,
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
function readLatestRequirement(record) {
|
|
367
|
+
return {
|
|
368
|
+
reason: readString(record, 'reason') ?? 'unknown',
|
|
369
|
+
outcome: readString(record, 'outcome') ?? null,
|
|
370
|
+
selected_intents: readStringArrayField(record, 'selected_intents'),
|
|
371
|
+
skipped_intents: readStringArrayField(record, 'skipped_intents'),
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
function createLatestReport(mustflowRoot, expectedPlanId, requested) {
|
|
375
|
+
if (!requested) {
|
|
376
|
+
return latestNotRequested();
|
|
377
|
+
}
|
|
378
|
+
let parsed;
|
|
379
|
+
try {
|
|
380
|
+
parsed = readJsonInsideRoot(mustflowRoot, LATEST_RUN_RELATIVE_PATH);
|
|
381
|
+
}
|
|
382
|
+
catch (error) {
|
|
383
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
384
|
+
return {
|
|
385
|
+
...latestEmpty('missing'),
|
|
386
|
+
issues: message.includes('ENOENT') ? [] : [message],
|
|
387
|
+
status: message.includes('ENOENT') ? 'missing' : 'unavailable',
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
if (!isRecord(parsed)) {
|
|
391
|
+
return { ...latestEmpty('unavailable'), exists: true, kind: 'unknown', issues: ['Latest evidence file must contain a JSON object.'] };
|
|
392
|
+
}
|
|
393
|
+
const evidenceModel = isRecord(parsed.evidence_model) ? parsed.evidence_model : null;
|
|
394
|
+
const completionVerdict = isRecord(parsed.completion_verdict) ? parsed.completion_verdict : null;
|
|
395
|
+
const requirements = evidenceModel ? recordArray(evidenceModel.requirements).map(readLatestRequirement) : [];
|
|
396
|
+
const receipts = evidenceModel ? recordArray(evidenceModel.receipts).map(readLatestReceipt) : [];
|
|
397
|
+
const skippedChecks = evidenceModel ? recordArray(evidenceModel.skipped_checks).map(toGapReport) : [];
|
|
398
|
+
const remainingRisks = evidenceModel ? recordArray(evidenceModel.remaining_risks).map(toGapReport) : [];
|
|
399
|
+
const latestPlanId = readString(parsed, 'verification_plan_id') ?? (evidenceModel ? readString(evidenceModel, 'verification_plan_id') : null) ?? null;
|
|
400
|
+
const latest = {
|
|
401
|
+
...latestEmpty('available'),
|
|
402
|
+
exists: true,
|
|
403
|
+
kind: latestKind(parsed),
|
|
404
|
+
verification_plan_id: latestPlanId,
|
|
405
|
+
applies_to_plan: expectedPlanId ? latestPlanId === expectedPlanId : null,
|
|
406
|
+
completion_verdict_status: completionVerdict ? readString(completionVerdict, 'status') ?? null : null,
|
|
407
|
+
execution_status: readString(parsed, 'execution_status') ?? readString(parsed, 'status') ?? null,
|
|
408
|
+
receipt_count: receipts.length,
|
|
409
|
+
skipped_check_count: skippedChecks.length,
|
|
410
|
+
remaining_risk_count: remainingRisks.length,
|
|
411
|
+
requirements,
|
|
412
|
+
receipts,
|
|
413
|
+
skipped_checks: skippedChecks,
|
|
414
|
+
remaining_risks: remainingRisks,
|
|
415
|
+
};
|
|
416
|
+
return latest;
|
|
417
|
+
}
|
|
418
|
+
function latestEmpty(status) {
|
|
419
|
+
return {
|
|
420
|
+
status,
|
|
421
|
+
exists: false,
|
|
422
|
+
path: LATEST_RUN_RELATIVE_PATH,
|
|
423
|
+
kind: null,
|
|
424
|
+
verification_plan_id: null,
|
|
425
|
+
applies_to_plan: null,
|
|
426
|
+
completion_verdict_status: null,
|
|
427
|
+
execution_status: null,
|
|
428
|
+
receipt_count: null,
|
|
429
|
+
skipped_check_count: 0,
|
|
430
|
+
remaining_risk_count: 0,
|
|
431
|
+
requirements: [],
|
|
432
|
+
receipts: [],
|
|
433
|
+
skipped_checks: [],
|
|
434
|
+
remaining_risks: [],
|
|
435
|
+
issues: [],
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
function latestNotRequested() {
|
|
439
|
+
return latestEmpty('not_requested');
|
|
440
|
+
}
|
|
441
|
+
function createPolicy(writesFiles) {
|
|
442
|
+
return {
|
|
443
|
+
mode: 'read_only_report',
|
|
444
|
+
command_authority: COMMAND_AUTHORITY,
|
|
445
|
+
direct_commands_allowed: false,
|
|
446
|
+
executes_commands: false,
|
|
447
|
+
grants_command_authority: false,
|
|
448
|
+
raw_output_included: false,
|
|
449
|
+
hidden_reasoning_included: false,
|
|
450
|
+
writes_files: writesFiles,
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
function createCoverage(plan, latest) {
|
|
454
|
+
const requirements = plan?.requirements ?? [];
|
|
455
|
+
return {
|
|
456
|
+
requirement_count: requirements.length,
|
|
457
|
+
covered_by_latest_count: requirements.filter((requirement) => requirement.status === 'covered_by_latest').length,
|
|
458
|
+
selected_requirement_count: requirements.filter((requirement) => requirement.status === 'selected').length,
|
|
459
|
+
blocked_requirement_count: requirements.filter((requirement) => requirement.status === 'blocked').length,
|
|
460
|
+
unselected_requirement_count: requirements.filter((requirement) => requirement.status === 'unselected').length,
|
|
461
|
+
selected_intent_count: plan?.selected_intents.length ?? 0,
|
|
462
|
+
receipt_count: latest.receipts.length,
|
|
463
|
+
skipped_check_count: latest.skipped_check_count,
|
|
464
|
+
remaining_risk_count: latest.remaining_risk_count,
|
|
465
|
+
gap_count: plan?.gap_count ?? 0,
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
function alignPlanWithLatest(plan, latest) {
|
|
469
|
+
if (!latest.applies_to_plan) {
|
|
470
|
+
return plan;
|
|
471
|
+
}
|
|
472
|
+
return {
|
|
473
|
+
...plan,
|
|
474
|
+
requirements: plan.requirements.map((requirement) => {
|
|
475
|
+
const latestOutcome = latestRequirementOutcome(latest, requirement.reason);
|
|
476
|
+
return {
|
|
477
|
+
...requirement,
|
|
478
|
+
status: latestOutcome === 'verified' ? 'covered_by_latest' : requirement.status,
|
|
479
|
+
evidence_receipts: latestReceiptPathsForIntents(latest, requirement.selected_intents),
|
|
480
|
+
};
|
|
481
|
+
}),
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
function outputStatus(plan, latest) {
|
|
485
|
+
if (!plan) {
|
|
486
|
+
return latest.status === 'available' ? 'latest_only' : 'unavailable';
|
|
487
|
+
}
|
|
488
|
+
if (plan.status === 'unavailable') {
|
|
489
|
+
return 'unavailable';
|
|
490
|
+
}
|
|
491
|
+
if (plan.status === 'no_changes') {
|
|
492
|
+
return 'no_changes';
|
|
493
|
+
}
|
|
494
|
+
if (latest.applies_to_plan && latest.completion_verdict_status === 'verified') {
|
|
495
|
+
return 'verified';
|
|
496
|
+
}
|
|
497
|
+
if (latest.applies_to_plan && latest.completion_verdict_status && latest.completion_verdict_status !== 'verified') {
|
|
498
|
+
return 'unresolved';
|
|
499
|
+
}
|
|
500
|
+
if (plan.gap_count > 0) {
|
|
501
|
+
return 'gaps';
|
|
502
|
+
}
|
|
503
|
+
return plan.selected_intents.length > 0 ? 'needs_verification' : 'unresolved';
|
|
504
|
+
}
|
|
505
|
+
function recommendedCommands(plan, latest) {
|
|
506
|
+
if (!plan) {
|
|
507
|
+
return latest.status === 'available' ? ['mf evidence --latest --json'] : ['mf verify --changed --json'];
|
|
508
|
+
}
|
|
509
|
+
if (plan.status === 'unavailable') {
|
|
510
|
+
return ['mf doctor --json', 'mf api verification-plan --changed --json'];
|
|
511
|
+
}
|
|
512
|
+
if (plan.status === 'no_changes') {
|
|
513
|
+
return ['mf doctor --json'];
|
|
514
|
+
}
|
|
515
|
+
if (plan.gap_count > 0) {
|
|
516
|
+
return ['mf onboard commands', 'mf api verification-plan --changed --json'];
|
|
517
|
+
}
|
|
518
|
+
if (latest.applies_to_plan) {
|
|
519
|
+
return ['mf api latest-evidence --json'];
|
|
520
|
+
}
|
|
521
|
+
if (plan.selected_intents.length > 0) {
|
|
522
|
+
return ['mf verify --changed --json', ...plan.selected_intents.map(commandForIntent)];
|
|
523
|
+
}
|
|
524
|
+
return ['mf api verification-plan --changed --json'];
|
|
525
|
+
}
|
|
526
|
+
function createEvidenceOutput(args) {
|
|
527
|
+
const mustflowRoot = resolveMustflowRoot();
|
|
528
|
+
const initialLatest = createLatestReport(mustflowRoot, null, args.mode !== 'plan');
|
|
529
|
+
const initialPlan = args.mode === 'latest'
|
|
530
|
+
? null
|
|
531
|
+
: args.mode === 'plan' && args.planPath
|
|
532
|
+
? createPlanFromFile(mustflowRoot, args.planPath, initialLatest)
|
|
533
|
+
: createChangedPlan(mustflowRoot, initialLatest);
|
|
534
|
+
const latest = args.mode === 'latest'
|
|
535
|
+
? initialLatest
|
|
536
|
+
: createLatestReport(mustflowRoot, initialPlan?.verification_plan_id ?? null, true);
|
|
537
|
+
const plan = initialPlan ? alignPlanWithLatest(initialPlan, latest) : null;
|
|
538
|
+
const issues = [...(plan?.issues ?? []), ...latest.issues];
|
|
539
|
+
return {
|
|
540
|
+
schema_version: EVIDENCE_SCHEMA_VERSION,
|
|
541
|
+
command: 'evidence',
|
|
542
|
+
mustflow_root: mustflowRoot,
|
|
543
|
+
status: outputStatus(plan, latest),
|
|
544
|
+
source: {
|
|
545
|
+
mode: args.mode,
|
|
546
|
+
changed: args.mode === 'changed',
|
|
547
|
+
plan_path: args.planPath,
|
|
548
|
+
latest_path: LATEST_RUN_RELATIVE_PATH,
|
|
549
|
+
},
|
|
550
|
+
policy: createPolicy(args.exportPath !== null),
|
|
551
|
+
plan,
|
|
552
|
+
latest,
|
|
553
|
+
coverage: createCoverage(plan, latest),
|
|
554
|
+
recommended_commands: recommendedCommands(plan, latest),
|
|
555
|
+
export_path: args.exportPath,
|
|
556
|
+
issues,
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
function renderEvidence(output, lang) {
|
|
560
|
+
const lines = [
|
|
561
|
+
t(lang, 'evidence.title'),
|
|
562
|
+
`${t(lang, 'label.mustflowRoot')}: ${output.mustflow_root}`,
|
|
563
|
+
`${t(lang, 'evidence.label.status')}: ${output.status}`,
|
|
564
|
+
`${t(lang, 'evidence.label.source')}: ${output.source.mode}`,
|
|
565
|
+
`${t(lang, 'evidence.label.plan')}: ${output.plan?.verification_plan_id ?? t(lang, 'value.none')}`,
|
|
566
|
+
`${t(lang, 'evidence.label.requirements')}: ${output.coverage.requirement_count}`,
|
|
567
|
+
`${t(lang, 'evidence.label.selectedIntents')}: ${output.plan?.selected_intents.join(', ') || t(lang, 'value.none')}`,
|
|
568
|
+
`${t(lang, 'evidence.label.gaps')}: ${output.coverage.gap_count}`,
|
|
569
|
+
`${t(lang, 'evidence.label.latest')}: ${output.latest.status}`,
|
|
570
|
+
];
|
|
571
|
+
if (output.plan && output.plan.requirements.length > 0) {
|
|
572
|
+
lines.push('', t(lang, 'evidence.section.requirements'));
|
|
573
|
+
for (const requirement of output.plan.requirements) {
|
|
574
|
+
const intents = requirement.selected_intents.length > 0 ? requirement.selected_intents.join(', ') : t(lang, 'value.none');
|
|
575
|
+
lines.push(`- ${requirement.reason}: ${requirement.status} (${intents})`);
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
if (output.plan && output.plan.gaps.length > 0) {
|
|
579
|
+
lines.push('', t(lang, 'evidence.section.gaps'));
|
|
580
|
+
for (const gap of output.plan.gaps) {
|
|
581
|
+
lines.push(`- ${gap.reason ?? t(lang, 'value.none')}: ${gap.detail}`);
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
if (output.latest.remaining_risks.length > 0) {
|
|
585
|
+
lines.push('', t(lang, 'evidence.section.remainingRisks'));
|
|
586
|
+
for (const risk of output.latest.remaining_risks) {
|
|
587
|
+
lines.push(`- ${risk.status ?? risk.reason ?? t(lang, 'value.none')}: ${risk.detail}`);
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
lines.push('', t(lang, 'next.section.commands'));
|
|
591
|
+
for (const command of output.recommended_commands) {
|
|
592
|
+
lines.push(`- ${command}`);
|
|
593
|
+
}
|
|
594
|
+
return lines.join('\n');
|
|
595
|
+
}
|
|
596
|
+
function evidenceErrorMessage(error, lang) {
|
|
597
|
+
if (error === 'missing_plan_value') {
|
|
598
|
+
return t(lang, 'cli.error.missingValue', { option: '--plan' });
|
|
599
|
+
}
|
|
600
|
+
if (error === 'missing_export_value') {
|
|
601
|
+
return t(lang, 'cli.error.missingValue', { option: '--export' });
|
|
602
|
+
}
|
|
603
|
+
if (error === 'conflicting_inputs') {
|
|
604
|
+
return t(lang, 'evidence.error.conflictingInputs');
|
|
605
|
+
}
|
|
606
|
+
if (error.startsWith('unexpected:')) {
|
|
607
|
+
return t(lang, 'cli.error.unexpectedArgument', { argument: error.slice('unexpected:'.length) });
|
|
608
|
+
}
|
|
609
|
+
return t(lang, 'cli.error.unknownOption', { option: error });
|
|
610
|
+
}
|
|
611
|
+
export function runEvidence(args, reporter, lang = 'en') {
|
|
612
|
+
if (hasCliOptionToken(args, '--help', ['-h'])) {
|
|
613
|
+
reporter.stdout(getEvidenceHelp(lang));
|
|
614
|
+
return 0;
|
|
615
|
+
}
|
|
616
|
+
const parsed = parseEvidenceArgs(args);
|
|
617
|
+
if (parsed.error) {
|
|
618
|
+
printUsageError(reporter, evidenceErrorMessage(parsed.error, lang), 'mf evidence --help', getEvidenceHelp(lang), lang);
|
|
619
|
+
return 1;
|
|
620
|
+
}
|
|
621
|
+
let output;
|
|
622
|
+
try {
|
|
623
|
+
output = createEvidenceOutput(parsed);
|
|
624
|
+
if (parsed.exportPath) {
|
|
625
|
+
const exportPath = path.isAbsolute(parsed.exportPath)
|
|
626
|
+
? parsed.exportPath
|
|
627
|
+
: path.join(output.mustflow_root, parsed.exportPath);
|
|
628
|
+
writeJsonFileInsideWithoutSymlinks(output.mustflow_root, exportPath, output);
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
catch (error) {
|
|
632
|
+
printUsageError(reporter, error instanceof Error ? error.message : String(error), 'mf evidence --help', getEvidenceHelp(lang), lang);
|
|
633
|
+
return 1;
|
|
634
|
+
}
|
|
635
|
+
if (parsed.json) {
|
|
636
|
+
reporter.stdout(JSON.stringify(output, null, 2));
|
|
637
|
+
}
|
|
638
|
+
else {
|
|
639
|
+
reporter.stdout(renderEvidence(output, lang));
|
|
640
|
+
}
|
|
641
|
+
return output.status === 'unavailable' ? 1 : 0;
|
|
642
|
+
}
|