@optave/codegraph 3.12.0 → 3.13.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.
Files changed (144) hide show
  1. package/README.md +71 -35
  2. package/dist/cli/commands/audit.d.ts.map +1 -1
  3. package/dist/cli/commands/audit.js +2 -1
  4. package/dist/cli/commands/audit.js.map +1 -1
  5. package/dist/cli/commands/batch.d.ts.map +1 -1
  6. package/dist/cli/commands/batch.js +1 -0
  7. package/dist/cli/commands/batch.js.map +1 -1
  8. package/dist/cli/commands/build.d.ts.map +1 -1
  9. package/dist/cli/commands/build.js +6 -1
  10. package/dist/cli/commands/build.js.map +1 -1
  11. package/dist/cli/commands/config.d.ts +3 -0
  12. package/dist/cli/commands/config.d.ts.map +1 -0
  13. package/dist/cli/commands/config.js +272 -0
  14. package/dist/cli/commands/config.js.map +1 -0
  15. package/dist/cli/commands/triage.js +1 -1
  16. package/dist/cli/commands/triage.js.map +1 -1
  17. package/dist/cli/index.d.ts.map +1 -1
  18. package/dist/cli/index.js +10 -0
  19. package/dist/cli/index.js.map +1 -1
  20. package/dist/cli/shared/options.d.ts +2 -1
  21. package/dist/cli/shared/options.d.ts.map +1 -1
  22. package/dist/cli/shared/options.js +11 -1
  23. package/dist/cli/shared/options.js.map +1 -1
  24. package/dist/cli/types.d.ts +2 -0
  25. package/dist/cli/types.d.ts.map +1 -1
  26. package/dist/db/migrations.js +1 -1
  27. package/dist/db/migrations.js.map +1 -1
  28. package/dist/domain/graph/builder/call-resolver.d.ts +12 -8
  29. package/dist/domain/graph/builder/call-resolver.d.ts.map +1 -1
  30. package/dist/domain/graph/builder/call-resolver.js +93 -38
  31. package/dist/domain/graph/builder/call-resolver.js.map +1 -1
  32. package/dist/domain/graph/builder/cha.d.ts +9 -1
  33. package/dist/domain/graph/builder/cha.d.ts.map +1 -1
  34. package/dist/domain/graph/builder/cha.js +17 -2
  35. package/dist/domain/graph/builder/cha.js.map +1 -1
  36. package/dist/domain/graph/builder/helpers.d.ts +8 -0
  37. package/dist/domain/graph/builder/helpers.d.ts.map +1 -1
  38. package/dist/domain/graph/builder/helpers.js +22 -3
  39. package/dist/domain/graph/builder/helpers.js.map +1 -1
  40. package/dist/domain/graph/builder/incremental.d.ts.map +1 -1
  41. package/dist/domain/graph/builder/incremental.js +1 -1
  42. package/dist/domain/graph/builder/incremental.js.map +1 -1
  43. package/dist/domain/graph/builder/pipeline.d.ts.map +1 -1
  44. package/dist/domain/graph/builder/pipeline.js +37 -2
  45. package/dist/domain/graph/builder/pipeline.js.map +1 -1
  46. package/dist/domain/graph/builder/stages/build-edges.d.ts +0 -2
  47. package/dist/domain/graph/builder/stages/build-edges.d.ts.map +1 -1
  48. package/dist/domain/graph/builder/stages/build-edges.js +88 -318
  49. package/dist/domain/graph/builder/stages/build-edges.js.map +1 -1
  50. package/dist/domain/graph/builder/stages/detect-changes.js +1 -1
  51. package/dist/domain/graph/builder/stages/detect-changes.js.map +1 -1
  52. package/dist/domain/graph/builder/stages/finalize.d.ts.map +1 -1
  53. package/dist/domain/graph/builder/stages/finalize.js +4 -0
  54. package/dist/domain/graph/builder/stages/finalize.js.map +1 -1
  55. package/dist/domain/graph/builder/stages/native-orchestrator.d.ts.map +1 -1
  56. package/dist/domain/graph/builder/stages/native-orchestrator.js +341 -82
  57. package/dist/domain/graph/builder/stages/native-orchestrator.js.map +1 -1
  58. package/dist/domain/graph/builder/stages/resolve-imports.js +1 -1
  59. package/dist/domain/graph/builder/stages/resolve-imports.js.map +1 -1
  60. package/dist/domain/parser.d.ts +4 -5
  61. package/dist/domain/parser.d.ts.map +1 -1
  62. package/dist/domain/parser.js +46 -15
  63. package/dist/domain/parser.js.map +1 -1
  64. package/dist/domain/wasm-worker-entry.js +10 -2
  65. package/dist/domain/wasm-worker-entry.js.map +1 -1
  66. package/dist/domain/wasm-worker-pool.d.ts.map +1 -1
  67. package/dist/domain/wasm-worker-pool.js +2 -0
  68. package/dist/domain/wasm-worker-pool.js.map +1 -1
  69. package/dist/domain/wasm-worker-protocol.d.ts +1 -0
  70. package/dist/domain/wasm-worker-protocol.d.ts.map +1 -1
  71. package/dist/extractors/cpp.d.ts.map +1 -1
  72. package/dist/extractors/cpp.js +42 -1
  73. package/dist/extractors/cpp.js.map +1 -1
  74. package/dist/extractors/cuda.d.ts.map +1 -1
  75. package/dist/extractors/cuda.js +42 -1
  76. package/dist/extractors/cuda.js.map +1 -1
  77. package/dist/extractors/helpers.d.ts +11 -0
  78. package/dist/extractors/helpers.d.ts.map +1 -1
  79. package/dist/extractors/helpers.js +40 -0
  80. package/dist/extractors/helpers.js.map +1 -1
  81. package/dist/extractors/java.d.ts.map +1 -1
  82. package/dist/extractors/java.js +8 -7
  83. package/dist/extractors/java.js.map +1 -1
  84. package/dist/extractors/javascript.js +137 -6
  85. package/dist/extractors/javascript.js.map +1 -1
  86. package/dist/features/structure-query.d.ts +1 -1
  87. package/dist/features/structure-query.d.ts.map +1 -1
  88. package/dist/features/structure-query.js +6 -6
  89. package/dist/features/structure-query.js.map +1 -1
  90. package/dist/index.d.ts +1 -1
  91. package/dist/index.d.ts.map +1 -1
  92. package/dist/index.js +1 -1
  93. package/dist/index.js.map +1 -1
  94. package/dist/infrastructure/config.d.ts +77 -4
  95. package/dist/infrastructure/config.d.ts.map +1 -1
  96. package/dist/infrastructure/config.js +395 -21
  97. package/dist/infrastructure/config.js.map +1 -1
  98. package/dist/infrastructure/registry.d.ts +27 -0
  99. package/dist/infrastructure/registry.d.ts.map +1 -1
  100. package/dist/infrastructure/registry.js +59 -1
  101. package/dist/infrastructure/registry.js.map +1 -1
  102. package/dist/presentation/structure.d.ts +1 -1
  103. package/dist/presentation/structure.d.ts.map +1 -1
  104. package/dist/presentation/structure.js +2 -2
  105. package/dist/presentation/structure.js.map +1 -1
  106. package/dist/types.d.ts +37 -0
  107. package/dist/types.d.ts.map +1 -1
  108. package/grammars/tree-sitter-gleam.wasm +0 -0
  109. package/package.json +7 -8
  110. package/src/cli/commands/audit.ts +2 -1
  111. package/src/cli/commands/batch.ts +1 -0
  112. package/src/cli/commands/build.ts +6 -1
  113. package/src/cli/commands/config.ts +353 -0
  114. package/src/cli/commands/triage.ts +1 -1
  115. package/src/cli/index.ts +10 -0
  116. package/src/cli/shared/options.ts +11 -1
  117. package/src/cli/types.ts +2 -0
  118. package/src/db/migrations.ts +1 -1
  119. package/src/domain/graph/builder/call-resolver.ts +99 -41
  120. package/src/domain/graph/builder/cha.ts +18 -1
  121. package/src/domain/graph/builder/helpers.ts +24 -4
  122. package/src/domain/graph/builder/incremental.ts +1 -0
  123. package/src/domain/graph/builder/pipeline.ts +49 -2
  124. package/src/domain/graph/builder/stages/build-edges.ts +130 -399
  125. package/src/domain/graph/builder/stages/detect-changes.ts +1 -1
  126. package/src/domain/graph/builder/stages/finalize.ts +4 -0
  127. package/src/domain/graph/builder/stages/native-orchestrator.ts +396 -92
  128. package/src/domain/graph/builder/stages/resolve-imports.ts +1 -1
  129. package/src/domain/parser.ts +45 -14
  130. package/src/domain/wasm-worker-entry.ts +10 -2
  131. package/src/domain/wasm-worker-pool.ts +1 -0
  132. package/src/domain/wasm-worker-protocol.ts +1 -0
  133. package/src/extractors/cpp.ts +44 -1
  134. package/src/extractors/cuda.ts +44 -1
  135. package/src/extractors/helpers.ts +43 -0
  136. package/src/extractors/java.ts +8 -7
  137. package/src/extractors/javascript.ts +127 -6
  138. package/src/features/structure-query.ts +7 -7
  139. package/src/index.ts +5 -1
  140. package/src/infrastructure/config.ts +481 -22
  141. package/src/infrastructure/registry.ts +82 -1
  142. package/src/presentation/structure.ts +3 -3
  143. package/src/types.ts +41 -0
  144. package/grammars/tree-sitter-erlang.wasm +0 -0
