code-graph-llm 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -5,6 +5,8 @@ A language-agnostic, ultra-compact codebase mapper designed specifically for LLM
5
5
  ## Features
6
6
  - **Smart Context Extraction:** Captures JSDoc, Python docstrings, and preceding comments for files and symbols.
7
7
  - **Signature Fallback:** Automatically extracts function signatures (parameters/types) if documentation is missing.
8
+ - **Recursive .gitignore Support:** Deeply respects both root and nested `.gitignore` files across the entire project structure.
9
+ - **Smart Flutter/Dart Support:** Optimized to reduce noise by filtering out common widget instantiations while capturing real functional declarations.
8
10
  - **Compact & Dense:** Optimized for LLM token efficiency, replacing expensive recursive file scans.
9
11
  - **Language-Agnostic:** Optimized regex support for JS/TS, Python, Go, Rust, Java, C#, C/C++, Swift, PHP, Ruby, Dart, and more.
10
12
  - **Recursive Ignore Logic:** Deeply respects `.gitignore` and standard excludes (`node_modules`, `.git`).
package/index.js CHANGED
@@ -13,42 +13,38 @@ const IGNORE_FILE = '.gitignore';
13
13
  const DEFAULT_MAP_FILE = 'llm-code-graph.md';
14
14
 
15
15
  const SYMBOL_REGEXES = [
16
- // Types, Classes, Interfaces, and Containers (Universal)
16
+ // Types, Classes, Interfaces (Universal)
17
17
  /\b(?:class|interface|type|struct|enum|protocol|extension|trait|module|namespace|object)\s+([a-zA-Z_]\w*)/g,
18
18
 
19
- // Explicit Function Keywords (JS, Python, Go, Rust, Ruby, PHP, Swift, Kotlin, Dart)
19
+ // Explicit Function Keywords
20
20
  /\b(?:function|def|fn|func|fun|method|procedure|sub|routine)\s+([a-zA-Z_]\w*)/g,
21
21
 
22
- // C-style / Java / C# / TypeScript Method Patterns
23
- // Matches: ReturnType Name(...) or AccessModifier Name(...)
24
- /\b(?:void|async|public|private|protected|static|virtual|override|readonly|int|float|double|char|bool|string|val|var|let|const|final)\s+([a-zA-Z_]\w*)(?=\s*\(|(?:\s*:\s*\w+)?\s*=>)/g,
22
+ // Method/Var Declarations (C-style, Java, C#, TS, Dart)
23
+ // Refined to require a variable/function name followed by a declaration signal
24
+ /\b(?:void|async|public|private|protected|static|virtual|override|readonly|int|float|double|char|bool|string|val|var|let|final)\s+([a-zA-Z_]\w*)(?=\s*(?:\([^)]*\)|[a-zA-Z_]\w*)\s*(?:\{|=>|;|=))/g,
25
25
 
26
- // Exported symbols (JS/TS specific but captures named exports)
27
- /\bexport\s+(?:default\s+)?(?:const|let|var|function|class|type|interface|enum|async|val)\s+([a-zA-Z_]\w*)/g,
28
-
29
- // Ruby: def name, class Name, module Name (defs covered by Explicit Function Keywords)
30
-
31
- // PHP: class Name, interface Name, trait Name, function Name
32
-
33
- // Swift: func name, class Name, struct Name, protocol Name, extension Name
34
-
35
- // Dart: class Name, void name, var name (void/var covered by C-style pattern)
26
+ // Exported symbols
27
+ /\bexport\s+(?:default\s+)?(?:const|let|var|function|class|type|interface|enum|async|val)\s+([a-zA-Z_]\w*)/g
36
28
  ];
37
29
 
38
30
  export const SUPPORTED_EXTENSIONS = [
39
- '.js', '.ts', '.jsx', '.tsx',
40
- '.py', '.go', '.rs', '.java',
41
- '.cpp', '.c', '.h', '.hpp', '.cc',
42
- '.rb', '.php', '.swift', '.kt',
43
- '.cs', '.dart', '.scala', '.m', '.mm'
31
+ '.js', '.ts', '.jsx', '.tsx', '.py', '.go', '.rs', '.java',
32
+ '.cpp', '.c', '.h', '.hpp', '.cc', '.rb', '.php', '.swift',
33
+ '.kt', '.cs', '.dart', '.scala', '.m', '.mm'
44
34
  ];
45
35
 
46
- export function getIgnores(cwd) {
47
- const ig = ignore().add(['.git/', 'node_modules/', DEFAULT_MAP_FILE, 'package-lock.json', '.idea/', 'build/', 'dist/', 'bin/', 'obj/']);
36
+ export function getIgnores(cwd, additionalLines = []) {
37
+ const ig = ignore().add([
38
+ '.git/', 'node_modules/', DEFAULT_MAP_FILE, 'package-lock.json',
39
+ '.idea/', 'build/', 'dist/', 'bin/', 'obj/', '.dart_tool/', '.pub-cache/', '.pub/'
40
+ ]);
48
41
  const ignorePath = path.join(cwd, IGNORE_FILE);
49
42
  if (fs.existsSync(ignorePath)) {
50
43
  ig.add(fs.readFileSync(ignorePath, 'utf8'));
51
44
  }
45
+ if (additionalLines.length > 0) {
46
+ ig.add(additionalLines);
47
+ }
52
48
  return ig;
53
49
  }
54
50
 
@@ -60,9 +56,8 @@ export function extractSymbols(content) {
60
56
  while ((match = regex.exec(content)) !== null) {
61
57
  if (match[1]) {
62
58
  const symbolName = match[1];
63
- if (['if', 'for', 'while', 'switch', 'return', 'await', 'yield'].includes(symbolName)) continue;
59
+ if (['if', 'for', 'while', 'switch', 'return', 'await', 'yield', 'const', 'new'].includes(symbolName)) continue;
64
60
 
65
- // 1. Extract preceding comment/docstring
66
61
  const linesBefore = content.substring(0, match.index).split('\n');
67
62
  let comment = '';
68
63
  for (let i = linesBefore.length - 1; i >= 0; i--) {
@@ -71,18 +66,13 @@ export function extractSymbols(content) {
71
66
  const clean = line.replace(/[\/*#"]/g, '').trim();
72
67
  if (clean) comment = clean + (comment ? ' ' + comment : '');
73
68
  if (comment.length > 80) break;
74
- } else if (line === '' && comment === '') {
75
- continue;
76
- } else {
77
- break;
78
- }
69
+ } else if (line === '' && comment === '') continue;
70
+ else break;
79
71
  }
80
72
 
81
- // 2. Backup: Extract Signature (Parameters/Type) if no comment
82
73
  let context = comment;
83
74
  if (!context) {
84
75
  const remainingLine = content.substring(match.index + match[0].length);
85
- // Match until the first opening brace or colon or end of line, but include balanced parentheses
86
76
  const sigMatch = remainingLine.match(/^\s*(\([^)]*\)|[^\n{;]*)/);
87
77
  if (sigMatch && sigMatch[1].trim()) {
88
78
  context = sigMatch[1].trim();
@@ -96,29 +86,41 @@ export function extractSymbols(content) {
96
86
  return Array.from(new Set(symbols)).sort();
97
87
  }
98
88
 
99
- export async function generate(cwd = process.cwd()) {
100
- const ig = getIgnores(cwd);
89
+ async function generate(cwd = process.cwd()) {
101
90
  const files = [];
102
91
 
103
- function walk(dir) {
92
+ function walk(dir, ig) {
93
+ // Check for local .gitignore and create a new scoped ignore object if found
94
+ let localIg = ig;
95
+ const localIgnorePath = path.join(dir, IGNORE_FILE);
96
+ if (fs.existsSync(localIgnorePath) && dir !== cwd) {
97
+ const content = fs.readFileSync(localIgnorePath, 'utf8');
98
+ const lines = content.split('\n').map(line => {
99
+ line = line.trim();
100
+ if (!line || line.startsWith('#')) return null;
101
+ // Rules in sub-gitignore are relative to that directory
102
+ const relDir = path.relative(cwd, dir).replace(/\\/g, '/');
103
+ return relDir ? `${relDir}/${line}` : line;
104
+ }).filter(Boolean);
105
+ localIg = ignore().add(ig).add(lines);
106
+ }
107
+
104
108
  const entries = fs.readdirSync(dir, { withFileTypes: true });
105
109
  for (const entry of entries) {
106
110
  const fullPath = path.join(dir, entry.name);
107
- let relativePath = path.relative(cwd, fullPath);
111
+ const relativePath = path.relative(cwd, fullPath);
108
112
  const normalizedPath = relativePath.replace(/\\/g, '/');
109
113
  const isDirectory = entry.isDirectory();
110
114
  const checkPath = isDirectory ? `${normalizedPath}/` : normalizedPath;
111
115
 
112
- if (ig.ignores(checkPath)) continue;
116
+ if (localIg.ignores(checkPath)) continue;
113
117
 
114
118
  if (isDirectory) {
115
- walk(fullPath);
119
+ walk(fullPath, localIg);
116
120
  } else if (entry.isFile()) {
117
121
  const ext = path.extname(entry.name);
118
122
  if (SUPPORTED_EXTENSIONS.includes(ext)) {
119
123
  const content = fs.readFileSync(fullPath, 'utf8');
120
-
121
- // Extract file-level description
122
124
  const firstLines = content.split('\n').slice(0, 5);
123
125
  let fileDesc = '';
124
126
  for (const line of firstLines) {
@@ -127,23 +129,15 @@ export async function generate(cwd = process.cwd()) {
127
129
  fileDesc += trimmed.replace(/[\/*#]/g, '').trim() + ' ';
128
130
  }
129
131
  }
130
-
131
132
  const symbols = extractSymbols(content);
132
-
133
- // Backup: If no file description, provide a summary
134
- if (!fileDesc.trim() && symbols.length > 0) {
135
- fileDesc = `Contains ${symbols.length} symbols.`;
136
- }
137
-
133
+ if (!fileDesc.trim() && symbols.length > 0) fileDesc = `Contains ${symbols.length} symbols.`;
138
134
  files.push({ path: normalizedPath, desc: fileDesc.trim(), symbols });
139
- } else {
140
- files.push({ path: normalizedPath, desc: '', symbols: [] });
141
135
  }
142
136
  }
143
137
  }
144
138
  }
145
139
 
146
- walk(cwd);
140
+ walk(cwd, getIgnores(cwd));
147
141
 
148
142
  const output = files.map(f => {
149
143
  const descStr = f.desc ? ` | desc: ${f.desc.substring(0, 100)}` : '';
@@ -156,36 +150,28 @@ export async function generate(cwd = process.cwd()) {
156
150
  console.log(`[Code-Graph] Updated ${DEFAULT_MAP_FILE}`);
157
151
  }
158
152
 
153
+ export { generate };
154
+
159
155
  export function watch(cwd = process.cwd()) {
160
156
  console.log(`[Code-Graph] Watching for changes in ${cwd}...`);
161
- const ig = getIgnores(cwd);
162
-
157
+ let timeout;
158
+ const debouncedGenerate = () => {
159
+ clearTimeout(timeout);
160
+ timeout = setTimeout(() => generate(cwd), 500);
161
+ };
162
+
163
163
  const watcher = chokidar.watch(cwd, {
164
164
  ignored: (p) => {
165
165
  if (p === cwd) return false;
166
- const rel = path.relative(cwd, p).replace(/\\/g, '/');
167
- // We must check if p is a directory to append the trailing slash
168
- // Since chokidar's ignore function is synchronous, we use fs.statSync
169
- try {
170
- const stats = fs.statSync(p);
171
- const checkPath = stats.isDirectory() ? `${rel}/` : rel;
172
- return ig.ignores(checkPath);
173
- } catch (e) {
174
- return false;
175
- }
166
+ // Watcher ignore is harder for recursive .gitignore without complexity
167
+ // We rely on the generate() call to skip them during walk
168
+ return false;
176
169
  },
177
170
  persistent: true,
178
171
  ignoreInitial: true
179
172
  });
180
173
 
181
- let timeout;
182
- const debouncedGenerate = () => {
183
- clearTimeout(timeout);
184
- timeout = setTimeout(() => generate(cwd), 500);
185
- };
186
-
187
174
  watcher.on('all', (event, path) => {
188
- console.log(`[Code-Graph] Change detected: ${event} on ${path}`);
189
175
  debouncedGenerate();
190
176
  });
191
177
  }
@@ -196,10 +182,8 @@ export function installHook(cwd = process.cwd()) {
196
182
  console.error('[Code-Graph] No .git directory found. Cannot install hook.');
197
183
  return;
198
184
  }
199
-
200
185
  const hookPath = path.join(hooksDir, 'pre-commit');
201
186
  const hookContent = `#!/bin/sh\n# Code-Graph pre-commit hook\nnode "${__filename}" generate\ngit add "${DEFAULT_MAP_FILE}"\n`;
202
-
203
187
  fs.writeFileSync(hookPath, hookContent, { mode: 0o755 });
204
188
  console.log('[Code-Graph] Installed pre-commit hook.');
205
189
  }
@@ -207,14 +191,8 @@ export function installHook(cwd = process.cwd()) {
207
191
  if (process.argv[1] && (process.argv[1] === fileURLToPath(import.meta.url) || process.argv[1].endsWith('index.js'))) {
208
192
  const args = process.argv.slice(2);
209
193
  const command = args[0] || 'generate';
210
-
211
- if (command === 'generate') {
212
- generate();
213
- } else if (command === 'watch') {
214
- watch();
215
- } else if (command === 'install-hook') {
216
- installHook();
217
- } else {
218
- console.log('Usage: code-graph [generate|watch|install-hook]');
219
- }
194
+ if (command === 'generate') generate();
195
+ else if (command === 'watch') watch();
196
+ else if (command === 'install-hook') installHook();
197
+ else console.log('Usage: code-graph [generate|watch|install-hook]');
220
198
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "code-graph-llm",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "Compact, language-agnostic codebase mapper for LLM token efficiency.",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -3,16 +3,11 @@ import test from 'node:test';
3
3
  import fs from 'node:fs';
4
4
  import path from 'node:path';
5
5
  import { fileURLToPath } from 'url';
6
-
7
- // Import the core functions by reading the file and eval-ing or refactoring.
8
- // For simplicity in this environment, I will redefine the core logic in the test
9
- // or point to the index.js if it was exported.
10
- // Since index.js is a CLI, I'll extract the logic into a testable state.
11
-
12
6
  import {
13
7
  extractSymbols,
14
8
  getIgnores,
15
- SUPPORTED_EXTENSIONS
9
+ SUPPORTED_EXTENSIONS,
10
+ generate
16
11
  } from '../index.js';
17
12
 
18
13
  test('extractSymbols - JS/TS Docstrings', () => {
@@ -33,27 +28,18 @@ test('extractSymbols - Signature Fallback', () => {
33
28
  }
34
29
  `;
35
30
  const symbols = extractSymbols(code);
31
+ // Matches "noDocFunc [(arg1: string, arg2: number)]"
36
32
  assert.ok(symbols.some(s => s.includes('noDocFunc') && s.includes('arg1: string, arg2: number')));
37
33
  });
38
34
 
39
- test('extractSymbols - Python Docstrings', () => {
35
+ test('extractSymbols - Flutter/Dart Noise Reduction', () => {
40
36
  const code = `
41
- def py_func(x):
42
- """
43
- Python docstring test
44
- """
45
- pass
37
+ const SizedBox(height: 10);
38
+ void realFunction() {}
46
39
  `;
47
40
  const symbols = extractSymbols(code);
48
- // Note: Current regex captures 'def py_func'. Docstring is captured if it's ABOVE the def.
49
- // Let's test the comment above pattern which is common for our current extractor.
50
- const codeWithComment = `
51
- # This is a python comment
52
- def py_func_2(x):
53
- pass
54
- `;
55
- const symbols2 = extractSymbols(codeWithComment);
56
- assert.ok(symbols2.some(s => s.includes('py_func_2') && s.includes('This is a python comment')));
41
+ assert.ok(symbols.some(s => s.includes('realFunction')));
42
+ assert.ok(!symbols.some(s => s.includes('SizedBox')));
57
43
  });
58
44
 
59
45
  test('getIgnores - Default Patterns', () => {
@@ -61,12 +47,29 @@ test('getIgnores - Default Patterns', () => {
61
47
  assert.strictEqual(ig.ignores('.git/'), true);
62
48
  assert.strictEqual(ig.ignores('node_modules/'), true);
63
49
  assert.strictEqual(ig.ignores('.idea/'), true);
64
- assert.strictEqual(ig.ignores('src/main.js'), false);
50
+ assert.strictEqual(ig.ignores('.dart_tool/'), true);
65
51
  });
66
52
 
67
- test('Supported Extensions', () => {
68
- assert.ok(SUPPORTED_EXTENSIONS.includes('.js'));
69
- assert.ok(SUPPORTED_EXTENSIONS.includes('.py'));
70
- assert.ok(SUPPORTED_EXTENSIONS.includes('.go'));
71
- assert.ok(SUPPORTED_EXTENSIONS.includes('.rs'));
53
+ test('Recursive Ignore Simulation (Logic Check)', async () => {
54
+ const tempDir = path.join(process.cwd(), 'temp_test_dir');
55
+ if (fs.existsSync(tempDir)) fs.rmSync(tempDir, { recursive: true });
56
+ fs.mkdirSync(tempDir);
57
+
58
+ const subDir = path.join(tempDir, 'subdir');
59
+ fs.mkdirSync(subDir);
60
+
61
+ // Create a file that should be ignored by subdir/.gitignore
62
+ fs.writeFileSync(path.join(subDir, 'ignored.js'), 'function ignored() {}');
63
+ fs.writeFileSync(path.join(subDir, 'included.js'), 'function included() {}');
64
+ fs.writeFileSync(path.join(subDir, '.gitignore'), 'ignored.js');
65
+
66
+ await generate(tempDir);
67
+
68
+ const mapPath = path.join(tempDir, 'llm-code-graph.md');
69
+ const mapContent = fs.readFileSync(mapPath, 'utf8');
70
+
71
+ assert.ok(mapContent.includes('included.js'));
72
+ assert.ok(!mapContent.includes('ignored.js'));
73
+
74
+ fs.rmSync(tempDir, { recursive: true });
72
75
  });