dsh-plugin-codegraph-tree-sitter 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.
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Per-language extraction tables: which tree-sitter node types are definitions, which are call
3
+ * sites, and which anchor an import, for every grammar this package loads today.
4
+ *
5
+ * The table is deliberately small. It covers the definition/call/import core the seam's operations
6
+ * depend on — not every declaration shape a language can produce — because the alternative is porting
7
+ * the reference indexer's language-specific resolution, which the proposal this package implements
8
+ * defers until the core proves itself against real workspaces.
9
+ * @module dsh-plugin-codegraph-tree-sitter/languages
10
+ */
11
+ /** Definitions common to every ECMAScript-family grammar (JavaScript, JSX, TypeScript, TSX). */
12
+ const ECMASCRIPT_DEFINITIONS = [
13
+ { nodeType: 'function_declaration', kind: 'function', nameField: 'name' },
14
+ { nodeType: 'generator_function_declaration', kind: 'function', nameField: 'name' },
15
+ { nodeType: 'method_definition', kind: 'method', nameField: 'name' },
16
+ { nodeType: 'class_declaration', kind: 'class', nameField: 'name' },
17
+ {
18
+ nodeType: 'variable_declarator',
19
+ kind: 'function',
20
+ nameField: 'name',
21
+ value: { field: 'value', types: ['arrow_function', 'function_expression', 'generator_function'] },
22
+ },
23
+ ];
24
+ /** Definitions TypeScript and TSX add on top of {@link ECMASCRIPT_DEFINITIONS}. */
25
+ const TYPESCRIPT_DEFINITIONS = [
26
+ ...ECMASCRIPT_DEFINITIONS,
27
+ { nodeType: 'interface_declaration', kind: 'interface', nameField: 'name' },
28
+ { nodeType: 'type_alias_declaration', kind: 'type_alias', nameField: 'name' },
29
+ { nodeType: 'enum_declaration', kind: 'enum', nameField: 'name' },
30
+ ];
31
+ const ECMASCRIPT_CALL_TYPES = ['call_expression'];
32
+ const ECMASCRIPT_IMPORT_TYPES = ['import_statement'];
33
+ /** Every grammar this package loads, keyed by the seam language label it produces. */
34
+ export const LANGUAGE_TABLE = [
35
+ {
36
+ language: 'typescript',
37
+ extensions: ['.ts', '.mts', '.cts'],
38
+ wasmFile: 'tree-sitter-typescript.wasm',
39
+ definitions: TYPESCRIPT_DEFINITIONS,
40
+ callTypes: ECMASCRIPT_CALL_TYPES,
41
+ callFunctionField: 'function',
42
+ importTypes: ECMASCRIPT_IMPORT_TYPES,
43
+ },
44
+ {
45
+ language: 'tsx',
46
+ extensions: ['.tsx'],
47
+ wasmFile: 'tree-sitter-tsx.wasm',
48
+ definitions: TYPESCRIPT_DEFINITIONS,
49
+ callTypes: ECMASCRIPT_CALL_TYPES,
50
+ callFunctionField: 'function',
51
+ importTypes: ECMASCRIPT_IMPORT_TYPES,
52
+ },
53
+ {
54
+ language: 'javascript',
55
+ extensions: ['.js', '.mjs', '.cjs'],
56
+ wasmFile: 'tree-sitter-javascript.wasm',
57
+ definitions: ECMASCRIPT_DEFINITIONS,
58
+ callTypes: ECMASCRIPT_CALL_TYPES,
59
+ callFunctionField: 'function',
60
+ importTypes: ECMASCRIPT_IMPORT_TYPES,
61
+ },
62
+ {
63
+ language: 'jsx',
64
+ extensions: ['.jsx'],
65
+ // The plain JavaScript grammar already parses JSX syntax; tree-sitter-wasms ships no separate
66
+ // "jsx" binary, only distinct typescript/tsx binaries.
67
+ wasmFile: 'tree-sitter-javascript.wasm',
68
+ definitions: ECMASCRIPT_DEFINITIONS,
69
+ callTypes: ECMASCRIPT_CALL_TYPES,
70
+ callFunctionField: 'function',
71
+ importTypes: ECMASCRIPT_IMPORT_TYPES,
72
+ },
73
+ {
74
+ language: 'python',
75
+ extensions: ['.py', '.pyi'],
76
+ wasmFile: 'tree-sitter-python.wasm',
77
+ definitions: [
78
+ { nodeType: 'function_definition', kind: 'function', nameField: 'name' },
79
+ { nodeType: 'class_definition', kind: 'class', nameField: 'name' },
80
+ ],
81
+ callTypes: ['call'],
82
+ callFunctionField: 'function',
83
+ importTypes: ['import_statement', 'import_from_statement'],
84
+ },
85
+ {
86
+ language: 'go',
87
+ extensions: ['.go'],
88
+ wasmFile: 'tree-sitter-go.wasm',
89
+ definitions: [
90
+ { nodeType: 'function_declaration', kind: 'function', nameField: 'name' },
91
+ { nodeType: 'method_declaration', kind: 'method', nameField: 'name' },
92
+ { nodeType: 'type_spec', kind: 'type_alias', nameField: 'name' },
93
+ ],
94
+ callTypes: ['call_expression'],
95
+ callFunctionField: 'function',
96
+ importTypes: ['import_declaration'],
97
+ },
98
+ ];
99
+ /**
100
+ * The grammar for a file extension, or `undefined` when this package indexes nothing for it.
101
+ * @param extension - the file extension, including the leading dot, e.g. `.ts`.
102
+ * @returns the matching language's extraction table entry, or `undefined`.
103
+ */
104
+ export function languageFor(extension) {
105
+ return LANGUAGE_TABLE.find(spec => spec.extensions.includes(extension));
106
+ }
107
+ //# sourceMappingURL=languages.js.map
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Workspace-wide, two-pass call resolution over every file's raw extraction.
3
+ *
4
+ * Pass 1 collects every definition into a name index. Pass 2 resolves each call site's callee name,
5
+ * in order: (1) the enclosing file imports that name from a relative specifier resolving to a file
6
+ * this run indexed — that file's matching declaration wins; (2) otherwise exactly one definition
7
+ * workspace-wide carries the name — it wins; (3) otherwise the call is recorded in `unresolved` and no
8
+ * `calls` edge is written. An ambiguous edge is worse than a missing one: the model acts on `callers`
9
+ * output, so a confidently wrong caller sends it to edit the wrong file, while a missing caller merely
10
+ * sends it back to text search.
11
+ *
12
+ * Rule 3's `unresolved` entries are not one uniform signal. Each carries {@link UnresolvedRef.likelyExternal},
13
+ * which is true for a member call (no type information behind its receiver) or a name the file itself
14
+ * imports (a known external origin the specifier just didn't resolve) — both real code that was never a
15
+ * candidate for a workspace edge, as against a bare, undeclared name, which is the actual gap this
16
+ * resolver's conservative rule leaves behind.
17
+ * @module dsh-plugin-codegraph-tree-sitter/resolve
18
+ */
19
+ import type { FileExtraction } from './extract.ts';
20
+ /** One file's extraction, positioned in the workspace. */
21
+ export interface ExtractedFile {
22
+ /** Project-relative path, forward-slash separated. */
23
+ readonly path: string;
24
+ readonly language: string;
25
+ readonly size: number;
26
+ readonly modifiedAt: number;
27
+ /** SHA-256 of the file's content, hex-encoded. */
28
+ readonly contentHash: string;
29
+ /** Total line count, for the synthetic file node's span. */
30
+ readonly lineCount: number;
31
+ readonly extraction: FileExtraction;
32
+ }
33
+ /** One resolved graph node — a file or a declaration. */
34
+ export interface GraphNode {
35
+ readonly id: string;
36
+ readonly kind: string;
37
+ readonly name: string;
38
+ readonly qualifiedName: string;
39
+ readonly filePath: string;
40
+ readonly language: string;
41
+ readonly startLine: number;
42
+ readonly endLine: number;
43
+ readonly startColumn: number;
44
+ readonly endColumn: number;
45
+ readonly isExported: boolean;
46
+ readonly isAsync: boolean;
47
+ readonly isStatic: boolean;
48
+ readonly updatedAt: number;
49
+ }
50
+ /** One resolved graph edge. */
51
+ export interface GraphEdge {
52
+ readonly source: string;
53
+ readonly target: string;
54
+ readonly kind: string;
55
+ readonly line?: number;
56
+ readonly col?: number;
57
+ readonly provenance: string;
58
+ }
59
+ /** One call site whose callee could not be resolved to exactly one declaration. */
60
+ export interface UnresolvedRef {
61
+ readonly source: string;
62
+ readonly filePath: string;
63
+ readonly calleeName: string;
64
+ readonly line: number;
65
+ readonly col: number;
66
+ /**
67
+ * Whether this site's own shape makes a workspace declaration structurally unlikely, rather than
68
+ * merely unresolved: a member access (`x.map()`, `expect(v).toBe()`) has no type information behind
69
+ * its receiver, and a bare name the file itself imports (`import { expect } from 'vitest'`) already
70
+ * has a known, non-workspace origin even though the specifier never resolved to an indexed file.
71
+ * Neither is a gap in the graph — both are real code that was never a candidate for a workspace edge
72
+ * — so a caller sizing "how much did resolution miss" should discount them.
73
+ */
74
+ readonly likelyExternal: boolean;
75
+ }
76
+ /** The resolved workspace graph. */
77
+ export interface ResolvedGraph {
78
+ readonly nodes: GraphNode[];
79
+ readonly edges: GraphEdge[];
80
+ readonly unresolved: UnresolvedRef[];
81
+ }
82
+ /**
83
+ * Resolve every file's raw extraction into the workspace graph.
84
+ * @param files - every file this run parsed, with its raw extraction.
85
+ * @param now - epoch milliseconds recorded as every node's `updatedAt`.
86
+ * @returns the resolved nodes, edges, and unresolved call sites.
87
+ */
88
+ export declare function resolveWorkspace(files: readonly ExtractedFile[], now: number): ResolvedGraph;
89
+ //# sourceMappingURL=resolve.d.ts.map
package/lib/resolve.js ADDED
@@ -0,0 +1,218 @@
1
+ /**
2
+ * Workspace-wide, two-pass call resolution over every file's raw extraction.
3
+ *
4
+ * Pass 1 collects every definition into a name index. Pass 2 resolves each call site's callee name,
5
+ * in order: (1) the enclosing file imports that name from a relative specifier resolving to a file
6
+ * this run indexed — that file's matching declaration wins; (2) otherwise exactly one definition
7
+ * workspace-wide carries the name — it wins; (3) otherwise the call is recorded in `unresolved` and no
8
+ * `calls` edge is written. An ambiguous edge is worse than a missing one: the model acts on `callers`
9
+ * output, so a confidently wrong caller sends it to edit the wrong file, while a missing caller merely
10
+ * sends it back to text search.
11
+ *
12
+ * Rule 3's `unresolved` entries are not one uniform signal. Each carries {@link UnresolvedRef.likelyExternal},
13
+ * which is true for a member call (no type information behind its receiver) or a name the file itself
14
+ * imports (a known external origin the specifier just didn't resolve) — both real code that was never a
15
+ * candidate for a workspace edge, as against a bare, undeclared name, which is the actual gap this
16
+ * resolver's conservative rule leaves behind.
17
+ * @module dsh-plugin-codegraph-tree-sitter/resolve
18
+ */
19
+ import { posix } from 'node:path';
20
+ /** File extensions tried, in order, when a relative specifier names no extension of its own. */
21
+ const RESOLUTION_SUFFIXES = {
22
+ typescript: ['.ts', '.tsx', '.d.ts', '/index.ts', '/index.tsx'],
23
+ tsx: ['.tsx', '.ts', '/index.tsx', '/index.ts'],
24
+ javascript: ['.js', '.jsx', '.mjs', '.cjs', '/index.js', '/index.jsx'],
25
+ jsx: ['.jsx', '.js', '/index.jsx', '/index.js'],
26
+ python: ['.py', '/__init__.py'],
27
+ };
28
+ /**
29
+ * Resolve a relative import specifier to a file this run indexed.
30
+ * @param fromPath - the importing file's project-relative path.
31
+ * @param specifier - the raw specifier text, e.g. `./bar` or `.bar`.
32
+ * @param language - the importing file's seam language label.
33
+ * @param indexed - every project-relative path this run indexed.
34
+ * @returns the resolved path, or `undefined` when the specifier is not relative, the language has no
35
+ * relative-import convention this package resolves, or nothing indexed matches.
36
+ */
37
+ function resolveSpecifier(fromPath, specifier, language, indexed) {
38
+ const suffixes = RESOLUTION_SUFFIXES[language];
39
+ if (suffixes === undefined)
40
+ return undefined;
41
+ const fromDir = posix.dirname(fromPath);
42
+ const base = language === 'python' ? pythonRelativeBase(fromDir, specifier) : ecmascriptRelativeBase(fromDir, specifier);
43
+ if (base === undefined)
44
+ return undefined;
45
+ if (indexed.has(base))
46
+ return base;
47
+ for (const suffix of suffixes) {
48
+ const candidate = `${base}${suffix}`;
49
+ if (indexed.has(candidate))
50
+ return candidate;
51
+ }
52
+ return undefined;
53
+ }
54
+ /** The ECMAScript relative-import base path, or `undefined` when `specifier` is not relative. */
55
+ function ecmascriptRelativeBase(fromDir, specifier) {
56
+ if (!specifier.startsWith('.'))
57
+ return undefined;
58
+ return posix.normalize(posix.join(fromDir, specifier));
59
+ }
60
+ /** The Python relative-import base path, or `undefined` when `specifier` carries no leading dots. */
61
+ function pythonRelativeBase(fromDir, specifier) {
62
+ const match = /^(\.+)(.*)$/.exec(specifier);
63
+ if (match === null)
64
+ return undefined;
65
+ const [, dots = '', rest = ''] = match;
66
+ let dir = fromDir;
67
+ // One dot means "this package" (no directory change); each additional dot climbs one level, matching
68
+ // Python's relative-import level convention.
69
+ for (let level = 1; level < dots.length; level++)
70
+ dir = posix.dirname(dir);
71
+ return rest === '' ? dir : posix.normalize(posix.join(dir, rest.replaceAll('.', '/')));
72
+ }
73
+ /**
74
+ * A map's value for `key`, asserting it is present. Every call site below looks up a key this same
75
+ * function inserted earlier in the same pass, so absence would mean a bug in that bookkeeping, not
76
+ * data this function must tolerate.
77
+ * @param map - the map to read.
78
+ * @param key - the key, guaranteed present by the caller's own construction.
79
+ * @returns the value.
80
+ */
81
+ function mustGet(map, key) {
82
+ const value = map.get(key);
83
+ /* v8 ignore next 2 -- every call site inserts `key` earlier in this same resolution pass. */
84
+ if (value === undefined)
85
+ throw new Error(`codegraph-tree-sitter: resolve.ts expected a value for ${String(key)}`);
86
+ return value;
87
+ }
88
+ /** The workspace's global declaration name index: every declaration name to the nodes carrying it. */
89
+ function buildNameIndex(nodesByFile) {
90
+ const index = new Map();
91
+ for (const nodes of nodesByFile.values()) {
92
+ for (const node of nodes) {
93
+ const existing = index.get(node.name);
94
+ if (existing === undefined)
95
+ index.set(node.name, [node]);
96
+ else
97
+ existing.push(node);
98
+ }
99
+ }
100
+ return index;
101
+ }
102
+ /** Resolve one file's raw imports against the indexed workspace. */
103
+ function resolveImports(path, language, imports, indexed) {
104
+ const byLocalName = new Map();
105
+ const targetFiles = new Set();
106
+ for (const binding of imports) {
107
+ const targetFile = resolveSpecifier(path, binding.specifier, language, indexed);
108
+ if (targetFile === undefined)
109
+ continue;
110
+ targetFiles.add(targetFile);
111
+ if (binding.importedName !== '*')
112
+ byLocalName.set(binding.localName, { targetFile, importedName: binding.importedName });
113
+ }
114
+ return { byLocalName, targetFiles };
115
+ }
116
+ /**
117
+ * Resolve every file's raw extraction into the workspace graph.
118
+ * @param files - every file this run parsed, with its raw extraction.
119
+ * @param now - epoch milliseconds recorded as every node's `updatedAt`.
120
+ * @returns the resolved nodes, edges, and unresolved call sites.
121
+ */
122
+ export function resolveWorkspace(files, now) {
123
+ const indexed = new Set(files.map(file => file.path));
124
+ const nodesByFile = new Map();
125
+ const fileNodeId = new Map();
126
+ const defIdByKey = new Map();
127
+ const nodes = [];
128
+ const edges = [];
129
+ for (const file of files) {
130
+ const id = `file:${file.path}`;
131
+ fileNodeId.set(file.path, id);
132
+ const fileNode = {
133
+ id,
134
+ kind: 'file',
135
+ name: posix.basename(file.path),
136
+ qualifiedName: file.path,
137
+ filePath: file.path,
138
+ language: file.language,
139
+ startLine: 1,
140
+ endLine: Math.max(1, file.lineCount),
141
+ startColumn: 0,
142
+ endColumn: 0,
143
+ isExported: false,
144
+ isAsync: false,
145
+ isStatic: false,
146
+ updatedAt: now,
147
+ };
148
+ nodes.push(fileNode);
149
+ const keyToId = new Map();
150
+ const fileNodes = [];
151
+ for (const def of file.extraction.definitions) {
152
+ const qualifiedName = def.container.length === 0
153
+ ? `${file.path}::${def.name}`
154
+ : `${file.path}::${[...def.container, def.name].join('.')}`;
155
+ const nodeId = `${file.path}:${def.startLine}:${def.startColumn}`;
156
+ keyToId.set(def.key, nodeId);
157
+ fileNodes.push({
158
+ id: nodeId,
159
+ kind: def.kind,
160
+ name: def.name,
161
+ qualifiedName,
162
+ filePath: file.path,
163
+ language: file.language,
164
+ startLine: def.startLine,
165
+ endLine: def.endLine,
166
+ startColumn: def.startColumn,
167
+ endColumn: def.endColumn,
168
+ isExported: def.isExported,
169
+ isAsync: def.isAsync,
170
+ isStatic: def.isStatic,
171
+ updatedAt: now,
172
+ });
173
+ }
174
+ nodes.push(...fileNodes);
175
+ nodesByFile.set(file.path, fileNodes);
176
+ defIdByKey.set(file.path, keyToId);
177
+ for (const def of file.extraction.definitions) {
178
+ const source = def.parentKey === null ? id : mustGet(keyToId, def.parentKey);
179
+ edges.push({ source, target: mustGet(keyToId, def.key), kind: 'contains', provenance: 'tree-sitter' });
180
+ }
181
+ }
182
+ const nameIndex = buildNameIndex(nodesByFile);
183
+ const unresolved = [];
184
+ for (const file of files) {
185
+ const bindings = resolveImports(file.path, file.language, file.extraction.imports, indexed);
186
+ for (const target of bindings.targetFiles) {
187
+ edges.push({ source: mustGet(fileNodeId, file.path), target: mustGet(fileNodeId, target), kind: 'imports', provenance: 'tree-sitter' });
188
+ }
189
+ const keyToId = mustGet(defIdByKey, file.path);
190
+ const importedLocalNames = new Set(file.extraction.imports.map(binding => binding.localName));
191
+ for (const call of file.extraction.calls) {
192
+ const callerId = call.callerKey === null ? mustGet(fileNodeId, file.path) : mustGet(keyToId, call.callerKey);
193
+ const resolved = resolveCall(call.calleeName, bindings, nodesByFile, nameIndex);
194
+ if (resolved === undefined) {
195
+ const likelyExternal = call.isMemberCall || importedLocalNames.has(call.calleeName);
196
+ unresolved.push({ source: callerId, filePath: file.path, calleeName: call.calleeName, line: call.line, col: call.column, likelyExternal });
197
+ continue;
198
+ }
199
+ edges.push({ source: callerId, target: resolved, kind: 'calls', line: call.line, col: call.column, provenance: 'tree-sitter' });
200
+ }
201
+ }
202
+ return { nodes, edges, unresolved };
203
+ }
204
+ /** Resolve one call's callee name to exactly one declaration, or `undefined` per rule 3. */
205
+ function resolveCall(calleeName, bindings, nodesByFile, nameIndex) {
206
+ const bound = bindings.byLocalName.get(calleeName);
207
+ if (bound !== undefined) {
208
+ const targetNodes = mustGet(nodesByFile, bound.targetFile);
209
+ const candidates = bound.importedName === 'default'
210
+ ? targetNodes.filter(node => node.isExported)
211
+ : targetNodes.filter(node => node.name === bound.importedName);
212
+ if (candidates.length === 1)
213
+ return candidates[0]?.id;
214
+ }
215
+ const workspaceWide = nameIndex.get(calleeName) ?? [];
216
+ return workspaceWide.length === 1 ? workspaceWide[0]?.id : undefined;
217
+ }
218
+ //# sourceMappingURL=resolve.js.map
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Schema-v4 database writer: create the on-disk tables `dsh-plugin-codegraph-sqlite` reads —
3
+ * verbatim in structure, so the store and the external `codegraph` CLI stay able to open what this
4
+ * package writes — and insert one indexing run's resolved graph.
5
+ *
6
+ * Writing replaces whatever was at the path: an indexing run is a full rebuild, not an incremental
7
+ * update. The new graph is built on a same-directory temporary file and `rename`d into place, so a
8
+ * reader opening `databasePath` at any point either sees the complete previous graph or the complete
9
+ * new one — never a window where the file exists with a fresh, empty schema and no rows yet, which a
10
+ * naive "delete then recreate" leaves open between `CREATE TABLE` and the first `COMMIT`.
11
+ * @module dsh-plugin-codegraph-tree-sitter/schema
12
+ */
13
+ import type { ExtractedFile, GraphEdge, GraphNode, UnresolvedRef } from './resolve.ts';
14
+ /** The schema version this package writes, matching the format `dsh-codegraph-sqlite` reads. */
15
+ export declare const SCHEMA_VERSION = 4;
16
+ /** One indexing run's complete output, ready to write. */
17
+ export interface WriteInput {
18
+ readonly files: readonly ExtractedFile[];
19
+ readonly nodes: readonly GraphNode[];
20
+ readonly edges: readonly GraphEdge[];
21
+ readonly unresolved: readonly UnresolvedRef[];
22
+ readonly indexedAt: number;
23
+ }
24
+ /**
25
+ * Replace the graph at `databasePath` with one indexing run's output.
26
+ *
27
+ * The full build happens on a temporary file beside `databasePath`; only a successful build is
28
+ * `rename`d over the real path, atomically on the same filesystem. A reader with a connection already
29
+ * open on the old file keeps reading it (POSIX unlink semantics) until it reopens — that half of
30
+ * freshness is the sqlite store's responsibility, not this writer's.
31
+ * @param databasePath - absolute path of the `.codegraph/codegraph.db` file to (re)create.
32
+ * @param input - the run's resolved graph and per-file metadata.
33
+ */
34
+ export declare function writeGraph(databasePath: string, input: WriteInput): Promise<void>;
35
+ //# sourceMappingURL=schema.d.ts.map
package/lib/schema.js ADDED
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Schema-v4 database writer: create the on-disk tables `dsh-plugin-codegraph-sqlite` reads —
3
+ * verbatim in structure, so the store and the external `codegraph` CLI stay able to open what this
4
+ * package writes — and insert one indexing run's resolved graph.
5
+ *
6
+ * Writing replaces whatever was at the path: an indexing run is a full rebuild, not an incremental
7
+ * update. The new graph is built on a same-directory temporary file and `rename`d into place, so a
8
+ * reader opening `databasePath` at any point either sees the complete previous graph or the complete
9
+ * new one — never a window where the file exists with a fresh, empty schema and no rows yet, which a
10
+ * naive "delete then recreate" leaves open between `CREATE TABLE` and the first `COMMIT`.
11
+ * @module dsh-plugin-codegraph-tree-sitter/schema
12
+ */
13
+ import { mkdir, rename, rm } from 'node:fs/promises';
14
+ import { dirname } from 'node:path';
15
+ import { DatabaseSync } from 'node:sqlite';
16
+ import { randomUUID } from 'node:crypto';
17
+ /** The schema version this package writes, matching the format `dsh-codegraph-sqlite` reads. */
18
+ export const SCHEMA_VERSION = 4;
19
+ /**
20
+ * DDL for every table this package writes. Structurally identical to the subset
21
+ * `dsh-codegraph-sqlite` reads, plus `unresolved_refs`, which no store reads today — it exists so an
22
+ * indexing run's conservative gaps are inspectable on disk, not only summarized in the run's report.
23
+ */
24
+ const SCHEMA = `
25
+ CREATE TABLE schema_versions (version INTEGER PRIMARY KEY, applied_at INTEGER NOT NULL, description TEXT);
26
+ CREATE TABLE nodes (
27
+ id TEXT PRIMARY KEY, kind TEXT NOT NULL, name TEXT NOT NULL, qualified_name TEXT NOT NULL,
28
+ file_path TEXT NOT NULL, language TEXT NOT NULL, start_line INTEGER NOT NULL, end_line INTEGER NOT NULL,
29
+ start_column INTEGER NOT NULL, end_column INTEGER NOT NULL, docstring TEXT, signature TEXT,
30
+ visibility TEXT, is_exported INTEGER DEFAULT 0, is_async INTEGER DEFAULT 0, is_static INTEGER DEFAULT 0,
31
+ is_abstract INTEGER DEFAULT 0, decorators TEXT, type_parameters TEXT, updated_at INTEGER NOT NULL);
32
+ CREATE TABLE edges (
33
+ id INTEGER PRIMARY KEY AUTOINCREMENT, source TEXT NOT NULL, target TEXT NOT NULL, kind TEXT NOT NULL,
34
+ metadata TEXT, line INTEGER, col INTEGER, provenance TEXT DEFAULT NULL);
35
+ CREATE TABLE files (
36
+ path TEXT PRIMARY KEY, content_hash TEXT NOT NULL, language TEXT NOT NULL, size INTEGER NOT NULL,
37
+ modified_at INTEGER NOT NULL, indexed_at INTEGER NOT NULL, node_count INTEGER DEFAULT 0, errors TEXT);
38
+ CREATE VIRTUAL TABLE nodes_fts USING fts5(
39
+ id, name, qualified_name, docstring, signature, content='nodes', content_rowid='rowid');
40
+ CREATE TRIGGER nodes_ai AFTER INSERT ON nodes BEGIN
41
+ INSERT INTO nodes_fts(rowid, id, name, qualified_name, docstring, signature)
42
+ VALUES (NEW.rowid, NEW.id, NEW.name, NEW.qualified_name, NEW.docstring, NEW.signature);
43
+ END;
44
+ CREATE TABLE unresolved_refs (
45
+ id INTEGER PRIMARY KEY AUTOINCREMENT, source TEXT NOT NULL, file_path TEXT NOT NULL,
46
+ callee_name TEXT NOT NULL, line INTEGER, col INTEGER);
47
+ `;
48
+ /** Remove a database file and its WAL/SHM sidecars, tolerating any of the three being absent. */
49
+ async function removeGraphFiles(path) {
50
+ await rm(path, { force: true });
51
+ await rm(`${path}-wal`, { force: true });
52
+ await rm(`${path}-shm`, { force: true });
53
+ }
54
+ /**
55
+ * Replace the graph at `databasePath` with one indexing run's output.
56
+ *
57
+ * The full build happens on a temporary file beside `databasePath`; only a successful build is
58
+ * `rename`d over the real path, atomically on the same filesystem. A reader with a connection already
59
+ * open on the old file keeps reading it (POSIX unlink semantics) until it reopens — that half of
60
+ * freshness is the sqlite store's responsibility, not this writer's.
61
+ * @param databasePath - absolute path of the `.codegraph/codegraph.db` file to (re)create.
62
+ * @param input - the run's resolved graph and per-file metadata.
63
+ */
64
+ export async function writeGraph(databasePath, input) {
65
+ await mkdir(dirname(databasePath), { recursive: true });
66
+ const tempPath = `${databasePath}.tmp-${process.pid}-${randomUUID()}`;
67
+ // Defensive: a previous run that crashed mid-write may have left this exact temp name behind.
68
+ await removeGraphFiles(tempPath);
69
+ try {
70
+ const db = new DatabaseSync(tempPath);
71
+ try {
72
+ db.exec('BEGIN');
73
+ db.exec(SCHEMA);
74
+ db.prepare('INSERT INTO schema_versions (version, applied_at, description) VALUES (?, ?, ?)')
75
+ .run(SCHEMA_VERSION, input.indexedAt, 'dsh-codegraph-tree-sitter');
76
+ const insertNode = db.prepare(`INSERT INTO nodes
77
+ (id, kind, name, qualified_name, file_path, language, start_line, end_line, start_column,
78
+ end_column, is_exported, is_async, is_static, updated_at)
79
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
80
+ for (const node of input.nodes) {
81
+ insertNode.run(node.id, node.kind, node.name, node.qualifiedName, node.filePath, node.language, node.startLine, node.endLine, node.startColumn, node.endColumn, node.isExported ? 1 : 0, node.isAsync ? 1 : 0, node.isStatic ? 1 : 0, node.updatedAt);
82
+ }
83
+ const insertEdge = db.prepare('INSERT INTO edges (source, target, kind, line, col, provenance) VALUES (?, ?, ?, ?, ?, ?)');
84
+ for (const edge of input.edges) {
85
+ insertEdge.run(edge.source, edge.target, edge.kind, edge.line ?? null, edge.col ?? null, edge.provenance);
86
+ }
87
+ const insertFile = db.prepare(`INSERT INTO files
88
+ (path, content_hash, language, size, modified_at, indexed_at, node_count)
89
+ VALUES (?, ?, ?, ?, ?, ?, ?)`);
90
+ const nodeCountByFile = new Map();
91
+ for (const node of input.nodes) {
92
+ if (node.kind === 'file')
93
+ continue;
94
+ nodeCountByFile.set(node.filePath, (nodeCountByFile.get(node.filePath) ?? 0) + 1);
95
+ }
96
+ for (const file of input.files) {
97
+ insertFile.run(file.path, file.contentHash, file.language, file.size, file.modifiedAt, input.indexedAt, nodeCountByFile.get(file.path) ?? 0);
98
+ }
99
+ const insertUnresolved = db.prepare('INSERT INTO unresolved_refs (source, file_path, callee_name, line, col) VALUES (?, ?, ?, ?, ?)');
100
+ for (const ref of input.unresolved) {
101
+ insertUnresolved.run(ref.source, ref.filePath, ref.calleeName, ref.line, ref.col);
102
+ }
103
+ db.exec('COMMIT');
104
+ }
105
+ catch (cause) {
106
+ db.exec('ROLLBACK');
107
+ throw cause;
108
+ }
109
+ finally {
110
+ db.close();
111
+ }
112
+ }
113
+ catch (cause) {
114
+ await removeGraphFiles(tempPath);
115
+ throw cause;
116
+ }
117
+ // The old file's sidecars, if any, describe the graph being replaced, not the new one; clear them
118
+ // before the rename so the new file never inherits a stale WAL/SHM pair.
119
+ await rm(`${databasePath}-wal`, { force: true });
120
+ await rm(`${databasePath}-shm`, { force: true });
121
+ await rename(tempPath, databasePath);
122
+ // rename() only moves the main file; the temp name's own sidecars (should the driver have left any)
123
+ // never travel with it and would otherwise linger under the temp name forever.
124
+ await removeGraphFiles(tempPath);
125
+ }
126
+ //# sourceMappingURL=schema.js.map
package/lib/walk.d.ts ADDED
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Workspace directory walk: find every file this package can extract from, parse it, and hand back
3
+ * one {@link ExtractedFile} per file that was actually indexed.
4
+ *
5
+ * The walk is deliberately simple: `exclude` entries name directory segments to never descend into
6
+ * (not wildcard globs — the default set needs none), and every bound (`maxFileBytes`, `maxFiles`) is
7
+ * a config field the deployment can retune rather than a constant, per the same-name entries this
8
+ * package's `Config` exposes.
9
+ * @module dsh-plugin-codegraph-tree-sitter/walk
10
+ */
11
+ import type { GitignoreRule } from './gitignore.ts';
12
+ import type { ExtractedFile } from './resolve.ts';
13
+ /**
14
+ * Whether one directory entry is excluded from both the index and (for `watcher.ts`) the watch tree —
15
+ * the single definition both consult, so "what the index covers" and "what gets watched" can never
16
+ * drift apart.
17
+ * @param name - the entry's own name (not a path), matched against `excluded` verbatim.
18
+ * @param relativePath - the entry's project-relative path, matched against the `.gitignore` rules.
19
+ * @param isDirectory - whether the entry is a directory, for `.gitignore`'s directory-only rules.
20
+ * @param excluded - directory/file segment names never descended into.
21
+ * @param gitignoreRules - the project's parsed `.gitignore`, or empty when not respected.
22
+ */
23
+ export declare function isExcluded(name: string, relativePath: string, isDirectory: boolean, excluded: ReadonlySet<string>, gitignoreRules: readonly GitignoreRule[]): boolean;
24
+ /** The walk's tunable bounds, mirroring this package's `Config`. */
25
+ export interface WalkConfig {
26
+ /** Directory segment names never descended into. */
27
+ readonly exclude: readonly string[];
28
+ /** Files larger than this are skipped and counted, never parsed. */
29
+ readonly maxFileBytes: number;
30
+ /** The walk stops discovering new files once this many have been found. */
31
+ readonly maxFiles: number;
32
+ /** Files parsed concurrently. */
33
+ readonly concurrency: number;
34
+ /** Restrict extraction to these seam language labels when set; every loaded grammar otherwise. */
35
+ readonly languages?: readonly string[];
36
+ /**
37
+ * Also exclude whatever the project root's `.gitignore` names, unioned with `exclude`. A project
38
+ * that compiles to a gitignored directory (`lib`, `dist`, ...) would otherwise hand the same symbol
39
+ * to the resolver twice — once from source, once from the build output — and "the one unique
40
+ * workspace-wide name wins" call resolution would pick between them arbitrarily.
41
+ */
42
+ readonly respectGitignore: boolean;
43
+ }
44
+ /** What the walk produced. */
45
+ export interface WalkResult {
46
+ readonly files: ExtractedFile[];
47
+ readonly filesSkipped: number;
48
+ }
49
+ /**
50
+ * Walk `projectRoot` and parse every candidate file it finds.
51
+ * @param projectRoot - absolute path of the workspace to index.
52
+ * @param config - the walk's bounds.
53
+ * @param signal - aborts the walk.
54
+ * @returns the parsed files and how many candidates were skipped (over size, or past `maxFiles`).
55
+ */
56
+ export declare function walkAndExtract(projectRoot: string, config: WalkConfig, signal?: AbortSignal): Promise<WalkResult>;
57
+ //# sourceMappingURL=walk.d.ts.map