mocode-ai 0.7.1 → 0.7.3
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 +598 -263
- package/dist/agent/index.js +2 -2
- package/dist/llm/index.js +14 -0
- package/dist/permissions/index.js +53 -8
- 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 +25 -3
- package/dist/tools/builtins/index.js +9 -2
- package/dist/tools/builtins/web-fetch.js +21 -5
- package/dist/tools/builtins/web-search.js +28 -4
- package/dist/tools/builtins/write-file.js +13 -1
- package/dist/tools/registry.js +121 -46
- package/dist/tools/resource-lock.js +148 -0
- package/dist/tools/retry.js +105 -0
- package/dist/tools/validation.js +80 -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 +2 -1
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { discoverProjectProfile } from './profile.js';
|
|
4
|
-
const
|
|
4
|
+
const COMPATIBILITY_PRIORITY = ['typecheck', 'test', 'build'];
|
|
5
|
+
const LAYERED_ORDER = ['typecheck', 'build', 'test'];
|
|
5
6
|
const isWindows = process.platform === 'win32';
|
|
6
7
|
function samePath(left, right) {
|
|
7
8
|
const normalizedLeft = path.resolve(left);
|
|
@@ -10,11 +11,7 @@ function samePath(left, right) {
|
|
|
10
11
|
? normalizedLeft.toLowerCase() === normalizedRight.toLowerCase()
|
|
11
12
|
: normalizedLeft === normalizedRight;
|
|
12
13
|
}
|
|
13
|
-
|
|
14
|
-
export function discoverPackageValidationCommand(profile, packageProfile) {
|
|
15
|
-
const script = SCRIPT_PRIORITY.find((name) => typeof packageProfile.scripts[name] === 'string');
|
|
16
|
-
if (!script)
|
|
17
|
-
return null;
|
|
14
|
+
function commandFor(profile, packageProfile, script) {
|
|
18
15
|
return {
|
|
19
16
|
script,
|
|
20
17
|
command: `${profile.packageManager} run ${script}`,
|
|
@@ -22,6 +19,17 @@ export function discoverPackageValidationCommand(profile, packageProfile) {
|
|
|
22
19
|
cwd: packageProfile.root,
|
|
23
20
|
};
|
|
24
21
|
}
|
|
22
|
+
/** Discover every available V3 command in increasing-cost order. */
|
|
23
|
+
export function discoverPackageValidationCommands(profile, packageProfile) {
|
|
24
|
+
return LAYERED_ORDER
|
|
25
|
+
.filter((script) => typeof packageProfile.scripts[script] === 'string')
|
|
26
|
+
.map((script) => commandFor(profile, packageProfile, script));
|
|
27
|
+
}
|
|
28
|
+
/** Compatibility API retained for callers that intentionally want one command. */
|
|
29
|
+
export function discoverPackageValidationCommand(profile, packageProfile) {
|
|
30
|
+
const script = COMPATIBILITY_PRIORITY.find((name) => typeof packageProfile.scripts[name] === 'string');
|
|
31
|
+
return script ? commandFor(profile, packageProfile, script) : null;
|
|
32
|
+
}
|
|
25
33
|
/** Compatibility wrapper that discovers the root package validation command. */
|
|
26
34
|
export function discoverValidationCommand(root) {
|
|
27
35
|
const resolvedRoot = path.resolve(root);
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { readFileSync, statSync } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
function sha256(value) {
|
|
5
|
+
return createHash('sha256').update(value).digest('hex');
|
|
6
|
+
}
|
|
7
|
+
function normalizeText(value, root) {
|
|
8
|
+
const normalizedRoot = path.resolve(root).replace(/\\/g, '/');
|
|
9
|
+
return value
|
|
10
|
+
.replace(/\u001b\[[0-9;]*m/g, '')
|
|
11
|
+
.replace(/\\/g, '/')
|
|
12
|
+
.replaceAll(normalizedRoot, '<root>')
|
|
13
|
+
.replace(/\r\n?/g, '\n')
|
|
14
|
+
.trim();
|
|
15
|
+
}
|
|
16
|
+
/** Fingerprint the relevant on-disk inputs, so rewriting identical content can reuse validation. */
|
|
17
|
+
export function fingerprintFiles(root, files) {
|
|
18
|
+
const entries = [...new Set(files)].sort().map((file) => {
|
|
19
|
+
const absolute = path.resolve(process.cwd(), file);
|
|
20
|
+
const display = path.relative(root, absolute).replace(/\\/g, '/');
|
|
21
|
+
try {
|
|
22
|
+
const stat = statSync(absolute);
|
|
23
|
+
if (!stat.isFile())
|
|
24
|
+
return `${display}\0<${stat.isDirectory() ? 'directory' : 'other'}>`;
|
|
25
|
+
return `${display}\0${sha256(readFileSync(absolute))}`;
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return `${display}\0<missing>`;
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
return sha256(entries.join('\n'));
|
|
32
|
+
}
|
|
33
|
+
export function fingerprintValidation(input) {
|
|
34
|
+
const diagnostics = [...input.diagnostics]
|
|
35
|
+
.map((item) => ({
|
|
36
|
+
source: item.source,
|
|
37
|
+
severity: item.severity,
|
|
38
|
+
code: item.code ?? '',
|
|
39
|
+
file: item.file ? normalizeText(item.file, input.root) : '',
|
|
40
|
+
line: item.line ?? 0,
|
|
41
|
+
column: item.column ?? 0,
|
|
42
|
+
message: normalizeText(item.message, input.root),
|
|
43
|
+
packageName: item.packageName ?? '',
|
|
44
|
+
}))
|
|
45
|
+
.sort((left, right) => JSON.stringify(left).localeCompare(JSON.stringify(right)));
|
|
46
|
+
return sha256(JSON.stringify({
|
|
47
|
+
level: input.level ?? '',
|
|
48
|
+
status: input.status,
|
|
49
|
+
adapter: input.adapter ?? '',
|
|
50
|
+
command: input.command ? normalizeText(input.command, input.root) : '',
|
|
51
|
+
diagnostics,
|
|
52
|
+
output: diagnostics.length === 0 ? normalizeText(input.output ?? '', input.root) : '',
|
|
53
|
+
}));
|
|
54
|
+
}
|
|
@@ -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
|
+
}
|