@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 +108 -5
- package/dist/config.d.ts +4 -0
- package/dist/config.js +40 -9
- package/dist/config.js.map +1 -1
- package/dist/crawler.d.ts +42 -2
- package/dist/crawler.js +236 -32
- package/dist/crawler.js.map +1 -1
- package/dist/graph/queries.d.ts +8 -5
- package/dist/graph/queries.js +101 -44
- package/dist/graph/queries.js.map +1 -1
- package/dist/graph/store.d.ts +31 -1
- package/dist/graph/store.js +217 -24
- package/dist/graph/store.js.map +1 -1
- package/dist/index.js +24 -1
- package/dist/index.js.map +1 -1
- package/dist/parser/cache.d.ts +28 -0
- package/dist/parser/cache.js +80 -0
- package/dist/parser/cache.js.map +1 -0
- package/dist/parser/language.d.ts +2 -0
- package/dist/server.js +78 -36
- package/dist/server.js.map +1 -1
- package/package.json +4 -2
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
|
-
- **
|
|
84
|
-
|
|
85
|
-
|
|
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 (
|
|
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
|
|
16
|
-
if (
|
|
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
|
-
|
|
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:
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
}
|
package/dist/config.js.map
CHANGED
|
@@ -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;
|
|
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
|
-
|
|
50
|
-
|
|
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
|
-
/**
|
|
11
|
-
|
|
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 (
|
|
26
|
-
|
|
90
|
+
if (excluded.has(entry.name) || ignored(rel, true))
|
|
91
|
+
continue;
|
|
92
|
+
walk(full);
|
|
27
93
|
}
|
|
28
|
-
else if (entry.isFile() &&
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
-
//
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
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
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
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,
|