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.
@@ -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
+ }
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mocode-ai",
3
- "version": "0.7.1",
3
+ "version": "0.7.2",
4
4
  "description": "终端编码 agent:LLM + tool-call 循环 + 流式输出(含思考)+ 16 个工具,接任意 OpenAI 兼容后端。",
5
5
  "type": "module",
6
6
  "bin": {