@optave/codegraph 2.5.1 → 2.6.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/src/owners.js ADDED
@@ -0,0 +1,359 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { findDbPath, openReadonlyOrFail } from './db.js';
4
+ import { isTestFile } from './queries.js';
5
+
6
+ // ─── CODEOWNERS Parsing ──────────────────────────────────────────────
7
+
8
+ const CODEOWNERS_PATHS = ['CODEOWNERS', '.github/CODEOWNERS', 'docs/CODEOWNERS'];
9
+
10
+ /** @type {Map<string, { rules: Array, path: string, mtime: number }>} */
11
+ const codeownersCache = new Map();
12
+
13
+ /**
14
+ * Find and parse a CODEOWNERS file from the standard locations.
15
+ * Results are cached per rootDir and invalidated when the file's mtime changes.
16
+ * @param {string} rootDir - Repository root directory
17
+ * @returns {{ rules: Array<{pattern: string, owners: string[], regex: RegExp}>, path: string } | null}
18
+ */
19
+ export function parseCodeowners(rootDir) {
20
+ const cached = codeownersCache.get(rootDir);
21
+
22
+ for (const rel of CODEOWNERS_PATHS) {
23
+ const fullPath = path.join(rootDir, rel);
24
+ if (fs.existsSync(fullPath)) {
25
+ const mtime = fs.statSync(fullPath).mtimeMs;
26
+ if (cached && cached.path === rel && cached.mtime === mtime) {
27
+ return { rules: cached.rules, path: cached.path };
28
+ }
29
+ const content = fs.readFileSync(fullPath, 'utf-8');
30
+ const rules = parseCodeownersContent(content);
31
+ codeownersCache.set(rootDir, { rules, path: rel, mtime });
32
+ return { rules, path: rel };
33
+ }
34
+ }
35
+ codeownersCache.delete(rootDir);
36
+ return null;
37
+ }
38
+
39
+ /** Clear the parseCodeowners cache (for testing). */
40
+ export function clearCodeownersCache() {
41
+ codeownersCache.clear();
42
+ }
43
+
44
+ /**
45
+ * Parse CODEOWNERS file content into rules.
46
+ * @param {string} content - Raw CODEOWNERS file content
47
+ * @returns {Array<{pattern: string, owners: string[], regex: RegExp}>}
48
+ */
49
+ export function parseCodeownersContent(content) {
50
+ const rules = [];
51
+ for (const raw of content.split('\n')) {
52
+ const line = raw.trim();
53
+ if (!line || line.startsWith('#')) continue;
54
+ const parts = line.split(/\s+/);
55
+ if (parts.length < 2) continue;
56
+ const pattern = parts[0];
57
+ const owners = parts.slice(1).filter((p) => p.startsWith('@') || /^[^@\s]+@[^@\s]+$/.test(p));
58
+ if (owners.length === 0) continue;
59
+ rules.push({ pattern, owners, regex: patternToRegex(pattern) });
60
+ }
61
+ return rules;
62
+ }
63
+
64
+ /**
65
+ * Convert a CODEOWNERS glob pattern to a RegExp.
66
+ *
67
+ * CODEOWNERS semantics:
68
+ * - Leading `/` anchors to repo root; without it, matches anywhere
69
+ * - `*` matches anything except `/`
70
+ * - `**` matches everything including `/`
71
+ * - Trailing `/` matches directory contents
72
+ * - A bare filename like `Makefile` matches anywhere
73
+ */
74
+ export function patternToRegex(pattern) {
75
+ let p = pattern;
76
+ const anchored = p.startsWith('/');
77
+ if (anchored) p = p.slice(1);
78
+
79
+ const dirMatch = p.endsWith('/');
80
+ if (dirMatch) p = p.slice(0, -1);
81
+
82
+ // Escape regex specials except * and ?
83
+ let regex = p.replace(/[.+^${}()|[\]\\]/g, '\\$&');
84
+
85
+ // Replace ? first (single non-slash char) before ** handling
86
+ regex = regex.replace(/\?/g, '[^/]');
87
+
88
+ // Handle **/ (zero or more directories) and /** (everything below)
89
+ // Use placeholders to prevent single-* replacement from clobbering
90
+ regex = regex.replace(/\*\*\//g, '<DSS>');
91
+ regex = regex.replace(/\/\*\*/g, '<DSE>');
92
+ regex = regex.replace(/\*\*/g, '<DS>');
93
+ regex = regex.replace(/\*/g, '[^/]*');
94
+ regex = regex.replace(/<DSS>/g, '(.*/)?');
95
+ regex = regex.replace(/<DSE>/g, '/.*');
96
+ regex = regex.replace(/<DS>/g, '.*');
97
+
98
+ if (dirMatch) {
99
+ // Pattern like `docs/` matches everything under docs/
100
+ regex = anchored ? `^${regex}/` : `(?:^|/)${regex}/`;
101
+ } else if (anchored) {
102
+ regex = `^${regex}$`;
103
+ } else if (!regex.includes('/')) {
104
+ // Bare filename like `Makefile` or `*.js` — match anywhere
105
+ regex = `(?:^|/)${regex}$`;
106
+ } else {
107
+ // Pattern with path separators but not anchored — match at start or after /
108
+ regex = `(?:^|/)${regex}$`;
109
+ }
110
+
111
+ return new RegExp(regex);
112
+ }
113
+
114
+ /**
115
+ * Find the owners for a file path. CODEOWNERS uses last-match-wins semantics.
116
+ * @param {string} filePath - Relative file path (forward slashes)
117
+ * @param {Array<{pattern: string, owners: string[], regex: RegExp}>} rules
118
+ * @returns {string[]}
119
+ */
120
+ export function matchOwners(filePath, rules) {
121
+ let owners = [];
122
+ for (const rule of rules) {
123
+ if (rule.regex.test(filePath)) {
124
+ owners = rule.owners;
125
+ }
126
+ }
127
+ return owners;
128
+ }
129
+
130
+ // ─── Data Functions ──────────────────────────────────────────────────
131
+
132
+ /**
133
+ * Lightweight helper for diff-impact integration.
134
+ * Returns owner mapping for a list of file paths.
135
+ * @param {string[]} filePaths - Relative file paths
136
+ * @param {string} repoRoot - Repository root directory
137
+ * @returns {{ owners: Map<string, string[]>, affectedOwners: string[], suggestedReviewers: string[] }}
138
+ */
139
+ export function ownersForFiles(filePaths, repoRoot) {
140
+ const parsed = parseCodeowners(repoRoot);
141
+ if (!parsed) return { owners: new Map(), affectedOwners: [], suggestedReviewers: [] };
142
+
143
+ const ownersMap = new Map();
144
+ const ownerSet = new Set();
145
+ for (const file of filePaths) {
146
+ const fileOwners = matchOwners(file, parsed.rules);
147
+ ownersMap.set(file, fileOwners);
148
+ for (const o of fileOwners) ownerSet.add(o);
149
+ }
150
+ const affectedOwners = [...ownerSet].sort();
151
+ return { owners: ownersMap, affectedOwners, suggestedReviewers: affectedOwners };
152
+ }
153
+
154
+ /**
155
+ * Full ownership data for the graph.
156
+ * @param {string} [customDbPath]
157
+ * @param {object} [opts]
158
+ * @param {string} [opts.owner] - Filter to a specific owner
159
+ * @param {string} [opts.file] - Filter by partial file path
160
+ * @param {string} [opts.kind] - Filter by symbol kind
161
+ * @param {boolean} [opts.noTests] - Exclude test files
162
+ * @param {boolean} [opts.boundary] - Show cross-owner boundary edges
163
+ */
164
+ export function ownersData(customDbPath, opts = {}) {
165
+ const db = openReadonlyOrFail(customDbPath);
166
+ const dbPath = findDbPath(customDbPath);
167
+ const repoRoot = path.resolve(path.dirname(dbPath), '..');
168
+
169
+ const parsed = parseCodeowners(repoRoot);
170
+ if (!parsed) {
171
+ db.close();
172
+ return {
173
+ codeownersFile: null,
174
+ files: [],
175
+ symbols: [],
176
+ boundaries: [],
177
+ summary: {
178
+ totalFiles: 0,
179
+ ownedFiles: 0,
180
+ unownedFiles: 0,
181
+ coveragePercent: 0,
182
+ ownerCount: 0,
183
+ byOwner: [],
184
+ },
185
+ };
186
+ }
187
+
188
+ // Get all distinct files from nodes
189
+ let allFiles = db
190
+ .prepare('SELECT DISTINCT file FROM nodes')
191
+ .all()
192
+ .map((r) => r.file);
193
+
194
+ if (opts.noTests) allFiles = allFiles.filter((f) => !isTestFile(f));
195
+ if (opts.file) {
196
+ const filter = opts.file;
197
+ allFiles = allFiles.filter((f) => f.includes(filter));
198
+ }
199
+
200
+ // Map files to owners
201
+ const fileOwners = allFiles.map((file) => ({
202
+ file,
203
+ owners: matchOwners(file, parsed.rules),
204
+ }));
205
+
206
+ // Build owner-to-files index
207
+ const ownerIndex = new Map();
208
+ let ownedCount = 0;
209
+ for (const fo of fileOwners) {
210
+ if (fo.owners.length > 0) ownedCount++;
211
+ for (const o of fo.owners) {
212
+ if (!ownerIndex.has(o)) ownerIndex.set(o, []);
213
+ ownerIndex.get(o).push(fo.file);
214
+ }
215
+ }
216
+
217
+ // Filter files if --owner specified
218
+ let filteredFiles = fileOwners;
219
+ if (opts.owner) {
220
+ filteredFiles = fileOwners.filter((fo) => fo.owners.includes(opts.owner));
221
+ }
222
+
223
+ // Get symbols for filtered files
224
+ const fileSet = new Set(filteredFiles.map((fo) => fo.file));
225
+ let symbols = db
226
+ .prepare('SELECT name, kind, file, line FROM nodes')
227
+ .all()
228
+ .filter((n) => fileSet.has(n.file));
229
+
230
+ if (opts.noTests) symbols = symbols.filter((s) => !isTestFile(s.file));
231
+ if (opts.kind) symbols = symbols.filter((s) => s.kind === opts.kind);
232
+
233
+ const symbolsWithOwners = symbols.map((s) => ({
234
+ ...s,
235
+ owners: matchOwners(s.file, parsed.rules),
236
+ }));
237
+
238
+ // Boundary analysis — cross-owner call edges
239
+ const boundaries = [];
240
+ if (opts.boundary) {
241
+ const edges = db
242
+ .prepare(
243
+ `SELECT e.id, e.kind AS edgeKind,
244
+ s.name AS srcName, s.kind AS srcKind, s.file AS srcFile, s.line AS srcLine,
245
+ t.name AS tgtName, t.kind AS tgtKind, t.file AS tgtFile, t.line AS tgtLine
246
+ FROM edges e
247
+ JOIN nodes s ON e.source_id = s.id
248
+ JOIN nodes t ON e.target_id = t.id
249
+ WHERE e.kind = 'calls'`,
250
+ )
251
+ .all();
252
+
253
+ for (const e of edges) {
254
+ if (opts.noTests && (isTestFile(e.srcFile) || isTestFile(e.tgtFile))) continue;
255
+ const srcOwners = matchOwners(e.srcFile, parsed.rules);
256
+ const tgtOwners = matchOwners(e.tgtFile, parsed.rules);
257
+ // Cross-boundary: different owner sets
258
+ const srcKey = srcOwners.sort().join(',');
259
+ const tgtKey = tgtOwners.sort().join(',');
260
+ if (srcKey !== tgtKey) {
261
+ boundaries.push({
262
+ from: {
263
+ name: e.srcName,
264
+ kind: e.srcKind,
265
+ file: e.srcFile,
266
+ line: e.srcLine,
267
+ owners: srcOwners,
268
+ },
269
+ to: {
270
+ name: e.tgtName,
271
+ kind: e.tgtKind,
272
+ file: e.tgtFile,
273
+ line: e.tgtLine,
274
+ owners: tgtOwners,
275
+ },
276
+ edgeKind: e.edgeKind,
277
+ });
278
+ }
279
+ }
280
+ }
281
+
282
+ // Summary
283
+ const byOwner = [...ownerIndex.entries()]
284
+ .map(([owner, files]) => ({ owner, fileCount: files.length }))
285
+ .sort((a, b) => b.fileCount - a.fileCount);
286
+
287
+ db.close();
288
+ return {
289
+ codeownersFile: parsed.path,
290
+ files: filteredFiles,
291
+ symbols: symbolsWithOwners,
292
+ boundaries,
293
+ summary: {
294
+ totalFiles: allFiles.length,
295
+ ownedFiles: ownedCount,
296
+ unownedFiles: allFiles.length - ownedCount,
297
+ coveragePercent: allFiles.length > 0 ? Math.round((ownedCount / allFiles.length) * 100) : 0,
298
+ ownerCount: ownerIndex.size,
299
+ byOwner,
300
+ },
301
+ };
302
+ }
303
+
304
+ // ─── CLI Display ─────────────────────────────────────────────────────
305
+
306
+ /**
307
+ * CLI display function for the `owners` command.
308
+ * @param {string} [customDbPath]
309
+ * @param {object} [opts]
310
+ */
311
+ export function owners(customDbPath, opts = {}) {
312
+ const data = ownersData(customDbPath, opts);
313
+ if (opts.json) {
314
+ console.log(JSON.stringify(data, null, 2));
315
+ return;
316
+ }
317
+
318
+ if (!data.codeownersFile) {
319
+ console.log('No CODEOWNERS file found.');
320
+ return;
321
+ }
322
+
323
+ console.log(`\nCODEOWNERS: ${data.codeownersFile}\n`);
324
+
325
+ const s = data.summary;
326
+ console.log(
327
+ ` Coverage: ${s.coveragePercent}% (${s.ownedFiles}/${s.totalFiles} files owned, ${s.ownerCount} owners)\n`,
328
+ );
329
+
330
+ if (s.byOwner.length > 0) {
331
+ console.log(' Owners:\n');
332
+ for (const o of s.byOwner) {
333
+ console.log(` ${o.owner} ${o.fileCount} files`);
334
+ }
335
+ console.log();
336
+ }
337
+
338
+ if (data.files.length > 0 && opts.owner) {
339
+ console.log(` Files owned by ${opts.owner}:\n`);
340
+ for (const f of data.files) {
341
+ console.log(` ${f.file}`);
342
+ }
343
+ console.log();
344
+ }
345
+
346
+ if (data.boundaries.length > 0) {
347
+ console.log(` Cross-owner boundaries: ${data.boundaries.length} edges\n`);
348
+ const shown = data.boundaries.slice(0, 30);
349
+ for (const b of shown) {
350
+ const srcOwner = b.from.owners.join(', ') || '(unowned)';
351
+ const tgtOwner = b.to.owners.join(', ') || '(unowned)';
352
+ console.log(` ${b.from.name} [${srcOwner}] -> ${b.to.name} [${tgtOwner}]`);
353
+ }
354
+ if (data.boundaries.length > 30) {
355
+ console.log(` ... and ${data.boundaries.length - 30} more`);
356
+ }
357
+ console.log();
358
+ }
359
+ }
package/src/paginate.js CHANGED
@@ -7,12 +7,30 @@
7
7
 
8
8
  /** Default limits applied by MCP tool handlers (not by the programmatic API). */
9
9
  export const MCP_DEFAULTS = {
10
+ // Existing
10
11
  list_functions: 100,
11
12
  query_function: 50,
12
13
  where: 50,
13
14
  node_roles: 100,
14
15
  list_entry_points: 100,
15
16
  export_graph: 500,
17
+ // Smaller defaults for rich/nested results
18
+ fn_deps: 10,
19
+ fn_impact: 5,
20
+ context: 5,
21
+ explain: 10,
22
+ file_deps: 20,
23
+ diff_impact: 30,
24
+ impact_analysis: 20,
25
+ semantic_search: 20,
26
+ execution_flow: 50,
27
+ hotspots: 20,
28
+ co_changes: 20,
29
+ complexity: 30,
30
+ manifesto: 50,
31
+ communities: 20,
32
+ structure: 30,
33
+ triage: 20,
16
34
  };
17
35
 
18
36
  /** Hard cap to prevent abuse via MCP. */
@@ -68,3 +86,20 @@ export function paginateResult(result, field, { limit, offset } = {}) {
68
86
  const { items, pagination } = paginate(arr, { limit, offset });
69
87
  return { ...result, [field]: items, _pagination: pagination };
70
88
  }
89
+
90
+ /**
91
+ * Print data as newline-delimited JSON (NDJSON).
92
+ *
93
+ * Emits a `_meta` line with pagination info (if present), then one JSON
94
+ * line per item in the named array field.
95
+ *
96
+ * @param {object} data - Result object (may contain `_pagination`)
97
+ * @param {string} field - Array field name to stream (e.g. `'results'`)
98
+ */
99
+ export function printNdjson(data, field) {
100
+ if (data._pagination) console.log(JSON.stringify({ _meta: data._pagination }));
101
+ const items = data[field];
102
+ if (Array.isArray(items)) {
103
+ for (const item of items) console.log(JSON.stringify(item));
104
+ }
105
+ }