mocode-ai 0.7.0 → 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/README.md +4 -3
- package/README.zh-CN.md +3 -3
- package/dist/__trace_manual_test__.js +1 -0
- package/dist/agent/core.js +621 -260
- package/dist/agent/index.js +20 -0
- package/dist/agent/spawn.js +9 -1
- package/dist/config/index.js +12 -0
- package/dist/i18n/index.js +34 -0
- package/dist/llm/index.js +20 -2
- package/dist/mcp/index.js +15 -2
- package/dist/permissions/index.js +149 -93
- package/dist/repl/index.js +66 -10
- package/dist/rollback/index.js +36 -0
- package/dist/session/index.js +3 -0
- package/dist/session/trace-metrics.js +70 -0
- package/dist/session/trace-sanitize.js +34 -0
- package/dist/session/trace.js +54 -0
- package/dist/tools/builtins/edit-file.js +13 -1
- package/dist/tools/builtins/index.js +42 -4
- package/dist/tools/builtins/run-command.js +115 -66
- package/dist/tools/builtins/task.js +5 -2
- package/dist/tools/builtins/write-file.js +13 -1
- package/dist/tools/constants.js +5 -1
- package/dist/tools/registry.js +141 -39
- package/dist/tools/resource-lock.js +148 -0
- package/dist/verification/affected.js +149 -0
- package/dist/verification/diagnostics.js +108 -0
- package/dist/verification/discovery.js +48 -0
- package/dist/verification/fingerprint.js +54 -0
- package/dist/verification/index.js +333 -0
- package/dist/verification/postconditions.js +98 -0
- package/dist/verification/profile.js +237 -0
- package/dist/verification/targeted-tests.js +96 -0
- package/dist/verification/types.js +1 -0
- package/package.json +5 -2
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { getSandboxRoot } from '../sandbox/index.js';
|
|
3
|
+
import { checkPermission } from '../permissions/index.js';
|
|
4
|
+
import { beginWorkspaceMutation, endWorkspaceMutation, getCurrentTurnMutationState, } from '../rollback/index.js';
|
|
5
|
+
import { formatCommandResult, runCommandRaw, runCommandTool, } from '../tools/builtins/run-command.js';
|
|
6
|
+
import { resolveAffectedPackages } from './affected.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';
|
|
11
|
+
import { discoverProjectProfile } from './profile.js';
|
|
12
|
+
import { discoverTargetedTestCommands } from './targeted-tests.js';
|
|
13
|
+
export { resolveAffectedPackages } from './affected.js';
|
|
14
|
+
export { discoverPackageValidationCommand, discoverPackageValidationCommands, discoverValidationCommand, } from './discovery.js';
|
|
15
|
+
export { clearProjectProfileCache, discoverProjectProfile } from './profile.js';
|
|
16
|
+
const NON_CODE_EXTENSIONS = new Set([
|
|
17
|
+
'.md', '.mdx', '.txt', '.rst', '.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp',
|
|
18
|
+
]);
|
|
19
|
+
function isNonCodePath(file) {
|
|
20
|
+
return NON_CODE_EXTENSIONS.has(path.extname(file).toLowerCase());
|
|
21
|
+
}
|
|
22
|
+
function affectedSummary(affected) {
|
|
23
|
+
return affected?.packages.map((item) => ({
|
|
24
|
+
name: item.package.name,
|
|
25
|
+
root: item.package.root,
|
|
26
|
+
reasons: item.reasons,
|
|
27
|
+
})) ?? [];
|
|
28
|
+
}
|
|
29
|
+
function selectionOutput(affected) {
|
|
30
|
+
if (!affected || affected.packages.length === 0)
|
|
31
|
+
return '';
|
|
32
|
+
return [
|
|
33
|
+
'Affected packages:',
|
|
34
|
+
...affected.packages.map((item) => {
|
|
35
|
+
const reasons = item.reasons.map((reason) => `${reason.kind}(${reason.changedPath})`).join(', ');
|
|
36
|
+
return `- ${item.package.name}: ${reasons}`;
|
|
37
|
+
}),
|
|
38
|
+
].join('\n');
|
|
39
|
+
}
|
|
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) {
|
|
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) {
|
|
100
|
+
return {
|
|
101
|
+
level,
|
|
102
|
+
status: 'skipped',
|
|
103
|
+
adapter,
|
|
104
|
+
packageName,
|
|
105
|
+
diagnostics: [],
|
|
106
|
+
output,
|
|
107
|
+
durationMs: 0,
|
|
108
|
+
skipReason: reason,
|
|
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
|
+
},
|
|
193
|
+
};
|
|
194
|
+
}
|
|
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
|
+
}
|
|
217
|
+
}
|
|
218
|
+
/** Run V0→V3 in cost order and stop at the first actionable failure. */
|
|
219
|
+
export async function runAutomaticValidation(signal, callbacks = {}) {
|
|
220
|
+
const before = getCurrentTurnMutationState();
|
|
221
|
+
const root = path.resolve(getSandboxRoot() ?? process.cwd());
|
|
222
|
+
const changedPaths = before.changedFiles.map((item) => item.path);
|
|
223
|
+
if (changedPaths.length === 0)
|
|
224
|
+
return emptyResult(root, 'no_changes', before.version, changedPaths);
|
|
225
|
+
const validationPaths = changedPaths.filter((item) => !isNonCodePath(item));
|
|
226
|
+
if (validationPaths.length === 0) {
|
|
227
|
+
return emptyResult(root, 'non_code_changes', before.version, changedPaths);
|
|
228
|
+
}
|
|
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');
|
|
253
|
+
}
|
|
254
|
+
if (affected.rejected.length > 0) {
|
|
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');
|
|
257
|
+
}
|
|
258
|
+
if (affected.packages.length === 0) {
|
|
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));
|
|
270
|
+
}
|
|
271
|
+
else if (!await runCommands(targeted, root, inputFingerprint, signal, callbacks, stages, packageResults)) {
|
|
272
|
+
return finish(stages.at(-1)?.skipReason === 'permission_denied' ? 'permission_denied' : undefined);
|
|
273
|
+
}
|
|
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);
|
|
312
|
+
return {
|
|
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 })),
|
|
316
|
+
durationMs: 0,
|
|
317
|
+
inputMutationVersion: state.version,
|
|
318
|
+
changedFiles,
|
|
319
|
+
mutationVersion: state.version,
|
|
320
|
+
};
|
|
321
|
+
}
|
|
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
|
+
}
|
|
329
|
+
}
|
|
330
|
+
cache.set(cacheKey, result);
|
|
331
|
+
return result;
|
|
332
|
+
};
|
|
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,237 @@
|
|
|
1
|
+
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import fg from 'fast-glob';
|
|
4
|
+
const cache = new Map();
|
|
5
|
+
const ROOT_INPUTS = [
|
|
6
|
+
'package.json', 'pnpm-workspace.yaml', 'pnpm-workspace.yml',
|
|
7
|
+
'package-lock.json', 'pnpm-lock.yaml', 'yarn.lock', 'bun.lock', 'bun.lockb',
|
|
8
|
+
];
|
|
9
|
+
const SOURCE_DIRS = ['src', 'lib', 'app'];
|
|
10
|
+
const TEST_DIRS = ['test', 'tests', '__tests__', 'spec', 'src/test', 'src/tests', 'src/__tests__'];
|
|
11
|
+
const GENERATED_DIRS = ['generated', 'gen', 'dist', 'build', 'coverage', 'src/generated', 'src/gen'];
|
|
12
|
+
const VENDOR_DIRS = ['vendor', 'vendors', 'third_party', 'third-party', 'node_modules'];
|
|
13
|
+
const FIXTURE_DIRS = ['fixture', 'fixtures', '__fixtures__', 'test/fixtures', 'tests/fixtures'];
|
|
14
|
+
const ALL_MARKED_DIRS = [...SOURCE_DIRS, ...TEST_DIRS, ...GENERATED_DIRS, ...VENDOR_DIRS, ...FIXTURE_DIRS];
|
|
15
|
+
const TSCONFIG_GLOBS = ['tsconfig*.json', '{config,configs}/tsconfig*.json'];
|
|
16
|
+
const TEST_CONFIG_GLOBS = [
|
|
17
|
+
'{vitest,jest,playwright,cypress}.config.{js,cjs,mjs,ts,cts,mts,json}',
|
|
18
|
+
'.mocharc.{js,cjs,mjs,json,yaml,yml}',
|
|
19
|
+
];
|
|
20
|
+
const LINT_CONFIG_GLOBS = [
|
|
21
|
+
'eslint.config.{js,cjs,mjs,ts,cts,mts}',
|
|
22
|
+
'.eslintrc',
|
|
23
|
+
'.eslintrc.{js,cjs,json,yaml,yml}',
|
|
24
|
+
'biome.json',
|
|
25
|
+
'biome.jsonc',
|
|
26
|
+
];
|
|
27
|
+
const GLOB_OPTIONS = {
|
|
28
|
+
absolute: true,
|
|
29
|
+
onlyFiles: true,
|
|
30
|
+
unique: true,
|
|
31
|
+
followSymbolicLinks: false,
|
|
32
|
+
suppressErrors: true,
|
|
33
|
+
ignore: ['**/node_modules/**', '**/.git/**'],
|
|
34
|
+
};
|
|
35
|
+
function readManifest(file) {
|
|
36
|
+
const parsed = JSON.parse(readFileSync(file, 'utf8'));
|
|
37
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
38
|
+
throw new Error(`Invalid package manifest: ${file}`);
|
|
39
|
+
}
|
|
40
|
+
return parsed;
|
|
41
|
+
}
|
|
42
|
+
function packageManagerFromField(value) {
|
|
43
|
+
if (typeof value !== 'string')
|
|
44
|
+
return null;
|
|
45
|
+
const match = /^(npm|pnpm|yarn|bun)(?:@|$)/.exec(value.trim());
|
|
46
|
+
return match ? match[1] : null;
|
|
47
|
+
}
|
|
48
|
+
function detectPackageManager(root, field) {
|
|
49
|
+
const declared = packageManagerFromField(field);
|
|
50
|
+
if (declared)
|
|
51
|
+
return declared;
|
|
52
|
+
if (existsSync(path.join(root, 'pnpm-lock.yaml')))
|
|
53
|
+
return 'pnpm';
|
|
54
|
+
if (existsSync(path.join(root, 'yarn.lock')))
|
|
55
|
+
return 'yarn';
|
|
56
|
+
if (existsSync(path.join(root, 'bun.lock')) || existsSync(path.join(root, 'bun.lockb')))
|
|
57
|
+
return 'bun';
|
|
58
|
+
return 'npm';
|
|
59
|
+
}
|
|
60
|
+
function manifestWorkspacePatterns(value) {
|
|
61
|
+
const patterns = Array.isArray(value)
|
|
62
|
+
? value
|
|
63
|
+
: value && typeof value === 'object'
|
|
64
|
+
? value.packages
|
|
65
|
+
: [];
|
|
66
|
+
return Array.isArray(patterns)
|
|
67
|
+
? patterns.filter((item) => typeof item === 'string' && item.trim().length > 0)
|
|
68
|
+
: [];
|
|
69
|
+
}
|
|
70
|
+
function cleanYamlValue(value) {
|
|
71
|
+
const withoutComment = value.replace(/\s+#.*$/, '').trim();
|
|
72
|
+
const unquoted = withoutComment.replace(/^(['"])(.*)\1$/, '$2').trim();
|
|
73
|
+
return unquoted || null;
|
|
74
|
+
}
|
|
75
|
+
function pnpmWorkspacePatterns(file) {
|
|
76
|
+
if (!existsSync(file))
|
|
77
|
+
return [];
|
|
78
|
+
const lines = readFileSync(file, 'utf8').split(/\r?\n/);
|
|
79
|
+
const result = [];
|
|
80
|
+
let packageIndent = null;
|
|
81
|
+
for (const line of lines) {
|
|
82
|
+
const declaration = /^(\s*)packages\s*:\s*(.*)$/.exec(line);
|
|
83
|
+
if (declaration) {
|
|
84
|
+
packageIndent = declaration[1]?.length ?? 0;
|
|
85
|
+
const inline = declaration[2]?.trim();
|
|
86
|
+
if (inline?.startsWith('[') && inline.endsWith(']')) {
|
|
87
|
+
for (const item of inline.slice(1, -1).split(',')) {
|
|
88
|
+
const value = cleanYamlValue(item);
|
|
89
|
+
if (value)
|
|
90
|
+
result.push(value);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (packageIndent === null || /^\s*(?:#.*)?$/.test(line))
|
|
96
|
+
continue;
|
|
97
|
+
const indent = /^\s*/.exec(line)?.[0].length ?? 0;
|
|
98
|
+
if (indent <= packageIndent)
|
|
99
|
+
break;
|
|
100
|
+
const item = /^\s*-\s*(.+?)\s*$/.exec(line);
|
|
101
|
+
if (item?.[1]) {
|
|
102
|
+
const value = cleanYamlValue(item[1]);
|
|
103
|
+
if (value)
|
|
104
|
+
result.push(value);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return result;
|
|
108
|
+
}
|
|
109
|
+
function uniqueSorted(values) {
|
|
110
|
+
return [...new Set(values)].sort((left, right) => left.localeCompare(right));
|
|
111
|
+
}
|
|
112
|
+
function workspaceManifestGlobs(patterns) {
|
|
113
|
+
return patterns.map((raw) => {
|
|
114
|
+
const negative = raw.startsWith('!');
|
|
115
|
+
const value = (negative ? raw.slice(1) : raw).replace(/[\\/]+$/, '');
|
|
116
|
+
const manifest = value.endsWith('package.json') ? value : `${value}/package.json`;
|
|
117
|
+
return negative ? `!${manifest}` : manifest;
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
function isInside(root, candidate) {
|
|
121
|
+
const relative = path.relative(root, candidate);
|
|
122
|
+
return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative));
|
|
123
|
+
}
|
|
124
|
+
function discoverManifestPaths(root, patterns) {
|
|
125
|
+
const rootManifest = path.join(root, 'package.json');
|
|
126
|
+
const matches = fg.sync(workspaceManifestGlobs(patterns), { cwd: root, ...GLOB_OPTIONS });
|
|
127
|
+
const paths = [
|
|
128
|
+
...(existsSync(rootManifest) ? [rootManifest] : []),
|
|
129
|
+
...matches.map((item) => path.resolve(item)),
|
|
130
|
+
].filter((item) => isInside(root, item));
|
|
131
|
+
return uniqueSorted(paths.map((item) => path.resolve(item)));
|
|
132
|
+
}
|
|
133
|
+
function discoverConfigPaths(root, patterns) {
|
|
134
|
+
return uniqueSorted(fg.sync(patterns, { cwd: root, ...GLOB_OPTIONS }).map((item) => path.resolve(item)));
|
|
135
|
+
}
|
|
136
|
+
function existingDirectories(root, relativePaths) {
|
|
137
|
+
return relativePaths.map((item) => path.resolve(root, item)).filter((item) => {
|
|
138
|
+
try {
|
|
139
|
+
return statSync(item).isDirectory();
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
function scriptsFromManifest(value) {
|
|
147
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
148
|
+
return {};
|
|
149
|
+
return Object.fromEntries(Object.entries(value).filter((entry) => typeof entry[1] === 'string'));
|
|
150
|
+
}
|
|
151
|
+
function createPackageProfile(manifestPath) {
|
|
152
|
+
const root = path.dirname(manifestPath);
|
|
153
|
+
const manifest = readManifest(manifestPath);
|
|
154
|
+
return {
|
|
155
|
+
name: typeof manifest.name === 'string' && manifest.name ? manifest.name : path.basename(root),
|
|
156
|
+
root,
|
|
157
|
+
scripts: scriptsFromManifest(manifest.scripts),
|
|
158
|
+
sourceRoots: existingDirectories(root, SOURCE_DIRS),
|
|
159
|
+
testRoots: existingDirectories(root, TEST_DIRS),
|
|
160
|
+
tsconfigPaths: discoverConfigPaths(root, TSCONFIG_GLOBS),
|
|
161
|
+
testConfigPaths: discoverConfigPaths(root, TEST_CONFIG_GLOBS),
|
|
162
|
+
lintConfigPaths: discoverConfigPaths(root, LINT_CONFIG_GLOBS),
|
|
163
|
+
generatedRoots: existingDirectories(root, GENERATED_DIRS),
|
|
164
|
+
vendorRoots: existingDirectories(root, VENDOR_DIRS),
|
|
165
|
+
fixtureRoots: existingDirectories(root, FIXTURE_DIRS),
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
function inputSignatures(root, workspacePatterns) {
|
|
169
|
+
const manifestPaths = discoverManifestPaths(root, workspacePatterns);
|
|
170
|
+
const packageRoots = uniqueSorted([root, ...manifestPaths.map((item) => path.dirname(item))]);
|
|
171
|
+
const configPaths = packageRoots.flatMap((packageRoot) => [
|
|
172
|
+
...discoverConfigPaths(packageRoot, TSCONFIG_GLOBS),
|
|
173
|
+
...discoverConfigPaths(packageRoot, TEST_CONFIG_GLOBS),
|
|
174
|
+
...discoverConfigPaths(packageRoot, LINT_CONFIG_GLOBS),
|
|
175
|
+
]);
|
|
176
|
+
const directoryPaths = packageRoots.flatMap((packageRoot) => ALL_MARKED_DIRS.map((item) => path.resolve(packageRoot, item)));
|
|
177
|
+
const candidates = uniqueSorted([
|
|
178
|
+
...ROOT_INPUTS.map((item) => path.join(root, item)),
|
|
179
|
+
...manifestPaths,
|
|
180
|
+
...configPaths,
|
|
181
|
+
...directoryPaths,
|
|
182
|
+
]);
|
|
183
|
+
return candidates.flatMap((item) => {
|
|
184
|
+
try {
|
|
185
|
+
const stat = statSync(item);
|
|
186
|
+
return [`${item}\0${stat.isDirectory() ? 'd' : 'f'}\0${stat.size}\0${stat.mtimeMs}`];
|
|
187
|
+
}
|
|
188
|
+
catch {
|
|
189
|
+
return [];
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
function sameSignatures(left, right) {
|
|
194
|
+
return left.length === right.length && left.every((item, index) => item === right[index]);
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Build a safe, cached description of a package project. Manifests are parsed strictly as JSON;
|
|
198
|
+
* package scripts and configuration files are never imported or executed.
|
|
199
|
+
*/
|
|
200
|
+
export function discoverProjectProfile(projectRoot) {
|
|
201
|
+
const root = path.resolve(projectRoot);
|
|
202
|
+
const cached = cache.get(root);
|
|
203
|
+
if (cached) {
|
|
204
|
+
const current = inputSignatures(root, cached.workspacePatterns);
|
|
205
|
+
if (sameSignatures(current, cached.signatures))
|
|
206
|
+
return cached.profile;
|
|
207
|
+
}
|
|
208
|
+
const rootManifestPath = path.join(root, 'package.json');
|
|
209
|
+
const rootManifest = existsSync(rootManifestPath) ? readManifest(rootManifestPath) : {};
|
|
210
|
+
const pnpmFiles = ['pnpm-workspace.yaml', 'pnpm-workspace.yml']
|
|
211
|
+
.map((item) => path.join(root, item))
|
|
212
|
+
.filter((item) => existsSync(item));
|
|
213
|
+
const explicitPatterns = [
|
|
214
|
+
...manifestWorkspacePatterns(rootManifest.workspaces),
|
|
215
|
+
...pnpmFiles.flatMap((item) => pnpmWorkspacePatterns(item)),
|
|
216
|
+
];
|
|
217
|
+
const workspacePatterns = uniqueSorted(explicitPatterns.length > 0 ? explicitPatterns : ['packages/*']);
|
|
218
|
+
const profile = {
|
|
219
|
+
root,
|
|
220
|
+
packageManager: detectPackageManager(root, rootManifest.packageManager),
|
|
221
|
+
workspaceConfigPaths: uniqueSorted([
|
|
222
|
+
...(manifestWorkspacePatterns(rootManifest.workspaces).length > 0 ? [rootManifestPath] : []),
|
|
223
|
+
...pnpmFiles,
|
|
224
|
+
]),
|
|
225
|
+
workspacePatterns,
|
|
226
|
+
packages: discoverManifestPaths(root, workspacePatterns).map(createPackageProfile),
|
|
227
|
+
};
|
|
228
|
+
cache.set(root, { profile, workspacePatterns, signatures: inputSignatures(root, workspacePatterns) });
|
|
229
|
+
return profile;
|
|
230
|
+
}
|
|
231
|
+
/** Clear one cached project profile, or all profiles when no root is provided. */
|
|
232
|
+
export function clearProjectProfileCache(projectRoot) {
|
|
233
|
+
if (projectRoot === undefined)
|
|
234
|
+
cache.clear();
|
|
235
|
+
else
|
|
236
|
+
cache.delete(path.resolve(projectRoot));
|
|
237
|
+
}
|