@optave/codegraph 2.3.1-dev.1aeea34 → 2.5.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 +66 -10
- package/package.json +15 -6
- package/src/builder.js +183 -22
- package/src/cli.js +251 -5
- package/src/cochange.js +8 -8
- package/src/communities.js +303 -0
- package/src/complexity.js +2056 -0
- package/src/config.js +20 -1
- package/src/db.js +111 -1
- package/src/embedder.js +49 -12
- package/src/export.js +25 -1
- package/src/flow.js +361 -0
- package/src/index.js +32 -2
- package/src/manifesto.js +442 -0
- package/src/mcp.js +244 -5
- package/src/paginate.js +70 -0
- package/src/parser.js +21 -5
- package/src/queries.js +396 -7
- package/src/structure.js +88 -24
- package/src/update-check.js +160 -0
- package/src/watcher.js +2 -2
package/src/config.js
CHANGED
|
@@ -24,6 +24,20 @@ export const DEFAULTS = {
|
|
|
24
24
|
llm: { provider: null, model: null, baseUrl: null, apiKey: null, apiKeyCommand: null },
|
|
25
25
|
search: { defaultMinScore: 0.2, rrfK: 60, topK: 15 },
|
|
26
26
|
ci: { failOnCycles: false, impactThreshold: null },
|
|
27
|
+
manifesto: {
|
|
28
|
+
rules: {
|
|
29
|
+
cognitive: { warn: 15 },
|
|
30
|
+
cyclomatic: { warn: 10 },
|
|
31
|
+
maxNesting: { warn: 4 },
|
|
32
|
+
maintainabilityIndex: { warn: 20, fail: null },
|
|
33
|
+
importCount: { warn: null, fail: null },
|
|
34
|
+
exportCount: { warn: null, fail: null },
|
|
35
|
+
lineCount: { warn: null, fail: null },
|
|
36
|
+
fanIn: { warn: null, fail: null },
|
|
37
|
+
fanOut: { warn: null, fail: null },
|
|
38
|
+
noCycles: { warn: null, fail: null },
|
|
39
|
+
},
|
|
40
|
+
},
|
|
27
41
|
coChange: {
|
|
28
42
|
since: '1 year ago',
|
|
29
43
|
minSupport: 3,
|
|
@@ -45,7 +59,12 @@ export function loadConfig(cwd) {
|
|
|
45
59
|
const raw = fs.readFileSync(filePath, 'utf-8');
|
|
46
60
|
const config = JSON.parse(raw);
|
|
47
61
|
debug(`Loaded config from ${filePath}`);
|
|
48
|
-
|
|
62
|
+
const merged = mergeConfig(DEFAULTS, config);
|
|
63
|
+
if ('excludeTests' in config && !(config.query && 'excludeTests' in config.query)) {
|
|
64
|
+
merged.query.excludeTests = Boolean(config.excludeTests);
|
|
65
|
+
}
|
|
66
|
+
delete merged.excludeTests;
|
|
67
|
+
return resolveSecrets(applyEnvOverrides(merged));
|
|
49
68
|
} catch (err) {
|
|
50
69
|
debug(`Failed to parse config ${filePath}: ${err.message}`);
|
|
51
70
|
}
|
package/src/db.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import Database from 'better-sqlite3';
|
|
4
|
-
import { debug } from './logger.js';
|
|
4
|
+
import { debug, warn } from './logger.js';
|
|
5
5
|
|
|
6
6
|
// ─── Schema Migrations ─────────────────────────────────────────────────
|
|
7
7
|
export const MIGRATIONS = [
|
|
@@ -101,16 +101,126 @@ export const MIGRATIONS = [
|
|
|
101
101
|
);
|
|
102
102
|
`,
|
|
103
103
|
},
|
|
104
|
+
{
|
|
105
|
+
version: 7,
|
|
106
|
+
up: `
|
|
107
|
+
CREATE TABLE IF NOT EXISTS build_meta (
|
|
108
|
+
key TEXT PRIMARY KEY,
|
|
109
|
+
value TEXT NOT NULL
|
|
110
|
+
);
|
|
111
|
+
`,
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
version: 8,
|
|
115
|
+
up: `
|
|
116
|
+
CREATE TABLE IF NOT EXISTS function_complexity (
|
|
117
|
+
node_id INTEGER PRIMARY KEY,
|
|
118
|
+
cognitive INTEGER NOT NULL,
|
|
119
|
+
cyclomatic INTEGER NOT NULL,
|
|
120
|
+
max_nesting INTEGER NOT NULL,
|
|
121
|
+
FOREIGN KEY(node_id) REFERENCES nodes(id)
|
|
122
|
+
);
|
|
123
|
+
CREATE INDEX IF NOT EXISTS idx_fc_cognitive ON function_complexity(cognitive DESC);
|
|
124
|
+
CREATE INDEX IF NOT EXISTS idx_fc_cyclomatic ON function_complexity(cyclomatic DESC);
|
|
125
|
+
`,
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
version: 9,
|
|
129
|
+
up: `
|
|
130
|
+
ALTER TABLE function_complexity ADD COLUMN loc INTEGER DEFAULT 0;
|
|
131
|
+
ALTER TABLE function_complexity ADD COLUMN sloc INTEGER DEFAULT 0;
|
|
132
|
+
ALTER TABLE function_complexity ADD COLUMN comment_lines INTEGER DEFAULT 0;
|
|
133
|
+
ALTER TABLE function_complexity ADD COLUMN halstead_n1 INTEGER DEFAULT 0;
|
|
134
|
+
ALTER TABLE function_complexity ADD COLUMN halstead_n2 INTEGER DEFAULT 0;
|
|
135
|
+
ALTER TABLE function_complexity ADD COLUMN halstead_big_n1 INTEGER DEFAULT 0;
|
|
136
|
+
ALTER TABLE function_complexity ADD COLUMN halstead_big_n2 INTEGER DEFAULT 0;
|
|
137
|
+
ALTER TABLE function_complexity ADD COLUMN halstead_vocabulary INTEGER DEFAULT 0;
|
|
138
|
+
ALTER TABLE function_complexity ADD COLUMN halstead_length INTEGER DEFAULT 0;
|
|
139
|
+
ALTER TABLE function_complexity ADD COLUMN halstead_volume REAL DEFAULT 0;
|
|
140
|
+
ALTER TABLE function_complexity ADD COLUMN halstead_difficulty REAL DEFAULT 0;
|
|
141
|
+
ALTER TABLE function_complexity ADD COLUMN halstead_effort REAL DEFAULT 0;
|
|
142
|
+
ALTER TABLE function_complexity ADD COLUMN halstead_bugs REAL DEFAULT 0;
|
|
143
|
+
ALTER TABLE function_complexity ADD COLUMN maintainability_index REAL DEFAULT 0;
|
|
144
|
+
CREATE INDEX IF NOT EXISTS idx_fc_mi ON function_complexity(maintainability_index ASC);
|
|
145
|
+
`,
|
|
146
|
+
},
|
|
104
147
|
];
|
|
105
148
|
|
|
149
|
+
export function getBuildMeta(db, key) {
|
|
150
|
+
try {
|
|
151
|
+
const row = db.prepare('SELECT value FROM build_meta WHERE key = ?').get(key);
|
|
152
|
+
return row ? row.value : null;
|
|
153
|
+
} catch {
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function setBuildMeta(db, entries) {
|
|
159
|
+
const upsert = db.prepare('INSERT OR REPLACE INTO build_meta (key, value) VALUES (?, ?)');
|
|
160
|
+
const tx = db.transaction(() => {
|
|
161
|
+
for (const [key, value] of Object.entries(entries)) {
|
|
162
|
+
upsert.run(key, String(value));
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
tx();
|
|
166
|
+
}
|
|
167
|
+
|
|
106
168
|
export function openDb(dbPath) {
|
|
107
169
|
const dir = path.dirname(dbPath);
|
|
108
170
|
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
171
|
+
acquireAdvisoryLock(dbPath);
|
|
109
172
|
const db = new Database(dbPath);
|
|
110
173
|
db.pragma('journal_mode = WAL');
|
|
174
|
+
db.pragma('busy_timeout = 5000');
|
|
175
|
+
db.__lockPath = `${dbPath}.lock`;
|
|
111
176
|
return db;
|
|
112
177
|
}
|
|
113
178
|
|
|
179
|
+
export function closeDb(db) {
|
|
180
|
+
db.close();
|
|
181
|
+
if (db.__lockPath) releaseAdvisoryLock(db.__lockPath);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function isProcessAlive(pid) {
|
|
185
|
+
try {
|
|
186
|
+
process.kill(pid, 0);
|
|
187
|
+
return true;
|
|
188
|
+
} catch {
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function acquireAdvisoryLock(dbPath) {
|
|
194
|
+
const lockPath = `${dbPath}.lock`;
|
|
195
|
+
try {
|
|
196
|
+
if (fs.existsSync(lockPath)) {
|
|
197
|
+
const content = fs.readFileSync(lockPath, 'utf-8').trim();
|
|
198
|
+
const pid = Number(content);
|
|
199
|
+
if (pid && pid !== process.pid && isProcessAlive(pid)) {
|
|
200
|
+
warn(`Another process (PID ${pid}) may be using this database. Proceeding with caution.`);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
} catch {
|
|
204
|
+
/* ignore read errors */
|
|
205
|
+
}
|
|
206
|
+
try {
|
|
207
|
+
fs.writeFileSync(lockPath, String(process.pid), 'utf-8');
|
|
208
|
+
} catch {
|
|
209
|
+
/* best-effort */
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function releaseAdvisoryLock(lockPath) {
|
|
214
|
+
try {
|
|
215
|
+
const content = fs.readFileSync(lockPath, 'utf-8').trim();
|
|
216
|
+
if (Number(content) === process.pid) {
|
|
217
|
+
fs.unlinkSync(lockPath);
|
|
218
|
+
}
|
|
219
|
+
} catch {
|
|
220
|
+
/* ignore */
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
114
224
|
export function initSchema(db) {
|
|
115
225
|
db.exec(`CREATE TABLE IF NOT EXISTS schema_version (version INTEGER NOT NULL DEFAULT 0)`);
|
|
116
226
|
|
package/src/embedder.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
1
2
|
import fs from 'node:fs';
|
|
2
3
|
import path from 'node:path';
|
|
3
|
-
import
|
|
4
|
-
import { findDbPath, openReadonlyOrFail } from './db.js';
|
|
5
|
-
import { warn } from './logger.js';
|
|
4
|
+
import { createInterface } from 'node:readline';
|
|
5
|
+
import { closeDb, findDbPath, openDb, openReadonlyOrFail } from './db.js';
|
|
6
|
+
import { info, warn } from './logger.js';
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* Split an identifier into readable words.
|
|
@@ -133,8 +134,10 @@ export function estimateTokens(text) {
|
|
|
133
134
|
* Returns the cleaned comment text or null if none found.
|
|
134
135
|
*/
|
|
135
136
|
function extractLeadingComment(lines, fnLineIndex) {
|
|
137
|
+
if (fnLineIndex > lines.length) return null;
|
|
136
138
|
const raw = [];
|
|
137
139
|
for (let i = fnLineIndex - 1; i >= Math.max(0, fnLineIndex - 15); i--) {
|
|
140
|
+
if (i >= lines.length) continue;
|
|
138
141
|
const trimmed = lines[i].trim();
|
|
139
142
|
if (/^(\/\/|\/\*|\*\/|\*|#|\/\/\/)/.test(trimmed)) {
|
|
140
143
|
raw.unshift(trimmed);
|
|
@@ -222,18 +225,52 @@ function buildSourceText(node, file, lines) {
|
|
|
222
225
|
return `${node.kind} ${node.name} (${readable}) in ${file}\n${context}`;
|
|
223
226
|
}
|
|
224
227
|
|
|
228
|
+
/**
|
|
229
|
+
* Prompt the user to install a missing package interactively.
|
|
230
|
+
* Returns true if the package was installed, false otherwise.
|
|
231
|
+
* Skips the prompt entirely in non-TTY environments (CI, piped stdin).
|
|
232
|
+
*/
|
|
233
|
+
function promptInstall(packageName) {
|
|
234
|
+
if (!process.stdin.isTTY) return Promise.resolve(false);
|
|
235
|
+
|
|
236
|
+
return new Promise((resolve) => {
|
|
237
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
238
|
+
rl.question(`Semantic search requires ${packageName}. Install it now? [y/N] `, (answer) => {
|
|
239
|
+
rl.close();
|
|
240
|
+
if (answer.trim().toLowerCase() !== 'y') return resolve(false);
|
|
241
|
+
try {
|
|
242
|
+
execFileSync('npm', ['install', packageName], {
|
|
243
|
+
stdio: 'inherit',
|
|
244
|
+
timeout: 300_000,
|
|
245
|
+
});
|
|
246
|
+
resolve(true);
|
|
247
|
+
} catch {
|
|
248
|
+
resolve(false);
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
|
|
225
254
|
/**
|
|
226
255
|
* Lazy-load @huggingface/transformers.
|
|
227
|
-
*
|
|
256
|
+
* If the package is missing, prompts the user to install it interactively.
|
|
257
|
+
* In non-TTY environments, prints an error and exits.
|
|
228
258
|
*/
|
|
229
259
|
async function loadTransformers() {
|
|
230
260
|
try {
|
|
231
261
|
return await import('@huggingface/transformers');
|
|
232
262
|
} catch {
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
263
|
+
const pkg = '@huggingface/transformers';
|
|
264
|
+
const installed = await promptInstall(pkg);
|
|
265
|
+
if (installed) {
|
|
266
|
+
try {
|
|
267
|
+
return await import(pkg);
|
|
268
|
+
} catch {
|
|
269
|
+
console.error(`\n${pkg} was installed but failed to load. Please check your environment.`);
|
|
270
|
+
process.exit(1);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
console.error(`Semantic search requires ${pkg}.\n` + `Install it with: npm install ${pkg}`);
|
|
237
274
|
process.exit(1);
|
|
238
275
|
}
|
|
239
276
|
}
|
|
@@ -262,7 +299,7 @@ async function loadModel(modelKey) {
|
|
|
262
299
|
pipeline = transformers.pipeline;
|
|
263
300
|
_cos_sim = transformers.cos_sim;
|
|
264
301
|
|
|
265
|
-
|
|
302
|
+
info(`Loading embedding model: ${config.name} (${config.dim}d)...`);
|
|
266
303
|
const pipelineOpts = config.quantized ? { quantized: true } : {};
|
|
267
304
|
try {
|
|
268
305
|
extractor = await pipeline('feature-extraction', config.name, pipelineOpts);
|
|
@@ -285,7 +322,7 @@ async function loadModel(modelKey) {
|
|
|
285
322
|
process.exit(1);
|
|
286
323
|
}
|
|
287
324
|
activeModel = config.name;
|
|
288
|
-
|
|
325
|
+
info('Model loaded.');
|
|
289
326
|
return { extractor, config };
|
|
290
327
|
}
|
|
291
328
|
|
|
@@ -369,7 +406,7 @@ export async function buildEmbeddings(rootDir, modelKey, customDbPath, options =
|
|
|
369
406
|
process.exit(1);
|
|
370
407
|
}
|
|
371
408
|
|
|
372
|
-
const db =
|
|
409
|
+
const db = openDb(dbPath);
|
|
373
410
|
initEmbeddingsSchema(db);
|
|
374
411
|
|
|
375
412
|
db.exec('DELETE FROM embeddings');
|
|
@@ -474,7 +511,7 @@ export async function buildEmbeddings(rootDir, modelKey, customDbPath, options =
|
|
|
474
511
|
console.log(
|
|
475
512
|
`\nStored ${vectors.length} embeddings (${dim}d, ${config.name}, strategy: ${strategy}) in graph.db`,
|
|
476
513
|
);
|
|
477
|
-
db
|
|
514
|
+
closeDb(db);
|
|
478
515
|
}
|
|
479
516
|
|
|
480
517
|
/**
|
package/src/export.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
|
+
import { paginateResult } from './paginate.js';
|
|
2
3
|
import { isTestFile } from './queries.js';
|
|
3
4
|
|
|
4
5
|
const DEFAULT_MIN_CONFIDENCE = 0.5;
|
|
@@ -10,6 +11,7 @@ export function exportDOT(db, opts = {}) {
|
|
|
10
11
|
const fileLevel = opts.fileLevel !== false;
|
|
11
12
|
const noTests = opts.noTests || false;
|
|
12
13
|
const minConf = opts.minConfidence ?? DEFAULT_MIN_CONFIDENCE;
|
|
14
|
+
const edgeLimit = opts.limit;
|
|
13
15
|
const lines = [
|
|
14
16
|
'digraph codegraph {',
|
|
15
17
|
' rankdir=LR;',
|
|
@@ -30,6 +32,8 @@ export function exportDOT(db, opts = {}) {
|
|
|
30
32
|
`)
|
|
31
33
|
.all(minConf);
|
|
32
34
|
if (noTests) edges = edges.filter((e) => !isTestFile(e.source) && !isTestFile(e.target));
|
|
35
|
+
const totalFileEdges = edges.length;
|
|
36
|
+
if (edgeLimit && edges.length > edgeLimit) edges = edges.slice(0, edgeLimit);
|
|
33
37
|
|
|
34
38
|
// Try to use directory nodes from DB (built by structure analysis)
|
|
35
39
|
const hasDirectoryNodes =
|
|
@@ -95,6 +99,9 @@ export function exportDOT(db, opts = {}) {
|
|
|
95
99
|
for (const { source, target } of edges) {
|
|
96
100
|
lines.push(` "${source}" -> "${target}";`);
|
|
97
101
|
}
|
|
102
|
+
if (edgeLimit && totalFileEdges > edgeLimit) {
|
|
103
|
+
lines.push(` // Truncated: showing ${edges.length} of ${totalFileEdges} edges`);
|
|
104
|
+
}
|
|
98
105
|
} else {
|
|
99
106
|
let edges = db
|
|
100
107
|
.prepare(`
|
|
@@ -111,6 +118,8 @@ export function exportDOT(db, opts = {}) {
|
|
|
111
118
|
.all(minConf);
|
|
112
119
|
if (noTests)
|
|
113
120
|
edges = edges.filter((e) => !isTestFile(e.source_file) && !isTestFile(e.target_file));
|
|
121
|
+
const totalFnEdges = edges.length;
|
|
122
|
+
if (edgeLimit && edges.length > edgeLimit) edges = edges.slice(0, edgeLimit);
|
|
114
123
|
|
|
115
124
|
for (const e of edges) {
|
|
116
125
|
const sId = `${e.source_file}:${e.source_name}`.replace(/[^a-zA-Z0-9_]/g, '_');
|
|
@@ -119,6 +128,9 @@ export function exportDOT(db, opts = {}) {
|
|
|
119
128
|
lines.push(` ${tId} [label="${e.target_name}\\n${path.basename(e.target_file)}"];`);
|
|
120
129
|
lines.push(` ${sId} -> ${tId};`);
|
|
121
130
|
}
|
|
131
|
+
if (edgeLimit && totalFnEdges > edgeLimit) {
|
|
132
|
+
lines.push(` // Truncated: showing ${edges.length} of ${totalFnEdges} edges`);
|
|
133
|
+
}
|
|
122
134
|
}
|
|
123
135
|
|
|
124
136
|
lines.push('}');
|
|
@@ -169,6 +181,7 @@ export function exportMermaid(db, opts = {}) {
|
|
|
169
181
|
const noTests = opts.noTests || false;
|
|
170
182
|
const minConf = opts.minConfidence ?? DEFAULT_MIN_CONFIDENCE;
|
|
171
183
|
const direction = opts.direction || 'LR';
|
|
184
|
+
const edgeLimit = opts.limit;
|
|
172
185
|
const lines = [`flowchart ${direction}`];
|
|
173
186
|
|
|
174
187
|
let nodeCounter = 0;
|
|
@@ -190,6 +203,8 @@ export function exportMermaid(db, opts = {}) {
|
|
|
190
203
|
`)
|
|
191
204
|
.all(minConf);
|
|
192
205
|
if (noTests) edges = edges.filter((e) => !isTestFile(e.source) && !isTestFile(e.target));
|
|
206
|
+
const totalMermaidFileEdges = edges.length;
|
|
207
|
+
if (edgeLimit && edges.length > edgeLimit) edges = edges.slice(0, edgeLimit);
|
|
193
208
|
|
|
194
209
|
// Collect all files referenced in edges
|
|
195
210
|
const allFiles = new Set();
|
|
@@ -248,6 +263,9 @@ export function exportMermaid(db, opts = {}) {
|
|
|
248
263
|
for (const { source, target, labels } of edgeMap.values()) {
|
|
249
264
|
lines.push(` ${nodeId(source)} -->|${[...labels].join(', ')}| ${nodeId(target)}`);
|
|
250
265
|
}
|
|
266
|
+
if (edgeLimit && totalMermaidFileEdges > edgeLimit) {
|
|
267
|
+
lines.push(` %% Truncated: showing ${edges.length} of ${totalMermaidFileEdges} edges`);
|
|
268
|
+
}
|
|
251
269
|
} else {
|
|
252
270
|
let edges = db
|
|
253
271
|
.prepare(`
|
|
@@ -265,6 +283,8 @@ export function exportMermaid(db, opts = {}) {
|
|
|
265
283
|
.all(minConf);
|
|
266
284
|
if (noTests)
|
|
267
285
|
edges = edges.filter((e) => !isTestFile(e.source_file) && !isTestFile(e.target_file));
|
|
286
|
+
const totalMermaidFnEdges = edges.length;
|
|
287
|
+
if (edgeLimit && edges.length > edgeLimit) edges = edges.slice(0, edgeLimit);
|
|
268
288
|
|
|
269
289
|
// Group nodes by file for subgraphs
|
|
270
290
|
const fileNodes = new Map();
|
|
@@ -301,6 +321,9 @@ export function exportMermaid(db, opts = {}) {
|
|
|
301
321
|
const tId = nodeId(`${e.target_file}::${e.target_name}`);
|
|
302
322
|
lines.push(` ${sId} -->|${e.edge_kind}| ${tId}`);
|
|
303
323
|
}
|
|
324
|
+
if (edgeLimit && totalMermaidFnEdges > edgeLimit) {
|
|
325
|
+
lines.push(` %% Truncated: showing ${edges.length} of ${totalMermaidFnEdges} edges`);
|
|
326
|
+
}
|
|
304
327
|
|
|
305
328
|
// Role styling — query roles for all referenced nodes
|
|
306
329
|
const allKeys = [...nodeIdMap.keys()];
|
|
@@ -348,5 +371,6 @@ export function exportJSON(db, opts = {}) {
|
|
|
348
371
|
.all(minConf);
|
|
349
372
|
if (noTests) edges = edges.filter((e) => !isTestFile(e.source) && !isTestFile(e.target));
|
|
350
373
|
|
|
351
|
-
|
|
374
|
+
const base = { nodes, edges };
|
|
375
|
+
return paginateResult(base, 'edges', { limit: opts.limit, offset: opts.offset });
|
|
352
376
|
}
|