@nithin554/code-crawler 0.1.53 → 0.1.55

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
@@ -80,9 +80,19 @@ and answers them with bounded recursive SQL queries.
80
80
  bare/`this`/`super` calls, static/qualified calls, and superclass chains.
81
81
  Unresolvable calls become `unknown_symbol` phantom nodes with `resolved:false` edges,
82
82
  so the graph stays traversable.
83
- - **Idempotent, file-scoped indexing** — a full re-crawl refreshes changed files, rebuilds
84
- derived dynamic edges, and removes files that disappeared, without duplicating the graph.
85
- Scoped crawls (`fileFilter` or a forced `language`) never remove files outside their scope.
83
+ - **Incremental, file-scoped indexing** — each crawl records a per-file manifest
84
+ (`size`, mtime, SHA-256) and replays the cached parse result for files whose content is
85
+ unchanged, so a re-crawl re-parses only what actually changed. Changes are reported as
86
+ `filesAdded` / `filesUpdated` / `filesUnchanged` / `filesRemoved`. Derived dynamic edges
87
+ and orphan pruning are rebuilt only when something changed. A full re-crawl still removes
88
+ files that disappeared, without duplicating the graph, and scoped crawls (`fileFilter` or a
89
+ forced `language`) never remove files outside their scope.
90
+ - **FTS5-backed symbol search** — symbol lookup runs against an external-content FTS5 index
91
+ with BM25 ranking and exact/prefix boosting, falling back to a `LIKE` scan only when FTS
92
+ finds nothing. Search does not degrade as the graph grows.
93
+ - **Versioned schema with automatic migration** — the database carries a schema version and
94
+ upgrades itself in place on open. A database written by a newer build is rejected with a
95
+ clear message instead of being read as if the shape were known.
86
96
  - **One project per database** — indexing a different project into the same database fails
87
97
  with a `clear_index` hint instead of silently wiping the first index.
88
98
  - **Analytics** — hotspots (fan-in/fan-out/advice counts) and dead-code candidates.
@@ -97,7 +107,8 @@ Requires Node.js ≥ 20.
97
107
  ```bash
98
108
  npm install
99
109
  npm run build # tsc → dist/
100
- npm test # vitest (135 tests)
110
+ npm test # vitest (174 tests; run with CODECRAWLER_BENCH=1 for the scale test)
111
+ npm run bench # crawl/query benchmark on a generated ~137k-line project
101
112
  ```
102
113
 
103
114
  ## CLI
@@ -116,9 +127,101 @@ Environment variables:
116
127
  | `CODECRAWLER_DB` | `~/.code-crawler/index.db` | Database path |
117
128
  | `CODECRAWLER_EXCLUDE_DIRS` | `.git,node_modules,dist,build,target,...` | Dirs skipped during crawl |
118
129
  | `CODECRAWLER_DEFAULT_LIMIT` | `50` | Default result cap |
119
- | `CODECRAWLER_MAX_DEPTH` | `10` | Traversal depth cap |
130
+ | `CODECRAWLER_MAX_DEPTH` | `10` | Traversal depth cap (1–100) |
131
+ | `CODECRAWLER_MAX_FILE_BYTES` | `1000000` | Files larger than this are skipped, not parsed |
132
+ | `CODECRAWLER_RESPECT_GITIGNORE` | `true` | Honor the project's `.gitignore` during discovery |
120
133
  | `CODECRAWLER_HELPFUL_ERRORS` | `true` | Fuzzy suggestions on symbol misses |
121
134
 
