@kodus/kodus-graph 0.2.8 → 0.2.10

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 (171) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +252 -0
  3. package/dist/analysis/blast-radius.d.ts +2 -0
  4. package/dist/analysis/blast-radius.js +55 -0
  5. package/dist/analysis/communities.d.ts +28 -0
  6. package/dist/analysis/communities.js +100 -0
  7. package/dist/analysis/context-builder.d.ts +34 -0
  8. package/dist/analysis/context-builder.js +92 -0
  9. package/dist/analysis/diff.d.ts +41 -0
  10. package/dist/analysis/diff.js +155 -0
  11. package/dist/analysis/enrich.d.ts +5 -0
  12. package/dist/analysis/enrich.js +126 -0
  13. package/dist/analysis/flows.d.ts +27 -0
  14. package/dist/analysis/flows.js +86 -0
  15. package/dist/analysis/inheritance.d.ts +3 -0
  16. package/dist/analysis/inheritance.js +31 -0
  17. package/dist/analysis/prompt-formatter.d.ts +2 -0
  18. package/dist/analysis/prompt-formatter.js +173 -0
  19. package/dist/analysis/risk-score.d.ts +4 -0
  20. package/dist/analysis/risk-score.js +51 -0
  21. package/dist/analysis/search.d.ts +11 -0
  22. package/dist/analysis/search.js +64 -0
  23. package/dist/analysis/test-gaps.d.ts +2 -0
  24. package/dist/analysis/test-gaps.js +14 -0
  25. package/dist/cli.d.ts +2 -0
  26. package/dist/cli.js +210 -0
  27. package/dist/commands/analyze.d.ts +9 -0
  28. package/dist/commands/analyze.js +116 -0
  29. package/dist/commands/communities.d.ts +8 -0
  30. package/dist/commands/communities.js +9 -0
  31. package/dist/commands/context.d.ts +12 -0
  32. package/dist/commands/context.js +130 -0
  33. package/dist/commands/diff.d.ts +9 -0
  34. package/dist/commands/diff.js +89 -0
  35. package/dist/commands/flows.d.ts +8 -0
  36. package/dist/commands/flows.js +9 -0
  37. package/dist/commands/parse.d.ts +11 -0
  38. package/dist/commands/parse.js +101 -0
  39. package/dist/commands/search.d.ts +12 -0
  40. package/dist/commands/search.js +27 -0
  41. package/dist/commands/update.d.ts +7 -0
  42. package/dist/commands/update.js +154 -0
  43. package/dist/graph/builder.d.ts +6 -0
  44. package/dist/graph/builder.js +248 -0
  45. package/dist/graph/edges.d.ts +23 -0
  46. package/dist/graph/edges.js +159 -0
  47. package/dist/graph/json-writer.d.ts +9 -0
  48. package/dist/graph/json-writer.js +38 -0
  49. package/dist/graph/loader.d.ts +13 -0
  50. package/dist/graph/loader.js +101 -0
  51. package/dist/graph/merger.d.ts +7 -0
  52. package/dist/graph/merger.js +18 -0
  53. package/dist/graph/types.d.ts +252 -0
  54. package/dist/graph/types.js +1 -0
  55. package/dist/parser/batch.d.ts +5 -0
  56. package/dist/parser/batch.js +93 -0
  57. package/dist/parser/discovery.d.ts +7 -0
  58. package/dist/parser/discovery.js +61 -0
  59. package/dist/parser/extractor.d.ts +4 -0
  60. package/dist/parser/extractor.js +33 -0
  61. package/dist/parser/extractors/generic.d.ts +8 -0
  62. package/dist/parser/extractors/generic.js +471 -0
  63. package/dist/parser/extractors/python.d.ts +8 -0
  64. package/dist/parser/extractors/python.js +133 -0
  65. package/dist/parser/extractors/ruby.d.ts +8 -0
  66. package/dist/parser/extractors/ruby.js +153 -0
  67. package/dist/parser/extractors/typescript.d.ts +10 -0
  68. package/dist/parser/extractors/typescript.js +365 -0
  69. package/dist/parser/languages.d.ts +32 -0
  70. package/dist/parser/languages.js +304 -0
  71. package/dist/resolver/call-resolver.d.ts +36 -0
  72. package/dist/resolver/call-resolver.js +178 -0
  73. package/dist/resolver/external-detector.d.ts +11 -0
  74. package/dist/resolver/external-detector.js +820 -0
  75. package/dist/resolver/fs-cache.d.ts +8 -0
  76. package/dist/resolver/fs-cache.js +36 -0
  77. package/dist/resolver/import-map.d.ts +12 -0
  78. package/dist/resolver/import-map.js +21 -0
  79. package/dist/resolver/import-resolver.d.ts +19 -0
  80. package/dist/resolver/import-resolver.js +310 -0
  81. package/dist/resolver/languages/csharp.d.ts +3 -0
  82. package/dist/resolver/languages/csharp.js +94 -0
  83. package/dist/resolver/languages/go.d.ts +3 -0
  84. package/dist/resolver/languages/go.js +197 -0
  85. package/dist/resolver/languages/java.d.ts +1 -0
  86. package/dist/resolver/languages/java.js +193 -0
  87. package/dist/resolver/languages/php.d.ts +3 -0
  88. package/dist/resolver/languages/php.js +75 -0
  89. package/dist/resolver/languages/python.d.ts +11 -0
  90. package/dist/resolver/languages/python.js +127 -0
  91. package/dist/resolver/languages/ruby.d.ts +24 -0
  92. package/dist/resolver/languages/ruby.js +110 -0
  93. package/dist/resolver/languages/rust.d.ts +1 -0
  94. package/dist/resolver/languages/rust.js +197 -0
  95. package/dist/resolver/languages/typescript.d.ts +35 -0
  96. package/dist/resolver/languages/typescript.js +416 -0
  97. package/dist/resolver/re-export-resolver.d.ts +24 -0
  98. package/dist/resolver/re-export-resolver.js +57 -0
  99. package/dist/resolver/symbol-table.d.ts +17 -0
  100. package/dist/resolver/symbol-table.js +60 -0
  101. package/dist/shared/extract-calls.d.ts +26 -0
  102. package/dist/shared/extract-calls.js +57 -0
  103. package/dist/shared/file-hash.d.ts +3 -0
  104. package/dist/shared/file-hash.js +10 -0
  105. package/dist/shared/filters.d.ts +3 -0
  106. package/dist/shared/filters.js +240 -0
  107. package/dist/shared/logger.d.ts +6 -0
  108. package/dist/shared/logger.js +17 -0
  109. package/dist/shared/qualified-name.d.ts +1 -0
  110. package/dist/shared/qualified-name.js +9 -0
  111. package/dist/shared/safe-path.d.ts +6 -0
  112. package/dist/shared/safe-path.js +29 -0
  113. package/dist/shared/schemas.d.ts +43 -0
  114. package/dist/shared/schemas.js +30 -0
  115. package/dist/shared/temp.d.ts +11 -0
  116. package/{src/shared/temp.ts → dist/shared/temp.js} +4 -5
  117. package/package.json +20 -6
  118. package/src/analysis/blast-radius.ts +0 -54
  119. package/src/analysis/communities.ts +0 -135
  120. package/src/analysis/context-builder.ts +0 -130
  121. package/src/analysis/diff.ts +0 -169
  122. package/src/analysis/enrich.ts +0 -110
  123. package/src/analysis/flows.ts +0 -112
  124. package/src/analysis/inheritance.ts +0 -34
  125. package/src/analysis/prompt-formatter.ts +0 -175
  126. package/src/analysis/risk-score.ts +0 -62
  127. package/src/analysis/search.ts +0 -76
  128. package/src/analysis/test-gaps.ts +0 -21
  129. package/src/cli.ts +0 -210
  130. package/src/commands/analyze.ts +0 -128
  131. package/src/commands/communities.ts +0 -19
  132. package/src/commands/context.ts +0 -182
  133. package/src/commands/diff.ts +0 -96
  134. package/src/commands/flows.ts +0 -19
  135. package/src/commands/parse.ts +0 -124
  136. package/src/commands/search.ts +0 -41
  137. package/src/commands/update.ts +0 -166
  138. package/src/graph/builder.ts +0 -209
  139. package/src/graph/edges.ts +0 -101
  140. package/src/graph/json-writer.ts +0 -43
  141. package/src/graph/loader.ts +0 -113
  142. package/src/graph/merger.ts +0 -25
  143. package/src/graph/types.ts +0 -283
  144. package/src/parser/batch.ts +0 -82
  145. package/src/parser/discovery.ts +0 -75
  146. package/src/parser/extractor.ts +0 -37
  147. package/src/parser/extractors/generic.ts +0 -132
  148. package/src/parser/extractors/python.ts +0 -133
  149. package/src/parser/extractors/ruby.ts +0 -147
  150. package/src/parser/extractors/typescript.ts +0 -350
  151. package/src/parser/languages.ts +0 -122
  152. package/src/resolver/call-resolver.ts +0 -244
  153. package/src/resolver/import-map.ts +0 -27
  154. package/src/resolver/import-resolver.ts +0 -72
  155. package/src/resolver/languages/csharp.ts +0 -7
  156. package/src/resolver/languages/go.ts +0 -7
  157. package/src/resolver/languages/java.ts +0 -7
  158. package/src/resolver/languages/php.ts +0 -7
  159. package/src/resolver/languages/python.ts +0 -35
  160. package/src/resolver/languages/ruby.ts +0 -21
  161. package/src/resolver/languages/rust.ts +0 -7
  162. package/src/resolver/languages/typescript.ts +0 -168
  163. package/src/resolver/re-export-resolver.ts +0 -66
  164. package/src/resolver/symbol-table.ts +0 -67
  165. package/src/shared/extract-calls.ts +0 -75
  166. package/src/shared/file-hash.ts +0 -12
  167. package/src/shared/filters.ts +0 -243
  168. package/src/shared/logger.ts +0 -17
  169. package/src/shared/qualified-name.ts +0 -5
  170. package/src/shared/safe-path.ts +0 -31
  171. package/src/shared/schemas.ts +0 -32
