@kodus/kodus-graph 0.1.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 (50) hide show
  1. package/package.json +62 -0
  2. package/src/analysis/blast-radius.ts +54 -0
  3. package/src/analysis/communities.ts +135 -0
  4. package/src/analysis/diff.ts +120 -0
  5. package/src/analysis/flows.ts +112 -0
  6. package/src/analysis/review-context.ts +141 -0
  7. package/src/analysis/risk-score.ts +62 -0
  8. package/src/analysis/search.ts +76 -0
  9. package/src/analysis/test-gaps.ts +21 -0
  10. package/src/cli.ts +192 -0
  11. package/src/commands/analyze.ts +66 -0
  12. package/src/commands/communities.ts +19 -0
  13. package/src/commands/context.ts +69 -0
  14. package/src/commands/diff.ts +96 -0
  15. package/src/commands/flows.ts +19 -0
  16. package/src/commands/parse.ts +100 -0
  17. package/src/commands/search.ts +41 -0
  18. package/src/commands/update.ts +166 -0
  19. package/src/graph/builder.ts +170 -0
  20. package/src/graph/edges.ts +101 -0
  21. package/src/graph/loader.ts +100 -0
  22. package/src/graph/merger.ts +25 -0
  23. package/src/graph/types.ts +218 -0
  24. package/src/parser/batch.ts +74 -0
  25. package/src/parser/discovery.ts +42 -0
  26. package/src/parser/extractor.ts +37 -0
  27. package/src/parser/extractors/generic.ts +87 -0
  28. package/src/parser/extractors/python.ts +127 -0
  29. package/src/parser/extractors/ruby.ts +142 -0
  30. package/src/parser/extractors/typescript.ts +329 -0
  31. package/src/parser/languages.ts +122 -0
  32. package/src/resolver/call-resolver.ts +179 -0
  33. package/src/resolver/import-map.ts +27 -0
  34. package/src/resolver/import-resolver.ts +72 -0
  35. package/src/resolver/languages/csharp.ts +7 -0
  36. package/src/resolver/languages/go.ts +7 -0
  37. package/src/resolver/languages/java.ts +7 -0
  38. package/src/resolver/languages/php.ts +7 -0
  39. package/src/resolver/languages/python.ts +35 -0
  40. package/src/resolver/languages/ruby.ts +21 -0
  41. package/src/resolver/languages/rust.ts +7 -0
  42. package/src/resolver/languages/typescript.ts +168 -0
  43. package/src/resolver/symbol-table.ts +53 -0
  44. package/src/shared/file-hash.ts +7 -0
  45. package/src/shared/filters.ts +243 -0
  46. package/src/shared/logger.ts +14 -0
  47. package/src/shared/qualified-name.ts +5 -0
  48. package/src/shared/safe-path.ts +31 -0
  49. package/src/shared/schemas.ts +31 -0
  50. package/src/shared/temp.ts +17 -0
