mcp-castor 2026.3.0
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 +487 -0
- package/bin/castor.js +706 -0
- package/index.js +206 -0
- package/package.json +97 -0
- package/skills/canary-test-staging/SKILL.md +24 -0
- package/skills/evo-mutation-rollback/SKILL.md +29 -0
- package/skills/hypothesis-generation/SKILL.md +26 -0
- package/skills/traceback-condensing/SKILL.md +26 -0
- package/src/castor_runner.js +469 -0
- package/src/config.js +1204 -0
- package/src/env.js +10 -0
- package/src/evo_engine.js +214 -0
- package/src/harness/core/events.js +75 -0
- package/src/harness/core/kernel.js +209 -0
- package/src/harness/evo/evaluator.js +156 -0
- package/src/harness/evo/evo_operator.js +550 -0
- package/src/harness/evo/lineage_dag.js +383 -0
- package/src/harness/evo/trace_repair.js +173 -0
- package/src/harness/evo/watchdog.js +72 -0
- package/src/harness/loop_detector.js +135 -0
- package/src/harness/runner.js +1216 -0
- package/src/harness/services/ast_service.js +1813 -0
- package/src/harness/services/event_logger.js +275 -0
- package/src/harness/services/mcp_bridge.js +408 -0
- package/src/harness/services/provider_vllm.js +728 -0
- package/src/harness/services/sandbox_fs.js +1238 -0
- package/src/harness/services/searxng_lifecycle.js +254 -0
- package/src/harness/services/shell_executor.js +264 -0
- package/src/harness/services/shell_validator.js +506 -0
- package/src/harness/services/web_service.js +828 -0
- package/src/platform.js +344 -0
- package/src/repetition_detector.js +139 -0
- package/src/semaphore.js +373 -0
- package/src/server_lifecycle.js +781 -0
- package/src/skills.js +400 -0
- package/src/state_pruner.js +392 -0
- package/src/task_registry.js +1357 -0
- package/src/telemetry.js +638 -0
- package/src/tools.js +997 -0
- package/src/wsl_bridge.js +629 -0
- package/src/wsl_env.js +171 -0
- package/stream_proxy.js +453 -0
|
@@ -0,0 +1,1813 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structural AST Service (Powered by ast-grep)
|
|
3
|
+
*
|
|
4
|
+
* Provides:
|
|
5
|
+
* - Code-like pattern matching (ast_search) with metavariables ($VAR, $$$BODY)
|
|
6
|
+
* - Structural code surgery (ast_replace) that preserves formatting and ignores whitespace
|
|
7
|
+
* - Mandatory compile/syntax validation before writing mutations to disk
|
|
8
|
+
* - Castor plugin integration with reversible registration
|
|
9
|
+
* - Dual Windows NTFS and WSL DrvFs cross-path safety
|
|
10
|
+
*
|
|
11
|
+
* Engine selection (P5):
|
|
12
|
+
* - Primary: in-process @ast-grep/napi binding (lazy-loaded, cached).
|
|
13
|
+
* Supported languages: js, jsx, ts, tsx, html, css.
|
|
14
|
+
* - Fallback: ast-grep CLI (execFileSync). Used automatically when the napi
|
|
15
|
+
* binding is unavailable OR the target language is not supported by napi
|
|
16
|
+
* (python, go, rust, c, cpp, json). The choice is invisible to callers.
|
|
17
|
+
* - The exit-1/empty-stdout "no matches" quirk is handled ONLY in the CLI path.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import fs from "node:fs";
|
|
21
|
+
import path from "node:path";
|
|
22
|
+
import os from "node:os";
|
|
23
|
+
import { execFileSync } from "node:child_process";
|
|
24
|
+
import { createRequire } from "node:module";
|
|
25
|
+
import { fileURLToPath } from "node:url";
|
|
26
|
+
import { IS_WINDOWS } from "../../config.js";
|
|
27
|
+
import { toWindowsPath, toPosixWslPath, canonicalizePath } from "../../wsl_bridge.js";
|
|
28
|
+
import { DEFAULT_IGNORED_DIRS } from "./sandbox_fs.js";
|
|
29
|
+
|
|
30
|
+
const PKG_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");
|
|
31
|
+
|
|
32
|
+
const EXT_TO_LANG = {
|
|
33
|
+
".js": "js",
|
|
34
|
+
".mjs": "js",
|
|
35
|
+
".cjs": "js",
|
|
36
|
+
".jsx": "jsx",
|
|
37
|
+
".ts": "ts",
|
|
38
|
+
".mts": "ts",
|
|
39
|
+
".cts": "ts",
|
|
40
|
+
".tsx": "tsx",
|
|
41
|
+
".py": "python",
|
|
42
|
+
".pyw": "python",
|
|
43
|
+
".go": "go",
|
|
44
|
+
".rs": "rust",
|
|
45
|
+
".c": "c",
|
|
46
|
+
".h": "c",
|
|
47
|
+
".cpp": "cpp",
|
|
48
|
+
".hpp": "cpp",
|
|
49
|
+
".cc": "cpp",
|
|
50
|
+
".html": "html",
|
|
51
|
+
".css": "css",
|
|
52
|
+
".json": "json",
|
|
53
|
+
".tex": "latex",
|
|
54
|
+
".latex": "latex",
|
|
55
|
+
".sty": "latex",
|
|
56
|
+
".cls": "latex",
|
|
57
|
+
".bib": "bibtex",
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
// Languages the in-process @ast-grep/napi binding can parse.
|
|
61
|
+
// (napi ships tree-sitter grammars for these only; everything else uses the CLI.)
|
|
62
|
+
const NAPI_LANGS = new Set(["js", "jsx", "ts", "tsx", "html", "css"]);
|
|
63
|
+
|
|
64
|
+
// Global cap on matches returned by any search (single file or directory scan).
|
|
65
|
+
const MAX_MATCHES = 50;
|
|
66
|
+
|
|
67
|
+
// Files larger than this are skipped during directory scans (implausible for AST).
|
|
68
|
+
const MAX_FILE_BYTES = 1_000_000;
|
|
69
|
+
|
|
70
|
+
// P7 — A target string is treated as a glob when it contains any of the
|
|
71
|
+
// standard glob metacharacters. Plain relative/absolute paths (no metachars)
|
|
72
|
+
// are treated as literal file or directory paths.
|
|
73
|
+
const GLOB_META_RE = /[*?[\]{}!+()]/;
|
|
74
|
+
function isGlobPattern(p) {
|
|
75
|
+
return typeof p === "string" && GLOB_META_RE.test(p);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
// Lazy napi engine loader (module-level cache, shared across all AstService
|
|
80
|
+
// instances). On ANY load failure we log a single stderr note and return null,
|
|
81
|
+
// which routes the caller to the CLI fallback.
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
let _napiEngine = null;
|
|
84
|
+
let _napiLoadAttempted = false;
|
|
85
|
+
|
|
86
|
+
async function loadNapiEngine() {
|
|
87
|
+
if (_napiLoadAttempted) return _napiEngine;
|
|
88
|
+
_napiLoadAttempted = true;
|
|
89
|
+
try {
|
|
90
|
+
const mod = await import("@ast-grep/napi");
|
|
91
|
+
const engine = mod && mod.default ? mod.default : mod;
|
|
92
|
+
if (engine && typeof engine.parse === "function" && typeof engine.pattern === "function") {
|
|
93
|
+
_napiEngine = engine;
|
|
94
|
+
} else {
|
|
95
|
+
_napiEngine = null;
|
|
96
|
+
process.stderr.write("[ast_service] @ast-grep/napi loaded but incomplete; using CLI fallback\n");
|
|
97
|
+
}
|
|
98
|
+
} catch (err) {
|
|
99
|
+
_napiEngine = null;
|
|
100
|
+
process.stderr.write(`[ast_service] @ast-grep/napi unavailable (${err.message}); using CLI fallback\n`);
|
|
101
|
+
}
|
|
102
|
+
return _napiEngine;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
// P6 — Universal syntax-gate infrastructure.
|
|
107
|
+
//
|
|
108
|
+
// The syntax gate (validateSyntax) must cover EVERY language the AST engine
|
|
109
|
+
// can rewrite. Each language has a dedicated checker; when a checker is
|
|
110
|
+
// unavailable (binary missing, module failed to load) the gate degrades
|
|
111
|
+
// HONESTLY: it returns a structured { checked: false, ... } result and a
|
|
112
|
+
// stderr note, and NEVER reports an unchecked rewrite as "valid".
|
|
113
|
+
//
|
|
114
|
+
// All external checkers are probed ONCE and cached (module-level), mirroring
|
|
115
|
+
// the lazy-load discipline of the napi engine so startup stays light.
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
// createRequire so the typescript package can be loaded synchronously and
|
|
119
|
+
// lazily (it is a CJS package; a dynamic import would force an async path in
|
|
120
|
+
// the otherwise-synchronous validateSyntax gate).
|
|
121
|
+
const _require = createRequire(import.meta.url);
|
|
122
|
+
|
|
123
|
+
// --- TypeScript (ts/tsx/mts/cts) ------------------------------------------
|
|
124
|
+
// Lazy-loaded once; null if the module fails to load (treated as unavailable).
|
|
125
|
+
let _tsModule = null;
|
|
126
|
+
let _tsLoadAttempted = false;
|
|
127
|
+
|
|
128
|
+
function loadTypescript() {
|
|
129
|
+
// Test seam: a forced "unavailable" override short-circuits the real load.
|
|
130
|
+
if (_probeOverrides.typescript === false) return null;
|
|
131
|
+
if (_tsLoadAttempted) return _tsModule;
|
|
132
|
+
_tsLoadAttempted = true;
|
|
133
|
+
try {
|
|
134
|
+
const mod = _require("typescript");
|
|
135
|
+
if (mod && typeof mod.transpileModule === "function") {
|
|
136
|
+
_tsModule = mod;
|
|
137
|
+
} else {
|
|
138
|
+
_tsModule = null;
|
|
139
|
+
process.stderr.write("[ast_service] typescript loaded but incomplete; TS syntax gate unavailable\n");
|
|
140
|
+
}
|
|
141
|
+
} catch (err) {
|
|
142
|
+
_tsModule = null;
|
|
143
|
+
process.stderr.write(`[ast_service] typescript unavailable (${err.message}); TS syntax gate unavailable\n`);
|
|
144
|
+
}
|
|
145
|
+
return _tsModule;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// --- External binary availability probes (cached once) ----------------------
|
|
149
|
+
// A probe returns true only when the binary exists AND runs successfully
|
|
150
|
+
// (exit 0). ENOENT (binary missing) and non-zero exit both mean "unavailable".
|
|
151
|
+
function probeBinary(cmd, args) {
|
|
152
|
+
try {
|
|
153
|
+
execFileSync(cmd, args, { stdio: "ignore", timeout: 10000, windowsHide: true });
|
|
154
|
+
return true;
|
|
155
|
+
} catch (err) {
|
|
156
|
+
// ENOENT => binary not on PATH; non-zero exit => present but not runnable.
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Seamable override registry: tests can force a checker to "available" or
|
|
162
|
+
// "unavailable" without touching the real environment (dependency injection).
|
|
163
|
+
// Keys: "python" | "gofmt" | "rustfmt" | "typescript". Value: boolean, or
|
|
164
|
+
// undefined to clear the override and fall back to the real probe.
|
|
165
|
+
const _probeOverrides = {};
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Test seam: force a syntax-gate checker to be treated as available/unavailable
|
|
169
|
+
* regardless of the real environment. Pass undefined to clear the override.
|
|
170
|
+
* @param {"python"|"gofmt"|"rustfmt"|"typescript"} name
|
|
171
|
+
* @param {boolean|undefined} available
|
|
172
|
+
*/
|
|
173
|
+
export function setSyntaxProbeOverride(name, available) {
|
|
174
|
+
if (available === undefined) delete _probeOverrides[name];
|
|
175
|
+
else _probeOverrides[name] = available;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
let _pythonProbe = null; // null = not probed yet; true/false = cached result
|
|
179
|
+
function pythonAvailable() {
|
|
180
|
+
if ("python" in _probeOverrides) return _probeOverrides.python;
|
|
181
|
+
if (_pythonProbe !== null) return _pythonProbe;
|
|
182
|
+
const bin = IS_WINDOWS ? "python" : "python3";
|
|
183
|
+
_pythonProbe = probeBinary(bin, ["--version"]);
|
|
184
|
+
if (!_pythonProbe) {
|
|
185
|
+
process.stderr.write(`[ast_service] python (${bin}) unavailable; Python syntax gate will degrade honestly\n`);
|
|
186
|
+
}
|
|
187
|
+
return _pythonProbe;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
let _gofmtProbe = null;
|
|
191
|
+
function gofmtAvailable() {
|
|
192
|
+
if ("gofmt" in _probeOverrides) return _probeOverrides.gofmt;
|
|
193
|
+
if (_gofmtProbe !== null) return _gofmtProbe;
|
|
194
|
+
_gofmtProbe = probeBinary("gofmt", ["-h"]);
|
|
195
|
+
if (!_gofmtProbe) {
|
|
196
|
+
process.stderr.write("[ast_service] gofmt unavailable; Go syntax gate will degrade honestly\n");
|
|
197
|
+
}
|
|
198
|
+
return _gofmtProbe;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
let _rustfmtProbe = null;
|
|
202
|
+
function rustfmtAvailable() {
|
|
203
|
+
if ("rustfmt" in _probeOverrides) return _probeOverrides.rustfmt;
|
|
204
|
+
if (_rustfmtProbe !== null) return _rustfmtProbe;
|
|
205
|
+
_rustfmtProbe = probeBinary("rustfmt", ["--version"]);
|
|
206
|
+
if (!_rustfmtProbe) {
|
|
207
|
+
process.stderr.write("[ast_service] rustfmt unavailable; Rust syntax gate will degrade honestly\n");
|
|
208
|
+
}
|
|
209
|
+
return _rustfmtProbe;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Extracts single ($NAME) and multi ($$$NAME) metavariable names from a pattern.
|
|
214
|
+
* Returns { single: Set<string>, multi: Set<string> }.
|
|
215
|
+
*/
|
|
216
|
+
function extractMetaVars(pattern) {
|
|
217
|
+
const single = new Set();
|
|
218
|
+
const multi = new Set();
|
|
219
|
+
const re = /\$+([A-Z][A-Z0-9_]*)/g;
|
|
220
|
+
let m;
|
|
221
|
+
while ((m = re.exec(pattern)) !== null) {
|
|
222
|
+
const dollarCount = m[0].length - m[1].length;
|
|
223
|
+
if (dollarCount >= 3) multi.add(m[1]);
|
|
224
|
+
else single.add(m[1]);
|
|
225
|
+
}
|
|
226
|
+
return { single, multi };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export class AstService {
|
|
230
|
+
constructor(options = {}) {
|
|
231
|
+
// P4i: canonicalize the AST workspace root through the OS symlink/junction
|
|
232
|
+
// resolution layer so a junction/symlink cwd (e.g. D:\mnt\d -> D:\) is
|
|
233
|
+
// stored as its real path. Containment checks then compare realpath
|
|
234
|
+
// against a real root, eliminating false escape errors while still
|
|
235
|
+
// catching real escapes.
|
|
236
|
+
const rawRoot = options.root || process.cwd();
|
|
237
|
+
this.root = canonicalizePath(rawRoot);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
detectLanguage(filePath, explicitLang) {
|
|
241
|
+
if (explicitLang) return explicitLang.toLowerCase();
|
|
242
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
243
|
+
return EXT_TO_LANG[ext] || "js";
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Infers a language purely by file extension for directory scans.
|
|
248
|
+
* Returns null when the extension is not in the supported set (file is skipped).
|
|
249
|
+
*/
|
|
250
|
+
inferLanguageByExtension(filePath) {
|
|
251
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
252
|
+
return EXT_TO_LANG[ext] || null;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
resolvePath(inputPath) {
|
|
256
|
+
if (!inputPath) throw new Error("Path parameter is required for AST operation");
|
|
257
|
+
if (typeof inputPath !== "string") {
|
|
258
|
+
throw new Error("InvalidPathError: Path must be a string");
|
|
259
|
+
}
|
|
260
|
+
if (inputPath.includes("\0")) {
|
|
261
|
+
throw new Error("NullByteError: Path contains prohibited null byte character");
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
let p = inputPath.trim();
|
|
265
|
+
const baseName = path.basename(p.replace(/\\/g, "/")).toUpperCase();
|
|
266
|
+
const reserved = /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(\..*)?$/;
|
|
267
|
+
if (reserved.test(baseName)) {
|
|
268
|
+
throw new Error(`DeviceNameError: Prohibited access to Windows reserved device '${baseName}'`);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (IS_WINDOWS) {
|
|
272
|
+
p = toWindowsPath(p);
|
|
273
|
+
if (!path.isAbsolute(p)) {
|
|
274
|
+
p = path.resolve(this.root, p);
|
|
275
|
+
}
|
|
276
|
+
} else {
|
|
277
|
+
p = toPosixWslPath(p);
|
|
278
|
+
if (!path.isAbsolute(p)) {
|
|
279
|
+
p = path.resolve(this.root, p);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
const normalizedTarget = path.normalize(p);
|
|
283
|
+
const normalizedRoot = path.normalize(this.root);
|
|
284
|
+
|
|
285
|
+
// P4i: canonicalize BOTH sides of the containment comparison through the
|
|
286
|
+
// OS symlink/junction resolution layer so a junction-form target is
|
|
287
|
+
// compared against the real root in the same "real" path space. This
|
|
288
|
+
// eliminates false PathEscapeError/SymlinkEscapeError from junction/symlink
|
|
289
|
+
// cwds while real escapes (../outside, symlink-to-outside) still resolve
|
|
290
|
+
// outside the real root and are caught.
|
|
291
|
+
const realTarget = canonicalizePath(normalizedTarget);
|
|
292
|
+
const realRoot = canonicalizePath(normalizedRoot);
|
|
293
|
+
const rel = path.relative(realRoot, realTarget);
|
|
294
|
+
if (rel.startsWith("..") || path.isAbsolute(rel)) {
|
|
295
|
+
throw new Error(`PathEscapeError: Access denied. Path '${inputPath}' escapes sandbox root '${this.root}'`);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Symlink escape verification (both sides now in real-path space)
|
|
299
|
+
this.verifySymlinkContainment(realTarget, realRoot);
|
|
300
|
+
|
|
301
|
+
// Return the ORIGINAL normalized path (not the canonical one) so that
|
|
302
|
+
// downstream path labels (napi fileLabel, CLI m.file, path.relative in
|
|
303
|
+
// tests) stay consistent with the path the caller passed in. The
|
|
304
|
+
// containment decision above was made in canonical space, which is what
|
|
305
|
+
// matters for security; the returned label is only used for reporting.
|
|
306
|
+
return normalizedTarget;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
verifySymlinkContainment(targetPath, rootPath) {
|
|
310
|
+
try {
|
|
311
|
+
if (fs.existsSync(targetPath)) {
|
|
312
|
+
const real = fs.realpathSync(targetPath);
|
|
313
|
+
const relReal = path.relative(rootPath, path.normalize(real));
|
|
314
|
+
if (relReal.startsWith("..") || path.isAbsolute(relReal)) {
|
|
315
|
+
throw new Error(`SymlinkEscapeError: Real path '${real}' escapes sandbox root '${rootPath}'`);
|
|
316
|
+
}
|
|
317
|
+
} else {
|
|
318
|
+
let parent = path.dirname(targetPath);
|
|
319
|
+
while (parent && parent !== path.dirname(parent)) {
|
|
320
|
+
if (fs.existsSync(parent)) {
|
|
321
|
+
const realParent = fs.realpathSync(parent);
|
|
322
|
+
const relReal = path.relative(rootPath, path.normalize(realParent));
|
|
323
|
+
if (relReal.startsWith("..") || path.isAbsolute(relReal)) {
|
|
324
|
+
throw new Error(`SymlinkEscapeError: Parent directory '${parent}' resolves to '${realParent}' escaping root '${rootPath}'`);
|
|
325
|
+
}
|
|
326
|
+
break;
|
|
327
|
+
}
|
|
328
|
+
parent = path.dirname(parent);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
} catch (err) {
|
|
332
|
+
if (err.message.startsWith("SymlinkEscapeError")) throw err;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Resolves the ast-grep CLI binary across platform candidate locations.
|
|
338
|
+
* @returns {string} Path or command name for the ast-grep binary.
|
|
339
|
+
*/
|
|
340
|
+
getBinary() {
|
|
341
|
+
const winCandidates = [
|
|
342
|
+
path.join(PKG_ROOT, "node_modules", "@ast-grep", "cli-win32-x64-msvc", "ast-grep.exe"),
|
|
343
|
+
path.join(this.root, "node_modules", "@ast-grep", "cli-win32-x64-msvc", "ast-grep.exe"),
|
|
344
|
+
];
|
|
345
|
+
const linuxCandidates = [
|
|
346
|
+
path.join(PKG_ROOT, "node_modules", "@ast-grep", "cli-linux-x64-gnu", "ast-grep"),
|
|
347
|
+
path.join(this.root, "node_modules", "@ast-grep", "cli-linux-x64-gnu", "ast-grep"),
|
|
348
|
+
];
|
|
349
|
+
|
|
350
|
+
if (IS_WINDOWS) {
|
|
351
|
+
for (const p of winCandidates) {
|
|
352
|
+
if (fs.existsSync(p)) return p;
|
|
353
|
+
}
|
|
354
|
+
return "ast-grep";
|
|
355
|
+
} else {
|
|
356
|
+
for (const p of linuxCandidates) {
|
|
357
|
+
if (fs.existsSync(p)) return p;
|
|
358
|
+
}
|
|
359
|
+
return "ast-grep";
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// -------------------------------------------------------------------------
|
|
364
|
+
// Search
|
|
365
|
+
// -------------------------------------------------------------------------
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Searches for syntactic code patterns using ast-grep.
|
|
369
|
+
*
|
|
370
|
+
* @param {object} params
|
|
371
|
+
* @param {string} params.path Target file or directory
|
|
372
|
+
* @param {string} params.pattern Search pattern with metavariables ($VAR, $$$BODY)
|
|
373
|
+
* @param {string} [params.lang] Optional language identifier
|
|
374
|
+
* @returns {Promise<{ path: string, language: string|null, count: number, matches: Array<object> }>}
|
|
375
|
+
*/
|
|
376
|
+
async search({ path: targetPath, pattern, lang }) {
|
|
377
|
+
if (!pattern || typeof pattern !== "string" || pattern.trim().length === 0) {
|
|
378
|
+
throw new Error("AST search requires a non-empty pattern");
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const resolved = this.resolvePath(targetPath);
|
|
382
|
+
if (!fs.existsSync(resolved)) {
|
|
383
|
+
throw new Error(`Path does not exist: ${targetPath}`);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
if (fs.statSync(resolved).isDirectory()) {
|
|
387
|
+
return this.searchDirectory(resolved, pattern);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const detectedLang = this.detectLanguage(resolved, lang);
|
|
391
|
+
const matches = await this.searchFile(resolved, detectedLang, pattern);
|
|
392
|
+
return {
|
|
393
|
+
path: resolved,
|
|
394
|
+
language: detectedLang,
|
|
395
|
+
count: matches.length,
|
|
396
|
+
matches: matches.slice(0, MAX_MATCHES),
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Searches a single file, choosing the napi or CLI engine transparently.
|
|
402
|
+
* Returns the raw (uncapped) array of match objects.
|
|
403
|
+
*/
|
|
404
|
+
async searchFile(resolved, lang, pattern) {
|
|
405
|
+
const engine = await loadNapiEngine();
|
|
406
|
+
if (engine && NAPI_LANGS.has(lang)) {
|
|
407
|
+
const content = fs.readFileSync(resolved, "utf8");
|
|
408
|
+
return this.napiSearch(engine, content, lang, pattern, resolved);
|
|
409
|
+
}
|
|
410
|
+
return this.cliSearch(resolved, lang, pattern);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* P7 — Additive match enrichment. Adds two derived fields to an existing
|
|
415
|
+
* match object WITHOUT altering any pre-existing field (live agents consume
|
|
416
|
+
* the original shape):
|
|
417
|
+
* - location: `${file}:${line}:${col}` (0-indexed line/col, matching the
|
|
418
|
+
* existing `line`/`column` contract) for quick grep-able referencing.
|
|
419
|
+
* - snippet: the match text collapsed to a single trimmed line (all
|
|
420
|
+
* whitespace runs -> one space) so multi-line matches render compactly.
|
|
421
|
+
* The original `file`, `line`, `column`, `text`, `metavariables` fields are
|
|
422
|
+
* preserved verbatim.
|
|
423
|
+
*/
|
|
424
|
+
_enrichMatch(m) {
|
|
425
|
+
const snippet = (m.text || "").replace(/\s+/g, " ").trim();
|
|
426
|
+
return {
|
|
427
|
+
...m,
|
|
428
|
+
location: `${m.file}:${m.line}:${m.column}`,
|
|
429
|
+
snippet,
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* In-process napi search. Produces the exact same match shape as the CLI path:
|
|
435
|
+
* { file, line, column, text, metavariables } plus the additive P7
|
|
436
|
+
* { location, snippet } fields. Both engines report 0-indexed line /
|
|
437
|
+
* 0-indexed column, so the values pass through unchanged.
|
|
438
|
+
*/
|
|
439
|
+
napiSearch(engine, content, lang, pattern, fileLabel) {
|
|
440
|
+
const root = engine.parse(lang, content);
|
|
441
|
+
const rootNode = root.root();
|
|
442
|
+
const config = engine.pattern(lang, pattern);
|
|
443
|
+
const matches = rootNode.findAll(config);
|
|
444
|
+
const { single } = extractMetaVars(pattern);
|
|
445
|
+
|
|
446
|
+
return matches.map((m) => {
|
|
447
|
+
const range = m.range();
|
|
448
|
+
const mv = {};
|
|
449
|
+
for (const name of single) {
|
|
450
|
+
const node = m.getMatch(name);
|
|
451
|
+
if (node) mv[name] = node.text();
|
|
452
|
+
}
|
|
453
|
+
return this._enrichMatch({
|
|
454
|
+
file: fileLabel,
|
|
455
|
+
// Both the CLI --json=compact and napi report 0-indexed line / 0-indexed
|
|
456
|
+
// column. Pass through raw so the napi path is byte-identical to the CLI
|
|
457
|
+
// path (the existing tool-result contract).
|
|
458
|
+
line: range.start.line,
|
|
459
|
+
column: range.start.column,
|
|
460
|
+
text: m.text(),
|
|
461
|
+
metavariables: mv,
|
|
462
|
+
});
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* CLI search (fallback). The exit-1/empty-stdout "no matches" quirk is handled
|
|
468
|
+
* ONLY here.
|
|
469
|
+
*/
|
|
470
|
+
cliSearch(file, lang, pattern) {
|
|
471
|
+
const bin = this.getBinary();
|
|
472
|
+
const args = [
|
|
473
|
+
"run",
|
|
474
|
+
"--pattern",
|
|
475
|
+
pattern,
|
|
476
|
+
"--lang",
|
|
477
|
+
lang,
|
|
478
|
+
"--json=compact",
|
|
479
|
+
file,
|
|
480
|
+
];
|
|
481
|
+
|
|
482
|
+
try {
|
|
483
|
+
const stdout = execFileSync(bin, args, {
|
|
484
|
+
cwd: this.root,
|
|
485
|
+
encoding: "utf8",
|
|
486
|
+
timeout: 30_000,
|
|
487
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
488
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
489
|
+
windowsHide: true,
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
const parsed = stdout.trim() ? JSON.parse(stdout) : [];
|
|
493
|
+
return parsed.map((m) => {
|
|
494
|
+
const mv = {};
|
|
495
|
+
const rawMv = m.metaVariables?.single || {};
|
|
496
|
+
for (const [k, v] of Object.entries(rawMv)) {
|
|
497
|
+
mv[k] = typeof v === "object" && v !== null && "text" in v ? v.text : v;
|
|
498
|
+
}
|
|
499
|
+
return this._enrichMatch({
|
|
500
|
+
file: m.file,
|
|
501
|
+
line: m.range?.start?.line ?? 1,
|
|
502
|
+
column: m.range?.start?.column ?? 1,
|
|
503
|
+
text: m.text,
|
|
504
|
+
metavariables: mv,
|
|
505
|
+
});
|
|
506
|
+
});
|
|
507
|
+
} catch (err) {
|
|
508
|
+
// Exit code 1 with empty stdout means 0 matches found in ast-grep
|
|
509
|
+
if (err.status === 1 && !err.stderr) {
|
|
510
|
+
return [];
|
|
511
|
+
}
|
|
512
|
+
throw new Error(`AST search error: ${err.stderr || err.message}`);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* Directory-level search: recursively walks `resolved`, skipping the sandbox
|
|
518
|
+
* ignore set, inferring language by extension, and applying a global
|
|
519
|
+
* MAX_MATCHES cap across the whole scan.
|
|
520
|
+
*/
|
|
521
|
+
async searchDirectory(resolved, pattern) {
|
|
522
|
+
const engine = await loadNapiEngine();
|
|
523
|
+
const matches = [];
|
|
524
|
+
|
|
525
|
+
const walk = async (dir) => {
|
|
526
|
+
if (matches.length >= MAX_MATCHES) return;
|
|
527
|
+
let entries;
|
|
528
|
+
try {
|
|
529
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
530
|
+
} catch {
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
for (const entry of entries) {
|
|
534
|
+
if (matches.length >= MAX_MATCHES) break;
|
|
535
|
+
if (DEFAULT_IGNORED_DIRS.has(entry.name)) continue;
|
|
536
|
+
const full = path.join(dir, entry.name);
|
|
537
|
+
if (entry.isDirectory()) {
|
|
538
|
+
await walk(full);
|
|
539
|
+
} else if (entry.isFile()) {
|
|
540
|
+
const lang = this.inferLanguageByExtension(full);
|
|
541
|
+
if (!lang) continue; // language cannot be inferred -> skip
|
|
542
|
+
let st;
|
|
543
|
+
try {
|
|
544
|
+
st = fs.statSync(full);
|
|
545
|
+
} catch {
|
|
546
|
+
continue;
|
|
547
|
+
}
|
|
548
|
+
if (st.size > MAX_FILE_BYTES) continue; // implausibly large -> skip
|
|
549
|
+
try {
|
|
550
|
+
const fileMatches = await this.searchFile(full, lang, pattern);
|
|
551
|
+
for (const m of fileMatches) {
|
|
552
|
+
if (matches.length >= MAX_MATCHES) break;
|
|
553
|
+
matches.push(m);
|
|
554
|
+
}
|
|
555
|
+
} catch {
|
|
556
|
+
// A file that fails to parse (e.g. syntax error) is skipped; the
|
|
557
|
+
// scan continues with the remaining files.
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
};
|
|
562
|
+
|
|
563
|
+
await walk(resolved);
|
|
564
|
+
|
|
565
|
+
return {
|
|
566
|
+
path: resolved,
|
|
567
|
+
language: null, // mixed languages across a directory scan
|
|
568
|
+
count: matches.length,
|
|
569
|
+
matches: matches.slice(0, MAX_MATCHES),
|
|
570
|
+
};
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// -------------------------------------------------------------------------
|
|
574
|
+
// Replace
|
|
575
|
+
// -------------------------------------------------------------------------
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* Replaces a syntactic AST pattern with a rewritten pattern.
|
|
579
|
+
* Performs mandatory compile/syntax checking before committing to disk.
|
|
580
|
+
*
|
|
581
|
+
* @param {object} params
|
|
582
|
+
* @param {string} params.path Target file
|
|
583
|
+
* @param {string} params.pattern Search pattern with metavariables ($VAR, $$$BODY)
|
|
584
|
+
* @param {string} params.rewrite Replacement pattern
|
|
585
|
+
* @param {string} [params.lang] Optional language identifier
|
|
586
|
+
* @returns {Promise<{ path: string, modified: boolean, message: string }>}
|
|
587
|
+
*/
|
|
588
|
+
async replace({ path: targetPath, pattern, rewrite, lang, dry_run }) {
|
|
589
|
+
if (!pattern || typeof pattern !== "string") {
|
|
590
|
+
throw new Error("AST replace requires a pattern parameter");
|
|
591
|
+
}
|
|
592
|
+
if (typeof rewrite !== "string") {
|
|
593
|
+
throw new Error("AST replace requires a rewrite parameter");
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
const resolved = this.resolvePath(targetPath);
|
|
597
|
+
if (!fs.existsSync(resolved)) {
|
|
598
|
+
throw new Error(`File does not exist: ${targetPath}`);
|
|
599
|
+
}
|
|
600
|
+
if (fs.statSync(resolved).isDirectory()) {
|
|
601
|
+
throw new Error(`Target path must be a file, not a directory: ${targetPath}`);
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
const originalContent = fs.readFileSync(resolved, "utf8");
|
|
605
|
+
const detectedLang = this.detectLanguage(resolved, lang);
|
|
606
|
+
|
|
607
|
+
const engine = await loadNapiEngine();
|
|
608
|
+
if (engine && NAPI_LANGS.has(detectedLang)) {
|
|
609
|
+
return this.napiReplace(engine, resolved, originalContent, detectedLang, pattern, rewrite, dry_run);
|
|
610
|
+
}
|
|
611
|
+
return this.cliReplace(resolved, originalContent, detectedLang, pattern, rewrite, dry_run);
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/**
|
|
615
|
+
* P7 — Pure in-memory napi rewrite. Computes the new file content from the
|
|
616
|
+
* original WITHOUT touching disk, so it can be reused by both the single-file
|
|
617
|
+
* path, the batch path, and dry-run previews. Returns
|
|
618
|
+
* { newContent, replacements }
|
|
619
|
+
* where `replacements` is the number of matched AST nodes rewritten (0 when
|
|
620
|
+
* the pattern matched nothing, in which case newContent === originalContent).
|
|
621
|
+
*/
|
|
622
|
+
_napiComputeNewContent(engine, originalContent, lang, pattern, rewrite) {
|
|
623
|
+
const root = engine.parse(lang, originalContent);
|
|
624
|
+
const rootNode = root.root();
|
|
625
|
+
const config = engine.pattern(lang, pattern);
|
|
626
|
+
const matches = rootNode.findAll(config);
|
|
627
|
+
if (matches.length === 0) {
|
|
628
|
+
return { newContent: originalContent, replacements: 0 };
|
|
629
|
+
}
|
|
630
|
+
const { single, multi } = extractMetaVars(pattern);
|
|
631
|
+
|
|
632
|
+
const edits = [];
|
|
633
|
+
for (const m of matches) {
|
|
634
|
+
// Build substitution table; longer tokens first so a single-var name that
|
|
635
|
+
// is a substring of a multi-var token (e.g. $A vs $$AB) is not corrupted.
|
|
636
|
+
const table = [];
|
|
637
|
+
for (const name of single) {
|
|
638
|
+
table.push({ token: `$${name}`, value: () => {
|
|
639
|
+
const node = m.getMatch(name);
|
|
640
|
+
return node ? node.text() : "";
|
|
641
|
+
} });
|
|
642
|
+
}
|
|
643
|
+
for (const name of multi) {
|
|
644
|
+
table.push({ token: `$$$${name}`, value: () => {
|
|
645
|
+
const nodes = m.getMultipleMatches(name);
|
|
646
|
+
return nodes.map((n) => n.text()).join("");
|
|
647
|
+
} });
|
|
648
|
+
}
|
|
649
|
+
table.sort((a, b) => b.token.length - a.token.length);
|
|
650
|
+
|
|
651
|
+
let substituted = rewrite;
|
|
652
|
+
for (const t of table) {
|
|
653
|
+
substituted = substituted.split(t.token).join(t.value());
|
|
654
|
+
}
|
|
655
|
+
edits.push(m.replace(substituted));
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
return { newContent: rootNode.commitEdits(edits), replacements: matches.length };
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
/**
|
|
662
|
+
* P7 — In-memory CLI compute. Copies the file to a throwaway temp file, runs
|
|
663
|
+
* the CLI --update-all against the COPY (never the real file), reads the
|
|
664
|
+
* result back, and cleans up. This guarantees a dry_run preview never touches
|
|
665
|
+
* the real file on disk during the compute phase. Returns
|
|
666
|
+
* { newContent, replacements }
|
|
667
|
+
* where replacements is 0 when the pattern matched nothing (newContent ===
|
|
668
|
+
* originalContent) and 1 otherwise (the CLI does not report a per-node count).
|
|
669
|
+
*/
|
|
670
|
+
_cliComputeNewContent(file, originalContent, lang, pattern, rewrite) {
|
|
671
|
+
const tmp = _writeTemp(originalContent, path.extname(file) || ".js");
|
|
672
|
+
try {
|
|
673
|
+
const bin = this.getBinary();
|
|
674
|
+
const args = [
|
|
675
|
+
"run",
|
|
676
|
+
"--pattern", pattern,
|
|
677
|
+
"--rewrite", rewrite,
|
|
678
|
+
"--lang", lang,
|
|
679
|
+
"--update-all",
|
|
680
|
+
tmp,
|
|
681
|
+
];
|
|
682
|
+
try {
|
|
683
|
+
execFileSync(bin, args, {
|
|
684
|
+
cwd: this.root,
|
|
685
|
+
encoding: "utf8",
|
|
686
|
+
timeout: 30_000,
|
|
687
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
688
|
+
windowsHide: true,
|
|
689
|
+
});
|
|
690
|
+
} catch (err) {
|
|
691
|
+
// Exit 1 / empty stderr = no matches (benign) -> content unchanged.
|
|
692
|
+
if (err.status === 1 && !err.stderr) {
|
|
693
|
+
return { newContent: originalContent, replacements: 0 };
|
|
694
|
+
}
|
|
695
|
+
throw err;
|
|
696
|
+
}
|
|
697
|
+
const newContent = fs.readFileSync(tmp, "utf8");
|
|
698
|
+
return { newContent, replacements: newContent === originalContent ? 0 : 1 };
|
|
699
|
+
} finally {
|
|
700
|
+
_removeTemp(tmp);
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
/**
|
|
705
|
+
* P7 — Unified compute + gate for a single file. Computes the rewrite FULLY
|
|
706
|
+
* IN MEMORY (napi, or CLI against a temp copy — never the real file), then
|
|
707
|
+
* either:
|
|
708
|
+
* - dry_run: validates the would-be content in memory and writes NOTHING, or
|
|
709
|
+
* - real: commits through the shared _commitReplace gate
|
|
710
|
+
* (write -> validateSyntax -> pristine rollback on verified-invalid).
|
|
711
|
+
* Returns { changed, replacements, syntaxVerified, error }:
|
|
712
|
+
* - changed:false, error:null -> no match (content identical)
|
|
713
|
+
* - changed:false, error:<msg> -> verified invalid (rolled back / would be)
|
|
714
|
+
* - changed:true, syntaxVerified -> committed / would change
|
|
715
|
+
* (true=validated, false=honest-degrade)
|
|
716
|
+
*/
|
|
717
|
+
async _computeAndGate(file, originalContent, lang, pattern, rewrite, dry_run) {
|
|
718
|
+
const engine = await loadNapiEngine();
|
|
719
|
+
let newContent;
|
|
720
|
+
let replacements;
|
|
721
|
+
if (engine && NAPI_LANGS.has(lang)) {
|
|
722
|
+
const r = this._napiComputeNewContent(engine, originalContent, lang, pattern, rewrite);
|
|
723
|
+
newContent = r.newContent;
|
|
724
|
+
replacements = r.replacements;
|
|
725
|
+
} else {
|
|
726
|
+
const r = this._cliComputeNewContent(file, originalContent, lang, pattern, rewrite);
|
|
727
|
+
newContent = r.newContent;
|
|
728
|
+
replacements = r.replacements;
|
|
729
|
+
}
|
|
730
|
+
if (newContent === originalContent) {
|
|
731
|
+
return { changed: false, replacements: 0, syntaxVerified: null, error: null };
|
|
732
|
+
}
|
|
733
|
+
if (dry_run) {
|
|
734
|
+
// Preview only: validate the would-be content in memory, write nothing.
|
|
735
|
+
const validation = this.validateSyntax(file, newContent, lang);
|
|
736
|
+
if (validation.checked && !validation.valid) {
|
|
737
|
+
return {
|
|
738
|
+
changed: false,
|
|
739
|
+
replacements: 0,
|
|
740
|
+
syntaxVerified: false,
|
|
741
|
+
error: `SyntaxValidationError: AST replacement would result in malformed syntax (${validation.error}).`,
|
|
742
|
+
};
|
|
743
|
+
}
|
|
744
|
+
return { changed: true, replacements, syntaxVerified: validation.checked, error: null };
|
|
745
|
+
}
|
|
746
|
+
return this._commitReplace(file, originalContent, newContent, replacements, lang);
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
/**
|
|
750
|
+
* In-process napi replace. Computes the rewrite in memory (shared
|
|
751
|
+
* _napiComputeNewContent), then commits through the shared _commitReplace gate
|
|
752
|
+
* (write -> validateSyntax -> pristine rollback on verified-invalid).
|
|
753
|
+
* Honors dry_run: when true, validates the would-be content in memory and
|
|
754
|
+
* writes nothing to disk.
|
|
755
|
+
*/
|
|
756
|
+
napiReplace(engine, resolved, originalContent, lang, pattern, rewrite, dry_run) {
|
|
757
|
+
const { newContent, replacements } = this._napiComputeNewContent(
|
|
758
|
+
engine, originalContent, lang, pattern, rewrite
|
|
759
|
+
);
|
|
760
|
+
if (newContent === originalContent) {
|
|
761
|
+
return {
|
|
762
|
+
path: resolved,
|
|
763
|
+
modified: false,
|
|
764
|
+
message: `Pattern '${pattern}' did not match any AST nodes in ${path.basename(resolved)}. File unchanged.`,
|
|
765
|
+
};
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
if (dry_run) {
|
|
769
|
+
// Preview only: validate the would-be content in memory, write nothing.
|
|
770
|
+
const validation = this.validateSyntax(resolved, newContent, lang);
|
|
771
|
+
if (validation.checked && !validation.valid) {
|
|
772
|
+
throw new Error(
|
|
773
|
+
`SyntaxValidationError: AST replacement would result in malformed syntax (${validation.error}).`
|
|
774
|
+
);
|
|
775
|
+
}
|
|
776
|
+
const verifiedNote = validation.checked
|
|
777
|
+
? "Syntax validated."
|
|
778
|
+
: `Syntax NOT verified (no checker available for '${lang}').`;
|
|
779
|
+
return {
|
|
780
|
+
path: resolved,
|
|
781
|
+
modified: true,
|
|
782
|
+
dry_run: true,
|
|
783
|
+
syntax_verified: validation.checked,
|
|
784
|
+
bytes_before: Buffer.byteLength(originalContent, "utf8"),
|
|
785
|
+
bytes_after: Buffer.byteLength(newContent, "utf8"),
|
|
786
|
+
message: `AST pattern '${pattern}' would replace in ${path.basename(resolved)} (dry run, no write). ${verifiedNote}`,
|
|
787
|
+
};
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
const r = this._commitReplace(resolved, originalContent, newContent, replacements, lang);
|
|
791
|
+
if (r.error) throw new Error(r.error);
|
|
792
|
+
|
|
793
|
+
// Honest degradation: the rewrite is committed, but the tool result must
|
|
794
|
+
// state it was NOT syntax-verified (no checker available for this lang).
|
|
795
|
+
const verifiedNote = r.syntaxVerified
|
|
796
|
+
? "Syntax validated."
|
|
797
|
+
: `Syntax NOT verified (no checker available for '${lang}').`;
|
|
798
|
+
|
|
799
|
+
return {
|
|
800
|
+
path: resolved,
|
|
801
|
+
modified: true,
|
|
802
|
+
syntax_verified: r.syntaxVerified,
|
|
803
|
+
bytes_before: Buffer.byteLength(originalContent, "utf8"),
|
|
804
|
+
bytes_after: Buffer.byteLength(newContent, "utf8"),
|
|
805
|
+
message: `AST pattern '${pattern}' successfully replaced in ${path.basename(resolved)}. ${verifiedNote}`,
|
|
806
|
+
};
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
/**
|
|
810
|
+
* P7 — Shared commit gate for a single file. Writes `newContent` to disk,
|
|
811
|
+
* runs the SAME validateSyntax gate, and rolls the file back to
|
|
812
|
+
* `originalContent` (pristine) when the rewrite is verified INVALID. Returns a
|
|
813
|
+
* structured result so both the single-file and batch paths can share it:
|
|
814
|
+
* { changed, replacements, syntaxVerified, error }
|
|
815
|
+
* - changed:false, error:null -> no-op (content identical)
|
|
816
|
+
* - changed:false, error:<msg> -> verified invalid, rolled back
|
|
817
|
+
* - changed:true, syntaxVerified -> committed (true=validated,
|
|
818
|
+
* false=honest-degrade, no checker)
|
|
819
|
+
*/
|
|
820
|
+
_commitReplace(file, originalContent, newContent, replacements, lang) {
|
|
821
|
+
if (newContent === originalContent) {
|
|
822
|
+
return { changed: false, replacements: 0, syntaxVerified: null, error: null };
|
|
823
|
+
}
|
|
824
|
+
fs.writeFileSync(file, newContent, "utf8");
|
|
825
|
+
const validation = this.validateSyntax(file, newContent, lang);
|
|
826
|
+
if (validation.checked && !validation.valid) {
|
|
827
|
+
// Verified INVALID -> rollback immediately to original pristine content.
|
|
828
|
+
fs.writeFileSync(file, originalContent, "utf8");
|
|
829
|
+
return {
|
|
830
|
+
changed: false,
|
|
831
|
+
replacements: 0,
|
|
832
|
+
syntaxVerified: false,
|
|
833
|
+
error: `SyntaxValidationError: AST replacement resulted in malformed syntax (${validation.error}). Disk rolled back.`,
|
|
834
|
+
};
|
|
835
|
+
}
|
|
836
|
+
return {
|
|
837
|
+
changed: true,
|
|
838
|
+
replacements,
|
|
839
|
+
syntaxVerified: validation.checked,
|
|
840
|
+
error: null,
|
|
841
|
+
};
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
/**
|
|
845
|
+
* CLI replace (fallback). P7: the rewrite is now computed IN MEMORY against a
|
|
846
|
+
* throwaway temp copy (never the real file), so a dry_run preview is
|
|
847
|
+
* byte-safe on this path too. The result is then committed through the SAME
|
|
848
|
+
* shared _commitReplace gate (write -> validateSyntax -> pristine rollback),
|
|
849
|
+
* giving byte-identical behavior to the napi path.
|
|
850
|
+
*/
|
|
851
|
+
cliReplace(resolved, originalContent, lang, pattern, rewrite, dry_run) {
|
|
852
|
+
let newContent;
|
|
853
|
+
let replacements;
|
|
854
|
+
try {
|
|
855
|
+
const r = this._cliComputeNewContent(resolved, originalContent, lang, pattern, rewrite);
|
|
856
|
+
newContent = r.newContent;
|
|
857
|
+
replacements = r.replacements;
|
|
858
|
+
} catch (err) {
|
|
859
|
+
if (err.status === 1 && !err.stderr) {
|
|
860
|
+
return {
|
|
861
|
+
path: resolved,
|
|
862
|
+
modified: false,
|
|
863
|
+
message: `Pattern '${pattern}' not found in ${path.basename(resolved)}. Try a broader pattern or edit_file.`,
|
|
864
|
+
};
|
|
865
|
+
}
|
|
866
|
+
throw new Error(`AST replace error: ${err.stderr || err.message}`);
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
if (newContent === originalContent) {
|
|
870
|
+
return {
|
|
871
|
+
path: resolved,
|
|
872
|
+
modified: false,
|
|
873
|
+
message: `Pattern '${pattern}' did not match any AST nodes in ${path.basename(resolved)}. File unchanged.`,
|
|
874
|
+
};
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
if (dry_run) {
|
|
878
|
+
// Preview only: validate the would-be content in memory, write nothing.
|
|
879
|
+
const validation = this.validateSyntax(resolved, newContent, lang);
|
|
880
|
+
if (validation.checked && !validation.valid) {
|
|
881
|
+
throw new Error(
|
|
882
|
+
`SyntaxValidationError: AST replacement would result in malformed syntax (${validation.error}).`
|
|
883
|
+
);
|
|
884
|
+
}
|
|
885
|
+
const verifiedNote = validation.checked
|
|
886
|
+
? "Syntax validated."
|
|
887
|
+
: `Syntax NOT verified (no checker available for '${lang}').`;
|
|
888
|
+
return {
|
|
889
|
+
path: resolved,
|
|
890
|
+
modified: true,
|
|
891
|
+
dry_run: true,
|
|
892
|
+
syntax_verified: validation.checked,
|
|
893
|
+
bytes_before: Buffer.byteLength(originalContent, "utf8"),
|
|
894
|
+
bytes_after: Buffer.byteLength(newContent, "utf8"),
|
|
895
|
+
message: `AST pattern '${pattern}' would replace in ${path.basename(resolved)} (dry run, no write). ${verifiedNote}`,
|
|
896
|
+
};
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
const r = this._commitReplace(resolved, originalContent, newContent, replacements, lang);
|
|
900
|
+
if (r.error) throw new Error(r.error);
|
|
901
|
+
|
|
902
|
+
// Honest degradation: committed, but the result states it was NOT
|
|
903
|
+
// syntax-verified when no checker was available for this language.
|
|
904
|
+
const verifiedNote = r.syntaxVerified
|
|
905
|
+
? "Syntax validated."
|
|
906
|
+
: `Syntax NOT verified (no checker available for '${lang}').`;
|
|
907
|
+
|
|
908
|
+
return {
|
|
909
|
+
path: resolved,
|
|
910
|
+
modified: true,
|
|
911
|
+
syntax_verified: r.syntaxVerified,
|
|
912
|
+
bytes_before: Buffer.byteLength(originalContent, "utf8"),
|
|
913
|
+
bytes_after: Buffer.byteLength(newContent, "utf8"),
|
|
914
|
+
message: `AST pattern '${pattern}' successfully replaced in ${path.basename(resolved)}. ${verifiedNote}`,
|
|
915
|
+
};
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
// -------------------------------------------------------------------------
|
|
919
|
+
// P7 — Batch replace (directory / glob target) with dry_run preview.
|
|
920
|
+
//
|
|
921
|
+
// `replace` is single-file only. `replaceBatch` applies the SAME pattern/
|
|
922
|
+
// rewrite across every matching file in a directory or glob, running the
|
|
923
|
+
// SAME validateSyntax gate per file, rolling back ONLY the file that is
|
|
924
|
+
// verified invalid, and continuing to the next file (never aborting the
|
|
925
|
+
// batch). It respects DEFAULT_IGNORED_DIRS and the 1 MB per-file guard.
|
|
926
|
+
//
|
|
927
|
+
// dry_run computes everything (matches, rewrites, per-file validation) but
|
|
928
|
+
// writes NOTHING to disk, so a preview is byte-safe.
|
|
929
|
+
// -------------------------------------------------------------------------
|
|
930
|
+
|
|
931
|
+
/**
|
|
932
|
+
* Resolves a batch target (directory or glob) to a deterministic, sorted list
|
|
933
|
+
* of candidate file paths. Returns null when the target is a single file
|
|
934
|
+
* (caller should use the single-file path) or does not exist.
|
|
935
|
+
*
|
|
936
|
+
* @param {string} resolved Resolved (absolute) target path.
|
|
937
|
+
* @returns {string[]|null}
|
|
938
|
+
*/
|
|
939
|
+
_resolveBatchFiles(resolved) {
|
|
940
|
+
if (isGlobPattern(resolved)) {
|
|
941
|
+
// Glob: expand relative to the sandbox root, then filter to regular files
|
|
942
|
+
// that are inside the root and not in an ignored directory.
|
|
943
|
+
const base = path.dirname(resolved);
|
|
944
|
+
const pattern = path.basename(resolved);
|
|
945
|
+
let hits;
|
|
946
|
+
try {
|
|
947
|
+
hits = fs.globSync(pattern, {
|
|
948
|
+
cwd: base,
|
|
949
|
+
absolute: true,
|
|
950
|
+
withFileTypes: true,
|
|
951
|
+
dot: false,
|
|
952
|
+
});
|
|
953
|
+
} catch {
|
|
954
|
+
return [];
|
|
955
|
+
}
|
|
956
|
+
const out = [];
|
|
957
|
+
for (const h of hits) {
|
|
958
|
+
const p = h.path;
|
|
959
|
+
if (!this._isWithinRoot(p)) continue;
|
|
960
|
+
if (this._isIgnoredPath(p)) continue;
|
|
961
|
+
let st;
|
|
962
|
+
try {
|
|
963
|
+
st = fs.statSync(p);
|
|
964
|
+
} catch {
|
|
965
|
+
continue;
|
|
966
|
+
}
|
|
967
|
+
if (!st.isFile()) continue;
|
|
968
|
+
if (st.size > MAX_FILE_BYTES) continue;
|
|
969
|
+
if (!this.inferLanguageByExtension(p)) continue;
|
|
970
|
+
out.push(p);
|
|
971
|
+
}
|
|
972
|
+
out.sort();
|
|
973
|
+
return out;
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
// Directory: recursive walk (same discipline as searchDirectory).
|
|
977
|
+
if (fs.existsSync(resolved) && fs.statSync(resolved).isDirectory()) {
|
|
978
|
+
const out = [];
|
|
979
|
+
const walk = (dir) => {
|
|
980
|
+
let entries;
|
|
981
|
+
try {
|
|
982
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
983
|
+
} catch {
|
|
984
|
+
return;
|
|
985
|
+
}
|
|
986
|
+
for (const entry of entries) {
|
|
987
|
+
if (DEFAULT_IGNORED_DIRS.has(entry.name)) continue;
|
|
988
|
+
const full = path.join(dir, entry.name);
|
|
989
|
+
if (entry.isDirectory()) {
|
|
990
|
+
walk(full);
|
|
991
|
+
} else if (entry.isFile()) {
|
|
992
|
+
if (!this.inferLanguageByExtension(full)) continue;
|
|
993
|
+
let st;
|
|
994
|
+
try {
|
|
995
|
+
st = fs.statSync(full);
|
|
996
|
+
} catch {
|
|
997
|
+
continue;
|
|
998
|
+
}
|
|
999
|
+
if (st.size > MAX_FILE_BYTES) continue;
|
|
1000
|
+
out.push(full);
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
};
|
|
1004
|
+
walk(resolved);
|
|
1005
|
+
out.sort();
|
|
1006
|
+
return out;
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
// Single file or nonexistent -> not a batch target.
|
|
1010
|
+
return null;
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
/** True when `p` is inside the sandbox root (canonical space). */
|
|
1014
|
+
_isWithinRoot(p) {
|
|
1015
|
+
const realTarget = canonicalizePath(path.normalize(p));
|
|
1016
|
+
const realRoot = canonicalizePath(path.normalize(this.root));
|
|
1017
|
+
const rel = path.relative(realRoot, realTarget);
|
|
1018
|
+
return !(rel.startsWith("..") || path.isAbsolute(rel));
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
/** True when any path segment of `p` is in DEFAULT_IGNORED_DIRS. */
|
|
1022
|
+
_isIgnoredPath(p) {
|
|
1023
|
+
const parts = path.normalize(p).split(path.sep);
|
|
1024
|
+
return parts.some((seg) => DEFAULT_IGNORED_DIRS.has(seg));
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
/**
|
|
1028
|
+
* Batch AST replace across a directory or glob target.
|
|
1029
|
+
*
|
|
1030
|
+
* @param {object} params
|
|
1031
|
+
* @param {string} params.path Target directory or glob (a directory path,
|
|
1032
|
+
* or a glob pattern using standard recursive wildcards)
|
|
1033
|
+
* @param {string} params.pattern Search pattern with metavariables
|
|
1034
|
+
* @param {string} params.rewrite Replacement pattern
|
|
1035
|
+
* @param {string} [params.lang] Optional language (only used for single-file
|
|
1036
|
+
* inference; batch infers per-file by extension)
|
|
1037
|
+
* @param {boolean} [params.dry_run] When true, compute everything but write
|
|
1038
|
+
* nothing to disk.
|
|
1039
|
+
* @returns {Promise<object>} Batch summary:
|
|
1040
|
+
* { dry_run, files_scanned, files_matched, files_changed,
|
|
1041
|
+
* files_would_change, replacements,
|
|
1042
|
+
* failures: [{file, error}], syntax_unverified: [file, ...] }
|
|
1043
|
+
*/
|
|
1044
|
+
async replaceBatch({ path: targetPath, pattern, rewrite, lang, dry_run }) {
|
|
1045
|
+
if (!pattern || typeof pattern !== "string") {
|
|
1046
|
+
throw new Error("AST replace requires a pattern parameter");
|
|
1047
|
+
}
|
|
1048
|
+
if (typeof rewrite !== "string") {
|
|
1049
|
+
throw new Error("AST replace requires a rewrite parameter");
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
const resolved = this.resolvePath(targetPath);
|
|
1053
|
+
if (!fs.existsSync(resolved)) {
|
|
1054
|
+
throw new Error(`Path does not exist: ${targetPath}`);
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
const files = this._resolveBatchFiles(resolved);
|
|
1058
|
+
if (files === null) {
|
|
1059
|
+
// Not a directory/glob: a single file. Delegate to the single-file path
|
|
1060
|
+
// (which honors dry_run via the shared gate) and shape the result as a
|
|
1061
|
+
// one-file batch summary so callers get a uniform contract. A per-file
|
|
1062
|
+
// gate failure is recorded in failures[] (not thrown), matching the
|
|
1063
|
+
// directory path.
|
|
1064
|
+
let single;
|
|
1065
|
+
let gateError = null;
|
|
1066
|
+
try {
|
|
1067
|
+
single = await this.replace({ path: targetPath, pattern, rewrite, lang, dry_run });
|
|
1068
|
+
} catch (err) {
|
|
1069
|
+
if (err.message.startsWith("SyntaxValidationError")) {
|
|
1070
|
+
gateError = err.message;
|
|
1071
|
+
} else {
|
|
1072
|
+
throw err;
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
const changed = single && single.modified === true;
|
|
1076
|
+
return {
|
|
1077
|
+
dry_run: !!dry_run,
|
|
1078
|
+
files_scanned: 1,
|
|
1079
|
+
files_matched: changed || gateError ? 1 : 0,
|
|
1080
|
+
files_changed: changed && !dry_run ? 1 : 0,
|
|
1081
|
+
files_would_change: changed ? 1 : 0,
|
|
1082
|
+
replacements: changed ? 1 : 0,
|
|
1083
|
+
failures: gateError ? [{ file: resolved, error: gateError }] : [],
|
|
1084
|
+
syntax_unverified: changed && single.syntax_verified === false ? [resolved] : [],
|
|
1085
|
+
};
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
const summary = {
|
|
1089
|
+
dry_run: !!dry_run,
|
|
1090
|
+
files_scanned: files.length,
|
|
1091
|
+
files_matched: 0,
|
|
1092
|
+
files_changed: 0,
|
|
1093
|
+
files_would_change: 0,
|
|
1094
|
+
replacements: 0,
|
|
1095
|
+
failures: [],
|
|
1096
|
+
syntax_unverified: [],
|
|
1097
|
+
};
|
|
1098
|
+
|
|
1099
|
+
for (const file of files) {
|
|
1100
|
+
const fileLang = this.inferLanguageByExtension(file);
|
|
1101
|
+
let originalContent;
|
|
1102
|
+
try {
|
|
1103
|
+
originalContent = fs.readFileSync(file, "utf8");
|
|
1104
|
+
} catch {
|
|
1105
|
+
continue; // unreadable -> skip, do not abort the batch
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
// Compute the rewrite FULLY IN MEMORY (napi, or CLI against a temp copy —
|
|
1109
|
+
// the real file is never touched during compute), then gate per file.
|
|
1110
|
+
let r;
|
|
1111
|
+
try {
|
|
1112
|
+
r = await this._computeAndGate(file, originalContent, fileLang, pattern, rewrite, dry_run);
|
|
1113
|
+
} catch {
|
|
1114
|
+
// Parse failure on this file -> skip it, continue the batch.
|
|
1115
|
+
continue;
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
if (r.error) {
|
|
1119
|
+
// Verified invalid (rolled back on a real run; would-be on a dry run).
|
|
1120
|
+
summary.files_matched += 1;
|
|
1121
|
+
summary.failures.push({ file, error: r.error });
|
|
1122
|
+
continue;
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
if (!r.changed) {
|
|
1126
|
+
// No match in this file -> not counted as matched.
|
|
1127
|
+
continue;
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
summary.files_matched += 1;
|
|
1131
|
+
summary.replacements += r.replacements;
|
|
1132
|
+
if (dry_run) {
|
|
1133
|
+
summary.files_would_change += 1;
|
|
1134
|
+
if (!r.syntaxVerified) summary.syntax_unverified.push(file);
|
|
1135
|
+
} else {
|
|
1136
|
+
summary.files_changed += 1;
|
|
1137
|
+
if (!r.syntaxVerified) summary.syntax_unverified.push(file);
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
return summary;
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
// -------------------------------------------------------------------------
|
|
1145
|
+
// P6 — Universal syntax gate.
|
|
1146
|
+
//
|
|
1147
|
+
// validateSyntax returns a STRUCTURED result so callers can distinguish
|
|
1148
|
+
// three outcomes:
|
|
1149
|
+
// { checked: true, valid: true } -> verified valid
|
|
1150
|
+
// { checked: true, valid: false, error } -> verified INVALID (rollback)
|
|
1151
|
+
// { checked: false, valid: false, reason } -> no checker (honest degrade)
|
|
1152
|
+
//
|
|
1153
|
+
// The gate NEVER reports an unchecked rewrite as valid, and NEVER silently
|
|
1154
|
+
// skips: an unavailable checker yields checked:false plus a stderr note.
|
|
1155
|
+
// -------------------------------------------------------------------------
|
|
1156
|
+
|
|
1157
|
+
/**
|
|
1158
|
+
* Validates the syntax of a modified file for the given language.
|
|
1159
|
+
*
|
|
1160
|
+
* @param {string} filePath Path of the (already-written) file (used for
|
|
1161
|
+
* on-disk checkers such as python/gofmt/rustfmt).
|
|
1162
|
+
* @param {string} content The new file content to validate.
|
|
1163
|
+
* @param {string} lang Normalized language identifier.
|
|
1164
|
+
* @returns {{checked: boolean, valid: boolean, language: string,
|
|
1165
|
+
* reason?: string, error?: string}}
|
|
1166
|
+
*/
|
|
1167
|
+
validateSyntax(filePath, content, lang) {
|
|
1168
|
+
const language = (lang || "js").toLowerCase();
|
|
1169
|
+
const result = this._dispatchSyntaxCheck(filePath, content, language);
|
|
1170
|
+
// Honest-degradation stderr note (never silent, never a false "valid").
|
|
1171
|
+
if (!result.checked) {
|
|
1172
|
+
process.stderr.write(
|
|
1173
|
+
`[ast_service] syntax gate: no checker available for '${language}' (${result.reason}); rewrite NOT syntax-verified\n`
|
|
1174
|
+
);
|
|
1175
|
+
}
|
|
1176
|
+
return result;
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
/**
|
|
1180
|
+
* Routes a language to its dedicated checker. Unknown languages (c, cpp,
|
|
1181
|
+
* html, css, ...) have no dedicated checker and degrade honestly.
|
|
1182
|
+
*/
|
|
1183
|
+
_dispatchSyntaxCheck(filePath, content, lang) {
|
|
1184
|
+
switch (lang) {
|
|
1185
|
+
case "ts":
|
|
1186
|
+
case "tsx":
|
|
1187
|
+
case "mts":
|
|
1188
|
+
case "cts":
|
|
1189
|
+
return this._checkTypescript(content, lang);
|
|
1190
|
+
case "js":
|
|
1191
|
+
case "jsx":
|
|
1192
|
+
case "mjs":
|
|
1193
|
+
case "cjs":
|
|
1194
|
+
return this._checkJavaScript(content, lang);
|
|
1195
|
+
case "json":
|
|
1196
|
+
return this._checkJson(content);
|
|
1197
|
+
case "python":
|
|
1198
|
+
return this._checkPython(filePath, content);
|
|
1199
|
+
case "go":
|
|
1200
|
+
return this._checkGo(filePath, content);
|
|
1201
|
+
case "rust":
|
|
1202
|
+
return this._checkRust(filePath, content);
|
|
1203
|
+
case "latex":
|
|
1204
|
+
return this._checkLatex(content);
|
|
1205
|
+
case "bibtex":
|
|
1206
|
+
return this._checkBibtex(content);
|
|
1207
|
+
default:
|
|
1208
|
+
return {
|
|
1209
|
+
checked: false,
|
|
1210
|
+
valid: false,
|
|
1211
|
+
language: lang,
|
|
1212
|
+
reason: `no syntax checker registered for '${lang}'`,
|
|
1213
|
+
};
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
/**
|
|
1218
|
+
* TypeScript gate: typescript.transpileModule with reportDiagnostics.
|
|
1219
|
+
* Any diagnostic with category === Error means INVALID. transpileModule
|
|
1220
|
+
* strips types, so type-level errors are NOT reported (only syntax errors
|
|
1221
|
+
* surface) — which is exactly the parse-gate semantics we want.
|
|
1222
|
+
*/
|
|
1223
|
+
_checkTypescript(content, lang) {
|
|
1224
|
+
const ts = loadTypescript();
|
|
1225
|
+
if (!ts) {
|
|
1226
|
+
return {
|
|
1227
|
+
checked: false,
|
|
1228
|
+
valid: false,
|
|
1229
|
+
language: lang,
|
|
1230
|
+
reason: "typescript module unavailable",
|
|
1231
|
+
};
|
|
1232
|
+
}
|
|
1233
|
+
try {
|
|
1234
|
+
const compilerOptions = {
|
|
1235
|
+
target: ts.ScriptTarget.ES2020,
|
|
1236
|
+
module: ts.ModuleKind.ESNext,
|
|
1237
|
+
};
|
|
1238
|
+
// Only set jsx for .tsx; passing `jsx: undefined` makes transpileModule
|
|
1239
|
+
// reject the option, so it must be omitted entirely for non-tsx.
|
|
1240
|
+
if (lang === "tsx") compilerOptions.jsx = ts.JsxEmit.Preserve;
|
|
1241
|
+
const out = ts.transpileModule(content, {
|
|
1242
|
+
reportDiagnostics: true,
|
|
1243
|
+
compilerOptions,
|
|
1244
|
+
});
|
|
1245
|
+
const errors = (out.diagnostics || []).filter(
|
|
1246
|
+
(d) => d.category === ts.DiagnosticCategory.Error
|
|
1247
|
+
);
|
|
1248
|
+
if (errors.length === 0) {
|
|
1249
|
+
return { checked: true, valid: true, language: lang };
|
|
1250
|
+
}
|
|
1251
|
+
const msg = errors
|
|
1252
|
+
.map((d) => ts.flattenDiagnosticMessageText(d.messageText, "\n"))
|
|
1253
|
+
.join("; ");
|
|
1254
|
+
return { checked: true, valid: false, language: lang, error: msg };
|
|
1255
|
+
} catch (err) {
|
|
1256
|
+
// A throw from transpileModule is itself a hard parse failure.
|
|
1257
|
+
return { checked: true, valid: false, language: lang, error: err.message };
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
/**
|
|
1262
|
+
* JavaScript gate: `node --check` against a temp file whose extension
|
|
1263
|
+
* matches the module system. This is the ESM nuance: a bare `.js` file has
|
|
1264
|
+
* an ambiguous module type, so we force it deterministically —
|
|
1265
|
+
* ESM-syntax content -> temp .mjs (import/export parsed as ESM)
|
|
1266
|
+
* CJS/other content -> temp .cjs (require/module.exports parsed as CJS)
|
|
1267
|
+
* A valid ESM file is therefore never rejected, and a syntax-broken file
|
|
1268
|
+
* (in either module system) never passes.
|
|
1269
|
+
*/
|
|
1270
|
+
_checkJavaScript(content, lang) {
|
|
1271
|
+
const ext = looksLikeESM(content) ? ".mjs" : ".cjs";
|
|
1272
|
+
const tmp = _writeTemp(content, ext);
|
|
1273
|
+
try {
|
|
1274
|
+
execFileSync(process.execPath, ["--check", tmp], {
|
|
1275
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
1276
|
+
timeout: 5000,
|
|
1277
|
+
windowsHide: true,
|
|
1278
|
+
});
|
|
1279
|
+
return { checked: true, valid: true, language: lang };
|
|
1280
|
+
} catch (err) {
|
|
1281
|
+
const detail = (err.stderr || err.message || "").toString().trim();
|
|
1282
|
+
return {
|
|
1283
|
+
checked: true,
|
|
1284
|
+
valid: false,
|
|
1285
|
+
language: lang,
|
|
1286
|
+
error: detail || "JavaScript parse error",
|
|
1287
|
+
};
|
|
1288
|
+
} finally {
|
|
1289
|
+
_removeTemp(tmp);
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
/**
|
|
1294
|
+
* JSON gate: in-process JSON.parse (fast, no subprocess).
|
|
1295
|
+
*/
|
|
1296
|
+
_checkJson(content) {
|
|
1297
|
+
try {
|
|
1298
|
+
JSON.parse(content);
|
|
1299
|
+
return { checked: true, valid: true, language: "json" };
|
|
1300
|
+
} catch (err) {
|
|
1301
|
+
return { checked: true, valid: false, language: "json", error: err.message };
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
/**
|
|
1306
|
+
* Python gate: `python -c "import ast; ast.parse(...)"` on a temp file.
|
|
1307
|
+
* The python binary is probed once; if unavailable, degrade honestly.
|
|
1308
|
+
*/
|
|
1309
|
+
_checkPython(filePath, content) {
|
|
1310
|
+
if (!pythonAvailable()) {
|
|
1311
|
+
return {
|
|
1312
|
+
checked: false,
|
|
1313
|
+
valid: false,
|
|
1314
|
+
language: "python",
|
|
1315
|
+
reason: "python interpreter unavailable",
|
|
1316
|
+
};
|
|
1317
|
+
}
|
|
1318
|
+
const bin = IS_WINDOWS ? "python" : "python3";
|
|
1319
|
+
const tmp = _writeTemp(content, ".py");
|
|
1320
|
+
try {
|
|
1321
|
+
execFileSync(
|
|
1322
|
+
bin,
|
|
1323
|
+
["-c", "import ast,sys; ast.parse(open(sys.argv[1], encoding='utf-8').read())", tmp],
|
|
1324
|
+
{ stdio: ["ignore", "pipe", "pipe"], timeout: 5000, windowsHide: true }
|
|
1325
|
+
);
|
|
1326
|
+
return { checked: true, valid: true, language: "python" };
|
|
1327
|
+
} catch (err) {
|
|
1328
|
+
const detail = (err.stderr || err.message || "").toString().trim();
|
|
1329
|
+
return {
|
|
1330
|
+
checked: true,
|
|
1331
|
+
valid: false,
|
|
1332
|
+
language: "python",
|
|
1333
|
+
error: detail || "Python syntax error",
|
|
1334
|
+
};
|
|
1335
|
+
} finally {
|
|
1336
|
+
_removeTemp(tmp);
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
/**
|
|
1341
|
+
* Go gate: `gofmt -e` on a temp file. gofmt -e reports ALL syntax errors to
|
|
1342
|
+
* stderr and exits non-zero on a parse failure. Probed once; degrades
|
|
1343
|
+
* honestly when gofmt is absent.
|
|
1344
|
+
*/
|
|
1345
|
+
_checkGo(filePath, content) {
|
|
1346
|
+
if (!gofmtAvailable()) {
|
|
1347
|
+
return {
|
|
1348
|
+
checked: false,
|
|
1349
|
+
valid: false,
|
|
1350
|
+
language: "go",
|
|
1351
|
+
reason: "gofmt unavailable",
|
|
1352
|
+
};
|
|
1353
|
+
}
|
|
1354
|
+
const tmp = _writeTemp(content, ".go");
|
|
1355
|
+
try {
|
|
1356
|
+
execFileSync("gofmt", ["-e", tmp], {
|
|
1357
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
1358
|
+
timeout: 5000,
|
|
1359
|
+
windowsHide: true,
|
|
1360
|
+
});
|
|
1361
|
+
return { checked: true, valid: true, language: "go" };
|
|
1362
|
+
} catch (err) {
|
|
1363
|
+
const detail = (err.stderr || err.message || "").toString().trim();
|
|
1364
|
+
return {
|
|
1365
|
+
checked: true,
|
|
1366
|
+
valid: false,
|
|
1367
|
+
language: "go",
|
|
1368
|
+
error: detail || "Go syntax error",
|
|
1369
|
+
};
|
|
1370
|
+
} finally {
|
|
1371
|
+
_removeTemp(tmp);
|
|
1372
|
+
}
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
/**
|
|
1376
|
+
* Rust gate: `rustfmt --check` on a temp file. rustfmt exits non-zero (and
|
|
1377
|
+
* writes to stderr) when the source does not parse. Probed once; degrades
|
|
1378
|
+
* honestly when rustfmt is absent.
|
|
1379
|
+
*/
|
|
1380
|
+
_checkRust(filePath, content) {
|
|
1381
|
+
if (!rustfmtAvailable()) {
|
|
1382
|
+
return {
|
|
1383
|
+
checked: false,
|
|
1384
|
+
valid: false,
|
|
1385
|
+
language: "rust",
|
|
1386
|
+
reason: "rustfmt unavailable",
|
|
1387
|
+
};
|
|
1388
|
+
}
|
|
1389
|
+
const tmp = _writeTemp(content, ".rs");
|
|
1390
|
+
try {
|
|
1391
|
+
execFileSync("rustfmt", ["--check", tmp], {
|
|
1392
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
1393
|
+
timeout: 5000,
|
|
1394
|
+
windowsHide: true,
|
|
1395
|
+
});
|
|
1396
|
+
return { checked: true, valid: true, language: "rust" };
|
|
1397
|
+
} catch (err) {
|
|
1398
|
+
// rustfmt exits 1 for BOTH "would reformat" and "parse error". We only
|
|
1399
|
+
// care about parse errors, so inspect stderr for a parse/syntax signal.
|
|
1400
|
+
const detail = (err.stderr || err.message || "").toString().trim();
|
|
1401
|
+
const isParseError = /error(\[|:)|expected|unexpected|parse/i.test(detail);
|
|
1402
|
+
if (isParseError) {
|
|
1403
|
+
return {
|
|
1404
|
+
checked: true,
|
|
1405
|
+
valid: false,
|
|
1406
|
+
language: "rust",
|
|
1407
|
+
error: detail || "Rust syntax error",
|
|
1408
|
+
};
|
|
1409
|
+
}
|
|
1410
|
+
// Non-zero exit but no parse signal (pure formatting diff) -> the source
|
|
1411
|
+
// parsed fine; treat as valid for the parse-gate purpose.
|
|
1412
|
+
return { checked: true, valid: true, language: "rust" };
|
|
1413
|
+
} finally {
|
|
1414
|
+
_removeTemp(tmp);
|
|
1415
|
+
}
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
/**
|
|
1419
|
+
* LaTeX gate: in-process structural validation of LaTeX documents.
|
|
1420
|
+
* Validates:
|
|
1421
|
+
* - Comment stripping (unescaped % to end-of-line)
|
|
1422
|
+
* - Verbatim environments (verbatim, lstlisting, minted) and inline \\verb
|
|
1423
|
+
* - Balanced environment nesting (\\begin{env} ... \\end{env})
|
|
1424
|
+
* - Balanced grouping delimiters ({ and }) outside comments and verbatim
|
|
1425
|
+
*/
|
|
1426
|
+
_checkLatex(content) {
|
|
1427
|
+
const lines = content.split("\n");
|
|
1428
|
+
const envStack = []; // { name, line }
|
|
1429
|
+
let braceDepth = 0;
|
|
1430
|
+
let inVerbatim = null;
|
|
1431
|
+
|
|
1432
|
+
for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) {
|
|
1433
|
+
const lineNum = lineIdx + 1;
|
|
1434
|
+
const rawLine = lines[lineIdx];
|
|
1435
|
+
|
|
1436
|
+
// If inside a verbatim block, wait for \\end{inVerbatim}
|
|
1437
|
+
if (inVerbatim) {
|
|
1438
|
+
const endPattern = new RegExp(`\\\\end\\{${inVerbatim}\\}`);
|
|
1439
|
+
const endMatch = rawLine.match(endPattern);
|
|
1440
|
+
if (endMatch) {
|
|
1441
|
+
inVerbatim = null;
|
|
1442
|
+
if (envStack.length > 0 && envStack[envStack.length - 1].name === inVerbatim) {
|
|
1443
|
+
envStack.pop();
|
|
1444
|
+
}
|
|
1445
|
+
} else {
|
|
1446
|
+
continue;
|
|
1447
|
+
}
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
// Strip comments: find first unescaped %
|
|
1451
|
+
let code = "";
|
|
1452
|
+
let inVerbInline = false;
|
|
1453
|
+
let verbDelim = "";
|
|
1454
|
+
|
|
1455
|
+
for (let i = 0; i < rawLine.length; i++) {
|
|
1456
|
+
const ch = rawLine[i];
|
|
1457
|
+
|
|
1458
|
+
// Inline \\verb|...| or \\verb+...etc+
|
|
1459
|
+
if (!inVerbInline && rawLine.slice(i).startsWith("\\verb") && i + 5 < rawLine.length) {
|
|
1460
|
+
const nextCh = rawLine[i + 5];
|
|
1461
|
+
if (nextCh !== "*" && nextCh !== " " && nextCh !== "\t") {
|
|
1462
|
+
inVerbInline = true;
|
|
1463
|
+
verbDelim = nextCh;
|
|
1464
|
+
code += "\\verb" + verbDelim;
|
|
1465
|
+
i += 5;
|
|
1466
|
+
continue;
|
|
1467
|
+
}
|
|
1468
|
+
}
|
|
1469
|
+
if (inVerbInline) {
|
|
1470
|
+
code += ch;
|
|
1471
|
+
if (ch === verbDelim) {
|
|
1472
|
+
inVerbInline = false;
|
|
1473
|
+
}
|
|
1474
|
+
continue;
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
// Unescaped % starts a comment
|
|
1478
|
+
if (ch === "%") {
|
|
1479
|
+
let bsCount = 0;
|
|
1480
|
+
let k = i - 1;
|
|
1481
|
+
while (k >= 0 && rawLine[k] === "\\") {
|
|
1482
|
+
bsCount++;
|
|
1483
|
+
k--;
|
|
1484
|
+
}
|
|
1485
|
+
if (bsCount % 2 === 0) {
|
|
1486
|
+
break; // comment till end of line
|
|
1487
|
+
}
|
|
1488
|
+
}
|
|
1489
|
+
code += ch;
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1492
|
+
// Parse code for \\begin{...}, \\end{...}, and unescaped { / }
|
|
1493
|
+
let i = 0;
|
|
1494
|
+
while (i < code.length) {
|
|
1495
|
+
if (code.slice(i).startsWith("\\begin{")) {
|
|
1496
|
+
const closeIdx = code.indexOf("}", i + 7);
|
|
1497
|
+
if (closeIdx === -1) {
|
|
1498
|
+
return {
|
|
1499
|
+
checked: true,
|
|
1500
|
+
valid: false,
|
|
1501
|
+
language: "latex",
|
|
1502
|
+
error: `Malformed \\begin statement at line ${lineNum}`,
|
|
1503
|
+
};
|
|
1504
|
+
}
|
|
1505
|
+
const envName = code.slice(i + 7, closeIdx).trim();
|
|
1506
|
+
envStack.push({ name: envName, line: lineNum });
|
|
1507
|
+
if (envName === "verbatim" || envName === "lstlisting" || envName === "minted") {
|
|
1508
|
+
inVerbatim = envName;
|
|
1509
|
+
i = closeIdx + 1;
|
|
1510
|
+
break;
|
|
1511
|
+
}
|
|
1512
|
+
i = closeIdx + 1;
|
|
1513
|
+
continue;
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1516
|
+
if (code.slice(i).startsWith("\\end{")) {
|
|
1517
|
+
const closeIdx = code.indexOf("}", i + 5);
|
|
1518
|
+
if (closeIdx === -1) {
|
|
1519
|
+
return {
|
|
1520
|
+
checked: true,
|
|
1521
|
+
valid: false,
|
|
1522
|
+
language: "latex",
|
|
1523
|
+
error: `Malformed \\end statement at line ${lineNum}`,
|
|
1524
|
+
};
|
|
1525
|
+
}
|
|
1526
|
+
const envName = code.slice(i + 5, closeIdx).trim();
|
|
1527
|
+
if (envStack.length === 0) {
|
|
1528
|
+
return {
|
|
1529
|
+
checked: true,
|
|
1530
|
+
valid: false,
|
|
1531
|
+
language: "latex",
|
|
1532
|
+
error: `Unexpected \\end{${envName}} at line ${lineNum} with no open environment`,
|
|
1533
|
+
};
|
|
1534
|
+
}
|
|
1535
|
+
const top = envStack.pop();
|
|
1536
|
+
if (top.name !== envName) {
|
|
1537
|
+
return {
|
|
1538
|
+
checked: true,
|
|
1539
|
+
valid: false,
|
|
1540
|
+
language: "latex",
|
|
1541
|
+
error: `Mismatched LaTeX environment: expected '\\end{${top.name}}' (opened at line ${top.line}), but found '\\end{${envName}}' at line ${lineNum}`,
|
|
1542
|
+
};
|
|
1543
|
+
}
|
|
1544
|
+
i = closeIdx + 1;
|
|
1545
|
+
continue;
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1548
|
+
const ch = code[i];
|
|
1549
|
+
let bsCount = 0;
|
|
1550
|
+
let k = i - 1;
|
|
1551
|
+
while (k >= 0 && code[k] === "\\") {
|
|
1552
|
+
bsCount++;
|
|
1553
|
+
k--;
|
|
1554
|
+
}
|
|
1555
|
+
const isEscaped = bsCount % 2 === 1;
|
|
1556
|
+
|
|
1557
|
+
if (!isEscaped) {
|
|
1558
|
+
if (ch === "{") {
|
|
1559
|
+
braceDepth++;
|
|
1560
|
+
} else if (ch === "}") {
|
|
1561
|
+
braceDepth--;
|
|
1562
|
+
if (braceDepth < 0) {
|
|
1563
|
+
return {
|
|
1564
|
+
checked: true,
|
|
1565
|
+
valid: false,
|
|
1566
|
+
language: "latex",
|
|
1567
|
+
error: `Unexpected closing brace '}' at line ${lineNum}`,
|
|
1568
|
+
};
|
|
1569
|
+
}
|
|
1570
|
+
}
|
|
1571
|
+
}
|
|
1572
|
+
i++;
|
|
1573
|
+
}
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
if (inVerbatim) {
|
|
1577
|
+
return {
|
|
1578
|
+
checked: true,
|
|
1579
|
+
valid: false,
|
|
1580
|
+
language: "latex",
|
|
1581
|
+
error: `Unclosed LaTeX verbatim environment '\\begin{${inVerbatim}}'`,
|
|
1582
|
+
};
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1585
|
+
if (envStack.length > 0) {
|
|
1586
|
+
const unclosed = envStack.pop();
|
|
1587
|
+
return {
|
|
1588
|
+
checked: true,
|
|
1589
|
+
valid: false,
|
|
1590
|
+
language: "latex",
|
|
1591
|
+
error: `Unclosed LaTeX environment '\\begin{${unclosed.name}}' opened at line ${unclosed.line}`,
|
|
1592
|
+
};
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
if (braceDepth > 0) {
|
|
1596
|
+
return {
|
|
1597
|
+
checked: true,
|
|
1598
|
+
valid: false,
|
|
1599
|
+
language: "latex",
|
|
1600
|
+
error: `Unclosed brace '{' (${braceDepth} unclosed)`,
|
|
1601
|
+
};
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
return { checked: true, valid: true, language: "latex" };
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
/**
|
|
1608
|
+
* BibTeX gate: in-process structural validation of BibTeX files.
|
|
1609
|
+
* Validates:
|
|
1610
|
+
* - BibTeX entry boundary syntax (@type{key, ...} or @type(key, ...))
|
|
1611
|
+
* - Required citation keys for standard entry types
|
|
1612
|
+
* - Balanced braces and quote delimiters across each entry
|
|
1613
|
+
*/
|
|
1614
|
+
_checkBibtex(content) {
|
|
1615
|
+
let i = 0;
|
|
1616
|
+
const len = content.length;
|
|
1617
|
+
let lineNum = 1;
|
|
1618
|
+
|
|
1619
|
+
while (i < len) {
|
|
1620
|
+
const ch = content[i];
|
|
1621
|
+
if (ch === "\n") {
|
|
1622
|
+
lineNum++;
|
|
1623
|
+
i++;
|
|
1624
|
+
continue;
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1627
|
+
if (ch !== "@") {
|
|
1628
|
+
i++;
|
|
1629
|
+
continue;
|
|
1630
|
+
}
|
|
1631
|
+
|
|
1632
|
+
const entryStartLine = lineNum;
|
|
1633
|
+
i++; // skip '@'
|
|
1634
|
+
|
|
1635
|
+
let type = "";
|
|
1636
|
+
while (i < len && /[a-zA-Z0-9_]/.test(content[i])) {
|
|
1637
|
+
type += content[i];
|
|
1638
|
+
i++;
|
|
1639
|
+
}
|
|
1640
|
+
type = type.toLowerCase();
|
|
1641
|
+
if (!type) {
|
|
1642
|
+
return {
|
|
1643
|
+
checked: true,
|
|
1644
|
+
valid: false,
|
|
1645
|
+
language: "bibtex",
|
|
1646
|
+
error: `Malformed BibTeX entry at line ${entryStartLine}: missing entry type after '@'`,
|
|
1647
|
+
};
|
|
1648
|
+
}
|
|
1649
|
+
|
|
1650
|
+
while (i < len && /\s/.test(content[i])) {
|
|
1651
|
+
if (content[i] === "\n") lineNum++;
|
|
1652
|
+
i++;
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1655
|
+
if (i >= len) {
|
|
1656
|
+
return {
|
|
1657
|
+
checked: true,
|
|
1658
|
+
valid: false,
|
|
1659
|
+
language: "bibtex",
|
|
1660
|
+
error: `Unclosed BibTeX entry '@${type}' at line ${entryStartLine}`,
|
|
1661
|
+
};
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1664
|
+
const opener = content[i];
|
|
1665
|
+
if (opener !== "{" && opener !== "(") {
|
|
1666
|
+
if (type === "comment") {
|
|
1667
|
+
while (i < len && content[i] !== "\n") i++;
|
|
1668
|
+
continue;
|
|
1669
|
+
}
|
|
1670
|
+
return {
|
|
1671
|
+
checked: true,
|
|
1672
|
+
valid: false,
|
|
1673
|
+
language: "bibtex",
|
|
1674
|
+
error: `Malformed BibTeX entry '@${type}' at line ${entryStartLine}: expected '{' or '(' after entry type, found '${opener}'`,
|
|
1675
|
+
};
|
|
1676
|
+
}
|
|
1677
|
+
|
|
1678
|
+
const closer = opener === "{" ? "}" : ")";
|
|
1679
|
+
i++; // skip opener
|
|
1680
|
+
|
|
1681
|
+
const isSpecial = type === "comment" || type === "string" || type === "preamble";
|
|
1682
|
+
|
|
1683
|
+
while (i < len && /\s/.test(content[i])) {
|
|
1684
|
+
if (content[i] === "\n") lineNum++;
|
|
1685
|
+
i++;
|
|
1686
|
+
}
|
|
1687
|
+
|
|
1688
|
+
if (!isSpecial) {
|
|
1689
|
+
let key = "";
|
|
1690
|
+
while (i < len && content[i] !== "," && content[i] !== closer && !/\s/.test(content[i])) {
|
|
1691
|
+
key += content[i];
|
|
1692
|
+
i++;
|
|
1693
|
+
}
|
|
1694
|
+
if (!key || key.includes("=")) {
|
|
1695
|
+
return {
|
|
1696
|
+
checked: true,
|
|
1697
|
+
valid: false,
|
|
1698
|
+
language: "bibtex",
|
|
1699
|
+
error: `Malformed BibTeX entry '@${type}' at line ${entryStartLine}: missing citation key`,
|
|
1700
|
+
};
|
|
1701
|
+
}
|
|
1702
|
+
}
|
|
1703
|
+
|
|
1704
|
+
let depth = 1;
|
|
1705
|
+
let inQuote = false;
|
|
1706
|
+
|
|
1707
|
+
while (i < len && depth > 0) {
|
|
1708
|
+
const c = content[i];
|
|
1709
|
+
if (c === "\n") {
|
|
1710
|
+
lineNum++;
|
|
1711
|
+
} else if (c === "\\" && i + 1 < len) {
|
|
1712
|
+
i += 2;
|
|
1713
|
+
continue;
|
|
1714
|
+
} else if (c === '"' && depth === 1) {
|
|
1715
|
+
inQuote = !inQuote;
|
|
1716
|
+
} else if (!inQuote) {
|
|
1717
|
+
if (c === opener) {
|
|
1718
|
+
depth++;
|
|
1719
|
+
} else if (c === closer) {
|
|
1720
|
+
depth--;
|
|
1721
|
+
}
|
|
1722
|
+
}
|
|
1723
|
+
i++;
|
|
1724
|
+
}
|
|
1725
|
+
|
|
1726
|
+
if (depth > 0) {
|
|
1727
|
+
return {
|
|
1728
|
+
checked: true,
|
|
1729
|
+
valid: false,
|
|
1730
|
+
language: "bibtex",
|
|
1731
|
+
error: `Unclosed BibTeX entry '@${type}' (opened at line ${entryStartLine})`,
|
|
1732
|
+
};
|
|
1733
|
+
}
|
|
1734
|
+
}
|
|
1735
|
+
|
|
1736
|
+
return { checked: true, valid: true, language: "bibtex" };
|
|
1737
|
+
}
|
|
1738
|
+
}
|
|
1739
|
+
|
|
1740
|
+
// ---------------------------------------------------------------------------
|
|
1741
|
+
// Module-level helpers for the syntax gate (kept outside the class so they are
|
|
1742
|
+
// trivially unit-testable and shared across instances).
|
|
1743
|
+
// ---------------------------------------------------------------------------
|
|
1744
|
+
|
|
1745
|
+
/**
|
|
1746
|
+
* Heuristic ESM detection: true when the source has a top-level import /
|
|
1747
|
+
* export / import.meta. Comments and string literals are stripped first so a
|
|
1748
|
+
* word like "import" inside a comment or string does not misfire.
|
|
1749
|
+
*/
|
|
1750
|
+
function looksLikeESM(src) {
|
|
1751
|
+
if (typeof src !== "string") return false;
|
|
1752
|
+
const stripped = src
|
|
1753
|
+
.replace(/\/\*[\s\S]*?\*\//g, "") // block comments
|
|
1754
|
+
.replace(/\/\/[^\n]*/g, "") // line comments
|
|
1755
|
+
.replace(/`(?:\\.|[^`\\])*`/g, "``") // template literals
|
|
1756
|
+
.replace(/'(?:\\.|[^'\\\n])*'/g, "''") // single-quoted strings
|
|
1757
|
+
.replace(/"(?:\\.|[^"\\\n])*"/g, '""'); // double-quoted strings
|
|
1758
|
+
return /^\s*(import\s|import\(|export\s|import\.meta)/m.test(stripped);
|
|
1759
|
+
}
|
|
1760
|
+
|
|
1761
|
+
/** Writes content to a fresh temp file with the given extension; returns path. */
|
|
1762
|
+
function _writeTemp(content, ext) {
|
|
1763
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "astgate_"));
|
|
1764
|
+
const file = path.join(dir, `check${ext}`);
|
|
1765
|
+
fs.writeFileSync(file, content, "utf8");
|
|
1766
|
+
return file;
|
|
1767
|
+
}
|
|
1768
|
+
|
|
1769
|
+
/** Best-effort removal of a temp file and its parent dir. */
|
|
1770
|
+
function _removeTemp(file) {
|
|
1771
|
+
try {
|
|
1772
|
+
fs.rmSync(path.dirname(file), { recursive: true, force: true });
|
|
1773
|
+
} catch {
|
|
1774
|
+
/* ignore cleanup failures */
|
|
1775
|
+
}
|
|
1776
|
+
}
|
|
1777
|
+
|
|
1778
|
+
/**
|
|
1779
|
+
* Castor Plugin to mount AstService into Context.
|
|
1780
|
+
*/
|
|
1781
|
+
export function astPlugin(ctx, options = {}) {
|
|
1782
|
+
const ast = new AstService(options);
|
|
1783
|
+
ctx.provide("ast", ast);
|
|
1784
|
+
|
|
1785
|
+
ctx.registerTool("ast_search", {
|
|
1786
|
+
description:
|
|
1787
|
+
"Searches code by syntactic AST pattern across 20+ languages (JS, TS, Python, Go, Rust, C++). " +
|
|
1788
|
+
"Use metavariables ($VAR, $$$BODY) to capture elements regardless of whitespace or formatting differences. " +
|
|
1789
|
+
"Example pattern: 'function $NAME($$$ARGS) { $$$BODY }' or 'def $NAME($$$ARGS): $$$BODY'. " +
|
|
1790
|
+
"Each match reports file, line, column, the matched text, a compact single-line snippet, a " +
|
|
1791
|
+
"grep-able 'file:line:column' location, and a metavariables map of every $VAR/$$$BODY binding. " +
|
|
1792
|
+
"Directory targets are scanned recursively (sandbox ignore-set and size caps applied).",
|
|
1793
|
+
parameters: {
|
|
1794
|
+
type: "object",
|
|
1795
|
+
properties: {
|
|
1796
|
+
path: {
|
|
1797
|
+
type: "string",
|
|
1798
|
+
description: "Path to file or directory to search",
|
|
1799
|
+
},
|
|
1800
|
+
pattern: {
|
|
1801
|
+
type: "string",
|
|
1802
|
+
description: "AST code pattern with metavariables (e.g. 'function $NAME($ARGS) { $$$BODY }')",
|
|
1803
|
+
},
|
|
1804
|
+
lang: {
|
|
1805
|
+
type: "string",
|
|
1806
|
+
description: "Optional language ('js', 'ts', 'python', 'go', 'rust')",
|
|
1807
|
+
},
|
|
1808
|
+
},
|
|
1809
|
+
required: ["path", "pattern"],
|
|
1810
|
+
},
|
|
1811
|
+
execute: async (args) => ast.search(args),
|
|
1812
|
+
});
|
|
1813
|
+
}
|