@@ -0,0 +1,93 @@
1
+ import { parseAsync } from '@ast-grep/napi';
2
+ import { readFileSync } from 'fs';
3
+ import { extname, relative } from 'path';
4
+ import { NOISE } from '../shared/filters';
5
+ import { log } from '../shared/logger';
6
+ import { extractCallsFromFile, extractFromFile } from './extractor';
7
+ import { getLanguage } from './languages';
8
+ const INITIAL_BATCH = 50;
9
+ const MEMORY_THRESHOLD_RATIO = 0.7;
10
+ export async function parseBatch(files, repoRoot, options) {
11
+ const graph = {
12
+ functions: [],
13
+ classes: [],
14
+ interfaces: [],
15
+ enums: [],
16
+ tests: [],
17
+ imports: [],
18
+ reExports: [],
19
+ rawCalls: [],
20
+ diMaps: new Map(),
21
+ };
22
+ const seen = new Set();
23
+ let parseErrors = 0;
24
+ let extractErrors = 0;
25
+ let batchSize = INITIAL_BATCH;
26
+ const maxMemBytes = (options?.maxMemoryMB ?? 768) * 1024 * 1024;
27
+ for (let i = 0; i < files.length; i += batchSize) {
28
+ const batch = files.slice(i, i + batchSize);
29
+ const promises = batch.map(async (filePath) => {
30
+ const lang = getLanguage(extname(filePath));
31
+ if (!lang) {
32
+ return;
33
+ }
34
+ let source;
35
+ try {
36
+ source = readFileSync(filePath, 'utf-8');
37
+ }
38
+ catch (err) {
39
+ log.warn('Failed to read file', { file: filePath, error: String(err) });
40
+ parseErrors++;
41
+ return;
42
+ }
43
+ let root;
44
+ try {
45
+ root = await parseAsync(lang, source);
46
+ }
47
+ catch (err) {
48
+ log.warn('Failed to parse file', { file: filePath, error: String(err) });
49
+ parseErrors++;
50
+ return;
51
+ }
52
+ const fp = relative(repoRoot, filePath);
53
+ try {
54
+ extractFromFile(root, fp, lang, seen, graph);
55
+ }
56
+ catch (err) {
57
+ log.error('Extraction crashed', { file: fp, error: String(err) });
58
+ extractErrors++;
59
+ }
60
+ try {
61
+ // Extract calls into a temporary buffer, then filter noise before pushing
62
+ const rawCalls = [];
63
+ extractCallsFromFile(root, fp, lang, rawCalls);
64
+ for (const call of rawCalls) {
65
+ if (!NOISE.has(call.callName)) {
66
+ graph.rawCalls.push(call);
67
+ }
68
+ }
69
+ }
70
+ catch (err) {
71
+ log.error('Call extraction crashed', { file: fp, error: String(err) });
72
+ extractErrors++;
73
+ }
74
+ });
75
+ await Promise.all(promises);
76
+ // Dynamic batch sizing: reduce if memory pressure detected
77
+ const rss = process.memoryUsage().rss;
78
+ if (rss > maxMemBytes * MEMORY_THRESHOLD_RATIO) {
79
+ const oldBatch = batchSize;
80
+ batchSize = Math.max(5, Math.floor(batchSize / 2));
81
+ log.warn('Memory pressure detected, reducing batch size', {
82
+ rssMB: Math.round(rss / 1024 / 1024),
83
+ maxMB: Math.round(maxMemBytes / 1024 / 1024),
84
+ oldBatchSize: oldBatch,
85
+ newBatchSize: batchSize,
86
+ });
87
+ }
88
+ }
89
+ if (options?.skipTests) {
90
+ graph.tests = [];
91
+ }
92
+ return { ...graph, parseErrors, extractErrors };
93
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Walk the filesystem and find all supported source files.
3
+ * If `filterFiles` is provided, only return those specific files (resolved to absolute paths).
4
+ * If `include` patterns are provided, keep only files matching at least one pattern.
5
+ * If `exclude` patterns are provided, remove files matching any pattern.
6
+ */
7
+ export declare function discoverFiles(repoDir: string, filterFiles?: string[], include?: string[], exclude?: string[]): string[];
@@ -0,0 +1,61 @@
1
+ import { readdirSync } from 'fs';
2
+ import { extname, join, relative, resolve } from 'path';
3
+ import { isSkippableFile, SKIP_DIRS } from '../shared/filters';
4
+ import { log } from '../shared/logger';
5
+ import { ensureWithinRoot } from '../shared/safe-path';
6
+ import { getLanguage } from './languages';
7
+ /**
8
+ * Walk the filesystem and find all supported source files.
9
+ * If `filterFiles` is provided, only return those specific files (resolved to absolute paths).
10
+ * If `include` patterns are provided, keep only files matching at least one pattern.
11
+ * If `exclude` patterns are provided, remove files matching any pattern.
12
+ */
13
+ export function discoverFiles(repoDir, filterFiles, include, exclude) {
14
+ const absRepoDir = resolve(repoDir);
15
+ if (filterFiles) {
16
+ return filterFiles
17
+ .map((f) => (f.startsWith('/') ? f : join(absRepoDir, f)))
18
+ .filter((f) => {
19
+ try {
20
+ ensureWithinRoot(f, absRepoDir);
21
+ return getLanguage(extname(f)) !== null;
22
+ }
23
+ catch (err) {
24
+ log.warn('Skipping file outside repository root', { file: f, error: String(err) });
25
+ return false;
26
+ }
27
+ });
28
+ }
29
+ let files = [];
30
+ walkFiles(absRepoDir, files);
31
+ // Apply include/exclude filters using Bun.Glob
32
+ const hasInclude = include && include.length > 0;
33
+ const hasExclude = exclude && exclude.length > 0;
34
+ if (hasInclude || hasExclude) {
35
+ const includeGlobs = hasInclude ? include.map((p) => new Bun.Glob(p)) : null;
36
+ const excludeGlobs = hasExclude ? exclude.map((p) => new Bun.Glob(p)) : null;
37
+ files = files.filter((absPath) => {
38
+ const rel = relative(absRepoDir, absPath);
39
+ // If include patterns exist, file must match at least one
40
+ if (includeGlobs && !includeGlobs.some((g) => g.match(rel))) {
41
+ return false;
42
+ }
43
+ // If exclude patterns exist, file must not match any
44
+ if (excludeGlobs?.some((g) => g.match(rel))) {
45
+ return false;
46
+ }
47
+ return true;
48
+ });
49
+ }
50
+ return files;
51
+ }
52
+ function walkFiles(dir, files) {
53
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
54
+ if (entry.isDirectory() && !SKIP_DIRS.has(entry.name)) {
55
+ walkFiles(join(dir, entry.name), files);
56
+ }
57
+ else if (entry.isFile() && getLanguage(extname(entry.name)) !== null && !isSkippableFile(entry.name)) {
58
+ files.push(join(dir, entry.name));
59
+ }
60
+ }
61
+ }
@@ -0,0 +1,4 @@
1
+ import type { Lang, SgRoot } from '@ast-grep/napi';
2
+ import type { RawCallSite, RawGraph } from '../graph/types';
3
+ export declare function extractFromFile(root: SgRoot, filePath: string, lang: Lang | string, seen: Set<string>, graph: RawGraph): void;
4
+ export declare function extractCallsFromFile(root: SgRoot, filePath: string, lang: Lang | string, calls: RawCallSite[]): void;
@@ -0,0 +1,33 @@
1
+ import { extractCallsFromGeneric, extractGeneric } from './extractors/generic';
2
+ import { extractCallsFromPython, extractPython } from './extractors/python';
3
+ import { extractCallsFromRuby, extractRuby } from './extractors/ruby';
4
+ import { extractCallsFromTypeScript, extractTypeScript } from './extractors/typescript';
5
+ import { isTypeScriptLike } from './languages';
6
+ export function extractFromFile(root, filePath, lang, seen, graph) {
7
+ if (isTypeScriptLike(lang)) {
8
+ extractTypeScript(root, filePath, seen, graph, lang);
9
+ }
10
+ else if (lang === 'python') {
11
+ extractPython(root, filePath, seen, graph);
12
+ }
13
+ else if (lang === 'ruby') {
14
+ extractRuby(root, filePath, seen, graph);
15
+ }
16
+ else {
17
+ extractGeneric(root, filePath, lang, seen, graph);
18
+ }
19
+ }
20
+ export function extractCallsFromFile(root, filePath, lang, calls) {
21
+ if (isTypeScriptLike(lang)) {
22
+ extractCallsFromTypeScript(root, filePath, calls);
23
+ }
24
+ else if (lang === 'python') {
25
+ extractCallsFromPython(root, filePath, calls);
26
+ }
27
+ else if (lang === 'ruby') {
28
+ extractCallsFromRuby(root, filePath, calls);
29
+ }
30
+ else {
31
+ extractCallsFromGeneric(root, filePath, lang, calls);
32
+ }
33
+ }
@@ -0,0 +1,8 @@
1
+ import type { SgRoot } from '@ast-grep/napi';
2
+ import type { RawCallSite, RawGraph } from '../../graph/types';
3
+ export declare function extractGeneric(root: SgRoot, fp: string, lang: string, seen: Set<string>, graph: RawGraph): void;
4
+ /**
5
+ * Extract raw call sites from a generic language AST.
6
+ * Uses per-language config for self/super detection.
7
+ */
8
+ export declare function extractCallsFromGeneric(root: SgRoot, fp: string, lang: string, calls: RawCallSite[]): void;
@@ -0,0 +1,471 @@
1
+ import { extractCalls } from '../../shared/extract-calls';
2
+ import { computeContentHash } from '../../shared/file-hash';
3
+ import { log } from '../../shared/logger';
4
+ import { LANG_CONFIGS } from '../languages';
5
+ // ---------------------------------------------------------------------------
6
+ // Go disambiguation helpers
7
+ // ---------------------------------------------------------------------------
8
+ /** Determine whether a Go `type_declaration` node is a struct, interface, or unknown. */
9
+ function goTypeKind(node) {
10
+ const typeSpec = node.children().find((c) => c.kind() === 'type_spec');
11
+ if (!typeSpec) {
12
+ return null;
13
+ }
14
+ const hasStruct = typeSpec.children().some((c) => c.kind() === 'struct_type');
15
+ if (hasStruct) {
16
+ return 'struct';
17
+ }
18
+ const hasInterface = typeSpec.children().some((c) => c.kind() === 'interface_type');
19
+ if (hasInterface) {
20
+ return 'interface';
21
+ }
22
+ return null;
23
+ }
24
+ /** Get the name for a Go `type_declaration` node (name lives inside `type_spec`). */
25
+ function goTypeName(node) {
26
+ const typeSpec = node.children().find((c) => c.kind() === 'type_spec');
27
+ return typeSpec?.field('name')?.text();
28
+ }
29
+ // ---------------------------------------------------------------------------
30
+ // Test detection helpers
31
+ // ---------------------------------------------------------------------------
32
+ /**
33
+ * Returns true if the function should be considered a test based on file-path
34
+ * and function-name conventions defined in config.tests.
35
+ *
36
+ * matchMode controls how filePatterns and funcPatterns interact:
37
+ * - 'and': BOTH must match (e.g., Go: file must be _test.go AND func must start with Test/Benchmark)
38
+ * - 'or' (default): EITHER matching is sufficient
39
+ */
40
+ function isTestByConvention(fp, funcName, config) {
41
+ const tests = config.tests;
42
+ if (!tests) {
43
+ return false;
44
+ }
45
+ const fileMatch = tests.filePatterns ? tests.filePatterns.some((re) => re.test(fp)) : false;
46
+ const funcMatch = tests.funcPatterns ? tests.funcPatterns.some((re) => re.test(funcName)) : false;
47
+ if (tests.matchMode === 'and') {
48
+ // Both must match (or only the defined one must match)
49
+ const fileOk = !tests.filePatterns || fileMatch;
50
+ const funcOk = !tests.funcPatterns || funcMatch;
51
+ return fileOk && funcOk;
52
+ }
53
+ // Default: OR — either matching is sufficient
54
+ if (fileMatch) {
55
+ return true;
56
+ }
57
+ if (funcMatch) {
58
+ return true;
59
+ }
60
+ return false;
61
+ }
62
+ /**
63
+ * Returns true if the function node has an annotation/attribute sibling or
64
+ * child that matches config.tests.annotationKind / annotationNames.
65
+ */
66
+ function hasTestAnnotation(node, config) {
67
+ const tests = config.tests;
68
+ if (!tests?.annotationKind || !tests.annotationNames?.length) {
69
+ return false;
70
+ }
71
+ const annotationKind = tests.annotationKind;
72
+ const annotationNames = tests.annotationNames;
73
+ function textMatchesAnnotation(text) {
74
+ return annotationNames.some((name) => text.includes(name));
75
+ }
76
+ // Check previous siblings for annotation nodes
77
+ for (const sibling of node.prevAll()) {
78
+ if (sibling.kind() === annotationKind && textMatchesAnnotation(sibling.text())) {
79
+ return true;
80
+ }
81
+ }
82
+ // Check inside modifiers or attribute_list children of the function node
83
+ for (const child of node.children()) {
84
+ const ck = child.kind();
85
+ if (ck === 'modifiers' || ck === 'attribute_list' || ck === annotationKind) {
86
+ if (textMatchesAnnotation(child.text())) {
87
+ return true;
88
+ }
89
+ }
90
+ }
91
+ return false;
92
+ }
93
+ // ---------------------------------------------------------------------------
94
+ // Import extraction helper
95
+ // ---------------------------------------------------------------------------
96
+ /**
97
+ * Extract the module name from an import node using multiple strategies.
98
+ */
99
+ function extractImportModule(node) {
100
+ // Strategy 1: look for string literal children
101
+ for (const child of node.children()) {
102
+ const ck = child.kind();
103
+ if (ck === 'string' || ck === 'interpreted_string_literal' || ck === 'string_fragment') {
104
+ const raw = child.text();
105
+ // Strip surrounding quotes
106
+ return raw.replace(/^["'`]|["'`]$/g, '');
107
+ }
108
+ // Recurse into string children one level
109
+ for (const grandchild of child.children()) {
110
+ const gck = grandchild.kind();
111
+ if (gck === 'string_fragment' || gck === 'string_content') {
112
+ return grandchild.text();
113
+ }
114
+ }
115
+ }
116
+ // Strategy 2: scoped identifiers / qualified names
117
+ for (const child of node.children()) {
118
+ const ck = child.kind();
119
+ if (ck === 'scoped_identifier' || ck === 'scoped_type_identifier' || ck === 'qualified_name') {
120
+ return child.text();
121
+ }
122
+ }
123
+ // Strategy 3: namespace names / use_tree (Rust `use` paths)
124
+ for (const child of node.children()) {
125
+ const ck = child.kind();
126
+ if (ck === 'name' || ck === 'namespace_name' || ck === 'use_tree') {
127
+ return child.text();
128
+ }
129
+ }
130
+ // Strategy 4: identifier children as last resort
131
+ for (const child of node.children()) {
132
+ if (child.kind() === 'identifier' || child.kind() === 'type_identifier') {
133
+ return child.text();
134
+ }
135
+ }
136
+ // Fallback: strip import/use/using/require prefix from full text
137
+ return node
138
+ .text()
139
+ .replace(/^\s*(import|use|using|require)\s+/i, '')
140
+ .replace(/[;{}]/g, '')
141
+ .trim();
142
+ }
143
+ /**
144
+ * Extract imported names (symbols) from an import node.
145
+ */
146
+ function extractImportNames(node) {
147
+ const names = [];
148
+ // Look for namespace_use_group (PHP), use_tree_list (Rust), etc.
149
+ for (const child of node.children()) {
150
+ const ck = child.kind();
151
+ if (ck === 'identifier' || ck === 'type_identifier' || ck === 'name') {
152
+ names.push(child.text());
153
+ }
154
+ }
155
+ return names;
156
+ }
157
+ // ---------------------------------------------------------------------------
158
+ // Main extractor
159
+ // ---------------------------------------------------------------------------
160
+ export function extractGeneric(root, fp, lang, seen, graph) {
161
+ const config = LANG_CONFIGS[lang];
162
+ if (!config) {
163
+ return;
164
+ }
165
+ const rootNode = root.root();
166
+ // ── Classes / Structs ─────────────────────────────────────────────────
167
+ if (config.class?.length) {
168
+ for (const classKind of config.class) {
169
+ try {
170
+ for (const node of rootNode.findAll({ rule: { kind: classKind } })) {
171
+ // Go disambiguation: type_declaration is shared between struct and interface
172
+ if (lang === 'go' && classKind === 'type_declaration') {
173
+ const kind = goTypeKind(node);
174
+ if (kind !== 'struct') {
175
+ continue; // interfaces handled separately below
176
+ }
177
+ const name = goTypeName(node);
178
+ if (!name || seen.has(`c:${fp}:${name}`)) {
179
+ continue;
180
+ }
181
+ seen.add(`c:${fp}:${name}`);
182
+ graph.classes.push({
183
+ name,
184
+ file: fp,
185
+ line_start: node.range().start.line,
186
+ line_end: node.range().end.line,
187
+ extends: '',
188
+ implements: [],
189
+ ast_kind: String(node.kind()),
190
+ qualified: `${fp}::${name}`,
191
+ content_hash: computeContentHash(node.text()),
192
+ });
193
+ continue;
194
+ }
195
+ const name = node.field('name')?.text();
196
+ if (!name || seen.has(`c:${fp}:${name}`)) {
197
+ continue;
198
+ }
199
+ seen.add(`c:${fp}:${name}`);
200
+ // Heritage extraction
201
+ let extendsVal = '';
202
+ let implementsVal = [];
203
+ if (config.heritage?.extends) {
204
+ const raw = config.heritage.extends(node);
205
+ if (typeof raw === 'string') {
206
+ extendsVal = raw;
207
+ }
208
+ else if (Array.isArray(raw) && raw.length > 0) {
209
+ extendsVal = raw[0];
210
+ }
211
+ }
212
+ if (config.heritage?.implements) {
213
+ const raw = config.heritage.implements(node);
214
+ if (typeof raw === 'string') {
215
+ implementsVal = [raw];
216
+ }
217
+ else if (Array.isArray(raw)) {
218
+ implementsVal = raw;
219
+ }
220
+ }
221
+ graph.classes.push({
222
+ name,
223
+ file: fp,
224
+ line_start: node.range().start.line,
225
+ line_end: node.range().end.line,
226
+ extends: extendsVal,
227
+ implements: implementsVal,
228
+ ast_kind: String(node.kind()),
229
+ qualified: `${fp}::${name}`,
230
+ content_hash: computeContentHash(node.text()),
231
+ });
232
+ }
233
+ }
234
+ catch (err) {
235
+ log.debug('Generic class extraction failed', { file: fp, lang, error: String(err) });
236
+ }
237
+ }
238
+ }
239
+ // ── Interfaces / Traits ───────────────────────────────────────────────
240
+ if (config.interface?.length) {
241
+ for (const ifaceKind of config.interface) {
242
+ try {
243
+ for (const node of rootNode.findAll({ rule: { kind: ifaceKind } })) {
244
+ // Go disambiguation: type_declaration shared with class — only pick interface_type
245
+ if (lang === 'go' && ifaceKind === 'type_declaration') {
246
+ const kind = goTypeKind(node);
247
+ if (kind !== 'interface') {
248
+ continue;
249
+ }
250
+ const name = goTypeName(node);
251
+ if (!name || seen.has(`i:${fp}:${name}`)) {
252
+ continue;
253
+ }
254
+ seen.add(`i:${fp}:${name}`);
255
+ graph.interfaces.push({
256
+ name,
257
+ file: fp,
258
+ line_start: node.range().start.line,
259
+ line_end: node.range().end.line,
260
+ methods: [],
261
+ ast_kind: String(node.kind()),
262
+ qualified: `${fp}::${name}`,
263
+ content_hash: computeContentHash(node.text()),
264
+ });
265
+ continue;
266
+ }
267
+ const name = node.field('name')?.text();
268
+ if (!name || seen.has(`i:${fp}:${name}`)) {
269
+ continue;
270
+ }
271
+ seen.add(`i:${fp}:${name}`);
272
+ graph.interfaces.push({
273
+ name,
274
+ file: fp,
275
+ line_start: node.range().start.line,
276
+ line_end: node.range().end.line,
277
+ methods: [],
278
+ ast_kind: String(node.kind()),
279
+ qualified: `${fp}::${name}`,
280
+ content_hash: computeContentHash(node.text()),
281
+ });
282
+ }
283
+ }
284
+ catch (err) {
285
+ log.debug('Generic interface extraction failed', { file: fp, lang, error: String(err) });
286
+ }
287
+ }
288
+ }
289
+ // ── Enums ─────────────────────────────────────────────────────────────
290
+ if (config.enum?.length) {
291
+ for (const enumKind of config.enum) {
292
+ try {
293
+ for (const node of rootNode.findAll({ rule: { kind: enumKind } })) {
294
+ const name = node.field('name')?.text();
295
+ if (!name || seen.has(`e:${fp}:${name}`)) {
296
+ continue;
297
+ }
298
+ seen.add(`e:${fp}:${name}`);
299
+ graph.enums.push({
300
+ name,
301
+ file: fp,
302
+ line_start: node.range().start.line,
303
+ line_end: node.range().end.line,
304
+ ast_kind: String(node.kind()),
305
+ qualified: `${fp}::${name}`,
306
+ content_hash: computeContentHash(node.text()),
307
+ });
308
+ }
309
+ }
310
+ catch (err) {
311
+ log.debug('Generic enum extraction failed', { file: fp, lang, error: String(err) });
312
+ }
313
+ }
314
+ }
315
+ // ── Functions / Methods / Constructors ────────────────────────────────
316
+ const funcKinds = [...(config.function ?? []), ...(config.method ?? []), ...(config.constructorKinds ?? [])];
317
+ const constructorKindSet = new Set(config.constructorKinds ?? []);
318
+ const methodKindSet = new Set(config.method ?? []);
319
+ for (const funcKind of funcKinds) {
320
+ try {
321
+ for (const node of rootNode.findAll({ rule: { kind: funcKind } })) {
322
+ const name = node.field('name')?.text();
323
+ if (!name) {
324
+ continue;
325
+ }
326
+ const line = node.range().start.line;
327
+ if (seen.has(`f:${fp}:${name}:${line}`)) {
328
+ continue;
329
+ }
330
+ seen.add(`f:${fp}:${name}:${line}`);
331
+ const classAncestor = node.ancestors().find((a) => {
332
+ const k = String(a.kind());
333
+ return k.includes('class') || k.includes('struct') || k.includes('impl');
334
+ });
335
+ const className = classAncestor?.field('name')?.text() || '';
336
+ // Determine function kind
337
+ let kind;
338
+ if (constructorKindSet.has(funcKind)) {
339
+ kind = 'Constructor';
340
+ }
341
+ else if (methodKindSet.has(funcKind) || className) {
342
+ kind = 'Method';
343
+ }
344
+ else {
345
+ kind = 'Function';
346
+ }
347
+ // Test detection
348
+ const isTest = isTestByConvention(fp, name, config) || hasTestAnnotation(node, config);
349
+ if (isTest) {
350
+ // Push to graph.tests
351
+ const testKey = `t:${fp}:${name}:${line}`;
352
+ if (!seen.has(testKey)) {
353
+ seen.add(testKey);
354
+ graph.tests.push({
355
+ name,
356
+ file: fp,
357
+ line_start: line,
358
+ line_end: node.range().end.line,
359
+ ast_kind: String(node.kind()),
360
+ qualified: className ? `${fp}::${className}.${name}` : `${fp}::${name}`,
361
+ content_hash: computeContentHash(node.text()),
362
+ });
363
+ }
364
+ // Also push to functions so call resolution still works
365
+ }
366
+ graph.functions.push({
367
+ name,
368
+ file: fp,
369
+ line_start: line,
370
+ line_end: node.range().end.line,
371
+ params: node.field('parameters')?.text() || '()',
372
+ returnType: node.field('return_type')?.text() || '',
373
+ kind,
374
+ ast_kind: String(node.kind()),
375
+ className,
376
+ qualified: className ? `${fp}::${className}.${name}` : `${fp}::${name}`,
377
+ content_hash: computeContentHash(node.text()),
378
+ });
379
+ }
380
+ }
381
+ catch (err) {
382
+ log.debug('Generic function extraction failed', { file: fp, lang, error: String(err) });
383
+ }
384
+ }
385
+ // ── Imports ───────────────────────────────────────────────────────────
386
+ if (config.import?.length) {
387
+ for (const importKind of config.import) {
388
+ try {
389
+ for (const node of rootNode.findAll({ rule: { kind: importKind } })) {
390
+ const module = extractImportModule(node);
391
+ if (!module) {
392
+ continue;
393
+ }
394
+ graph.imports.push({
395
+ module,
396
+ file: fp,
397
+ line: node.range().start.line,
398
+ names: extractImportNames(node),
399
+ lang,
400
+ });
401
+ }
402
+ }
403
+ catch (err) {
404
+ log.debug('Generic import extraction failed', { file: fp, lang, error: String(err) });
405
+ }
406
+ }
407
+ }
408
+ }
409
+ /** Shared class-finder for languages using class/struct/impl AST kinds. */
410
+ function findEnclosingClassGeneric(node) {
411
+ return (node.ancestors().find((a) => {
412
+ const k = String(a.kind());
413
+ return k.includes('class') || k.includes('struct') || k.includes('impl');
414
+ }) ?? null);
415
+ }
416
+ /** Per-language call extraction configs for self/super detection. */
417
+ const GENERIC_CONFIGS = {
418
+ java: {
419
+ selfPrefixes: ['this.'],
420
+ superPrefixes: ['super.'],
421
+ findEnclosingClass: findEnclosingClassGeneric,
422
+ getParentClass: (classNode) => {
423
+ const sc = classNode.children().find((c) => c.kind() === 'superclass');
424
+ return sc
425
+ ?.children()
426
+ .find((c) => c.kind() === 'type_identifier')
427
+ ?.text();
428
+ },
429
+ },
430
+ csharp: {
431
+ selfPrefixes: ['this.'],
432
+ superPrefixes: ['base.'],
433
+ findEnclosingClass: findEnclosingClassGeneric,
434
+ getParentClass: (classNode) => {
435
+ const bl = classNode.children().find((c) => c.kind() === 'base_list');
436
+ return bl
437
+ ?.children()
438
+ .find((c) => c.kind() === 'identifier' || c.kind() === 'type_identifier')
439
+ ?.text();
440
+ },
441
+ },
442
+ rust: {
443
+ selfPrefixes: ['self.'],
444
+ superPrefixes: [],
445
+ findEnclosingClass: (node) => node.ancestors().find((a) => a.kind() === 'impl_item') ?? null,
446
+ },
447
+ go: {
448
+ selfPrefixes: [],
449
+ superPrefixes: [],
450
+ findEnclosingClass: findEnclosingClassGeneric,
451
+ },
452
+ php: {
453
+ selfPrefixes: ['$this->'],
454
+ superPrefixes: ['parent::'],
455
+ findEnclosingClass: findEnclosingClassGeneric,
456
+ },
457
+ };
458
+ /** Fallback config for unknown languages — no self/super detection. */
459
+ const FALLBACK_CONFIG = {
460
+ selfPrefixes: [],
461
+ superPrefixes: [],
462
+ findEnclosingClass: findEnclosingClassGeneric,
463
+ };
464
+ /**
465
+ * Extract raw call sites from a generic language AST.
466
+ * Uses per-language config for self/super detection.
467
+ */
468
+ export function extractCallsFromGeneric(root, fp, lang, calls) {
469
+ const config = GENERIC_CONFIGS[lang] ?? FALLBACK_CONFIG;
470
+ extractCalls(root.root(), fp, config, calls);
471
+ }