@@ -0,0 +1,353 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import {
5
+ clearConfigCache,
6
+ DEFAULTS,
7
+ getDefaultUserConfigPath,
8
+ loadConfig,
9
+ loadConfigWithProvenance,
10
+ resolveUserConfigPath,
11
+ } from '../../infrastructure/config.js';
12
+ import {
13
+ getUserConfigConsent,
14
+ listUserConfigConsent,
15
+ REGISTRY_PATH,
16
+ setUserConfigConsent,
17
+ } from '../../infrastructure/registry.js';
18
+ import { formatTable } from '../../presentation/table.js';
19
+ import type { ConfigSource } from '../../types.js';
20
+ import type { CommandDefinition } from '../types.js';
21
+
22
+ /**
23
+ * Recursively flatten a nested config object to dot-notation key/value pairs.
24
+ * Arrays and null values are serialised to strings.
25
+ */
26
+ function flattenConfig(
27
+ obj: Record<string, unknown>,
28
+ prefix = '',
29
+ ): Array<{ key: string; value: string }> {
30
+ const out: Array<{ key: string; value: string }> = [];
31
+ for (const [k, v] of Object.entries(obj)) {
32
+ const fullKey = prefix ? `${prefix}.${k}` : k;
33
+ if (v !== null && typeof v === 'object' && !Array.isArray(v)) {
34
+ out.push(...flattenConfig(v as Record<string, unknown>, fullKey));
35
+ } else if (Array.isArray(v)) {
36
+ out.push({ key: fullKey, value: v.length === 0 ? '[]' : JSON.stringify(v) });
37
+ } else {
38
+ out.push({ key: fullKey, value: v === null ? 'null' : String(v) });
39
+ }
40
+ }
41
+ return out;
42
+ }
43
+
44
+ /**
45
+ * Expand a top-level provenance map (e.g. { build: 'project' }) to cover every
46
+ * flattened dot-notation key (e.g. 'build.incremental' → 'project').
47
+ */
48
+ function expandProvenance(
49
+ flatEntries: Array<{ key: string; value: string }>,
50
+ provenance: Record<string, ConfigSource>,
51
+ ): Map<string, ConfigSource> {
52
+ const map = new Map<string, ConfigSource>();
53
+ for (const { key } of flatEntries) {
54
+ // Provenance is keyed by top-level section (e.g. 'build', 'llm'), so
55
+ // extract the first segment to find the governing provenance entry.
56
+ const topLevel = key.split('.')[0] ?? key;
57
+ map.set(key, provenance[topLevel] ?? 'default');
58
+ }
59
+ return map;
60
+ }
61
+
62
+ /**
63
+ * Render the effective config as a human-readable Key/Value/Source table.
64
+ * All rows are shown, sorted so non-default overrides appear first, then
65
+ * remaining defaults alphabetically.
66
+ */
67
+ function renderConfigTable(
68
+ config: Record<string, unknown>,
69
+ provenance: Record<string, ConfigSource>,
70
+ ): string {
71
+ const flat = flattenConfig(config);
72
+ const sourceMap = expandProvenance(flat, provenance);
73
+
74
+ // Show all entries — sorting non-defaults first, then alphabetically
75
+ const rows = flat
76
+ .slice()
77
+ .sort((a, b) => {
78
+ const sa = sourceMap.get(a.key) ?? 'default';
79
+ const sb = sourceMap.get(b.key) ?? 'default';
80
+ // Non-defaults first
81
+ if (sa !== 'default' && sb === 'default') return -1;
82
+ if (sa === 'default' && sb !== 'default') return 1;
83
+ return a.key.localeCompare(b.key);
84
+ })
85
+ .map(({ key, value }) => [key, value, sourceMap.get(key) ?? 'default']);
86
+
87
+ const keyWidth = Math.max(3, ...rows.map((r) => r[0]!.length));
88
+ const valWidth = Math.max(5, ...rows.map((r) => r[1]!.length));
89
+ // Source column is always short ('default', 'user', 'project', 'env')
90
+ const srcWidth = 7;
91
+
92
+ return `${formatTable({
93
+ columns: [
94
+ { header: 'Key', width: keyWidth },
95
+ { header: 'Value', width: valWidth },
96
+ { header: 'Source', width: srcWidth },
97
+ ],
98
+ rows: rows as string[][],
99
+ indent: 0,
100
+ })}\n`;
101
+ }
102
+
103
+ /**
104
+ * Build a scaffolded global config JSON file.
105
+ * Produces valid JSON with common sections pre-populated at their defaults.
106
+ * Uses DEFAULTS so the values always reflect the current schema.
107
+ *
108
+ * All keys are optional — users can delete sections they don't need.
109
+ */
110
+ function buildInitTemplate(): string {
111
+ // Build a plain object — no comments in JSON, but keep it self-explanatory.
112
+ // Unknown top-level keys are silently ignored by mergeConfig.
113
+ const template: Record<string, unknown> = {
114
+ // LLM provider for AI features (codegraph explain, context, etc.)
115
+ // Use apiKeyCommand to pull the key from a secret manager at runtime.
116
+ // Scope to specific repos with:
117
+ // { "appliesTo": ["~/projects/*"], "config": { ... } }
118
+ llm: {
119
+ provider: DEFAULTS.llm.provider,
120
+ model: DEFAULTS.llm.model,
121
+ baseUrl: DEFAULTS.llm.baseUrl,
122
+ apiKey: DEFAULTS.llm.apiKey,
123
+ apiKeyCommand: DEFAULTS.llm.apiKeyCommand,
124
+ },
125
+
126
+ query: {
127
+ defaultDepth: DEFAULTS.query.defaultDepth,
128
+ defaultLimit: DEFAULTS.query.defaultLimit,
129
+ excludeTests: DEFAULTS.query.excludeTests,
130
+ },
131
+
132
+ build: {
133
+ incremental: DEFAULTS.build.incremental,
134
+ typescriptResolver: DEFAULTS.build.typescriptResolver,
135
+ },
136
+
137
+ ci: {
138
+ failOnCycles: DEFAULTS.ci.failOnCycles,
139
+ impactThreshold: DEFAULTS.ci.impactThreshold,
140
+ },
141
+
142
+ search: {
143
+ defaultMinScore: DEFAULTS.search.defaultMinScore,
144
+ topK: DEFAULTS.search.topK,
145
+ },
146
+ };
147
+
148
+ return `${JSON.stringify(template, null, 2)}\n`;
149
+ }
150
+
151
+ export const command: CommandDefinition = {
152
+ name: 'config',
153
+ description: 'Show or manage codegraph configuration (project + user-level global config)',
154
+ options: [
155
+ ['-j, --json', 'Output as JSON'],
156
+ ['--explain', 'Show per-key provenance (default / user / project / env)'],
157
+ ['--enable-global', 'Record consent to apply the global config to this repo'],
158
+ ['--disable-global', 'Record consent to skip the global config for this repo'],
159
+ ['--list-global', 'List all repos with a recorded consent decision'],
160
+ [
161
+ '--init',
162
+ 'Scaffold a global config file at the default XDG location with all sections pre-populated',
163
+ ],
164
+ ['--edit', 'Open the global config file in $EDITOR (prints the path if $EDITOR is unset)'],
165
+ ],
166
+ execute(_args, opts, ctx) {
167
+ const rootDir = path.resolve('.');
168
+
169
+ // ── Init: scaffold global config ───────────────────────────────────
170
+
171
+ if (opts.init) {
172
+ const targetPath = getDefaultUserConfigPath();
173
+ if (fs.existsSync(targetPath)) {
174
+ process.stderr.write(
175
+ `Global config already exists at ${targetPath}\n` +
176
+ `Run \`codegraph config --edit\` to open it, or delete it and re-run --init.\n`,
177
+ );
178
+ process.exit(1);
179
+ }
180
+ fs.mkdirSync(path.dirname(targetPath), { recursive: true });
181
+ fs.writeFileSync(targetPath, buildInitTemplate(), 'utf-8');
182
+ process.stdout.write(`Created global config at ${targetPath}\n`);
183
+ process.stdout.write(
184
+ `Next steps:\n` +
185
+ ` 1. Edit the file: codegraph config --edit\n` +
186
+ ` 2. Enable it for this repo: codegraph config --enable-global\n`,
187
+ );
188
+ return;
189
+ }
190
+
191
+ // ── Edit: open global config in $EDITOR ────────────────────────────
192
+
193
+ if (opts.edit) {
194
+ // Prefer the existing file; fall back to the default path so the user
195
+ // can create-and-edit in one step even before running --init.
196
+ const filePath = resolveUserConfigPath() ?? getDefaultUserConfigPath();
197
+
198
+ const editor = process.env.EDITOR || process.env.VISUAL;
199
+ if (!editor) {
200
+ process.stdout.write(`${filePath}\n`);
201
+ process.stderr.write(
202
+ `$EDITOR is not set. Set it in your shell profile (e.g. export EDITOR=nano)\n` +
203
+ `or open the file manually at the path printed above.\n`,
204
+ );
205
+ return;
206
+ }
207
+
208
+ // Ensure the directory exists so the editor can create the file
209
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
210
+
211
+ const result = spawnSync(editor, [filePath], { stdio: 'inherit' });
212
+ if (result.error) {
213
+ process.stderr.write(`Failed to launch editor "${editor}": ${result.error.message}\n`);
214
+ process.exit(1);
215
+ }
216
+ if (result.status !== 0) {
217
+ process.exit(result.status ?? 1);
218
+ }
219
+ return;
220
+ }
221
+
222
+ // ── Consent management ─────────────────────────────────────────────
223
+
224
+ if (opts.enableGlobal) {
225
+ setUserConfigConsent(rootDir, 'enabled');
226
+ clearConfigCache();
227
+ const globalPath = resolveUserConfigPath();
228
+ if (!globalPath) {
229
+ process.stderr.write(
230
+ `Consent recorded: "enabled" for ${rootDir}\n` +
231
+ `Note: no global config file found. Create one at ~/.config/codegraph/config.json\n`,
232
+ );
233
+ } else {
234
+ process.stderr.write(
235
+ `Consent recorded: "enabled" for ${rootDir}\n` + `Global config: ${globalPath}\n`,
236
+ );
237
+ }
238
+ return;
239
+ }
240
+
241
+ if (opts.disableGlobal) {
242
+ setUserConfigConsent(rootDir, 'disabled');
243
+ clearConfigCache();
244
+ process.stderr.write(`Consent recorded: "disabled" for ${rootDir}\n`);
245
+ return;
246
+ }
247
+
248
+ if (opts.listGlobal) {
249
+ const entries = listUserConfigConsent(REGISTRY_PATH);
250
+ if (opts.json) {
251
+ process.stdout.write(`${JSON.stringify(entries, null, 2)}\n`);
252
+ return;
253
+ }
254
+ if (entries.length === 0) {
255
+ process.stdout.write('No repos have a recorded global-config consent decision.\n');
256
+ return;
257
+ }
258
+ process.stdout.write('Global config consent decisions:\n\n');
259
+ for (const { path: p, decision } of entries) {
260
+ process.stdout.write(
261
+ ` ${decision === 'enabled' ? '✔' : '✘'} ${decision.padEnd(8)} ${p}\n`,
262
+ );
263
+ }
264
+ return;
265
+ }
266
+
267
+ // ── Explain mode ───────────────────────────────────────────────────
268
+
269
+ if (opts.explain) {
270
+ const { config, provenance, appliedGlobalPath, consentDecision } = loadConfigWithProvenance(
271
+ rootDir,
272
+ {
273
+ userConfig: ctx.program.opts().userConfig,
274
+ },
275
+ );
276
+ const globalPath = resolveUserConfigPath();
277
+ const consent = getUserConfigConsent(rootDir);
278
+
279
+ if (opts.json) {
280
+ process.stdout.write(
281
+ `${JSON.stringify(
282
+ {
283
+ config,
284
+ provenance,
285
+ appliedGlobalPath,
286
+ globalFilePath: globalPath,
287
+ consentDecision: consentDecision ?? consent ?? 'undecided',
288
+ },
289
+ null,
290
+ 2,
291
+ )}\n`,
292
+ );
293
+ return;
294
+ }
295
+
296
+ // Human-readable explain output
297
+ process.stdout.write('=== Codegraph config provenance ===\n\n');
298
+
299
+ const consentStr = consentDecision ?? consent ?? 'undecided';
300
+ process.stdout.write(`Global config file : ${globalPath ?? '(none found)'}\n`);
301
+ process.stdout.write(`Applied this run : ${appliedGlobalPath ? 'yes' : 'no'}\n`);
302
+ process.stdout.write(`Consent for repo : ${consentStr}\n`);
303
+ process.stdout.write(
304
+ ` (change with \`codegraph config --enable-global\` or \`--disable-global\`)\n`,
305
+ );
306
+
307
+ if (!globalPath) {
308
+ process.stdout.write(
309
+ `\nDiscovery hint: create a global config at ~/.config/codegraph/config.json\n` +
310
+ `then run \`codegraph config --enable-global\` in repos where you want it applied.\n`,
311
+ );
312
+ } else if (!appliedGlobalPath) {
313
+ process.stdout.write(
314
+ `\nDiscovery hint: global config exists but is not applied to this repo.\n` +
315
+ `Run \`codegraph config --enable-global\` to enable it here.\n`,
316
+ );
317
+ }
318
+
319
+ process.stdout.write('\n--- Per-key provenance ---\n\n');
320
+ const provenanceEntries = Object.entries(provenance).sort(([a], [b]) => a.localeCompare(b));
321
+ for (const [key, source] of provenanceEntries) {
322
+ process.stdout.write(` ${source.padEnd(8)} ${key}\n`);
323
+ }
324
+ return;
325
+ }
326
+
327
+ // ── Default: print effective config ────────────────────────────────
328
+
329
+ const globalPath = resolveUserConfigPath();
330
+ const consent = getUserConfigConsent(rootDir);
331
+
332
+ if (opts.json) {
333
+ const config = loadConfig(rootDir, { userConfig: ctx.program.opts().userConfig });
334
+ process.stdout.write(`${JSON.stringify(config, null, 2)}\n`);
335
+ } else {
336
+ // Human-readable table: Key | Value | Source
337
+ const { config, provenance } = loadConfigWithProvenance(rootDir, {
338
+ userConfig: ctx.program.opts().userConfig,
339
+ });
340
+ process.stdout.write(
341
+ renderConfigTable(config as unknown as Record<string, unknown>, provenance),
342
+ );
343
+
344
+ if (globalPath && !consent) {
345
+ process.stderr.write(
346
+ `\nℹ Global config found at ${globalPath} — not applied to this repo.\n` +
347
+ ` Run \`codegraph config --enable-global\` to opt in, or\n` +
348
+ ` \`codegraph config --disable-global\` to dismiss this notice.\n`,
349
+ );
350
+ }
351
+ }
352
+ },
353
+ };
@@ -31,7 +31,7 @@ async function runHotspots(opts: CommandOpts, ctx: CliContext): Promise<void> {
31
31
  offset: opts.offset ? parseInt(opts.offset as string, 10) : undefined,
32
32
  noTests: ctx.resolveNoTests(opts),
33
33
  });
