codelocal 1.5.0-beta.1

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,293 @@
1
+ import { createHash } from "node:crypto";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ const rank = { SAFE: 0, REVIEW: 1, HIGH: 2, CRITICAL: 3, BLOCKED: 4 };
5
+ const SECRET_ENV_NAME = /(?:^|_)(?:API_?KEY|TOKEN|SECRET|PASSWORD|PASSWD|PRIVATE_?KEY|ACCESS_?KEY|SESSION_?TOKEN|COOKIE)(?:$|_)/i;
6
+ function hashKey(value) {
7
+ return createHash("sha256").update(value).digest("hex").slice(0, 24);
8
+ }
9
+ export function redactCommand(command) {
10
+ let value = command;
11
+ value = value.replace(/(authorization\s*:\s*bearer\s+)[^\s"']+/gi, "$1[REDACTED]");
12
+ value = value.replace(/((?:api[_-]?key|access[_-]?token|auth[_-]?token|password|secret|cookie)\s*[=:]\s*)[^\s"']+/gi, "$1[REDACTED]");
13
+ value = value.replace(/(--(?:token|password|secret|api-key|apikey)\s+)([^\s]+)/gi, "$1[REDACTED]");
14
+ value = value.replace(/((?:OPENAI_API_KEY|CODEX_API_KEY|AWS_SECRET_ACCESS_KEY|AWS_SESSION_TOKEN|GITHUB_TOKEN|GH_TOKEN|NPM_TOKEN)=)([^\s]+)/gi, "$1[REDACTED]");
15
+ value = value.replace(/(https?:\/\/[^\s:@]+:)[^@\s]+@/gi, "$1[REDACTED]@");
16
+ return value;
17
+ }
18
+ export function isSensitivePath(relativePath) {
19
+ const p = relativePath.replace(/\\/g, "/").replace(/^\.\//, "");
20
+ const base = p.split("/").pop()?.toLowerCase() ?? "";
21
+ if ([".env.example", ".env.sample", ".env.template"].includes(base))
22
+ return false;
23
+ if (/(^|\/)\.(git|ssh|aws|gnupg|gcloud|azure)(\/|$)/i.test(p))
24
+ return true;
25
+ if (/(^|\/)\.env($|\.)/i.test(p))
26
+ return true;
27
+ if (/\.(pem|p12|pfx|key|kdbx)$/i.test(base))
28
+ return true;
29
+ if (/(credentials?|service[-_]?account|private[-_]?key|secrets?)\.(json|ya?ml|toml|ini)$/i.test(base))
30
+ return true;
31
+ return false;
32
+ }
33
+ export function hasShellComposition(command) {
34
+ return /[;&|<>`\r\n]/.test(command) || /\$\s*\(/.test(command);
35
+ }
36
+ function shellWords(command) {
37
+ const words = [];
38
+ let current = "";
39
+ let quote = null;
40
+ let escaped = false;
41
+ for (const char of command) {
42
+ if (escaped) {
43
+ current += char;
44
+ escaped = false;
45
+ continue;
46
+ }
47
+ if (char === "\\" && quote !== "'") {
48
+ escaped = true;
49
+ continue;
50
+ }
51
+ if (quote) {
52
+ if (char === quote)
53
+ quote = null;
54
+ else
55
+ current += char;
56
+ continue;
57
+ }
58
+ if (char === "'" || char === '"') {
59
+ quote = char;
60
+ continue;
61
+ }
62
+ if (/\s/.test(char)) {
63
+ if (current) {
64
+ words.push(current);
65
+ current = "";
66
+ }
67
+ continue;
68
+ }
69
+ current += char;
70
+ }
71
+ if (escaped || quote)
72
+ return null;
73
+ if (current)
74
+ words.push(current);
75
+ return words;
76
+ }
77
+ function commandWords(command) {
78
+ const words = shellWords(command);
79
+ if (!words)
80
+ return null;
81
+ let index = 0;
82
+ while (index < words.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[index]))
83
+ index++;
84
+ if (path.basename(words[index] ?? "").toLowerCase() === "env") {
85
+ index++;
86
+ while (index < words.length && (/^[A-Za-z_][A-Za-z0-9_]*=/.test(words[index]) || words[index].startsWith("-")))
87
+ index++;
88
+ }
89
+ return { words, commandIndex: index, executable: path.basename(words[index] ?? "").toLowerCase(), args: words.slice(index + 1) };
90
+ }
91
+ function isInside(workspaceRoot, candidate) {
92
+ const root = path.resolve(workspaceRoot);
93
+ const value = path.resolve(candidate);
94
+ return value === root || value.startsWith(root + path.sep);
95
+ }
96
+ function pathCandidate(token) {
97
+ const equals = token.indexOf("=");
98
+ const raw = equals > 0 && token.startsWith("-") ? token.slice(equals + 1) : token;
99
+ if (/^(?:https?|wss?|ssh):\/\//i.test(raw))
100
+ return null;
101
+ if (raw === "/dev/null")
102
+ return null;
103
+ if (raw === "~" || raw.startsWith("~/") || path.isAbsolute(raw) || raw === ".." || raw.startsWith("../") || raw.includes("/../"))
104
+ return raw;
105
+ return null;
106
+ }
107
+ function explicitPathEscape(command, context) {
108
+ if (!context.workspaceRoot)
109
+ return null;
110
+ const parsed = commandWords(command);
111
+ if (!parsed)
112
+ return null;
113
+ const cwd = path.resolve(context.cwd ?? context.workspaceRoot);
114
+ const rawExecutable = parsed.words[parsed.commandIndex] ?? "";
115
+ const tokens = [rawExecutable, ...parsed.args];
116
+ for (const token of tokens) {
117
+ const candidate = pathCandidate(token);
118
+ if (!candidate)
119
+ continue;
120
+ const expanded = candidate === "~" ? os.homedir() : candidate.startsWith("~/") ? path.join(os.homedir(), candidate.slice(2)) : path.isAbsolute(candidate) ? candidate : path.resolve(cwd, candidate);
121
+ if (!isInside(context.workspaceRoot, expanded))
122
+ return candidate;
123
+ }
124
+ return null;
125
+ }
126
+ function structuredApproval(command) {
127
+ if (hasShellComposition(command))
128
+ return null;
129
+ const parsed = commandWords(command);
130
+ if (!parsed?.executable)
131
+ return null;
132
+ const normalized = command.replace(/\s+/g, " ").trim();
133
+ const redacted = redactCommand(normalized);
134
+ if (parsed.executable === "git") {
135
+ const sub = parsed.args[0]?.toLowerCase();
136
+ const rest = parsed.args.slice(1);
137
+ if (sub === "commit")
138
+ return { key: "git.commit", label: "Git commit in this workspace" };
139
+ if (sub === "push" && !rest.some((arg) => /^(?:-f|--force(?:-with-lease)?(?:=.*)?|--delete|--mirror|--all|--tags|--prune)$/.test(arg))) {
140
+ const positional = rest.filter((arg) => !arg.startsWith("-"));
141
+ if (positional.some((arg) => arg.startsWith(":")))
142
+ return null;
143
+ if (positional[0] && positional[1])
144
+ return { key: `git.push:${positional[0]}:${positional[1]}`, label: `Git push ${positional[0]} ${positional[1]}` };
145
+ return null;
146
+ }
147
+ if (["tag", "merge", "rebase", "pull", "fetch"].includes(sub ?? ""))
148
+ return { key: `git.${sub}:${hashKey(redacted)}`, label: redacted };
149
+ }
150
+ if (/^(?:npm|pnpm|yarn|bun|pip|pipx|poetry|uv|cargo|go)$/i.test(parsed.executable) && /^(?:install|add|remove|uninstall|update|upgrade|get)$/i.test(parsed.args[0] ?? "")) {
151
+ return { key: `dependency:${parsed.executable}:${hashKey(redacted)}`, label: redacted };
152
+ }
153
+ const rawExecutable = parsed.words[parsed.commandIndex] ?? "";
154
+ if (rawExecutable.includes("/") || /^(?:node|python|python3|ruby|perl|php|deno|tsx|ts-node|jest|vitest|pytest|mocha|ava|bash|sh|zsh|fish|pwsh|powershell|java|swift|swiftc|dotnet|xcodebuild)$/i.test(parsed.executable)) {
155
+ return { key: `workspace-exec:${hashKey(redacted)}`, label: redacted };
156
+ }
157
+ if (/^(?:npm|pnpm|yarn|bun)$/i.test(parsed.executable) && /^(?:run|test|start|exec|x)$/i.test(parsed.args[0] ?? "")) {
158
+ return { key: `workspace-exec:${hashKey(redacted)}`, label: redacted };
159
+ }
160
+ if (/^(?:npx|make|just|task|gradle|gradlew|mvn|mvnw)$/i.test(parsed.executable)) {
161
+ return { key: `workspace-exec:${hashKey(redacted)}`, label: redacted };
162
+ }
163
+ if (/^(?:cargo)$/i.test(parsed.executable) && /^(?:run|test|build|bench)$/i.test(parsed.args[0] ?? "")) {
164
+ return { key: `workspace-exec:${hashKey(redacted)}`, label: redacted };
165
+ }
166
+ if (/^(?:go)$/i.test(parsed.executable) && /^(?:run|test|build|generate)$/i.test(parsed.args[0] ?? "")) {
167
+ return { key: `workspace-exec:${hashKey(redacted)}`, label: redacted };
168
+ }
169
+ if (/^(?:flutter|dart)$/i.test(parsed.executable) && /^(?:run|test|build|compile)$/i.test(parsed.args[0] ?? "")) {
170
+ return { key: `workspace-exec:${hashKey(redacted)}`, label: redacted };
171
+ }
172
+ if (parsed.executable === "dotnet" && /^(?:run|test|build|publish)$/i.test(parsed.args[0] ?? "")) {
173
+ return { key: `workspace-exec:${hashKey(redacted)}`, label: redacted };
174
+ }
175
+ if (/^(?:flutter|dart)$/i.test(parsed.executable) && parsed.args[0] === "pub" && /^(?:add|remove|upgrade|downgrade|get)$/i.test(parsed.args[1] ?? "")) {
176
+ return { key: `dependency:${parsed.executable}-pub:${hashKey(redacted)}`, label: redacted };
177
+ }
178
+ if (parsed.executable === "pod" && /^(?:install|update)$/i.test(parsed.args[0] ?? "")) {
179
+ return { key: `dependency:pod:${hashKey(redacted)}`, label: redacted };
180
+ }
181
+ return null;
182
+ }
183
+ function isRoutineDeveloperCommand(normalized) {
184
+ if (hasShellComposition(normalized))
185
+ return false;
186
+ return /^(?:(?:[A-Za-z_][A-Za-z0-9_]*=[^\s]+)\s+)*(?:(?:fvm\s+)?flutter\s+(?:analyze|test|run|doctor|devices|emulators|clean|build)\b|dart\s+(?:analyze|test|format|fix|compile)\b|xcodebuild\b|xcrun\s+(?:simctl|xctrace)\b|pod\s+(?:repo\s+list|env)\b)/i.test(normalized);
187
+ }
188
+ export function classifyCommand(command, networkPolicy = "approval", context = {}) {
189
+ const normalized = command.replace(/\s+/g, " ").trim();
190
+ const rules = [];
191
+ let risk = "SAFE";
192
+ let blocked = false;
193
+ let approval = false;
194
+ const hit = (pattern, reason, level, options = {}) => {
195
+ if (!pattern.test(normalized))
196
+ return;
197
+ rules.push(reason);
198
+ if (rank[level] > rank[risk])
199
+ risk = level;
200
+ blocked ||= !!options.block;
201
+ approval ||= !!options.approve;
202
+ };
203
+ hit(/(^|[;&|]\s*)sudo\b/i, "privilege escalation", "BLOCKED", { block: true });
204
+ hit(/(^|[;&|]\s*)(shutdown|reboot|halt|diskutil|mkfs|fdisk|gpt|mount|umount)\b/i, "system or disk administration", "BLOCKED", { block: true });
205
+ hit(/\bdd\s+[^\n]*\bof=\/dev\//i, "raw device write", "BLOCKED", { block: true });
206
+ hit(/(^|\s)(~\/)?\.(ssh|aws|gnupg|gcloud|azure)(\/|\s|$)/i, "credential directory access", "BLOCKED", { block: true });
207
+ hit(/(^|\s)\/dev\/(?!null\b|zero\b|random\b|urandom\b)/i, "device file access", "BLOCKED", { block: true });
208
+ hit(/\b(?:security\s+(?:find|dump|unlock|set)|gh\s+auth\s+token|git\s+credential(?:-\w+)?\b)/i, "credential retrieval", "BLOCKED", { block: true });
209
+ hit(/\$(?:\{)?(?:HOME|USERPROFILE|OLDPWD|TMPDIR|XDG_CONFIG_HOME|XDG_DATA_HOME)(?:\})?(?:[\\/]|$)/i, "environment-based path can escape authorized workspace", "BLOCKED", { block: true });
210
+ hit(/^(?:env|printenv|set|export\s+-p)\s*$/i, "environment secret enumeration", "BLOCKED", { block: true });
211
+ if (/\bprintenv\s+([A-Za-z_][A-Za-z0-9_]*)/i.test(normalized)) {
212
+ const name = normalized.match(/\bprintenv\s+([A-Za-z_][A-Za-z0-9_]*)/i)?.[1] ?? "";
213
+ if (SECRET_ENV_NAME.test(name))
214
+ hit(/\bprintenv\b/i, "sensitive environment variable access", "BLOCKED", { block: true });
215
+ }
216
+ if (/\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?/.test(normalized)) {
217
+ const vars = [...normalized.matchAll(/\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?/g)].map((m) => m[1]);
218
+ if (vars.some((name) => SECRET_ENV_NAME.test(name)))
219
+ hit(/\$/i, "sensitive environment variable expansion", "BLOCKED", { block: true });
220
+ }
221
+ const escaped = explicitPathEscape(command, context);
222
+ if (escaped) {
223
+ rules.push(`explicit path escapes authorized workspace: ${redactCommand(escaped)}`);
224
+ risk = "BLOCKED";
225
+ blocked = true;
226
+ }
227
+ else if (!context.workspaceRoot) {
228
+ hit(/(^|\s)\.\.\/(?:\.\.\/)?/, "parent-directory traversal", "BLOCKED", { block: true });
229
+ }
230
+ hit(/\bgit\s+(?:commit\b[^\n]*--amend\b|reset\s+--hard|clean\s+-[a-z]*f|push\s+[^\n]*(?:-f\b|--force(?:-with-lease)?(?:=\S+)?|--delete\b|--mirror\b|--all\b|--tags\b|--prune\b)|push\s+\S+\s+:\S+|restore\s+(?!--staged))\b/i, "destructive Git action", "CRITICAL", { approve: true });
231
+ hit(/\bgit\s+remote\s+(?:add|remove|rename|set-url)\b/i, "Git remote configuration change", "CRITICAL", { approve: true });
232
+ hit(/\bgit\s+config\b(?![^\n]*\s(?:--get|--get-all|--list|-l)\b)/i, "Git configuration change", "CRITICAL", { approve: true });
233
+ hit(/\brm\s+-[^\s]*r/i, "recursive delete", "CRITICAL", { approve: true });
234
+ hit(/\b(prisma|typeorm|sequelize|knex|alembic|rails)\b[^\n]*(migrate|migration|db:)/i, "database migration", "CRITICAL", { approve: true });
235
+ hit(/\b(?:npm|pnpm|yarn|bun)\s+(?:publish|login|logout)\b/i, "package registry or account action", "CRITICAL", { approve: true });
236
+ hit(/\bdart\s+pub\s+publish\b/i, "package publish action", "CRITICAL", { approve: true });
237
+ hit(/\bpod\s+trunk\s+push\b/i, "package publish action", "CRITICAL", { approve: true });
238
+ hit(/\b(chmod|chown|launchctl|systemctl|service)\b/i, "permission or service modification", "CRITICAL", { approve: true });
239
+ hit(/\b(?:node\s+-e|python(?:3)?\s+-c|ruby\s+-e|perl\s+-e|bash\s+-c|sh\s+-c|zsh\s+-c|fish\s+-c|pwsh\s+-Command|powershell\s+-Command|cmd(?:\.exe)?\s+\/c|osascript\s+-e)\b/i, "inline interpreter can bypass workspace path analysis", "CRITICAL", { approve: true });
240
+ hit(/\bgit\s+(commit|push|tag|merge|rebase|pull|fetch)\b/i, "Git write action", "REVIEW", { approve: true });
241
+ hit(/^(?:(?:[A-Za-z_][A-Za-z0-9_]*=[^\s]+)\s+)*(?:(?:\.\/?|[^\s]+\/)[^\s]+|(?:node|python|python3|ruby|perl|php|deno|tsx|ts-node|jest|vitest|pytest|mocha|ava|bash|sh|zsh|fish|pwsh|powershell|java|swift|swiftc|xcodebuild)\b|(?:npm|pnpm|yarn|bun)\s+(?:run|test|start|exec|x)\b|npx\b|(?:make|just|task|gradle|gradlew|mvn|mvnw)\b|cargo\s+(?:run|test|build|bench)\b|go\s+(?:run|test|build|generate)\b|(?:flutter|dart)\s+(?:run|test|build|compile)\b|dotnet\s+(?:run|test|build|publish)\b)/i, "workspace code execution", "REVIEW", { approve: true });
242
+ hit(/\b(npm|pnpm|yarn|bun|pip|pipx|poetry|uv|cargo|go)\s+(install|add|remove|uninstall|update|upgrade|get)\b/i, "dependency or toolchain change", "REVIEW", { approve: true });
243
+ hit(/\b(?:flutter|dart)\s+pub\s+(?:add|remove|upgrade|downgrade|get)\b/i, "Flutter/Dart dependency change", "REVIEW", { approve: true });
244
+ hit(/\bpod\s+(?:install|update|repo\s+update)\b/i, "CocoaPods dependency change", "REVIEW", { approve: true });
245
+ const networkPattern = /\b(curl|wget|ssh|scp|sftp|ftp|nc|ncat|telnet)\b/i;
246
+ if (networkPattern.test(normalized)) {
247
+ if (networkPolicy === "deny")
248
+ hit(networkPattern, "network access denied by policy", "BLOCKED", { block: true });
249
+ else if (networkPolicy === "approval")
250
+ hit(networkPattern, "network or remote command", "CRITICAL", { approve: true });
251
+ }
252
+ if (!blocked && hasShellComposition(command)) {
253
+ rules.push("composed shell command requires one-time review");
254
+ if (rank.CRITICAL > rank[risk])
255
+ risk = "CRITICAL";
256
+ approval = true;
257
+ }
258
+ if (!blocked && !approval && isRoutineDeveloperCommand(normalized))
259
+ rules.push("routine developer command");
260
+ if (blocked)
261
+ risk = "BLOCKED";
262
+ const uniqueRules = [...new Set(rules)];
263
+ const structured = !blocked && approval && rank[risk] === rank.REVIEW ? structuredApproval(command) : null;
264
+ const approvalPolicy = blocked ? "blocked" : !approval ? "none" : structured ? "rememberable" : "always";
265
+ const reason = uniqueRules.length ? uniqueRules.join("; ") : "no risky policy rule matched";
266
+ return {
267
+ riskLevel: risk,
268
+ matchedRules: uniqueRules,
269
+ requiresApproval: !blocked && approval,
270
+ blocked,
271
+ redactedCommand: redactCommand(normalized),
272
+ reason,
273
+ approvalPolicy,
274
+ approvalKey: structured?.key,
275
+ approvalLabel: structured?.label,
276
+ };
277
+ }
278
+ export function classifyGitWrite(operation, detail = "") {
279
+ return classifyCommand(`git ${operation} ${detail}`.trim(), "approval");
280
+ }
281
+ export function decisionSummary(decision) {
282
+ return {
283
+ riskLevel: decision.riskLevel,
284
+ matchedRules: decision.matchedRules,
285
+ requiresApproval: decision.requiresApproval,
286
+ blocked: decision.blocked,
287
+ redactedCommand: decision.redactedCommand,
288
+ reason: decision.reason,
289
+ approvalPolicy: decision.approvalPolicy,
290
+ approvalKey: decision.approvalKey,
291
+ approvalLabel: decision.approvalLabel,
292
+ };
293
+ }
@@ -0,0 +1,378 @@
1
+ import path from "node:path";
2
+ import { fileURLToPath } from "node:url";
3
+ import { promises as fs } from "node:fs";
4
+ import { spawn } from "node:child_process";
5
+ import { TypeScriptSemanticIndex } from "./semantic.js";
6
+ import { LspClient, commandExists, resolveLspRoot } from "./lsp.js";
7
+ import { isSensitivePath } from "./security-policy.js";
8
+ const SPECS = [
9
+ { id: "pyright", command: "pyright-langserver", args: ["--stdio"], languages: ["python"], extensions: [".py"], rootMarkers: ["pyproject.toml", "setup.cfg", "setup.py", "requirements.txt"] },
10
+ { id: "rust-analyzer", command: "rust-analyzer", args: [], languages: ["rust"], extensions: [".rs"], rootMarkers: ["Cargo.toml"] },
11
+ { id: "gopls", command: "gopls", args: [], languages: ["go"], extensions: [".go"], rootMarkers: ["go.mod", "go.work"] },
12
+ { id: "clangd", command: "clangd", args: ["--background-index"], languages: ["c", "cpp"], extensions: [".c", ".h", ".cc", ".cpp", ".cxx", ".hpp", ".hh"], rootMarkers: ["compile_commands.json", "CMakeLists.txt"] },
13
+ { id: "jdtls", command: "jdtls", args: [], languages: ["java"], extensions: [".java"], rootMarkers: ["pom.xml", "build.gradle", "build.gradle.kts", "settings.gradle", "settings.gradle.kts"] },
14
+ { id: "kotlin-language-server", command: "kotlin-language-server", args: [], languages: ["kotlin"], extensions: [".kt", ".kts"], rootMarkers: ["build.gradle", "build.gradle.kts", "settings.gradle", "settings.gradle.kts", "pom.xml"] },
15
+ { id: "lua-language-server", command: "lua-language-server", args: [], languages: ["lua"], extensions: [".lua"], rootMarkers: [".luarc.json", ".luarc.jsonc"] },
16
+ { id: "sourcekit-lsp", command: "sourcekit-lsp", args: [], languages: ["swift"], extensions: [".swift"], rootMarkers: ["Package.swift"] },
17
+ { id: "dart-analyzer", command: "dart", args: ["language-server", "--protocol=lsp"], languages: ["dart"], extensions: [".dart"], rootMarkers: ["pubspec.yaml"] },
18
+ { id: "zls", command: "zls", args: [], languages: ["zig"], extensions: [".zig"], rootMarkers: ["build.zig"] },
19
+ ];
20
+ function rel(root, file) {
21
+ return path.relative(root, file).split(path.sep).join("/") || ".";
22
+ }
23
+ function lspUriToPath(uri) {
24
+ try {
25
+ return fileURLToPath(uri);
26
+ }
27
+ catch {
28
+ return uri;
29
+ }
30
+ }
31
+ function locationFromLsp(root, value, provider) {
32
+ const target = value?.targetUri
33
+ ? { uri: value.targetUri, range: value.targetSelectionRange ?? value.targetRange }
34
+ : value?.location
35
+ ? value.location
36
+ : value;
37
+ const uri = target?.uri;
38
+ const range = target?.range ?? target?.selectionRange;
39
+ if (!uri || !range?.start)
40
+ return null;
41
+ const absolute = lspUriToPath(String(uri));
42
+ const relative = rel(root, absolute);
43
+ if (relative.startsWith("../") || isSensitivePath(relative))
44
+ return null;
45
+ return {
46
+ path: relative,
47
+ line: Number(range.start.line ?? 0) + 1,
48
+ column: Number(range.start.character ?? 0) + 1,
49
+ endLine: Number(range.end?.line ?? range.start.line ?? 0) + 1,
50
+ endColumn: Number(range.end?.character ?? range.start.character ?? 0) + 1,
51
+ provider,
52
+ };
53
+ }
54
+ function flattenLocations(root, value, provider) {
55
+ const values = Array.isArray(value) ? value : value ? [value] : [];
56
+ return values.map((item) => locationFromLsp(root, item, provider)).filter((item) => !!item);
57
+ }
58
+ function symbolLocations(root, value, provider) {
59
+ const out = [];
60
+ const visit = (item, fallbackUri) => {
61
+ if (!item)
62
+ return;
63
+ const uri = item.location?.uri ?? fallbackUri;
64
+ const range = item.location?.range ?? item.selectionRange ?? item.range;
65
+ if (uri && range?.start) {
66
+ const loc = locationFromLsp(root, { uri, range }, provider);
67
+ if (loc)
68
+ out.push({ ...loc, name: String(item.name ?? ""), kind: String(item.kind ?? "") });
69
+ }
70
+ for (const child of Array.isArray(item.children) ? item.children : [])
71
+ visit(child, uri);
72
+ };
73
+ for (const item of Array.isArray(value) ? value : value ? [value] : [])
74
+ visit(item);
75
+ return out;
76
+ }
77
+ function callHierarchyLocations(root, value, provider, direction) {
78
+ const values = Array.isArray(value) ? value : value ? [value] : [];
79
+ const out = [];
80
+ for (const entry of values) {
81
+ const item = direction === "incoming" ? entry?.from : entry?.to;
82
+ const loc = locationFromLsp(root, item, provider);
83
+ if (loc)
84
+ out.push({ ...loc, name: item?.name ? String(item.name) : undefined, kind: "call-hierarchy" });
85
+ }
86
+ return out;
87
+ }
88
+ async function runCapture(command, args, cwd, timeoutMs = 20_000) {
89
+ return new Promise((resolve, reject) => {
90
+ const child = spawn(command, args, { cwd, env: { ...process.env, PAGER: "cat", GIT_PAGER: "cat" }, stdio: ["ignore", "pipe", "pipe"] });
91
+ let stdout = "", stderr = "", done = false;
92
+ const timer = setTimeout(() => { if (!done)
93
+ child.kill("SIGTERM"); }, timeoutMs);
94
+ child.stdout.on("data", (d) => { stdout = (stdout + d.toString()).slice(-2_000_000); });
95
+ child.stderr.on("data", (d) => { stderr = (stderr + d.toString()).slice(-2_000_000); });
96
+ child.on("error", reject);
97
+ child.on("close", (code) => { done = true; clearTimeout(timer); resolve({ stdout, stderr, code }); });
98
+ });
99
+ }
100
+ export class SemanticRouter {
101
+ root;
102
+ ts;
103
+ clients = new Map();
104
+ brokenUntil = new Map();
105
+ availability = new Map();
106
+ rootCache = new Map();
107
+ constructor(root) {
108
+ this.root = root;
109
+ this.ts = new TypeScriptSemanticIndex(root);
110
+ }
111
+ invalidate() {
112
+ this.ts.invalidate();
113
+ }
114
+ specForFile(file) {
115
+ const ext = path.extname(file).toLowerCase();
116
+ return SPECS.find((spec) => spec.extensions.includes(ext)) ?? null;
117
+ }
118
+ isTypeScriptLike(file) {
119
+ return !file || [".ts", ".tsx", ".js", ".jsx", ".mts", ".cts", ".mjs", ".cjs"].includes(path.extname(file).toLowerCase());
120
+ }
121
+ async installed(spec) {
122
+ const cached = this.availability.get(spec.id);
123
+ if (cached && Date.now() - cached.at < 30_000)
124
+ return cached.installed;
125
+ const installed = await commandExists(spec.command);
126
+ this.availability.set(spec.id, { at: Date.now(), installed });
127
+ return installed;
128
+ }
129
+ async rootForFile(relativePath, spec) {
130
+ const absolute = path.resolve(this.root, relativePath);
131
+ const cacheKey = `${spec.id}:${path.dirname(absolute)}`;
132
+ const cached = this.rootCache.get(cacheKey);
133
+ if (cached)
134
+ return cached;
135
+ const resolved = await resolveLspRoot(this.root, absolute, spec);
136
+ this.rootCache.set(cacheKey, resolved);
137
+ return resolved;
138
+ }
139
+ async clientFor(relativePath) {
140
+ const spec = this.specForFile(relativePath);
141
+ if (!spec || !(await this.installed(spec)))
142
+ return null;
143
+ const projectRoot = await this.rootForFile(relativePath, spec);
144
+ const key = `${spec.id}:${projectRoot}`;
145
+ if ((this.brokenUntil.get(key) ?? 0) > Date.now())
146
+ return null;
147
+ let client = this.clients.get(key);
148
+ if (!client) {
149
+ client = new LspClient(projectRoot, spec);
150
+ this.clients.set(key, client);
151
+ }
152
+ return { key, spec, client };
153
+ }
154
+ async withClient(relativePath, fn) {
155
+ const resolved = await this.clientFor(relativePath);
156
+ if (!resolved)
157
+ return null;
158
+ try {
159
+ return await fn(resolved.client, resolved.spec);
160
+ }
161
+ catch {
162
+ this.brokenUntil.set(resolved.key, Date.now() + 30_000);
163
+ this.clients.delete(resolved.key);
164
+ await resolved.client.stop().catch(() => undefined);
165
+ return null;
166
+ }
167
+ }
168
+ async info() {
169
+ const providers = await Promise.all(SPECS.map(async (spec) => ({
170
+ id: spec.id,
171
+ languages: spec.languages,
172
+ installed: await this.installed(spec),
173
+ activeRoots: [...this.clients.values()].filter((client) => client.spec.id === spec.id).map((client) => rel(this.root, client.root)),
174
+ })));
175
+ return {
176
+ typescript: this.ts.info(),
177
+ providers,
178
+ activeClients: [...this.clients.values()].map((client) => ({ ...client.status(), root: rel(this.root, client.root) })),
179
+ fallback: (await commandExists("rg")) ? "ripgrep" : "grep",
180
+ routing: "nearest-project-root/polyglot",
181
+ };
182
+ }
183
+ async identifierAt(relativePath, line, column) {
184
+ const absolute = path.resolve(this.root, relativePath);
185
+ const text = await fs.readFile(absolute, "utf8");
186
+ const lines = text.split(/\r?\n/);
187
+ const row = lines[Math.max(0, line - 1)] ?? "";
188
+ const index = Math.max(0, Math.min(row.length, column - 1));
189
+ const left = row.slice(0, index + 1).match(/[A-Za-z_$][\w$]*$/)?.[0] ?? "";
190
+ const right = row.slice(index + 1).match(/^[\w$]*/)?.[0] ?? "";
191
+ return `${left}${right}` || row.slice(index).match(/^[A-Za-z_$][\w$]*/)?.[0] || "";
192
+ }
193
+ async workspaceSymbols(query, limit = 200) {
194
+ const out = this.ts.workspaceSymbols(query, limit).map((item) => ({ ...item, provider: "typescript" }));
195
+ if (out.length >= limit)
196
+ return out.slice(0, limit);
197
+ for (const client of this.clients.values()) {
198
+ if (out.length >= limit)
199
+ break;
200
+ const result = await client.workspaceSymbols(query).catch(() => []);
201
+ for (const item of symbolLocations(this.root, result, client.spec.id)) {
202
+ if (!out.some((existing) => existing.path === item.path && existing.line === item.line && existing.name === item.name))
203
+ out.push(item);
204
+ if (out.length >= limit)
205
+ break;
206
+ }
207
+ }
208
+ if (query && out.length < limit) {
209
+ const fallback = await this.textSearch(query, limit - out.length, true);
210
+ for (const item of fallback)
211
+ if (!out.some((existing) => existing.path === item.path && existing.line === item.line))
212
+ out.push(item);
213
+ }
214
+ return out.slice(0, limit);
215
+ }
216
+ async documentSymbols(relativePath, limit = 500) {
217
+ if (this.isTypeScriptLike(relativePath)) {
218
+ const ts = this.ts.workspaceSymbols("", 10_000).filter((item) => item.path === relativePath).slice(0, limit).map((item) => ({ ...item, provider: "typescript" }));
219
+ if (ts.length)
220
+ return ts;
221
+ }
222
+ const absolute = path.resolve(this.root, relativePath);
223
+ const result = await this.withClient(relativePath, (client) => client.documentSymbols(absolute));
224
+ if (result)
225
+ return symbolLocations(this.root, result, this.specForFile(relativePath)?.id ?? "lsp").slice(0, limit);
226
+ return this.textSearchInFile(relativePath, /\b(class|struct|interface|enum|trait|mixin|extension|def|fn|func|function|type|module)\s+([A-Za-z_$][\w$]*)/g, limit);
227
+ }
228
+ async definition(input) {
229
+ const limit = input.limit ?? 100;
230
+ if (input.path && input.line && input.column && !this.isTypeScriptLike(input.path)) {
231
+ const provider = this.specForFile(input.path)?.id ?? "lsp";
232
+ const value = await this.withClient(input.path, (client) => client.positionRequest("textDocument/definition", path.resolve(this.root, input.path), input.line, input.column));
233
+ const locations = value ? flattenLocations(this.root, value, provider) : [];
234
+ if (locations.length)
235
+ return locations.slice(0, limit);
236
+ }
237
+ const name = input.name || (input.path && input.line && input.column ? await this.identifierAt(input.path, input.line, input.column) : "");
238
+ if (!name)
239
+ return [];
240
+ if (!input.path || this.isTypeScriptLike(input.path)) {
241
+ const ts = this.ts.definitions(name, limit).map((item) => ({ ...item, provider: "typescript" }));
242
+ if (ts.length)
243
+ return ts;
244
+ }
245
+ return this.textSearch(name, limit, true);
246
+ }
247
+ async references(input) {
248
+ const limit = input.limit ?? 500;
249
+ if (input.path && input.line && input.column && !this.isTypeScriptLike(input.path)) {
250
+ const provider = this.specForFile(input.path)?.id ?? "lsp";
251
+ const value = await this.withClient(input.path, (client) => client.positionRequest("textDocument/references", path.resolve(this.root, input.path), input.line, input.column, { context: { includeDeclaration: true } }));
252
+ const locations = value ? flattenLocations(this.root, value, provider) : [];
253
+ if (locations.length)
254
+ return locations.slice(0, limit);
255
+ }
256
+ const name = input.name || (input.path && input.line && input.column ? await this.identifierAt(input.path, input.line, input.column) : "");
257
+ if (!name)
258
+ return [];
259
+ if (!input.path || this.isTypeScriptLike(input.path)) {
260
+ const ts = this.ts.references(name, limit).map((item) => ({ ...item, provider: "typescript" }));
261
+ if (ts.length)
262
+ return ts;
263
+ }
264
+ return this.textSearch(name, limit, false);
265
+ }
266
+ async implementations(input) {
267
+ const provider = this.specForFile(input.path)?.id ?? "lsp";
268
+ const value = await this.withClient(input.path, (client) => client.positionRequest("textDocument/implementation", path.resolve(this.root, input.path), input.line, input.column));
269
+ const locations = value ? flattenLocations(this.root, value, provider) : [];
270
+ if (locations.length)
271
+ return locations.slice(0, input.limit ?? 200);
272
+ return this.definition({ ...input, limit: input.limit });
273
+ }
274
+ async hover(input) {
275
+ const provider = this.specForFile(input.path)?.id;
276
+ if (provider) {
277
+ const value = await this.withClient(input.path, (client) => client.positionRequest("textDocument/hover", path.resolve(this.root, input.path), input.line, input.column));
278
+ if (value)
279
+ return { provider, result: value };
280
+ }
281
+ const name = await this.identifierAt(input.path, input.line, input.column);
282
+ return { provider: this.isTypeScriptLike(input.path) ? "typescript" : "text", name, definitions: await this.definition({ ...input, name, limit: 10 }) };
283
+ }
284
+ async diagnostics(relativePath, limit = 500) {
285
+ if (!relativePath)
286
+ return this.ts.diagnostics(limit).map((item) => ({ ...item, provider: "typescript" }));
287
+ if (this.isTypeScriptLike(relativePath)) {
288
+ const ts = this.ts.diagnostics(limit).filter((item) => !item.path || item.path === relativePath).map((item) => ({ ...item, provider: "typescript" }));
289
+ if (ts.length)
290
+ return ts;
291
+ }
292
+ const provider = this.specForFile(relativePath)?.id;
293
+ if (!provider)
294
+ return [];
295
+ const values = await this.withClient(relativePath, (client) => client.diagnostics(path.resolve(this.root, relativePath)));
296
+ if (!values)
297
+ return [];
298
+ return values.slice(0, limit).map((diagnostic) => ({
299
+ path: relativePath,
300
+ line: Number(diagnostic.range?.start?.line ?? 0) + 1,
301
+ column: Number(diagnostic.range?.start?.character ?? 0) + 1,
302
+ severity: diagnostic.severity,
303
+ code: diagnostic.code,
304
+ message: diagnostic.message,
305
+ provider,
306
+ }));
307
+ }
308
+ async incomingCalls(input) {
309
+ const provider = this.specForFile(input.path)?.id;
310
+ if (!provider)
311
+ return [];
312
+ const value = await this.withClient(input.path, (client) => client.callHierarchy(path.resolve(this.root, input.path), input.line, input.column, "incoming"));
313
+ return callHierarchyLocations(this.root, value ?? [], provider, "incoming").slice(0, input.limit ?? 200);
314
+ }
315
+ async outgoingCalls(input) {
316
+ const provider = this.specForFile(input.path)?.id;
317
+ if (!provider)
318
+ return [];
319
+ const value = await this.withClient(input.path, (client) => client.callHierarchy(path.resolve(this.root, input.path), input.line, input.column, "outgoing"));
320
+ return callHierarchyLocations(this.root, value ?? [], provider, "outgoing").slice(0, input.limit ?? 200);
321
+ }
322
+ callers(name, limit = 300) {
323
+ return this.ts.callers(name, limit).map((item) => ({ ...item, provider: "typescript" }));
324
+ }
325
+ callees(name, limit = 300) {
326
+ return this.ts.callees(name, limit).map((item) => ({ ...item, provider: "typescript" }));
327
+ }
328
+ importGraph(limit = 2000) {
329
+ return this.ts.importGraph(limit).map((item) => ({ ...item, provider: "typescript" }));
330
+ }
331
+ async textSearch(query, limit, definitionLike) {
332
+ if (!query)
333
+ return [];
334
+ const rg = await commandExists("rg");
335
+ const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
336
+ const pattern = definitionLike
337
+ ? `\\b(class|struct|interface|enum|trait|mixin|extension|def|fn|func|function|type|const|let|var|final)\\s+${escaped}\\b|\\b${escaped}\\s*[:=]`
338
+ : `\\b${escaped}\\b`;
339
+ const result = rg
340
+ ? await runCapture("rg", ["--line-number", "--column", "--no-heading", "--color", "never", "--hidden", "--glob", "!.git/**", "--glob", "!node_modules/**", pattern, "."], this.root).catch(() => null)
341
+ : null;
342
+ if (!result)
343
+ return [];
344
+ const out = [];
345
+ for (const line of result.stdout.split("\n")) {
346
+ const match = /^(.*?):(\d+):(\d+):/.exec(line);
347
+ if (!match)
348
+ continue;
349
+ const relative = match[1].replace(/^\.\//, "");
350
+ if (isSensitivePath(relative))
351
+ continue;
352
+ out.push({ path: relative, line: Number(match[2]), column: Number(match[3]), name: query, provider: "ripgrep" });
353
+ if (out.length >= limit)
354
+ break;
355
+ }
356
+ return out;
357
+ }
358
+ async textSearchInFile(relativePath, pattern, limit) {
359
+ const text = await fs.readFile(path.resolve(this.root, relativePath), "utf8");
360
+ const out = [];
361
+ for (const [index, line] of text.split(/\r?\n/).entries()) {
362
+ pattern.lastIndex = 0;
363
+ let match;
364
+ while ((match = pattern.exec(line))) {
365
+ out.push({ path: relativePath, line: index + 1, column: match.index + 1, name: match[2], provider: "text-structure" });
366
+ if (out.length >= limit)
367
+ return out;
368
+ if (!pattern.global)
369
+ break;
370
+ }
371
+ }
372
+ return out;
373
+ }
374
+ async shutdown() {
375
+ await Promise.all([...this.clients.values()].map((client) => client.stop().catch(() => undefined)));
376
+ this.clients.clear();
377
+ }
378
+ }