@@ -0,0 +1,218 @@
1
+ // ── Node kinds (aligned with Postgres ast_nodes.kind) ──
2
+ export type NodeKind = 'Function' | 'Method' | 'Constructor' | 'Class' | 'Interface' | 'Enum' | 'Test';
3
+
4
+ // ── Edge kinds (aligned with Postgres ast_edges.kind) ──
5
+ export type EdgeKind = 'CALLS' | 'IMPORTS' | 'INHERITS' | 'IMPLEMENTS' | 'TESTED_BY' | 'CONTAINS';
6
+
7
+ // ── Graph node (matches ast_nodes table) ──
8
+ export interface GraphNode {
9
+ kind: NodeKind;
10
+ name: string;
11
+ qualified_name: string;
12
+ file_path: string;
13
+ line_start: number;
14
+ line_end: number;
15
+ language: string;
16
+ parent_name?: string;
17
+ params?: string;
18
+ return_type?: string;
19
+ modifiers?: string;
20
+ is_test: boolean;
21
+ file_hash: string;
22
+ }
23
+
24
+ // ── Graph edge (matches ast_edges table) ──
25
+ export interface GraphEdge {
26
+ kind: EdgeKind;
27
+ source_qualified: string;
28
+ target_qualified: string;
29
+ file_path: string;
30
+ line: number;
31
+ confidence?: number; // 0.0-1.0, only for CALLS
32
+ }
33
+
34
+ // ── Full graph data ──
35
+ export interface GraphData {
36
+ nodes: GraphNode[];
37
+ edges: GraphEdge[];
38
+ }
39
+
40
+ // ── Parse command output ──
41
+ export interface ParseMetadata {
42
+ repo_dir: string;
43
+ files_parsed: number;
44
+ total_nodes: number;
45
+ total_edges: number;
46
+ duration_ms: number;
47
+ parse_errors: number;
48
+ extract_errors: number;
49
+ files_unchanged?: number;
50
+ incremental?: boolean;
51
+ }
52
+
53
+ export interface ParseOutput {
54
+ metadata: ParseMetadata;
55
+ nodes: GraphNode[];
56
+ edges: GraphEdge[];
57
+ }
58
+
59
+ // ── Analyze command output ──
60
+ export interface BlastRadiusResult {
61
+ total_functions: number;
62
+ total_files: number;
63
+ by_depth: Record<string, string[]>;
64
+ }
65
+
66
+ export interface RiskFactor {
67
+ weight: number;
68
+ value: number;
69
+ detail: string;
70
+ }
71
+
72
+ export interface RiskScoreResult {
73
+ level: 'LOW' | 'MEDIUM' | 'HIGH';
74
+ score: number;
75
+ factors: {
76
+ blast_radius: RiskFactor;
77
+ test_gaps: RiskFactor;
78
+ complexity: RiskFactor;
79
+ inheritance: RiskFactor;
80
+ };
81
+ }
82
+
83
+ export interface TestGap {
84
+ function: string;
85
+ file_path: string;
86
+ line_start: number;
87
+ }
88
+
89
+ export interface AnalysisOutput {
90
+ blast_radius: BlastRadiusResult;
91
+ risk_score: RiskScoreResult;
92
+ test_gaps: TestGap[];
93
+ }
94
+
95
+ // ── Context command output ──
96
+ export interface ContextMetadata {
97
+ changed_functions: number;
98
+ caller_count: number;
99
+ callee_count: number;
100
+ untested_count: number;
101
+ blast_radius: { functions: number; files: number };
102
+ risk_level: 'LOW' | 'MEDIUM' | 'HIGH';
103
+ risk_score: number;
104
+ }
105
+
106
+ export interface ContextOutput {
107
+ text: string;
108
+ metadata: ContextMetadata;
109
+ }
110
+
111
+ // ── Main graph JSON (input --graph, from Postgres) ──
112
+ export interface MainGraphInput {
113
+ repo_id: string;
114
+ sha: string;
115
+ nodes: GraphNode[];
116
+ edges: GraphEdge[];
117
+ }
118
+
119
+ // ── Internal types used during parsing pipeline ──
120
+ export interface RawFunction {
121
+ name: string;
122
+ file: string;
123
+ line_start: number;
124
+ line_end: number;
125
+ params: string;
126
+ returnType: string;
127
+ kind: 'Function' | 'Method' | 'Constructor';
128
+ className: string;
129
+ qualified: string;
130
+ }
131
+
132
+ export interface RawClass {
133
+ name: string;
134
+ file: string;
135
+ line_start: number;
136
+ line_end: number;
137
+ extends: string;
138
+ implements: string;
139
+ qualified: string;
140
+ }
141
+
142
+ export interface RawInterface {
143
+ name: string;
144
+ file: string;
145
+ line_start: number;
146
+ line_end: number;
147
+ methods: string[];
148
+ qualified: string;
149
+ }
150
+
151
+ export interface RawEnum {
152
+ name: string;
153
+ file: string;
154
+ line_start: number;
155
+ line_end: number;
156
+ qualified: string;
157
+ }
158
+
159
+ export interface RawTest {
160
+ name: string;
161
+ file: string;
162
+ line_start: number;
163
+ line_end: number;
164
+ qualified: string;
165
+ }
166
+
167
+ export interface RawImport {
168
+ module: string;
169
+ file: string;
170
+ line: number;
171
+ names: string[];
172
+ lang: string;
173
+ }
174
+
175
+ export interface RawReExport {
176
+ module: string;
177
+ file: string;
178
+ line: number;
179
+ }
180
+
181
+ export interface RawCallSite {
182
+ source: string; // relative file path
183
+ callName: string; // function or method name being called
184
+ line: number; // line number of the call
185
+ diField?: string; // if DI pattern (this.field.method), the field name
186
+ }
187
+
188
+ export interface RawCallEdge {
189
+ source: string; // file path of the caller
190
+ target: string; // qualified name of the callee
191
+ callName: string;
192
+ line: number;
193
+ confidence: number;
194
+ }
195
+
196
+ export interface ImportEdge {
197
+ source: string; // source file
198
+ target: string; // resolved target file or unresolved module
199
+ resolved: boolean;
200
+ line: number;
201
+ }
202
+
203
+ export interface RawGraph {
204
+ functions: RawFunction[];
205
+ classes: RawClass[];
206
+ interfaces: RawInterface[];
207
+ enums: RawEnum[];
208
+ tests: RawTest[];
209
+ imports: RawImport[];
210
+ reExports: RawReExport[];
211
+ rawCalls: RawCallSite[];
212
+ diMaps: Map<string, Map<string, string>>; // file -> Map<fieldName, typeName>
213
+ }
214
+
215
+ export interface ParseBatchResult extends RawGraph {
216
+ parseErrors: number;
217
+ extractErrors: number;
218
+ }
@@ -0,0 +1,74 @@
1
+ import type { SgRoot } from '@ast-grep/napi';
2
+ import { parseAsync } from '@ast-grep/napi';
3
+ import { readFileSync } from 'fs';
4
+ import { extname, relative } from 'path';
5
+ import type { ParseBatchResult, RawGraph } from '../graph/types';
6
+ import { log } from '../shared/logger';
7
+ import { extractCallsFromFile, extractFromFile } from './extractor';
8
+ import { getLanguage } from './languages';
9
+
10
+ const BATCH_SIZE = 50;
11
+
12
+ export async function parseBatch(files: string[], repoRoot: string): Promise<ParseBatchResult> {
13
+ const graph: RawGraph = {
14
+ functions: [],
15
+ classes: [],
16
+ interfaces: [],
17
+ enums: [],
18
+ tests: [],
19
+ imports: [],
20
+ reExports: [],
21
+ rawCalls: [],
22
+ diMaps: new Map(),
23
+ };
24
+ const seen = new Set<string>();
25
+ let parseErrors = 0;
26
+ let extractErrors = 0;
27
+
28
+ for (let i = 0; i < files.length; i += BATCH_SIZE) {
29
+ const batch = files.slice(i, i + BATCH_SIZE);
30
+
31
+ const promises = batch.map(async (filePath) => {
32
+ const lang = getLanguage(extname(filePath));
33
+ if (!lang) return;
34
+
35
+ let source: string;
36
+ try {
37
+ source = readFileSync(filePath, 'utf-8');
38
+ } catch (err) {
39
+ log.warn('Failed to read file', { file: filePath, error: String(err) });
40
+ parseErrors++;
41
+ return;
42
+ }
43
+
44
+ let root: SgRoot;
45
+ try {
46
+ root = await parseAsync(lang, source);
47
+ } catch (err) {
48
+ log.warn('Failed to parse file', { file: filePath, error: String(err) });
49
+ parseErrors++;
50
+ return;
51
+ }
52
+
53
+ const fp = relative(repoRoot, filePath);
54
+
55
+ try {
56
+ extractFromFile(root, fp, lang, seen, graph);
57
+ } catch (err) {
58
+ log.error('Extraction crashed', { file: fp, error: String(err) });
59
+ extractErrors++;
60
+ }
61
+
62
+ try {
63
+ extractCallsFromFile(root, fp, lang, graph.rawCalls);
64
+ } catch (err) {
65
+ log.error('Call extraction crashed', { file: fp, error: String(err) });
66
+ extractErrors++;
67
+ }
68
+ });
69
+
70
+ await Promise.all(promises);
71
+ }
72
+
73
+ return { ...graph, parseErrors, extractErrors };
74
+ }
@@ -0,0 +1,42 @@
1
+ import { readdirSync } from 'fs';
2
+ import { extname, join, 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
+ /**
9
+ * Walk the filesystem and find all supported source files.
10
+ * If `filterFiles` is provided, only return those specific files (resolved to absolute paths).
11
+ */
12
+ export function discoverFiles(repoDir: string, filterFiles?: string[]): string[] {
13
+ const absRepoDir = resolve(repoDir);
14
+
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
+ } catch (err) {
23
+ log.warn('Skipping file outside repository root', { file: f, error: String(err) });
24
+ return false;
25
+ }
26
+ });
27
+ }
28
+
29
+ const files: string[] = [];
30
+ walkFiles(absRepoDir, files);
31
+ return files;
32
+ }
33
+
34
+ function walkFiles(dir: string, files: string[]): void {
35
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
36
+ if (entry.isDirectory() && !SKIP_DIRS.has(entry.name)) {
37
+ walkFiles(join(dir, entry.name), files);
38
+ } else if (entry.isFile() && getLanguage(extname(entry.name)) !== null && !isSkippableFile(entry.name)) {
39
+ files.push(join(dir, entry.name));
40
+ }
41
+ }
42
+ }
@@ -0,0 +1,37 @@
1
+ import type { Lang, SgRoot } from '@ast-grep/napi';
2
+ import type { RawCallSite, RawGraph } from '../graph/types';
3
+ import { extractCallsFromGeneric, extractGeneric } from './extractors/generic';
4
+ import { extractCallsFromPython, extractPython } from './extractors/python';
5
+ import { extractCallsFromRuby, extractRuby } from './extractors/ruby';
6
+ import { extractCallsFromTypeScript, extractTypeScript } from './extractors/typescript';
7
+ import { isTypeScriptLike } from './languages';
8
+
9
+ export function extractFromFile(
10
+ root: SgRoot,
11
+ filePath: string,
12
+ lang: Lang | string,
13
+ seen: Set<string>,
14
+ graph: RawGraph,
15
+ ): void {
16
+ if (isTypeScriptLike(lang)) {
17
+ extractTypeScript(root, filePath, seen, graph, lang);
18
+ } else if (lang === 'python') {
19
+ extractPython(root, filePath, seen, graph);
20
+ } else if (lang === 'ruby') {
21
+ extractRuby(root, filePath, seen, graph);
22
+ } else {
23
+ extractGeneric(root, filePath, lang as string, seen, graph);
24
+ }
25
+ }
26
+
27
+ export function extractCallsFromFile(root: SgRoot, filePath: string, lang: Lang | string, calls: RawCallSite[]): void {
28
+ if (isTypeScriptLike(lang)) {
29
+ extractCallsFromTypeScript(root, filePath, calls);
30
+ } else if (lang === 'python') {
31
+ extractCallsFromPython(root, filePath, calls);
32
+ } else if (lang === 'ruby') {
33
+ extractCallsFromRuby(root, filePath, calls);
34
+ } else {
35
+ extractCallsFromGeneric(root, filePath, calls);
36
+ }
37
+ }
@@ -0,0 +1,87 @@
1
+ import type { SgNode, SgRoot } from '@ast-grep/napi';
2
+ import type { RawCallSite, RawGraph } from '../../graph/types';
3
+ import { NOISE } from '../../shared/filters';
4
+ import { log } from '../../shared/logger';
5
+ import { LANG_KINDS } from '../languages';
6
+
7
+ export function extractGeneric(root: SgRoot, fp: string, lang: string, seen: Set<string>, graph: RawGraph): void {
8
+ const kinds = LANG_KINDS[lang];
9
+ if (!kinds) return;
10
+ const rootNode = root.root();
11
+
12
+ // Try to extract classes
13
+ for (const classKind of [kinds.class, kinds.struct, kinds.interface].filter(Boolean)) {
14
+ try {
15
+ for (const node of rootNode.findAll({ rule: { kind: classKind } })) {
16
+ const name = node.field('name')?.text();
17
+ if (!name || seen.has(`c:${fp}:${name}`)) continue;
18
+ seen.add(`c:${fp}:${name}`);
19
+ graph.classes.push({
20
+ name,
21
+ file: fp,
22
+ line_start: node.range().start.line,
23
+ line_end: node.range().end.line,
24
+ extends: '',
25
+ implements: '',
26
+ qualified: `${fp}::${name}`,
27
+ });
28
+ }
29
+ } catch (err) {
30
+ log.debug('Generic extraction failed', { file: fp, error: String(err) });
31
+ }
32
+ }
33
+
34
+ // Try to extract functions/methods
35
+ // biome-ignore lint/complexity/useLiteralKeys: 'constructor' must use bracket notation to avoid Object.prototype.constructor
36
+ for (const funcKind of [kinds.function, kinds.method, kinds['constructor'] as string | undefined].filter(Boolean)) {
37
+ try {
38
+ for (const node of rootNode.findAll({ rule: { kind: funcKind } })) {
39
+ const name = node.field('name')?.text();
40
+ if (!name) continue;
41
+ const line = node.range().start.line;
42
+ if (seen.has(`f:${fp}:${name}:${line}`)) continue;
43
+ seen.add(`f:${fp}:${name}:${line}`);
44
+
45
+ const classAncestor = node.ancestors().find((a: SgNode) => {
46
+ const k = String(a.kind());
47
+ return k.includes('class') || k.includes('struct') || k.includes('impl');
48
+ });
49
+ const className = classAncestor?.field('name')?.text() || '';
50
+
51
+ graph.functions.push({
52
+ name,
53
+ file: fp,
54
+ line_start: line,
55
+ line_end: node.range().end.line,
56
+ params: node.field('parameters')?.text() || '()',
57
+ returnType: node.field('return_type')?.text() || '',
58
+ kind: className ? 'Method' : 'Function',
59
+ className,
60
+ qualified: className ? `${fp}::${className}.${name}` : `${fp}::${name}`,
61
+ });
62
+ }
63
+ } catch (err) {
64
+ log.debug('Generic extraction failed', { file: fp, error: String(err) });
65
+ }
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Extract raw call sites from a generic language AST.
71
+ * Direct calls only.
72
+ */
73
+ export function extractCallsFromGeneric(root: SgRoot, fp: string, calls: RawCallSite[]): void {
74
+ const rootNode = root.root();
75
+
76
+ for (const m of rootNode.findAll('$CALLEE($$$ARGS)')) {
77
+ const callee = m.getMatch('CALLEE')?.text();
78
+ if (!callee) continue;
79
+ const callName = callee.includes('.') ? callee.split('.').pop()! : callee;
80
+ if (NOISE.has(callName)) continue;
81
+ calls.push({
82
+ source: fp,
83
+ callName,
84
+ line: m.range().start.line,
85
+ });
86
+ }
87
+ }
@@ -0,0 +1,127 @@
1
+ import type { SgNode, SgRoot } from '@ast-grep/napi';
2
+ import type { RawCallSite, RawGraph } from '../../graph/types';
3
+ import { NOISE } from '../../shared/filters';
4
+ import { LANG_KINDS } from '../languages';
5
+
6
+ export function extractPython(root: SgRoot, fp: string, seen: Set<string>, graph: RawGraph): void {
7
+ const kinds = LANG_KINDS.python;
8
+ const rootNode = root.root();
9
+
10
+ // ── Classes ──
11
+ for (const node of rootNode.findAll({ rule: { kind: kinds.class } })) {
12
+ const name = node.field('name')?.text();
13
+ if (!name || seen.has(`c:${fp}:${name}`)) continue;
14
+ seen.add(`c:${fp}:${name}`);
15
+
16
+ const argList = node.field('superclasses') || node.children().find((c: SgNode) => c.kind() === 'argument_list');
17
+ const extendsName =
18
+ argList
19
+ ?.children()
20
+ .find((c: SgNode) => c.kind() === 'identifier')
21
+ ?.text() || '';
22
+
23
+ graph.classes.push({
24
+ name,
25
+ file: fp,
26
+ line_start: node.range().start.line,
27
+ line_end: node.range().end.line,
28
+ extends: extendsName,
29
+ implements: '',
30
+ qualified: `${fp}::${name}`,
31
+ });
32
+ }
33
+
34
+ // ── Functions / Methods ──
35
+ for (const node of rootNode.findAll({ rule: { kind: kinds.function } })) {
36
+ const name = node.field('name')?.text();
37
+ if (!name) continue;
38
+ const line = node.range().start.line;
39
+ if (seen.has(`m:${fp}:${name}:${line}`)) continue;
40
+ seen.add(`m:${fp}:${name}:${line}`);
41
+
42
+ const classAncestor = node.ancestors().find((a: SgNode) => a.kind() === kinds.class);
43
+ const className = classAncestor?.field('name')?.text() || '';
44
+ const retType =
45
+ node
46
+ .field('return_type')
47
+ ?.text()
48
+ ?.replace(/^->\s*/, '') || '';
49
+
50
+ const isTest = name.startsWith('test_');
51
+ if (isTest) {
52
+ graph.tests.push({
53
+ name,
54
+ file: fp,
55
+ line_start: line,
56
+ line_end: node.range().end.line,
57
+ qualified: `${fp}::test:${name}`,
58
+ });
59
+ }
60
+
61
+ graph.functions.push({
62
+ name,
63
+ file: fp,
64
+ line_start: line,
65
+ line_end: node.range().end.line,
66
+ params: node.field('parameters')?.text() || '()',
67
+ returnType: retType,
68
+ kind: name === '__init__' ? 'Constructor' : className ? 'Method' : 'Function',
69
+ className,
70
+ qualified: className ? `${fp}::${className}.${name}` : `${fp}::${name}`,
71
+ });
72
+ }
73
+
74
+ // ── Imports (from X import Y) ──
75
+ for (const node of rootNode.findAll({ rule: { kind: kinds.import } })) {
76
+ const modNode = node.children().find((c: SgNode) => c.kind() === 'dotted_name' || c.kind() === 'relative_import');
77
+ const modulePath = modNode?.text() || '';
78
+ if (!modulePath) continue;
79
+
80
+ const names: string[] = [];
81
+ for (const child of node.children()) {
82
+ if (child.kind() === 'dotted_name' && child !== modNode) names.push(child.text());
83
+ if (child.kind() === 'identifier' && child !== modNode) names.push(child.text());
84
+ }
85
+ graph.imports.push({
86
+ module: modulePath,
87
+ file: fp,
88
+ line: node.range().start.line,
89
+ names,
90
+ lang: 'python',
91
+ });
92
+ }
93
+
94
+ // ── Regular imports (import X) ──
95
+ for (const node of rootNode.findAll({ rule: { kind: kinds.importRegular } })) {
96
+ const modNode = node.children().find((c: SgNode) => c.kind() === 'dotted_name');
97
+ if (modNode) {
98
+ graph.imports.push({
99
+ module: modNode.text(),
100
+ file: fp,
101
+ line: node.range().start.line,
102
+ names: [modNode.text()],
103
+ lang: 'python',
104
+ });
105
+ }
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Extract raw call sites from a Python AST.
111
+ * Direct calls only — Python has no DI pattern.
112
+ */
113
+ export function extractCallsFromPython(root: SgRoot, fp: string, calls: RawCallSite[]): void {
114
+ const rootNode = root.root();
115
+
116
+ for (const m of rootNode.findAll('$CALLEE($$$ARGS)')) {
117
+ const callee = m.getMatch('CALLEE')?.text();
118
+ if (!callee) continue;
119
+ const callName = callee.includes('.') ? callee.split('.').pop()! : callee;
120
+ if (NOISE.has(callName)) continue;
121
+ calls.push({
122
+ source: fp,
123
+ callName,
124
+ line: m.range().start.line,
125
+ });
126
+ }
127
+ }