code-graph-llm 1.1.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,46 +13,42 @@ 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
- 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'
30
+ export const SUPPORTED_EXTENSIONS = [
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
- function getIgnores(cwd) {
47
- const ig = ignore().add(['.git', 'node_modules', DEFAULT_MAP_FILE, 'package-lock.json']);
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
 
55
- function extractSymbols(content) {
51
+ export function extractSymbols(content) {
56
52
  const symbols = [];
57
53
  for (const regex of SYMBOL_REGEXES) {
58
54
  let match;
@@ -60,9 +56,8 @@ 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,20 +66,16 @@ 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
- const remainingLine = content.substring(match.index + match[0].length).split('\n')[0];
85
- const sigMatch = remainingLine.match(/^[^:{;]*/);
86
- if (sigMatch && sigMatch[0].trim()) {
87
- context = sigMatch[0].trim();
75
+ const remainingLine = content.substring(match.index + match[0].length);
76
+ const sigMatch = remainingLine.match(/^\s*(\([^)]*\)|[^\n{;]*)/);
77
+ if (sigMatch && sigMatch[1].trim()) {
78
+ context = sigMatch[1].trim();
88
79
  }
89
80
  }
90
81
 
@@ -96,27 +87,40 @@ function extractSymbols(content) {
96
87
  }
97
88
 
98
89
  async function generate(cwd = process.cwd()) {
99
- const ig = getIgnores(cwd);
100
90
  const files = [];
101
91
 
102
- 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
+
103
108
  const entries = fs.readdirSync(dir, { withFileTypes: true });
104
109
  for (const entry of entries) {
105
110
  const fullPath = path.join(dir, entry.name);
106
- let relativePath = path.relative(cwd, fullPath);
111
+ const relativePath = path.relative(cwd, fullPath);
107
112
  const normalizedPath = relativePath.replace(/\\/g, '/');
108
- const checkPath = entry.isDirectory() ? `${normalizedPath}/` : normalizedPath;
113
+ const isDirectory = entry.isDirectory();
114
+ const checkPath = isDirectory ? `${normalizedPath}/` : normalizedPath;
109
115
 
110
- if (ig.ignores(checkPath)) continue;
116
+ if (localIg.ignores(checkPath)) continue;
111
117
 
112
- if (entry.isDirectory()) {
113
- walk(fullPath);
118
+ if (isDirectory) {
119
+ walk(fullPath, localIg);
114
120
  } else if (entry.isFile()) {
115
121
  const ext = path.extname(entry.name);
116
122
  if (SUPPORTED_EXTENSIONS.includes(ext)) {
117
123
  const content = fs.readFileSync(fullPath, 'utf8');
118
-
119
- // Extract file-level description
120
124
  const firstLines = content.split('\n').slice(0, 5);
121
125
  let fileDesc = '';
122
126
  for (const line of firstLines) {
@@ -125,23 +129,15 @@ async function generate(cwd = process.cwd()) {
125
129
  fileDesc += trimmed.replace(/[\/*#]/g, '').trim() + ' ';
126
130
  }
127
131
  }
128
-
129
132
  const symbols = extractSymbols(content);
130
-
131
- // Backup: If no file description, provide a summary
132
- if (!fileDesc.trim() && symbols.length > 0) {
133
- fileDesc = `Contains ${symbols.length} symbols.`;
134
- }
135
-
133
+ if (!fileDesc.trim() && symbols.length > 0) fileDesc = `Contains ${symbols.length} symbols.`;
136
134
  files.push({ path: normalizedPath, desc: fileDesc.trim(), symbols });
137
- } else {
138
- files.push({ path: normalizedPath, desc: '', symbols: [] });
139
135
  }
140
136
  }
141
137
  }
142
138
  }
143
139
 
144
- walk(cwd);
140
+ walk(cwd, getIgnores(cwd));
145
141
 
146
142
  const output = files.map(f => {
147
143
  const descStr = f.desc ? ` | desc: ${f.desc.substring(0, 100)}` : '';
@@ -154,54 +150,49 @@ async function generate(cwd = process.cwd()) {
154
150
  console.log(`[Code-Graph] Updated ${DEFAULT_MAP_FILE}`);
155
151
  }
156
152
 
157
- function watch(cwd = process.cwd()) {
153
+ export { generate };
154
+
155
+ export function watch(cwd = process.cwd()) {
158
156
  console.log(`[Code-Graph] Watching for changes in ${cwd}...`);
159
- const ig = getIgnores(cwd);
160
-
157
+ let timeout;
158
+ const debouncedGenerate = () => {
159
+ clearTimeout(timeout);
160
+ timeout = setTimeout(() => generate(cwd), 500);
161
+ };
162
+
161
163
  const watcher = chokidar.watch(cwd, {
162
164
  ignored: (p) => {
163
- const rel = path.relative(cwd, p);
164
- return rel && ig.ignores(rel);
165
+ if (p === cwd) return false;
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;
165
169
  },
166
170
  persistent: true,
167
171
  ignoreInitial: true
168
172
  });
169
173
 
170
- let timeout;
171
- const debouncedGenerate = () => {
172
- clearTimeout(timeout);
173
- timeout = setTimeout(() => generate(cwd), 500);
174
- };
175
-
176
174
  watcher.on('all', (event, path) => {
177
- console.log(`[Code-Graph] Change detected: ${event} on ${path}`);
178
175
  debouncedGenerate();
179
176
  });
180
177
  }
181
178
 
182
- function installHook(cwd = process.cwd()) {
179
+ export function installHook(cwd = process.cwd()) {
183
180
  const hooksDir = path.join(cwd, '.git', 'hooks');
184
181
  if (!fs.existsSync(hooksDir)) {
185
182
  console.error('[Code-Graph] No .git directory found. Cannot install hook.');
186
183
  return;
187
184
  }
188
-
189
185
  const hookPath = path.join(hooksDir, 'pre-commit');
190
186
  const hookContent = `#!/bin/sh\n# Code-Graph pre-commit hook\nnode "${__filename}" generate\ngit add "${DEFAULT_MAP_FILE}"\n`;
191
-
192
187
  fs.writeFileSync(hookPath, hookContent, { mode: 0o755 });
193
188
  console.log('[Code-Graph] Installed pre-commit hook.');
194
189
  }
195
190
 
196
- const args = process.argv.slice(2);
197
- const command = args[0] || 'generate';
198
-
199
- if (command === 'generate') {
200
- generate();
201
- } else if (command === 'watch') {
202
- watch();
203
- } else if (command === 'install-hook') {
204
- installHook();
205
- } else {
206
- console.log('Usage: code-graph [generate|watch|install-hook]');
191
+ if (process.argv[1] && (process.argv[1] === fileURLToPath(import.meta.url) || process.argv[1].endsWith('index.js'))) {
192
+ const args = process.argv.slice(2);
193
+ const command = args[0] || 'generate';
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]');
207
198
  }
package/llm-code-graph.md CHANGED
@@ -3,6 +3,8 @@
3
3
 
4
4
  - .gitignore
5
5
  - index.js | desc: !usrbinenv node
6
- - syms: [Name [Dart:], Name [PHP: class Name, interface Name, trait Name,], Name [PHP: class Name, interface Name,], Name [PHP: class Name,], Name [PHP:], Name [Ruby: def name, class Name,], Name [Ruby: def name,], Name [Swift: func name, class Name, struct Name, protocol Name,], Name [Swift: func name, class Name, struct Name,], Name [Swift: func name, class Name,], Name [Swift: func name,], extractSymbols [(content)], generate [(cwd = process.cwd())], getIgnores [(cwd)], installHook [(cwd = process.cwd())], name [Dart: class Name, void name,], name [Ruby:], name [Swift:], walk [(dir)], watch [(cwd = process.cwd())]]
6
+ - syms: [Name [Dart:], Name [PHP: class Name, interface Name, trait Name,], Name [PHP: class Name, interface Name,], Name [PHP: class Name,], Name [PHP:], Name [Ruby: def name, class Name,], Name [Ruby: def name,], Name [Swift: func name, class Name, struct Name, protocol Name,], Name [Swift: func name, class Name, struct Name,], Name [Swift: func name, class Name,], Name [Swift: func name,], SUPPORTED_EXTENSIONS [= [], extractSymbols [(content)], function [generate(cwd = process.cwd())], generate [(cwd = process.cwd()], getIgnores [(cwd)], installHook [(cwd = process.cwd()], is [We must check if p is a directory to append the trailing slash Since chokidar's ignore], name [Dart: class Name, void name,], name [Ruby:], name [Swift:], walk [(dir)], watch [(cwd = process.cwd()]]
7
7
  - package.json
8
- - README.md
8
+ - README.md
9
+ - test/index.test.js | desc: Contains 5 symbols.
10
+ - syms: [noDocFunc [(arg1: string, arg2: number)], py_func [(x)], py_func [Note: Current regex captures '], py_func_2 [This is a python comment], testFunc [This is a test function]]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "code-graph-llm",
3
- "version": "1.1.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": {
@@ -24,5 +24,8 @@
24
24
  "chokidar": "^3.6.0",
25
25
  "ignore": "^5.3.1"
26
26
  },
27
- "type": "module"
27
+ "type": "module",
28
+ "scripts": {
29
+ "test": "node --test test/*.test.js"
30
+ }
28
31
  }
@@ -0,0 +1,75 @@
1
+ import assert from 'node:assert';
2
+ import test from 'node:test';
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import { fileURLToPath } from 'url';
6
+ import {
7
+ extractSymbols,
8
+ getIgnores,
9
+ SUPPORTED_EXTENSIONS,
10
+ generate
11
+ } from '../index.js';
12
+
13
+ test('extractSymbols - JS/TS Docstrings', () => {
14
+ const code = `
15
+ /**
16
+ * This is a test function
17
+ */
18
+ function testFunc(a, b) {}
19
+ `;
20
+ const symbols = extractSymbols(code);
21
+ assert.ok(symbols.some(s => s.includes('testFunc') && s.includes('This is a test function')));
22
+ });
23
+
24
+ test('extractSymbols - Signature Fallback', () => {
25
+ const code = `
26
+ function noDocFunc(arg1: string, arg2: number) {
27
+ return true;
28
+ }
29
+ `;
30
+ const symbols = extractSymbols(code);
31
+ // Matches "noDocFunc [(arg1: string, arg2: number)]"
32
+ assert.ok(symbols.some(s => s.includes('noDocFunc') && s.includes('arg1: string, arg2: number')));
33
+ });
34
+
35
+ test('extractSymbols - Flutter/Dart Noise Reduction', () => {
36
+ const code = `
37
+ const SizedBox(height: 10);
38
+ void realFunction() {}
39
+ `;
40
+ const symbols = extractSymbols(code);
41
+ assert.ok(symbols.some(s => s.includes('realFunction')));
42
+ assert.ok(!symbols.some(s => s.includes('SizedBox')));
43
+ });
44
+
45
+ test('getIgnores - Default Patterns', () => {
46
+ const ig = getIgnores(process.cwd());
47
+ assert.strictEqual(ig.ignores('.git/'), true);
48
+ assert.strictEqual(ig.ignores('node_modules/'), true);
49
+ assert.strictEqual(ig.ignores('.idea/'), true);
50
+ assert.strictEqual(ig.ignores('.dart_tool/'), true);
51
+ });
52
+
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 });
75
+ });