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,149 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
const isWindows = process.platform === 'win32';
|
|
3
|
+
const ROOT_CONFIG_NAMES = new Set([
|
|
4
|
+
'package.json', 'pnpm-workspace.yaml', 'pnpm-workspace.yml',
|
|
5
|
+
'package-lock.json', 'pnpm-lock.yaml', 'yarn.lock', 'bun.lock', 'bun.lockb',
|
|
6
|
+
]);
|
|
7
|
+
function comparisonKey(value) {
|
|
8
|
+
const resolved = path.resolve(value);
|
|
9
|
+
return isWindows ? resolved.toLowerCase() : resolved;
|
|
10
|
+
}
|
|
11
|
+
function isInside(parent, child) {
|
|
12
|
+
const relative = path.relative(comparisonKey(parent), comparisonKey(child));
|
|
13
|
+
return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative));
|
|
14
|
+
}
|
|
15
|
+
function toNativeSeparators(value) {
|
|
16
|
+
return value.replace(/[\\/]/g, path.sep);
|
|
17
|
+
}
|
|
18
|
+
function canonicalize(profile, input, base) {
|
|
19
|
+
if (!input.trim() || input.includes('\0'))
|
|
20
|
+
return { input, reason: 'invalid_path' };
|
|
21
|
+
try {
|
|
22
|
+
const absolute = path.resolve(base, toNativeSeparators(input));
|
|
23
|
+
if (!isInside(profile.root, absolute))
|
|
24
|
+
return { input, reason: 'outside_project' };
|
|
25
|
+
const relative = path.relative(profile.root, absolute);
|
|
26
|
+
return {
|
|
27
|
+
absolute,
|
|
28
|
+
key: comparisonKey(absolute),
|
|
29
|
+
display: relative === '' ? '.' : relative.split(path.sep).join('/'),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return { input, reason: 'invalid_path' };
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
function matchesAnyRoot(file, roots) {
|
|
37
|
+
return roots.some((root) => isInside(root, file));
|
|
38
|
+
}
|
|
39
|
+
function classify(file, owner) {
|
|
40
|
+
if (matchesAnyRoot(file, owner.fixtureRoots))
|
|
41
|
+
return 'fixture';
|
|
42
|
+
if (matchesAnyRoot(file, owner.generatedRoots))
|
|
43
|
+
return 'generated';
|
|
44
|
+
if (matchesAnyRoot(file, owner.vendorRoots))
|
|
45
|
+
return 'vendor';
|
|
46
|
+
if (matchesAnyRoot(file, owner.testRoots))
|
|
47
|
+
return 'test';
|
|
48
|
+
if (matchesAnyRoot(file, owner.sourceRoots))
|
|
49
|
+
return 'source';
|
|
50
|
+
return 'other';
|
|
51
|
+
}
|
|
52
|
+
function rootConfigReason(profile, file) {
|
|
53
|
+
const workspaceKeys = new Set(profile.workspaceConfigPaths.map(comparisonKey));
|
|
54
|
+
if (workspaceKeys.has(file.key))
|
|
55
|
+
return 'workspace_config_change';
|
|
56
|
+
const rootPackage = profile.packages.find((item) => comparisonKey(item.root) === comparisonKey(profile.root));
|
|
57
|
+
const configKeys = new Set([
|
|
58
|
+
...(rootPackage?.tsconfigPaths ?? []),
|
|
59
|
+
...(rootPackage?.testConfigPaths ?? []),
|
|
60
|
+
...(rootPackage?.lintConfigPaths ?? []),
|
|
61
|
+
].map(comparisonKey));
|
|
62
|
+
if (configKeys.has(file.key))
|
|
63
|
+
return 'root_config_change';
|
|
64
|
+
if (comparisonKey(path.dirname(file.absolute)) !== comparisonKey(profile.root))
|
|
65
|
+
return null;
|
|
66
|
+
const name = path.basename(file.absolute).toLowerCase();
|
|
67
|
+
if (ROOT_CONFIG_NAMES.has(name) || /^tsconfig(?:\..+)?\.json$/i.test(name)) {
|
|
68
|
+
return name.startsWith('pnpm-workspace') ? 'workspace_config_change' : 'root_config_change';
|
|
69
|
+
}
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
function addReason(selections, packageProfile, reason) {
|
|
73
|
+
const key = comparisonKey(packageProfile.root);
|
|
74
|
+
let selected = selections.get(key);
|
|
75
|
+
if (!selected) {
|
|
76
|
+
selected = { package: packageProfile, reasons: [] };
|
|
77
|
+
selections.set(key, selected);
|
|
78
|
+
}
|
|
79
|
+
if (!selected.reasons.some((item) => item.kind === reason.kind
|
|
80
|
+
&& item.changedPath === reason.changedPath
|
|
81
|
+
&& item.sourcePackage === reason.sourcePackage)) {
|
|
82
|
+
selected.reasons.push(reason);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/** Map changed paths to their longest matching package roots without touching the filesystem. */
|
|
86
|
+
export function resolveAffectedPackages(profile, changedFiles, options) {
|
|
87
|
+
const canonicalByKey = new Map();
|
|
88
|
+
const rejected = [];
|
|
89
|
+
for (const input of changedFiles) {
|
|
90
|
+
const result = canonicalize(profile, input, path.resolve(options.changedFilesBase));
|
|
91
|
+
if ('reason' in result)
|
|
92
|
+
rejected.push(result);
|
|
93
|
+
else if (!canonicalByKey.has(result.key))
|
|
94
|
+
canonicalByKey.set(result.key, result);
|
|
95
|
+
}
|
|
96
|
+
const canonical = [...canonicalByKey.values()];
|
|
97
|
+
const packageByDepth = [...profile.packages].sort((left, right) => comparisonKey(right.root).length - comparisonKey(left.root).length);
|
|
98
|
+
const selections = new Map();
|
|
99
|
+
const unmatchedFiles = [];
|
|
100
|
+
let affectsAll = false;
|
|
101
|
+
for (const file of canonical) {
|
|
102
|
+
const configReason = rootConfigReason(profile, file);
|
|
103
|
+
if (configReason) {
|
|
104
|
+
affectsAll = true;
|
|
105
|
+
for (const packageProfile of profile.packages) {
|
|
106
|
+
addReason(selections, packageProfile, {
|
|
107
|
+
kind: configReason,
|
|
108
|
+
changedPath: file.display,
|
|
109
|
+
classification: 'other',
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
const owner = packageByDepth.find((packageProfile) => isInside(packageProfile.root, file.absolute));
|
|
115
|
+
if (!owner) {
|
|
116
|
+
unmatchedFiles.push(file.display);
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
addReason(selections, owner, {
|
|
120
|
+
kind: 'direct_change',
|
|
121
|
+
changedPath: file.display,
|
|
122
|
+
classification: classify(file.absolute, owner),
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
if (!affectsAll && options.expandDependents && selections.size > 0) {
|
|
126
|
+
const direct = profile.packages.filter((item) => selections.has(comparisonKey(item.root)));
|
|
127
|
+
const sourcePackage = direct.map((item) => item.name).join(', ');
|
|
128
|
+
for (const expanded of options.expandDependents(direct, profile)) {
|
|
129
|
+
const packageProfile = profile.packages.find((item) => comparisonKey(item.root) === comparisonKey(expanded.root));
|
|
130
|
+
if (!packageProfile || selections.has(comparisonKey(packageProfile.root)))
|
|
131
|
+
continue;
|
|
132
|
+
addReason(selections, packageProfile, {
|
|
133
|
+
kind: 'dependent_change',
|
|
134
|
+
changedPath: canonical[0]?.display ?? '.',
|
|
135
|
+
classification: 'other',
|
|
136
|
+
sourcePackage,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return {
|
|
141
|
+
packages: profile.packages
|
|
142
|
+
.map((item) => selections.get(comparisonKey(item.root)))
|
|
143
|
+
.filter((item) => item !== undefined),
|
|
144
|
+
canonicalChangedFiles: canonical.map((item) => item.display),
|
|
145
|
+
rejected,
|
|
146
|
+
unmatchedFiles,
|
|
147
|
+
affectsAll,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { createRequire } from 'node:module';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { pathToFileURL } from 'node:url';
|
|
5
|
+
const SUPPORTED_EXTENSIONS = new Set([
|
|
6
|
+
'.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs',
|
|
7
|
+
]);
|
|
8
|
+
async function loadTypeScript(root) {
|
|
9
|
+
const candidates = [];
|
|
10
|
+
try {
|
|
11
|
+
candidates.push(createRequire(path.join(root, 'package.json')).resolve('typescript'));
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
// Target project may not depend on TypeScript; fall back to mocode's installation.
|
|
15
|
+
}
|
|
16
|
+
try {
|
|
17
|
+
candidates.push(createRequire(import.meta.url).resolve('typescript'));
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
// A production install may intentionally omit the optional parser.
|
|
21
|
+
}
|
|
22
|
+
for (const candidate of [...new Set(candidates)]) {
|
|
23
|
+
try {
|
|
24
|
+
const loaded = await import(pathToFileURL(candidate).href);
|
|
25
|
+
return loaded.default ?? loaded;
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
// Try the next resolution root.
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
function severity(category, ts) {
|
|
34
|
+
if (category === ts.DiagnosticCategory.Error)
|
|
35
|
+
return 'error';
|
|
36
|
+
if (category === ts.DiagnosticCategory.Warning)
|
|
37
|
+
return 'warning';
|
|
38
|
+
return 'info';
|
|
39
|
+
}
|
|
40
|
+
/** Parse only changed TS/JS files; package-wide semantic checking remains V3. */
|
|
41
|
+
export async function runChangedFileDiagnostics(root, changedFiles, inputFingerprint) {
|
|
42
|
+
const startedAt = Date.now();
|
|
43
|
+
const files = [...new Set(changedFiles)]
|
|
44
|
+
.map((file) => path.resolve(process.cwd(), file))
|
|
45
|
+
.filter((file) => SUPPORTED_EXTENSIONS.has(path.extname(file).toLowerCase()));
|
|
46
|
+
if (files.length === 0) {
|
|
47
|
+
return {
|
|
48
|
+
level: 'V1', status: 'skipped', adapter: 'typescript-parser', diagnostics: [],
|
|
49
|
+
output: 'No changed TypeScript or JavaScript files.', durationMs: Date.now() - startedAt,
|
|
50
|
+
skipReason: 'unsupported_files', inputFingerprint,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
const ts = await loadTypeScript(root);
|
|
54
|
+
if (!ts) {
|
|
55
|
+
return {
|
|
56
|
+
level: 'V1', status: 'skipped', adapter: 'typescript-parser', diagnostics: [],
|
|
57
|
+
output: 'TypeScript parser is unavailable.', durationMs: Date.now() - startedAt,
|
|
58
|
+
skipReason: 'typescript_unavailable', inputFingerprint,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
const diagnostics = [];
|
|
62
|
+
for (const file of files) {
|
|
63
|
+
let source;
|
|
64
|
+
try {
|
|
65
|
+
source = await readFile(file, 'utf8');
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
diagnostics.push({
|
|
69
|
+
level: 'V1', source: 'typescript', severity: 'error', code: 'READ_FAILED',
|
|
70
|
+
file: path.relative(root, file), message: error instanceof Error ? error.message : String(error),
|
|
71
|
+
});
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
const result = ts.transpileModule(source, {
|
|
75
|
+
fileName: file,
|
|
76
|
+
reportDiagnostics: true,
|
|
77
|
+
compilerOptions: {
|
|
78
|
+
allowJs: true,
|
|
79
|
+
jsx: ts.JsxEmit.Preserve,
|
|
80
|
+
module: ts.ModuleKind.ESNext,
|
|
81
|
+
target: ts.ScriptTarget.Latest,
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
for (const item of result.diagnostics ?? []) {
|
|
85
|
+
const location = item.file && item.start !== undefined
|
|
86
|
+
? item.file.getLineAndCharacterOfPosition(item.start)
|
|
87
|
+
: undefined;
|
|
88
|
+
diagnostics.push({
|
|
89
|
+
level: 'V1',
|
|
90
|
+
source: 'typescript',
|
|
91
|
+
severity: severity(item.category, ts),
|
|
92
|
+
code: item.code,
|
|
93
|
+
file: item.file ? path.relative(root, item.file.fileName) : path.relative(root, file),
|
|
94
|
+
line: location ? location.line + 1 : undefined,
|
|
95
|
+
column: location ? location.character + 1 : undefined,
|
|
96
|
+
message: ts.flattenDiagnosticMessageText(item.messageText, '\n'),
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
const failed = diagnostics.some((item) => item.severity === 'error');
|
|
101
|
+
const output = diagnostics.length === 0
|
|
102
|
+
? `Parsed ${files.length} changed TypeScript/JavaScript file(s).`
|
|
103
|
+
: diagnostics.map((item) => `${item.file ?? '<unknown>'}:${item.line ?? 0}:${item.column ?? 0} TS${item.code ?? ''} ${item.message}`).join('\n');
|
|
104
|
+
return {
|
|
105
|
+
level: 'V1', status: failed ? 'failed' : 'passed', adapter: 'typescript-parser',
|
|
106
|
+
diagnostics, output, durationMs: Date.now() - startedAt, inputFingerprint,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { discoverProjectProfile } from './profile.js';
|
|
4
|
+
const COMPATIBILITY_PRIORITY = ['typecheck', 'test', 'build'];
|
|
5
|
+
const LAYERED_ORDER = ['typecheck', 'build', 'test'];
|
|
6
|
+
const isWindows = process.platform === 'win32';
|
|
7
|
+
function samePath(left, right) {
|
|
8
|
+
const normalizedLeft = path.resolve(left);
|
|
9
|
+
const normalizedRight = path.resolve(right);
|
|
10
|
+
return isWindows
|
|
11
|
+
? normalizedLeft.toLowerCase() === normalizedRight.toLowerCase()
|
|
12
|
+
: normalizedLeft === normalizedRight;
|
|
13
|
+
}
|
|
14
|
+
function commandFor(profile, packageProfile, script) {
|
|
15
|
+
return {
|
|
16
|
+
script,
|
|
17
|
+
command: `${profile.packageManager} run ${script}`,
|
|
18
|
+
packageManager: profile.packageManager,
|
|
19
|
+
cwd: packageProfile.root,
|
|
20
|
+
};
|
|
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
|
+
}
|
|
33
|
+
/** Compatibility wrapper that discovers the root package validation command. */
|
|
34
|
+
export function discoverValidationCommand(root) {
|
|
35
|
+
const resolvedRoot = path.resolve(root);
|
|
36
|
+
if (!existsSync(path.join(resolvedRoot, 'package.json'))) {
|
|
37
|
+
return { command: null, reason: 'no_package_json' };
|
|
38
|
+
}
|
|
39
|
+
try {
|
|
40
|
+
const profile = discoverProjectProfile(resolvedRoot);
|
|
41
|
+
const rootPackage = profile.packages.find((item) => samePath(item.root, resolvedRoot));
|
|
42
|
+
const command = rootPackage ? discoverPackageValidationCommand(profile, rootPackage) : null;
|
|
43
|
+
return command ? { command } : { command: null, reason: 'no_validation_script' };
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return { command: null, reason: 'no_validation_script' };
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -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
|
+
}
|