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.
@@ -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 SCRIPT_PRIORITY = ['typecheck', 'test', 'build'];
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
- /** Discover one lowest-cost validation command for a package in a project profile. */
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 { discoverPackageValidationCommand } from './discovery.js';
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.packages.map((item) => ({
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 skipped(reason, patch = {}) {
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
- output: '',
103
+ adapter,
104
+ packageName,
105
+ diagnostics: [],
106
+ output,
41
107
  durationMs: 0,
42
108
  skipReason: reason,
43
- changedFiles: state.changedFiles.map((item) => item.path),
44
- mutationVersion: state.version,
45
- ...patch,
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 commandSummary(selected) {
49
- return selected.map((item) => `${item.affected.package.name}: ${item.command.command}`).join(' | ');
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 the lowest-cost validation command for each package affected by this turn's code changes. */
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 skipped('no_changes');
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 skipped('non_code_changes');
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
- catch {
72
- return skipped('no_validation_script');
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
- return skipped('invalid_changed_path', {
78
- output: [
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
- return skipped('no_affected_package', { output: selectedOutput, affectedPackages: summary });
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 (selected.length === 0) {
90
- return skipped('no_validation_script', { output: selectedOutput, affectedPackages: summary });
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 commands = commandSummary(selected);
93
- for (const item of selected) {
94
- const relativeCwd = path.relative(root, item.command.cwd) || '.';
95
- const permission = await checkPermission(runCommandTool, { command: item.command.command, path: relativeCwd }, signal, { projectRoot: root });
96
- if (signal?.aborted) {
97
- const state = getCurrentTurnMutationState();
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
- status: 'aborted',
100
- command: commands,
101
- output: selectedOutput,
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
- affectedPackages: summary,
104
- changedFiles: state.changedFiles.map((change) => change.path),
317
+ inputMutationVersion: state.version,
318
+ changedFiles,
105
319
  mutationVersion: state.version,
106
320
  };
107
321
  }
108
- if (permission === 'deny') {
109
- return skipped('permission_denied', {
110
- command: commands,
111
- output: selectedOutput,
112
- affectedPackages: summary,
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
- finally {
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
+ }