34
- if (!ctx.outputResult(data, 'hotspots', opts)) {
34
+ if (!ctx.outputResult(data, 'items', opts)) {
35
35
  console.log(formatHotspots(data));
36
36
  }
37
37
  }
package/src/cli/index.ts CHANGED
@@ -2,6 +2,7 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { fileURLToPath, pathToFileURL } from 'node:url';
4
4
  import { Command } from 'commander';
5
+ import { setUserConfigOverride } from '../infrastructure/config.js';
5
6
  import { setVerbose } from '../infrastructure/logger.js';
6
7
  import { checkForUpdates, printUpdateNotification } from '../infrastructure/update-check.js';
7
8
  import { ConfigError } from '../shared/errors.js';
@@ -25,9 +26,16 @@ program
25
26
  .version(pkg.version)
26
27
  .option('-v, --verbose', 'Enable verbose/debug output')
27
28
  .option('--engine <engine>', 'Parser engine: native, wasm, or auto (default: auto)', 'auto')
29
+ .option('--user-config [path]', 'Apply global user config for this run (optional custom path)')
30
+ .option('--no-user-config', 'Skip global user config for this run')
28
31
  .hook('preAction', (thisCommand) => {
29
32
  const opts = thisCommand.opts();
30
33
  if (opts.verbose) setVerbose(true);
34
+ // Wire user-config flags into the config loader before any command runs.
35
+ // Commander sets opts.userConfig = true (bare flag), a string (path), or undefined.
36
+ // opts.userConfig is false when --no-user-config is passed (Commander negation).
37
+ const uc = opts.userConfig as string | boolean | undefined;
38
+ setUserConfigOverride(uc);
31
39
  })
