mocode-ai 0.7.1 → 0.7.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/dist/__trace_manual_test__.js +1 -0
- package/dist/agent/core.js +570 -263
- package/dist/agent/index.js +2 -2
- package/dist/llm/index.js +14 -0
- package/dist/permissions/index.js +46 -7
- package/dist/repl/index.js +27 -5
- package/dist/rollback/index.js +5 -0
- package/dist/session/index.js +3 -1
- package/dist/session/trace-metrics.js +70 -0
- package/dist/session/trace-sanitize.js +34 -0
- package/dist/session/trace.js +41 -13
- package/dist/tools/builtins/edit-file.js +13 -1
- package/dist/tools/builtins/index.js +9 -2
- package/dist/tools/builtins/write-file.js +13 -1
- package/dist/tools/registry.js +50 -31
- package/dist/tools/resource-lock.js +148 -0
- package/dist/verification/diagnostics.js +108 -0
- package/dist/verification/discovery.js +14 -6
- package/dist/verification/fingerprint.js +54 -0
- package/dist/verification/index.js +275 -105
- package/dist/verification/postconditions.js +98 -0
- package/dist/verification/targeted-tests.js +96 -0
- package/package.json +1 -1
|
@@ -4,10 +4,14 @@ import { checkPermission } from '../permissions/index.js';
|
|
|
4
4
|
import { beginWorkspaceMutation, endWorkspaceMutation, getCurrentTurnMutationState, } from '../rollback/index.js';
|
|
5
5
|
import { formatCommandResult, runCommandRaw, runCommandTool, } from '../tools/builtins/run-command.js';
|
|
6
6
|
import { resolveAffectedPackages } from './affected.js';
|
|
7
|
-
import {
|
|
7
|
+
import { discoverPackageValidationCommands } from './discovery.js';
|
|
8
|
+
import { runChangedFileDiagnostics } from './diagnostics.js';
|
|
9
|
+
import { fingerprintFiles, fingerprintValidation } from './fingerprint.js';
|
|
10
|
+
import { runFilePostconditions } from './postconditions.js';
|
|
8
11
|
import { discoverProjectProfile } from './profile.js';
|
|
12
|
+
import { discoverTargetedTestCommands } from './targeted-tests.js';
|
|
9
13
|
export { resolveAffectedPackages } from './affected.js';
|
|
10
|
-
export { discoverPackageValidationCommand, discoverValidationCommand } from './discovery.js';
|
|
14
|
+
export { discoverPackageValidationCommand, discoverPackageValidationCommands, discoverValidationCommand, } from './discovery.js';
|
|
11
15
|
export { clearProjectProfileCache, discoverProjectProfile } from './profile.js';
|
|
12
16
|
const NON_CODE_EXTENSIONS = new Set([
|
|
13
17
|
'.md', '.mdx', '.txt', '.rst', '.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp',
|
|
@@ -16,14 +20,14 @@ function isNonCodePath(file) {
|
|
|
16
20
|
return NON_CODE_EXTENSIONS.has(path.extname(file).toLowerCase());
|
|
17
21
|
}
|
|
18
22
|
function affectedSummary(affected) {
|
|
19
|
-
return affected
|
|
23
|
+
return affected?.packages.map((item) => ({
|
|
20
24
|
name: item.package.name,
|
|
21
25
|
root: item.package.root,
|
|
22
26
|
reasons: item.reasons,
|
|
23
|
-
}));
|
|
27
|
+
})) ?? [];
|
|
24
28
|
}
|
|
25
29
|
function selectionOutput(affected) {
|
|
26
|
-
if (affected.packages.length === 0)
|
|
30
|
+
if (!affected || affected.packages.length === 0)
|
|
27
31
|
return '';
|
|
28
32
|
return [
|
|
29
33
|
'Affected packages:',
|
|
@@ -33,131 +37,297 @@ function selectionOutput(affected) {
|
|
|
33
37
|
}),
|
|
34
38
|
].join('\n');
|
|
35
39
|
}
|
|
36
|
-
function
|
|
40
|
+
function stageOutput(stage) {
|
|
41
|
+
const scope = stage.packageName ? ` ${stage.packageName}` : '';
|
|
42
|
+
return `[${stage.level}${scope}] ${stage.status} (${stage.adapter})${stage.output ? `\n${stage.output}` : ''}`;
|
|
43
|
+
}
|
|
44
|
+
function highestLevel(stages) {
|
|
45
|
+
return stages.at(-1)?.level;
|
|
46
|
+
}
|
|
47
|
+
function aggregate(options) {
|
|
37
48
|
const state = getCurrentTurnMutationState();
|
|
49
|
+
const diagnostics = options.stages.flatMap((stage) => stage.diagnostics);
|
|
50
|
+
const terminal = options.stages.find((stage) => stage.status === 'failed' || stage.status === 'aborted');
|
|
51
|
+
const blockingSkip = options.stages.find((stage) => stage.status === 'skipped' && [
|
|
52
|
+
'permission_denied', 'no_validation_script', 'no_package_json',
|
|
53
|
+
'no_affected_package', 'invalid_changed_path',
|
|
54
|
+
].includes(stage.skipReason ?? ''));
|
|
55
|
+
const v3Passed = options.stages.some((stage) => stage.level === 'V3' && stage.status === 'passed');
|
|
56
|
+
const substantivePassed = options.stages.some((stage) => stage.level !== 'V0' && stage.status === 'passed');
|
|
57
|
+
const status = terminal?.status ?? (blockingSkip || !substantivePassed ? 'skipped' : 'passed');
|
|
58
|
+
const level = terminal?.level ?? highestLevel(options.stages);
|
|
59
|
+
const output = [selectionOutput(options.affected), ...options.stages.map(stageOutput)].filter(Boolean).join('\n\n');
|
|
60
|
+
const commands = options.stages.flatMap((stage) => stage.command ? [stage.command] : []);
|
|
61
|
+
const skipReason = options.skipReason
|
|
62
|
+
?? blockingSkip?.skipReason
|
|
63
|
+
?? (status === 'skipped' ? 'no_applicable_validator' : undefined);
|
|
64
|
+
const fingerprint = fingerprintValidation({
|
|
65
|
+
root: options.root,
|
|
66
|
+
level,
|
|
67
|
+
status,
|
|
68
|
+
adapter: terminal?.adapter,
|
|
69
|
+
command: commands.join(' | '),
|
|
70
|
+
diagnostics,
|
|
71
|
+
output,
|
|
72
|
+
});
|
|
73
|
+
return {
|
|
74
|
+
status,
|
|
75
|
+
level,
|
|
76
|
+
script: commands.length === 1 ? options.packageResults?.[0]?.script : undefined,
|
|
77
|
+
command: commands.length > 0 ? commands.join(' | ') : undefined,
|
|
78
|
+
exitCode: terminal?.exitCode,
|
|
79
|
+
output,
|
|
80
|
+
durationMs: options.stages.reduce((sum, stage) => sum + stage.durationMs, 0),
|
|
81
|
+
skipReason,
|
|
82
|
+
diagnostics,
|
|
83
|
+
stages: options.stages,
|
|
84
|
+
verificationComplete: status === 'passed' && v3Passed && !blockingSkip,
|
|
85
|
+
fingerprint,
|
|
86
|
+
inputFingerprint: options.inputFingerprint,
|
|
87
|
+
inputMutationVersion: options.inputMutationVersion,
|
|
88
|
+
affectedPackages: affectedSummary(options.affected),
|
|
89
|
+
rejectedChangedFiles: options.affected?.rejected,
|
|
90
|
+
packageResults: options.packageResults,
|
|
91
|
+
changedFiles: state.changedFiles.map((item) => item.path),
|
|
92
|
+
mutationVersion: state.version,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
function emptyResult(root, reason, inputMutationVersion, changedFiles) {
|
|
96
|
+
const inputFingerprint = fingerprintFiles(root, changedFiles);
|
|
97
|
+
return aggregate({ root, inputMutationVersion, inputFingerprint, stages: [], skipReason: reason });
|
|
98
|
+
}
|
|
99
|
+
function skippedStage(level, adapter, reason, output, inputFingerprint, packageName) {
|
|
38
100
|
return {
|
|
101
|
+
level,
|
|
39
102
|
status: 'skipped',
|
|
40
|
-
|
|
103
|
+
adapter,
|
|
104
|
+
packageName,
|
|
105
|
+
diagnostics: [],
|
|
106
|
+
output,
|
|
41
107
|
durationMs: 0,
|
|
42
108
|
skipReason: reason,
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
109
|
+
inputFingerprint,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
function commandSource(command) {
|
|
113
|
+
return command.script;
|
|
114
|
+
}
|
|
115
|
+
async function executeSelectedCommand(item, root, inputFingerprint, signal, callbacks) {
|
|
116
|
+
const relativeCwd = path.relative(root, item.command.cwd) || '.';
|
|
117
|
+
const permissionArgs = { command: item.command.command, path: relativeCwd };
|
|
118
|
+
const permission = await checkPermission(runCommandTool, permissionArgs, signal, { projectRoot: root });
|
|
119
|
+
callbacks.onPermissionDecision?.({
|
|
120
|
+
decision: permission,
|
|
121
|
+
tool: runCommandTool.name,
|
|
122
|
+
arguments: permissionArgs,
|
|
123
|
+
});
|
|
124
|
+
if (signal?.aborted) {
|
|
125
|
+
return {
|
|
126
|
+
stage: {
|
|
127
|
+
level: item.level, status: 'aborted', adapter: item.adapter,
|
|
128
|
+
packageName: item.affected.package.name, command: item.command.command,
|
|
129
|
+
diagnostics: [], output: 'Validation aborted.', durationMs: 0, inputFingerprint,
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
if (permission === 'deny') {
|
|
134
|
+
return {
|
|
135
|
+
stage: skippedStage(item.level, item.adapter, 'permission_denied', 'Validation command permission was denied.', inputFingerprint, item.affected.package.name),
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
callbacks.onCommandStart?.(`[${item.level} ${item.affected.package.name}] ${item.command.command}`);
|
|
139
|
+
const capture = beginWorkspaceMutation();
|
|
140
|
+
let raw;
|
|
141
|
+
try {
|
|
142
|
+
raw = await runCommandRaw(item.command.command, 120000, signal, item.command.cwd);
|
|
143
|
+
}
|
|
144
|
+
finally {
|
|
145
|
+
endWorkspaceMutation(capture, 'automatic_validation');
|
|
146
|
+
}
|
|
147
|
+
const status = raw.status === 'passed' ? 'passed' : raw.status === 'aborted' ? 'aborted' : 'failed';
|
|
148
|
+
const output = formatCommandResult(raw);
|
|
149
|
+
const diagnostics = status === 'failed'
|
|
150
|
+
? [{
|
|
151
|
+
level: item.level,
|
|
152
|
+
source: commandSource(item.command),
|
|
153
|
+
severity: 'error',
|
|
154
|
+
code: raw.status.toUpperCase(),
|
|
155
|
+
packageName: item.affected.package.name,
|
|
156
|
+
message: output,
|
|
157
|
+
}]
|
|
158
|
+
: [];
|
|
159
|
+
const stage = {
|
|
160
|
+
level: item.level,
|
|
161
|
+
status,
|
|
162
|
+
adapter: item.adapter,
|
|
163
|
+
packageName: item.affected.package.name,
|
|
164
|
+
command: item.command.command,
|
|
165
|
+
exitCode: raw.exitCode,
|
|
166
|
+
diagnostics,
|
|
167
|
+
output,
|
|
168
|
+
durationMs: raw.durationMs,
|
|
169
|
+
inputFingerprint,
|
|
170
|
+
};
|
|
171
|
+
stage.fingerprint = fingerprintValidation({
|
|
172
|
+
root,
|
|
173
|
+
level: stage.level,
|
|
174
|
+
status: stage.status,
|
|
175
|
+
adapter: stage.adapter,
|
|
176
|
+
command: stage.command,
|
|
177
|
+
diagnostics,
|
|
178
|
+
output,
|
|
179
|
+
});
|
|
180
|
+
return {
|
|
181
|
+
stage,
|
|
182
|
+
packageResult: {
|
|
183
|
+
packageName: item.affected.package.name,
|
|
184
|
+
packageRoot: item.affected.package.root,
|
|
185
|
+
level: item.level,
|
|
186
|
+
status,
|
|
187
|
+
script: item.command.script,
|
|
188
|
+
command: item.command.command,
|
|
189
|
+
exitCode: raw.exitCode,
|
|
190
|
+
output,
|
|
191
|
+
durationMs: raw.durationMs,
|
|
192
|
+
},
|
|
46
193
|
};
|
|
47
194
|
}
|
|
48
|
-
function
|
|
49
|
-
|
|
195
|
+
async function runCommands(selected, root, inputFingerprint, signal, callbacks, stages, packageResults) {
|
|
196
|
+
for (const item of selected) {
|
|
197
|
+
const result = await executeSelectedCommand(item, root, inputFingerprint, signal, callbacks);
|
|
198
|
+
stages.push(result.stage);
|
|
199
|
+
if (result.packageResult)
|
|
200
|
+
packageResults.push(result.packageResult);
|
|
201
|
+
if (result.stage.status !== 'passed')
|
|
202
|
+
return false;
|
|
203
|
+
}
|
|
204
|
+
return true;
|
|
205
|
+
}
|
|
206
|
+
function resolveProfile(root, validationPaths) {
|
|
207
|
+
try {
|
|
208
|
+
const profile = discoverProjectProfile(root);
|
|
209
|
+
return {
|
|
210
|
+
profile,
|
|
211
|
+
affected: resolveAffectedPackages(profile, validationPaths, { changedFilesBase: process.cwd() }),
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
return {};
|
|
216
|
+
}
|
|
50
217
|
}
|
|
51
|
-
/** Run
|
|
218
|
+
/** Run V0→V3 in cost order and stop at the first actionable failure. */
|
|
52
219
|
export async function runAutomaticValidation(signal, callbacks = {}) {
|
|
53
220
|
const before = getCurrentTurnMutationState();
|
|
221
|
+
const root = path.resolve(getSandboxRoot() ?? process.cwd());
|
|
54
222
|
const changedPaths = before.changedFiles.map((item) => item.path);
|
|
55
223
|
if (changedPaths.length === 0)
|
|
56
|
-
return
|
|
224
|
+
return emptyResult(root, 'no_changes', before.version, changedPaths);
|
|
57
225
|
const validationPaths = changedPaths.filter((item) => !isNonCodePath(item));
|
|
58
|
-
if (validationPaths.length === 0)
|
|
59
|
-
return
|
|
60
|
-
const root = path.resolve(getSandboxRoot() ?? process.cwd());
|
|
61
|
-
let affected;
|
|
62
|
-
let selected;
|
|
63
|
-
try {
|
|
64
|
-
const profile = discoverProjectProfile(root);
|
|
65
|
-
affected = resolveAffectedPackages(profile, validationPaths, { changedFilesBase: process.cwd() });
|
|
66
|
-
selected = affected.packages.flatMap((item) => {
|
|
67
|
-
const command = discoverPackageValidationCommand(profile, item.package);
|
|
68
|
-
return command ? [{ affected: item, command }] : [];
|
|
69
|
-
});
|
|
226
|
+
if (validationPaths.length === 0) {
|
|
227
|
+
return emptyResult(root, 'non_code_changes', before.version, changedPaths);
|
|
70
228
|
}
|
|
71
|
-
|
|
72
|
-
|
|
229
|
+
const inputFingerprint = fingerprintFiles(root, validationPaths);
|
|
230
|
+
const { profile, affected } = resolveProfile(root, validationPaths);
|
|
231
|
+
const stages = [];
|
|
232
|
+
const packageResults = [];
|
|
233
|
+
const finish = (skipReason) => aggregate({
|
|
234
|
+
root,
|
|
235
|
+
inputMutationVersion: before.version,
|
|
236
|
+
inputFingerprint,
|
|
237
|
+
stages,
|
|
238
|
+
affected,
|
|
239
|
+
packageResults,
|
|
240
|
+
skipReason,
|
|
241
|
+
});
|
|
242
|
+
const v0 = await runFilePostconditions(root, validationPaths, inputFingerprint);
|
|
243
|
+
stages.push(v0);
|
|
244
|
+
if (v0.status === 'failed')
|
|
245
|
+
return finish();
|
|
246
|
+
const v1 = await runChangedFileDiagnostics(root, validationPaths, inputFingerprint);
|
|
247
|
+
stages.push(v1);
|
|
248
|
+
if (v1.status === 'failed')
|
|
249
|
+
return finish();
|
|
250
|
+
if (!profile || !affected) {
|
|
251
|
+
stages.push(skippedStage('V3', 'package-scripts', 'no_package_json', 'No project package profile is available.', inputFingerprint));
|
|
252
|
+
return finish('no_package_json');
|
|
73
253
|
}
|
|
74
|
-
const summary = affectedSummary(affected);
|
|
75
|
-
const selectedOutput = selectionOutput(affected);
|
|
76
254
|
if (affected.rejected.length > 0) {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
selectedOutput,
|
|
80
|
-
...affected.rejected.map((item) => `Rejected changed path: ${item.input} (${item.reason})`),
|
|
81
|
-
].filter(Boolean).join('\n'),
|
|
82
|
-
affectedPackages: summary,
|
|
83
|
-
rejectedChangedFiles: affected.rejected,
|
|
84
|
-
});
|
|
255
|
+
stages.push(skippedStage('V3', 'affected-packages', 'invalid_changed_path', affected.rejected.map((item) => `Rejected changed path: ${item.input} (${item.reason})`).join('\n'), inputFingerprint));
|
|
256
|
+
return finish('invalid_changed_path');
|
|
85
257
|
}
|
|
86
258
|
if (affected.packages.length === 0) {
|
|
87
|
-
|
|
259
|
+
stages.push(skippedStage('V3', 'affected-packages', 'no_affected_package', 'No package owns the changed files.', inputFingerprint));
|
|
260
|
+
return finish('no_affected_package');
|
|
261
|
+
}
|
|
262
|
+
const targeted = discoverTargetedTestCommands(profile, affected.packages).map((item) => ({
|
|
263
|
+
affected: item.affected,
|
|
264
|
+
command: item.command,
|
|
265
|
+
level: 'V2',
|
|
266
|
+
adapter: item.adapter,
|
|
267
|
+
}));
|
|
268
|
+
if (targeted.length === 0) {
|
|
269
|
+
stages.push(skippedStage('V2', 'targeted-tests', 'no_targeted_tests', 'No reliable targeted tests were found.', inputFingerprint));
|
|
88
270
|
}
|
|
89
|
-
if (
|
|
90
|
-
return
|
|
271
|
+
else if (!await runCommands(targeted, root, inputFingerprint, signal, callbacks, stages, packageResults)) {
|
|
272
|
+
return finish(stages.at(-1)?.skipReason === 'permission_denied' ? 'permission_denied' : undefined);
|
|
91
273
|
}
|
|
92
|
-
const
|
|
93
|
-
for (const item of
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
274
|
+
const selected = [];
|
|
275
|
+
for (const item of affected.packages) {
|
|
276
|
+
const commands = discoverPackageValidationCommands(profile, item.package);
|
|
277
|
+
if (commands.length === 0) {
|
|
278
|
+
stages.push(skippedStage('V3', 'package-scripts', 'no_validation_script', 'Affected package has no typecheck, build, or test script.', inputFingerprint, item.package.name));
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
selected.push(...commands.map((command) => ({
|
|
282
|
+
affected: item,
|
|
283
|
+
command,
|
|
284
|
+
level: 'V3',
|
|
285
|
+
adapter: `package-${command.script}`,
|
|
286
|
+
})));
|
|
287
|
+
}
|
|
288
|
+
const scriptOrder = { typecheck: 0, build: 1, test: 2 };
|
|
289
|
+
selected.sort((left, right) => scriptOrder[left.command.script] - scriptOrder[right.command.script]);
|
|
290
|
+
if (selected.length === 0)
|
|
291
|
+
return finish('no_validation_script');
|
|
292
|
+
await runCommands(selected, root, inputFingerprint, signal, callbacks, stages, packageResults);
|
|
293
|
+
return finish(stages.at(-1)?.skipReason === 'permission_denied' ? 'permission_denied' : undefined);
|
|
294
|
+
}
|
|
295
|
+
/** Create a run-scoped verifier cache; identical content never repeats expensive validation. */
|
|
296
|
+
export function createAutomaticValidator() {
|
|
297
|
+
const cache = new Map();
|
|
298
|
+
const failureCounts = new Map();
|
|
299
|
+
return async (signal, callbacks = {}) => {
|
|
300
|
+
const state = getCurrentTurnMutationState();
|
|
301
|
+
const root = path.resolve(getSandboxRoot() ?? process.cwd());
|
|
302
|
+
const changedFiles = state.changedFiles.map((item) => item.path);
|
|
303
|
+
const inputFingerprint = fingerprintFiles(root, changedFiles);
|
|
304
|
+
const cacheKey = `${root}\0${inputFingerprint}`;
|
|
305
|
+
const cached = cache.get(cacheKey);
|
|
306
|
+
if (cached) {
|
|
307
|
+
const count = cached.status === 'failed'
|
|
308
|
+
? (failureCounts.get(cached.fingerprint) ?? 1) + 1
|
|
309
|
+
: 1;
|
|
310
|
+
if (cached.status === 'failed')
|
|
311
|
+
failureCounts.set(cached.fingerprint, count);
|
|
98
312
|
return {
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
313
|
+
...cached,
|
|
314
|
+
output: `${cached.output}\n\n[validation cache] Identical inputs reused; failure occurrence ${count}.`,
|
|
315
|
+
stages: cached.stages.map((stage) => ({ ...stage, cached: true, durationMs: 0 })),
|
|
102
316
|
durationMs: 0,
|
|
103
|
-
|
|
104
|
-
changedFiles
|
|
317
|
+
inputMutationVersion: state.version,
|
|
318
|
+
changedFiles,
|
|
105
319
|
mutationVersion: state.version,
|
|
106
320
|
};
|
|
107
321
|
}
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
}
|
|
116
|
-
const packageResults = [];
|
|
117
|
-
const capture = beginWorkspaceMutation();
|
|
118
|
-
try {
|
|
119
|
-
for (const item of selected) {
|
|
120
|
-
callbacks.onCommandStart?.(`[${item.affected.package.name}] ${item.command.command}`);
|
|
121
|
-
const raw = await runCommandRaw(item.command.command, 120000, signal, item.command.cwd);
|
|
122
|
-
const status = raw.status === 'passed'
|
|
123
|
-
? 'passed'
|
|
124
|
-
: raw.status === 'aborted'
|
|
125
|
-
? 'aborted'
|
|
126
|
-
: 'failed';
|
|
127
|
-
packageResults.push({
|
|
128
|
-
packageName: item.affected.package.name,
|
|
129
|
-
packageRoot: item.affected.package.root,
|
|
130
|
-
status,
|
|
131
|
-
script: item.command.script,
|
|
132
|
-
command: item.command.command,
|
|
133
|
-
exitCode: raw.exitCode,
|
|
134
|
-
output: formatCommandResult(raw),
|
|
135
|
-
durationMs: raw.durationMs,
|
|
136
|
-
});
|
|
137
|
-
if (status !== 'passed')
|
|
138
|
-
break;
|
|
322
|
+
const result = await runAutomaticValidation(signal, callbacks);
|
|
323
|
+
if (result.status === 'failed') {
|
|
324
|
+
const count = (failureCounts.get(result.fingerprint) ?? 0) + 1;
|
|
325
|
+
failureCounts.set(result.fingerprint, count);
|
|
326
|
+
if (count > 1) {
|
|
327
|
+
result.output += `\n\n[validation thrashing] The same failure fingerprint occurred ${count} times; change strategy before retrying.`;
|
|
328
|
+
}
|
|
139
329
|
}
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
endWorkspaceMutation(capture, 'automatic_validation');
|
|
143
|
-
}
|
|
144
|
-
const after = getCurrentTurnMutationState();
|
|
145
|
-
const terminal = packageResults.find((item) => item.status !== 'passed')
|
|
146
|
-
?? packageResults[packageResults.length - 1];
|
|
147
|
-
const output = [
|
|
148
|
-
selectedOutput,
|
|
149
|
-
...packageResults.map((item) => `[${item.packageName}] ${item.command} (cwd: ${path.relative(root, item.packageRoot) || '.'})\n${item.output}`),
|
|
150
|
-
].filter(Boolean).join('\n\n');
|
|
151
|
-
return {
|
|
152
|
-
status: terminal.status,
|
|
153
|
-
script: selected.length === 1 ? selected[0]?.command.script : undefined,
|
|
154
|
-
command: commands,
|
|
155
|
-
exitCode: terminal.exitCode,
|
|
156
|
-
output,
|
|
157
|
-
durationMs: packageResults.reduce((total, item) => total + item.durationMs, 0),
|
|
158
|
-
affectedPackages: summary,
|
|
159
|
-
packageResults,
|
|
160
|
-
changedFiles: after.changedFiles.map((item) => item.path),
|
|
161
|
-
mutationVersion: after.version,
|
|
330
|
+
cache.set(cacheKey, result);
|
|
331
|
+
return result;
|
|
162
332
|
};
|
|
163
333
|
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
function hash(value) {
|
|
5
|
+
return createHash('sha256').update(value).digest('hex');
|
|
6
|
+
}
|
|
7
|
+
function diagnostic(file, code, message) {
|
|
8
|
+
return {
|
|
9
|
+
level: 'V0',
|
|
10
|
+
source: 'postcondition',
|
|
11
|
+
severity: 'error',
|
|
12
|
+
code,
|
|
13
|
+
file,
|
|
14
|
+
message,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
function parseJson(file, content) {
|
|
18
|
+
if (path.extname(file).toLowerCase() !== '.json')
|
|
19
|
+
return [];
|
|
20
|
+
try {
|
|
21
|
+
JSON.parse(content);
|
|
22
|
+
return [];
|
|
23
|
+
}
|
|
24
|
+
catch (error) {
|
|
25
|
+
return [diagnostic(file, 'INVALID_JSON', error instanceof Error ? error.message : String(error))];
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/** Immediate read-after-write verification used by write_file and edit_file. */
|
|
29
|
+
export async function verifyWrittenFile(file, expectedContent) {
|
|
30
|
+
const expectedHash = hash(Buffer.from(expectedContent, 'utf8'));
|
|
31
|
+
let actual;
|
|
32
|
+
try {
|
|
33
|
+
actual = await readFile(file);
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
return {
|
|
37
|
+
status: 'failed',
|
|
38
|
+
expectedHash,
|
|
39
|
+
diagnostics: [diagnostic(file, 'POSTCONDITION_READ_FAILED', error instanceof Error ? error.message : String(error))],
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
const actualHash = hash(actual);
|
|
43
|
+
const diagnostics = actualHash === expectedHash
|
|
44
|
+
? parseJson(file, actual.toString('utf8'))
|
|
45
|
+
: [diagnostic(file, 'CONTENT_HASH_MISMATCH', 'Content read after writing differs from the requested content.')];
|
|
46
|
+
return {
|
|
47
|
+
status: diagnostics.length === 0 ? 'passed' : 'failed',
|
|
48
|
+
expectedHash,
|
|
49
|
+
actualHash,
|
|
50
|
+
diagnostics,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
/** Re-check current changed files before more expensive validation layers. */
|
|
54
|
+
export async function runFilePostconditions(root, changedFiles, inputFingerprint) {
|
|
55
|
+
const startedAt = Date.now();
|
|
56
|
+
const diagnostics = [];
|
|
57
|
+
const hashes = [];
|
|
58
|
+
let checked = 0;
|
|
59
|
+
for (const changedFile of changedFiles) {
|
|
60
|
+
const absolute = path.resolve(process.cwd(), changedFile);
|
|
61
|
+
const relative = path.relative(root, absolute);
|
|
62
|
+
if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
|
|
63
|
+
diagnostics.push(diagnostic(changedFile, 'OUTSIDE_PROJECT', 'Changed path is outside the project root.'));
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
try {
|
|
67
|
+
const fileStat = await stat(absolute);
|
|
68
|
+
if (!fileStat.isFile())
|
|
69
|
+
continue;
|
|
70
|
+
const content = await readFile(absolute);
|
|
71
|
+
checked += 1;
|
|
72
|
+
hashes.push(`${relative.replace(/\\/g, '/')}: ${hash(content)}`);
|
|
73
|
+
diagnostics.push(...parseJson(relative, content.toString('utf8')));
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
const code = error.code;
|
|
77
|
+
if (code !== 'ENOENT') {
|
|
78
|
+
diagnostics.push(diagnostic(relative, 'POSTCONDITION_READ_FAILED', error instanceof Error ? error.message : String(error)));
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
const status = diagnostics.some((item) => item.severity === 'error') ? 'failed' : checked > 0 ? 'passed' : 'skipped';
|
|
83
|
+
return {
|
|
84
|
+
level: 'V0',
|
|
85
|
+
status,
|
|
86
|
+
adapter: 'file-postconditions',
|
|
87
|
+
diagnostics,
|
|
88
|
+
output: status === 'skipped' ? 'No current files require V0 postconditions.' : hashes.join('\n'),
|
|
89
|
+
durationMs: Date.now() - startedAt,
|
|
90
|
+
skipReason: status === 'skipped' ? 'no_current_files' : undefined,
|
|
91
|
+
inputFingerprint,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
export function formatPostconditionFailure(result) {
|
|
95
|
+
return result.diagnostics
|
|
96
|
+
.map((item) => `[${item.code ?? 'V0_FAILED'}] ${item.file ?? ''}: ${item.message}`)
|
|
97
|
+
.join('\n');
|
|
98
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { existsSync, statSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
const TEST_FILE = /(?:^|[/\\])(?:__tests__[/\\].+|.+\.(?:test|spec))\.[cm]?[jt]sx?$/i;
|
|
4
|
+
const SOURCE_EXTENSION = /\.[cm]?[jt]sx?$/i;
|
|
5
|
+
function isFile(file) {
|
|
6
|
+
try {
|
|
7
|
+
return statSync(file).isFile();
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
return false;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
function inside(root, candidate) {
|
|
14
|
+
const relative = path.relative(root, candidate);
|
|
15
|
+
return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative));
|
|
16
|
+
}
|
|
17
|
+
function quoteArg(value) {
|
|
18
|
+
if (process.platform === 'win32')
|
|
19
|
+
return `"${value.replace(/%/g, '%%').replace(/"/g, '""')}"`;
|
|
20
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
21
|
+
}
|
|
22
|
+
function adapterFor(script) {
|
|
23
|
+
if (/\bvitest\b/i.test(script))
|
|
24
|
+
return 'vitest';
|
|
25
|
+
if (/\bjest\b/i.test(script))
|
|
26
|
+
return 'jest';
|
|
27
|
+
if (/\bnode\s+--test\b/i.test(script))
|
|
28
|
+
return 'node-test';
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
function candidateTests(source) {
|
|
32
|
+
if (TEST_FILE.test(source))
|
|
33
|
+
return [source];
|
|
34
|
+
if (!SOURCE_EXTENSION.test(source) || source.endsWith('.d.ts'))
|
|
35
|
+
return [];
|
|
36
|
+
const extension = path.extname(source);
|
|
37
|
+
const stem = source.slice(0, -extension.length);
|
|
38
|
+
const base = path.basename(stem);
|
|
39
|
+
const directory = path.dirname(source);
|
|
40
|
+
return [
|
|
41
|
+
`${stem}.test${extension}`,
|
|
42
|
+
`${stem}.spec${extension}`,
|
|
43
|
+
path.join(directory, '__tests__', `${base}.test${extension}`),
|
|
44
|
+
path.join(directory, '__tests__', `${base}.spec${extension}`),
|
|
45
|
+
];
|
|
46
|
+
}
|
|
47
|
+
function runnerArgs(adapter, script) {
|
|
48
|
+
if (adapter === 'vitest' && !/\bvitest\s+run\b/i.test(script))
|
|
49
|
+
return ['--run'];
|
|
50
|
+
if (adapter === 'jest')
|
|
51
|
+
return ['--runInBand'];
|
|
52
|
+
return [];
|
|
53
|
+
}
|
|
54
|
+
/** Discover reliable direct/co-located tests without crossing package boundaries. */
|
|
55
|
+
export function discoverTargetedTestCommands(profile, affectedPackages) {
|
|
56
|
+
const selected = [];
|
|
57
|
+
for (const affected of affectedPackages) {
|
|
58
|
+
const script = affected.package.scripts.test;
|
|
59
|
+
if (typeof script !== 'string')
|
|
60
|
+
continue;
|
|
61
|
+
const adapter = adapterFor(script);
|
|
62
|
+
if (!adapter)
|
|
63
|
+
continue;
|
|
64
|
+
const files = new Set();
|
|
65
|
+
for (const reason of affected.reasons) {
|
|
66
|
+
if (reason.kind !== 'direct_change')
|
|
67
|
+
continue;
|
|
68
|
+
const changed = path.resolve(profile.root, reason.changedPath);
|
|
69
|
+
if (!inside(affected.package.root, changed))
|
|
70
|
+
continue;
|
|
71
|
+
for (const candidate of candidateTests(changed)) {
|
|
72
|
+
if (existsSync(candidate) && isFile(candidate) && inside(affected.package.root, candidate)) {
|
|
73
|
+
files.add(candidate);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (files.size === 0)
|
|
78
|
+
continue;
|
|
79
|
+
const relativeFiles = [...files]
|
|
80
|
+
.map((file) => path.relative(affected.package.root, file))
|
|
81
|
+
.sort();
|
|
82
|
+
const args = [...runnerArgs(adapter, script), ...relativeFiles].map(quoteArg).join(' ');
|
|
83
|
+
selected.push({
|
|
84
|
+
affected,
|
|
85
|
+
adapter,
|
|
86
|
+
files: relativeFiles,
|
|
87
|
+
command: {
|
|
88
|
+
script: 'test',
|
|
89
|
+
command: `${profile.packageManager} run test -- ${args}`,
|
|
90
|
+
packageManager: profile.packageManager,
|
|
91
|
+
cwd: affected.package.root,
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
return selected;
|
|
96
|
+
}
|