@bynlk/codeomnivis 0.0.0-bootstrap.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/LICENSE +77 -0
- package/README.md +62 -0
- package/bin/codeomnivis.js +19 -0
- package/dist/chunk-D2FE2QSJ.js +10271 -0
- package/dist/dist-YNCI4IFF.js +462 -0
- package/dist/index.js +2219 -0
- package/dist/ui/assets/index-BlLl2ApB.css +1 -0
- package/dist/ui/assets/index-RYkt2Yk8.js +1 -0
- package/dist/ui/assets/vendor-cytoscape-CUqq0XTU.js +331 -0
- package/dist/ui/assets/vendor-i18n-DMayGKXS.js +1 -0
- package/dist/ui/assets/vendor-l0sNRNKZ.js +1 -0
- package/dist/ui/assets/vendor-query-CdMHRlIH.js +1 -0
- package/dist/ui/assets/vendor-react-CHCXY0b5.js +48 -0
- package/dist/ui/brand/favicon.svg +4 -0
- package/dist/ui/brand/logo-mark-dark.svg +5 -0
- package/dist/ui/brand/logo-mark-light.svg +5 -0
- package/dist/ui/brand/logo-mark-mono.svg +5 -0
- package/dist/ui/index.html +18 -0
- package/dist/wasm/tree-sitter-kotlin.wasm +0 -0
- package/package.json +87 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,2219 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AuthDetector,
|
|
3
|
+
ConsistencyChecker,
|
|
4
|
+
DataFlowTracer,
|
|
5
|
+
NPlusOneDetector,
|
|
6
|
+
OmniDatabase,
|
|
7
|
+
RSCBoundaryDetector,
|
|
8
|
+
analyzeProject,
|
|
9
|
+
computeSnapshotDigest,
|
|
10
|
+
detectProject,
|
|
11
|
+
getDbPath,
|
|
12
|
+
importJunitXml,
|
|
13
|
+
isEdgeType,
|
|
14
|
+
isIpLiteral,
|
|
15
|
+
isJsonObject,
|
|
16
|
+
isNodeType,
|
|
17
|
+
isTestFramework,
|
|
18
|
+
loadConfig,
|
|
19
|
+
parseAiChatRequest,
|
|
20
|
+
projectTestView,
|
|
21
|
+
resolveAiConfig,
|
|
22
|
+
runAnalysis,
|
|
23
|
+
sanitizeGraph,
|
|
24
|
+
validateResolvedAddresses,
|
|
25
|
+
validateUpstreamBaseUrl
|
|
26
|
+
} from "./chunk-D2FE2QSJ.js";
|
|
27
|
+
|
|
28
|
+
// src/index.ts
|
|
29
|
+
import { pathToFileURL } from "url";
|
|
30
|
+
|
|
31
|
+
// src/program.ts
|
|
32
|
+
import { readFileSync } from "fs";
|
|
33
|
+
import { Command } from "commander";
|
|
34
|
+
|
|
35
|
+
// src/commands/analyze.ts
|
|
36
|
+
import ora from "ora";
|
|
37
|
+
import chalk from "chalk";
|
|
38
|
+
import * as fs from "fs";
|
|
39
|
+
import * as path from "path";
|
|
40
|
+
|
|
41
|
+
// src/utils/autoDetect.ts
|
|
42
|
+
async function autoDetectProject(root, config) {
|
|
43
|
+
return detectProject(root, config);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// src/commands/analyze.ts
|
|
47
|
+
var defaultAnalyzeDeps = {
|
|
48
|
+
openDatabase: (dbPath) => new OmniDatabase(dbPath)
|
|
49
|
+
};
|
|
50
|
+
async function runAnalyze(options, deps = defaultAnalyzeDeps) {
|
|
51
|
+
const report = deps.onProgress ?? (() => {
|
|
52
|
+
});
|
|
53
|
+
const projectRoot = path.resolve(options.project ?? ".");
|
|
54
|
+
report("Detecting project structure...");
|
|
55
|
+
const config = loadConfig(projectRoot);
|
|
56
|
+
const projectMeta = await autoDetectProject(projectRoot, config);
|
|
57
|
+
const dbPath = getDbPath(projectRoot);
|
|
58
|
+
const db = deps.openDatabase(dbPath);
|
|
59
|
+
try {
|
|
60
|
+
await db.ready();
|
|
61
|
+
const result = await analyzeProject({
|
|
62
|
+
projectRoot,
|
|
63
|
+
dbPath,
|
|
64
|
+
projectMeta,
|
|
65
|
+
db,
|
|
66
|
+
onProgress: (event) => {
|
|
67
|
+
if (event.filesScanned !== void 0) report(`Parsing ${event.filesScanned} files...`);
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
const graph = result.snapshot.graph;
|
|
71
|
+
if (options.json) {
|
|
72
|
+
process.stdout.write(
|
|
73
|
+
`${JSON.stringify({
|
|
74
|
+
data: result.snapshot,
|
|
75
|
+
meta: {
|
|
76
|
+
snapshotId: result.snapshot.snapshotId,
|
|
77
|
+
snapshotDigest: result.snapshot.snapshotDigest
|
|
78
|
+
}
|
|
79
|
+
})}
|
|
80
|
+
`
|
|
81
|
+
);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
const nPlusOneDetector = new NPlusOneDetector();
|
|
85
|
+
const authDetector = new AuthDetector();
|
|
86
|
+
const rscBoundaryDetector = new RSCBoundaryDetector();
|
|
87
|
+
const allIssues = [
|
|
88
|
+
...nPlusOneDetector.detect(graph, projectRoot),
|
|
89
|
+
...authDetector.detect(graph, projectRoot),
|
|
90
|
+
...rscBoundaryDetector.detect(graph, projectRoot)
|
|
91
|
+
];
|
|
92
|
+
console.log("");
|
|
93
|
+
console.log(chalk.blue("Statistics:"));
|
|
94
|
+
console.log(` Nodes: ${graph.nodes.length}`);
|
|
95
|
+
console.log(` Edges: ${graph.edges.length}`);
|
|
96
|
+
console.log(` Errors: ${result.snapshot.parseErrors.length}`);
|
|
97
|
+
const crossLayerTypes = /* @__PURE__ */ new Set(["calls_api", "handles", "calls_service", "queries_db"]);
|
|
98
|
+
if (graph.edges.some((edge) => crossLayerTypes.has(edge.type))) {
|
|
99
|
+
const countEdge = (type) => graph.edges.filter((edge) => edge.type === type).length;
|
|
100
|
+
console.log("");
|
|
101
|
+
console.log(chalk.blue("Cross-layer links:"));
|
|
102
|
+
console.log(` calls_api: ${countEdge("calls_api")}`);
|
|
103
|
+
console.log(` handles: ${countEdge("handles")}`);
|
|
104
|
+
console.log(` calls_service: ${countEdge("calls_service")}`);
|
|
105
|
+
console.log(` queries_db: ${countEdge("queries_db")}`);
|
|
106
|
+
}
|
|
107
|
+
if (allIssues.length > 0) {
|
|
108
|
+
console.log("");
|
|
109
|
+
console.log(chalk.red(`Issues Found: ${allIssues.length}`));
|
|
110
|
+
const byType = {};
|
|
111
|
+
for (const issue of allIssues) {
|
|
112
|
+
byType[issue.type] = (byType[issue.type] || 0) + 1;
|
|
113
|
+
}
|
|
114
|
+
for (const [type, count] of Object.entries(byType)) {
|
|
115
|
+
const severity = allIssues.find((i) => i.type === type)?.severity || "info";
|
|
116
|
+
const color = severity === "critical" ? chalk.red : severity === "warning" ? chalk.yellow : chalk.gray;
|
|
117
|
+
console.log(color(` ${type}: ${count}`));
|
|
118
|
+
}
|
|
119
|
+
const criticals = allIssues.filter((i) => i.severity === "critical");
|
|
120
|
+
if (criticals.length > 0) {
|
|
121
|
+
console.log("");
|
|
122
|
+
console.log(chalk.red("Critical issues:"));
|
|
123
|
+
for (const issue of criticals.slice(0, 10)) {
|
|
124
|
+
console.log(chalk.red(` \u26A0 ${issue.description}`));
|
|
125
|
+
if (issue.locations[0]) {
|
|
126
|
+
console.log(chalk.gray(` at ${issue.locations[0].file}:${issue.locations[0].line}`));
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
if (criticals.length > 10) {
|
|
130
|
+
console.log(chalk.gray(` ... and ${criticals.length - 10} more`));
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
const json = JSON.stringify(graph, null, 2);
|
|
135
|
+
if (options.output === "-") {
|
|
136
|
+
console.log(json);
|
|
137
|
+
} else {
|
|
138
|
+
fs.writeFileSync(options.output, json, "utf-8");
|
|
139
|
+
console.log(chalk.gray(`
|
|
140
|
+
Graph saved to ${options.output}`));
|
|
141
|
+
}
|
|
142
|
+
} finally {
|
|
143
|
+
db.close();
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
function analyzeCommand(program) {
|
|
147
|
+
program.command("analyze").description("Analyze project and output graph as JSON").option("-p, --project <path>", "Project root", ".").option("-o, --output <file>", "Output file path", "codeomnivis-graph.json").option("--json", "Write the versioned snapshot envelope to stdout").action(async (options) => {
|
|
148
|
+
const spinner = ora("Analyzing project...").start();
|
|
149
|
+
try {
|
|
150
|
+
await runAnalyze(options, {
|
|
151
|
+
openDatabase: (dbPath) => new OmniDatabase(dbPath),
|
|
152
|
+
onProgress: (message) => {
|
|
153
|
+
spinner.text = message;
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
spinner.succeed(chalk.green("Analysis complete!"));
|
|
157
|
+
} catch (err) {
|
|
158
|
+
spinner.fail(chalk.red("Analysis failed"));
|
|
159
|
+
console.error(err);
|
|
160
|
+
process.exit(1);
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// src/commands/check.ts
|
|
166
|
+
import ora2 from "ora";
|
|
167
|
+
import chalk2 from "chalk";
|
|
168
|
+
import * as path2 from "path";
|
|
169
|
+
var defaultCheckDeps = {
|
|
170
|
+
openDatabase: (dbPath) => new OmniDatabase(dbPath)
|
|
171
|
+
};
|
|
172
|
+
async function runCheck(deps = defaultCheckDeps) {
|
|
173
|
+
const report = deps.onProgress ?? (() => {
|
|
174
|
+
});
|
|
175
|
+
const projectRoot = path2.resolve(deps.cwd ?? ".");
|
|
176
|
+
report("Detecting project structure...");
|
|
177
|
+
const config = loadConfig(projectRoot);
|
|
178
|
+
const projectMeta = await autoDetectProject(projectRoot, config);
|
|
179
|
+
const dbPath = getDbPath(projectRoot);
|
|
180
|
+
const db = deps.openDatabase(dbPath);
|
|
181
|
+
try {
|
|
182
|
+
await db.ready();
|
|
183
|
+
const result = await runAnalysis({
|
|
184
|
+
projectRoot,
|
|
185
|
+
dbPath,
|
|
186
|
+
projectMeta,
|
|
187
|
+
db,
|
|
188
|
+
onFilesCollected: (count) => report(`Parsing ${count} files...`)
|
|
189
|
+
});
|
|
190
|
+
report("Checking consistency...");
|
|
191
|
+
const graph = db.loadGraph();
|
|
192
|
+
const checker = new ConsistencyChecker();
|
|
193
|
+
const consistencyReport = checker.check(graph);
|
|
194
|
+
console.log("");
|
|
195
|
+
console.log(chalk2.blue("Statistics:"));
|
|
196
|
+
console.log(` Nodes: ${graph.nodes.length}`);
|
|
197
|
+
console.log(` Edges: ${graph.edges.length}`);
|
|
198
|
+
console.log(` Errors: ${result.errors}`);
|
|
199
|
+
if (result.errors > 0) {
|
|
200
|
+
console.log("");
|
|
201
|
+
console.log(chalk2.yellow("Parse Errors:"));
|
|
202
|
+
const errors = db.getAllErrors();
|
|
203
|
+
for (const error of errors) {
|
|
204
|
+
const icon = error.severity === "error" ? "\u274C" : "\u26A0\uFE0F";
|
|
205
|
+
console.log(` ${icon} ${error.file}: ${error.message}`);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
if (consistencyReport.issues.length > 0) {
|
|
209
|
+
console.log("");
|
|
210
|
+
console.log(chalk2.blue("Consistency Issues:"));
|
|
211
|
+
console.log(` Total: ${consistencyReport.summary.total}`);
|
|
212
|
+
console.log(` Critical: ${consistencyReport.summary.critical}`);
|
|
213
|
+
console.log(` Warning: ${consistencyReport.summary.warning}`);
|
|
214
|
+
console.log(` Info: ${consistencyReport.summary.info}`);
|
|
215
|
+
console.log("");
|
|
216
|
+
for (const issue of consistencyReport.issues) {
|
|
217
|
+
const icon = issue.severity === "critical" ? "\u{1F534}" : issue.severity === "warning" ? "\u{1F7E1}" : "\u2139\uFE0F";
|
|
218
|
+
console.log(` ${icon} [${issue.type}] ${issue.description}`);
|
|
219
|
+
for (const loc of issue.locations) {
|
|
220
|
+
console.log(` at ${loc.file}:${loc.line}`);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
} else {
|
|
224
|
+
console.log("");
|
|
225
|
+
console.log(chalk2.green("\u2705 No consistency issues found"));
|
|
226
|
+
}
|
|
227
|
+
} finally {
|
|
228
|
+
db.close();
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
function checkCommand(program) {
|
|
232
|
+
program.command("check").description("Check project for consistency issues").action(async () => {
|
|
233
|
+
const spinner = ora2("Checking project...").start();
|
|
234
|
+
try {
|
|
235
|
+
await runCheck({
|
|
236
|
+
openDatabase: (dbPath) => new OmniDatabase(dbPath),
|
|
237
|
+
onProgress: (message) => {
|
|
238
|
+
spinner.text = message;
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
spinner.succeed(chalk2.green("Check complete!"));
|
|
242
|
+
} catch (err) {
|
|
243
|
+
spinner.fail(chalk2.red("Check failed"));
|
|
244
|
+
console.error(err);
|
|
245
|
+
process.exit(1);
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// src/commands/init.ts
|
|
251
|
+
import ora3 from "ora";
|
|
252
|
+
import chalk3 from "chalk";
|
|
253
|
+
function initCommand(program) {
|
|
254
|
+
program.command("init").description("Generate CodeOmniVis configuration file").action(async () => {
|
|
255
|
+
const spinner = ora3("Generating configuration...").start();
|
|
256
|
+
try {
|
|
257
|
+
const fs8 = await import("fs");
|
|
258
|
+
const path10 = await import("path");
|
|
259
|
+
const configPath = path10.join(process.cwd(), ".codeomnivis.json");
|
|
260
|
+
if (fs8.existsSync(configPath)) {
|
|
261
|
+
spinner.warn(chalk3.yellow("Configuration file already exists"));
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
const config = {
|
|
265
|
+
$schema: "https://codeomnivis.dev/schema.json",
|
|
266
|
+
version: "0.0.1",
|
|
267
|
+
exclude: ["node_modules", "dist", ".git", ".next"],
|
|
268
|
+
parsers: {
|
|
269
|
+
prisma: {
|
|
270
|
+
enabled: true
|
|
271
|
+
},
|
|
272
|
+
nextjs: {
|
|
273
|
+
enabled: true
|
|
274
|
+
},
|
|
275
|
+
trpc: {
|
|
276
|
+
enabled: true
|
|
277
|
+
}
|
|
278
|
+
},
|
|
279
|
+
server: {
|
|
280
|
+
port: 4321,
|
|
281
|
+
host: "localhost"
|
|
282
|
+
}
|
|
283
|
+
};
|
|
284
|
+
fs8.writeFileSync(configPath, JSON.stringify(config, null, 2), "utf-8");
|
|
285
|
+
spinner.succeed(chalk3.green("Configuration file generated!"));
|
|
286
|
+
console.log(chalk3.gray(`
|
|
287
|
+
Created: ${configPath}`));
|
|
288
|
+
console.log(chalk3.gray("Edit this file to customize CodeOmniVis behavior."));
|
|
289
|
+
} catch (err) {
|
|
290
|
+
spinner.fail(chalk3.red("Failed to generate configuration"));
|
|
291
|
+
console.error(err);
|
|
292
|
+
process.exit(1);
|
|
293
|
+
}
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// src/commands/mcp.ts
|
|
298
|
+
import * as path3 from "path";
|
|
299
|
+
var defaultDeps = {
|
|
300
|
+
start: async (projectRoot) => {
|
|
301
|
+
const { startMcpServer } = await import("./dist-YNCI4IFF.js");
|
|
302
|
+
return startMcpServer({ projectRoot });
|
|
303
|
+
},
|
|
304
|
+
once: (signal, listener) => {
|
|
305
|
+
process.once(signal, listener);
|
|
306
|
+
},
|
|
307
|
+
remove: (signal, listener) => {
|
|
308
|
+
process.removeListener(signal, listener);
|
|
309
|
+
},
|
|
310
|
+
stderr: (message) => {
|
|
311
|
+
process.stderr.write(message);
|
|
312
|
+
},
|
|
313
|
+
exit: (code) => {
|
|
314
|
+
process.exit(code);
|
|
315
|
+
},
|
|
316
|
+
setExitCode: (code) => {
|
|
317
|
+
process.exitCode = code;
|
|
318
|
+
}
|
|
319
|
+
};
|
|
320
|
+
async function runMcpCommand(options, deps = defaultDeps) {
|
|
321
|
+
let handle;
|
|
322
|
+
let stopRequested = false;
|
|
323
|
+
let stopping = false;
|
|
324
|
+
const stop = () => {
|
|
325
|
+
stopRequested = true;
|
|
326
|
+
if (!handle || stopping) return;
|
|
327
|
+
stopping = true;
|
|
328
|
+
void handle.close().then(() => deps.exit(0)).catch((err) => {
|
|
329
|
+
deps.stderr(`Failed to stop MCP Server: ${String(err)}
|
|
330
|
+
`);
|
|
331
|
+
deps.exit(1);
|
|
332
|
+
});
|
|
333
|
+
};
|
|
334
|
+
deps.once("SIGINT", stop);
|
|
335
|
+
deps.once("SIGTERM", stop);
|
|
336
|
+
try {
|
|
337
|
+
handle = await deps.start(path3.resolve(options.project));
|
|
338
|
+
if (stopRequested) stop();
|
|
339
|
+
} catch (err) {
|
|
340
|
+
deps.remove("SIGINT", stop);
|
|
341
|
+
deps.remove("SIGTERM", stop);
|
|
342
|
+
deps.stderr(`Failed to start MCP Server: ${String(err)}
|
|
343
|
+
`);
|
|
344
|
+
deps.setExitCode(1);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
function mcpCommand(program) {
|
|
348
|
+
program.command("mcp").description("Start MCP Server for AI assistant integration").option("--project <path>", "Project root path", ".").action(async (options) => {
|
|
349
|
+
await runMcpCommand(options);
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// src/commands/serve.ts
|
|
354
|
+
import ora4 from "ora";
|
|
355
|
+
import chalk4 from "chalk";
|
|
356
|
+
import * as fs5 from "fs";
|
|
357
|
+
import * as path6 from "path";
|
|
358
|
+
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
359
|
+
|
|
360
|
+
// src/utils/validateProjectRoot.ts
|
|
361
|
+
import * as fs2 from "fs";
|
|
362
|
+
import * as path4 from "path";
|
|
363
|
+
function validateProjectRoot(project) {
|
|
364
|
+
const projectRoot = path4.resolve(project);
|
|
365
|
+
if (!fs2.existsSync(projectRoot) || !fs2.statSync(projectRoot).isDirectory()) {
|
|
366
|
+
throw new Error(`Project root is not an existing directory: ${projectRoot}`);
|
|
367
|
+
}
|
|
368
|
+
return projectRoot;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// ../server/dist/index.mjs
|
|
372
|
+
import express from "express";
|
|
373
|
+
import cors from "cors";
|
|
374
|
+
import path42 from "path";
|
|
375
|
+
import fs3 from "fs";
|
|
376
|
+
import { fileURLToPath } from "url";
|
|
377
|
+
import { createServer as createHttpServer } from "http";
|
|
378
|
+
import { WebSocket, WebSocketServer } from "ws";
|
|
379
|
+
import { Router } from "express";
|
|
380
|
+
import { Router as Router2 } from "express";
|
|
381
|
+
import { EventEmitter } from "events";
|
|
382
|
+
import * as path5 from "path";
|
|
383
|
+
import * as fs4 from "fs";
|
|
384
|
+
import chokidar from "chokidar";
|
|
385
|
+
import { createHash } from "crypto";
|
|
386
|
+
import { promises as dns } from "dns";
|
|
387
|
+
import { isIP } from "net";
|
|
388
|
+
import { Agent, buildConnector, request as undiciRequest } from "undici";
|
|
389
|
+
import { createHash as createHash2, timingSafeEqual } from "crypto";
|
|
390
|
+
import { randomBytes } from "crypto";
|
|
391
|
+
import path32 from "path";
|
|
392
|
+
import path22 from "path";
|
|
393
|
+
import fs22 from "fs";
|
|
394
|
+
var DETECTOR_ORDER = [
|
|
395
|
+
{ id: "consistency", key: "consistency", source: "consistency" },
|
|
396
|
+
{ id: "auth", key: "auth", source: "security" },
|
|
397
|
+
{ id: "n_plus_one", key: "nPlusOne", source: "performance" },
|
|
398
|
+
{ id: "rsc", key: "rsc", source: "framework" }
|
|
399
|
+
];
|
|
400
|
+
var SEVERITY_RANK = { critical: 0, warning: 1, info: 2 };
|
|
401
|
+
function createDefaultDetectors() {
|
|
402
|
+
return {
|
|
403
|
+
consistency: (graph) => new ConsistencyChecker().check(graph).issues,
|
|
404
|
+
auth: (graph, projectRoot) => new AuthDetector().detect(graph, projectRoot),
|
|
405
|
+
nPlusOne: (graph, projectRoot) => new NPlusOneDetector().detect(graph, projectRoot),
|
|
406
|
+
rsc: (graph, projectRoot) => new RSCBoundaryDetector().detect(graph, projectRoot)
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
function compareIssues(left, right) {
|
|
410
|
+
const severity = SEVERITY_RANK[left.severity] - SEVERITY_RANK[right.severity];
|
|
411
|
+
if (severity !== 0) return severity;
|
|
412
|
+
const leftLocation = left.locations[0];
|
|
413
|
+
const rightLocation = right.locations[0];
|
|
414
|
+
const file = (leftLocation?.file ?? "").localeCompare(rightLocation?.file ?? "");
|
|
415
|
+
if (file !== 0) return file;
|
|
416
|
+
const line = (leftLocation?.line ?? 0) - (rightLocation?.line ?? 0);
|
|
417
|
+
return line !== 0 ? line : left.id.localeCompare(right.id);
|
|
418
|
+
}
|
|
419
|
+
function collectGraphIssues(graph, projectRoot, detectors = createDefaultDetectors()) {
|
|
420
|
+
const byId = /* @__PURE__ */ new Map();
|
|
421
|
+
const statuses = [];
|
|
422
|
+
for (const detector of DETECTOR_ORDER) {
|
|
423
|
+
try {
|
|
424
|
+
const issues2 = detectors[detector.key](graph, projectRoot);
|
|
425
|
+
for (const issue of issues2) {
|
|
426
|
+
if (!byId.has(issue.id)) {
|
|
427
|
+
byId.set(issue.id, { ...issue, source: detector.source });
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
statuses.push({ id: detector.id, status: "complete" });
|
|
431
|
+
} catch {
|
|
432
|
+
statuses.push({
|
|
433
|
+
id: detector.id,
|
|
434
|
+
status: "failed",
|
|
435
|
+
message: "Detector failed"
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
const issues = [...byId.values()].sort(compareIssues);
|
|
440
|
+
return {
|
|
441
|
+
issues,
|
|
442
|
+
summary: {
|
|
443
|
+
total: issues.length,
|
|
444
|
+
critical: issues.filter((issue) => issue.severity === "critical").length,
|
|
445
|
+
warning: issues.filter((issue) => issue.severity === "warning").length,
|
|
446
|
+
info: issues.filter((issue) => issue.severity === "info").length
|
|
447
|
+
},
|
|
448
|
+
detectors: statuses
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
function sendInternalError(response, logMessage, publicMessage, error) {
|
|
452
|
+
console.error(`${logMessage}:`, error);
|
|
453
|
+
response.status(500).json({
|
|
454
|
+
error: { code: "INTERNAL_ERROR", message: publicMessage }
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
var VALID_NODE_TYPES = /* @__PURE__ */ new Set([
|
|
458
|
+
"page",
|
|
459
|
+
"component",
|
|
460
|
+
"api_route",
|
|
461
|
+
"trpc_procedure",
|
|
462
|
+
"express_route",
|
|
463
|
+
"handler",
|
|
464
|
+
"service",
|
|
465
|
+
"db_model",
|
|
466
|
+
"module",
|
|
467
|
+
"tsrpc_service",
|
|
468
|
+
"tsrpc_api",
|
|
469
|
+
"tsrpc_msg",
|
|
470
|
+
"kotlin_class",
|
|
471
|
+
"kotlin_interface",
|
|
472
|
+
"kotlin_object",
|
|
473
|
+
"kotlin_function",
|
|
474
|
+
"kotlin_route"
|
|
475
|
+
]);
|
|
476
|
+
var VALID_EDGE_TYPES = /* @__PURE__ */ new Set([
|
|
477
|
+
"renders",
|
|
478
|
+
"navigates_to",
|
|
479
|
+
"calls_api",
|
|
480
|
+
"handles",
|
|
481
|
+
"calls_service",
|
|
482
|
+
"queries_db",
|
|
483
|
+
"db_relation",
|
|
484
|
+
"imports",
|
|
485
|
+
"contains",
|
|
486
|
+
"data_flows_to",
|
|
487
|
+
"sends_msg",
|
|
488
|
+
"listens_msg",
|
|
489
|
+
"kotlin_inherits",
|
|
490
|
+
"kotlin_implements",
|
|
491
|
+
"kotlin_uses"
|
|
492
|
+
]);
|
|
493
|
+
function createGraphRouter(db, mutatingGuard, getProjectRoot = () => process.cwd()) {
|
|
494
|
+
const router = Router();
|
|
495
|
+
const guard = mutatingGuard ?? ((_req, _res, next) => next());
|
|
496
|
+
router.get("/", (_req, res) => {
|
|
497
|
+
try {
|
|
498
|
+
const rawGraph = db.loadGraph();
|
|
499
|
+
const { graph, stats: sanitizeStats } = sanitizeGraph(rawGraph);
|
|
500
|
+
const stats = db.getStats();
|
|
501
|
+
const snapshot = db.loadSnapshot();
|
|
502
|
+
res.json({
|
|
503
|
+
data: graph,
|
|
504
|
+
meta: {
|
|
505
|
+
nodeCount: graph.nodes.length,
|
|
506
|
+
edgeCount: graph.edges.length,
|
|
507
|
+
nodesByType: stats.nodeTypeCounts,
|
|
508
|
+
edgesByType: stats.edgeTypeCounts,
|
|
509
|
+
sanitize: sanitizeStats,
|
|
510
|
+
snapshotId: snapshot?.snapshotId ?? null,
|
|
511
|
+
snapshotDigest: snapshot?.snapshotDigest ?? null
|
|
512
|
+
}
|
|
513
|
+
});
|
|
514
|
+
} catch (err) {
|
|
515
|
+
sendInternalError(res, "Failed to get graph", "Failed to load graph data", err);
|
|
516
|
+
}
|
|
517
|
+
});
|
|
518
|
+
router.get("/nodes", (req, res) => {
|
|
519
|
+
try {
|
|
520
|
+
const { type } = req.query;
|
|
521
|
+
let nodes;
|
|
522
|
+
if (type && typeof type === "string") {
|
|
523
|
+
if (!isNodeType(type)) {
|
|
524
|
+
return res.status(400).json({
|
|
525
|
+
error: {
|
|
526
|
+
code: "INVALID_TYPE",
|
|
527
|
+
message: `Invalid node type: ${type}. Valid types: ${[...VALID_NODE_TYPES].join(", ")}`
|
|
528
|
+
}
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
nodes = db.getNodesByType(type);
|
|
532
|
+
} else {
|
|
533
|
+
nodes = db.getAllNodes();
|
|
534
|
+
}
|
|
535
|
+
res.json({
|
|
536
|
+
data: nodes,
|
|
537
|
+
meta: { count: nodes.length }
|
|
538
|
+
});
|
|
539
|
+
} catch (err) {
|
|
540
|
+
sendInternalError(res, "Failed to get nodes", "Failed to load nodes", err);
|
|
541
|
+
}
|
|
542
|
+
});
|
|
543
|
+
router.get("/nodes/:id", (req, res) => {
|
|
544
|
+
try {
|
|
545
|
+
const { id } = req.params;
|
|
546
|
+
const node = db.getNode(decodeURIComponent(id));
|
|
547
|
+
if (!node) {
|
|
548
|
+
return res.status(404).json({
|
|
549
|
+
error: {
|
|
550
|
+
code: "NOT_FOUND",
|
|
551
|
+
message: `Node not found: ${id}`
|
|
552
|
+
}
|
|
553
|
+
});
|
|
554
|
+
}
|
|
555
|
+
res.json({ data: node });
|
|
556
|
+
} catch (err) {
|
|
557
|
+
sendInternalError(res, "Failed to get node", "Failed to load node", err);
|
|
558
|
+
}
|
|
559
|
+
});
|
|
560
|
+
router.get("/nodes/:id/edges", (req, res) => {
|
|
561
|
+
try {
|
|
562
|
+
const { id } = req.params;
|
|
563
|
+
const nodeId = decodeURIComponent(id);
|
|
564
|
+
const inEdges = db.getInEdges(nodeId);
|
|
565
|
+
const outEdges = db.getOutEdges(nodeId);
|
|
566
|
+
res.json({
|
|
567
|
+
data: {
|
|
568
|
+
inEdges,
|
|
569
|
+
outEdges
|
|
570
|
+
},
|
|
571
|
+
meta: {
|
|
572
|
+
inCount: inEdges.length,
|
|
573
|
+
outCount: outEdges.length
|
|
574
|
+
}
|
|
575
|
+
});
|
|
576
|
+
} catch (err) {
|
|
577
|
+
sendInternalError(res, "Failed to get node edges", "Failed to load node edges", err);
|
|
578
|
+
}
|
|
579
|
+
});
|
|
580
|
+
router.get("/edges", (req, res) => {
|
|
581
|
+
try {
|
|
582
|
+
const { type } = req.query;
|
|
583
|
+
let edges;
|
|
584
|
+
if (type && typeof type === "string") {
|
|
585
|
+
if (!isEdgeType(type)) {
|
|
586
|
+
return res.status(400).json({
|
|
587
|
+
error: {
|
|
588
|
+
code: "INVALID_TYPE",
|
|
589
|
+
message: `Invalid edge type: ${type}. Valid types: ${[...VALID_EDGE_TYPES].join(", ")}`
|
|
590
|
+
}
|
|
591
|
+
});
|
|
592
|
+
}
|
|
593
|
+
edges = db.getEdgesByType(type);
|
|
594
|
+
} else {
|
|
595
|
+
edges = db.getAllEdges();
|
|
596
|
+
}
|
|
597
|
+
res.json({
|
|
598
|
+
data: edges,
|
|
599
|
+
meta: { count: edges.length }
|
|
600
|
+
});
|
|
601
|
+
} catch (err) {
|
|
602
|
+
sendInternalError(res, "Failed to get edges", "Failed to load edges", err);
|
|
603
|
+
}
|
|
604
|
+
});
|
|
605
|
+
router.get("/stats", (_req, res) => {
|
|
606
|
+
try {
|
|
607
|
+
const stats = db.getStats();
|
|
608
|
+
res.json({ data: stats });
|
|
609
|
+
} catch (err) {
|
|
610
|
+
sendInternalError(res, "Failed to get stats", "Failed to load stats", err);
|
|
611
|
+
}
|
|
612
|
+
});
|
|
613
|
+
router.delete("/", guard, (req, res) => {
|
|
614
|
+
try {
|
|
615
|
+
if (req.headers["x-confirm"] !== "true") {
|
|
616
|
+
return res.status(400).json({
|
|
617
|
+
error: {
|
|
618
|
+
code: "CONFIRMATION_REQUIRED",
|
|
619
|
+
message: "This operation requires X-Confirm: true header"
|
|
620
|
+
}
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
db.clearGraph();
|
|
624
|
+
res.json({ data: { success: true } });
|
|
625
|
+
} catch (err) {
|
|
626
|
+
sendInternalError(res, "Failed to clear graph", "Failed to clear graph", err);
|
|
627
|
+
}
|
|
628
|
+
});
|
|
629
|
+
router.get("/errors", (_req, res) => {
|
|
630
|
+
try {
|
|
631
|
+
const errors = db.getAllErrors();
|
|
632
|
+
res.json({
|
|
633
|
+
data: errors,
|
|
634
|
+
meta: { count: errors.length }
|
|
635
|
+
});
|
|
636
|
+
} catch (err) {
|
|
637
|
+
sendInternalError(res, "Failed to get errors", "Failed to load errors", err);
|
|
638
|
+
}
|
|
639
|
+
});
|
|
640
|
+
router.get("/issues", (_req, res) => {
|
|
641
|
+
try {
|
|
642
|
+
const report = collectGraphIssues(db.loadGraph(), getProjectRoot());
|
|
643
|
+
res.json({
|
|
644
|
+
data: report.issues,
|
|
645
|
+
meta: {
|
|
646
|
+
count: report.summary.total,
|
|
647
|
+
...report.summary,
|
|
648
|
+
detectors: report.detectors
|
|
649
|
+
}
|
|
650
|
+
});
|
|
651
|
+
} catch (err) {
|
|
652
|
+
sendInternalError(res, "Failed to get graph issues", "Failed to load graph issues", err);
|
|
653
|
+
}
|
|
654
|
+
});
|
|
655
|
+
router.get("/trace", (req, res) => {
|
|
656
|
+
try {
|
|
657
|
+
const nodeId = typeof req.query.node === "string" ? req.query.node : void 0;
|
|
658
|
+
if (!nodeId) {
|
|
659
|
+
return res.status(400).json({
|
|
660
|
+
error: { code: "MISSING_PARAM", message: 'Query param "node" is required' }
|
|
661
|
+
});
|
|
662
|
+
}
|
|
663
|
+
const graph = db.loadGraph();
|
|
664
|
+
const exists = graph.nodes.some((n) => n.id === nodeId);
|
|
665
|
+
if (!exists) {
|
|
666
|
+
return res.status(404).json({
|
|
667
|
+
error: { code: "NOT_FOUND", message: `Node not found: ${nodeId}` }
|
|
668
|
+
});
|
|
669
|
+
}
|
|
670
|
+
const tracer = new DataFlowTracer(graph);
|
|
671
|
+
const result = tracer.traceFromNode(nodeId);
|
|
672
|
+
res.json({ data: result, meta: { totalSteps: result.totalSteps } });
|
|
673
|
+
} catch (err) {
|
|
674
|
+
sendInternalError(res, "Failed to trace from node", "Failed to trace from node", err);
|
|
675
|
+
}
|
|
676
|
+
});
|
|
677
|
+
router.get("/dataflow", (req, res) => {
|
|
678
|
+
try {
|
|
679
|
+
const graph = db.loadGraph();
|
|
680
|
+
const tracer = new DataFlowTracer(graph);
|
|
681
|
+
const model = typeof req.query.model === "string" ? req.query.model : void 0;
|
|
682
|
+
if (model) {
|
|
683
|
+
const modelNode = graph.nodes.find(
|
|
684
|
+
(n) => n.type === "db_model" && (n.name === model || n.id.includes(model))
|
|
685
|
+
);
|
|
686
|
+
if (!modelNode) {
|
|
687
|
+
return res.status(404).json({
|
|
688
|
+
error: { code: "NOT_FOUND", message: `Model not found: ${model}` }
|
|
689
|
+
});
|
|
690
|
+
}
|
|
691
|
+
const path52 = tracer.traceModelFlow(modelNode);
|
|
692
|
+
res.json({
|
|
693
|
+
data: {
|
|
694
|
+
modelId: modelNode.id,
|
|
695
|
+
modelName: modelNode.name,
|
|
696
|
+
paths: [path52],
|
|
697
|
+
totalRoutes: path52.apiNodes.length,
|
|
698
|
+
totalComponents: path52.componentNodes.length
|
|
699
|
+
}
|
|
700
|
+
});
|
|
701
|
+
} else {
|
|
702
|
+
const results = tracer.traceAllModels();
|
|
703
|
+
res.json({
|
|
704
|
+
data: results,
|
|
705
|
+
meta: { count: results.length }
|
|
706
|
+
});
|
|
707
|
+
}
|
|
708
|
+
} catch (err) {
|
|
709
|
+
sendInternalError(res, "Failed to trace dataflow", "Failed to trace dataflow", err);
|
|
710
|
+
}
|
|
711
|
+
});
|
|
712
|
+
return router;
|
|
713
|
+
}
|
|
714
|
+
function createTestsRouter(db) {
|
|
715
|
+
const router = Router2();
|
|
716
|
+
router.get("/", (req, res) => {
|
|
717
|
+
const framework = typeof req.query.framework === "string" && isTestFramework(req.query.framework) ? req.query.framework : void 0;
|
|
718
|
+
const target = typeof req.query.target === "string" ? req.query.target : void 0;
|
|
719
|
+
const snapshot = db.loadSnapshot();
|
|
720
|
+
res.json({
|
|
721
|
+
data: projectTestView(db.loadGraph(), { framework, target }),
|
|
722
|
+
meta: {
|
|
723
|
+
snapshotId: snapshot?.snapshotId ?? null,
|
|
724
|
+
snapshotDigest: snapshot?.snapshotDigest ?? null
|
|
725
|
+
}
|
|
726
|
+
});
|
|
727
|
+
});
|
|
728
|
+
return router;
|
|
729
|
+
}
|
|
730
|
+
var codeomnivisEvents = new EventEmitter();
|
|
731
|
+
var EVENTS = {
|
|
732
|
+
GRAPH_UPDATED: "graph:updated",
|
|
733
|
+
ANALYSIS_STARTED: "analysis:started",
|
|
734
|
+
ANALYSIS_COMPLETED: "analysis:completed",
|
|
735
|
+
STATUS_CHANGED: "status:changed"
|
|
736
|
+
};
|
|
737
|
+
var IGNORED_PATHS = [
|
|
738
|
+
/(^|[/\\])\../,
|
|
739
|
+
// dotfiles
|
|
740
|
+
/node_modules/,
|
|
741
|
+
/\.next/,
|
|
742
|
+
/[/\\]dist[/\\]/,
|
|
743
|
+
/[/\\]build[/\\]/,
|
|
744
|
+
/[/\\]coverage[/\\]/,
|
|
745
|
+
/\.codeomnivis/
|
|
746
|
+
];
|
|
747
|
+
var SOURCE_FILE_RE = /\.(ts|tsx|js|jsx|prisma)$/;
|
|
748
|
+
var IncrementalAnalyzer = class {
|
|
749
|
+
watcher = null;
|
|
750
|
+
projectRoot;
|
|
751
|
+
dbPath;
|
|
752
|
+
db;
|
|
753
|
+
projectMeta;
|
|
754
|
+
debounceMs;
|
|
755
|
+
watchDirs;
|
|
756
|
+
debounceTimer = null;
|
|
757
|
+
// 新鲜度状态
|
|
758
|
+
state = "stale";
|
|
759
|
+
lastAnalyzedAt = null;
|
|
760
|
+
pendingChanges = 0;
|
|
761
|
+
isAnalyzing = false;
|
|
762
|
+
/** 分析进行中又来了新变更时置位,分析结束后补跑一次,避免丢失变更。 */
|
|
763
|
+
rerunRequested = false;
|
|
764
|
+
lastChangedFile = null;
|
|
765
|
+
/**
|
|
766
|
+
* 分析世代。每次切根 +1,用于作废切根前启动的在途/排队分析:
|
|
767
|
+
* 旧世代分析完成后不再广播结果、不再触发补跑。
|
|
768
|
+
*/
|
|
769
|
+
generation = 0;
|
|
770
|
+
/** 当前在途分析的 promise(无在途时为 null),供切根时串行等待其落库完成。 */
|
|
771
|
+
analysisInFlight = null;
|
|
772
|
+
constructor(options) {
|
|
773
|
+
this.projectRoot = options.projectRoot;
|
|
774
|
+
this.dbPath = options.dbPath;
|
|
775
|
+
this.db = options.db;
|
|
776
|
+
this.projectMeta = options.projectMeta;
|
|
777
|
+
this.debounceMs = options.debounceMs ?? 1e3;
|
|
778
|
+
this.watchDirs = options.watchDirs;
|
|
779
|
+
}
|
|
780
|
+
/** 当前监听的项目根路径。 */
|
|
781
|
+
getProjectRoot() {
|
|
782
|
+
return this.projectRoot;
|
|
783
|
+
}
|
|
784
|
+
/**
|
|
785
|
+
* 运行时切换项目根目录。
|
|
786
|
+
* 停止旧监听 -> 稳定并快照旧状态 -> 分析目标 -> 成功提交或失败回滚。
|
|
787
|
+
* 调用方需先确保 newRoot 是存在的目录;此处再次防御性校验。
|
|
788
|
+
*/
|
|
789
|
+
async setProjectRoot(newRoot, projectMeta) {
|
|
790
|
+
const resolved = path5.resolve(newRoot);
|
|
791
|
+
if (!fs4.existsSync(resolved) || !fs4.statSync(resolved).isDirectory()) {
|
|
792
|
+
throw new Error(`Project root is not an existing directory: ${resolved}`);
|
|
793
|
+
}
|
|
794
|
+
const wasWatching = this.watcher !== null;
|
|
795
|
+
await this.stop();
|
|
796
|
+
if (this.analysisInFlight) {
|
|
797
|
+
try {
|
|
798
|
+
await this.analysisInFlight;
|
|
799
|
+
} catch {
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
const snapshot = this.captureProjectSwitchSnapshot(wasWatching);
|
|
803
|
+
this.generation += 1;
|
|
804
|
+
try {
|
|
805
|
+
this.projectRoot = resolved;
|
|
806
|
+
this.projectMeta = resolved === snapshot.projectRoot && projectMeta === void 0 ? snapshot.projectMeta : projectMeta;
|
|
807
|
+
if (!this.db.clearGraph()) {
|
|
808
|
+
throw new Error("Failed to clear graph before project analysis");
|
|
809
|
+
}
|
|
810
|
+
this.pendingChanges = 0;
|
|
811
|
+
this.lastChangedFile = null;
|
|
812
|
+
this.lastAnalyzedAt = null;
|
|
813
|
+
this.rerunRequested = false;
|
|
814
|
+
this.setState("stale");
|
|
815
|
+
if (wasWatching) this.start();
|
|
816
|
+
await this.refresh();
|
|
817
|
+
} catch (err) {
|
|
818
|
+
this.generation += 1;
|
|
819
|
+
await this.stop();
|
|
820
|
+
const rollbackError = this.restoreProjectSwitchSnapshot(snapshot);
|
|
821
|
+
if (snapshot.wasWatching) this.start();
|
|
822
|
+
this.setState(snapshot.state);
|
|
823
|
+
const failure = err instanceof Error ? err : new Error(String(err));
|
|
824
|
+
if (rollbackError !== null) {
|
|
825
|
+
throw new AggregateError(
|
|
826
|
+
[failure, rollbackError],
|
|
827
|
+
`Project switch failed and rollback was incomplete: ${rollbackError.message}`,
|
|
828
|
+
{ cause: err }
|
|
829
|
+
);
|
|
830
|
+
}
|
|
831
|
+
throw failure;
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
captureProjectSwitchSnapshot(wasWatching) {
|
|
835
|
+
return {
|
|
836
|
+
projectRoot: this.projectRoot,
|
|
837
|
+
projectMeta: this.projectMeta,
|
|
838
|
+
graph: this.db.loadGraph(),
|
|
839
|
+
errors: this.db.getAllErrors(),
|
|
840
|
+
state: this.state,
|
|
841
|
+
lastAnalyzedAt: this.lastAnalyzedAt,
|
|
842
|
+
pendingChanges: this.pendingChanges,
|
|
843
|
+
rerunRequested: this.rerunRequested,
|
|
844
|
+
lastChangedFile: this.lastChangedFile,
|
|
845
|
+
wasWatching
|
|
846
|
+
};
|
|
847
|
+
}
|
|
848
|
+
/** Restore fields even when DB restoration fails, so roots and watcher authority never stay split. */
|
|
849
|
+
restoreProjectSwitchSnapshot(snapshot) {
|
|
850
|
+
this.projectRoot = snapshot.projectRoot;
|
|
851
|
+
this.projectMeta = snapshot.projectMeta;
|
|
852
|
+
this.state = snapshot.state;
|
|
853
|
+
this.lastAnalyzedAt = snapshot.lastAnalyzedAt;
|
|
854
|
+
this.pendingChanges = snapshot.pendingChanges;
|
|
855
|
+
this.rerunRequested = snapshot.rerunRequested;
|
|
856
|
+
this.lastChangedFile = snapshot.lastChangedFile;
|
|
857
|
+
if (!this.db.clearGraph()) {
|
|
858
|
+
return new Error("Failed to clear the failed target graph during rollback");
|
|
859
|
+
}
|
|
860
|
+
const restored = this.db.saveGraph(snapshot.graph);
|
|
861
|
+
const restoredErrors = this.db.insertErrors(snapshot.errors);
|
|
862
|
+
if (restored.nodesSaved !== snapshot.graph.nodes.length || restored.edgesSaved !== snapshot.graph.edges.length || restoredErrors !== snapshot.errors.length) {
|
|
863
|
+
return new Error("Failed to restore the complete project analysis snapshot");
|
|
864
|
+
}
|
|
865
|
+
return null;
|
|
866
|
+
}
|
|
867
|
+
/** 当前新鲜度快照(供 REST / WS 读取)。 */
|
|
868
|
+
getStatus() {
|
|
869
|
+
return {
|
|
870
|
+
state: this.state,
|
|
871
|
+
lastAnalyzedAt: this.lastAnalyzedAt,
|
|
872
|
+
pendingChanges: this.pendingChanges
|
|
873
|
+
};
|
|
874
|
+
}
|
|
875
|
+
/** 切换状态并广播。 */
|
|
876
|
+
setState(next) {
|
|
877
|
+
if (this.state === next) {
|
|
878
|
+
codeomnivisEvents.emit(EVENTS.STATUS_CHANGED, this.getStatus());
|
|
879
|
+
return;
|
|
880
|
+
}
|
|
881
|
+
this.state = next;
|
|
882
|
+
codeomnivisEvents.emit(EVENTS.STATUS_CHANGED, this.getStatus());
|
|
883
|
+
}
|
|
884
|
+
/** 解析智能监听目标:显式 watchDirs(存在的)或整个 projectRoot。 */
|
|
885
|
+
resolveWatchTargets() {
|
|
886
|
+
if (this.watchDirs && this.watchDirs.length > 0) {
|
|
887
|
+
return this.watchDirs.map((dir) => path5.join(this.projectRoot, dir)).filter((dir) => fs4.existsSync(dir));
|
|
888
|
+
}
|
|
889
|
+
return fs4.existsSync(this.projectRoot) ? [this.projectRoot] : [];
|
|
890
|
+
}
|
|
891
|
+
/**
|
|
892
|
+
* 启动文件监听
|
|
893
|
+
*/
|
|
894
|
+
start() {
|
|
895
|
+
const dirsToWatch = this.resolveWatchTargets();
|
|
896
|
+
if (dirsToWatch.length === 0) {
|
|
897
|
+
console.warn("[IncrementalAnalyzer] No directories to watch");
|
|
898
|
+
return;
|
|
899
|
+
}
|
|
900
|
+
this.watcher = chokidar.watch(dirsToWatch, {
|
|
901
|
+
ignored: IGNORED_PATHS,
|
|
902
|
+
persistent: true,
|
|
903
|
+
ignoreInitial: true
|
|
904
|
+
});
|
|
905
|
+
this.watcher.on("change", (filePath) => this.onFileChange(filePath));
|
|
906
|
+
this.watcher.on("add", (filePath) => this.onFileChange(filePath));
|
|
907
|
+
this.watcher.on("unlink", (filePath) => this.onFileChange(filePath));
|
|
908
|
+
console.log(`[IncrementalAnalyzer] Smart-watching ${dirsToWatch.length} target(s)`);
|
|
909
|
+
}
|
|
910
|
+
/**
|
|
911
|
+
* 文件变更处理(防抖)
|
|
912
|
+
*/
|
|
913
|
+
onFileChange(filePath) {
|
|
914
|
+
if (!SOURCE_FILE_RE.test(filePath)) return;
|
|
915
|
+
console.log(`[IncrementalAnalyzer] File changed: ${path5.relative(this.projectRoot, filePath)}`);
|
|
916
|
+
this.pendingChanges += 1;
|
|
917
|
+
this.lastChangedFile = filePath;
|
|
918
|
+
if (!this.isAnalyzing) {
|
|
919
|
+
this.setState("stale");
|
|
920
|
+
}
|
|
921
|
+
if (this.debounceTimer) {
|
|
922
|
+
clearTimeout(this.debounceTimer);
|
|
923
|
+
}
|
|
924
|
+
this.debounceTimer = setTimeout(() => {
|
|
925
|
+
void this.triggerAnalysis(filePath);
|
|
926
|
+
}, this.debounceMs);
|
|
927
|
+
}
|
|
928
|
+
/**
|
|
929
|
+
* 手动触发重新分析(REST 兜底入口)。
|
|
930
|
+
* 与文件监听共用串行化逻辑;分析进行中则记为补跑请求。
|
|
931
|
+
*/
|
|
932
|
+
async refresh(onFilesCollected) {
|
|
933
|
+
await this.triggerAnalysis(this.lastChangedFile, true, onFilesCollected);
|
|
934
|
+
}
|
|
935
|
+
/**
|
|
936
|
+
* 触发增量分析(串行化,不丢失分析期间到达的变更)
|
|
937
|
+
*/
|
|
938
|
+
async triggerAnalysis(filePath, manual = false, onFilesCollected) {
|
|
939
|
+
if (this.isAnalyzing) {
|
|
940
|
+
console.log("[IncrementalAnalyzer] Analysis in progress, queuing rerun");
|
|
941
|
+
this.rerunRequested = true;
|
|
942
|
+
return;
|
|
943
|
+
}
|
|
944
|
+
const myGeneration = this.generation;
|
|
945
|
+
const run = this.runAnalysisCycle(filePath, myGeneration, manual, onFilesCollected);
|
|
946
|
+
this.analysisInFlight = run;
|
|
947
|
+
try {
|
|
948
|
+
await run;
|
|
949
|
+
} finally {
|
|
950
|
+
if (this.analysisInFlight === run) {
|
|
951
|
+
this.analysisInFlight = null;
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
/** 实际执行一轮分析;myGeneration 用于切根后作废过期结果。 */
|
|
956
|
+
async runAnalysisCycle(filePath, myGeneration, manual, onFilesCollected) {
|
|
957
|
+
this.isAnalyzing = true;
|
|
958
|
+
this.setState("analyzing");
|
|
959
|
+
codeomnivisEvents.emit(EVENTS.ANALYSIS_STARTED);
|
|
960
|
+
const consumed = this.pendingChanges;
|
|
961
|
+
this.pendingChanges = 0;
|
|
962
|
+
let failure = null;
|
|
963
|
+
try {
|
|
964
|
+
console.log("[IncrementalAnalyzer] Running re-analysis...");
|
|
965
|
+
await runAnalysis({
|
|
966
|
+
projectRoot: this.projectRoot,
|
|
967
|
+
dbPath: this.dbPath,
|
|
968
|
+
projectMeta: this.projectMeta,
|
|
969
|
+
onFilesCollected,
|
|
970
|
+
// 共享 server 持有的同一 DB 句柄(修复 RACE-01::memory: 下查询层才读得到结果)。
|
|
971
|
+
db: this.db
|
|
972
|
+
});
|
|
973
|
+
if (myGeneration !== this.generation) {
|
|
974
|
+
console.log("[IncrementalAnalyzer] Stale analysis (root switched), discarding result");
|
|
975
|
+
return;
|
|
976
|
+
}
|
|
977
|
+
console.log("[IncrementalAnalyzer] Re-analysis complete");
|
|
978
|
+
this.lastAnalyzedAt = Date.now();
|
|
979
|
+
codeomnivisEvents.emit(EVENTS.GRAPH_UPDATED, filePath);
|
|
980
|
+
codeomnivisEvents.emit(EVENTS.ANALYSIS_COMPLETED);
|
|
981
|
+
} catch (err) {
|
|
982
|
+
console.error("[IncrementalAnalyzer] Analysis failed:", err);
|
|
983
|
+
failure = err;
|
|
984
|
+
this.pendingChanges += consumed;
|
|
985
|
+
} finally {
|
|
986
|
+
this.isAnalyzing = false;
|
|
987
|
+
if (myGeneration !== this.generation) {
|
|
988
|
+
this.rerunRequested = false;
|
|
989
|
+
} else if (this.rerunRequested) {
|
|
990
|
+
this.rerunRequested = false;
|
|
991
|
+
const next = this.lastChangedFile;
|
|
992
|
+
await this.triggerAnalysis(next);
|
|
993
|
+
} else {
|
|
994
|
+
this.setState(failure !== null || this.pendingChanges > 0 ? "stale" : "fresh");
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
if (manual && failure !== null) {
|
|
998
|
+
throw failure instanceof Error ? failure : new Error(String(failure));
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
/**
|
|
1002
|
+
* 停止文件监听
|
|
1003
|
+
*/
|
|
1004
|
+
async stop() {
|
|
1005
|
+
if (this.debounceTimer) {
|
|
1006
|
+
clearTimeout(this.debounceTimer);
|
|
1007
|
+
this.debounceTimer = null;
|
|
1008
|
+
}
|
|
1009
|
+
if (this.watcher) {
|
|
1010
|
+
const watcher = this.watcher;
|
|
1011
|
+
this.watcher = null;
|
|
1012
|
+
await watcher.close();
|
|
1013
|
+
}
|
|
1014
|
+
console.log("[IncrementalAnalyzer] Stopped");
|
|
1015
|
+
}
|
|
1016
|
+
};
|
|
1017
|
+
var DEFAULT_AI_LIMITS = {
|
|
1018
|
+
timeoutMs: 1e4,
|
|
1019
|
+
maxRequestBytes: 256 * 1024,
|
|
1020
|
+
maxResponseBytes: 1024 * 1024,
|
|
1021
|
+
maxConcurrentPerIdentity: 2,
|
|
1022
|
+
requestsPerMinute: 20
|
|
1023
|
+
};
|
|
1024
|
+
var AiPolicyError = class extends Error {
|
|
1025
|
+
constructor(code, status, message) {
|
|
1026
|
+
super(message);
|
|
1027
|
+
this.code = code;
|
|
1028
|
+
this.status = status;
|
|
1029
|
+
this.name = "AiPolicyError";
|
|
1030
|
+
}
|
|
1031
|
+
code;
|
|
1032
|
+
status;
|
|
1033
|
+
};
|
|
1034
|
+
function isLiteralLoopback(hostname) {
|
|
1035
|
+
const normalized = hostname.replace(/^\[|\]$/gu, "");
|
|
1036
|
+
return normalized === "localhost" || normalized === "::1" || /^127\./u.test(normalized);
|
|
1037
|
+
}
|
|
1038
|
+
async function resolveUpstreamDestination(baseUrl, resolver, allowLoopback = true) {
|
|
1039
|
+
const literalCheck = validateUpstreamBaseUrl(baseUrl);
|
|
1040
|
+
if (!literalCheck.ok) {
|
|
1041
|
+
throw new AiPolicyError("AI_DESTINATION_REJECTED", 400, "AI base URL is not allowed");
|
|
1042
|
+
}
|
|
1043
|
+
const url = new URL(baseUrl.replace(/\/+$/u, "") + "/chat/completions");
|
|
1044
|
+
const hostname = url.hostname;
|
|
1045
|
+
if (isLiteralLoopback(hostname) && !allowLoopback) {
|
|
1046
|
+
throw new AiPolicyError("AI_DESTINATION_REJECTED", 400, "Loopback AI providers are disabled");
|
|
1047
|
+
}
|
|
1048
|
+
if (hostname === "localhost") return { url, hostname };
|
|
1049
|
+
if (isIpLiteral(hostname)) {
|
|
1050
|
+
const family2 = isIP(hostname.replace(/^\[|\]$/gu, ""));
|
|
1051
|
+
return {
|
|
1052
|
+
url,
|
|
1053
|
+
hostname,
|
|
1054
|
+
address: hostname.replace(/^\[|\]$/gu, ""),
|
|
1055
|
+
family: family2 === 6 ? 6 : 4
|
|
1056
|
+
};
|
|
1057
|
+
}
|
|
1058
|
+
let addresses;
|
|
1059
|
+
try {
|
|
1060
|
+
addresses = await resolver(hostname);
|
|
1061
|
+
} catch {
|
|
1062
|
+
throw new AiPolicyError("AI_DESTINATION_REJECTED", 400, "AI hostname could not be resolved");
|
|
1063
|
+
}
|
|
1064
|
+
const resolvedCheck = validateResolvedAddresses(addresses);
|
|
1065
|
+
if (!resolvedCheck.ok) {
|
|
1066
|
+
throw new AiPolicyError(
|
|
1067
|
+
"AI_DESTINATION_REJECTED",
|
|
1068
|
+
400,
|
|
1069
|
+
"AI hostname resolved to a blocked address"
|
|
1070
|
+
);
|
|
1071
|
+
}
|
|
1072
|
+
const address = addresses[0];
|
|
1073
|
+
const family = isIP(address);
|
|
1074
|
+
if (family !== 4 && family !== 6) {
|
|
1075
|
+
throw new AiPolicyError("AI_DESTINATION_REJECTED", 400, "AI hostname resolution was invalid");
|
|
1076
|
+
}
|
|
1077
|
+
return { url, hostname, address, family };
|
|
1078
|
+
}
|
|
1079
|
+
async function readBoundedBody(body, maxBytes) {
|
|
1080
|
+
const chunks = [];
|
|
1081
|
+
let total = 0;
|
|
1082
|
+
for await (const chunk of body) {
|
|
1083
|
+
total += chunk.byteLength;
|
|
1084
|
+
if (total > maxBytes) {
|
|
1085
|
+
throw new AiPolicyError("AI_RESPONSE_TOO_LARGE", 502, "Upstream AI response was too large");
|
|
1086
|
+
}
|
|
1087
|
+
chunks.push(Buffer.from(chunk));
|
|
1088
|
+
}
|
|
1089
|
+
return Buffer.concat(chunks, total).toString("utf8");
|
|
1090
|
+
}
|
|
1091
|
+
function matchesValidatedAddress(peerAddress, validatedAddress) {
|
|
1092
|
+
return peerAddress === validatedAddress || peerAddress === `::ffff:${validatedAddress}`;
|
|
1093
|
+
}
|
|
1094
|
+
function createPinnedAgent(destination) {
|
|
1095
|
+
let peerAddress;
|
|
1096
|
+
if (!destination.address || !destination.family || isLiteralLoopback(destination.hostname)) {
|
|
1097
|
+
return {
|
|
1098
|
+
dispatcher: new Agent({ maxRedirections: 0 }),
|
|
1099
|
+
getPeerAddress: () => peerAddress
|
|
1100
|
+
};
|
|
1101
|
+
}
|
|
1102
|
+
const { address, family } = destination;
|
|
1103
|
+
const connector = buildConnector({
|
|
1104
|
+
// A validated destination contains exactly one pinned address; Node's
|
|
1105
|
+
// multi-address family selection would request the incompatible `all` lookup shape.
|
|
1106
|
+
autoSelectFamily: false,
|
|
1107
|
+
lookup: (_hostname, _options, callback) => callback(null, address, family)
|
|
1108
|
+
});
|
|
1109
|
+
const verifiedConnector = (options, callback) => {
|
|
1110
|
+
connector(options, (error, socket) => {
|
|
1111
|
+
if (error) {
|
|
1112
|
+
callback(error, null);
|
|
1113
|
+
return;
|
|
1114
|
+
}
|
|
1115
|
+
peerAddress = socket.remoteAddress;
|
|
1116
|
+
if (!peerAddress || !matchesValidatedAddress(peerAddress, address)) {
|
|
1117
|
+
socket.destroy();
|
|
1118
|
+
callback(new Error("AI upstream peer address did not match validated DNS"), null);
|
|
1119
|
+
return;
|
|
1120
|
+
}
|
|
1121
|
+
callback(null, socket);
|
|
1122
|
+
});
|
|
1123
|
+
};
|
|
1124
|
+
return {
|
|
1125
|
+
dispatcher: new Agent({ connect: verifiedConnector, maxRedirections: 0 }),
|
|
1126
|
+
getPeerAddress: () => peerAddress
|
|
1127
|
+
};
|
|
1128
|
+
}
|
|
1129
|
+
var defaultAiHttpClient = async (request) => {
|
|
1130
|
+
const pinnedAgent = createPinnedAgent(request.destination);
|
|
1131
|
+
try {
|
|
1132
|
+
const response = await undiciRequest(request.destination.url, {
|
|
1133
|
+
method: "POST",
|
|
1134
|
+
headers: request.headers,
|
|
1135
|
+
body: request.body,
|
|
1136
|
+
dispatcher: pinnedAgent.dispatcher,
|
|
1137
|
+
maxRedirections: 0,
|
|
1138
|
+
signal: request.signal
|
|
1139
|
+
});
|
|
1140
|
+
return {
|
|
1141
|
+
status: response.statusCode,
|
|
1142
|
+
body: response.body,
|
|
1143
|
+
peerAddress: pinnedAgent.getPeerAddress(),
|
|
1144
|
+
close: async () => pinnedAgent.dispatcher.close()
|
|
1145
|
+
};
|
|
1146
|
+
} catch (err) {
|
|
1147
|
+
await pinnedAgent.dispatcher.close().catch(() => {
|
|
1148
|
+
});
|
|
1149
|
+
throw err;
|
|
1150
|
+
}
|
|
1151
|
+
};
|
|
1152
|
+
var RequestLimiter = class {
|
|
1153
|
+
constructor(options) {
|
|
1154
|
+
this.options = options;
|
|
1155
|
+
this.maxIdentities = options.maxIdentities ?? 1e3;
|
|
1156
|
+
}
|
|
1157
|
+
options;
|
|
1158
|
+
states = /* @__PURE__ */ new Map();
|
|
1159
|
+
maxIdentities;
|
|
1160
|
+
acquire(identity, now = Date.now()) {
|
|
1161
|
+
let state = this.states.get(identity);
|
|
1162
|
+
if (!state) {
|
|
1163
|
+
while (this.states.size >= this.maxIdentities) {
|
|
1164
|
+
const oldest = this.states.keys().next().value;
|
|
1165
|
+
if (typeof oldest !== "string") break;
|
|
1166
|
+
this.states.delete(oldest);
|
|
1167
|
+
}
|
|
1168
|
+
state = { windowStartedAt: now, requests: 0, inFlight: 0 };
|
|
1169
|
+
this.states.set(identity, state);
|
|
1170
|
+
}
|
|
1171
|
+
if (now - state.windowStartedAt >= this.options.windowMs) {
|
|
1172
|
+
state.windowStartedAt = now;
|
|
1173
|
+
state.requests = 0;
|
|
1174
|
+
}
|
|
1175
|
+
if (state.inFlight >= this.options.maxConcurrent) {
|
|
1176
|
+
return { ok: false, reason: "concurrency" };
|
|
1177
|
+
}
|
|
1178
|
+
if (state.requests >= this.options.requestsPerWindow) {
|
|
1179
|
+
return { ok: false, reason: "rate" };
|
|
1180
|
+
}
|
|
1181
|
+
state.requests += 1;
|
|
1182
|
+
state.inFlight += 1;
|
|
1183
|
+
let released = false;
|
|
1184
|
+
return {
|
|
1185
|
+
ok: true,
|
|
1186
|
+
release: () => {
|
|
1187
|
+
if (released) return;
|
|
1188
|
+
released = true;
|
|
1189
|
+
state.inFlight = Math.max(0, state.inFlight - 1);
|
|
1190
|
+
}
|
|
1191
|
+
};
|
|
1192
|
+
}
|
|
1193
|
+
};
|
|
1194
|
+
var defaultResolver = async (hostname) => {
|
|
1195
|
+
const records = await dns.lookup(hostname, { all: true });
|
|
1196
|
+
return records.map((record) => record.address);
|
|
1197
|
+
};
|
|
1198
|
+
function readAiEnv(env) {
|
|
1199
|
+
return {
|
|
1200
|
+
baseUrl: env.AI_BASE_URL,
|
|
1201
|
+
apiKey: env.AI_API_KEY,
|
|
1202
|
+
model: env.AI_MODEL
|
|
1203
|
+
};
|
|
1204
|
+
}
|
|
1205
|
+
function extractContent(payload) {
|
|
1206
|
+
if (!isJsonObject(payload) || !Array.isArray(payload.choices) || payload.choices.length === 0) {
|
|
1207
|
+
return null;
|
|
1208
|
+
}
|
|
1209
|
+
const first = payload.choices[0];
|
|
1210
|
+
if (!isJsonObject(first) || !isJsonObject(first.message)) return null;
|
|
1211
|
+
return typeof first.message.content === "string" ? first.message.content : null;
|
|
1212
|
+
}
|
|
1213
|
+
async function callAiChat(config, messages, options = {}) {
|
|
1214
|
+
const client = options.client ?? defaultAiHttpClient;
|
|
1215
|
+
const resolver = options.resolver ?? defaultResolver;
|
|
1216
|
+
const limits = options.limits ?? DEFAULT_AI_LIMITS;
|
|
1217
|
+
const requestBody = JSON.stringify({ model: config.model, messages });
|
|
1218
|
+
if (Buffer.byteLength(requestBody, "utf8") > limits.maxRequestBytes) {
|
|
1219
|
+
throw new AiPolicyError("AI_REQUEST_TOO_LARGE", 413, "AI request body was too large");
|
|
1220
|
+
}
|
|
1221
|
+
const destination = await resolveUpstreamDestination(
|
|
1222
|
+
config.baseUrl,
|
|
1223
|
+
resolver,
|
|
1224
|
+
options.allowLoopback ?? true
|
|
1225
|
+
);
|
|
1226
|
+
const signal = AbortSignal.timeout(limits.timeoutMs);
|
|
1227
|
+
let upstream;
|
|
1228
|
+
try {
|
|
1229
|
+
upstream = await client({
|
|
1230
|
+
destination,
|
|
1231
|
+
headers: {
|
|
1232
|
+
"Content-Type": "application/json",
|
|
1233
|
+
Authorization: `Bearer ${config.apiKey}`
|
|
1234
|
+
},
|
|
1235
|
+
body: requestBody,
|
|
1236
|
+
signal
|
|
1237
|
+
});
|
|
1238
|
+
} catch (err) {
|
|
1239
|
+
if (signal.aborted) {
|
|
1240
|
+
throw new AiPolicyError("AI_UPSTREAM_TIMEOUT", 504, "Upstream AI request timed out");
|
|
1241
|
+
}
|
|
1242
|
+
if (err instanceof AiPolicyError) throw err;
|
|
1243
|
+
throw new AiPolicyError("AI_UPSTREAM_FAILED", 502, "Upstream AI request failed");
|
|
1244
|
+
}
|
|
1245
|
+
try {
|
|
1246
|
+
if (destination.address && upstream.peerAddress && !matchesValidatedAddress(upstream.peerAddress, destination.address)) {
|
|
1247
|
+
throw new AiPolicyError(
|
|
1248
|
+
"AI_UPSTREAM_PEER_MISMATCH",
|
|
1249
|
+
502,
|
|
1250
|
+
"Upstream AI peer address was rejected"
|
|
1251
|
+
);
|
|
1252
|
+
}
|
|
1253
|
+
if (upstream.status >= 300 && upstream.status < 400) {
|
|
1254
|
+
throw new AiPolicyError("AI_UPSTREAM_REDIRECT", 502, "Upstream AI redirect was rejected");
|
|
1255
|
+
}
|
|
1256
|
+
if (upstream.status < 200 || upstream.status >= 300) {
|
|
1257
|
+
throw new AiPolicyError("AI_UPSTREAM_FAILED", 502, "Upstream AI request failed");
|
|
1258
|
+
}
|
|
1259
|
+
const responseText = await readBoundedBody(upstream.body, limits.maxResponseBytes);
|
|
1260
|
+
let payload;
|
|
1261
|
+
try {
|
|
1262
|
+
payload = JSON.parse(responseText);
|
|
1263
|
+
} catch {
|
|
1264
|
+
throw new AiPolicyError("AI_RESPONSE_INVALID", 502, "Upstream AI response was invalid");
|
|
1265
|
+
}
|
|
1266
|
+
const content = extractContent(payload);
|
|
1267
|
+
if (content === null) {
|
|
1268
|
+
throw new AiPolicyError("AI_RESPONSE_INVALID", 502, "Upstream AI response was invalid");
|
|
1269
|
+
}
|
|
1270
|
+
return content;
|
|
1271
|
+
} catch (err) {
|
|
1272
|
+
if (signal.aborted) {
|
|
1273
|
+
throw new AiPolicyError("AI_UPSTREAM_TIMEOUT", 504, "Upstream AI request timed out");
|
|
1274
|
+
}
|
|
1275
|
+
throw err;
|
|
1276
|
+
} finally {
|
|
1277
|
+
await upstream.close().catch(() => {
|
|
1278
|
+
});
|
|
1279
|
+
}
|
|
1280
|
+
}
|
|
1281
|
+
function requestIdentity(req) {
|
|
1282
|
+
const source = req.headers.authorization ?? req.headers.cookie ?? req.socket.remoteAddress ?? "local";
|
|
1283
|
+
return createHash("sha256").update(source).digest("hex");
|
|
1284
|
+
}
|
|
1285
|
+
function sendError(res, error) {
|
|
1286
|
+
res.status(error.status).json({ error: { code: error.code, message: error.message } });
|
|
1287
|
+
}
|
|
1288
|
+
async function handleChat(req, res, resolver, options) {
|
|
1289
|
+
const body = req.body;
|
|
1290
|
+
const parsed = parseAiChatRequest(body);
|
|
1291
|
+
if (parsed === null) {
|
|
1292
|
+
sendError(res, new AiPolicyError("AI_REQUEST_INVALID", 400, "Invalid AI chat request body"));
|
|
1293
|
+
return;
|
|
1294
|
+
}
|
|
1295
|
+
const config = resolveAiConfig(parsed.config, readAiEnv(process.env));
|
|
1296
|
+
if (config === null) {
|
|
1297
|
+
res.status(501).json({
|
|
1298
|
+
error: {
|
|
1299
|
+
code: "AI_NOT_CONFIGURED",
|
|
1300
|
+
message: "Connect an API key in settings to enable AI features"
|
|
1301
|
+
}
|
|
1302
|
+
});
|
|
1303
|
+
return;
|
|
1304
|
+
}
|
|
1305
|
+
const acquired = options.limiter.acquire(requestIdentity(req));
|
|
1306
|
+
if (!acquired.ok) {
|
|
1307
|
+
const concurrency = acquired.reason === "concurrency";
|
|
1308
|
+
sendError(
|
|
1309
|
+
res,
|
|
1310
|
+
new AiPolicyError(
|
|
1311
|
+
concurrency ? "AI_CONCURRENCY_LIMIT" : "AI_RATE_LIMIT",
|
|
1312
|
+
429,
|
|
1313
|
+
concurrency ? "Too many concurrent AI requests" : "AI request rate limit exceeded"
|
|
1314
|
+
)
|
|
1315
|
+
);
|
|
1316
|
+
return;
|
|
1317
|
+
}
|
|
1318
|
+
try {
|
|
1319
|
+
const content = await callAiChat(config, parsed.messages, {
|
|
1320
|
+
client: options.client,
|
|
1321
|
+
resolver,
|
|
1322
|
+
limits: options.limits,
|
|
1323
|
+
allowLoopback: options.allowLoopback
|
|
1324
|
+
});
|
|
1325
|
+
res.json({ data: { content }, meta: {} });
|
|
1326
|
+
} catch (err) {
|
|
1327
|
+
sendError(
|
|
1328
|
+
res,
|
|
1329
|
+
err instanceof AiPolicyError ? err : new AiPolicyError("AI_UPSTREAM_FAILED", 502, "Upstream AI request failed")
|
|
1330
|
+
);
|
|
1331
|
+
} finally {
|
|
1332
|
+
acquired.release();
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
function registerAiRoutes(app, resolver = defaultResolver, accessGuard, routeOptions = {}) {
|
|
1336
|
+
const guard = accessGuard ?? ((_req, _res, next) => next());
|
|
1337
|
+
const limits = { ...DEFAULT_AI_LIMITS, ...routeOptions.limits };
|
|
1338
|
+
const handlerOptions = {
|
|
1339
|
+
client: routeOptions.client ?? defaultAiHttpClient,
|
|
1340
|
+
limits,
|
|
1341
|
+
allowLoopback: routeOptions.allowLoopback ?? true,
|
|
1342
|
+
limiter: new RequestLimiter({
|
|
1343
|
+
maxConcurrent: limits.maxConcurrentPerIdentity,
|
|
1344
|
+
requestsPerWindow: limits.requestsPerMinute,
|
|
1345
|
+
windowMs: 6e4
|
|
1346
|
+
})
|
|
1347
|
+
};
|
|
1348
|
+
app.post("/api/ai/chat", guard, (req, res) => {
|
|
1349
|
+
void handleChat(req, res, resolver, handlerOptions);
|
|
1350
|
+
});
|
|
1351
|
+
app.post("/api/ai/explain", guard, (req, res) => {
|
|
1352
|
+
void handleChat(req, res, resolver, handlerOptions);
|
|
1353
|
+
});
|
|
1354
|
+
}
|
|
1355
|
+
var SESSION_COOKIE_NAME = "codeomnivis_session";
|
|
1356
|
+
function isLoopbackHost(host) {
|
|
1357
|
+
const normalized = host.replace(/^\[|\]$/g, "").toLowerCase();
|
|
1358
|
+
return normalized === "localhost" || normalized === "::1" || /^127\./u.test(normalized);
|
|
1359
|
+
}
|
|
1360
|
+
function digestToken(token) {
|
|
1361
|
+
return createHash2("sha256").update(token, "utf8").digest();
|
|
1362
|
+
}
|
|
1363
|
+
function tokenMatches(provided, expected) {
|
|
1364
|
+
if (!expected) return false;
|
|
1365
|
+
return timingSafeEqual(digestToken(provided), expected);
|
|
1366
|
+
}
|
|
1367
|
+
function bearerToken(headers) {
|
|
1368
|
+
const authorization = headers.authorization;
|
|
1369
|
+
if (typeof authorization === "string") {
|
|
1370
|
+
const match = /^Bearer\s+(.+)$/iu.exec(authorization.trim());
|
|
1371
|
+
if (match) return match[1].trim();
|
|
1372
|
+
}
|
|
1373
|
+
const legacy = headers["x-access-token"];
|
|
1374
|
+
if (typeof legacy === "string" && legacy.trim() !== "") return legacy.trim();
|
|
1375
|
+
return void 0;
|
|
1376
|
+
}
|
|
1377
|
+
function sessionCookie(headers) {
|
|
1378
|
+
const cookie = headers.cookie;
|
|
1379
|
+
if (typeof cookie !== "string") return void 0;
|
|
1380
|
+
for (const segment of cookie.split(";")) {
|
|
1381
|
+
const separator = segment.indexOf("=");
|
|
1382
|
+
if (separator < 0) continue;
|
|
1383
|
+
const name = segment.slice(0, separator).trim();
|
|
1384
|
+
if (name === SESSION_COOKIE_NAME) return segment.slice(separator + 1).trim();
|
|
1385
|
+
}
|
|
1386
|
+
return void 0;
|
|
1387
|
+
}
|
|
1388
|
+
function createAccessPolicy(options) {
|
|
1389
|
+
return {
|
|
1390
|
+
loopback: isLoopbackHost(options.host),
|
|
1391
|
+
accessTokenDigest: options.accessToken ? digestToken(options.accessToken) : void 0,
|
|
1392
|
+
sessions: options.sessions,
|
|
1393
|
+
secureCookies: options.secureCookies
|
|
1394
|
+
};
|
|
1395
|
+
}
|
|
1396
|
+
function authenticateHeaders(headers, policy) {
|
|
1397
|
+
if (policy.loopback) return { ok: true };
|
|
1398
|
+
if (!policy.accessTokenDigest) {
|
|
1399
|
+
return { ok: false, status: 403, code: "AUTH_NOT_CONFIGURED" };
|
|
1400
|
+
}
|
|
1401
|
+
const providedToken = bearerToken(headers);
|
|
1402
|
+
if (providedToken && tokenMatches(providedToken, policy.accessTokenDigest)) {
|
|
1403
|
+
return { ok: true };
|
|
1404
|
+
}
|
|
1405
|
+
const sessionId = sessionCookie(headers);
|
|
1406
|
+
if (sessionId && policy.sessions.validate(sessionId)) return { ok: true };
|
|
1407
|
+
return { ok: false, status: 401, code: "UNAUTHORIZED" };
|
|
1408
|
+
}
|
|
1409
|
+
function sendAccessError(res, decision) {
|
|
1410
|
+
const message = decision.code === "AUTH_NOT_CONFIGURED" ? "Remote access is disabled because no access token is configured" : "A valid access token or session is required for this operation";
|
|
1411
|
+
res.status(decision.status).json({ error: { code: decision.code, message } });
|
|
1412
|
+
}
|
|
1413
|
+
function createAccessGuard(policy) {
|
|
1414
|
+
return (req, res, next) => {
|
|
1415
|
+
const decision = authenticateHeaders(req.headers, policy);
|
|
1416
|
+
if (!decision.ok) {
|
|
1417
|
+
sendAccessError(res, decision);
|
|
1418
|
+
return;
|
|
1419
|
+
}
|
|
1420
|
+
next();
|
|
1421
|
+
};
|
|
1422
|
+
}
|
|
1423
|
+
function createSessionHandler(policy) {
|
|
1424
|
+
return (req, res) => {
|
|
1425
|
+
if (!policy.accessTokenDigest) {
|
|
1426
|
+
sendAccessError(res, { ok: false, status: 403, code: "AUTH_NOT_CONFIGURED" });
|
|
1427
|
+
return;
|
|
1428
|
+
}
|
|
1429
|
+
const body = req.body;
|
|
1430
|
+
const accessToken = isJsonObject(body) ? body.accessToken : void 0;
|
|
1431
|
+
if (typeof accessToken !== "string" || !tokenMatches(accessToken, policy.accessTokenDigest)) {
|
|
1432
|
+
sendAccessError(res, { ok: false, status: 401, code: "UNAUTHORIZED" });
|
|
1433
|
+
return;
|
|
1434
|
+
}
|
|
1435
|
+
const session = policy.sessions.create();
|
|
1436
|
+
const attributes = [
|
|
1437
|
+
`${SESSION_COOKIE_NAME}=${session.id}`,
|
|
1438
|
+
"HttpOnly",
|
|
1439
|
+
"SameSite=Strict",
|
|
1440
|
+
"Path=/",
|
|
1441
|
+
`Max-Age=${Math.floor(policy.sessions.ttlMs / 1e3)}`
|
|
1442
|
+
];
|
|
1443
|
+
if (policy.secureCookies) attributes.push("Secure");
|
|
1444
|
+
res.setHeader("Set-Cookie", attributes.join("; "));
|
|
1445
|
+
res.json({ data: { expiresAt: session.expiresAt }, meta: {} });
|
|
1446
|
+
};
|
|
1447
|
+
}
|
|
1448
|
+
var SessionStore = class {
|
|
1449
|
+
ttlMs;
|
|
1450
|
+
maxSessions;
|
|
1451
|
+
sessions = /* @__PURE__ */ new Map();
|
|
1452
|
+
cleanupTimer;
|
|
1453
|
+
constructor(options) {
|
|
1454
|
+
if (!Number.isFinite(options.ttlMs) || options.ttlMs <= 0) {
|
|
1455
|
+
throw new Error("Session ttlMs must be a positive finite number");
|
|
1456
|
+
}
|
|
1457
|
+
if (!Number.isInteger(options.maxSessions) || options.maxSessions <= 0) {
|
|
1458
|
+
throw new Error("Session maxSessions must be a positive integer");
|
|
1459
|
+
}
|
|
1460
|
+
this.ttlMs = options.ttlMs;
|
|
1461
|
+
this.maxSessions = options.maxSessions;
|
|
1462
|
+
this.cleanupTimer = setInterval(
|
|
1463
|
+
() => this.removeExpired(Date.now()),
|
|
1464
|
+
Math.min(this.ttlMs, 6e4)
|
|
1465
|
+
);
|
|
1466
|
+
this.cleanupTimer.unref();
|
|
1467
|
+
}
|
|
1468
|
+
create(now = Date.now()) {
|
|
1469
|
+
this.removeExpired(now);
|
|
1470
|
+
while (this.sessions.size >= this.maxSessions) {
|
|
1471
|
+
const oldest = this.sessions.keys().next().value;
|
|
1472
|
+
if (typeof oldest !== "string") break;
|
|
1473
|
+
this.sessions.delete(oldest);
|
|
1474
|
+
}
|
|
1475
|
+
const record = {
|
|
1476
|
+
id: randomBytes(32).toString("base64url"),
|
|
1477
|
+
expiresAt: now + this.ttlMs
|
|
1478
|
+
};
|
|
1479
|
+
this.sessions.set(record.id, record.expiresAt);
|
|
1480
|
+
return record;
|
|
1481
|
+
}
|
|
1482
|
+
validate(id, now = Date.now()) {
|
|
1483
|
+
const expiresAt = this.sessions.get(id);
|
|
1484
|
+
if (expiresAt === void 0) return false;
|
|
1485
|
+
if (expiresAt <= now) {
|
|
1486
|
+
this.sessions.delete(id);
|
|
1487
|
+
return false;
|
|
1488
|
+
}
|
|
1489
|
+
return true;
|
|
1490
|
+
}
|
|
1491
|
+
revoke(id) {
|
|
1492
|
+
this.sessions.delete(id);
|
|
1493
|
+
}
|
|
1494
|
+
clear() {
|
|
1495
|
+
this.sessions.clear();
|
|
1496
|
+
}
|
|
1497
|
+
dispose() {
|
|
1498
|
+
clearInterval(this.cleanupTimer);
|
|
1499
|
+
this.clear();
|
|
1500
|
+
}
|
|
1501
|
+
removeExpired(now) {
|
|
1502
|
+
for (const [id, expiresAt] of this.sessions) {
|
|
1503
|
+
if (expiresAt <= now) this.sessions.delete(id);
|
|
1504
|
+
}
|
|
1505
|
+
}
|
|
1506
|
+
};
|
|
1507
|
+
function toOriginAllowlist(corsOrigin) {
|
|
1508
|
+
return Array.isArray(corsOrigin) ? corsOrigin : [corsOrigin];
|
|
1509
|
+
}
|
|
1510
|
+
function isOriginAllowed(origin, allowlist) {
|
|
1511
|
+
if (origin === void 0 || origin === "") return true;
|
|
1512
|
+
if (allowlist.includes("*")) return true;
|
|
1513
|
+
if (allowlist.includes(origin)) return true;
|
|
1514
|
+
try {
|
|
1515
|
+
const candidate = new URL(origin);
|
|
1516
|
+
return allowlist.some((allowed) => {
|
|
1517
|
+
const configured = new URL(allowed);
|
|
1518
|
+
return candidate.protocol === configured.protocol && candidate.port === configured.port && isLoopbackHost(candidate.hostname) && isLoopbackHost(configured.hostname);
|
|
1519
|
+
});
|
|
1520
|
+
} catch {
|
|
1521
|
+
return false;
|
|
1522
|
+
}
|
|
1523
|
+
}
|
|
1524
|
+
function isWithin(parent, child) {
|
|
1525
|
+
if (parent === child) return true;
|
|
1526
|
+
const rel = path22.relative(parent, child);
|
|
1527
|
+
return rel !== ".." && !rel.startsWith(`..${path22.sep}`) && !path22.isAbsolute(rel);
|
|
1528
|
+
}
|
|
1529
|
+
function realpathBestEffort(target) {
|
|
1530
|
+
let current = path22.resolve(target);
|
|
1531
|
+
const tail = [];
|
|
1532
|
+
for (; ; ) {
|
|
1533
|
+
try {
|
|
1534
|
+
const real = fs22.realpathSync.native(current);
|
|
1535
|
+
return tail.length === 0 ? real : path22.join(real, ...tail.reverse());
|
|
1536
|
+
} catch {
|
|
1537
|
+
const parent = path22.dirname(current);
|
|
1538
|
+
if (parent === current) {
|
|
1539
|
+
return path22.resolve(target);
|
|
1540
|
+
}
|
|
1541
|
+
tail.push(path22.basename(current));
|
|
1542
|
+
current = parent;
|
|
1543
|
+
}
|
|
1544
|
+
}
|
|
1545
|
+
}
|
|
1546
|
+
function resolveWithinBoundary(boundaryRoot, input) {
|
|
1547
|
+
const root = path22.resolve(boundaryRoot);
|
|
1548
|
+
const resolved = path22.resolve(root, input);
|
|
1549
|
+
const rel = path22.relative(root, resolved);
|
|
1550
|
+
const escapes = rel === ".." || rel.startsWith(`..${path22.sep}`) || path22.isAbsolute(rel);
|
|
1551
|
+
if (escapes) {
|
|
1552
|
+
return { ok: false, resolved };
|
|
1553
|
+
}
|
|
1554
|
+
const realRoot = realpathBestEffort(root);
|
|
1555
|
+
const realResolved = realpathBestEffort(resolved);
|
|
1556
|
+
if (!isWithin(realRoot, realResolved)) {
|
|
1557
|
+
return { ok: false, resolved };
|
|
1558
|
+
}
|
|
1559
|
+
return { ok: true, resolved };
|
|
1560
|
+
}
|
|
1561
|
+
function resolveProjectRootRequest(startupRoot, requestedRoot, allowArbitraryAbsolute) {
|
|
1562
|
+
if (allowArbitraryAbsolute && path32.isAbsolute(requestedRoot)) {
|
|
1563
|
+
return { ok: true, resolved: path32.resolve(requestedRoot) };
|
|
1564
|
+
}
|
|
1565
|
+
return resolveWithinBoundary(startupRoot, requestedRoot);
|
|
1566
|
+
}
|
|
1567
|
+
var __filename = fileURLToPath(import.meta.url);
|
|
1568
|
+
var __dirname = path42.dirname(__filename);
|
|
1569
|
+
function createOmniServer(options = {}) {
|
|
1570
|
+
const {
|
|
1571
|
+
port = 4321,
|
|
1572
|
+
host = "localhost",
|
|
1573
|
+
dbPath = ":memory:",
|
|
1574
|
+
projectRoot = process.cwd(),
|
|
1575
|
+
projectMeta,
|
|
1576
|
+
detectProjectMeta,
|
|
1577
|
+
uiDistPath = path42.resolve(__dirname, "../../ui/dist"),
|
|
1578
|
+
corsOrigin = `http://localhost:${port}`,
|
|
1579
|
+
accessToken,
|
|
1580
|
+
secureCookies,
|
|
1581
|
+
sessionTtlMs = 15 * 60 * 1e3,
|
|
1582
|
+
maxSessions = 128
|
|
1583
|
+
} = options;
|
|
1584
|
+
const app = express();
|
|
1585
|
+
app.disable("x-powered-by");
|
|
1586
|
+
app.use((_req, res, next) => {
|
|
1587
|
+
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
1588
|
+
res.setHeader("X-Frame-Options", "DENY");
|
|
1589
|
+
res.setHeader("Referrer-Policy", "no-referrer");
|
|
1590
|
+
res.setHeader("X-DNS-Prefetch-Control", "off");
|
|
1591
|
+
next();
|
|
1592
|
+
});
|
|
1593
|
+
app.locals.projectRoot = path42.resolve(projectRoot);
|
|
1594
|
+
app.locals.dbPath = dbPath;
|
|
1595
|
+
app.use(cors({ origin: corsOrigin, credentials: true }));
|
|
1596
|
+
app.use(express.json());
|
|
1597
|
+
const db = new OmniDatabase(dbPath);
|
|
1598
|
+
const incrementalAnalyzer = new IncrementalAnalyzer({
|
|
1599
|
+
projectRoot,
|
|
1600
|
+
dbPath,
|
|
1601
|
+
db,
|
|
1602
|
+
projectMeta
|
|
1603
|
+
});
|
|
1604
|
+
const sessions = new SessionStore({ ttlMs: sessionTtlMs, maxSessions });
|
|
1605
|
+
const accessPolicy = createAccessPolicy({
|
|
1606
|
+
host,
|
|
1607
|
+
accessToken,
|
|
1608
|
+
sessions,
|
|
1609
|
+
secureCookies: secureCookies ?? (Array.isArray(corsOrigin) ? corsOrigin.some((origin) => origin.startsWith("https://")) : corsOrigin.startsWith("https://"))
|
|
1610
|
+
});
|
|
1611
|
+
const accessGuard = createAccessGuard(accessPolicy);
|
|
1612
|
+
const allowArbitraryAbsoluteProjectRoots = isLoopbackHost(host);
|
|
1613
|
+
app.post("/api/session", createSessionHandler(accessPolicy));
|
|
1614
|
+
const graphRouter = createGraphRouter(db, void 0, () => app.locals.projectRoot);
|
|
1615
|
+
app.use("/api/graph", accessGuard, graphRouter);
|
|
1616
|
+
app.use("/api/tests", accessGuard, createTestsRouter(db));
|
|
1617
|
+
app.get("/api/health", (_req, res) => {
|
|
1618
|
+
res.json({ status: "ok", timestamp: Date.now() });
|
|
1619
|
+
});
|
|
1620
|
+
app.get("/api/status", accessGuard, (_req, res) => {
|
|
1621
|
+
res.json({ data: incrementalAnalyzer.getStatus(), meta: {} });
|
|
1622
|
+
});
|
|
1623
|
+
app.get("/api/project", accessGuard, (_req, res) => {
|
|
1624
|
+
res.json({ data: { projectRoot: app.locals.projectRoot }, meta: {} });
|
|
1625
|
+
});
|
|
1626
|
+
app.post("/api/analyze", accessGuard, async (_req, res) => {
|
|
1627
|
+
try {
|
|
1628
|
+
await incrementalAnalyzer.refresh();
|
|
1629
|
+
res.json({ data: { success: true, status: incrementalAnalyzer.getStatus() }, meta: {} });
|
|
1630
|
+
} catch (err) {
|
|
1631
|
+
console.error("Analysis failed:", err);
|
|
1632
|
+
res.status(500).json({
|
|
1633
|
+
error: {
|
|
1634
|
+
code: "ANALYSIS_FAILED",
|
|
1635
|
+
message: err instanceof Error ? err.message : String(err)
|
|
1636
|
+
}
|
|
1637
|
+
});
|
|
1638
|
+
}
|
|
1639
|
+
});
|
|
1640
|
+
app.post("/api/project", accessGuard, async (req, res) => {
|
|
1641
|
+
const body = req.body;
|
|
1642
|
+
const projectRootInput = isJsonObject(body) ? body.projectRoot : void 0;
|
|
1643
|
+
if (typeof projectRootInput !== "string" || projectRootInput.trim() === "") {
|
|
1644
|
+
res.status(400).json({
|
|
1645
|
+
error: { code: "INVALID_INPUT", message: "projectRoot must be a non-empty string" }
|
|
1646
|
+
});
|
|
1647
|
+
return;
|
|
1648
|
+
}
|
|
1649
|
+
const boundaryRoot = path42.resolve(projectRoot);
|
|
1650
|
+
const { ok, resolved } = resolveProjectRootRequest(
|
|
1651
|
+
boundaryRoot,
|
|
1652
|
+
projectRootInput.trim(),
|
|
1653
|
+
allowArbitraryAbsoluteProjectRoots
|
|
1654
|
+
);
|
|
1655
|
+
if (!ok) {
|
|
1656
|
+
res.status(400).json({
|
|
1657
|
+
error: {
|
|
1658
|
+
code: "PATH_TRAVERSAL",
|
|
1659
|
+
message: `projectRoot escapes the allowed boundary: ${boundaryRoot}`
|
|
1660
|
+
}
|
|
1661
|
+
});
|
|
1662
|
+
return;
|
|
1663
|
+
}
|
|
1664
|
+
if (!fs3.existsSync(resolved) || !fs3.statSync(resolved).isDirectory()) {
|
|
1665
|
+
res.status(400).json({
|
|
1666
|
+
error: { code: "INVALID_PROJECT_ROOT", message: `Not an existing directory: ${resolved}` }
|
|
1667
|
+
});
|
|
1668
|
+
return;
|
|
1669
|
+
}
|
|
1670
|
+
let targetProjectMeta;
|
|
1671
|
+
try {
|
|
1672
|
+
targetProjectMeta = await detectProjectMeta?.(resolved);
|
|
1673
|
+
} catch (err) {
|
|
1674
|
+
console.error("Failed to detect target project metadata:", err);
|
|
1675
|
+
res.status(500).json({
|
|
1676
|
+
error: {
|
|
1677
|
+
code: "PROJECT_DETECTION_FAILED",
|
|
1678
|
+
message: "Failed to detect the target project structure"
|
|
1679
|
+
}
|
|
1680
|
+
});
|
|
1681
|
+
return;
|
|
1682
|
+
}
|
|
1683
|
+
try {
|
|
1684
|
+
await incrementalAnalyzer.setProjectRoot(resolved, targetProjectMeta);
|
|
1685
|
+
app.locals.projectRoot = resolved;
|
|
1686
|
+
res.json({
|
|
1687
|
+
data: { projectRoot: resolved, status: incrementalAnalyzer.getStatus() },
|
|
1688
|
+
meta: {}
|
|
1689
|
+
});
|
|
1690
|
+
} catch (err) {
|
|
1691
|
+
console.error("Failed to switch project root:", err);
|
|
1692
|
+
res.status(500).json({
|
|
1693
|
+
error: {
|
|
1694
|
+
code: "PROJECT_SWITCH_FAILED",
|
|
1695
|
+
message: "Failed to switch project"
|
|
1696
|
+
}
|
|
1697
|
+
});
|
|
1698
|
+
}
|
|
1699
|
+
});
|
|
1700
|
+
registerAiRoutes(app, void 0, accessGuard, { allowLoopback: accessPolicy.loopback });
|
|
1701
|
+
app.use(express.static(uiDistPath));
|
|
1702
|
+
app.get("*", (_req, res) => {
|
|
1703
|
+
res.sendFile(path42.join(uiDistPath, "index.html"));
|
|
1704
|
+
});
|
|
1705
|
+
const server = createHttpServer(app);
|
|
1706
|
+
const wsOriginAllowlist = toOriginAllowlist(corsOrigin);
|
|
1707
|
+
const wss = new WebSocketServer({
|
|
1708
|
+
server,
|
|
1709
|
+
path: "/ws",
|
|
1710
|
+
verifyClient: (info, done) => {
|
|
1711
|
+
const origin = info.origin;
|
|
1712
|
+
if (!isOriginAllowed(origin, wsOriginAllowlist)) {
|
|
1713
|
+
console.warn(`WebSocket upgrade rejected: disallowed Origin "${origin ?? ""}"`);
|
|
1714
|
+
done(false, 403, "Forbidden Origin");
|
|
1715
|
+
return;
|
|
1716
|
+
}
|
|
1717
|
+
const decision = authenticateHeaders(info.req.headers, accessPolicy);
|
|
1718
|
+
if (!decision.ok) {
|
|
1719
|
+
console.warn(`WebSocket upgrade rejected: ${decision.code}`);
|
|
1720
|
+
done(false, decision.status, decision.code);
|
|
1721
|
+
return;
|
|
1722
|
+
}
|
|
1723
|
+
done(true);
|
|
1724
|
+
}
|
|
1725
|
+
});
|
|
1726
|
+
const clients = /* @__PURE__ */ new Set();
|
|
1727
|
+
wss.on("connection", (ws) => {
|
|
1728
|
+
clients.add(ws);
|
|
1729
|
+
console.log(`WebSocket client connected. Total: ${clients.size}`);
|
|
1730
|
+
ws.on("close", () => {
|
|
1731
|
+
clients.delete(ws);
|
|
1732
|
+
console.log(`WebSocket client disconnected. Total: ${clients.size}`);
|
|
1733
|
+
});
|
|
1734
|
+
ws.on("error", (err) => {
|
|
1735
|
+
console.error("WebSocket error:", err);
|
|
1736
|
+
clients.delete(ws);
|
|
1737
|
+
});
|
|
1738
|
+
});
|
|
1739
|
+
wss.on("error", (err) => {
|
|
1740
|
+
console.error("WebSocketServer error:", err);
|
|
1741
|
+
});
|
|
1742
|
+
function broadcastGraphUpdate() {
|
|
1743
|
+
try {
|
|
1744
|
+
const graph = db.loadGraph();
|
|
1745
|
+
const message = JSON.stringify({
|
|
1746
|
+
type: "graph_updated",
|
|
1747
|
+
payload: graph,
|
|
1748
|
+
timestamp: Date.now()
|
|
1749
|
+
});
|
|
1750
|
+
for (const client of clients) {
|
|
1751
|
+
if (client.readyState === WebSocket.OPEN) {
|
|
1752
|
+
client.send(message);
|
|
1753
|
+
}
|
|
1754
|
+
}
|
|
1755
|
+
} catch (err) {
|
|
1756
|
+
console.error("Failed to broadcast graph update:", err);
|
|
1757
|
+
}
|
|
1758
|
+
}
|
|
1759
|
+
const onGraphUpdated = () => {
|
|
1760
|
+
broadcastGraphUpdate();
|
|
1761
|
+
};
|
|
1762
|
+
codeomnivisEvents.on(EVENTS.GRAPH_UPDATED, onGraphUpdated);
|
|
1763
|
+
function broadcastStatus(status) {
|
|
1764
|
+
const message = JSON.stringify({
|
|
1765
|
+
type: "status_changed",
|
|
1766
|
+
payload: status,
|
|
1767
|
+
timestamp: Date.now()
|
|
1768
|
+
});
|
|
1769
|
+
for (const client of clients) {
|
|
1770
|
+
if (client.readyState === WebSocket.OPEN) {
|
|
1771
|
+
client.send(message);
|
|
1772
|
+
}
|
|
1773
|
+
}
|
|
1774
|
+
}
|
|
1775
|
+
const onStatusChanged = (status) => {
|
|
1776
|
+
broadcastStatus(status);
|
|
1777
|
+
};
|
|
1778
|
+
codeomnivisEvents.on(EVENTS.STATUS_CHANGED, onStatusChanged);
|
|
1779
|
+
let startState = "idle";
|
|
1780
|
+
let tearingDown = false;
|
|
1781
|
+
const handleExit = (signal) => {
|
|
1782
|
+
if (tearingDown) return;
|
|
1783
|
+
tearingDown = true;
|
|
1784
|
+
console.log(`Received ${signal}, releasing resources...`);
|
|
1785
|
+
void stop().then(() => process.exit(0)).catch((err) => {
|
|
1786
|
+
console.error("Teardown failed:", err);
|
|
1787
|
+
process.exit(1);
|
|
1788
|
+
});
|
|
1789
|
+
};
|
|
1790
|
+
async function start() {
|
|
1791
|
+
if (startState !== "idle") {
|
|
1792
|
+
throw new Error(`server.start() cannot run in state '${startState}'`);
|
|
1793
|
+
}
|
|
1794
|
+
startState = "starting";
|
|
1795
|
+
await db.ready();
|
|
1796
|
+
incrementalAnalyzer.start();
|
|
1797
|
+
process.on("SIGINT", handleExit);
|
|
1798
|
+
process.on("SIGTERM", handleExit);
|
|
1799
|
+
return new Promise((resolve22, reject) => {
|
|
1800
|
+
const onError = (err) => {
|
|
1801
|
+
server.removeListener("listening", onListening);
|
|
1802
|
+
process.removeListener("SIGINT", handleExit);
|
|
1803
|
+
process.removeListener("SIGTERM", handleExit);
|
|
1804
|
+
startState = "stopped";
|
|
1805
|
+
reject(err);
|
|
1806
|
+
void incrementalAnalyzer.stop().catch(() => {
|
|
1807
|
+
});
|
|
1808
|
+
};
|
|
1809
|
+
const onListening = () => {
|
|
1810
|
+
server.removeListener("error", onError);
|
|
1811
|
+
startState = "listening";
|
|
1812
|
+
console.log(`CodeOmniVis server running at http://${host}:${port}`);
|
|
1813
|
+
console.log(`WebSocket available at ws://${host}:${port}/ws`);
|
|
1814
|
+
resolve22();
|
|
1815
|
+
};
|
|
1816
|
+
server.once("error", onError);
|
|
1817
|
+
server.once("listening", onListening);
|
|
1818
|
+
server.listen(port, host);
|
|
1819
|
+
});
|
|
1820
|
+
}
|
|
1821
|
+
async function analyze(onFilesCollected) {
|
|
1822
|
+
await incrementalAnalyzer.refresh(onFilesCollected);
|
|
1823
|
+
}
|
|
1824
|
+
async function stop() {
|
|
1825
|
+
process.removeListener("SIGINT", handleExit);
|
|
1826
|
+
process.removeListener("SIGTERM", handleExit);
|
|
1827
|
+
await incrementalAnalyzer.stop();
|
|
1828
|
+
for (const client of clients) {
|
|
1829
|
+
client.close();
|
|
1830
|
+
}
|
|
1831
|
+
clients.clear();
|
|
1832
|
+
await new Promise((resolve22) => {
|
|
1833
|
+
wss.close(() => resolve22());
|
|
1834
|
+
});
|
|
1835
|
+
codeomnivisEvents.off(EVENTS.GRAPH_UPDATED, onGraphUpdated);
|
|
1836
|
+
codeomnivisEvents.off(EVENTS.STATUS_CHANGED, onStatusChanged);
|
|
1837
|
+
db.close();
|
|
1838
|
+
sessions.dispose();
|
|
1839
|
+
const wasListening = startState === "listening";
|
|
1840
|
+
startState = "stopped";
|
|
1841
|
+
if (!wasListening) {
|
|
1842
|
+
return;
|
|
1843
|
+
}
|
|
1844
|
+
return new Promise((resolve22, reject) => {
|
|
1845
|
+
server.close((err) => {
|
|
1846
|
+
if (err) reject(err);
|
|
1847
|
+
else resolve22();
|
|
1848
|
+
});
|
|
1849
|
+
});
|
|
1850
|
+
}
|
|
1851
|
+
return { app, server, db, analyze, start, stop };
|
|
1852
|
+
}
|
|
1853
|
+
|
|
1854
|
+
// src/commands/serve.ts
|
|
1855
|
+
function resolveUiDistPath() {
|
|
1856
|
+
const here = path6.dirname(fileURLToPath2(import.meta.url));
|
|
1857
|
+
const bundled = path6.join(here, "ui");
|
|
1858
|
+
if (fs5.existsSync(bundled)) return bundled;
|
|
1859
|
+
return void 0;
|
|
1860
|
+
}
|
|
1861
|
+
function serveCommand(program) {
|
|
1862
|
+
program.command("serve").description("Start CodeOmniVis server and visualize your project").option("-p, --port <port>", "Server port", "4321").option("-h, --host <host>", "Server host", "localhost").option("--project <path>", "Project root path", ".").option("--no-open", "Do not open browser automatically").option("--token <token>", "Access token required for mutating endpoints when binding to a non-loopback host").action(async (options) => {
|
|
1863
|
+
const spinner = ora4("Starting CodeOmniVis server...").start();
|
|
1864
|
+
try {
|
|
1865
|
+
spinner.text = "Detecting project structure...";
|
|
1866
|
+
const projectRoot = validateProjectRoot(options.project ?? ".");
|
|
1867
|
+
const config = loadConfig(projectRoot);
|
|
1868
|
+
const projectMeta = await autoDetectProject(projectRoot, config);
|
|
1869
|
+
const configPath = path6.join(projectRoot, ".codeomnivis.json");
|
|
1870
|
+
if (fs5.existsSync(configPath)) {
|
|
1871
|
+
console.log(chalk4.gray("Configuration loaded from .codeomnivis.json"));
|
|
1872
|
+
}
|
|
1873
|
+
const accessToken = options.token ?? process.env.CODEOMNIVIS_TOKEN;
|
|
1874
|
+
if (!isLoopbackHost(options.host) && (accessToken === void 0 || accessToken === "")) {
|
|
1875
|
+
spinner.fail(chalk4.red("Refusing to bind to a non-loopback host without an access token"));
|
|
1876
|
+
console.error(
|
|
1877
|
+
chalk4.yellow(
|
|
1878
|
+
`Host "${options.host}" is not loopback. Provide --token <token> or set CODEOMNIVIS_TOKEN to protect mutating endpoints (/api/analyze, /api/project, DELETE /api/graph).`
|
|
1879
|
+
)
|
|
1880
|
+
);
|
|
1881
|
+
process.exit(1);
|
|
1882
|
+
}
|
|
1883
|
+
spinner.text = "Starting server...";
|
|
1884
|
+
const dbPath = getDbPath(projectRoot);
|
|
1885
|
+
const server = createOmniServer({
|
|
1886
|
+
port: parseInt(options.port, 10),
|
|
1887
|
+
host: options.host,
|
|
1888
|
+
dbPath,
|
|
1889
|
+
projectRoot,
|
|
1890
|
+
projectMeta,
|
|
1891
|
+
detectProjectMeta: async (root) => autoDetectProject(root, loadConfig(root)),
|
|
1892
|
+
uiDistPath: resolveUiDistPath(),
|
|
1893
|
+
accessToken
|
|
1894
|
+
});
|
|
1895
|
+
await server.start();
|
|
1896
|
+
spinner.text = "Analyzing project...";
|
|
1897
|
+
let filesScanned = 0;
|
|
1898
|
+
await server.analyze((count) => {
|
|
1899
|
+
filesScanned = count;
|
|
1900
|
+
spinner.text = `Analyzing ${count} files...`;
|
|
1901
|
+
});
|
|
1902
|
+
console.log(chalk4.gray(`
|
|
1903
|
+
Scanned ${filesScanned} files.`));
|
|
1904
|
+
const finalGraph = server.db.loadGraph();
|
|
1905
|
+
spinner.succeed(chalk4.green(`Server running at http://${options.host}:${options.port}`));
|
|
1906
|
+
console.log("");
|
|
1907
|
+
console.log(chalk4.blue("Analysis results:"));
|
|
1908
|
+
console.log(` Files scanned: ${filesScanned}`);
|
|
1909
|
+
console.log(` Nodes: ${finalGraph.nodes.length}`);
|
|
1910
|
+
console.log(` Edges: ${finalGraph.edges.length}`);
|
|
1911
|
+
const countEdge = (type) => finalGraph.edges.filter((edge) => edge.type === type).length;
|
|
1912
|
+
const crossLayerEdges = ["calls_api", "handles", "calls_service", "queries_db"].reduce((count, type) => count + countEdge(type), 0);
|
|
1913
|
+
if (crossLayerEdges > 0) {
|
|
1914
|
+
console.log("");
|
|
1915
|
+
console.log(chalk4.blue("Cross-layer links:"));
|
|
1916
|
+
console.log(` calls_api: ${countEdge("calls_api")}`);
|
|
1917
|
+
console.log(` handles: ${countEdge("handles")}`);
|
|
1918
|
+
console.log(` calls_service: ${countEdge("calls_service")}`);
|
|
1919
|
+
console.log(` queries_db: ${countEdge("queries_db")}`);
|
|
1920
|
+
}
|
|
1921
|
+
const nodesByType = finalGraph.nodes.reduce((counts, node) => {
|
|
1922
|
+
counts[node.type] = (counts[node.type] ?? 0) + 1;
|
|
1923
|
+
return counts;
|
|
1924
|
+
}, {});
|
|
1925
|
+
if (Object.keys(nodesByType).length > 0) {
|
|
1926
|
+
console.log("");
|
|
1927
|
+
console.log(chalk4.blue("Node types:"));
|
|
1928
|
+
for (const [type, count] of Object.entries(nodesByType)) {
|
|
1929
|
+
console.log(` ${type}: ${count}`);
|
|
1930
|
+
}
|
|
1931
|
+
}
|
|
1932
|
+
const errorCount = server.db.getAllErrors().length;
|
|
1933
|
+
if (errorCount > 0) {
|
|
1934
|
+
console.log(`
|
|
1935
|
+
Errors: ${errorCount}`);
|
|
1936
|
+
}
|
|
1937
|
+
console.log("");
|
|
1938
|
+
console.log(chalk4.blue("Project detected:"));
|
|
1939
|
+
console.log(` Frontend: ${projectMeta.frontendFramework}`);
|
|
1940
|
+
console.log(` Backend: ${projectMeta.backendFramework}`);
|
|
1941
|
+
console.log(` Database: ${projectMeta.databaseType}`);
|
|
1942
|
+
if (options.open) {
|
|
1943
|
+
const open = (await import("open")).default;
|
|
1944
|
+
await open(`http://${options.host}:${options.port}`);
|
|
1945
|
+
console.log(chalk4.gray("\nBrowser opened automatically"));
|
|
1946
|
+
}
|
|
1947
|
+
console.log(chalk4.gray("\nPress Ctrl+C to stop the server"));
|
|
1948
|
+
process.on("SIGINT", async () => {
|
|
1949
|
+
console.log(chalk4.gray("\nShutting down..."));
|
|
1950
|
+
await server.stop();
|
|
1951
|
+
process.exit(0);
|
|
1952
|
+
});
|
|
1953
|
+
await new Promise(() => {
|
|
1954
|
+
});
|
|
1955
|
+
} catch (err) {
|
|
1956
|
+
spinner.fail(chalk4.red("Failed to start server"));
|
|
1957
|
+
console.error(err);
|
|
1958
|
+
process.exit(1);
|
|
1959
|
+
}
|
|
1960
|
+
});
|
|
1961
|
+
}
|
|
1962
|
+
|
|
1963
|
+
// src/commands/testImport.ts
|
|
1964
|
+
import * as fs6 from "fs";
|
|
1965
|
+
import * as path7 from "path";
|
|
1966
|
+
import { randomUUID } from "crypto";
|
|
1967
|
+
import fg from "fast-glob";
|
|
1968
|
+
function inside(root, candidate) {
|
|
1969
|
+
const relative4 = path7.relative(root, candidate);
|
|
1970
|
+
return relative4 === "" || !relative4.startsWith("..") && !path7.isAbsolute(relative4);
|
|
1971
|
+
}
|
|
1972
|
+
function resolveInputs(projectRoot, pattern) {
|
|
1973
|
+
const resolvedPattern = path7.resolve(projectRoot, pattern);
|
|
1974
|
+
if (!inside(projectRoot, resolvedPattern))
|
|
1975
|
+
throw new Error("JUnit XML inputs must be inside the project");
|
|
1976
|
+
const files = fg.sync(pattern, {
|
|
1977
|
+
cwd: projectRoot,
|
|
1978
|
+
absolute: true,
|
|
1979
|
+
onlyFiles: true,
|
|
1980
|
+
followSymbolicLinks: false
|
|
1981
|
+
});
|
|
1982
|
+
const realRoot = fs6.realpathSync.native(projectRoot);
|
|
1983
|
+
for (const file of files) {
|
|
1984
|
+
if (!inside(realRoot, fs6.realpathSync.native(file)))
|
|
1985
|
+
throw new Error("JUnit XML inputs must be inside the project");
|
|
1986
|
+
}
|
|
1987
|
+
if (files.length === 0) throw new Error("No JUnit XML files matched");
|
|
1988
|
+
return files.sort();
|
|
1989
|
+
}
|
|
1990
|
+
async function runTestImport(options) {
|
|
1991
|
+
const projectRoot = path7.resolve(options.project);
|
|
1992
|
+
if (!fs6.statSync(projectRoot).isDirectory()) throw new Error("Project root must be a directory");
|
|
1993
|
+
const files = resolveInputs(projectRoot, options.junit);
|
|
1994
|
+
const db = new OmniDatabase(getDbPath(projectRoot));
|
|
1995
|
+
try {
|
|
1996
|
+
await db.ready();
|
|
1997
|
+
const snapshot = db.loadSnapshot();
|
|
1998
|
+
if (!snapshot) throw new Error("No committed project snapshot found; run analyze first");
|
|
1999
|
+
const imports = files.map((file) => importJunitXml(file, snapshot));
|
|
2000
|
+
const updated = {
|
|
2001
|
+
...snapshot,
|
|
2002
|
+
snapshotId: randomUUID(),
|
|
2003
|
+
snapshotDigest: "",
|
|
2004
|
+
provenance: {
|
|
2005
|
+
...snapshot.provenance,
|
|
2006
|
+
generatedAt: Date.now(),
|
|
2007
|
+
testRuns: [...snapshot.provenance.testRuns ?? [], ...imports]
|
|
2008
|
+
}
|
|
2009
|
+
};
|
|
2010
|
+
updated.snapshotDigest = computeSnapshotDigest(updated);
|
|
2011
|
+
db.replaceSnapshot(updated);
|
|
2012
|
+
return {
|
|
2013
|
+
importedFiles: files.length,
|
|
2014
|
+
cases: imports.reduce((sum, item) => sum + item.cases.length, 0),
|
|
2015
|
+
unmatched: imports.reduce((sum, item) => sum + item.unmatched.length, 0)
|
|
2016
|
+
};
|
|
2017
|
+
} finally {
|
|
2018
|
+
db.close();
|
|
2019
|
+
}
|
|
2020
|
+
}
|
|
2021
|
+
function testImportCommand(program) {
|
|
2022
|
+
program.command("test-import").description("Import existing JUnit XML results without running tests").requiredOption("-p, --project <path>", "Project root").requiredOption("--junit <file-or-glob>", "JUnit XML file or glob inside the project").action(async (options) => {
|
|
2023
|
+
const result = await runTestImport(options);
|
|
2024
|
+
process.stdout.write(
|
|
2025
|
+
`Imported ${result.cases} case result(s) from ${result.importedFiles} file(s); ${result.unmatched} unmatched.
|
|
2026
|
+
`
|
|
2027
|
+
);
|
|
2028
|
+
});
|
|
2029
|
+
}
|
|
2030
|
+
|
|
2031
|
+
// src/commands/testRun.ts
|
|
2032
|
+
import * as path9 from "path";
|
|
2033
|
+
|
|
2034
|
+
// src/utils/testRunner.ts
|
|
2035
|
+
import * as fs7 from "fs";
|
|
2036
|
+
import * as path8 from "path";
|
|
2037
|
+
import { spawn } from "child_process";
|
|
2038
|
+
var MAX_OUTPUT_BYTES = 10 * 1024 * 1024;
|
|
2039
|
+
var MIN_TIMEOUT_MS = 1e3;
|
|
2040
|
+
var MAX_TIMEOUT_MS = 30 * 60 * 1e3;
|
|
2041
|
+
function isInside(root, candidate) {
|
|
2042
|
+
const relative4 = path8.relative(root, candidate);
|
|
2043
|
+
return relative4 === "" || !relative4.startsWith("..") && !path8.isAbsolute(relative4);
|
|
2044
|
+
}
|
|
2045
|
+
function validateArgs(root, args) {
|
|
2046
|
+
for (const argument of args) {
|
|
2047
|
+
if (argument.includes("\0")) throw new Error("Runner arguments cannot contain NUL");
|
|
2048
|
+
const possiblePath = argument.includes("=") ? argument.slice(argument.indexOf("=") + 1) : argument;
|
|
2049
|
+
if (path8.isAbsolute(possiblePath) && !isInside(root, path8.resolve(possiblePath))) {
|
|
2050
|
+
throw new Error("Runner argument path is outside the project");
|
|
2051
|
+
}
|
|
2052
|
+
}
|
|
2053
|
+
}
|
|
2054
|
+
function createTestRunPlan(request) {
|
|
2055
|
+
const root = path8.resolve(request.projectRoot);
|
|
2056
|
+
if (!fs7.statSync(root).isDirectory()) throw new Error("Project root must be a directory");
|
|
2057
|
+
if (!Number.isInteger(request.timeoutMs) || request.timeoutMs < MIN_TIMEOUT_MS || request.timeoutMs > MAX_TIMEOUT_MS) {
|
|
2058
|
+
throw new Error("Test timeout must be between 1000 and 1800000 milliseconds");
|
|
2059
|
+
}
|
|
2060
|
+
validateArgs(root, request.extraArgs);
|
|
2061
|
+
let command = "pnpm";
|
|
2062
|
+
let args;
|
|
2063
|
+
switch (request.runner) {
|
|
2064
|
+
case "vitest":
|
|
2065
|
+
args = ["exec", "vitest", "--run", ...request.extraArgs];
|
|
2066
|
+
break;
|
|
2067
|
+
case "jest":
|
|
2068
|
+
args = ["exec", "jest", "--runInBand", ...request.extraArgs];
|
|
2069
|
+
break;
|
|
2070
|
+
case "playwright":
|
|
2071
|
+
args = ["exec", "playwright", "test", ...request.extraArgs];
|
|
2072
|
+
break;
|
|
2073
|
+
case "cypress":
|
|
2074
|
+
args = ["exec", "cypress", "run", ...request.extraArgs];
|
|
2075
|
+
break;
|
|
2076
|
+
case "gradle": {
|
|
2077
|
+
command = path8.join(root, process.platform === "win32" ? "gradlew.bat" : "gradlew");
|
|
2078
|
+
if (!fs7.existsSync(command))
|
|
2079
|
+
throw new Error("Validated Gradle wrapper was not found in the project root");
|
|
2080
|
+
args = ["test", ...request.extraArgs];
|
|
2081
|
+
break;
|
|
2082
|
+
}
|
|
2083
|
+
default:
|
|
2084
|
+
throw new Error(`Unsupported test runner: ${String(request.runner)}`);
|
|
2085
|
+
}
|
|
2086
|
+
return { command, args, options: { cwd: root, shell: false, env: process.env } };
|
|
2087
|
+
}
|
|
2088
|
+
async function runTestRunner(request) {
|
|
2089
|
+
const plan = createTestRunPlan(request);
|
|
2090
|
+
return new Promise((resolve9) => {
|
|
2091
|
+
const child = spawn(plan.command, plan.args, {
|
|
2092
|
+
...plan.options,
|
|
2093
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
2094
|
+
});
|
|
2095
|
+
let stdout = Buffer.alloc(0);
|
|
2096
|
+
let stderr = Buffer.alloc(0);
|
|
2097
|
+
let truncated = false;
|
|
2098
|
+
let timedOut = false;
|
|
2099
|
+
let settled = false;
|
|
2100
|
+
const append = (current, chunk) => {
|
|
2101
|
+
if (current.length >= MAX_OUTPUT_BYTES) {
|
|
2102
|
+
truncated = true;
|
|
2103
|
+
return current;
|
|
2104
|
+
}
|
|
2105
|
+
const available = MAX_OUTPUT_BYTES - current.length;
|
|
2106
|
+
if (chunk.length > available) truncated = true;
|
|
2107
|
+
return Buffer.concat([current, chunk.subarray(0, available)]);
|
|
2108
|
+
};
|
|
2109
|
+
child.stdout.on("data", (chunk) => {
|
|
2110
|
+
stdout = append(stdout, chunk);
|
|
2111
|
+
});
|
|
2112
|
+
child.stderr.on("data", (chunk) => {
|
|
2113
|
+
stderr = append(stderr, chunk);
|
|
2114
|
+
});
|
|
2115
|
+
const timeout = setTimeout(() => {
|
|
2116
|
+
timedOut = true;
|
|
2117
|
+
child.kill("SIGTERM");
|
|
2118
|
+
const force = setTimeout(() => child.kill("SIGKILL"), 2e3);
|
|
2119
|
+
force.unref();
|
|
2120
|
+
}, request.timeoutMs);
|
|
2121
|
+
timeout.unref();
|
|
2122
|
+
const finish = (exitCode, signal) => {
|
|
2123
|
+
if (settled) return;
|
|
2124
|
+
settled = true;
|
|
2125
|
+
clearTimeout(timeout);
|
|
2126
|
+
resolve9({
|
|
2127
|
+
exitCode,
|
|
2128
|
+
signal,
|
|
2129
|
+
timedOut,
|
|
2130
|
+
stdout: stdout.toString("utf8"),
|
|
2131
|
+
stderr: stderr.toString("utf8"),
|
|
2132
|
+
truncated
|
|
2133
|
+
});
|
|
2134
|
+
};
|
|
2135
|
+
child.once("error", (error) => {
|
|
2136
|
+
stderr = append(stderr, Buffer.from(error.message));
|
|
2137
|
+
finish(null, null);
|
|
2138
|
+
});
|
|
2139
|
+
child.once("close", finish);
|
|
2140
|
+
});
|
|
2141
|
+
}
|
|
2142
|
+
|
|
2143
|
+
// src/commands/testRun.ts
|
|
2144
|
+
var defaultDeps2 = {
|
|
2145
|
+
run: runTestRunner,
|
|
2146
|
+
importResults: runTestImport,
|
|
2147
|
+
stdout: (value) => {
|
|
2148
|
+
process.stdout.write(value);
|
|
2149
|
+
},
|
|
2150
|
+
stderr: (value) => {
|
|
2151
|
+
process.stderr.write(value);
|
|
2152
|
+
}
|
|
2153
|
+
};
|
|
2154
|
+
async function runTestCommand(runnerArgs, options, deps = defaultDeps2) {
|
|
2155
|
+
const projectRoot = path9.resolve(options.project);
|
|
2156
|
+
const request = {
|
|
2157
|
+
projectRoot,
|
|
2158
|
+
runner: options.runner,
|
|
2159
|
+
timeoutMs: Number(options.timeout),
|
|
2160
|
+
extraArgs: runnerArgs
|
|
2161
|
+
};
|
|
2162
|
+
const plan = { cwd: projectRoot, runner: options.runner, args: runnerArgs };
|
|
2163
|
+
deps.stderr(`[codeomnivis] test-run ${JSON.stringify(plan)}
|
|
2164
|
+
`);
|
|
2165
|
+
const result = await deps.run(request);
|
|
2166
|
+
deps.stdout(result.stdout);
|
|
2167
|
+
deps.stderr(result.stderr);
|
|
2168
|
+
if (options.junit) await deps.importResults({ project: projectRoot, junit: options.junit });
|
|
2169
|
+
if (result.exitCode !== 0) process.exitCode = result.exitCode ?? 1;
|
|
2170
|
+
}
|
|
2171
|
+
function testRunCommand(program) {
|
|
2172
|
+
program.command("test-run").description("Explicitly run one supported test runner with resource bounds").requiredOption("-p, --project <path>", "Project root").requiredOption("--runner <runner>", "vitest, jest, playwright, cypress, or gradle").option("--timeout <ms>", "Timeout in milliseconds", "600000").option("--junit <path>", "Import this JUnit XML after the run").argument("[runnerArgs...]").allowUnknownOption().action(async (runnerArgs, options) => {
|
|
2173
|
+
await runTestCommand(runnerArgs, options);
|
|
2174
|
+
});
|
|
2175
|
+
}
|
|
2176
|
+
|
|
2177
|
+
// src/program.ts
|
|
2178
|
+
function readCliVersion() {
|
|
2179
|
+
try {
|
|
2180
|
+
const manifest = JSON.parse(
|
|
2181
|
+
readFileSync(new URL("../package.json", import.meta.url), "utf8")
|
|
2182
|
+
);
|
|
2183
|
+
if (isJsonObject(manifest) && typeof manifest.version === "string") {
|
|
2184
|
+
return manifest.version;
|
|
2185
|
+
}
|
|
2186
|
+
} catch {
|
|
2187
|
+
}
|
|
2188
|
+
return "0.0.0";
|
|
2189
|
+
}
|
|
2190
|
+
function createCliProgram() {
|
|
2191
|
+
const program = new Command();
|
|
2192
|
+
program.name("codeomnivis").description("Full-stack architecture visualizer for TypeScript projects").version(readCliVersion());
|
|
2193
|
+
serveCommand(program);
|
|
2194
|
+
analyzeCommand(program);
|
|
2195
|
+
checkCommand(program);
|
|
2196
|
+
mcpCommand(program);
|
|
2197
|
+
initCommand(program);
|
|
2198
|
+
testImportCommand(program);
|
|
2199
|
+
testRunCommand(program);
|
|
2200
|
+
return program;
|
|
2201
|
+
}
|
|
2202
|
+
|
|
2203
|
+
// src/index.ts
|
|
2204
|
+
function isCliMainModule(entry, moduleUrl) {
|
|
2205
|
+
return entry !== void 0 && pathToFileURL(entry).href === moduleUrl;
|
|
2206
|
+
}
|
|
2207
|
+
async function runCli(program = createCliProgram(), runtime = process, reportError = (message) => console.error(message)) {
|
|
2208
|
+
try {
|
|
2209
|
+
await program.parseAsync();
|
|
2210
|
+
} catch (err) {
|
|
2211
|
+
reportError(err instanceof Error ? err.message : String(err));
|
|
2212
|
+
runtime.exitCode = 1;
|
|
2213
|
+
}
|
|
2214
|
+
}
|
|
2215
|
+
if (isCliMainModule(process.argv[1], import.meta.url)) void runCli();
|
|
2216
|
+
export {
|
|
2217
|
+
isCliMainModule,
|
|
2218
|
+
runCli
|
|
2219
|
+
};
|