@optave/codegraph 2.6.0 → 3.0.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 +109 -52
- package/package.json +5 -5
- package/src/ast.js +392 -0
- package/src/batch.js +93 -3
- package/src/builder.js +314 -95
- package/src/cfg.js +1451 -0
- package/src/change-journal.js +130 -0
- package/src/cli.js +411 -139
- package/src/complexity.js +8 -8
- package/src/dataflow.js +1187 -0
- package/src/db.js +96 -0
- package/src/embedder.js +16 -16
- package/src/export.js +305 -0
- package/src/extractors/csharp.js +64 -1
- package/src/extractors/go.js +66 -1
- package/src/extractors/hcl.js +22 -0
- package/src/extractors/java.js +61 -1
- package/src/extractors/javascript.js +142 -0
- package/src/extractors/php.js +79 -0
- package/src/extractors/python.js +134 -0
- package/src/extractors/ruby.js +89 -0
- package/src/extractors/rust.js +71 -1
- package/src/index.js +51 -3
- package/src/mcp.js +403 -222
- package/src/paginate.js +3 -3
- package/src/parser.js +8 -0
- package/src/queries.js +362 -36
- package/src/structure.js +4 -1
- package/src/viewer.js +948 -0
- package/src/watcher.js +36 -1
package/src/ast.js
ADDED
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stored queryable AST nodes — build-time extraction + query functions.
|
|
3
|
+
*
|
|
4
|
+
* Persists selected AST nodes (calls, new, string, regex, throw, await) in the
|
|
5
|
+
* `ast_nodes` table during build. Queryable via CLI (`codegraph ast`), MCP
|
|
6
|
+
* (`ast_query`), and programmatic API.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { openReadonlyOrFail } from './db.js';
|
|
11
|
+
import { debug } from './logger.js';
|
|
12
|
+
import { paginateResult, printNdjson } from './paginate.js';
|
|
13
|
+
import { LANGUAGE_REGISTRY } from './parser.js';
|
|
14
|
+
|
|
15
|
+
// ─── Constants ────────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
export const AST_NODE_KINDS = ['call', 'new', 'string', 'regex', 'throw', 'await'];
|
|
18
|
+
|
|
19
|
+
const KIND_ICONS = {
|
|
20
|
+
call: '\u0192', // ƒ
|
|
21
|
+
new: '\u2295', // ⊕
|
|
22
|
+
string: '"',
|
|
23
|
+
regex: '/',
|
|
24
|
+
throw: '\u2191', // ↑
|
|
25
|
+
await: '\u22B3', // ⊳
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/** Max length for the `text` column. */
|
|
29
|
+
const TEXT_MAX = 200;
|
|
30
|
+
|
|
31
|
+
/** tree-sitter node types that map to our AST node kinds (JS/TS/TSX). */
|
|
32
|
+
const JS_TS_AST_TYPES = {
|
|
33
|
+
new_expression: 'new',
|
|
34
|
+
throw_statement: 'throw',
|
|
35
|
+
await_expression: 'await',
|
|
36
|
+
string: 'string',
|
|
37
|
+
template_string: 'string',
|
|
38
|
+
regex: 'regex',
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/** Extensions that support full AST walk (new/throw/await/string/regex). */
|
|
42
|
+
const WALK_EXTENSIONS = new Set();
|
|
43
|
+
for (const lang of Object.values(LANGUAGE_REGISTRY)) {
|
|
44
|
+
if (['javascript', 'typescript', 'tsx'].includes(lang.id)) {
|
|
45
|
+
for (const ext of lang.extensions) WALK_EXTENSIONS.add(ext);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ─── Helpers ──────────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
function truncate(s, max = TEXT_MAX) {
|
|
52
|
+
if (!s) return null;
|
|
53
|
+
return s.length <= max ? s : `${s.slice(0, max - 1)}\u2026`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Extract the constructor name from a `new_expression` node.
|
|
58
|
+
* Handles `new Foo()`, `new a.Foo()`, `new Foo.Bar()`.
|
|
59
|
+
*/
|
|
60
|
+
function extractNewName(node) {
|
|
61
|
+
for (let i = 0; i < node.childCount; i++) {
|
|
62
|
+
const child = node.child(i);
|
|
63
|
+
if (child.type === 'identifier') return child.text;
|
|
64
|
+
if (child.type === 'member_expression') {
|
|
65
|
+
// e.g. new a.Foo() → "a.Foo"
|
|
66
|
+
return child.text;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return node.text?.split('(')[0]?.replace('new ', '').trim() || '?';
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Extract the expression text from a throw/await node.
|
|
74
|
+
*/
|
|
75
|
+
function extractExpressionText(node) {
|
|
76
|
+
// Skip keyword child, take the rest
|
|
77
|
+
for (let i = 0; i < node.childCount; i++) {
|
|
78
|
+
const child = node.child(i);
|
|
79
|
+
if (child.type !== 'throw' && child.type !== 'await') {
|
|
80
|
+
return truncate(child.text);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return truncate(node.text);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Extract a meaningful name from throw/await nodes.
|
|
88
|
+
* For throw: the constructor or expression type.
|
|
89
|
+
* For await: the called function name.
|
|
90
|
+
*/
|
|
91
|
+
function extractName(kind, node) {
|
|
92
|
+
if (kind === 'throw') {
|
|
93
|
+
// throw new Error(...) → "Error"; throw x → "x"
|
|
94
|
+
for (let i = 0; i < node.childCount; i++) {
|
|
95
|
+
const child = node.child(i);
|
|
96
|
+
if (child.type === 'new_expression') return extractNewName(child);
|
|
97
|
+
if (child.type === 'call_expression') {
|
|
98
|
+
const fn = child.childForFieldName('function');
|
|
99
|
+
return fn ? fn.text : child.text?.split('(')[0] || '?';
|
|
100
|
+
}
|
|
101
|
+
if (child.type === 'identifier') return child.text;
|
|
102
|
+
}
|
|
103
|
+
return truncate(node.text);
|
|
104
|
+
}
|
|
105
|
+
if (kind === 'await') {
|
|
106
|
+
// await fetch(...) → "fetch"; await this.foo() → "this.foo"
|
|
107
|
+
for (let i = 0; i < node.childCount; i++) {
|
|
108
|
+
const child = node.child(i);
|
|
109
|
+
if (child.type === 'call_expression') {
|
|
110
|
+
const fn = child.childForFieldName('function');
|
|
111
|
+
return fn ? fn.text : child.text?.split('(')[0] || '?';
|
|
112
|
+
}
|
|
113
|
+
if (child.type === 'identifier' || child.type === 'member_expression') {
|
|
114
|
+
return child.text;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return truncate(node.text);
|
|
118
|
+
}
|
|
119
|
+
return truncate(node.text);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Find the narrowest enclosing definition for a given line.
|
|
124
|
+
*/
|
|
125
|
+
function findParentDef(defs, line) {
|
|
126
|
+
let best = null;
|
|
127
|
+
for (const def of defs) {
|
|
128
|
+
if (def.line <= line && (def.endLine == null || def.endLine >= line)) {
|
|
129
|
+
if (!best || def.endLine - def.line < best.endLine - best.line) {
|
|
130
|
+
best = def;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return best;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ─── Build ────────────────────────────────────────────────────────────
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Extract AST nodes from parsed files and persist to the ast_nodes table.
|
|
141
|
+
*
|
|
142
|
+
* @param {object} db - open better-sqlite3 database (read-write)
|
|
143
|
+
* @param {Map<string, object>} fileSymbols - Map<relPath, { definitions, calls, _tree, _langId }>
|
|
144
|
+
* @param {string} rootDir - absolute project root path
|
|
145
|
+
* @param {object} [_engineOpts] - engine options (unused)
|
|
146
|
+
*/
|
|
147
|
+
export async function buildAstNodes(db, fileSymbols, _rootDir, _engineOpts) {
|
|
148
|
+
// Ensure table exists (migration may not have run on older DBs)
|
|
149
|
+
let insertStmt;
|
|
150
|
+
try {
|
|
151
|
+
insertStmt = db.prepare(
|
|
152
|
+
'INSERT INTO ast_nodes (file, line, kind, name, text, receiver, parent_node_id) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
|
153
|
+
);
|
|
154
|
+
} catch {
|
|
155
|
+
debug('ast_nodes table not found — skipping AST extraction');
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const getNodeId = db.prepare(
|
|
160
|
+
'SELECT id FROM nodes WHERE name = ? AND kind = ? AND file = ? AND line = ?',
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
const tx = db.transaction((rows) => {
|
|
164
|
+
for (const r of rows) {
|
|
165
|
+
insertStmt.run(r.file, r.line, r.kind, r.name, r.text, r.receiver, r.parentNodeId);
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
let totalInserted = 0;
|
|
170
|
+
|
|
171
|
+
for (const [relPath, symbols] of fileSymbols) {
|
|
172
|
+
const rows = [];
|
|
173
|
+
const defs = symbols.definitions || [];
|
|
174
|
+
|
|
175
|
+
// 1. Call nodes from symbols.calls (all languages)
|
|
176
|
+
if (symbols.calls) {
|
|
177
|
+
for (const call of symbols.calls) {
|
|
178
|
+
const parentDef = findParentDef(defs, call.line);
|
|
179
|
+
let parentNodeId = null;
|
|
180
|
+
if (parentDef) {
|
|
181
|
+
const row = getNodeId.get(parentDef.name, parentDef.kind, relPath, parentDef.line);
|
|
182
|
+
if (row) parentNodeId = row.id;
|
|
183
|
+
}
|
|
184
|
+
rows.push({
|
|
185
|
+
file: relPath,
|
|
186
|
+
line: call.line,
|
|
187
|
+
kind: 'call',
|
|
188
|
+
name: call.name,
|
|
189
|
+
text: call.dynamic ? `[dynamic] ${call.name}` : null,
|
|
190
|
+
receiver: call.receiver || null,
|
|
191
|
+
parentNodeId,
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// 2. AST walk for JS/TS/TSX — extract new, throw, await, string, regex
|
|
197
|
+
const ext = path.extname(relPath).toLowerCase();
|
|
198
|
+
if (WALK_EXTENSIONS.has(ext) && symbols._tree) {
|
|
199
|
+
const astRows = [];
|
|
200
|
+
walkAst(symbols._tree.rootNode, defs, relPath, astRows, getNodeId);
|
|
201
|
+
rows.push(...astRows);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (rows.length > 0) {
|
|
205
|
+
tx(rows);
|
|
206
|
+
totalInserted += rows.length;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
debug(`AST extraction: ${totalInserted} nodes stored`);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Walk a tree-sitter AST and collect new/throw/await/string/regex nodes.
|
|
215
|
+
*/
|
|
216
|
+
function walkAst(node, defs, relPath, rows, getNodeId) {
|
|
217
|
+
const kind = JS_TS_AST_TYPES[node.type];
|
|
218
|
+
if (kind) {
|
|
219
|
+
// tree-sitter lines are 0-indexed, our DB uses 1-indexed
|
|
220
|
+
const line = node.startPosition.row + 1;
|
|
221
|
+
|
|
222
|
+
let name;
|
|
223
|
+
let text = null;
|
|
224
|
+
|
|
225
|
+
if (kind === 'new') {
|
|
226
|
+
name = extractNewName(node);
|
|
227
|
+
text = truncate(node.text);
|
|
228
|
+
} else if (kind === 'throw') {
|
|
229
|
+
name = extractName('throw', node);
|
|
230
|
+
text = extractExpressionText(node);
|
|
231
|
+
} else if (kind === 'await') {
|
|
232
|
+
name = extractName('await', node);
|
|
233
|
+
text = extractExpressionText(node);
|
|
234
|
+
} else if (kind === 'string') {
|
|
235
|
+
// Skip trivial strings (length < 2 after removing quotes)
|
|
236
|
+
const content = node.text?.replace(/^['"`]|['"`]$/g, '') || '';
|
|
237
|
+
if (content.length < 2) {
|
|
238
|
+
// Still recurse children
|
|
239
|
+
for (let i = 0; i < node.childCount; i++) {
|
|
240
|
+
walkAst(node.child(i), defs, relPath, rows, getNodeId);
|
|
241
|
+
}
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
name = truncate(content, 100);
|
|
245
|
+
text = truncate(node.text);
|
|
246
|
+
} else if (kind === 'regex') {
|
|
247
|
+
name = node.text || '?';
|
|
248
|
+
text = truncate(node.text);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const parentDef = findParentDef(defs, line);
|
|
252
|
+
let parentNodeId = null;
|
|
253
|
+
if (parentDef) {
|
|
254
|
+
const row = getNodeId.get(parentDef.name, parentDef.kind, relPath, parentDef.line);
|
|
255
|
+
if (row) parentNodeId = row.id;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
rows.push({
|
|
259
|
+
file: relPath,
|
|
260
|
+
line,
|
|
261
|
+
kind,
|
|
262
|
+
name,
|
|
263
|
+
text,
|
|
264
|
+
receiver: null,
|
|
265
|
+
parentNodeId,
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
// Don't recurse into the children of matched nodes for new/throw/await
|
|
269
|
+
// (we already extracted what we need, and nested strings inside them are noise)
|
|
270
|
+
if (kind !== 'string' && kind !== 'regex') return;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
for (let i = 0; i < node.childCount; i++) {
|
|
274
|
+
walkAst(node.child(i), defs, relPath, rows, getNodeId);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// ─── Query ────────────────────────────────────────────────────────────
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Query AST nodes — data-returning function.
|
|
282
|
+
*
|
|
283
|
+
* @param {string} [pattern] - GLOB pattern for node name (auto-wrapped in *..*)
|
|
284
|
+
* @param {string} [customDbPath] - path to graph.db
|
|
285
|
+
* @param {object} [opts]
|
|
286
|
+
* @returns {{ pattern, kind, count, results, _pagination? }}
|
|
287
|
+
*/
|
|
288
|
+
export function astQueryData(pattern, customDbPath, opts = {}) {
|
|
289
|
+
const db = openReadonlyOrFail(customDbPath);
|
|
290
|
+
const { kind, file, noTests, limit, offset } = opts;
|
|
291
|
+
|
|
292
|
+
let where = 'WHERE 1=1';
|
|
293
|
+
const params = [];
|
|
294
|
+
|
|
295
|
+
// Pattern matching
|
|
296
|
+
if (pattern && pattern !== '*') {
|
|
297
|
+
// If user already uses wildcards, use as-is; otherwise wrap in *..* for substring
|
|
298
|
+
const globPattern = pattern.includes('*') ? pattern : `*${pattern}*`;
|
|
299
|
+
where += ' AND a.name GLOB ?';
|
|
300
|
+
params.push(globPattern);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if (kind) {
|
|
304
|
+
where += ' AND a.kind = ?';
|
|
305
|
+
params.push(kind);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (file) {
|
|
309
|
+
where += ' AND a.file LIKE ?';
|
|
310
|
+
params.push(`%${file}%`);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if (noTests) {
|
|
314
|
+
where += ` AND a.file NOT LIKE '%.test.%'
|
|
315
|
+
AND a.file NOT LIKE '%.spec.%'
|
|
316
|
+
AND a.file NOT LIKE '%__test__%'
|
|
317
|
+
AND a.file NOT LIKE '%__tests__%'
|
|
318
|
+
AND a.file NOT LIKE '%.stories.%'`;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const sql = `
|
|
322
|
+
SELECT a.kind, a.name, a.file, a.line, a.text, a.receiver, a.parent_node_id,
|
|
323
|
+
p.name AS parent_name, p.kind AS parent_kind, p.file AS parent_file
|
|
324
|
+
FROM ast_nodes a
|
|
325
|
+
LEFT JOIN nodes p ON a.parent_node_id = p.id
|
|
326
|
+
${where}
|
|
327
|
+
ORDER BY a.file, a.line
|
|
328
|
+
`;
|
|
329
|
+
|
|
330
|
+
const rows = db.prepare(sql).all(...params);
|
|
331
|
+
db.close();
|
|
332
|
+
|
|
333
|
+
const results = rows.map((r) => ({
|
|
334
|
+
kind: r.kind,
|
|
335
|
+
name: r.name,
|
|
336
|
+
file: r.file,
|
|
337
|
+
line: r.line,
|
|
338
|
+
text: r.text,
|
|
339
|
+
receiver: r.receiver,
|
|
340
|
+
parent: r.parent_node_id
|
|
341
|
+
? { name: r.parent_name, kind: r.parent_kind, file: r.parent_file }
|
|
342
|
+
: null,
|
|
343
|
+
}));
|
|
344
|
+
|
|
345
|
+
const data = {
|
|
346
|
+
pattern: pattern || '*',
|
|
347
|
+
kind: kind || null,
|
|
348
|
+
count: results.length,
|
|
349
|
+
results,
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
return paginateResult(data, 'results', { limit, offset });
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Query AST nodes — display function (human/json/ndjson output).
|
|
357
|
+
*/
|
|
358
|
+
export function astQuery(pattern, customDbPath, opts = {}) {
|
|
359
|
+
const data = astQueryData(pattern, customDbPath, opts);
|
|
360
|
+
|
|
361
|
+
if (opts.ndjson) {
|
|
362
|
+
printNdjson(data, 'results');
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
if (opts.json) {
|
|
367
|
+
console.log(JSON.stringify(data, null, 2));
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Human-readable output
|
|
372
|
+
if (data.results.length === 0) {
|
|
373
|
+
console.log(`No AST nodes found${pattern ? ` matching "${pattern}"` : ''}.`);
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const kindLabel = opts.kind ? ` (kind: ${opts.kind})` : '';
|
|
378
|
+
console.log(`\n${data.count} AST nodes${pattern ? ` matching "${pattern}"` : ''}${kindLabel}:\n`);
|
|
379
|
+
|
|
380
|
+
for (const r of data.results) {
|
|
381
|
+
const icon = KIND_ICONS[r.kind] || '?';
|
|
382
|
+
const parentInfo = r.parent ? ` (in ${r.parent.name})` : '';
|
|
383
|
+
console.log(` ${icon} ${r.name} -- ${r.file}:${r.line}${parentInfo}`);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
if (data._pagination?.hasMore) {
|
|
387
|
+
console.log(
|
|
388
|
+
`\n ... ${data._pagination.total - data._pagination.offset - data._pagination.returned} more (use --offset ${data._pagination.offset + data._pagination.limit})`,
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
console.log();
|
|
392
|
+
}
|
package/src/batch.js
CHANGED
|
@@ -6,15 +6,16 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { complexityData } from './complexity.js';
|
|
9
|
+
import { dataflowData } from './dataflow.js';
|
|
9
10
|
import { flowData } from './flow.js';
|
|
10
11
|
import {
|
|
11
12
|
contextData,
|
|
12
13
|
explainData,
|
|
14
|
+
exportsData,
|
|
13
15
|
fileDepsData,
|
|
14
16
|
fnDepsData,
|
|
15
17
|
fnImpactData,
|
|
16
18
|
impactAnalysisData,
|
|
17
|
-
queryNameData,
|
|
18
19
|
whereData,
|
|
19
20
|
} from './queries.js';
|
|
20
21
|
|
|
@@ -31,11 +32,12 @@ export const BATCH_COMMANDS = {
|
|
|
31
32
|
context: { fn: contextData, sig: 'name' },
|
|
32
33
|
explain: { fn: explainData, sig: 'target' },
|
|
33
34
|
where: { fn: whereData, sig: 'target' },
|
|
34
|
-
query: { fn:
|
|
35
|
-
fn: { fn: fnDepsData, sig: 'name' },
|
|
35
|
+
query: { fn: fnDepsData, sig: 'name' },
|
|
36
36
|
impact: { fn: impactAnalysisData, sig: 'file' },
|
|
37
37
|
deps: { fn: fileDepsData, sig: 'file' },
|
|
38
|
+
exports: { fn: exportsData, sig: 'file' },
|
|
38
39
|
flow: { fn: flowData, sig: 'name' },
|
|
40
|
+
dataflow: { fn: dataflowData, sig: 'name' },
|
|
39
41
|
complexity: { fn: complexityData, sig: 'dbOnly' },
|
|
40
42
|
};
|
|
41
43
|
|
|
@@ -88,3 +90,91 @@ export function batch(command, targets, customDbPath, opts = {}) {
|
|
|
88
90
|
const data = batchData(command, targets, customDbPath, opts);
|
|
89
91
|
console.log(JSON.stringify(data, null, 2));
|
|
90
92
|
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Expand comma-separated positional args into individual entries.
|
|
96
|
+
* `['a,b', 'c']` → `['a', 'b', 'c']`.
|
|
97
|
+
* Trims whitespace, filters empties. Passes through object items unchanged.
|
|
98
|
+
*
|
|
99
|
+
* @param {Array<string|object>} targets
|
|
100
|
+
* @returns {Array<string|object>}
|
|
101
|
+
*/
|
|
102
|
+
export function splitTargets(targets) {
|
|
103
|
+
const out = [];
|
|
104
|
+
for (const item of targets) {
|
|
105
|
+
if (typeof item !== 'string') {
|
|
106
|
+
out.push(item);
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
for (const part of item.split(',')) {
|
|
110
|
+
const trimmed = part.trim();
|
|
111
|
+
if (trimmed) out.push(trimmed);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return out;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Multi-command batch orchestration — run different commands per target.
|
|
119
|
+
*
|
|
120
|
+
* @param {Array<{command: string, target: string, opts?: object}>} items
|
|
121
|
+
* @param {string} [customDbPath]
|
|
122
|
+
* @param {object} [sharedOpts] - Default opts merged under per-item opts
|
|
123
|
+
* @returns {{ mode: 'multi', total: number, succeeded: number, failed: number, results: object[] }}
|
|
124
|
+
*/
|
|
125
|
+
export function multiBatchData(items, customDbPath, sharedOpts = {}) {
|
|
126
|
+
const results = [];
|
|
127
|
+
let succeeded = 0;
|
|
128
|
+
let failed = 0;
|
|
129
|
+
|
|
130
|
+
for (const item of items) {
|
|
131
|
+
const { command, target, opts: itemOpts } = item;
|
|
132
|
+
const entry = BATCH_COMMANDS[command];
|
|
133
|
+
|
|
134
|
+
if (!entry) {
|
|
135
|
+
results.push({
|
|
136
|
+
command,
|
|
137
|
+
target,
|
|
138
|
+
ok: false,
|
|
139
|
+
error: `Unknown batch command "${command}". Valid commands: ${Object.keys(BATCH_COMMANDS).join(', ')}`,
|
|
140
|
+
});
|
|
141
|
+
failed++;
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const merged = { ...sharedOpts, ...itemOpts };
|
|
146
|
+
|
|
147
|
+
try {
|
|
148
|
+
let data;
|
|
149
|
+
if (entry.sig === 'dbOnly') {
|
|
150
|
+
data = entry.fn(customDbPath, { ...merged, target });
|
|
151
|
+
} else {
|
|
152
|
+
data = entry.fn(target, customDbPath, merged);
|
|
153
|
+
}
|
|
154
|
+
results.push({ command, target, ok: true, data });
|
|
155
|
+
succeeded++;
|
|
156
|
+
} catch (err) {
|
|
157
|
+
results.push({ command, target, ok: false, error: err.message });
|
|
158
|
+
failed++;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return { mode: 'multi', total: items.length, succeeded, failed, results };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* CLI wrapper for batch-query — detects multi-command mode (objects with .command)
|
|
167
|
+
* or falls back to single-command batchData (default: 'where').
|
|
168
|
+
*/
|
|
169
|
+
export function batchQuery(targets, customDbPath, opts = {}) {
|
|
170
|
+
const { command: defaultCommand = 'where', ...rest } = opts;
|
|
171
|
+
const isMulti = targets.length > 0 && typeof targets[0] === 'object' && targets[0].command;
|
|
172
|
+
|
|
173
|
+
let data;
|
|
174
|
+
if (isMulti) {
|
|
175
|
+
data = multiBatchData(targets, customDbPath, rest);
|
|
176
|
+
} else {
|
|
177
|
+
data = batchData(defaultCommand, targets, customDbPath, rest);
|
|
178
|
+
}
|
|
179
|
+
console.log(JSON.stringify(data, null, 2));
|
|
180
|
+
}
|