structx 1.0.0 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +72 -0
- package/dist/cli.js +260 -211
- package/dist/cli.js.map +1 -1
- package/dist/db/connection.js +28 -1
- package/dist/db/connection.js.map +1 -1
- package/dist/db/queries.d.ts +144 -0
- package/dist/db/queries.d.ts.map +1 -1
- package/dist/db/queries.js +282 -6
- package/dist/db/queries.js.map +1 -1
- package/dist/db/schema.sql +59 -0
- package/dist/ingest/constant-extractor.d.ts +11 -0
- package/dist/ingest/constant-extractor.d.ts.map +1 -0
- package/dist/ingest/constant-extractor.js +38 -0
- package/dist/ingest/constant-extractor.js.map +1 -0
- package/dist/ingest/file-metadata.d.ts +13 -0
- package/dist/ingest/file-metadata.d.ts.map +1 -0
- package/dist/ingest/file-metadata.js +55 -0
- package/dist/ingest/file-metadata.js.map +1 -0
- package/dist/ingest/ingester.d.ts +15 -0
- package/dist/ingest/ingester.d.ts.map +1 -0
- package/dist/ingest/ingester.js +217 -0
- package/dist/ingest/ingester.js.map +1 -0
- package/dist/ingest/parser.d.ts +12 -0
- package/dist/ingest/parser.d.ts.map +1 -1
- package/dist/ingest/parser.js +48 -0
- package/dist/ingest/parser.js.map +1 -1
- package/dist/ingest/route-extractor.d.ts +12 -0
- package/dist/ingest/route-extractor.d.ts.map +1 -0
- package/dist/ingest/route-extractor.js +64 -0
- package/dist/ingest/route-extractor.js.map +1 -0
- package/dist/ingest/type-extractor.d.ts +11 -0
- package/dist/ingest/type-extractor.d.ts.map +1 -0
- package/dist/ingest/type-extractor.js +47 -0
- package/dist/ingest/type-extractor.js.map +1 -0
- package/dist/instructions/claude.md +54 -25
- package/dist/instructions/codex.md +70 -0
- package/dist/instructions/copilot.md +57 -26
- package/dist/instructions/cursor.md +54 -25
- package/dist/instructions/generic.md +54 -25
- package/dist/query/answerer.d.ts.map +1 -1
- package/dist/query/answerer.js +6 -2
- package/dist/query/answerer.js.map +1 -1
- package/dist/query/classifier.d.ts +6 -1
- package/dist/query/classifier.d.ts.map +1 -1
- package/dist/query/classifier.js +22 -2
- package/dist/query/classifier.js.map +1 -1
- package/dist/query/context-builder.d.ts.map +1 -1
- package/dist/query/context-builder.js +152 -6
- package/dist/query/context-builder.js.map +1 -1
- package/dist/query/retriever.d.ts +44 -0
- package/dist/query/retriever.d.ts.map +1 -1
- package/dist/query/retriever.js +211 -14
- package/dist/query/retriever.js.map +1 -1
- package/dist/semantic/analyzer.d.ts +10 -0
- package/dist/semantic/analyzer.d.ts.map +1 -1
- package/dist/semantic/analyzer.js +147 -1
- package/dist/semantic/analyzer.js.map +1 -1
- package/dist/semantic/prompt.d.ts +21 -0
- package/dist/semantic/prompt.d.ts.map +1 -1
- package/dist/semantic/prompt.js +63 -0
- package/dist/semantic/prompt.js.map +1 -1
- package/package.json +1 -1
package/README.md
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# StructX
|
|
2
|
+
|
|
3
|
+
Graph-powered code intelligence for TypeScript. Drop into any project and let AI agents (Claude Code, Cursor, Copilot) use a function-level knowledge graph instead of reading raw files.
|
|
4
|
+
|
|
5
|
+
## Quick Start
|
|
6
|
+
|
|
7
|
+
Two commands to set up any TypeScript project:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
# 1. Install AI agent instruction files into your project
|
|
11
|
+
npx structx install .
|
|
12
|
+
|
|
13
|
+
# 2. Bootstrap the function graph (init + ingest + analyze)
|
|
14
|
+
ANTHROPIC_API_KEY=your-key npx structx setup .
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
That's it. Your AI agent will now automatically use StructX when it reads the instruction files.
|
|
18
|
+
|
|
19
|
+
## What Happens
|
|
20
|
+
|
|
21
|
+
**Step 1 — `npx structx install .`** creates these files in your project:
|
|
22
|
+
|
|
23
|
+
| File | For |
|
|
24
|
+
|------|-----|
|
|
25
|
+
| `CLAUDE.md` | Claude Code |
|
|
26
|
+
| `.cursorrules` | Cursor |
|
|
27
|
+
| `.github/copilot-instructions.md` | GitHub Copilot |
|
|
28
|
+
|
|
29
|
+
If any of these files already exist, StructX appends its section instead of overwriting.
|
|
30
|
+
|
|
31
|
+
**Step 2 — `npx structx setup .`** does three things in one shot:
|
|
32
|
+
|
|
33
|
+
1. **Init** — creates `.structx/` directory with a SQLite database
|
|
34
|
+
2. **Ingest** — parses all TypeScript files into a function graph (signatures, call relationships, exports)
|
|
35
|
+
3. **Analyze** — enriches each function with semantic metadata via LLM (purpose, behavior, tags)
|
|
36
|
+
|
|
37
|
+
## Requirements
|
|
38
|
+
|
|
39
|
+
- Node.js >= 18
|
|
40
|
+
- An Anthropic API key (set as `ANTHROPIC_API_KEY` environment variable)
|
|
41
|
+
|
|
42
|
+
## How AI Agents Use It
|
|
43
|
+
|
|
44
|
+
Once installed, the instruction files tell your AI agent to:
|
|
45
|
+
|
|
46
|
+
1. Run `npx structx status` on session start to check the graph
|
|
47
|
+
2. Run `npx structx ask "question" --repo .` before answering code questions
|
|
48
|
+
3. Run `npx structx ingest .` after making code changes
|
|
49
|
+
4. Run `npx structx analyze . --yes` after ingestion queues new functions
|
|
50
|
+
5. Run `npx structx ask "what breaks if I change X" --repo .` for impact analysis
|
|
51
|
+
|
|
52
|
+
## All Commands
|
|
53
|
+
|
|
54
|
+
| Command | Description |
|
|
55
|
+
|---------|-------------|
|
|
56
|
+
| `npx structx install .` | Drop instruction files into your project |
|
|
57
|
+
| `npx structx setup .` | One-step bootstrap (init + ingest + analyze) |
|
|
58
|
+
| `npx structx status` | Show graph stats |
|
|
59
|
+
| `npx structx ingest .` | Re-parse codebase after changes |
|
|
60
|
+
| `npx structx analyze . --yes` | Run semantic analysis on new/changed functions |
|
|
61
|
+
| `npx structx ask "question" --repo .` | Query the function graph |
|
|
62
|
+
| `npx structx doctor` | Validate environment and configuration |
|
|
63
|
+
|
|
64
|
+
## .gitignore
|
|
65
|
+
|
|
66
|
+
Add this to your `.gitignore`:
|
|
67
|
+
|
|
68
|
+
```
|
|
69
|
+
.structx/
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
The `.structx/` directory contains the SQLite database and is local to each developer.
|
package/dist/cli.js
CHANGED
|
@@ -42,10 +42,6 @@ const config_1 = require("./config");
|
|
|
42
42
|
const connection_1 = require("./db/connection");
|
|
43
43
|
const queries_1 = require("./db/queries");
|
|
44
44
|
const logger_1 = require("./utils/logger");
|
|
45
|
-
const scanner_1 = require("./ingest/scanner");
|
|
46
|
-
const parser_1 = require("./ingest/parser");
|
|
47
|
-
const relationships_1 = require("./ingest/relationships");
|
|
48
|
-
const differ_1 = require("./ingest/differ");
|
|
49
45
|
const analyzer_1 = require("./semantic/analyzer");
|
|
50
46
|
const cost_1 = require("./semantic/cost");
|
|
51
47
|
const queries_2 = require("./db/queries");
|
|
@@ -55,11 +51,12 @@ const context_builder_1 = require("./query/context-builder");
|
|
|
55
51
|
const answerer_1 = require("./query/answerer");
|
|
56
52
|
const runner_1 = require("./benchmark/runner");
|
|
57
53
|
const reporter_1 = require("./benchmark/reporter");
|
|
54
|
+
const ingester_1 = require("./ingest/ingester");
|
|
58
55
|
const program = new commander_1.Command();
|
|
59
56
|
program
|
|
60
57
|
.name('structx')
|
|
61
58
|
.description('Graph-powered code intelligence CLI for TypeScript')
|
|
62
|
-
.version('1.0
|
|
59
|
+
.version('2.1.0')
|
|
63
60
|
.option('--verbose', 'Enable verbose logging')
|
|
64
61
|
.hook('preAction', (thisCommand) => {
|
|
65
62
|
if (thisCommand.opts().verbose) {
|
|
@@ -71,7 +68,8 @@ program
|
|
|
71
68
|
.command('setup')
|
|
72
69
|
.description('One-step bootstrap: init + ingest + analyze')
|
|
73
70
|
.argument('[repo-path]', 'Path to TypeScript repository', '.')
|
|
74
|
-
.
|
|
71
|
+
.option('--api-key <key>', 'Anthropic API key (overrides ANTHROPIC_API_KEY env var)')
|
|
72
|
+
.action(async (repoPath, opts) => {
|
|
75
73
|
const resolved = path.resolve(repoPath);
|
|
76
74
|
const structxDir = (0, config_1.getStructXDir)(resolved);
|
|
77
75
|
// Step 1: Init
|
|
@@ -87,102 +85,14 @@ program
|
|
|
87
85
|
}
|
|
88
86
|
// Step 2: Ingest
|
|
89
87
|
const config = (0, config_1.loadConfig)(structxDir);
|
|
88
|
+
if (opts.apiKey)
|
|
89
|
+
config.anthropicApiKey = opts.apiKey;
|
|
90
90
|
const db = (0, connection_1.openDatabase)(dbPath);
|
|
91
|
-
const project = (0, parser_1.createProject)(resolved);
|
|
92
91
|
console.log(`\nScanning ${resolved} for TypeScript files...`);
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
let newFiles = 0;
|
|
96
|
-
let changedFiles = 0;
|
|
97
|
-
let unchangedFiles = 0;
|
|
98
|
-
let totalFunctions = 0;
|
|
99
|
-
let totalRelationships = 0;
|
|
100
|
-
let queued = 0;
|
|
101
|
-
for (const filePath of files) {
|
|
102
|
-
const relativePath = path.relative(resolved, filePath);
|
|
103
|
-
const content = fs.readFileSync(filePath, 'utf-8');
|
|
104
|
-
const contentHash = (0, parser_1.hashFileContent)(content);
|
|
105
|
-
const existingFile = (0, queries_1.getFileByPath)(db, relativePath);
|
|
106
|
-
if (existingFile && existingFile.content_hash === contentHash) {
|
|
107
|
-
unchangedFiles++;
|
|
108
|
-
continue;
|
|
109
|
-
}
|
|
110
|
-
const isNew = !existingFile;
|
|
111
|
-
if (isNew)
|
|
112
|
-
newFiles++;
|
|
113
|
-
else
|
|
114
|
-
changedFiles++;
|
|
115
|
-
const fileId = (0, queries_1.upsertFile)(db, relativePath, contentHash);
|
|
116
|
-
const oldFunctions = isNew ? [] : (0, queries_1.getFunctionsByFileId)(db, fileId);
|
|
117
|
-
const oldFunctionMap = new Map(oldFunctions.map(f => [f.name, f]));
|
|
118
|
-
if (!isNew) {
|
|
119
|
-
for (const oldFn of oldFunctions) {
|
|
120
|
-
(0, queries_1.deleteRelationshipsByCallerFunctionId)(db, oldFn.id);
|
|
121
|
-
}
|
|
122
|
-
(0, queries_1.deleteFunctionsByFileId)(db, fileId);
|
|
123
|
-
}
|
|
124
|
-
let functions;
|
|
125
|
-
try {
|
|
126
|
-
functions = (0, parser_1.parseFile)(project, filePath);
|
|
127
|
-
}
|
|
128
|
-
catch (err) {
|
|
129
|
-
logger_1.logger.warn(`Failed to parse ${relativePath}: ${err.message}`);
|
|
130
|
-
continue;
|
|
131
|
-
}
|
|
132
|
-
const functionIdMap = new Map();
|
|
133
|
-
for (const fn of functions) {
|
|
134
|
-
const fnId = (0, queries_1.insertFunction)(db, {
|
|
135
|
-
file_id: fileId,
|
|
136
|
-
name: fn.name,
|
|
137
|
-
signature: fn.signature,
|
|
138
|
-
body: fn.body,
|
|
139
|
-
code_hash: fn.codeHash,
|
|
140
|
-
start_line: fn.startLine,
|
|
141
|
-
end_line: fn.endLine,
|
|
142
|
-
is_exported: fn.isExported,
|
|
143
|
-
is_async: fn.isAsync,
|
|
144
|
-
});
|
|
145
|
-
functionIdMap.set(fn.name, fnId);
|
|
146
|
-
totalFunctions++;
|
|
147
|
-
const oldFn = oldFunctionMap.get(fn.name);
|
|
148
|
-
if (!oldFn) {
|
|
149
|
-
(0, queries_1.enqueueForAnalysis)(db, fnId, 'new', (0, differ_1.getPriority)('new', fn.isExported));
|
|
150
|
-
queued++;
|
|
151
|
-
}
|
|
152
|
-
else {
|
|
153
|
-
const { reanalyze, reason } = (0, differ_1.shouldReanalyze)(oldFn, fn.signature, fn.codeHash, fn.body, config.diffThreshold);
|
|
154
|
-
if (reanalyze) {
|
|
155
|
-
(0, queries_1.enqueueForAnalysis)(db, fnId, reason, (0, differ_1.getPriority)(reason, fn.isExported));
|
|
156
|
-
queued++;
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
try {
|
|
161
|
-
const calls = (0, relationships_1.extractCallsFromFile)(project, filePath);
|
|
162
|
-
for (const call of calls) {
|
|
163
|
-
if (call.callerName === '__file__')
|
|
164
|
-
continue;
|
|
165
|
-
const callerId = functionIdMap.get(call.callerName);
|
|
166
|
-
if (!callerId)
|
|
167
|
-
continue;
|
|
168
|
-
const callee = (0, queries_1.getFunctionByName)(db, call.calleeName);
|
|
169
|
-
(0, queries_1.insertRelationship)(db, callerId, call.calleeName, call.relationType, callee?.id);
|
|
170
|
-
totalRelationships++;
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
catch (err) {
|
|
174
|
-
logger_1.logger.warn(`Failed to extract calls from ${relativePath}: ${err.message}`);
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
console.log(`\nIngestion complete:`);
|
|
178
|
-
console.log(` New files: ${newFiles}`);
|
|
179
|
-
console.log(` Changed files: ${changedFiles}`);
|
|
180
|
-
console.log(` Unchanged: ${unchangedFiles}`);
|
|
181
|
-
console.log(` Functions: ${totalFunctions}`);
|
|
182
|
-
console.log(` Relationships: ${totalRelationships}`);
|
|
183
|
-
console.log(` Queued: ${queued} functions for semantic analysis`);
|
|
92
|
+
const ingestResult = (0, ingester_1.ingestDirectory)(db, resolved, config.diffThreshold);
|
|
93
|
+
(0, ingester_1.printIngestResult)(ingestResult);
|
|
184
94
|
// Step 3: Analyze (auto-confirm)
|
|
185
|
-
if (queued > 0 && config.anthropicApiKey) {
|
|
95
|
+
if (ingestResult.queued > 0 && config.anthropicApiKey) {
|
|
186
96
|
const pendingCount = (0, queries_2.getPendingAnalysisCount)(db);
|
|
187
97
|
const estimate = (0, cost_1.estimateAnalysisCost)(pendingCount, config.batchSize, config.analysisModel);
|
|
188
98
|
console.log('\n' + (0, cost_1.formatCostEstimate)(estimate));
|
|
@@ -209,19 +119,47 @@ program
|
|
|
209
119
|
totalOutputTokens += batchResult.totalOutputTokens;
|
|
210
120
|
totalCost += batchResult.totalCost;
|
|
211
121
|
}
|
|
122
|
+
// Analyze types, routes, and file summaries
|
|
123
|
+
console.log('\n Analyzing types, routes, and file summaries...');
|
|
124
|
+
const typeResult = await (0, analyzer_1.analyzeTypes)(db, config.analysisModel, config.anthropicApiKey);
|
|
125
|
+
const routeResult = await (0, analyzer_1.analyzeRoutes)(db, config.analysisModel, config.anthropicApiKey);
|
|
126
|
+
const fileResult = await (0, analyzer_1.analyzeFileSummaries)(db, config.analysisModel, config.anthropicApiKey);
|
|
127
|
+
totalAnalyzed += typeResult.analyzed + routeResult.analyzed + fileResult.analyzed;
|
|
128
|
+
totalFailed += typeResult.failed + routeResult.failed + fileResult.failed;
|
|
129
|
+
totalInputTokens += typeResult.totalInputTokens + routeResult.totalInputTokens + fileResult.totalInputTokens;
|
|
130
|
+
totalOutputTokens += typeResult.totalOutputTokens + routeResult.totalOutputTokens + fileResult.totalOutputTokens;
|
|
131
|
+
totalCost += typeResult.totalCost + routeResult.totalCost + fileResult.totalCost;
|
|
212
132
|
(0, analyzer_1.rebuildSearchIndex)(db);
|
|
213
133
|
console.log(`\nAnalysis complete:`);
|
|
214
|
-
console.log(`
|
|
134
|
+
console.log(` Functions: ${totalAnalyzed - typeResult.analyzed - routeResult.analyzed - fileResult.analyzed}`);
|
|
135
|
+
console.log(` Types: ${typeResult.analyzed}`);
|
|
136
|
+
console.log(` Routes: ${routeResult.analyzed}`);
|
|
137
|
+
console.log(` Files: ${fileResult.analyzed}`);
|
|
215
138
|
console.log(` From cache: ${totalCached}`);
|
|
216
139
|
console.log(` Failed: ${totalFailed}`);
|
|
217
140
|
console.log(` Input tokens: ${totalInputTokens.toLocaleString()}`);
|
|
218
141
|
console.log(` Output tokens: ${totalOutputTokens.toLocaleString()}`);
|
|
219
142
|
console.log(` Total cost: $${totalCost.toFixed(4)}`);
|
|
220
143
|
}
|
|
221
|
-
else if (queued > 0) {
|
|
144
|
+
else if (ingestResult.queued > 0) {
|
|
222
145
|
console.log('\nSkipping analysis: ANTHROPIC_API_KEY not set.');
|
|
223
146
|
console.log('Set the key and run "structx analyze . --yes" to enrich functions.');
|
|
224
147
|
}
|
|
148
|
+
else if (config.anthropicApiKey) {
|
|
149
|
+
// Still analyze types/routes/files even if no new functions
|
|
150
|
+
console.log('\nAnalyzing types, routes, and file summaries...');
|
|
151
|
+
const typeResult = await (0, analyzer_1.analyzeTypes)(db, config.analysisModel, config.anthropicApiKey);
|
|
152
|
+
const routeResult = await (0, analyzer_1.analyzeRoutes)(db, config.analysisModel, config.anthropicApiKey);
|
|
153
|
+
const fileResult = await (0, analyzer_1.analyzeFileSummaries)(db, config.analysisModel, config.anthropicApiKey);
|
|
154
|
+
const entityCount = typeResult.analyzed + routeResult.analyzed + fileResult.analyzed;
|
|
155
|
+
if (entityCount > 0) {
|
|
156
|
+
(0, analyzer_1.rebuildSearchIndex)(db);
|
|
157
|
+
console.log(` Analyzed ${entityCount} entities (${typeResult.analyzed} types, ${routeResult.analyzed} routes, ${fileResult.analyzed} files)`);
|
|
158
|
+
}
|
|
159
|
+
else {
|
|
160
|
+
console.log('No entities to analyze.');
|
|
161
|
+
}
|
|
162
|
+
}
|
|
225
163
|
else {
|
|
226
164
|
console.log('\nNo functions to analyze.');
|
|
227
165
|
}
|
|
@@ -303,6 +241,25 @@ program
|
|
|
303
241
|
console.log(' .github/copilot-instructions.md — created.');
|
|
304
242
|
installed++;
|
|
305
243
|
}
|
|
244
|
+
// AGENTS.md (OpenAI Codex)
|
|
245
|
+
const agentsMdPath = path.join(resolved, 'AGENTS.md');
|
|
246
|
+
const codexContent = fs.readFileSync(path.join(instructionsDir, 'codex.md'), 'utf-8');
|
|
247
|
+
if (fs.existsSync(agentsMdPath)) {
|
|
248
|
+
const existing = fs.readFileSync(agentsMdPath, 'utf-8');
|
|
249
|
+
if (existing.includes('StructX')) {
|
|
250
|
+
console.log(' AGENTS.md — already contains StructX section, skipping.');
|
|
251
|
+
}
|
|
252
|
+
else {
|
|
253
|
+
fs.appendFileSync(agentsMdPath, '\n\n' + codexContent, 'utf-8');
|
|
254
|
+
console.log(' AGENTS.md — appended StructX section.');
|
|
255
|
+
installed++;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
else {
|
|
259
|
+
fs.writeFileSync(agentsMdPath, codexContent, 'utf-8');
|
|
260
|
+
console.log(' AGENTS.md — created.');
|
|
261
|
+
installed++;
|
|
262
|
+
}
|
|
306
263
|
console.log(`\nInstalled ${installed} instruction file(s) into ${resolved}`);
|
|
307
264
|
});
|
|
308
265
|
// ── init ──
|
|
@@ -350,11 +307,105 @@ program
|
|
|
350
307
|
console.log('──────────────────────────');
|
|
351
308
|
console.log(` Files: ${stats.totalFiles}`);
|
|
352
309
|
console.log(` Functions: ${stats.totalFunctions}`);
|
|
310
|
+
console.log(` Types: ${stats.totalTypes}`);
|
|
311
|
+
console.log(` Routes: ${stats.totalRoutes}`);
|
|
312
|
+
console.log(` Constants: ${stats.totalConstants}`);
|
|
353
313
|
console.log(` Relationships: ${stats.totalRelationships}`);
|
|
354
314
|
console.log(` Analyzed: ${stats.analyzedFunctions} / ${stats.totalFunctions}`);
|
|
355
315
|
console.log(` Pending: ${stats.pendingAnalysis}`);
|
|
356
316
|
console.log(` QA Runs: ${stats.totalQaRuns}`);
|
|
357
317
|
});
|
|
318
|
+
// ── overview ──
|
|
319
|
+
program
|
|
320
|
+
.command('overview')
|
|
321
|
+
.description('Full codebase summary in one shot — shows all files, functions, types, routes, and constants')
|
|
322
|
+
.option('--repo <path>', 'Path to TypeScript repository', '.')
|
|
323
|
+
.action((opts) => {
|
|
324
|
+
const resolved = path.resolve(opts.repo);
|
|
325
|
+
const structxDir = (0, config_1.getStructXDir)(resolved);
|
|
326
|
+
const dbPath = (0, connection_1.getDbPath)(structxDir);
|
|
327
|
+
if (!fs.existsSync(dbPath)) {
|
|
328
|
+
console.log('StructX not initialized. Run "structx setup ." first.');
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
const db = (0, connection_1.openDatabase)(dbPath);
|
|
332
|
+
const overview = (0, queries_1.getFullOverview)(db);
|
|
333
|
+
db.close();
|
|
334
|
+
const { stats, files, functions, types, routes, constants } = overview;
|
|
335
|
+
// Header
|
|
336
|
+
console.log('StructX Codebase Overview');
|
|
337
|
+
console.log('═'.repeat(60));
|
|
338
|
+
console.log(` Files: ${stats.totalFiles} | Functions: ${stats.totalFunctions} | Types: ${stats.totalTypes} | Routes: ${stats.totalRoutes} | Constants: ${stats.totalConstants}`);
|
|
339
|
+
console.log(` Relationships: ${stats.totalRelationships} | Analyzed: ${stats.analyzedFunctions}/${stats.totalFunctions}`);
|
|
340
|
+
console.log('');
|
|
341
|
+
// Files section
|
|
342
|
+
if (files.length > 0) {
|
|
343
|
+
console.log('── Files ──');
|
|
344
|
+
for (const f of files) {
|
|
345
|
+
const purpose = f.summary?.purpose ? ` — ${f.summary.purpose}` : '';
|
|
346
|
+
const counts = [];
|
|
347
|
+
if (f.summary) {
|
|
348
|
+
if (f.summary.function_count > 0)
|
|
349
|
+
counts.push(`${f.summary.function_count} fns`);
|
|
350
|
+
if (f.summary.type_count > 0)
|
|
351
|
+
counts.push(`${f.summary.type_count} types`);
|
|
352
|
+
if (f.summary.route_count > 0)
|
|
353
|
+
counts.push(`${f.summary.route_count} routes`);
|
|
354
|
+
counts.push(`${f.summary.loc} LOC`);
|
|
355
|
+
}
|
|
356
|
+
const countsStr = counts.length > 0 ? ` (${counts.join(', ')})` : '';
|
|
357
|
+
console.log(` ${f.path}${countsStr}${purpose}`);
|
|
358
|
+
}
|
|
359
|
+
console.log('');
|
|
360
|
+
}
|
|
361
|
+
// Routes section
|
|
362
|
+
if (routes.length > 0) {
|
|
363
|
+
console.log('── Routes / Endpoints ──');
|
|
364
|
+
for (const r of routes) {
|
|
365
|
+
const purpose = r.purpose ? ` — ${r.purpose}` : '';
|
|
366
|
+
const file = r.filePath.split(/[/\\]/).slice(-1)[0];
|
|
367
|
+
console.log(` ${r.method.toUpperCase().padEnd(7)} ${r.path} [${file}:${r.start_line}]${purpose}`);
|
|
368
|
+
}
|
|
369
|
+
console.log('');
|
|
370
|
+
}
|
|
371
|
+
// Types section
|
|
372
|
+
if (types.length > 0) {
|
|
373
|
+
console.log('── Types & Interfaces ──');
|
|
374
|
+
for (const t of types) {
|
|
375
|
+
const purpose = t.purpose ? ` — ${t.purpose}` : '';
|
|
376
|
+
const exported = t.is_exported ? '(exported) ' : '';
|
|
377
|
+
const file = t.filePath.split(/[/\\]/).slice(-1)[0];
|
|
378
|
+
console.log(` ${t.kind.padEnd(12)} ${t.name} ${exported}[${file}:${t.start_line}]${purpose}`);
|
|
379
|
+
}
|
|
380
|
+
console.log('');
|
|
381
|
+
}
|
|
382
|
+
// Functions section
|
|
383
|
+
if (functions.length > 0) {
|
|
384
|
+
console.log('── Functions ──');
|
|
385
|
+
for (const fn of functions) {
|
|
386
|
+
const purpose = fn.purpose ? ` — ${fn.purpose}` : '';
|
|
387
|
+
const exported = fn.is_exported ? '(exported) ' : '';
|
|
388
|
+
const asyncStr = fn.is_async ? 'async ' : '';
|
|
389
|
+
const file = fn.filePath.split(/[/\\]/).slice(-1)[0];
|
|
390
|
+
console.log(` ${asyncStr}${fn.name} ${exported}[${file}:${fn.start_line}]${purpose}`);
|
|
391
|
+
}
|
|
392
|
+
console.log('');
|
|
393
|
+
}
|
|
394
|
+
// Exported constants section
|
|
395
|
+
if (constants.length > 0) {
|
|
396
|
+
console.log('── Exported Constants ──');
|
|
397
|
+
for (const c of constants) {
|
|
398
|
+
const typeStr = c.type_annotation ? `: ${c.type_annotation}` : '';
|
|
399
|
+
const valStr = c.value_text ? ` = ${c.value_text.substring(0, 60)}${c.value_text.length > 60 ? '...' : ''}` : '';
|
|
400
|
+
const file = c.filePath.split(/[/\\]/).slice(-1)[0];
|
|
401
|
+
console.log(` ${c.name}${typeStr}${valStr} [${file}:${c.start_line}]`);
|
|
402
|
+
}
|
|
403
|
+
console.log('');
|
|
404
|
+
}
|
|
405
|
+
if (stats.totalFunctions === 0 && stats.totalTypes === 0 && stats.totalRoutes === 0) {
|
|
406
|
+
console.log('Knowledge graph is empty. Run "structx setup ." to populate it.');
|
|
407
|
+
}
|
|
408
|
+
});
|
|
358
409
|
// ── doctor ──
|
|
359
410
|
program
|
|
360
411
|
.command('doctor')
|
|
@@ -430,112 +481,11 @@ program
|
|
|
430
481
|
}
|
|
431
482
|
const config = (0, config_1.loadConfig)(structxDir);
|
|
432
483
|
const db = (0, connection_1.openDatabase)(dbPath);
|
|
433
|
-
const project = (0, parser_1.createProject)(resolved);
|
|
434
484
|
console.log(`Scanning ${resolved} for TypeScript files...`);
|
|
435
|
-
const
|
|
436
|
-
|
|
437
|
-
let newFiles = 0;
|
|
438
|
-
let changedFiles = 0;
|
|
439
|
-
let unchangedFiles = 0;
|
|
440
|
-
let totalFunctions = 0;
|
|
441
|
-
let totalRelationships = 0;
|
|
442
|
-
let queued = 0;
|
|
443
|
-
for (const filePath of files) {
|
|
444
|
-
const relativePath = path.relative(resolved, filePath);
|
|
445
|
-
const content = fs.readFileSync(filePath, 'utf-8');
|
|
446
|
-
const contentHash = (0, parser_1.hashFileContent)(content);
|
|
447
|
-
// Check if file changed
|
|
448
|
-
const existingFile = (0, queries_1.getFileByPath)(db, relativePath);
|
|
449
|
-
if (existingFile && existingFile.content_hash === contentHash) {
|
|
450
|
-
unchangedFiles++;
|
|
451
|
-
continue;
|
|
452
|
-
}
|
|
453
|
-
const isNew = !existingFile;
|
|
454
|
-
if (isNew)
|
|
455
|
-
newFiles++;
|
|
456
|
-
else
|
|
457
|
-
changedFiles++;
|
|
458
|
-
// Upsert file record
|
|
459
|
-
const fileId = (0, queries_1.upsertFile)(db, relativePath, contentHash);
|
|
460
|
-
// Get old functions for diff comparison
|
|
461
|
-
const oldFunctions = isNew ? [] : (0, queries_1.getFunctionsByFileId)(db, fileId);
|
|
462
|
-
const oldFunctionMap = new Map(oldFunctions.map(f => [f.name, f]));
|
|
463
|
-
// Clear old data for this file
|
|
464
|
-
if (!isNew) {
|
|
465
|
-
// Delete relationships for all old functions in this file
|
|
466
|
-
for (const oldFn of oldFunctions) {
|
|
467
|
-
(0, queries_1.deleteRelationshipsByCallerFunctionId)(db, oldFn.id);
|
|
468
|
-
}
|
|
469
|
-
(0, queries_1.deleteFunctionsByFileId)(db, fileId);
|
|
470
|
-
}
|
|
471
|
-
// Parse functions
|
|
472
|
-
let functions;
|
|
473
|
-
try {
|
|
474
|
-
functions = (0, parser_1.parseFile)(project, filePath);
|
|
475
|
-
}
|
|
476
|
-
catch (err) {
|
|
477
|
-
logger_1.logger.warn(`Failed to parse ${relativePath}: ${err.message}`);
|
|
478
|
-
continue;
|
|
479
|
-
}
|
|
480
|
-
// Insert functions and check for re-analysis needs
|
|
481
|
-
const functionIdMap = new Map();
|
|
482
|
-
for (const fn of functions) {
|
|
483
|
-
const fnId = (0, queries_1.insertFunction)(db, {
|
|
484
|
-
file_id: fileId,
|
|
485
|
-
name: fn.name,
|
|
486
|
-
signature: fn.signature,
|
|
487
|
-
body: fn.body,
|
|
488
|
-
code_hash: fn.codeHash,
|
|
489
|
-
start_line: fn.startLine,
|
|
490
|
-
end_line: fn.endLine,
|
|
491
|
-
is_exported: fn.isExported,
|
|
492
|
-
is_async: fn.isAsync,
|
|
493
|
-
});
|
|
494
|
-
functionIdMap.set(fn.name, fnId);
|
|
495
|
-
totalFunctions++;
|
|
496
|
-
// Determine if we need semantic re-analysis
|
|
497
|
-
const oldFn = oldFunctionMap.get(fn.name);
|
|
498
|
-
if (!oldFn) {
|
|
499
|
-
// New function — queue for analysis
|
|
500
|
-
(0, queries_1.enqueueForAnalysis)(db, fnId, 'new', (0, differ_1.getPriority)('new', fn.isExported));
|
|
501
|
-
queued++;
|
|
502
|
-
}
|
|
503
|
-
else {
|
|
504
|
-
const { reanalyze, reason } = (0, differ_1.shouldReanalyze)(oldFn, fn.signature, fn.codeHash, fn.body, config.diffThreshold);
|
|
505
|
-
if (reanalyze) {
|
|
506
|
-
(0, queries_1.enqueueForAnalysis)(db, fnId, reason, (0, differ_1.getPriority)(reason, fn.isExported));
|
|
507
|
-
queued++;
|
|
508
|
-
}
|
|
509
|
-
}
|
|
510
|
-
}
|
|
511
|
-
// Extract and insert relationships
|
|
512
|
-
try {
|
|
513
|
-
const calls = (0, relationships_1.extractCallsFromFile)(project, filePath);
|
|
514
|
-
for (const call of calls) {
|
|
515
|
-
if (call.callerName === '__file__')
|
|
516
|
-
continue; // Skip file-level imports for now
|
|
517
|
-
const callerId = functionIdMap.get(call.callerName);
|
|
518
|
-
if (!callerId)
|
|
519
|
-
continue;
|
|
520
|
-
// Try to resolve callee to a function ID
|
|
521
|
-
const callee = (0, queries_1.getFunctionByName)(db, call.calleeName);
|
|
522
|
-
(0, queries_1.insertRelationship)(db, callerId, call.calleeName, call.relationType, callee?.id);
|
|
523
|
-
totalRelationships++;
|
|
524
|
-
}
|
|
525
|
-
}
|
|
526
|
-
catch (err) {
|
|
527
|
-
logger_1.logger.warn(`Failed to extract calls from ${relativePath}: ${err.message}`);
|
|
528
|
-
}
|
|
529
|
-
}
|
|
485
|
+
const ingestResult = (0, ingester_1.ingestDirectory)(db, resolved, config.diffThreshold);
|
|
486
|
+
(0, ingester_1.printIngestResult)(ingestResult);
|
|
530
487
|
db.close();
|
|
531
|
-
|
|
532
|
-
console.log(` New files: ${newFiles}`);
|
|
533
|
-
console.log(` Changed files: ${changedFiles}`);
|
|
534
|
-
console.log(` Unchanged: ${unchangedFiles}`);
|
|
535
|
-
console.log(` Functions: ${totalFunctions}`);
|
|
536
|
-
console.log(` Relationships: ${totalRelationships}`);
|
|
537
|
-
console.log(` Queued: ${queued} functions for semantic analysis`);
|
|
538
|
-
if (queued > 0) {
|
|
488
|
+
if (ingestResult.queued > 0) {
|
|
539
489
|
console.log(`\nNext: run 'structx analyze' to enrich functions with semantic metadata.`);
|
|
540
490
|
}
|
|
541
491
|
});
|
|
@@ -545,6 +495,7 @@ program
|
|
|
545
495
|
.description('Run LLM semantic analysis on extracted functions')
|
|
546
496
|
.argument('[repo-path]', 'Path to TypeScript repository', '.')
|
|
547
497
|
.option('--yes', 'Skip cost confirmation prompt')
|
|
498
|
+
.option('--api-key <key>', 'Anthropic API key (overrides ANTHROPIC_API_KEY env var)')
|
|
548
499
|
.action(async (repoPath, opts) => {
|
|
549
500
|
const resolved = path.resolve(repoPath);
|
|
550
501
|
const structxDir = (0, config_1.getStructXDir)(resolved);
|
|
@@ -554,8 +505,11 @@ program
|
|
|
554
505
|
return;
|
|
555
506
|
}
|
|
556
507
|
const config = (0, config_1.loadConfig)(structxDir);
|
|
508
|
+
if (opts.apiKey)
|
|
509
|
+
config.anthropicApiKey = opts.apiKey;
|
|
557
510
|
if (!config.anthropicApiKey) {
|
|
558
|
-
console.log('Anthropic API key not set.
|
|
511
|
+
console.log('ERROR: Anthropic API key not set.');
|
|
512
|
+
console.log('Fix: Set ANTHROPIC_API_KEY env var, pass --api-key <key>, or add to .structx/config.json');
|
|
559
513
|
return;
|
|
560
514
|
}
|
|
561
515
|
const db = (0, connection_1.openDatabase)(dbPath);
|
|
@@ -604,11 +558,21 @@ program
|
|
|
604
558
|
totalOutputTokens += batchResult.totalOutputTokens;
|
|
605
559
|
totalCost += batchResult.totalCost;
|
|
606
560
|
}
|
|
561
|
+
// Analyze types, routes, and file summaries
|
|
562
|
+
console.log('\n Analyzing types, routes, and file summaries...');
|
|
563
|
+
const typeResult = await (0, analyzer_1.analyzeTypes)(db, config.analysisModel, config.anthropicApiKey);
|
|
564
|
+
const routeResult = await (0, analyzer_1.analyzeRoutes)(db, config.analysisModel, config.anthropicApiKey);
|
|
565
|
+
const fileResult = await (0, analyzer_1.analyzeFileSummaries)(db, config.analysisModel, config.anthropicApiKey);
|
|
566
|
+
totalAnalyzed += typeResult.analyzed + routeResult.analyzed + fileResult.analyzed;
|
|
567
|
+
totalFailed += typeResult.failed + routeResult.failed + fileResult.failed;
|
|
568
|
+
totalInputTokens += typeResult.totalInputTokens + routeResult.totalInputTokens + fileResult.totalInputTokens;
|
|
569
|
+
totalOutputTokens += typeResult.totalOutputTokens + routeResult.totalOutputTokens + fileResult.totalOutputTokens;
|
|
570
|
+
totalCost += typeResult.totalCost + routeResult.totalCost + fileResult.totalCost;
|
|
607
571
|
// Rebuild FTS index
|
|
608
572
|
(0, analyzer_1.rebuildSearchIndex)(db);
|
|
609
573
|
db.close();
|
|
610
574
|
console.log(`\nAnalysis complete:`);
|
|
611
|
-
console.log(` Analyzed: ${totalAnalyzed}`);
|
|
575
|
+
console.log(` Analyzed: ${totalAnalyzed} (incl. ${typeResult.analyzed} types, ${routeResult.analyzed} routes, ${fileResult.analyzed} files)`);
|
|
612
576
|
console.log(` From cache: ${totalCached}`);
|
|
613
577
|
console.log(` Failed: ${totalFailed}`);
|
|
614
578
|
console.log(` Input tokens: ${totalInputTokens.toLocaleString()}`);
|
|
@@ -621,20 +585,88 @@ program
|
|
|
621
585
|
.description('Ask a question about the codebase')
|
|
622
586
|
.argument('<question>', 'The question to ask')
|
|
623
587
|
.option('--repo <path>', 'Path to TypeScript repository', '.')
|
|
588
|
+
.option('--api-key <key>', 'Anthropic API key (overrides ANTHROPIC_API_KEY env var)')
|
|
624
589
|
.action(async (question, opts) => {
|
|
625
590
|
const resolved = path.resolve(opts.repo);
|
|
626
591
|
const structxDir = (0, config_1.getStructXDir)(resolved);
|
|
627
592
|
const dbPath = (0, connection_1.getDbPath)(structxDir);
|
|
593
|
+
// Auto-setup: if DB doesn't exist, run full setup automatically
|
|
628
594
|
if (!fs.existsSync(dbPath)) {
|
|
629
|
-
console.log('StructX not initialized.
|
|
630
|
-
|
|
595
|
+
console.log('StructX not initialized. Running automatic setup...\n');
|
|
596
|
+
const db = (0, connection_1.initializeDatabase)(dbPath);
|
|
597
|
+
(0, config_1.saveConfig)(structxDir, { repoPath: resolved });
|
|
598
|
+
const config = (0, config_1.loadConfig)(structxDir);
|
|
599
|
+
if (opts.apiKey)
|
|
600
|
+
config.anthropicApiKey = opts.apiKey;
|
|
601
|
+
const result = (0, ingester_1.ingestDirectory)(db, resolved, config.diffThreshold);
|
|
602
|
+
(0, ingester_1.printIngestResult)(result);
|
|
603
|
+
// Run semantic analysis if API key available
|
|
604
|
+
if (config.anthropicApiKey) {
|
|
605
|
+
console.log('\nRunning semantic analysis...');
|
|
606
|
+
const pending = (0, queries_2.getPendingAnalysis)(db, config.batchSize);
|
|
607
|
+
if (pending.length > 0) {
|
|
608
|
+
let batchNum = 0;
|
|
609
|
+
while (true) {
|
|
610
|
+
const items = (0, queries_2.getPendingAnalysis)(db, config.batchSize);
|
|
611
|
+
if (items.length === 0)
|
|
612
|
+
break;
|
|
613
|
+
batchNum++;
|
|
614
|
+
console.log(` Batch ${batchNum}: ${items.length} functions...`);
|
|
615
|
+
await (0, analyzer_1.analyzeBatch)(db, items.map(p => ({ id: p.id, function_id: p.function_id })), config.analysisModel, config.anthropicApiKey);
|
|
616
|
+
}
|
|
617
|
+
await (0, analyzer_1.analyzeTypes)(db, config.analysisModel, config.anthropicApiKey);
|
|
618
|
+
await (0, analyzer_1.analyzeRoutes)(db, config.analysisModel, config.anthropicApiKey);
|
|
619
|
+
await (0, analyzer_1.analyzeFileSummaries)(db, config.analysisModel, config.anthropicApiKey);
|
|
620
|
+
(0, analyzer_1.rebuildSearchIndex)(db);
|
|
621
|
+
}
|
|
622
|
+
console.log('Setup complete. Now answering your question...\n');
|
|
623
|
+
}
|
|
624
|
+
else {
|
|
625
|
+
console.log('\nWARNING: ANTHROPIC_API_KEY not set. Semantic analysis skipped.');
|
|
626
|
+
console.log('Results will be limited. Set the key and run "structx analyze . --yes" for better answers.\n');
|
|
627
|
+
}
|
|
628
|
+
db.close();
|
|
631
629
|
}
|
|
632
630
|
const config = (0, config_1.loadConfig)(structxDir);
|
|
631
|
+
if (opts.apiKey)
|
|
632
|
+
config.anthropicApiKey = opts.apiKey;
|
|
633
633
|
if (!config.anthropicApiKey) {
|
|
634
|
-
console.log('Anthropic API key not set.
|
|
634
|
+
console.log('ERROR: Anthropic API key not set.');
|
|
635
|
+
console.log('Fix one of:');
|
|
636
|
+
console.log(' 1. Set ANTHROPIC_API_KEY environment variable');
|
|
637
|
+
console.log(' 2. Pass --api-key <key> to this command');
|
|
638
|
+
console.log(' 3. Add "anthropicApiKey" to .structx/config.json');
|
|
639
|
+
console.log('\nNote: "structx overview --repo ." works without an API key to see the codebase structure.');
|
|
635
640
|
return;
|
|
636
641
|
}
|
|
637
642
|
const db = (0, connection_1.openDatabase)(dbPath);
|
|
643
|
+
// Check if DB is empty — suggest re-ingesting
|
|
644
|
+
const stats = (0, queries_1.getStats)(db);
|
|
645
|
+
if (stats.totalFunctions === 0 && stats.totalTypes === 0 && stats.totalRoutes === 0) {
|
|
646
|
+
console.log('WARNING: The knowledge graph is empty (0 functions, 0 types, 0 routes).');
|
|
647
|
+
console.log('Running automatic re-ingestion...\n');
|
|
648
|
+
const result = (0, ingester_1.ingestDirectory)(db, resolved, config.diffThreshold);
|
|
649
|
+
(0, ingester_1.printIngestResult)(result);
|
|
650
|
+
if (result.queued > 0) {
|
|
651
|
+
console.log('\nRunning semantic analysis...');
|
|
652
|
+
while (true) {
|
|
653
|
+
const items = (0, queries_2.getPendingAnalysis)(db, config.batchSize);
|
|
654
|
+
if (items.length === 0)
|
|
655
|
+
break;
|
|
656
|
+
await (0, analyzer_1.analyzeBatch)(db, items.map(p => ({ id: p.id, function_id: p.function_id })), config.analysisModel, config.anthropicApiKey);
|
|
657
|
+
}
|
|
658
|
+
await (0, analyzer_1.analyzeTypes)(db, config.analysisModel, config.anthropicApiKey);
|
|
659
|
+
await (0, analyzer_1.analyzeRoutes)(db, config.analysisModel, config.anthropicApiKey);
|
|
660
|
+
await (0, analyzer_1.analyzeFileSummaries)(db, config.analysisModel, config.anthropicApiKey);
|
|
661
|
+
(0, analyzer_1.rebuildSearchIndex)(db);
|
|
662
|
+
}
|
|
663
|
+
console.log('');
|
|
664
|
+
}
|
|
665
|
+
// Warn if semantic analysis hasn't been done
|
|
666
|
+
if (stats.totalFunctions > 0 && stats.analyzedFunctions === 0) {
|
|
667
|
+
console.log('WARNING: No functions have been semantically analyzed. Results may be limited.');
|
|
668
|
+
console.log('Run "structx analyze . --yes" to enrich the knowledge graph.\n');
|
|
669
|
+
}
|
|
638
670
|
const startTime = Date.now();
|
|
639
671
|
// Step 1: Classify the question
|
|
640
672
|
console.log('Classifying question...');
|
|
@@ -660,6 +692,21 @@ program
|
|
|
660
692
|
case 'impact':
|
|
661
693
|
retrieved = (0, retriever_1.impactAnalysis)(db, classification.functionName || '');
|
|
662
694
|
break;
|
|
695
|
+
case 'route':
|
|
696
|
+
retrieved = (0, retriever_1.routeQuery)(db, classification.routePath, classification.routeMethod);
|
|
697
|
+
break;
|
|
698
|
+
case 'type':
|
|
699
|
+
retrieved = (0, retriever_1.typeQuery)(db, classification.typeName || classification.keywords.join(' '));
|
|
700
|
+
break;
|
|
701
|
+
case 'file':
|
|
702
|
+
retrieved = (0, retriever_1.fileQuery)(db, classification.filePath);
|
|
703
|
+
break;
|
|
704
|
+
case 'list':
|
|
705
|
+
retrieved = (0, retriever_1.listQuery)(db, classification.listEntity);
|
|
706
|
+
break;
|
|
707
|
+
case 'pattern':
|
|
708
|
+
retrieved = (0, retriever_1.patternQuery)(db, classification.keywords);
|
|
709
|
+
break;
|
|
663
710
|
default:
|
|
664
711
|
retrieved = (0, retriever_1.semanticSearch)(db, classification.keywords);
|
|
665
712
|
}
|
|
@@ -670,10 +717,12 @@ program
|
|
|
670
717
|
console.log('Generating answer...\n');
|
|
671
718
|
const answerResult = await (0, answerer_1.generateAnswer)(question, context, config.answerModel, config.anthropicApiKey);
|
|
672
719
|
// Display answer
|
|
720
|
+
const entityCount = retrieved.functions.length + retrieved.types.length +
|
|
721
|
+
retrieved.routes.length + retrieved.files.length + retrieved.constants.length;
|
|
673
722
|
console.log('─'.repeat(60));
|
|
674
723
|
console.log(answerResult.answer);
|
|
675
724
|
console.log('─'.repeat(60));
|
|
676
|
-
console.log(`\nStrategy: ${classification.strategy} |
|
|
725
|
+
console.log(`\nStrategy: ${classification.strategy} | Entities: ${entityCount} | Graph query: ${graphQueryTimeMs}ms`);
|
|
677
726
|
console.log(`Tokens: ${answerResult.inputTokens} in / ${answerResult.outputTokens} out | Cost: $${answerResult.cost.toFixed(4)} | Time: ${answerResult.responseTimeMs}ms`);
|
|
678
727
|
// Save run to DB
|
|
679
728
|
(0, queries_2.insertQaRun)(db, {
|