@linghun/pre-engine-darwin-x64 0.1.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.
@@ -0,0 +1,481 @@
1
+ "use strict";
2
+ const readline = require("readline");
3
+ const path = require("path");
4
+ const fs = require("fs");
5
+ const { spawn, spawnSync } = require("child_process");
6
+
7
+ // --- LSP JSON-RPC framing ---
8
+ let msgId = 0;
9
+ function lspEncode(obj) {
10
+ const body = JSON.stringify(obj);
11
+ return `Content-Length: ${Buffer.byteLength(body)}\r\n\r\n${body}`;
12
+ }
13
+ function lspRequest(method, params) {
14
+ return lspEncode({ jsonrpc: "2.0", id: ++msgId, method, params });
15
+ }
16
+ function lspNotify(method, params) {
17
+ return lspEncode({ jsonrpc: "2.0", method, params });
18
+ }
19
+
20
+ function fileUri(p) {
21
+ const abs = path.resolve(p).replace(/\\/g, "/");
22
+ return abs.startsWith("/") ? `file://${abs}` : `file:///${abs}`;
23
+ }
24
+
25
+ // Normalize URI for Map keying — rust-analyzer lowercases drive letters on Windows
26
+ function normUri(uri) { return uri.toLowerCase(); }
27
+
28
+ // --- rust-analyzer session state ---
29
+ let raProc = null;
30
+ let raRoot = null;
31
+ let raReady = false;
32
+ let raBuffer = "";
33
+ let raExpectedLen = -1;
34
+ let raInitResolve = null;
35
+ let raInitReject = null;
36
+ let raDiagnostics = new Map(); // normUri -> issue[]
37
+ let raDiagTimers = new Map(); // normUri -> settle timer
38
+ let raDiagWaiters = []; // { uri: normUri, resolve }
39
+ let openDocs = new Map(); // uri -> version
40
+ let raDocOpenTime = new Map(); // normUri -> timestamp when file was opened
41
+ let raEmptyCount = new Map(); // normUri -> count of consecutive empty publishDiagnostics
42
+ let raWarmedUp = false; // true after first non-empty diagnostic from this server instance
43
+ let raStartingPromise = null; // non-null while startRustAnalyzer is in progress (eager warm)
44
+
45
+ const BOOTSTRAP_BUDGET_MS = 3000; // max wait for LSP on cold first query before cargo fallback
46
+ const WARM_SETTLE_MS = 1500; // max wait for empty diagnostics on warm server to confirm "clean"
47
+
48
+ function findRustAnalyzer() {
49
+ const whichCmd = process.platform === "win32" ? "where.exe" : "which";
50
+ const r = spawnSync(whichCmd, ["rust-analyzer"], {
51
+ encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], windowsHide: true,
52
+ });
53
+ if (r.status === 0) {
54
+ const line = r.stdout.split(/\r?\n/).map(l => l.trim()).filter(Boolean)[0];
55
+ return line || null;
56
+ }
57
+ return null;
58
+ }
59
+
60
+ function killRa() {
61
+ if (raProc) { try { raProc.kill(); } catch {} raProc = null; }
62
+ raReady = false;
63
+ raWarmedUp = false;
64
+ raStartingPromise = null;
65
+ raInitResolve = null;
66
+ raInitReject = null;
67
+ raDiagWaiters.forEach(w => w.resolve([]));
68
+ raDiagWaiters = [];
69
+ raDiagTimers.forEach(t => clearTimeout(t));
70
+ raDiagTimers.clear();
71
+ openDocs.clear();
72
+ raDocOpenTime.clear();
73
+ raEmptyCount.clear();
74
+ }
75
+
76
+ // LSP message parser (Content-Length framing)
77
+ function onRaData(chunk) {
78
+ raBuffer += chunk.toString();
79
+ while (true) {
80
+ if (raExpectedLen === -1) {
81
+ const headerEnd = raBuffer.indexOf("\r\n\r\n");
82
+ if (headerEnd === -1) break;
83
+ const header = raBuffer.slice(0, headerEnd);
84
+ const m = header.match(/Content-Length:\s*(\d+)/i);
85
+ if (!m) { raBuffer = raBuffer.slice(headerEnd + 4); continue; }
86
+ raExpectedLen = parseInt(m[1], 10);
87
+ raBuffer = raBuffer.slice(headerEnd + 4);
88
+ }
89
+ if (raBuffer.length < raExpectedLen) break;
90
+ const body = raBuffer.slice(0, raExpectedLen);
91
+ raBuffer = raBuffer.slice(raExpectedLen);
92
+ raExpectedLen = -1;
93
+ handleRaMessage(body);
94
+ }
95
+ }
96
+
97
+ function handleRaMessage(body) {
98
+ let msg;
99
+ try { msg = JSON.parse(body); } catch { return; }
100
+
101
+ // Server-initiated requests need an ACK or LSP session hangs
102
+ if (msg.method && msg.id != null) {
103
+ raProc.stdin.write(lspEncode({ jsonrpc: "2.0", id: msg.id, result: null }));
104
+ return;
105
+ }
106
+
107
+ // Initialize response (must check msg.result exists to distinguish from notifications)
108
+ if (msg.id && msg.result !== undefined && raInitResolve && !raReady) {
109
+ raProc.stdin.write(lspNotify("initialized", {}));
110
+ raReady = true;
111
+ const resolve = raInitResolve;
112
+ raInitResolve = null;
113
+ raInitReject = null;
114
+ resolve();
115
+ return;
116
+ }
117
+
118
+ // publishDiagnostics notification
119
+ if (msg.method === "textDocument/publishDiagnostics" && msg.params) {
120
+ const { uri, diagnostics } = msg.params;
121
+ const uriKey = normUri(uri);
122
+ const issues = (diagnostics || [])
123
+ .filter(d => d.severity === 1) // 1 = Error
124
+ .map(d => ({
125
+ file: uriToRel(uri),
126
+ line: (d.range && d.range.start) ? d.range.start.line + 1 : 1,
127
+ kind: "type_error",
128
+ detail: d.message || "unknown error",
129
+ source: "rust-deep-layer",
130
+ }));
131
+ raDiagnostics.set(uriKey, issues);
132
+ if (raDiagTimers.has(uriKey)) clearTimeout(raDiagTimers.get(uriKey));
133
+ if (issues.length > 0) {
134
+ // Non-empty: errors are definitive, resolve waiters immediately
135
+ raWarmedUp = true;
136
+ raEmptyCount.set(uriKey, 0);
137
+ raDiagTimers.delete(uriKey);
138
+ raDiagWaiters = raDiagWaiters.filter(w => {
139
+ if (w.uri === uriKey) { w.resolve(issues); return false; }
140
+ return true;
141
+ });
142
+ } else if (raWarmedUp) {
143
+ // Warm server + empty diagnostics: track consecutive empties
144
+ const count = (raEmptyCount.get(uriKey) || 0) + 1;
145
+ raEmptyCount.set(uriKey, count);
146
+ // 2+ consecutive empties = confirmed clean, resolve immediately
147
+ if (count >= 2) {
148
+ raDiagTimers.delete(uriKey);
149
+ raDiagWaiters = raDiagWaiters.filter(w => {
150
+ if (w.uri === uriKey) { w.resolve([]); return false; }
151
+ return true;
152
+ });
153
+ } else {
154
+ // First empty: settle with reduced timeout
155
+ const openedAt = raDocOpenTime.get(uriKey) || 0;
156
+ const elapsed = Date.now() - openedAt;
157
+ const remaining = Math.max(0, WARM_SETTLE_MS - elapsed);
158
+ raDiagTimers.set(uriKey, setTimeout(() => {
159
+ raDiagTimers.delete(uriKey);
160
+ const final = raDiagnostics.get(uriKey) || [];
161
+ raDiagWaiters = raDiagWaiters.filter(w => {
162
+ if (w.uri === uriKey) { w.resolve(final); return false; }
163
+ return true;
164
+ });
165
+ }, remaining));
166
+ }
167
+ }
168
+ // Cold server + empty diagnostics: do NOT accept early.
169
+ // Let waitForDiag's DIAG_TIMEOUT be the backstop — real errors will
170
+ // arrive later and resolve immediately via the non-empty branch above.
171
+ }
172
+ }
173
+
174
+ function uriToRel(uri) {
175
+ // file:///abs/path -> relative to raRoot
176
+ const decoded = decodeURIComponent(uri.replace(/^file:\/\/\/?/, "").replace(/^([a-z]):/, (_, d) => d.toUpperCase() + ":"));
177
+ if (raRoot) {
178
+ let rel = path.relative(raRoot, decoded).replace(/\\/g, "/");
179
+ if (!rel.startsWith("..")) return rel;
180
+ }
181
+ return decoded.replace(/\\/g, "/");
182
+ }
183
+
184
+ function waitForDiag(uri, timeoutMs) {
185
+ const uriKey = normUri(uri);
186
+ return new Promise(resolve => {
187
+ // Return immediately if we have settled diagnostics (no pending debounce)
188
+ if (raDiagnostics.has(uriKey) && !raDiagTimers.has(uriKey)) {
189
+ resolve(raDiagnostics.get(uriKey));
190
+ return;
191
+ }
192
+ const timer = setTimeout(() => {
193
+ raDiagWaiters = raDiagWaiters.filter(w => w.resolve !== resolve);
194
+ resolve(raDiagnostics.get(uriKey) || []);
195
+ }, timeoutMs);
196
+ raDiagWaiters.push({ uri: uriKey, resolve: issues => { clearTimeout(timer); resolve(issues); } });
197
+ });
198
+ }
199
+
200
+ function startRustAnalyzer(root) {
201
+ return new Promise((resolve, reject) => {
202
+ const raPath = findRustAnalyzer();
203
+ if (!raPath) { reject(new Error("rust-analyzer not found")); return; }
204
+
205
+ raRoot = root;
206
+ raDiagnostics.clear();
207
+ openDocs.clear();
208
+ raWarmedUp = false;
209
+ raBuffer = "";
210
+ raExpectedLen = -1;
211
+ raReady = false;
212
+
213
+ raProc = spawn(raPath, [], {
214
+ cwd: root,
215
+ stdio: ["pipe", "pipe", "pipe"],
216
+ windowsHide: true,
217
+ });
218
+ raProc.on("error", e => { raProc = null; reject(e); });
219
+ raProc.on("exit", () => { raProc = null; raReady = false; });
220
+ raProc.stdout.on("data", chunk => onRaData(chunk));
221
+
222
+ raInitResolve = resolve;
223
+ raInitReject = reject;
224
+
225
+ raProc.stdin.write(lspRequest("initialize", {
226
+ processId: process.pid,
227
+ capabilities: { textDocument: { publishDiagnostics: { relatedInformation: false } } },
228
+ rootUri: fileUri(root),
229
+ workspaceFolders: [{ uri: fileUri(root), name: path.basename(root) }],
230
+ initializationOptions: {
231
+ checkOnSave: false,
232
+ diagnostics: { enable: true },
233
+ cargo: { buildScripts: { enable: false } },
234
+ procMacro: { enable: false },
235
+ },
236
+ }));
237
+
238
+ setTimeout(() => {
239
+ if (!raReady) { killRa(); reject(new Error("rust-analyzer init timeout (15s)")); }
240
+ }, 15000);
241
+ });
242
+ }
243
+
244
+ // Eagerly start rust-analyzer for a root without blocking. Triggers workspace
245
+ // loading by opening lib.rs or main.rs so subsequent queries hit a warm server.
246
+ function eagerWarmUp(root) {
247
+ if (raStartingPromise) return raStartingPromise;
248
+ raStartingPromise = startRustAnalyzer(root).then(() => {
249
+ // Open a sentinel file to trigger workspace loading
250
+ const candidates = ["src/lib.rs", "src/main.rs"];
251
+ for (const rel of candidates) {
252
+ const abs = path.join(root, rel);
253
+ if (fs.existsSync(abs)) {
254
+ sendDocOpen(abs, fileUri(abs));
255
+ break;
256
+ }
257
+ }
258
+ }).catch(() => {}).finally(() => { raStartingPromise = null; });
259
+ return raStartingPromise;
260
+ }
261
+
262
+ // Send didOpen (or didChange if already open) to force re-read from disk
263
+ function sendDocOpen(absPath, uri) {
264
+ let text;
265
+ try { text = fs.readFileSync(absPath, "utf8"); } catch { return false; }
266
+ const version = (openDocs.get(uri) || 0) + 1;
267
+ openDocs.set(uri, version);
268
+ const uriKey = normUri(uri);
269
+ raDiagnostics.delete(uriKey); // invalidate stale cache
270
+ raEmptyCount.set(uriKey, 0); // reset consecutive empty counter
271
+ raDocOpenTime.set(uriKey, Date.now());
272
+
273
+ if (version === 1) {
274
+ raProc.stdin.write(lspNotify("textDocument/didOpen", {
275
+ textDocument: { uri, languageId: "rust", version, text },
276
+ }));
277
+ } else {
278
+ raProc.stdin.write(lspNotify("textDocument/didChange", {
279
+ textDocument: { uri, version },
280
+ contentChanges: [{ text }],
281
+ }));
282
+ }
283
+ return true;
284
+ }
285
+
286
+ // Main LSP-based query: returns { issues, elapsed_ms } or throws
287
+ async function queryLsp(root, files) {
288
+ if (!raProc || !raReady || raRoot !== root) {
289
+ await startRustAnalyzer(root);
290
+ }
291
+
292
+ const DIAG_TIMEOUT = 25000;
293
+ const uris = files.map(f => {
294
+ const abs = path.isAbsolute(f) ? f : path.join(root, f);
295
+ return { abs, uri: fileUri(abs) };
296
+ });
297
+
298
+ // open / refresh each file
299
+ for (const { abs, uri } of uris) sendDocOpen(abs, uri);
300
+
301
+ // wait for diagnostics for each URI
302
+ const results = await Promise.all(uris.map(({ uri }) => waitForDiag(uri, DIAG_TIMEOUT)));
303
+ const issues = results.flat();
304
+ return issues;
305
+ }
306
+
307
+ // --- cargo-check fallback (unchanged from Phase 6-C) ---
308
+ let cachedCargoPath = null;
309
+ function findCargo() {
310
+ if (cachedCargoPath) return cachedCargoPath;
311
+ const whichCmd = process.platform === "win32" ? "where.exe" : "which";
312
+ const r = spawnSync(whichCmd, ["cargo"], {
313
+ encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], windowsHide: true,
314
+ });
315
+ if (r.status === 0) {
316
+ const lines = r.stdout.split(/\r?\n/).map(l => l.trim()).filter(Boolean);
317
+ const preferred = (process.platform === "win32" ? lines.find(l => l.endsWith(".exe")) : null) || lines[0];
318
+ if (preferred) { cachedCargoPath = preferred; return preferred; }
319
+ }
320
+ return null;
321
+ }
322
+
323
+ // Walk up from a file path to find the nearest Cargo.toml
324
+ function findManifest(filePath, fallbackRoot) {
325
+ let dir = path.dirname(path.isAbsolute(filePath) ? filePath : path.resolve(fallbackRoot, filePath));
326
+ const root = path.parse(dir).root;
327
+ while (true) {
328
+ const candidate = path.join(dir, "Cargo.toml");
329
+ if (fs.existsSync(candidate)) return candidate;
330
+ const parent = path.dirname(dir);
331
+ if (parent === dir || dir === root) break;
332
+ dir = parent;
333
+ }
334
+ return null;
335
+ }
336
+
337
+ function runCargoCheck(root, files) {
338
+ const cargoPath = findCargo();
339
+ if (!cargoPath) return { error: "cargo not found" };
340
+
341
+ // Find nearest Cargo.toml from the first changed file
342
+ const manifest = files.length > 0 ? findManifest(files[0], root) : null;
343
+ const args = manifest
344
+ ? ["check", "--manifest-path", manifest, "--message-format=json"]
345
+ : ["check", "--message-format=json"];
346
+ const cwd = manifest ? path.dirname(manifest) : root;
347
+
348
+ let result;
349
+ try {
350
+ result = spawnSync(cargoPath, args, {
351
+ cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"],
352
+ windowsHide: true, timeout: 30000,
353
+ });
354
+ } catch (e) { return { error: `cargo exec: ${e.message}` }; }
355
+ if (result.error) {
356
+ return { error: result.error.code === "ETIMEDOUT" ? "cargo check timeout" : result.error.message };
357
+ }
358
+ const manifestRoot = manifest ? path.dirname(manifest) : root;
359
+ const targetFiles = files.map(f =>
360
+ path.relative(manifestRoot, path.isAbsolute(f) ? f : path.resolve(root, f)).replace(/\\/g, "/")
361
+ );
362
+ const issues = [];
363
+ for (const line of (result.stdout || "").split(/\r?\n/)) {
364
+ if (!line.trim()) continue;
365
+ let msg;
366
+ try { msg = JSON.parse(line); } catch { continue; }
367
+ if (msg.reason !== "compiler-message") continue;
368
+ const diag = msg.message;
369
+ if (!diag || diag.level !== "error") continue;
370
+ const spans = diag.spans || [];
371
+ const span = spans.find(s => s.is_primary) || spans[0];
372
+ if (!span) continue;
373
+ const rel = (span.file_name || "").replace(/\\/g, "/");
374
+ if (!targetFiles.includes(rel)) continue;
375
+ issues.push({ file: rel, line: span.line_start || 1, kind: "type_error", detail: diag.message || "unknown", source: "rust-deep-layer" });
376
+ }
377
+ return { issues };
378
+ }
379
+
380
+ // --- Main request handler ---
381
+ // Primary path: rust-analyzer LSP (rich diagnostics, warm incremental).
382
+ // Fallback: cargo check when LSP unavailable, init fails, or times out.
383
+ // Bootstrap: first query on cold server uses a short budget — if LSP can't
384
+ // deliver in time, falls back to cargo-check while LSP continues warming.
385
+ async function handleRequest(req) {
386
+ const root = req.root;
387
+ const files = req.files || [];
388
+ const t0 = Date.now();
389
+
390
+ // If server not started yet, kick off eager warm-up for next time
391
+ if (!raProc && !raStartingPromise) {
392
+ eagerWarmUp(root);
393
+ }
394
+
395
+ // Warm path: LSP is ready and has proven it works
396
+ if (raWarmedUp && raProc && raReady && raRoot === root) {
397
+ try {
398
+ const tOpen = Date.now();
399
+ const issues = await queryLsp(root, files);
400
+ const tDone = Date.now();
401
+ return { issues, elapsed_ms: tDone - t0, timing: { open_ms: tOpen - t0, diag_ms: tDone - tOpen } };
402
+ } catch (lspErr) {
403
+ const cargoResult = runCargoCheck(root, files);
404
+ if (!cargoResult.error) {
405
+ cargoResult.elapsed_ms = Date.now() - t0;
406
+ cargoResult.fallback = `lsp: ${lspErr.message}`;
407
+ return cargoResult;
408
+ }
409
+ return { issues: [], elapsed_ms: Date.now() - t0, error: `lsp: ${lspErr.message}; cargo: ${cargoResult.error}` };
410
+ }
411
+ }
412
+
413
+ // Cold/bootstrap path: race LSP against cargo-check truly in parallel.
414
+ // LSP continues warming in background regardless of who wins.
415
+ const lspPromise = (async () => {
416
+ try {
417
+ const tInit = Date.now();
418
+ // Don't block on raStartingPromise — let it run concurrently with cargo
419
+ if (!raProc && !raStartingPromise) eagerWarmUp(root);
420
+ if (raStartingPromise) await raStartingPromise;
421
+ const tOpen = Date.now();
422
+ const issues = await queryLsp(root, files);
423
+ const tDone = Date.now();
424
+ return { issues, elapsed_ms: tDone - t0, timing: { init_ms: tOpen - tInit, open_ms: tOpen - t0, diag_ms: tDone - tOpen } };
425
+ } catch (e) {
426
+ return null; // LSP failed
427
+ }
428
+ })();
429
+
430
+ // Start cargo-check immediately in parallel (sync but on a separate "lane")
431
+ const cargoPromise = new Promise(resolve => {
432
+ setImmediate(() => {
433
+ const tCargo = Date.now();
434
+ const result = runCargoCheck(root, files);
435
+ if (!result.error) {
436
+ result.elapsed_ms = Date.now() - t0;
437
+ result.bootstrap = true;
438
+ result.timing = { cargo_ms: Date.now() - tCargo };
439
+ resolve(result);
440
+ } else {
441
+ resolve(null); // cargo failed
442
+ }
443
+ });
444
+ });
445
+
446
+ const budgetPromise = new Promise(resolve => {
447
+ setTimeout(() => resolve("timeout"), BOOTSTRAP_BUDGET_MS);
448
+ });
449
+
450
+ // Race: LSP wins if it delivers before budget, otherwise cargo wins immediately
451
+ const race = await Promise.race([lspPromise, budgetPromise]);
452
+ if (race !== "timeout" && race !== null) {
453
+ return race; // LSP delivered within budget
454
+ }
455
+
456
+ // Budget expired — take cargo result (already running in parallel)
457
+ const cargoResult = await cargoPromise;
458
+ if (cargoResult) return cargoResult;
459
+
460
+ // Cargo also failed — wait for LSP result (it's still running)
461
+ const lspResult = await lspPromise;
462
+ if (lspResult) return lspResult;
463
+
464
+ return { issues: [], elapsed_ms: Date.now() - t0, error: "bootstrap: both LSP and cargo failed" };
465
+ }
466
+
467
+ const rl = readline.createInterface({ input: process.stdin, terminal: false });
468
+ rl.on("line", line => {
469
+ line = line.trim();
470
+ if (!line) return;
471
+ let req;
472
+ try { req = JSON.parse(line); } catch { return; }
473
+ handleRequest(req).then(resp => {
474
+ process.stdout.write(JSON.stringify(resp) + "\n");
475
+ }).catch(e => {
476
+ process.stdout.write(JSON.stringify({ error: String(e), elapsed_ms: 0 }) + "\n");
477
+ });
478
+ });
479
+ rl.on("close", () => { killRa(); });
480
+
481
+ process.on("exit", killRa);
@@ -0,0 +1,161 @@
1
+ "use strict";
2
+ const readline = require("readline");
3
+ const path = require("path");
4
+ const fs = require("fs");
5
+ const { spawnSync } = require("child_process");
6
+
7
+ function findShellcheck() {
8
+ const whichCmd = process.platform === "win32" ? "where.exe" : "which";
9
+ const r = spawnSync(whichCmd, ["shellcheck"], {
10
+ encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], windowsHide: true,
11
+ });
12
+ if (r.status === 0) {
13
+ const line = r.stdout.split(/\r?\n/).map(l => l.trim()).filter(Boolean)[0];
14
+ return line || null;
15
+ }
16
+ return null;
17
+ }
18
+
19
+ function runShellcheck(root, files) {
20
+ const shellcheck = findShellcheck();
21
+ if (!shellcheck) return null;
22
+
23
+ const absPaths = files.map(f => path.isAbsolute(f) ? f : path.join(root, f));
24
+ const args = ["--format=json1", "--severity=warning", ...absPaths];
25
+ let r;
26
+ try {
27
+ r = spawnSync(shellcheck, args, {
28
+ cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"],
29
+ windowsHide: true, timeout: 30000,
30
+ });
31
+ } catch (e) { return { error: `shellcheck exec: ${e.message}` }; }
32
+
33
+ if (r.error) {
34
+ return { error: r.error.code === "ETIMEDOUT" ? "shellcheck timeout" : r.error.message };
35
+ }
36
+
37
+ const issues = [];
38
+ const output = r.stdout || "";
39
+ try {
40
+ const parsed = JSON.parse(output);
41
+ const comments = parsed.comments || parsed;
42
+ for (const c of (Array.isArray(comments) ? comments : [])) {
43
+ const filePath = c.file || "";
44
+ const rel = path.relative(root, filePath).replace(/\\/g, "/");
45
+ issues.push({
46
+ file: rel,
47
+ line: c.line || 1,
48
+ col: c.column || 1,
49
+ kind: c.level || "warning",
50
+ message: c.message || "shellcheck issue",
51
+ code: c.code ? `SC${c.code}` : null,
52
+ source: "shell-deep-layer",
53
+ });
54
+ }
55
+ } catch {
56
+ const lines = output.split(/\r?\n/);
57
+ for (const l of lines) {
58
+ const m = l.match(/^(.+?):(\d+):(\d+):\s*(warning|error|info):\s*(.+)/);
59
+ if (m) {
60
+ issues.push({
61
+ file: path.relative(root, m[1]).replace(/\\/g, "/"),
62
+ line: parseInt(m[2], 10), col: parseInt(m[3], 10),
63
+ kind: m[4], message: m[5], source: "shell-deep-layer",
64
+ });
65
+ }
66
+ }
67
+ }
68
+ return { issues };
69
+ }
70
+
71
+ function fallbackSyntaxCheck(root, files) {
72
+ const issues = [];
73
+ for (const f of files) {
74
+ const abs = path.isAbsolute(f) ? f : path.join(root, f);
75
+ let content;
76
+ try { content = fs.readFileSync(abs, "utf8"); } catch { continue; }
77
+ const rel = path.relative(root, abs).replace(/\\/g, "/");
78
+ const lines = content.split(/\r?\n/);
79
+
80
+ let inSingleQuote = false, sqLine = 0;
81
+ let inDoubleQuote = false, dqLine = 0;
82
+ for (let i = 0; i < lines.length; i++) {
83
+ const line = lines[i];
84
+ for (let j = 0; j < line.length; j++) {
85
+ const ch = line[j];
86
+ const prev = j > 0 ? line[j - 1] : "";
87
+ if (ch === "\\" && !inSingleQuote) { j++; continue; }
88
+ if (ch === "'" && !inDoubleQuote) {
89
+ if (!inSingleQuote) { inSingleQuote = true; sqLine = i + 1; }
90
+ else { inSingleQuote = false; }
91
+ } else if (ch === '"' && !inSingleQuote) {
92
+ if (!inDoubleQuote) { inDoubleQuote = true; dqLine = i + 1; }
93
+ else { inDoubleQuote = false; }
94
+ }
95
+ }
96
+ }
97
+ if (inSingleQuote) {
98
+ issues.push({ file: rel, line: sqLine, col: 1,
99
+ kind: "syntax_error", message: "Unclosed single quote",
100
+ source: "shell-deep-layer" });
101
+ }
102
+ if (inDoubleQuote) {
103
+ issues.push({ file: rel, line: dqLine, col: 1,
104
+ kind: "syntax_error", message: "Unclosed double quote",
105
+ source: "shell-deep-layer" });
106
+ }
107
+ }
108
+ return issues;
109
+ }
110
+
111
+ async function handleRequest(req) {
112
+ const t0 = Date.now();
113
+ const root = req.root || process.cwd();
114
+ const files = req.files || [];
115
+ if (files.length === 0) {
116
+ return { issues: [], status: "clean", reason: "no_files", elapsed_ms: 0 };
117
+ }
118
+
119
+ const shellcheckResult = runShellcheck(root, files);
120
+ if (shellcheckResult && !shellcheckResult.error) {
121
+ return {
122
+ issues: shellcheckResult.issues,
123
+ status: shellcheckResult.issues.length > 0 ? "shell_error" : "clean",
124
+ reason: "shellcheck",
125
+ elapsed_ms: Date.now() - t0,
126
+ };
127
+ }
128
+
129
+ const fallbackIssues = fallbackSyntaxCheck(root, files);
130
+ if (shellcheckResult && shellcheckResult.error) {
131
+ return {
132
+ issues: fallbackIssues,
133
+ status: fallbackIssues.length > 0 ? "syntax_error" : "clean",
134
+ reason: "fallback",
135
+ fallback: shellcheckResult.error,
136
+ elapsed_ms: Date.now() - t0,
137
+ };
138
+ }
139
+
140
+ return {
141
+ issues: fallbackIssues,
142
+ status: fallbackIssues.length > 0 ? "syntax_error" : "clean",
143
+ reason: fallbackIssues.length > 0 ? "fallback" : "fallback_clean",
144
+ elapsed_ms: Date.now() - t0,
145
+ };
146
+ }
147
+
148
+ const rl = readline.createInterface({ input: process.stdin, terminal: false });
149
+ rl.on("line", (line) => {
150
+ const trimmed = line.trim();
151
+ if (!trimmed) return;
152
+ let req;
153
+ try { req = JSON.parse(trimmed); } catch { return; }
154
+ handleRequest(req).then((result) => {
155
+ process.stdout.write(JSON.stringify(result) + "\n");
156
+ }).catch((err) => {
157
+ process.stdout.write(JSON.stringify({ issues: [], status: "error", error: String(err), elapsed_ms: 0 }) + "\n");
158
+ });
159
+ });
160
+ rl.on("close", () => { process.exit(0); });
161
+