135
+ Invalid values never crash the server: booleans fall back to their default and
136
+ integers are clamped to their documented range, each with a one-line warning on
137
+ stderr.
138
+
139
+ ## What gets indexed
140
+
141
+ `crawl` walks the project and skips `.git`, `node_modules`, build output, and
142
+ other `CODECRAWLER_EXCLUDE_DIRS` entries. On top of that:
143
+
144
+ - the project's **`.gitignore`** is honored (disable with
145
+ `CODECRAWLER_RESPECT_GITIGNORE=false`),
146
+ - an optional **`.crawlerrc`** in the project root can narrow or widen discovery
147
+ with gitignore-style globs:
148
+
149
+ ```json
150
+ { "include": ["src/", "packages/core/"], "exclude": ["**/generated/"] }
151
+ ```
152
+
153
+ When `include` is non-empty, only matching files are indexed. A malformed
154
+ `.crawlerrc` is ignored with a warning rather than failing the crawl.
155
+ - files above `CODECRAWLER_MAX_FILE_BYTES` and files that look binary are
156
+ skipped; `crawl_project` reports them in `skippedFiles` with a reason.
157
+ - per-file parse failures never abort the crawl; they are reported in
158
+ `parseErrors`.
159
+
160
+ Only the root `.gitignore` is read — nested `.gitignore` files are not yet
161
+ supported.
162
+
163
+ ## Incremental crawls
164
+
165
+ A crawl records a manifest row per file (`size`, mtime, SHA-256, language). On the
166
+ next crawl a file whose size and truncated mtime both match is served from a cached
167
+ parse result instead of being re-read and re-parsed; a file whose mtime changed but
168
+ whose hash did not is also replayed from cache. Resolution, phantom-node creation and
169
+ derived edges still run over the whole in-memory file set, so cross-file edges stay
170
+ correct — only I/O, parsing and database writes are skipped.
171
+
172
+ `crawl_project` reports the split:
173
+
174
+ | Field | Meaning |
175
+ | --- | --- |
176
+ | `filesAdded` | First seen by this crawl |
177
+ | `filesUpdated` | Content changed, re-parsed |
178
+ | `filesUnchanged` | Replayed from the parse cache |
179
+ | `filesRemoved` | Disappeared from disk and were pruned |
180
+
181
+ **Known limitation:** because an unchanged file's edges are not rewritten, a call that
182
+ becomes *newly resolvable* only because some other file changed (for example a method
183
+ gained an overload, or a class was introduced that a previously-ambiguous call could
184
+ now bind to) will not be upgraded in the untouched file. Touch that file, or run
185
+ `clear_index`, to force full resolution.
186
+
187
+ ## Benchmarks
188
+
189
+ `npm run bench` generates a synthetic TypeScript project (575 modules, ~137k lines,
190
+ ~9.8k methods), crawls it, and reports crawl phases and query latency percentiles.
191
+ `tests/perf.test.ts` asserts the same budgets and is opt-in, because a realistic
192
+ corpus takes time to crawl:
193
+
194
+ ```bash
195
+ npm run bench # human-readable report
196
+ CODECRAWLER_BENCH=1 npm test -- tests/perf.test.ts # enforce the budgets
197
+ CODECRAWLER_BENCH_LINES=20000 npm run bench # smaller corpus
198
+ ```
199
+
200
+ Measured on a Windows dev laptop (Node 20, warm disk cache); absolute numbers vary,
201
+ the *ratios* and the index usage are what matter:
202
+
203
+ | Phase | Duration | Added | Updated | Unchanged |
204
+ | --- | --- | --- | --- | --- |
205
+ | Cold crawl | 22.5 s | 575 | 0 | 0 |
206
+ | Re-crawl, nothing changed | 0.91 s | 0 | 0 | 575 |
207
+ | Re-crawl after a one-file edit | 0.75 s | 0 | 1 | 574 |
208
+
209
+ | Query | p50 | p95 | Budget (p95) |
210
+ | --- | --- | --- | --- |
211
+ | `search_symbols` (exact) | 0.69 ms | 1.96 ms | 50 ms |
212
+ | `search_symbols` (prefix) | 1.60 ms | 4.09 ms | 50 ms |
213
+ | `get_callers` (depth 2) | 0.16 ms | 0.21 ms | 50 ms |
214
+ | `get_callees` (depth 2) | 0.11 ms | 0.15 ms | 50 ms |
215
+ | `get_subgraph` (radius 2) | 0.16 ms | 0.27 ms | 50 ms |
216
+ | `find_entry_points` | 0.12 ms | 0.29 ms | 50 ms |
217
+ | `find_hotspots` | 145.5 ms | 181.4 ms | 1000 ms |
218
+ | `find_dead_code` | 99.8 ms | 290.3 ms | 1000 ms |
219
+
220
+ The index is 66 MB for those 14.4k nodes / 38.8k edges. `find_hotspots` and
221
+ `find_dead_code` are whole-project aggregations over every edge and node, which is why
222
+ their band is an order of magnitude looser than the symbol lookups; both were profiled
223
+ with `EXPLAIN QUERY PLAN` and are served by `idx_edges_target_kind`.
224
+
122
225
  ## CI & publishing
123
226
 
124
227
  GitHub Actions runs on every pull request (`pull-request.yml`): `npm ci` →
package/dist/config.d.ts CHANGED
@@ -7,6 +7,10 @@ export interface Config {
7
7
  defaultLimit: number;
8
8
  /** Cap on transitive traversal depth to keep queries bounded. */
9
9
  maxDepth: number;
10
+ /** Files larger than this are skipped instead of parsed. */
11
+ maxFileBytes: number;
12
+ /** Honor the project's .gitignore when discovering source files. */
13
+ respectGitignore: boolean;
10
14
  /** When true, errors include symbol-search suggestions. */
11
15
  helpfulErrors: boolean;
12
16
  }
