mustflow 1.30.0 → 2.11.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 +35 -11
- package/dist/cli/commands/classify.js +61 -6
- package/dist/cli/commands/contract-lint.js +13 -4
- package/dist/cli/commands/dashboard.js +6 -0
- package/dist/cli/commands/index.js +5 -0
- package/dist/cli/commands/run.js +224 -48
- package/dist/cli/commands/upgrade.js +65 -0
- package/dist/cli/commands/verify.js +550 -33
- package/dist/cli/i18n/en.js +73 -10
- package/dist/cli/i18n/es.js +73 -10
- package/dist/cli/i18n/fr.js +73 -10
- package/dist/cli/i18n/hi.js +73 -10
- package/dist/cli/i18n/ko.js +73 -10
- package/dist/cli/i18n/zh.js +73 -10
- package/dist/cli/index.js +27 -46
- package/dist/cli/lib/command-registry.js +5 -0
- package/dist/cli/lib/dashboard-export.js +62 -12
- package/dist/cli/lib/dashboard-html/client-script.js +1936 -0
- package/dist/cli/lib/dashboard-html/locale-bootstrap.js +8 -0
- package/dist/cli/lib/dashboard-html/styles.js +572 -0
- package/dist/cli/lib/dashboard-html/template.js +134 -0
- package/dist/cli/lib/dashboard-html/types.js +1 -0
- package/dist/cli/lib/dashboard-html.js +1 -1907
- package/dist/cli/lib/dashboard-locale.js +37 -0
- package/dist/cli/lib/local-index/constants.js +48 -0
- package/dist/cli/lib/local-index/index.js +2256 -0
- package/dist/cli/lib/local-index/sql.js +15 -0
- package/dist/cli/lib/local-index/types.js +1 -0
- package/dist/cli/lib/local-index.js +1 -1908
- package/dist/cli/lib/reporter.js +6 -0
- package/dist/cli/lib/run-plan.js +96 -4
- package/dist/cli/lib/templates.js +18 -1
- package/dist/cli/lib/validation/command-intents.js +11 -0
- package/dist/cli/lib/validation/constants.js +238 -0
- package/dist/cli/lib/validation/index.js +1384 -0
- package/dist/cli/lib/validation/primitives.js +198 -0
- package/dist/cli/lib/validation/test-selection.js +95 -0
- package/dist/cli/lib/validation/types.js +1 -0
- package/dist/cli/lib/validation.js +1 -1661
- 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 +12 -0
- package/dist/core/command-contract-validation.js +20 -0
- package/dist/core/command-effects.js +13 -0
- package/dist/core/completion-verdict.js +209 -0
- package/dist/core/contract-lint.js +316 -7
- package/dist/core/dashboard-verification.js +8 -0
- package/dist/core/external-evidence.js +9 -0
- package/dist/core/public-json-contracts.js +28 -0
- package/dist/core/repeated-failure.js +17 -0
- package/dist/core/repro-evidence.js +53 -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/scope-risk.js +64 -0
- package/dist/core/skill-route-alignment.js +110 -0
- package/dist/core/source-anchor-status.js +4 -1
- package/dist/core/test-selection.js +227 -0
- package/dist/core/validation-ratchet.js +52 -0
- package/dist/core/verification-decision-graph.js +67 -0
- package/dist/core/verification-evidence.js +249 -0
- package/dist/core/verification-scheduler.js +96 -2
- package/examples/README.md +12 -4
- package/package.json +1 -1
- package/schemas/README.md +18 -4
- package/schemas/change-verification-report.schema.json +169 -5
- package/schemas/commands.schema.json +51 -1
- package/schemas/contract-lint-report.schema.json +80 -0
- package/schemas/dashboard-export.schema.json +500 -0
- package/schemas/explain-report.schema.json +2 -0
- package/schemas/latest-run-pointer.schema.json +384 -0
- package/schemas/run-receipt.schema.json +113 -0
- package/schemas/test-selection.schema.json +81 -0
- package/schemas/verify-report.schema.json +361 -1
- package/schemas/verify-run-manifest.schema.json +410 -0
- package/templates/default/common/.mustflow/config/commands.toml +1 -1
- package/templates/default/i18n.toml +1 -1
- package/templates/default/locales/en/.mustflow/skills/INDEX.md +124 -29
- package/templates/default/locales/en/.mustflow/skills/routes.toml +289 -0
- package/templates/default/manifest.toml +29 -2
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
export const SCOPE_DIFF_BUDGET_DEFAULTS = {
|
|
2
|
+
maxChangedFiles: 8,
|
|
3
|
+
maxPublicSurfaces: 4,
|
|
4
|
+
maxChangeKindFamilies: 3,
|
|
5
|
+
maxPathsPerRisk: 8,
|
|
6
|
+
};
|
|
7
|
+
const CHANGE_KIND_FAMILY_BY_KIND = {
|
|
8
|
+
documentation: 'documentation',
|
|
9
|
+
example: 'documentation',
|
|
10
|
+
translation: 'documentation',
|
|
11
|
+
workflow: 'workflow',
|
|
12
|
+
host_instruction: 'workflow',
|
|
13
|
+
installed_template: 'template',
|
|
14
|
+
package_metadata: 'release',
|
|
15
|
+
schema: 'contract',
|
|
16
|
+
test: 'test',
|
|
17
|
+
test_fixture: 'test',
|
|
18
|
+
implementation: 'implementation',
|
|
19
|
+
unknown: 'unknown',
|
|
20
|
+
};
|
|
21
|
+
function uniqueSorted(values) {
|
|
22
|
+
return [...new Set(values)].sort((left, right) => left.localeCompare(right));
|
|
23
|
+
}
|
|
24
|
+
function changeKindFamily(kind) {
|
|
25
|
+
return CHANGE_KIND_FAMILY_BY_KIND[kind] ?? kind;
|
|
26
|
+
}
|
|
27
|
+
function firstPaths(paths) {
|
|
28
|
+
return paths.slice(0, SCOPE_DIFF_BUDGET_DEFAULTS.maxPathsPerRisk);
|
|
29
|
+
}
|
|
30
|
+
export function createScopeDiffRisks(report) {
|
|
31
|
+
const risks = [];
|
|
32
|
+
if (report.summary.fileCount > SCOPE_DIFF_BUDGET_DEFAULTS.maxChangedFiles) {
|
|
33
|
+
risks.push({
|
|
34
|
+
code: 'diff_budget_exceeded',
|
|
35
|
+
severity: 'high',
|
|
36
|
+
detail: `Changed file count ${report.summary.fileCount} exceeds the conservative completion budget of ${SCOPE_DIFF_BUDGET_DEFAULTS.maxChangedFiles}.`,
|
|
37
|
+
count: report.summary.fileCount,
|
|
38
|
+
limit: SCOPE_DIFF_BUDGET_DEFAULTS.maxChangedFiles,
|
|
39
|
+
paths: firstPaths(report.files),
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
if (report.summary.publicSurfaceCount > SCOPE_DIFF_BUDGET_DEFAULTS.maxPublicSurfaces) {
|
|
43
|
+
risks.push({
|
|
44
|
+
code: 'public_surface_budget_exceeded',
|
|
45
|
+
severity: 'high',
|
|
46
|
+
detail: `Public surface count ${report.summary.publicSurfaceCount} exceeds the conservative completion budget of ${SCOPE_DIFF_BUDGET_DEFAULTS.maxPublicSurfaces}.`,
|
|
47
|
+
count: report.summary.publicSurfaceCount,
|
|
48
|
+
limit: SCOPE_DIFF_BUDGET_DEFAULTS.maxPublicSurfaces,
|
|
49
|
+
paths: firstPaths(report.classifications.filter((classification) => classification.surface.isPublicSurface).map((classification) => classification.path)),
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
const changeKindFamilies = uniqueSorted(report.summary.changeKinds.map(changeKindFamily));
|
|
53
|
+
if (changeKindFamilies.length > SCOPE_DIFF_BUDGET_DEFAULTS.maxChangeKindFamilies) {
|
|
54
|
+
risks.push({
|
|
55
|
+
code: 'mixed_change_kind_budget_exceeded',
|
|
56
|
+
severity: 'medium',
|
|
57
|
+
detail: `Change kind family count ${changeKindFamilies.length} exceeds the conservative completion budget of ${SCOPE_DIFF_BUDGET_DEFAULTS.maxChangeKindFamilies}: ${changeKindFamilies.join(', ')}.`,
|
|
58
|
+
count: changeKindFamilies.length,
|
|
59
|
+
limit: SCOPE_DIFF_BUDGET_DEFAULTS.maxChangeKindFamilies,
|
|
60
|
+
paths: firstPaths(report.files),
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
return risks;
|
|
64
|
+
}
|
|
@@ -1,14 +1,38 @@
|
|
|
1
1
|
const SKILL_ROUTE_SOURCE_FILES = [
|
|
2
2
|
'.mustflow/skills/INDEX.md',
|
|
3
|
+
'.mustflow/skills/routes.toml',
|
|
3
4
|
'.mustflow/skills/*/SKILL.md',
|
|
4
5
|
'.mustflow/config/commands.toml',
|
|
5
6
|
'.mustflow/docs/agent-workflow.md',
|
|
6
7
|
];
|
|
7
8
|
const MARKDOWN_TABLE_SEPARATOR_PATTERN = /^:?-{3,}:?$/u;
|
|
9
|
+
const BROAD_CATCH_ALL_TRIGGERS = new Set([
|
|
10
|
+
'any request',
|
|
11
|
+
'any task',
|
|
12
|
+
'any change',
|
|
13
|
+
'all requests',
|
|
14
|
+
'all tasks',
|
|
15
|
+
'all changes',
|
|
16
|
+
'every request',
|
|
17
|
+
'every task',
|
|
18
|
+
'everything',
|
|
19
|
+
]);
|
|
8
20
|
export const SKILL_INDEX_ROUTE_COLUMN_COUNT = 7;
|
|
9
21
|
export const SKILL_INDEX_SKILL_PATH_COLUMN_INDEX = 1;
|
|
10
22
|
export const SKILL_INDEX_VERIFICATION_INTENTS_COLUMN_INDEX = 5;
|
|
11
23
|
export const SKILL_INDEX_ROUTE_COLUMNS = 'Trigger, Skill Document, Required Input, Edit Scope, Risk, Verification Intents, Expected Output';
|
|
24
|
+
export const SKILL_ROUTE_CATEGORY_LABELS = {
|
|
25
|
+
bug_failure: 'Bug and Failure',
|
|
26
|
+
general_code: 'General Code Change',
|
|
27
|
+
tests: 'Tests and Regression',
|
|
28
|
+
docs_release: 'Documentation and Release',
|
|
29
|
+
security_privacy: 'Security and Privacy',
|
|
30
|
+
data_external: 'Data and External Systems',
|
|
31
|
+
ui_assets: 'UI and Assets',
|
|
32
|
+
architecture_patterns: 'Architecture Patterns',
|
|
33
|
+
workflow_contracts: 'Workflow and Contract Maintenance',
|
|
34
|
+
};
|
|
35
|
+
const SKILL_ROUTE_CATEGORY_BY_HEADING = new Map(Object.entries(SKILL_ROUTE_CATEGORY_LABELS).map(([category, label]) => [label, category]));
|
|
12
36
|
function splitMarkdownTableRow(line) {
|
|
13
37
|
return line
|
|
14
38
|
.trim()
|
|
@@ -28,7 +52,13 @@ export function findSkillIndexRoutePathColumn(cells) {
|
|
|
28
52
|
}
|
|
29
53
|
export function parseSkillIndexRoutes(content) {
|
|
30
54
|
const routes = [];
|
|
55
|
+
let currentCategory;
|
|
31
56
|
for (const line of content.split(/\r?\n/u)) {
|
|
57
|
+
const categoryHeading = /^###\s+(.+?)\s*$/u.exec(line.trim())?.[1];
|
|
58
|
+
if (categoryHeading) {
|
|
59
|
+
currentCategory = SKILL_ROUTE_CATEGORY_BY_HEADING.get(categoryHeading);
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
32
62
|
if (!line.trim().startsWith('|')) {
|
|
33
63
|
continue;
|
|
34
64
|
}
|
|
@@ -52,15 +82,95 @@ export function parseSkillIndexRoutes(content) {
|
|
|
52
82
|
risk: cells[4] ?? '',
|
|
53
83
|
commandIntents: readBacktickValues(cells[SKILL_INDEX_VERIFICATION_INTENTS_COLUMN_INDEX] ?? ''),
|
|
54
84
|
expectedOutput: cells[6] ?? '',
|
|
85
|
+
category: currentCategory,
|
|
55
86
|
});
|
|
56
87
|
}
|
|
57
88
|
return routes;
|
|
58
89
|
}
|
|
90
|
+
function normalizeRouteText(value) {
|
|
91
|
+
return value
|
|
92
|
+
.toLowerCase()
|
|
93
|
+
.replace(/`[^`]+`/gu, ' ')
|
|
94
|
+
.replace(/<[^>]+>/gu, ' ')
|
|
95
|
+
.replace(/[^a-z0-9]+/gu, ' ')
|
|
96
|
+
.trim()
|
|
97
|
+
.replace(/\s+/gu, ' ');
|
|
98
|
+
}
|
|
99
|
+
function collectRoutePairs(routes) {
|
|
100
|
+
const pairs = [];
|
|
101
|
+
for (let leftIndex = 0; leftIndex < routes.length; leftIndex += 1) {
|
|
102
|
+
for (let rightIndex = leftIndex + 1; rightIndex < routes.length; rightIndex += 1) {
|
|
103
|
+
const left = routes[leftIndex];
|
|
104
|
+
const right = routes[rightIndex];
|
|
105
|
+
if (left.skillPath !== right.skillPath) {
|
|
106
|
+
pairs.push({ left, right });
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return pairs;
|
|
111
|
+
}
|
|
112
|
+
function routePairLabel(pair) {
|
|
113
|
+
return `${pair.left.skillPath} and ${pair.right.skillPath}`;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* mf:anchor core.skill-route-conflict-lint
|
|
117
|
+
* purpose: Keep skill routing warnings deterministic so broad or duplicate route rows are review candidates, not LLM guesses.
|
|
118
|
+
* search: skill route conflict, duplicate trigger, broad catch-all route
|
|
119
|
+
* invariant: Route conflict warnings are heuristic warnings only; missing routes and command-intent drift remain strict errors.
|
|
120
|
+
* risk: config
|
|
121
|
+
*/
|
|
122
|
+
export function findSkillRouteConflictWarnings(routes) {
|
|
123
|
+
const warnings = [];
|
|
124
|
+
const triggerToRoutes = new Map();
|
|
125
|
+
const surfaceToRoutes = new Map();
|
|
126
|
+
for (const route of routes) {
|
|
127
|
+
const trigger = normalizeRouteText(route.trigger);
|
|
128
|
+
const editScope = normalizeRouteText(route.editScope);
|
|
129
|
+
const risk = normalizeRouteText(route.risk);
|
|
130
|
+
const expectedOutput = normalizeRouteText(route.expectedOutput);
|
|
131
|
+
if (BROAD_CATCH_ALL_TRIGGERS.has(trigger)) {
|
|
132
|
+
warnings.push(`${route.skillPath} route uses broad catch-all trigger "${route.trigger}" that can shadow narrower skills`);
|
|
133
|
+
}
|
|
134
|
+
if (trigger) {
|
|
135
|
+
triggerToRoutes.set(trigger, [...(triggerToRoutes.get(trigger) ?? []), route]);
|
|
136
|
+
}
|
|
137
|
+
if (editScope && risk && expectedOutput) {
|
|
138
|
+
const surfaceKey = `${editScope}\n${risk}\n${expectedOutput}`;
|
|
139
|
+
surfaceToRoutes.set(surfaceKey, [...(surfaceToRoutes.get(surfaceKey) ?? []), route]);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
for (const pair of collectRoutePairs([...triggerToRoutes.values()].flatMap((matchingRoutes) => {
|
|
143
|
+
return matchingRoutes.length > 1 ? matchingRoutes : [];
|
|
144
|
+
}))) {
|
|
145
|
+
if (normalizeRouteText(pair.left.trigger) === normalizeRouteText(pair.right.trigger)) {
|
|
146
|
+
warnings.push(`${routePairLabel(pair)} have identical skill route trigger text`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
for (const pair of collectRoutePairs([...surfaceToRoutes.values()].flatMap((matchingRoutes) => {
|
|
150
|
+
return matchingRoutes.length > 1 ? matchingRoutes : [];
|
|
151
|
+
}))) {
|
|
152
|
+
const leftSurface = [
|
|
153
|
+
normalizeRouteText(pair.left.editScope),
|
|
154
|
+
normalizeRouteText(pair.left.risk),
|
|
155
|
+
normalizeRouteText(pair.left.expectedOutput),
|
|
156
|
+
].join('\n');
|
|
157
|
+
const rightSurface = [
|
|
158
|
+
normalizeRouteText(pair.right.editScope),
|
|
159
|
+
normalizeRouteText(pair.right.risk),
|
|
160
|
+
normalizeRouteText(pair.right.expectedOutput),
|
|
161
|
+
].join('\n');
|
|
162
|
+
if (leftSurface === rightSurface) {
|
|
163
|
+
warnings.push(`${routePairLabel(pair)} have duplicate edit scope, risk, and expected output route surface`);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return [...new Set(warnings)].sort((left, right) => left.localeCompare(right));
|
|
167
|
+
}
|
|
59
168
|
function pluralize(count, singular, plural) {
|
|
60
169
|
return count === 1 ? singular : plural;
|
|
61
170
|
}
|
|
62
171
|
export function isSkillRouteAlignmentIssue(issue) {
|
|
63
172
|
return (issue.includes('.mustflow/skills/INDEX.md route') ||
|
|
173
|
+
issue.includes('.mustflow/skills/INDEX.md .mustflow/skills/') ||
|
|
64
174
|
issue.includes('.mustflow/skills/INDEX.md has duplicate route') ||
|
|
65
175
|
issue.endsWith(' is not listed in .mustflow/skills/INDEX.md'));
|
|
66
176
|
}
|
|
@@ -19,6 +19,9 @@ const HIGH_RISK_SOURCE_ANCHOR_TAGS = new Set([
|
|
|
19
19
|
'ssrf',
|
|
20
20
|
'xss',
|
|
21
21
|
]);
|
|
22
|
+
export function hasHighRiskSourceAnchorRiskTags(risk) {
|
|
23
|
+
return risk.some((tag) => HIGH_RISK_SOURCE_ANCHOR_TAGS.has(tag));
|
|
24
|
+
}
|
|
22
25
|
function sha256(value) {
|
|
23
26
|
return `sha256:${createHash('sha256').update(value).digest('hex')}`;
|
|
24
27
|
}
|
|
@@ -62,7 +65,7 @@ function currentAnchorSignals(risk) {
|
|
|
62
65
|
};
|
|
63
66
|
}
|
|
64
67
|
function hasHighRisk(risk) {
|
|
65
|
-
return risk
|
|
68
|
+
return hasHighRiskSourceAnchorRiskTags(risk);
|
|
66
69
|
}
|
|
67
70
|
function sameSymbolIdentity(left, right) {
|
|
68
71
|
return left.kind === right.kind && left.name !== null && left.name === right.name;
|
|
@@ -0,0 +1,227 @@
|
|
|
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
|
+
const STALE_OR_MISSING_RULES_NOTE = 'Project-declared test selection rules did not cover the current changed files; review .mustflow/config/test-selection.toml for stale or missing rules.';
|
|
7
|
+
function uniqueSorted(values) {
|
|
8
|
+
return [...new Set(values)].sort((left, right) => left.localeCompare(right));
|
|
9
|
+
}
|
|
10
|
+
function toPosixPath(value) {
|
|
11
|
+
return value.replace(/\\/g, '/');
|
|
12
|
+
}
|
|
13
|
+
function escapeRegExp(value) {
|
|
14
|
+
return value.replace(/[\\^$+?.()|[\]{}]/g, '\\$&');
|
|
15
|
+
}
|
|
16
|
+
function globToRegExp(pattern) {
|
|
17
|
+
const normalized = toPosixPath(pattern).replace(/^\/+/u, '');
|
|
18
|
+
let source = '^';
|
|
19
|
+
for (let index = 0; index < normalized.length;) {
|
|
20
|
+
if (normalized.startsWith('**', index)) {
|
|
21
|
+
source += '.*';
|
|
22
|
+
index += 2;
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
const char = normalized[index];
|
|
26
|
+
if (char === '*') {
|
|
27
|
+
source += '[^/]*';
|
|
28
|
+
index += 1;
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
source += escapeRegExp(char ?? '');
|
|
32
|
+
index += 1;
|
|
33
|
+
}
|
|
34
|
+
return new RegExp(`${source}$`, 'u');
|
|
35
|
+
}
|
|
36
|
+
function readStringField(table, key) {
|
|
37
|
+
const value = table[key];
|
|
38
|
+
return typeof value === 'string' && value.trim().length > 0 ? value : null;
|
|
39
|
+
}
|
|
40
|
+
function readRule(value) {
|
|
41
|
+
if (!isRecord(value) || !isRecord(value.match) || !isRecord(value.select)) {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
const id = readStringField(value, 'id');
|
|
45
|
+
const risk = readStringField(value, 'risk');
|
|
46
|
+
const reason = readStringField(value, 'reason');
|
|
47
|
+
const paths = readStringArray(value.match, 'paths');
|
|
48
|
+
const surfaces = readStringArray(value.match, 'surfaces');
|
|
49
|
+
const intent = readStringField(value.select, 'intent');
|
|
50
|
+
const fallbackIntent = readStringField(value.select, 'fallback_intent');
|
|
51
|
+
const testTargets = readStringArray(value.select, 'test_targets') ?? [];
|
|
52
|
+
if (!id || !risk || !reason || !paths || paths.length === 0 || !surfaces || surfaces.length === 0 || !intent || !fallbackIntent) {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
id,
|
|
57
|
+
risk,
|
|
58
|
+
reason,
|
|
59
|
+
paths: uniqueSorted(paths),
|
|
60
|
+
surfaces: uniqueSorted(surfaces),
|
|
61
|
+
intent,
|
|
62
|
+
fallbackIntent,
|
|
63
|
+
testTargets: uniqueSorted(testTargets),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
function readRules(projectRoot) {
|
|
67
|
+
const configPath = resolveMustflowConfigPath(projectRoot, TEST_SELECTION_CONFIG_RELATIVE_PATH);
|
|
68
|
+
if (!existsSync(configPath)) {
|
|
69
|
+
return {
|
|
70
|
+
status: 'missing',
|
|
71
|
+
rules: [],
|
|
72
|
+
note: 'No project-declared test selection manifest exists; mustflow will not infer a user-project test subset.',
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
try {
|
|
76
|
+
const parsed = readTomlFile(configPath);
|
|
77
|
+
if (!isRecord(parsed) || parsed.schema_version !== '1' || !Array.isArray(parsed.rules)) {
|
|
78
|
+
return {
|
|
79
|
+
status: 'invalid',
|
|
80
|
+
rules: [],
|
|
81
|
+
note: 'Project-declared test selection manifest is invalid; run mf check --strict before relying on it.',
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
const rules = parsed.rules.map(readRule);
|
|
85
|
+
if (!rules.every((rule) => rule !== null)) {
|
|
86
|
+
return {
|
|
87
|
+
status: 'invalid',
|
|
88
|
+
rules: [],
|
|
89
|
+
note: 'Project-declared test selection manifest has invalid rule shapes; run mf check --strict before relying on it.',
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
return { status: 'loaded', rules };
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return {
|
|
96
|
+
status: 'invalid',
|
|
97
|
+
rules: [],
|
|
98
|
+
note: 'Project-declared test selection manifest cannot be read; run mf check --strict before relying on it.',
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
function ruleMatchesClassification(rule, classification) {
|
|
103
|
+
const pathMatches = rule.paths.some((pattern) => globToRegExp(pattern).test(toPosixPath(classification.path)));
|
|
104
|
+
const surfaceMatches = rule.surfaces.includes(classification.surface.kind);
|
|
105
|
+
return pathMatches && surfaceMatches;
|
|
106
|
+
}
|
|
107
|
+
function matchingClassifications(rule, report) {
|
|
108
|
+
return report.classifications.filter((classification) => ruleMatchesClassification(rule, classification));
|
|
109
|
+
}
|
|
110
|
+
function selectReason(classifications) {
|
|
111
|
+
return (classifications.flatMap((classification) => classification.surface.validationReasons)[0] ??
|
|
112
|
+
'unknown_change');
|
|
113
|
+
}
|
|
114
|
+
function withDetail(candidate, detail) {
|
|
115
|
+
return {
|
|
116
|
+
...candidate,
|
|
117
|
+
detail: candidate.detail ?? detail,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
function commandAcceptsTestTargets(commandContract, intent) {
|
|
121
|
+
const rawIntent = commandContract.intents[intent];
|
|
122
|
+
return isRecord(rawIntent) && isRecord(rawIntent.selection) && rawIntent.selection.accepts_test_targets === true;
|
|
123
|
+
}
|
|
124
|
+
function appliedTestTargetsForCandidate(rule, commandContract, candidate) {
|
|
125
|
+
return candidate.status === 'runnable' && commandAcceptsTestTargets(commandContract, candidate.intent)
|
|
126
|
+
? rule.testTargets
|
|
127
|
+
: [];
|
|
128
|
+
}
|
|
129
|
+
function toReportCandidate(rule, reason, role, candidate, appliedTestTargets) {
|
|
130
|
+
return {
|
|
131
|
+
ruleId: rule.id,
|
|
132
|
+
reason,
|
|
133
|
+
intent: candidate.intent,
|
|
134
|
+
role,
|
|
135
|
+
status: candidate.status,
|
|
136
|
+
skipReason: candidate.reason,
|
|
137
|
+
detail: candidate.detail,
|
|
138
|
+
testTargets: rule.testTargets,
|
|
139
|
+
appliedTestTargets,
|
|
140
|
+
testTargetsApplied: appliedTestTargets.length > 0,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
export function createProjectTestSelectionPlan(projectRoot, classificationReport, commandContract) {
|
|
144
|
+
const loaded = readRules(projectRoot);
|
|
145
|
+
if (loaded.status !== 'loaded') {
|
|
146
|
+
const status = loaded.status;
|
|
147
|
+
return {
|
|
148
|
+
report: {
|
|
149
|
+
source: 'test-selection.toml',
|
|
150
|
+
status,
|
|
151
|
+
configPath: TEST_SELECTION_CONFIG_RELATIVE_PATH,
|
|
152
|
+
authority: TEST_SELECTION_CONFIG_RELATIVE_PATH,
|
|
153
|
+
commandAuthority: '.mustflow/config/commands.toml',
|
|
154
|
+
grantsCommandAuthority: false,
|
|
155
|
+
matches: [],
|
|
156
|
+
selected: [],
|
|
157
|
+
notes: [loaded.note],
|
|
158
|
+
},
|
|
159
|
+
candidates: [],
|
|
160
|
+
selectedCandidates: [],
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
const matches = [];
|
|
164
|
+
const candidates = [];
|
|
165
|
+
const selectedCandidates = [];
|
|
166
|
+
const selectedReportCandidates = [];
|
|
167
|
+
for (const rule of loaded.rules) {
|
|
168
|
+
const classifications = matchingClassifications(rule, classificationReport);
|
|
169
|
+
if (classifications.length === 0) {
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
const reason = selectReason(classifications);
|
|
173
|
+
matches.push({
|
|
174
|
+
ruleId: rule.id,
|
|
175
|
+
risk: rule.risk,
|
|
176
|
+
reason: rule.reason,
|
|
177
|
+
files: uniqueSorted(classifications.map((classification) => classification.path)),
|
|
178
|
+
surfaces: uniqueSorted(classifications.map((classification) => classification.surface.kind)),
|
|
179
|
+
intent: rule.intent,
|
|
180
|
+
fallbackIntent: rule.fallbackIntent,
|
|
181
|
+
testTargets: rule.testTargets,
|
|
182
|
+
});
|
|
183
|
+
const primary = withDetail(classifyVerificationCandidate(rule.intent, commandContract.intents[rule.intent]), `Project-declared test selection rule "${rule.id}".`);
|
|
184
|
+
const primaryTestTargets = appliedTestTargetsForCandidate(rule, commandContract, primary);
|
|
185
|
+
const primaryCandidate = { reason, candidate: primary, testTargets: primaryTestTargets };
|
|
186
|
+
candidates.push(primaryCandidate);
|
|
187
|
+
if (primary.status === 'runnable') {
|
|
188
|
+
selectedCandidates.push(primaryCandidate);
|
|
189
|
+
selectedReportCandidates.push(toReportCandidate(rule, reason, 'primary', primary, primaryTestTargets));
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
selectedReportCandidates.push(toReportCandidate(rule, reason, 'primary', primary, primaryTestTargets));
|
|
193
|
+
const fallback = withDetail(classifyVerificationCandidate(rule.fallbackIntent, commandContract.intents[rule.fallbackIntent]), `Fallback for project-declared test selection rule "${rule.id}".`);
|
|
194
|
+
const fallbackTestTargets = appliedTestTargetsForCandidate(rule, commandContract, fallback);
|
|
195
|
+
const fallbackCandidate = { reason, candidate: fallback, testTargets: fallbackTestTargets };
|
|
196
|
+
candidates.push(fallbackCandidate);
|
|
197
|
+
if (fallback.status === 'runnable') {
|
|
198
|
+
selectedCandidates.push(fallbackCandidate);
|
|
199
|
+
selectedReportCandidates.push(toReportCandidate(rule, reason, 'fallback', fallback, fallbackTestTargets));
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
const status = matches.length > 0 ? 'matched' : 'unmatched';
|
|
203
|
+
const notes = [
|
|
204
|
+
matches.length > 0
|
|
205
|
+
? 'Matched project-declared test selection rules are treated as a minimum selected set.'
|
|
206
|
+
: 'No project-declared test selection rules matched the current changed files.',
|
|
207
|
+
...(matches.length > 0 ? [] : [STALE_OR_MISSING_RULES_NOTE]),
|
|
208
|
+
'Local index data and performance history may add suggestions later, but must not remove manifest-selected tests.',
|
|
209
|
+
'Absence of historical failures is not evidence that a test can be omitted.',
|
|
210
|
+
'Test targets are passed only when the selected command intent declares selection.accepts_test_targets = true.',
|
|
211
|
+
];
|
|
212
|
+
return {
|
|
213
|
+
report: {
|
|
214
|
+
source: 'test-selection.toml',
|
|
215
|
+
status,
|
|
216
|
+
configPath: TEST_SELECTION_CONFIG_RELATIVE_PATH,
|
|
217
|
+
authority: TEST_SELECTION_CONFIG_RELATIVE_PATH,
|
|
218
|
+
commandAuthority: '.mustflow/config/commands.toml',
|
|
219
|
+
grantsCommandAuthority: false,
|
|
220
|
+
matches,
|
|
221
|
+
selected: selectedReportCandidates,
|
|
222
|
+
notes,
|
|
223
|
+
},
|
|
224
|
+
candidates,
|
|
225
|
+
selectedCandidates,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
const TEST_CHANGE_KINDS = new Set(['test', 'test_fixture']);
|
|
4
|
+
const SKIP_OR_ONLY_MARKER = /\b(?:describe|it|test)\s*\.\s*(?:skip|only)\s*\(/u;
|
|
5
|
+
function isTestClassification(classification) {
|
|
6
|
+
return classification.surface.category === 'test' || classification.changeKinds.some((kind) => TEST_CHANGE_KINDS.has(kind));
|
|
7
|
+
}
|
|
8
|
+
function resolveInsideRoot(projectRoot, relativePath) {
|
|
9
|
+
const resolvedPath = path.resolve(projectRoot, relativePath);
|
|
10
|
+
const relative = path.relative(projectRoot, resolvedPath);
|
|
11
|
+
if (relative.startsWith('..') || path.isAbsolute(relative)) {
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
return resolvedPath;
|
|
15
|
+
}
|
|
16
|
+
function fileTextIfReadable(projectRoot, relativePath) {
|
|
17
|
+
const resolvedPath = resolveInsideRoot(projectRoot, relativePath);
|
|
18
|
+
if (resolvedPath === null || !existsSync(resolvedPath)) {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
try {
|
|
22
|
+
return readFileSync(resolvedPath, 'utf8');
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
export function createValidationRatchetRisks(report, projectRoot) {
|
|
29
|
+
const risks = [];
|
|
30
|
+
for (const classification of report.classifications.filter(isTestClassification)) {
|
|
31
|
+
const resolvedPath = resolveInsideRoot(projectRoot, classification.path);
|
|
32
|
+
if (report.source === 'changed' && (resolvedPath === null || !existsSync(resolvedPath))) {
|
|
33
|
+
risks.push({
|
|
34
|
+
code: 'related_test_deleted',
|
|
35
|
+
severity: 'high',
|
|
36
|
+
path: classification.path,
|
|
37
|
+
detail: `Changed test path ${classification.path} is absent; deleted related tests require review before marking the task verified.`,
|
|
38
|
+
});
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
const fileText = fileTextIfReadable(projectRoot, classification.path);
|
|
42
|
+
if (fileText !== null && SKIP_OR_ONLY_MARKER.test(fileText)) {
|
|
43
|
+
risks.push({
|
|
44
|
+
code: 'skip_or_only_marker_present',
|
|
45
|
+
severity: 'medium',
|
|
46
|
+
path: classification.path,
|
|
47
|
+
detail: `Changed test path ${classification.path} contains a .skip or .only marker; review whether validation was weakened before marking the task verified.`,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return risks;
|
|
52
|
+
}
|
|
@@ -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
|
});
|