32
40
  .hook('postAction', async (_thisCommand, actionCommand) => {
33
41
  const name = actionCommand.name();
@@ -67,6 +75,8 @@ const ctx: CliContext = {
67
75
  function registerCommand(parent: Command, def: CommandDefinition): Command {
68
76
  const cmd = parent.command(def.name).description(def.description);
69
77
 
78
+ if (def.alias) cmd.alias(def.alias);
79
+
70
80
  if (def.queryOpts) applyQueryOpts(cmd);
71
81
 
72
82
  for (const opt of def.options || []) {
@@ -1,8 +1,18 @@
1
1
  import type { Command } from 'commander';
2
2
  import { loadConfig } from '../../infrastructure/config.js';
3
+ import type { CodegraphConfig } from '../../types.js';
3
4
  import type { CommandOpts } from '../types.js';
4
5
 
5
- const config = loadConfig(process.cwd());
6
+ // Deferred so global --user-config / --no-user-config flags are parsed
7
+ // before config is first accessed (Commander parses flags before any command
8
+ // action runs, but module-level code executes at import time).
9
+ let _config: CodegraphConfig | undefined;
10
+ const config: CodegraphConfig = new Proxy({} as CodegraphConfig, {
11
+ get(_t, prop: string) {
12
+ if (_config === undefined) _config = loadConfig(process.cwd());
13
+ return _config[prop as keyof CodegraphConfig];
14
+ },
15
+ }) as CodegraphConfig;
6
16
 
7
17
  /**
8
18
  * Attach the common query options shared by most analysis commands.
package/src/cli/types.ts CHANGED
@@ -25,6 +25,8 @@ export interface CliContext {
25
25
  export interface CommandDefinition {
26
26
  name: string;
27
27
  description: string;
28
+ /** Optional Commander.js alias (e.g. 'explain' for the 'audit' command). */
29
+ alias?: string;
28
30
  queryOpts?: boolean;
29
31
  options?: Array<[string, string, ...unknown[]]>;
30
32
  validate?(args: string[], opts: CommandOpts, ctx: CliContext): string | undefined;
@@ -8,7 +8,7 @@ interface Migration {
8
8
  up: string;
9
9
  }
10
10
 
11
- // IMPORTANT: Migration DDL is mirrored in crates/codegraph-core/src/native_db.rs.
11
+ // IMPORTANT: Migration DDL is mirrored in crates/codegraph-core/src/db/connection.rs.
12
12
  // Any changes here MUST be reflected there (and vice-versa).
13
13
  export const MIGRATIONS: Migration[] = [
14
14
  {
@@ -47,6 +47,22 @@ export function isModuleScopedLanguage(relPath: string): boolean {
47
47
 
48
48
  // ── Shared resolution functions ──────────────────────────────────────────
49
49
 
50
+ /**
51
+ * Callable definition kinds — variable/constant bindings are NOT callable
52
+ * in the function-as-enclosing-scope sense (they are local declarations, not
53
+ * function bodies). Top-level variable bindings (e.g. Haskell `main = do …`)
54
+ * are handled separately as a fallback tier.
55
+ */
56
+ const CALLABLE_KINDS = new Set(['function', 'method']);
57
+
58
+ /**
59
+ * Variable-like binding kinds that may act as top-level callers when no
60
+ * enclosing function/method exists (e.g. Haskell top-level `main` is a
61
+ * `bind` node → kind `variable`). Local variable declarations inside a
62
+ * function body must NOT win over the enclosing function.
63
+ */
64
+ const TOP_LEVEL_BINDING_KINDS = new Set(['variable', 'constant']);
65
+
50
66
  export function findCaller(
51
67
  lookup: CallNodeLookup,
52
68
  call: { line: number },
@@ -59,26 +75,63 @@ export function findCaller(
59
75
  relPath: string,
60
76
  fileNodeRow: { id: number },
61
77
  ): { id: number; callerName: string | null } {
62
- let caller: { id: number } | null = null;
63
- let callerName: string | null = null;
64
- let callerSpan = Infinity;
78
+ // Pass 1: find the narrowest enclosing function/method.
79
+ let fnCaller: { id: number } | null = null;
80
+ let fnCallerName: string | null = null;
81
+ let fnCallerSpan = Infinity;
82
+
83
+ // Pass 2: find the widest (outermost) enclosing variable/constant binding.
84
+ // Used as fallback when no function/method encloses the call site
85
+ // (e.g. Haskell `main = do …` is a `bind` node with kind `variable`).
86
+ // We pick the WIDEST span (outermost binding), not the narrowest, so that
87
+ // nested `let` bindings inside `main`'s do-block do not shadow `main`
88
+ // itself as the attributing caller. The outermost enclosing variable is
89
+ // the "function-like" top-level binding.
90
+ let varCaller: { id: number } | null = null;
91
+ let varCallerName: string | null = null;
92
+ let varCallerSpan = -1; // looking for WIDEST span, so start at -1
93
+
65
94
  for (const def of definitions) {
66
95
  if (def.line <= call.line) {
67
- const end = def.endLine || Infinity;
96
+ const end = def.endLine ?? Infinity;
68
97
  if (call.line <= end) {
69
- const span = end - def.line;
70
- if (span < callerSpan) {
71
- const row = lookup.nodeId(def.name, def.kind, relPath, def.line);
72
- if (row) {
73
- caller = row;
74
- callerName = def.name;
75
- callerSpan = span;
98
+ const span = end === Infinity ? Infinity : end - def.line;
99
+ if (CALLABLE_KINDS.has(def.kind)) {
100
+ if (span < fnCallerSpan) {
101
+ const row = lookup.nodeId(def.name, def.kind, relPath, def.line);
102
+ if (row) {
103
+ fnCaller = row;
104
+ fnCallerName = def.name;
105
+ fnCallerSpan = span;
106
+ }
107
+ }
108
+ } else if (TOP_LEVEL_BINDING_KINDS.has(def.kind)) {
109
+ if (span > varCallerSpan) {
110
+ const row = lookup.nodeId(def.name, def.kind, relPath, def.line);
111
+ if (row) {
112
+ varCaller = row;
113
+ varCallerName = def.name;
114
+ varCallerSpan = span;
115
+ }
76
116
  }
77
117
  }
78
118
  }
79
119
  }
80
120
  }
81
- return { ...(caller ?? fileNodeRow), callerName };
121
+
122
+ // Prefer function/method enclosing scope over variable binding.
123
+ // If a function/method encloses the call, use it — local variable
124
+ // declarations inside the function body must not shadow it.
125
+ // Only fall back to a variable/constant binding when the call is at
126
+ // top-level scope (no enclosing function/method found), which handles
127
+ // languages like Haskell where `main` is a top-level `bind` node.
128
+ if (fnCaller) {
129
+ return { ...fnCaller, callerName: fnCallerName };
130
+ }
131
+ if (varCaller) {
132
+ return { ...varCaller, callerName: varCallerName };
133
+ }
134
+ return { ...fileNodeRow, callerName: null };
82
135
  }
83
136
 
84
137
  export function resolveByMethodOrGlobal(
@@ -94,22 +147,25 @@ export function resolveByMethodOrGlobal(
94
147
  const effectiveReceiver = call.receiver.startsWith('this.')
95
148
  ? call.receiver.slice('this.'.length)
96
149
  : call.receiver;
97
- // For this.prop receivers, also try the class-scoped key (ClassName.prop) seeded by
98
- // handlePropWriteTypeMap — prevents false edges when multiple classes define the same
99
- // property name (issue #1323).
100
- let typeEntry =
101
- typeMap.get(effectiveReceiver) ??
102
- typeMap.get(call.receiver) ??
103
- // Phase 8.3f: callee-scoped rest-param key (`callee::restName`) to avoid
104
- // same-name rest-binding collision across functions in the same file (#1358).
105
- (callerName ? typeMap.get(`${callerName}::${effectiveReceiver}`) : undefined);
106
- if (!typeEntry && call.receiver.startsWith('this.') && callerName) {
150
+ // For this.prop receivers, prefer the class-scoped key (ClassName.prop) seeded by
151
+ // handlePropWriteTypeMap / handleFieldDefTypeMap — prevents false edges when multiple
152
+ // classes define the same property name (issues #1323, #1458).
153
+ // Class-scoped lookup runs first so bare fallback keys (confidence 0.6) don't shadow
154
+ // the correct per-class entry when callerName is available.
155
+ let typeEntry: unknown;
156
+ if (call.receiver.startsWith('this.') && callerName) {
107
157
  const dotIdx = callerName.lastIndexOf('.');
108
158
  if (dotIdx > -1) {
109
159
  const callerClass = callerName.slice(0, dotIdx);
110
160
  typeEntry = typeMap.get(`${callerClass}.${effectiveReceiver}`);
111
161
  }
112
162
  }
163
+ typeEntry ??=
164
+ typeMap.get(effectiveReceiver) ??
165
+ typeMap.get(call.receiver) ??
166
+ // Phase 8.3f: callee-scoped rest-param key (`callee::restName`) to avoid
167
+ // same-name rest-binding collision across functions in the same file (#1358).
168
+ (callerName ? typeMap.get(`${callerName}::${effectiveReceiver}`) : undefined);
113
169
  let typeName = typeEntry
114
170
  ? typeof typeEntry === 'string'
115
171
  ? typeEntry
@@ -299,13 +355,17 @@ export function resolveCallTargets(
299
355
  * Returns the edge tuple to insert, or null if nothing matched or the edge
300
356
  * was already seen. Callers are responsible for the actual DB/array insert.
301
357
  *
302
- * Receiver resolution collects all same-file candidates first (no kind
303
- * filter), falls back to global candidates only when the same-file set is
304
- * entirely empty, then filters the chosen set by RECEIVER_KINDS. This
305
- * matches the native Rust build path: if a file imports a name that happens
306
- * to be emitted as `kind='function'` in the importer, the same-file set is
307
- * non-empty and blocks the global fallback, so no receiver edge is emitted.
308
- * Keeping this behaviour identical to the Rust path maintains engine parity.
358
+ * Receiver resolution:
359
+ * 1. Look up same-file nodes for `effectiveReceiver` (unfiltered by kind).
360
+ * 2. If any same-file node exists AND `effectiveReceiver` is not in `importedNames`
361
+ * (i.e. it is a locally-defined symbol, not an import artifact), apply
362
+ * RECEIVER_KINDS and return the filtered set no global fallback.
363
+ * A local `function C(){}` means this file owns `C`; no cross-file class
364
+ * should win over it (issue #1539).
365
+ * 3. If the same-file node IS an import artifact (e.g. destructured require),
366
+ * or no same-file node exists at all, fall back to global candidates filtered
367
+ * by RECEIVER_KINDS. This preserves the pre-#1539 behaviour for cases where
368
+ * an imported name appears as kind='function' in the importer file.
309
369
  */
310
370
  export function resolveReceiverEdge(
311
371
  lookup: CallNodeLookup,
@@ -314,6 +374,7 @@ export function resolveReceiverEdge(
314
374
  relPath: string,
315
375
  typeMap: Map<string, unknown>,
316
376
  seenCallEdges: Set<string>,
377
+ importedNames: ReadonlyMap<string, string>,
317
378
  ): { callerId: number; receiverId: number; confidence: number } | null {
318
379
  const typeEntry = typeMap.get(call.receiver);
319
380
  const typeName = typeEntry
@@ -326,18 +387,15 @@ export function resolveReceiverEdge(
326
387
  ? ((typeEntry as { confidence?: number }).confidence ?? null)
327
388
  : null;
328
389
  const effectiveReceiver = typeName || call.receiver;
329
- // Filter-before: apply RECEIVER_KINDS to same-file candidates first, then
330
- // fall back to global candidates (also filtered) only when same-file yields
331
- // nothing. This prevents an imported name emitted as kind='function' in the
332
- // importing file from blocking the fallback to the actual class/struct/etc.
333
- // node in the defining file.
334
- const sameFileCandidates = lookup
335
- .byNameAndFile(effectiveReceiver, relPath)
336
- .filter((n) => RECEIVER_KINDS.has(n.kind ?? ''));
337
- const candidates =
338
- sameFileCandidates.length > 0
339
- ? sameFileCandidates
340
- : lookup.byName(effectiveReceiver).filter((n) => RECEIVER_KINDS.has(n.kind ?? ''));
390
+ // Block global fallback only when the same-file node is a local definition,
391
+ // not when it's an import artifact (e.g. `const { C } = require(…)` seeds a
392
+ // kind='function' node in the importer but the real class lives elsewhere).
393
+ const sameFileAll = lookup.byNameAndFile(effectiveReceiver, relPath);
394
+ const isLocalDefinition = sameFileAll.length > 0 && !importedNames?.has(effectiveReceiver);
395
+ const sameFileCandidates = sameFileAll.filter((n) => RECEIVER_KINDS.has(n.kind ?? ''));
396
+ const candidates = isLocalDefinition
397
+ ? sameFileCandidates
398
+ : lookup.byName(effectiveReceiver).filter((n) => RECEIVER_KINDS.has(n.kind ?? ''));
341
399
  if (candidates.length === 0) return null;
342
400
  const recvTarget = candidates[0]!;
343
401
  const recvKey = `recv|${caller.id}|${recvTarget.id}`;
@@ -96,6 +96,14 @@ export function buildChaContext(fileSymbols: ReadonlyMap<string, ExtractorOutput
96
96
  * For `super`, resolution starts from the parent of the caller's class.
97
97
  * For `this`/`self`, resolution starts from the caller's own class and walks
98
98
  * up the inheritance chain (supporting inherited method lookup).
99
+ *
100
+ * When `callerFile` is provided, same-file method nodes are preferred: if the
101
+ * hierarchy walk finds a qualified method that exists in both the caller's own
102
+ * file AND in unrelated files (e.g. a class named `A` that appears in multiple
103
+ * fixture files), only the same-file nodes are returned. This prevents
104
+ * cross-fixture false edges caused by accidental name collisions across
105
+ * unrelated files in the same project build. When no same-file nodes exist,
106
+ * all found nodes are returned as before.
99
107
  */
100
108
  export function resolveThisDispatch(
101
109
  methodName: string,
@@ -103,6 +111,7 @@ export function resolveThisDispatch(
103
111
  receiver: 'this' | 'self' | 'super',
104
112
  chaCtx: ChaContext,
105
113
  lookup: CallNodeLookup,
114
+ callerFile?: string | null,
106
115
  ): ReadonlyArray<{ id: number; file: string }> {
107
116
  if (!callerName) return [];
108
117
  const dotIdx = callerName.indexOf('.');
@@ -119,7 +128,15 @@ export function resolveThisDispatch(
119
128
  visited.add(current);
120
129
  const qualified = `${current}.${methodName}`;
121
130
  const found = lookup.byName(qualified).filter((n) => n.kind === 'method');
122
- if (found.length > 0) return found;
131
+ if (found.length > 0) {
132
+ // When the caller's file is known, prefer same-file nodes to avoid
133
+ // emitting cross-file edges to identically-named methods in unrelated
134
+ // files. Only fall back to the full set when no same-file node exists.
135
+ if (callerFile && found.some((n) => n.file === callerFile)) {
136
+ return found.filter((n) => n.file === callerFile);
137
+ }
138
+ return found;
139
+ }
123
140
  current = chaCtx.parents.get(current);
124
141
  }
125
142
  return [];