package/dist/config.js CHANGED
@@ -7,25 +7,56 @@ import os from "node:os";
7
7
  * point the server at different databases without code changes.
8
8
  */
9
9
  const DEFAULT_DB_PATH = path.join(os.homedir(), ".code-crawler", "index.db");
10
+ function warn(message) {
11
+ process.stderr.write(`code-crawler: ${message}\n`);
12
+ }
10
13
  function envStr(name, fallback) {
11
14
  const v = process.env[name];
12
15
  return v && v.length > 0 ? v : fallback;
13
16
  }
14
17
  function envBool(name, fallback) {
15
- const v = process.env[name];
16
- if (v === undefined)
18
+ const raw = process.env[name];
19
+ if (raw === undefined || raw.trim() === "")
20
+ return fallback;
21
+ const v = raw.trim().toLowerCase();
22
+ if (v === "1" || v === "true" || v === "yes" || v === "on")
23
+ return true;
24
+ if (v === "0" || v === "false" || v === "no" || v === "off")
25
+ return false;
26
+ warn(`${name}="${raw}" is not a boolean (use true/false); using ${fallback}`);
27
+ return fallback;
28
+ }
29
+ /** Parse an integer, clamping out-of-range values and warning about both. */
30
+ function envInt(name, fallback, min, max) {
31
+ const raw = process.env[name];
32
+ if (raw === undefined || raw.trim() === "")
33
+ return fallback;
34
+ const parsed = Number(raw);
35
+ if (!Number.isFinite(parsed)) {
36
+ warn(`${name}="${raw}" is not a number; using ${fallback}`);
17
37
  return fallback;
18
- return v === "1" || v.toLowerCase() === "true";
38
+ }
39
+ const truncated = Math.trunc(parsed);
40
+ if (truncated < min || truncated > max) {
41
+ const clamped = Math.min(max, Math.max(min, truncated));
42
+ warn(`${name}="${raw}" must be between ${min} and ${max}; using ${clamped}`);
43
+ return clamped;
44
+ }
45
+ return truncated;
19
46
  }
20
47
  export function loadConfig() {
21
48
  return {
22
49
  dbPath: envStr("CODECRAWLER_DB", DEFAULT_DB_PATH),
23
- excludeDirs: envStr("CODECRAWLER_EXCLUDE_DIRS", ".git,node_modules,dist,build,target,.idea,.venv,.gradle,out,.next,coverage")
24
- .split(",")
25
- .map((s) => s.trim())
26
- .filter((s) => s.length > 0),
27
- defaultLimit: Number(envStr("CODECRAWLER_DEFAULT_LIMIT", "50")),
28
- maxDepth: Number(envStr("CODECRAWLER_MAX_DEPTH", "10")),
50
+ excludeDirs: [
51
+ ...new Set(envStr("CODECRAWLER_EXCLUDE_DIRS", ".git,node_modules,dist,build,target,.idea,.venv,.gradle,out,.next,coverage")
52
+ .split(",")
53
+ .map((s) => s.trim())
54
+ .filter((s) => s.length > 0)),
55
+ ],
56
+ defaultLimit: envInt("CODECRAWLER_DEFAULT_LIMIT", 50, 1, 5000),
57
+ maxDepth: envInt("CODECRAWLER_MAX_DEPTH", 10, 1, 100),
58
+ maxFileBytes: envInt("CODECRAWLER_MAX_FILE_BYTES", 1_000_000, 1024, 64 * 1024 * 1024),
59
+ respectGitignore: envBool("CODECRAWLER_RESPECT_GITIGNORE", true),
29
60
  helpfulErrors: envBool("CODECRAWLER_HELPFUL_ERRORS", true),
30
61
  };
31
62
  }
@@ -1 +1 @@
1
- {"version":3,"file":"config.js","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AAAA,OAAO,IAAI,MAAM,WAAW,CAAC;AAC7B,OAAO,EAAE,MAAM,SAAS,CAAC;AAEzB;;;;;GAKG;AAEH,MAAM,eAAe,GAAG,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC,OAAO,EAAE,EAAE,eAAe,EAAE,UAAU,CAAC,CAAC;AAE7E,SAAS,MAAM,CAAC,IAAY,EAAE,QAAgB;IAC5C,MAAM,CAAC,GAAG,OAAO,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;IAC5B,OAAO,CAAC,IAAI,CAAC,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC;AAC1C,CAAC;AAED,SAAS,OAAO,CAAC,IAAY,EAAE,QAAiB;IAC9C,MAAM,CAAC,GAAG,OAAO,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;IAC5B,IAAI,CAAC,KAAK,SAAS;QAAE,OAAO,QAAQ,CAAC;IACrC,OAAO,CAAC,KAAK,GAAG,IAAI,CAAC,CAAC,WAAW,EAAE,KAAK,MAAM,CAAC;AACjD,CAAC;AAeD,MAAM,UAAU,UAAU;IACxB,OAAO;QACL,MAAM,EAAE,MAAM,CAAC,gBAAgB,EAAE,eAAe,CAAC;QACjD,WAAW,EAAE,MAAM,CACjB,0BAA0B,EAC1B,4EAA4E,CAC7E;aACE,KAAK,CAAC,GAAG,CAAC;aACV,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC;aACpB,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,MAAM,GAAG,CAAC,CAAC;QAC9B,YAAY,EAAE,MAAM,CAAC,MAAM,CAAC,2BAA2B,EAAE,IAAI,CAAC,CAAC;QAC/D,QAAQ,EAAE,MAAM,CAAC,MAAM,CAAC,uBAAuB,EAAE,IAAI,CAAC,CAAC;QACvD,aAAa,EAAE,OAAO,CAAC,4BAA4B,EAAE,IAAI,CAAC;KAC3D,CAAC;AACJ,CAAC"}
1
+ {"version":3,"file":"config.js","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"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"}
package/dist/crawler.d.ts CHANGED
@@ -11,12 +11,40 @@ export interface CrawlOptions {
11
11
  language?: string;
12
12
  /** Optional include filter: path must contain this substring. */
13
13
  fileFilter?: string;
14
+ /** Honor the project's .gitignore (default true). */
15
+ respectGitignore?: boolean;
16
+ /** Skip files larger than this many bytes. */
17
+ maxFileBytes?: number;
18
+ /** Called as parsing/persisting advances, for progress reporting. */
19
+ onProgress?: (progress: CrawlProgress) => void;
20
+ }
21
+ export interface CrawlProgress {
22
+ phase: "parse" | "persist";
23
+ processed: number;
24
+ total: number;
25
+ }
26
+ /** A discovered file that was deliberately not parsed. */
27
+ export interface SkippedFile {
28
+ path: string;
29
+ reason: "too-large" | "binary";
14
30
  }
15
31
  export interface CrawlResult {
16
32
  projectPath: string;
17
33
  filesScanned: number;
34
+ /** Files whose parse result is in the index after this crawl. */
18
35
  filesParsed: number;
36
+ /** Files parsed for the first time by this crawl. */
37
+ filesAdded: number;
38
+ /** Files re-parsed because their content changed. */
39
+ filesUpdated: number;
40
+ /** Files replayed from the parse cache because their content was identical. */
41
+ filesUnchanged: number;
19
42
  filesRemoved: number;
43
+ /** Files skipped because they are too large or look binary. */
44
+ filesSkipped: number;
45
+ skippedFiles: SkippedFile[];
46
+ /** Per-file parse failures (paths and messages, capped for output size). */
47
+ parseErrors: string[];
20
48
  /** False when the crawl was scoped (fileFilter/language) and stale pruning was skipped. */
21
49
  pruned: boolean;
22
50
  /** File-less shared nodes deleted because nothing references them anymore. */
@@ -46,8 +74,20 @@ export interface CrawlResult {
46
74
  languages: string[];
47
75
  durationMs: number;
48
76
  }
49
- /** Discover source files under a project, respecting exclusion dirs. */
50
- export declare function discoverSourceFiles(projectPath: string, excludeDirs: string[], language?: string): string[];
77
+ export interface DiscoverOptions {
78
+ language?: string;
79
+ /** Honor the project's .gitignore (default true). */
80
+ respectGitignore?: boolean;
81
+ }
82
+ /**
83
+ * Discover source files under a project.
84
+ *
85
+ * Honors the hardcoded/configured exclusion directory names, the project's
86
+ * `.gitignore`, and an optional `.crawlerrc` with gitignore-style `include`
87
+ * and `exclude` globs. When `include` is present only matching files are
88
+ * indexed.
89
+ */
90
+ export declare function discoverSourceFiles(projectPath: string, excludeDirs: string[], options?: string | DiscoverOptions): string[];
51
91
  /** Convert a ParsedFile into graph nodes + edges (declarations only). */
52
92
  export declare function buildDeclarationGraph(parsed: ParsedFile): {
53
93
  nodes: GraphNode[];
package/dist/crawler.js CHANGED
@@ -1,15 +1,79 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
+ import { createHash } from "node:crypto";
4
+ import ignore from "ignore";
3
5
  import { annotationNodeId, externalNodeId, fieldNodeId, importNodeId } from "./graph/schema.js";
4
6
  import { languageForFile } from "./parser/languages/index.js";
7
+ import { decodeParsedFile, encodeParsedFile } from "./parser/cache.js";
5
8
  import { buildTypeIndex, phantomNodeId } from "./resolver/types.js";
6
9
  import { resolveJavaFile } from "./resolver/javaResolver.js";
7
10
  import { resolveGenericFile, resolveInjectionTarget, resolveReflectiveTarget, resolveRouteHandler } from "./resolver/genericResolver.js";
8
11
  import { normalizeTypeText, resolveTypeCandidates } from "./resolver/common.js";
9
12
  import { buildDynamicEdges, isDecoratorAnnotation } from "./resolver/dynamic.js";
10
- /** Discover source files under a project, respecting exclusion dirs. */
11
- export function discoverSourceFiles(projectPath, excludeDirs, language) {
13
+ /** Optional project-local crawl configuration. */
14
+ const CRAWLER_RC = ".crawlerrc";
15
+ function loadCrawlerRc(projectPath) {
16
+ const file = path.join(projectPath, CRAWLER_RC);
17
+ if (!fs.existsSync(file))
18
+ return {};
19
+ try {
20
+ const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
21
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
22
+ throw new Error("expected a JSON object");
23
+ }
24
+ const record = parsed;
25
+ const strings = (value, field) => {
26
+ if (value === undefined)
27
+ return undefined;
28
+ if (!Array.isArray(value) || value.some((v) => typeof v !== "string")) {
29
+ throw new Error(`"${field}" must be an array of strings`);
30
+ }
31
+ return value;
32
+ };
33
+ return {
34
+ include: strings(record.include, "include"),
35
+ exclude: strings(record.exclude, "exclude"),
36
+ };
37
+ }
38
+ catch (err) {
39
+ process.stderr.write(`code-crawler: ignoring ${CRAWLER_RC}: ${err.message}\n`);
40
+ return {};
41
+ }
42
+ }
43
+ /**
44
+ * Discover source files under a project.
45
+ *
46
+ * Honors the hardcoded/configured exclusion directory names, the project's
47
+ * `.gitignore`, and an optional `.crawlerrc` with gitignore-style `include`
48
+ * and `exclude` globs. When `include` is present only matching files are
49
+ * indexed.
50
+ */
51
+ export function discoverSourceFiles(projectPath, excludeDirs, options = {}) {
52
+ const opts = typeof options === "string" ? { language: options } : options;
12
53
  const excluded = new Set(excludeDirs.map((d) => d.replace(/\/+$/, "")));
54
+ const rc = loadCrawlerRc(projectPath);
55
+ const excludeMatcher = ignore().add(rc.exclude ?? []);
56
+ const includeMatcher = ignore().add(rc.include ?? []);
57
+ const hasIncludes = (rc.include?.length ?? 0) > 0;
58
+ if (opts.respectGitignore !== false) {
59
+ try {
60
+ const gitignorePath = path.join(projectPath, ".gitignore");
61
+ if (fs.existsSync(gitignorePath)) {
62
+ excludeMatcher.add(fs.readFileSync(gitignorePath, "utf8"));
63
+ }
64
+ }
65
+ catch {
66
+ // Unreadable .gitignore: fall back to indexing everything.
67
+ }
68
+ }
69
+ const ignored = (rel, isDir) => {
70
+ const candidate = isDir ? `${rel}/` : rel;
71
+ if (excludeMatcher.ignores(candidate))
72
+ return true;
73
+ if (hasIncludes && !includeMatcher.ignores(candidate))
74
+ return true;
75
+ return false;
76
+ };
13
77
  const out = [];
14
78
  function walk(dir) {
15
79
  let entries;
@@ -21,11 +85,15 @@ export function discoverSourceFiles(projectPath, excludeDirs, language) {
21
85
  }
22
86
  for (const entry of entries) {
23
87
  const full = path.join(dir, entry.name);
88
+ const rel = path.relative(projectPath, full).split(path.sep).join("/");
24
89
  if (entry.isDirectory()) {
25
- if (!excluded.has(entry.name))
26
- walk(full);
90
+ if (excluded.has(entry.name) || ignored(rel, true))
91
+ continue;
92
+ walk(full);
27
93
  }
28
- else if (entry.isFile() && languageForFile(entry.name, language)) {
94
+ else if (entry.isFile() &&
95
+ languageForFile(entry.name, opts.language) &&
96
+ !ignored(rel, false)) {
29
97
  out.push(full);
30
98
  }
31
99
  }
@@ -38,7 +106,7 @@ export function buildDeclarationGraph(parsed) {
38
106
  const nodes = [];
39
107
  const edges = [];
40
108
  const fileId = parsed.filePath;
41
- const fileLines = sourceLineCount(parsed.filePath);
109
+ const fileLines = parsed.lineCount ?? sourceLineCount(parsed.filePath);
42
110
  nodes.push({
43
111
  id: fileId,
44
112
  kind: "file",
@@ -599,30 +667,125 @@ export async function crawlProject(store, projectPath, excludeDirs, options = {}
599
667
  // Stale pruning is only meaningful for a full scan. fileFilter and a forced
600
668
  // language narrow the scan, so files outside that scope must be left alone.
601
669
  const scopedScan = Boolean(options.fileFilter || options.language);
602
- const files = discoverSourceFiles(absPath, excludeDirs, options.language);
670
+ const files = discoverSourceFiles(absPath, excludeDirs, {
671
+ language: options.language,
672
+ respectGitignore: options.respectGitignore,
673
+ });
603
674
  if (options.fileFilter) {
604
675
  const filter = options.fileFilter;
605
676
  files.splice(0, files.length, ...files.filter((f) => f.includes(filter)));
606
677
  }
607
- // Parse every file (grammar is cached, so subsequent parses are fast).
678
+ const maxFileBytes = options.maxFileBytes ?? 1_000_000;
679
+ const skipSampleBytes = 8192;
680
+ // Incremental crawl: the manifest records size + mtime + content hash for
681
+ // every indexed file, together with its parse result. A file whose metadata
682
+ // is unchanged is replayed from the cache; a file whose metadata changed but
683
+ // whose hash matches was only touched and is replayed too.
684
+ const manifest = store.listFileRecords();
608
685
  const parsedFiles = [];
609
686
  const parseErrors = [];
687
+ const skippedFiles = [];
688
+ const changedPaths = new Set();
689
+ const recordsToWrite = new Map();
690
+ let filesAdded = 0;
691
+ let filesUpdated = 0;
692
+ let filesUnchanged = 0;
693
+ const pendingParses = [];
694
+ // Pass 1: stat only. Cheap enough to run over every discovered file and it
695
+ // decides whether any real parsing (or indexing) work is needed at all.
610
696
  for (const file of files) {
611
697
  try {
612
- const source = fs.readFileSync(file, "utf8");
698
+ const stat = fs.statSync(file);
699
+ if (stat.size > maxFileBytes) {
700
+ skippedFiles.push({ path: file, reason: "too-large" });
701
+ continue;
702
+ }
703
+ const record = manifest.get(file);
704
+ const mtimeMs = Math.trunc(stat.mtimeMs);
705
+ if (record && record.size === stat.size && record.mtimeMs === mtimeMs) {
706
+ const cached = decodeParsedFile(record.summary);
707
+ if (cached) {
708
+ filesUnchanged++;
709
+ parsedFiles.push(cached);
710
+ continue;
711
+ }
712
+ }
713
+ pendingParses.push({ path: file, size: stat.size, mtimeMs });
714
+ }
715
+ catch (err) {
716
+ if (parseErrors.length < 50) {
717
+ parseErrors.push(`${file}: ${err.message}`);
718
+ }
719
+ }
720
+ }
721
+ // Pass 2: read, hash and (only when the content really changed) parse.
722
+ // Each file is read once: the same buffer provides the binary sniff, the
723
+ // content hash, the source text, and the line count.
724
+ let processedFiles = 0;
725
+ for (const pending of pendingParses) {
726
+ const file = pending.path;
727
+ try {
728
+ const buffer = fs.readFileSync(file);
729
+ if (buffer.subarray(0, skipSampleBytes).includes(0)) {
730
+ skippedFiles.push({ path: file, reason: "binary" });
731
+ continue;
732
+ }
613
733
  const config = languageForFile(file, options.language);
614
734
  if (!config)
615
735
  continue;
616
- parsedFiles.push(await config.parseFile(source, file, { projectRoot: absPath }));
736
+ const sha256 = createHash("sha256").update(buffer).digest("hex");
737
+ const record = manifest.get(file);
738
+ if (record && record.sha256 === sha256) {
739
+ const cached = decodeParsedFile(record.summary);
740
+ if (cached) {
741
+ // Same content, new timestamp: refresh metadata without re-parsing.
742
+ filesUnchanged++;
743
+ parsedFiles.push(cached);
744
+ recordsToWrite.set(file, { ...record, size: pending.size, mtimeMs: pending.mtimeMs });
745
+ continue;
746
+ }
747
+ }
748
+ const source = buffer.toString("utf8");
749
+ const parsed = await config.parseFile(source, file, { projectRoot: absPath });
750
+ const full = { ...parsed, lineCount: source.split("\n").length };
751
+ parsedFiles.push(full);
752
+ changedPaths.add(file);
753
+ if (record)
754
+ filesUpdated++;
755
+ else
756
+ filesAdded++;
757
+ recordsToWrite.set(file, {
758
+ path: file,
759
+ size: pending.size,
760
+ mtimeMs: pending.mtimeMs,
761
+ sha256,
762
+ language: full.language,
763
+ summary: encodeParsedFile(full),
764
+ });
617
765
  }
618
766
  catch (err) {
619
- parseErrors.push(`${file}: ${err.message}`);
767
+ if (parseErrors.length < 50) {
768
+ parseErrors.push(`${file}: ${err.message}`);
769
+ }
770
+ }
771
+ finally {
772
+ processedFiles++;
773
+ options.onProgress?.({
774
+ phase: "parse",
775
+ processed: processedFiles,
776
+ total: pendingParses.length,
777
+ });
778
+ // Parsing is synchronous once a grammar is loaded; yield so the stdio
779
+ // server can answer requests during long crawls.
780
+ await new Promise((resolve) => setImmediate(resolve));
620
781
  }
621
782
  }
622
783
  // Global index for cross-file resolution.
623
784
  const index = buildTypeIndex(parsedFiles);
624
785
  // Build nodes + edges per file and persist file-scoped.
625
- const previouslyIndexed = new Set(store.raw("SELECT DISTINCT file_path FROM nodes WHERE file_path IS NOT NULL").map((r) => r.file_path));
786
+ const previouslyIndexed = new Set(store
787
+ .raw("SELECT DISTINCT file_path FROM nodes WHERE file_path IS NOT NULL")
788
+ .map((r) => r.file_path));
626
789
  const currentFiles = new Set(files);
627
790
  let filesRemoved = 0;
628
791
  let orphansRemoved = 0;
@@ -685,6 +848,16 @@ export async function crawlProject(store, projectPath, excludeDirs, options = {}
685
848
  entryPointIds.add(node.id);
686
849
  }
687
850
  }
851
+ // Nodes owned by files this crawl rewrote; their rows already carry the
852
+ // markers computed above, so only the rest need a targeted update.
853
+ const changedNodeIds = new Set();
854
+ for (const parsed of parsedFiles) {
855
+ if (!changedPaths.has(parsed.filePath))
856
+ continue;
857
+ for (const node of declarations.get(parsed.filePath)?.nodes ?? []) {
858
+ changedNodeIds.add(node.id);
859
+ }
860
+ }
688
861
  // Hierarchy metadata is emitted with whatever name the source used
689
862
  // (`implements Payment`), which is not always the fully-qualified node id.
690
863
  // Resolve imports first, then fall back to unique simple names.
@@ -715,6 +888,7 @@ export async function crawlProject(store, projectPath, excludeDirs, options = {}
715
888
  const resolved = candidates?.length === 1 ? candidates[0] : undefined;
716
889
  return resolved ? { ...edge, target: resolved } : edge;
717
890
  };
891
+ let persistedFiles = 0;
718
892
  store.withTransaction(() => {
719
893
  for (const parsed of parsedFiles) {
720
894
  const decl = declarations.get(parsed.filePath);
@@ -724,17 +898,21 @@ export async function crawlProject(store, projectPath, excludeDirs, options = {}
724
898
  externalDeps.add(pkg);
725
899
  for (const p of calls.phantomNodes)
726
900
  allNodeIds.add(p.id);
727
- // Phantom nodes must exist as edge endpoints (FK constraint).
728
- for (const p of calls.phantomNodes)
729
- store.ensureNode(p);
730
- // Drop edges whose endpoints aren't part of the indexed project
731
- // (external EXTENDS/IMPLEMENTS/IMPORTS targets).
732
- const edges = decl.edges
733
- .map((edge) => normalizeHierarchyTarget(edge, parsed.filePath))
734
- .concat(calls.edges, deps.edges)
735
- .filter((e) => allNodeIds.has(e.source) && allNodeIds.has(e.target));
736
- const nodes = decl.nodes.concat(calls.phantomNodes, deps.nodes);
737
- store.replaceFile(parsed.filePath, nodes, edges);
901
+ // Unchanged files keep their existing rows: re-resolving them would
902
+ // rewrite identical data and defeat the point of an incremental crawl.
903
+ if (changedPaths.has(parsed.filePath)) {
904
+ // Phantom nodes must exist as edge endpoints (FK constraint).
905
+ for (const p of calls.phantomNodes)
906
+ store.ensureNode(p);
907
+ // Drop edges whose endpoints aren't part of the indexed project
908
+ // (external EXTENDS/IMPLEMENTS/IMPORTS targets).
909
+ const edges = decl.edges
910
+ .map((edge) => normalizeHierarchyTarget(edge, parsed.filePath))
911
+ .concat(calls.edges, deps.edges)
912
+ .filter((e) => allNodeIds.has(e.source) && allNodeIds.has(e.target));
913
+ const nodes = decl.nodes.concat(calls.phantomNodes, deps.nodes);
914
+ store.replaceFile(parsed.filePath, nodes, edges);
915
+ }
738
916
  for (const edge of calls.edges) {
739
917
  if (allNodeIds.has(edge.source) && allNodeIds.has(edge.target)) {
740
918
  allCallEdges.push(edge);
@@ -744,6 +922,12 @@ export async function crawlProject(store, projectPath, excludeDirs, options = {}
744
922
  totalResolved += calls.resolved;
745
923
  totalUnresolved += calls.unresolved;
746
924
  previouslyIndexed.delete(parsed.filePath);
925
+ persistedFiles++;
926
+ options.onProgress?.({
927
+ phase: "persist",
928
+ processed: persistedFiles,
929
+ total: parsedFiles.length,
930
+ });
747
931
  }
748
932
  // Remove files that disappeared from the project. Only a full scan knows
749
933
  // the complete file set, and only files under this project belong to it.
@@ -755,16 +939,30 @@ export async function crawlProject(store, projectPath, excludeDirs, options = {}
755
939
  filesRemoved++;
756
940
  }
757
941
  }
942
+ // Refresh the manifest for parsed files and for untouched-but-retimed ones.
943
+ for (const record of recordsToWrite.values())
944
+ store.putFileRecord(record);
945
+ // Entry points can be registered in one file and declared in another. Only
946
+ // changed files were rewritten, so handlers living in unchanged files need
947
+ // the marker applied directly.
948
+ for (const [id, mark] of entryPointMarks) {
949
+ if (changedNodeIds.has(id))
950
+ continue;
951
+ store.markEntryPoint(id, mark.kind, mark.detail ?? mark.kind);
952
+ }
758
953
  // Rebuild project-wide derived edges: virtual dispatch and AOP advice.
759
- store.deleteEdgesByKind(["DYNAMIC_DISPATCH", "ADVISES"]);
760
- const dynamic = buildDynamicEdges(parsedFiles, index, allNodeIds, allCallEdges);
761
- store.upsertEdges(dynamic.edges);
762
- dynamicDispatchCount = dynamic.dispatchCount;
763
- adviceCount = dynamic.adviceCount;
764
- aspectCount = dynamic.aspects;
765
- // Shared file-less nodes (packages, annotations, external symbols) outlive
766
- // the files that created them; collect the ones nothing references now.
767
- orphansRemoved = store.pruneOrphanNodes();
954
+ // Nothing changed, so the existing derived edges are still correct.
955
+ if (changedPaths.size > 0 || filesRemoved > 0) {
956
+ store.deleteEdgesByKind(["DYNAMIC_DISPATCH", "ADVISES"]);
957
+ const dynamic = buildDynamicEdges(parsedFiles, index, allNodeIds, allCallEdges);
958
+ store.upsertEdges(dynamic.edges);
959
+ dynamicDispatchCount = dynamic.dispatchCount;
960
+ adviceCount = dynamic.adviceCount;
961
+ aspectCount = dynamic.aspects;
962
+ // Shared file-less nodes (packages, annotations, external symbols)
963
+ // outlive the files that created them; collect the unreferenced ones.
964
+ orphansRemoved = store.pruneOrphanNodes();
965
+ }
768
966
  });
769
967
  store.setMeta("last_crawl_at", new Date().toISOString());
770
968
  store.setMeta("last_project_path", absPath);
@@ -773,7 +971,13 @@ export async function crawlProject(store, projectPath, excludeDirs, options = {}
773
971
  projectPath: absPath,
774
972
  filesScanned: files.length,
775
973
  filesParsed: parsedFiles.length,
974
+ filesAdded,
975
+ filesUpdated,
976
+ filesUnchanged,
776
977
  filesRemoved,
978
+ filesSkipped: skippedFiles.length,
979
+ skippedFiles,
980
+ parseErrors,
777
981
  pruned: !scopedScan,
778
982
  orphansRemoved,
779
983
  nodes: stats.nodes,