clast-ai 0.1.2 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +14 -0
- package/.claude-plugin/plugin.json +11 -9
- package/README.md +447 -401
- package/bench/ab/onair-concept.json +22 -0
- package/bench/ab/onair-deadcode.json +7 -0
- package/bench/ab/onair-hard.json +32 -0
- package/bench/ab/onair-impact.json +17 -0
- package/bench/ab/onair-refactors.json +18 -0
- package/bench/ab/onair-survey.json +17 -0
- package/bench/ab/onair-tasks.json +20 -0
- package/bench/ab/onair.json +52 -0
- package/bench/ab/vscode-concept.json +22 -0
- package/bench/ab/vscode-hard.json +32 -0
- package/bench/ab/vscode-impact.json +17 -0
- package/bench/ab/vscode-refactors.json +18 -0
- package/bench/ab/vscode-tasks.json +20 -0
- package/bench/ab/vscode.json +52 -0
- package/bench/ab.mjs +213 -0
- package/bench/bench.mjs +98 -0
- package/bench/resolution.mjs +75 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +257 -0
- package/dist/cli.js.map +1 -0
- package/dist/config/defaults.d.ts +12 -2
- package/dist/config/defaults.js +38 -31
- package/dist/config/defaults.js.map +1 -1
- package/dist/config/index.d.ts +1 -1
- package/dist/config/index.js +26 -14
- package/dist/config/index.js.map +1 -1
- package/dist/db/index.d.ts +73 -13
- package/dist/db/index.js +481 -39
- package/dist/db/index.js.map +1 -1
- package/dist/engine.d.ts +15 -0
- package/dist/engine.js +28 -0
- package/dist/engine.js.map +1 -0
- package/dist/indexer/imports.d.ts +6 -0
- package/dist/indexer/imports.js +254 -0
- package/dist/indexer/imports.js.map +1 -0
- package/dist/indexer/index.d.ts +63 -0
- package/dist/indexer/index.js +360 -0
- package/dist/indexer/index.js.map +1 -0
- package/dist/indexer/process.d.ts +22 -0
- package/dist/indexer/process.js +42 -0
- package/dist/indexer/process.js.map +1 -0
- package/dist/indexer/scan.d.ts +43 -0
- package/dist/indexer/scan.js +223 -0
- package/dist/indexer/scan.js.map +1 -0
- package/dist/indexer/worker.d.ts +1 -0
- package/dist/indexer/worker.js +23 -0
- package/dist/indexer/worker.js.map +1 -0
- package/dist/llm/comment-generator.d.ts +17 -19
- package/dist/llm/comment-generator.js +46 -80
- package/dist/llm/comment-generator.js.map +1 -1
- package/dist/llm/index.d.ts +0 -1
- package/dist/llm/index.js.map +1 -1
- package/dist/llm/prompts.d.ts +0 -1
- package/dist/llm/prompts.js +16 -16
- package/dist/parser/extractor.d.ts +21 -16
- package/dist/parser/extractor.js +700 -228
- package/dist/parser/extractor.js.map +1 -1
- package/dist/parser/index.d.ts +17 -21
- package/dist/parser/index.js +115 -171
- package/dist/parser/index.js.map +1 -1
- package/dist/parser/langs/c.d.ts +3 -0
- package/dist/parser/langs/c.js +879 -0
- package/dist/parser/langs/c.js.map +1 -0
- package/dist/parser/langs/csharp.d.ts +2 -0
- package/dist/parser/langs/csharp.js +548 -0
- package/dist/parser/langs/csharp.js.map +1 -0
- package/dist/parser/langs/dart.d.ts +2 -0
- package/dist/parser/langs/dart.js +893 -0
- package/dist/parser/langs/dart.js.map +1 -0
- package/dist/parser/langs/go.d.ts +2 -0
- package/dist/parser/langs/go.js +455 -0
- package/dist/parser/langs/go.js.map +1 -0
- package/dist/parser/langs/java.d.ts +2 -0
- package/dist/parser/langs/java.js +390 -0
- package/dist/parser/langs/java.js.map +1 -0
- package/dist/parser/langs/php.d.ts +2 -0
- package/dist/parser/langs/php.js +486 -0
- package/dist/parser/langs/php.js.map +1 -0
- package/dist/parser/langs/python.d.ts +2 -0
- package/dist/parser/langs/python.js +216 -0
- package/dist/parser/langs/python.js.map +1 -0
- package/dist/parser/langs/ruby.d.ts +2 -0
- package/dist/parser/langs/ruby.js +602 -0
- package/dist/parser/langs/ruby.js.map +1 -0
- package/dist/parser/langs/rust.d.ts +2 -0
- package/dist/parser/langs/rust.js +931 -0
- package/dist/parser/langs/rust.js.map +1 -0
- package/dist/parser/langs/swift.d.ts +2 -0
- package/dist/parser/langs/swift.js +616 -0
- package/dist/parser/langs/swift.js.map +1 -0
- package/dist/parser/langs/typescript.d.ts +4 -0
- package/dist/parser/langs/typescript.js +535 -0
- package/dist/parser/langs/typescript.js.map +1 -0
- package/dist/parser/registry.d.ts +16 -0
- package/dist/parser/registry.js +87 -0
- package/dist/parser/registry.js.map +1 -0
- package/dist/parser/returns.d.ts +9 -0
- package/dist/parser/returns.js +152 -0
- package/dist/parser/returns.js.map +1 -0
- package/dist/parser/spec.d.ts +165 -0
- package/dist/parser/spec.js +188 -0
- package/dist/parser/spec.js.map +1 -0
- package/dist/query/arity.d.ts +27 -0
- package/dist/query/arity.js +187 -0
- package/dist/query/arity.js.map +1 -0
- package/dist/query/callgraph.d.ts +113 -0
- package/dist/query/callgraph.js +332 -0
- package/dist/query/callgraph.js.map +1 -0
- package/dist/query/format.d.ts +36 -0
- package/dist/query/format.js +136 -0
- package/dist/query/format.js.map +1 -0
- package/dist/query/hierarchy.d.ts +44 -0
- package/dist/query/hierarchy.js +441 -0
- package/dist/query/hierarchy.js.map +1 -0
- package/dist/query/resolve.d.ts +265 -0
- package/dist/query/resolve.js +1957 -0
- package/dist/query/resolve.js.map +1 -0
- package/dist/query/search.d.ts +35 -0
- package/dist/query/search.js +379 -0
- package/dist/query/search.js.map +1 -0
- package/dist/query/words.d.ts +30 -0
- package/dist/query/words.js +201 -0
- package/dist/query/words.js.map +1 -0
- package/dist/server/index.d.ts +1 -3
- package/dist/server/index.js +313 -73
- package/dist/server/index.js.map +1 -1
- package/dist/tools/context.d.ts +35 -0
- package/dist/tools/context.js +79 -0
- package/dist/tools/context.js.map +1 -0
- package/dist/tools/explore.d.ts +51 -0
- package/dist/tools/explore.js +779 -0
- package/dist/tools/explore.js.map +1 -0
- package/dist/tools/impact.d.ts +21 -0
- package/dist/tools/impact.js +743 -0
- package/dist/tools/impact.js.map +1 -0
- package/dist/tools/search.d.ts +59 -0
- package/dist/tools/search.js +843 -0
- package/dist/tools/search.js.map +1 -0
- package/dist/tools/stats.d.ts +26 -0
- package/dist/tools/stats.js +604 -0
- package/dist/tools/stats.js.map +1 -0
- package/dist/types.d.ts +95 -40
- package/dist/types.js +9 -1
- package/dist/types.js.map +1 -1
- package/dist/version.d.ts +2 -0
- package/dist/version.js +3 -0
- package/dist/version.js.map +1 -0
- package/dist/watcher/index.d.ts +18 -16
- package/dist/watcher/index.js +94 -77
- package/dist/watcher/index.js.map +1 -1
- package/package.json +21 -24
- package/plugin/server.mjs +50 -0
- package/skills/audit/SKILL.md +37 -0
- package/skills/deadcode/SKILL.md +33 -0
- package/skills/explore/SKILL.md +54 -0
- package/skills/impact/SKILL.md +24 -0
- package/skills/status/SKILL.md +11 -0
- package/src/cli.ts +256 -0
- package/src/config/defaults.ts +50 -32
- package/src/config/index.ts +23 -14
- package/src/db/index.ts +540 -43
- package/src/engine.ts +30 -0
- package/src/indexer/imports.ts +241 -0
- package/src/indexer/index.ts +369 -0
- package/src/indexer/process.ts +63 -0
- package/src/indexer/scan.ts +226 -0
- package/src/indexer/worker.ts +24 -0
- package/src/llm/comment-generator.ts +52 -103
- package/src/llm/index.ts +80 -80
- package/src/llm/prompts.ts +95 -95
- package/src/parser/extractor.ts +643 -256
- package/src/parser/index.ts +105 -205
- package/src/parser/langs/c.ts +860 -0
- package/src/parser/langs/csharp.ts +519 -0
- package/src/parser/langs/dart.ts +820 -0
- package/src/parser/langs/go.ts +437 -0
- package/src/parser/langs/java.ts +361 -0
- package/src/parser/langs/php.ts +450 -0
- package/src/parser/langs/python.ts +206 -0
- package/src/parser/langs/ruby.ts +554 -0
- package/src/parser/langs/rust.ts +904 -0
- package/src/parser/langs/swift.ts +590 -0
- package/src/parser/langs/typescript.ts +499 -0
- package/src/parser/registry.ts +88 -0
- package/src/parser/returns.ts +137 -0
- package/src/parser/spec.ts +309 -0
- package/src/query/arity.ts +173 -0
- package/src/query/callgraph.ts +473 -0
- package/src/query/format.ts +136 -0
- package/src/query/hierarchy.ts +447 -0
- package/src/query/resolve.ts +1972 -0
- package/src/query/search.ts +430 -0
- package/src/query/words.ts +183 -0
- package/src/server/index.ts +390 -79
- package/src/tools/context.ts +82 -0
- package/src/tools/explore.ts +860 -0
- package/src/tools/impact.ts +748 -0
- package/src/tools/search.ts +886 -0
- package/src/tools/stats.ts +633 -0
- package/src/types.ts +113 -45
- package/src/version.ts +2 -0
- package/src/watcher/index.ts +82 -88
- package/tsconfig.json +7 -4
- package/.mcp.json +0 -8
- package/commands/clast-status.md +0 -9
- package/dist/config/defaults.d.ts.map +0 -1
- package/dist/config/index.d.ts.map +0 -1
- package/dist/db/index.d.ts.map +0 -1
- package/dist/db/queries.d.ts +0 -44
- package/dist/db/queries.d.ts.map +0 -1
- package/dist/db/queries.js +0 -251
- package/dist/db/queries.js.map +0 -1
- package/dist/db/schema.d.ts +0 -3
- package/dist/db/schema.d.ts.map +0 -1
- package/dist/db/schema.js +0 -94
- package/dist/db/schema.js.map +0 -1
- package/dist/db/types.d.ts +0 -44
- package/dist/db/types.d.ts.map +0 -1
- package/dist/db/types.js +0 -2
- package/dist/db/types.js.map +0 -1
- package/dist/llm/comment-generator.d.ts.map +0 -1
- package/dist/llm/index.d.ts.map +0 -1
- package/dist/llm/prompts.d.ts.map +0 -1
- package/dist/parser/call-resolver.d.ts +0 -15
- package/dist/parser/call-resolver.d.ts.map +0 -1
- package/dist/parser/call-resolver.js +0 -16
- package/dist/parser/call-resolver.js.map +0 -1
- package/dist/parser/extractor.d.ts.map +0 -1
- package/dist/parser/index.d.ts.map +0 -1
- package/dist/parser/languages.d.ts +0 -11
- package/dist/parser/languages.d.ts.map +0 -1
- package/dist/parser/languages.js +0 -74
- package/dist/parser/languages.js.map +0 -1
- package/dist/server/index.d.ts.map +0 -1
- package/dist/server/register-tools.d.ts +0 -7
- package/dist/server/register-tools.d.ts.map +0 -1
- package/dist/server/register-tools.js +0 -17
- package/dist/server/register-tools.js.map +0 -1
- package/dist/server/tools/clast-call-graph.d.ts +0 -4
- package/dist/server/tools/clast-call-graph.d.ts.map +0 -1
- package/dist/server/tools/clast-call-graph.js +0 -70
- package/dist/server/tools/clast-call-graph.js.map +0 -1
- package/dist/server/tools/clast-comment.d.ts +0 -5
- package/dist/server/tools/clast-comment.d.ts.map +0 -1
- package/dist/server/tools/clast-comment.js +0 -64
- package/dist/server/tools/clast-comment.js.map +0 -1
- package/dist/server/tools/clast-file-summary.d.ts +0 -4
- package/dist/server/tools/clast-file-summary.d.ts.map +0 -1
- package/dist/server/tools/clast-file-summary.js +0 -42
- package/dist/server/tools/clast-file-summary.js.map +0 -1
- package/dist/server/tools/clast-get-context.d.ts +0 -4
- package/dist/server/tools/clast-get-context.d.ts.map +0 -1
- package/dist/server/tools/clast-get-context.js +0 -80
- package/dist/server/tools/clast-get-context.js.map +0 -1
- package/dist/server/tools/clast-reindex.d.ts +0 -5
- package/dist/server/tools/clast-reindex.d.ts.map +0 -1
- package/dist/server/tools/clast-reindex.js +0 -36
- package/dist/server/tools/clast-reindex.js.map +0 -1
- package/dist/server/tools/clast-search.d.ts +0 -4
- package/dist/server/tools/clast-search.d.ts.map +0 -1
- package/dist/server/tools/clast-search.js +0 -29
- package/dist/server/tools/clast-search.js.map +0 -1
- package/dist/server/tools/clast-status.d.ts +0 -5
- package/dist/server/tools/clast-status.d.ts.map +0 -1
- package/dist/server/tools/clast-status.js +0 -23
- package/dist/server/tools/clast-status.js.map +0 -1
- package/dist/types.d.ts.map +0 -1
- package/dist/watcher/gitignore.d.ts +0 -9
- package/dist/watcher/gitignore.d.ts.map +0 -1
- package/dist/watcher/gitignore.js +0 -38
- package/dist/watcher/gitignore.js.map +0 -1
- package/dist/watcher/index.d.ts.map +0 -1
- package/skills/clast-explore/SKILL.md +0 -41
- package/src/db/queries.ts +0 -332
- package/src/db/schema.ts +0 -106
- package/src/db/types.ts +0 -47
- package/src/parser/call-resolver.ts +0 -17
- package/src/parser/languages.ts +0 -86
- package/src/server/register-tools.ts +0 -30
- package/src/server/tools/clast-call-graph.ts +0 -82
- package/src/server/tools/clast-comment.ts +0 -76
- package/src/server/tools/clast-file-summary.ts +0 -59
- package/src/server/tools/clast-get-context.ts +0 -97
- package/src/server/tools/clast-reindex.ts +0 -52
- package/src/server/tools/clast-search.ts +0 -38
- package/src/server/tools/clast-status.ts +0 -37
- package/src/watcher/gitignore.ts +0 -42
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { posix } from 'node:path';
|
|
2
|
+
import type { ClastDb } from '../db/index.js';
|
|
3
|
+
|
|
4
|
+
const JS_LANGS = new Set(['typescript', 'tsx', 'javascript', 'vue']);
|
|
5
|
+
const EXT_RE = /\.[A-Za-z0-9]+$/;
|
|
6
|
+
|
|
7
|
+
interface ImportRow {
|
|
8
|
+
rowid: number;
|
|
9
|
+
file_id: number;
|
|
10
|
+
source: string;
|
|
11
|
+
path: string;
|
|
12
|
+
lang: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* In-memory lookup from path shapes to file ids, used to resolve import
|
|
17
|
+
* specifiers to indexed files.
|
|
18
|
+
*/
|
|
19
|
+
class PathIndex {
|
|
20
|
+
/** Exact path -> id. */
|
|
21
|
+
readonly exact = new Map<string, number>();
|
|
22
|
+
/** Path without extension -> ids. */
|
|
23
|
+
readonly noExt = new Map<string, number[]>();
|
|
24
|
+
/** Last path segment (without extension) -> [pathNoExt, id][]. */
|
|
25
|
+
readonly byBase = new Map<string, Array<[string, number, string]>>();
|
|
26
|
+
|
|
27
|
+
constructor(files: Array<{ id: number; path: string }>) {
|
|
28
|
+
for (const f of files) {
|
|
29
|
+
this.exact.set(f.path, f.id);
|
|
30
|
+
const stem = f.path.replace(EXT_RE, '');
|
|
31
|
+
const list = this.noExt.get(stem);
|
|
32
|
+
if (list) list.push(f.id);
|
|
33
|
+
else this.noExt.set(stem, [f.id]);
|
|
34
|
+
const base = stem.slice(stem.lastIndexOf('/') + 1);
|
|
35
|
+
const bl = this.byBase.get(base);
|
|
36
|
+
if (bl) bl.push([stem, f.id, f.path]);
|
|
37
|
+
else this.byBase.set(base, [[stem, f.id, f.path]]);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Resolve a concrete project-relative path, trying extensions and index files. */
|
|
42
|
+
lookup(p: string): number | undefined {
|
|
43
|
+
const direct = this.exact.get(p);
|
|
44
|
+
if (direct !== undefined) return direct;
|
|
45
|
+
const stemmed = this.noExt.get(p);
|
|
46
|
+
if (stemmed) return stemmed[0];
|
|
47
|
+
// foo.js -> foo.ts (ESM-style TypeScript imports)
|
|
48
|
+
if (EXT_RE.test(p)) {
|
|
49
|
+
const alt = this.noExt.get(p.replace(EXT_RE, ''));
|
|
50
|
+
if (alt) return alt[0];
|
|
51
|
+
}
|
|
52
|
+
for (const idx of ['/index', '/__init__', '/mod']) {
|
|
53
|
+
const hit = this.noExt.get(p + idx);
|
|
54
|
+
if (hit) return hit[0];
|
|
55
|
+
}
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Resolve a non-relative specifier by path suffix. When several files match,
|
|
61
|
+
* prefer the one closest to the importing file; give up if still ambiguous.
|
|
62
|
+
*/
|
|
63
|
+
suffix(spec: string, fromPath: string): number | undefined {
|
|
64
|
+
const stem = spec.replace(EXT_RE, '');
|
|
65
|
+
const base = stem.slice(stem.lastIndexOf('/') + 1);
|
|
66
|
+
const candidates = this.byBase.get(base) ?? (EXT_RE.test(spec) ? undefined : this.byBase.get(spec));
|
|
67
|
+
const hits: Array<[string, number, string]> = [];
|
|
68
|
+
const hasExt = spec !== stem;
|
|
69
|
+
for (const c of candidates ?? []) {
|
|
70
|
+
if (c[0] === stem || c[0].endsWith('/' + stem)) {
|
|
71
|
+
if (hasExt && !c[2].endsWith(spec)) continue;
|
|
72
|
+
hits.push(c);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (hits.length === 0) {
|
|
76
|
+
// Package directory: a/b -> a/b/index.*, a/b/__init__.py, a/b/mod.rs
|
|
77
|
+
for (const idx of ['index', '__init__', 'mod']) {
|
|
78
|
+
for (const c of this.byBase.get(idx) ?? []) {
|
|
79
|
+
const dir = c[0].slice(0, -(idx.length + 1));
|
|
80
|
+
if (dir === stem || dir.endsWith('/' + stem)) hits.push(c);
|
|
81
|
+
}
|
|
82
|
+
if (hits.length) break;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if (hits.length === 0) return undefined;
|
|
86
|
+
if (hits.length === 1) return hits[0]![1];
|
|
87
|
+
let best: number | undefined;
|
|
88
|
+
let bestScore = -1;
|
|
89
|
+
let tie = false;
|
|
90
|
+
for (const h of hits) {
|
|
91
|
+
const score = commonPrefixSegments(h[0], fromPath);
|
|
92
|
+
if (score > bestScore) { bestScore = score; best = h[1]; tie = false; }
|
|
93
|
+
else if (score === bestScore) tie = true;
|
|
94
|
+
}
|
|
95
|
+
return tie ? undefined : best;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function commonPrefixSegments(a: string, b: string): number {
|
|
100
|
+
const as = a.split('/');
|
|
101
|
+
const bs = b.split('/');
|
|
102
|
+
let n = 0;
|
|
103
|
+
while (n < as.length && n < bs.length && as[n] === bs[n]) n++;
|
|
104
|
+
return n;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function resolveOne(row: ImportRow, index: PathIndex): number | undefined {
|
|
108
|
+
const dir = posix.dirname(row.path);
|
|
109
|
+
let src = row.source;
|
|
110
|
+
|
|
111
|
+
if (JS_LANGS.has(row.lang)) {
|
|
112
|
+
src = src.replace(/[?#].*$/, '');
|
|
113
|
+
if (src.startsWith('.')) {
|
|
114
|
+
const target = posix.normalize(posix.join(dir, src)).replace(/\/$/, '');
|
|
115
|
+
// require('..') / import '.' name a directory: its index file.
|
|
116
|
+
if (target === '.' || target === '') return index.lookup('index');
|
|
117
|
+
return index.lookup(target);
|
|
118
|
+
}
|
|
119
|
+
if (src.startsWith('/')) return index.lookup(src.slice(1));
|
|
120
|
+
// tsconfig-style aliases: "@/x", "~/x", "src/x", bare "vs/base/common/x"
|
|
121
|
+
const stripped = src.replace(/^(@|~)\//, '');
|
|
122
|
+
if (!stripped.includes('/')) return undefined; // bare package name
|
|
123
|
+
return index.lookup(stripped) ?? index.lookup('src/' + stripped) ?? index.suffix(stripped, row.path);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (row.lang === 'python') {
|
|
127
|
+
const m = /^(\.+)(.*)$/.exec(src);
|
|
128
|
+
if (m) {
|
|
129
|
+
let base = dir;
|
|
130
|
+
for (let i = 1; i < m[1]!.length; i++) base = posix.dirname(base);
|
|
131
|
+
const rest = m[2]!.replace(/\./g, '/');
|
|
132
|
+
return index.lookup(posix.normalize(posix.join(base, rest || '.')));
|
|
133
|
+
}
|
|
134
|
+
const p = src.replace(/\./g, '/');
|
|
135
|
+
const direct = index.lookup(p) ?? index.lookup('src/' + p) ?? index.lookup('lib/' + p);
|
|
136
|
+
if (direct !== undefined) return direct;
|
|
137
|
+
// A bare top-level module (`import datetime`) that is not at a source
|
|
138
|
+
// root is almost always the standard library or a dependency.
|
|
139
|
+
if (!p.includes('/')) return undefined;
|
|
140
|
+
return index.suffix(p, row.path);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (row.lang === 'c' || row.lang === 'cpp' || row.lang === 'objc') {
|
|
144
|
+
// <system.h> includes only resolve to an exact project path (a public
|
|
145
|
+
// include dir), never by suffix: <math.h> is not some vendored test/math.h.
|
|
146
|
+
const sys = /^<(.*)>$/.exec(src);
|
|
147
|
+
if (sys) {
|
|
148
|
+
const p = sys[1]!;
|
|
149
|
+
return index.lookup(p) ?? index.lookup('include/' + p) ?? index.lookup('src/' + p);
|
|
150
|
+
}
|
|
151
|
+
return (
|
|
152
|
+
index.lookup(posix.normalize(posix.join(dir, src))) ??
|
|
153
|
+
index.lookup(src) ??
|
|
154
|
+
index.suffix(src, row.path)
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (row.lang === 'rust') {
|
|
159
|
+
let parts = src.replace(/\{.*$/s, '').replace(/::\*?$/, '').split('::').filter(Boolean);
|
|
160
|
+
let base = '';
|
|
161
|
+
if (parts[0] === 'crate') parts = parts.slice(1);
|
|
162
|
+
else if (parts[0] === 'self') { parts = parts.slice(1); base = dir; }
|
|
163
|
+
else if (parts[0] === 'super') {
|
|
164
|
+
base = dir;
|
|
165
|
+
while (parts[0] === 'super') { parts = parts.slice(1); base = posix.dirname(base); }
|
|
166
|
+
}
|
|
167
|
+
// Trailing segments are usually items inside the module: try shorter paths.
|
|
168
|
+
for (let n = parts.length; n >= 1 && n >= parts.length - 2; n--) {
|
|
169
|
+
const p = parts.slice(0, n).join('/');
|
|
170
|
+
const hit = base ? index.lookup(posix.join(base, p)) : index.suffix(p, row.path);
|
|
171
|
+
if (hit !== undefined) return hit;
|
|
172
|
+
}
|
|
173
|
+
return undefined;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (row.lang === 'go') return undefined; // Go imports name packages (directories), not files.
|
|
177
|
+
|
|
178
|
+
// Java / C# / PHP / Ruby / Kotlin / Swift / Dart ...: dotted or slashed names.
|
|
179
|
+
if (row.lang === 'ruby' || row.lang === 'dart') {
|
|
180
|
+
const clean = src.replace(/^package:[^/]+\//, '');
|
|
181
|
+
return (
|
|
182
|
+
index.lookup(posix.normalize(posix.join(dir, clean))) ??
|
|
183
|
+
index.lookup('lib/' + clean) ??
|
|
184
|
+
index.suffix(clean, row.path)
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
const p = src.replace(/^\\/, '').replace(/[.\\]|::/g, '/').replace(/\/\*$/, '');
|
|
188
|
+
const hit = index.suffix(p, row.path);
|
|
189
|
+
if (hit !== undefined || row.lang !== 'java' || !p.includes('/')) return hit;
|
|
190
|
+
// `import static pkg.Utils.mkEntry;` / `import pkg.Outer.Inner;`: the last segment is a member of the class file.
|
|
191
|
+
return index.suffix(p.slice(0, p.lastIndexOf('/')), row.path);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Resolve import specifiers to indexed files. With `fileIds`, only imports of
|
|
196
|
+
* those files are (re)resolved; otherwise every import is.
|
|
197
|
+
*/
|
|
198
|
+
export function resolveImports(db: ClastDb, fileIds?: number[], newPaths?: string[]): number {
|
|
199
|
+
const files = db.all<{ id: number; path: string }>(`SELECT id, path FROM files`);
|
|
200
|
+
const index = new PathIndex(files);
|
|
201
|
+
|
|
202
|
+
const base = `SELECT i.rowid AS rowid, i.file_id, i.source, f.path, f.lang
|
|
203
|
+
FROM imports i JOIN files f ON f.id = i.file_id`;
|
|
204
|
+
let rows: ImportRow[];
|
|
205
|
+
if (fileIds) {
|
|
206
|
+
rows = [];
|
|
207
|
+
for (const id of fileIds) rows.push(...db.all<ImportRow>(`${base} WHERE i.file_id = ?`, id));
|
|
208
|
+
// Files that just appeared may satisfy imports that could not be resolved before:
|
|
209
|
+
// retry only the unresolved imports that mention such a file's name.
|
|
210
|
+
const seen = new Set(rows.map(r => r.rowid));
|
|
211
|
+
for (const p of newPaths ?? []) {
|
|
212
|
+
const stem = p.slice(p.lastIndexOf('/') + 1).replace(/\.[A-Za-z0-9]+$/, '');
|
|
213
|
+
if (stem.length < 2) continue;
|
|
214
|
+
const like = '%' + stem.replace(/[\\%_]/g, c => '\\' + c) + '%';
|
|
215
|
+
for (const r of db.all<ImportRow>(`${base} WHERE i.resolved_id IS NULL AND i.source LIKE ? ESCAPE '\\'`, like)) {
|
|
216
|
+
if (!seen.has(r.rowid)) {
|
|
217
|
+
seen.add(r.rowid);
|
|
218
|
+
rows.push(r);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
} else {
|
|
223
|
+
rows = db.all<ImportRow>(base);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
let resolved = 0;
|
|
227
|
+
db.transaction(() => {
|
|
228
|
+
for (const row of rows) {
|
|
229
|
+
let target: number | undefined;
|
|
230
|
+
try {
|
|
231
|
+
target = resolveOne(row, index);
|
|
232
|
+
} catch {
|
|
233
|
+
target = undefined;
|
|
234
|
+
}
|
|
235
|
+
if (target === row.file_id) target = undefined;
|
|
236
|
+
db.run(`UPDATE imports SET resolved_id = ? WHERE rowid = ?`, target ?? null, row.rowid);
|
|
237
|
+
if (target !== undefined) resolved++;
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
return resolved;
|
|
241
|
+
}
|
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
import { Worker } from 'node:worker_threads';
|
|
2
|
+
import { availableParallelism } from 'node:os';
|
|
3
|
+
import { existsSync, statSync } from 'node:fs';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { isAbsolute, join, relative } from 'node:path';
|
|
6
|
+
import type { ClastConfig } from '../config/index.js';
|
|
7
|
+
import type { ClastDb } from '../db/index.js';
|
|
8
|
+
import type { FileRow } from '../types.js';
|
|
9
|
+
import { SourceParser } from '../parser/index.js';
|
|
10
|
+
import { IgnoreRules, type ScannedFile } from './scan.js';
|
|
11
|
+
import { processFile, type FileResult, type FileTask } from './process.js';
|
|
12
|
+
import { resolveImports } from './imports.js';
|
|
13
|
+
|
|
14
|
+
export interface IndexResult {
|
|
15
|
+
scanned: number;
|
|
16
|
+
parsed: number;
|
|
17
|
+
unchanged: number;
|
|
18
|
+
removed: number;
|
|
19
|
+
durationMs: number;
|
|
20
|
+
workers: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface IndexProgress {
|
|
24
|
+
phase: 'idle' | 'scanning' | 'parsing' | 'finalizing';
|
|
25
|
+
done: number;
|
|
26
|
+
total: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const WORKER_THRESHOLD = 48;
|
|
30
|
+
const TASK_BATCH = 12;
|
|
31
|
+
const WRITE_BATCH = 150;
|
|
32
|
+
const BULK_THRESHOLD = 400;
|
|
33
|
+
const BULK_WRITE_BATCH = 800;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Keeps the database in sync with the working tree: bulk (re)indexing with a
|
|
37
|
+
* worker pool, single-file updates, and on-demand freshness checks.
|
|
38
|
+
*/
|
|
39
|
+
export class Indexer {
|
|
40
|
+
readonly root: string;
|
|
41
|
+
readonly rules: IgnoreRules;
|
|
42
|
+
readonly parser = new SourceParser();
|
|
43
|
+
progress: IndexProgress = { phase: 'idle', done: 0, total: 0 };
|
|
44
|
+
lastResult: IndexResult | null = null;
|
|
45
|
+
/** True while a first-time index is being loaded (secondary indexes are not built yet). */
|
|
46
|
+
coldBuild = false;
|
|
47
|
+
private running: Promise<IndexResult> | null = null;
|
|
48
|
+
private structureChanged = false;
|
|
49
|
+
|
|
50
|
+
constructor(
|
|
51
|
+
root: string,
|
|
52
|
+
private readonly config: ClastConfig,
|
|
53
|
+
private readonly db: ClastDb
|
|
54
|
+
) {
|
|
55
|
+
this.root = root;
|
|
56
|
+
this.rules = new IgnoreRules(root, config);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
get isIndexing(): boolean {
|
|
60
|
+
return this.running !== null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Resolves when the in-flight bulk index (if any) is finished. */
|
|
64
|
+
async whenIdle(timeoutMs?: number): Promise<boolean> {
|
|
65
|
+
if (!this.running) return true;
|
|
66
|
+
if (timeoutMs === undefined) {
|
|
67
|
+
await this.running.catch(() => undefined);
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
71
|
+
const timedOut = new Promise<boolean>(res => { timer = setTimeout(() => res(false), timeoutMs); });
|
|
72
|
+
const done = this.running.then(() => true, () => true);
|
|
73
|
+
const result = await Promise.race([done, timedOut]);
|
|
74
|
+
clearTimeout(timer);
|
|
75
|
+
return result;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
toRel(path: string): string {
|
|
79
|
+
const rel = isAbsolute(path) ? relative(this.root, path) : path;
|
|
80
|
+
return rel.replace(/\\/g, '/').replace(/^\.\//, '');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Bring the whole index up to date. Concurrent calls share one run. */
|
|
84
|
+
indexAll(opts: { force?: boolean } = {}): Promise<IndexResult> {
|
|
85
|
+
if (this.running) return this.running;
|
|
86
|
+
this.running = this.runIndexAll(opts).finally(() => {
|
|
87
|
+
this.running = null;
|
|
88
|
+
this.progress = { phase: 'idle', done: 0, total: 0 };
|
|
89
|
+
});
|
|
90
|
+
return this.running;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
private async runIndexAll(opts: { force?: boolean }): Promise<IndexResult> {
|
|
94
|
+
const start = performance.now();
|
|
95
|
+
this.progress = { phase: 'scanning', done: 0, total: 0 };
|
|
96
|
+
this.rules.invalidate();
|
|
97
|
+
const scanned = await this.rules.scan();
|
|
98
|
+
|
|
99
|
+
// A forced rebuild starts from an empty index so it can use the cold bulk path.
|
|
100
|
+
if (opts.force) this.db.clearIndex();
|
|
101
|
+
const known = new Map<string, FileRow>();
|
|
102
|
+
for (const row of this.db.allFiles()) known.set(row.path, row);
|
|
103
|
+
|
|
104
|
+
const tasks: FileTask[] = [];
|
|
105
|
+
const seen = new Set<string>();
|
|
106
|
+
for (const f of scanned) {
|
|
107
|
+
seen.add(f.rel);
|
|
108
|
+
const row = known.get(f.rel);
|
|
109
|
+
if (!opts.force && row && row.mtime === f.mtime && row.size === f.size) continue;
|
|
110
|
+
tasks.push({ abs: f.abs, rel: f.rel, knownHash: opts.force ? undefined : row?.hash });
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
let removed = 0;
|
|
114
|
+
const gone = [...known.keys()].filter(p => !seen.has(p));
|
|
115
|
+
if (gone.length) {
|
|
116
|
+
this.db.transaction(() => {
|
|
117
|
+
for (const p of gone) if (this.db.deleteFile(p)) removed++;
|
|
118
|
+
});
|
|
119
|
+
this.structureChanged = true;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const bulk = tasks.length >= BULK_THRESHOLD;
|
|
123
|
+
const cold = known.size === 0;
|
|
124
|
+
const writeBatch = bulk ? BULK_WRITE_BATCH : WRITE_BATCH;
|
|
125
|
+
if (bulk) this.db.beginBulk(cold);
|
|
126
|
+
this.coldBuild = bulk && cold;
|
|
127
|
+
|
|
128
|
+
this.progress = { phase: 'parsing', done: 0, total: tasks.length };
|
|
129
|
+
let parsed = 0;
|
|
130
|
+
let unchanged = 0;
|
|
131
|
+
const touchedIds: number[] = [];
|
|
132
|
+
let pending: FileResult[] = [];
|
|
133
|
+
|
|
134
|
+
let writeMs = 0;
|
|
135
|
+
const flush = (): void => {
|
|
136
|
+
if (pending.length === 0) return;
|
|
137
|
+
const batch = pending;
|
|
138
|
+
pending = [];
|
|
139
|
+
const t0 = performance.now();
|
|
140
|
+
this.db.transaction(() => {
|
|
141
|
+
for (const r of batch) {
|
|
142
|
+
const outcome = this.apply(r);
|
|
143
|
+
if (outcome.parsed) { parsed++; touchedIds.push(outcome.fileId!); }
|
|
144
|
+
else if (r.status === 'unchanged') unchanged++;
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
writeMs += performance.now() - t0;
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const completed = new Set<string>();
|
|
151
|
+
const onResult = (r: FileResult): void => {
|
|
152
|
+
pending.push(r);
|
|
153
|
+
completed.add(r.rel);
|
|
154
|
+
this.progress.done++;
|
|
155
|
+
if (pending.length >= writeBatch) flush();
|
|
156
|
+
};
|
|
157
|
+
const inline = async (list: FileTask[]): Promise<void> => {
|
|
158
|
+
for (const task of list) {
|
|
159
|
+
onResult(await processFile(this.parser, task, this.config.maxFileSize));
|
|
160
|
+
// Yield so the MCP transport stays responsive during a cold index.
|
|
161
|
+
if (this.progress.done % 25 === 0) await new Promise<void>(res => setImmediate(res));
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
let workerCount = this.workerCount(tasks.length);
|
|
166
|
+
let endBulkMs = 0;
|
|
167
|
+
try {
|
|
168
|
+
if (workerCount > 1) {
|
|
169
|
+
try {
|
|
170
|
+
await this.runPool(tasks, workerCount, onResult);
|
|
171
|
+
} catch (err) {
|
|
172
|
+
// A worker died (out of memory, a crashing grammar): finish the rest on this thread.
|
|
173
|
+
console.error(`[clast] parser worker failed (${err instanceof Error ? err.message : err}); continuing without workers`);
|
|
174
|
+
workerCount = 1;
|
|
175
|
+
await inline(tasks.filter(t => !completed.has(t.rel)));
|
|
176
|
+
}
|
|
177
|
+
} else {
|
|
178
|
+
await inline(tasks);
|
|
179
|
+
}
|
|
180
|
+
flush();
|
|
181
|
+
} finally {
|
|
182
|
+
// Always leave bulk mode: it restores the journal and rebuilds the secondary indexes,
|
|
183
|
+
// without which every query would scan whole tables.
|
|
184
|
+
const tBulk = performance.now();
|
|
185
|
+
if (bulk) this.db.endBulk();
|
|
186
|
+
endBulkMs = performance.now() - tBulk;
|
|
187
|
+
this.coldBuild = false;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const parseDoneMs = performance.now() - start;
|
|
191
|
+
this.progress.phase = 'finalizing';
|
|
192
|
+
if (parsed > 0 || removed > 0 || this.structureChanged) {
|
|
193
|
+
// New or removed files can change how *other* files' imports resolve.
|
|
194
|
+
const full = removed > 0 || this.structureChanged || parsed > 200;
|
|
195
|
+
resolveImports(this.db, full ? undefined : touchedIds);
|
|
196
|
+
this.structureChanged = false;
|
|
197
|
+
try { this.db.db.exec('PRAGMA optimize'); } catch { /* ignore */ }
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const result: IndexResult = {
|
|
201
|
+
scanned: scanned.length,
|
|
202
|
+
parsed,
|
|
203
|
+
unchanged,
|
|
204
|
+
removed,
|
|
205
|
+
durationMs: Math.round(performance.now() - start),
|
|
206
|
+
workers: workerCount,
|
|
207
|
+
};
|
|
208
|
+
if (process.env.CLAST_DEBUG) {
|
|
209
|
+
console.error(`[clast] timing: total ${result.durationMs}ms, parse+write phase ${Math.round(parseDoneMs)}ms, db writes ${Math.round(writeMs)}ms, index build ${Math.round(endBulkMs)}ms`);
|
|
210
|
+
}
|
|
211
|
+
this.lastResult = result;
|
|
212
|
+
this.db.setMeta('last_full_index', String(Date.now()));
|
|
213
|
+
return result;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Write one processing result. Must run inside a transaction. */
|
|
217
|
+
private apply(r: FileResult): { parsed: boolean; fileId?: number } {
|
|
218
|
+
if (r.status === 'ok' && r.extraction) {
|
|
219
|
+
const isNew = !this.db.fileByPath(r.rel);
|
|
220
|
+
const fileId = this.db.writeFile({ path: r.rel, hash: r.hash, mtime: r.mtime, size: r.size }, r.extraction);
|
|
221
|
+
if (isNew) this.structureChanged = true;
|
|
222
|
+
return { parsed: true, fileId };
|
|
223
|
+
}
|
|
224
|
+
if (r.status === 'unchanged') {
|
|
225
|
+
const row = this.db.fileByPath(r.rel);
|
|
226
|
+
if (row) this.db.touchFile(row.id, r.mtime, r.size);
|
|
227
|
+
return { parsed: false };
|
|
228
|
+
}
|
|
229
|
+
// skip / missing: make sure nothing stale remains.
|
|
230
|
+
if (this.db.deleteFile(r.rel)) this.structureChanged = true;
|
|
231
|
+
return { parsed: false };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
private workerCount(taskCount: number): number {
|
|
235
|
+
if (taskCount < WORKER_THRESHOLD) return 1;
|
|
236
|
+
if (!existsSync(workerPath())) return 1;
|
|
237
|
+
const configured = this.config.workers;
|
|
238
|
+
if (configured === 1) return 1;
|
|
239
|
+
const auto = Math.max(1, Math.min(12, availableParallelism() - 1));
|
|
240
|
+
const n = configured > 0 ? configured : auto;
|
|
241
|
+
return Math.max(1, Math.min(n, Math.ceil(taskCount / TASK_BATCH)));
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
private runPool(tasks: FileTask[], count: number, onResult: (r: FileResult) => void): Promise<void> {
|
|
245
|
+
return new Promise<void>((resolveAll, rejectAll) => {
|
|
246
|
+
let next = 0;
|
|
247
|
+
let active = 0;
|
|
248
|
+
let failed = false;
|
|
249
|
+
const workers: Worker[] = [];
|
|
250
|
+
|
|
251
|
+
const finish = (err?: Error): void => {
|
|
252
|
+
if (failed) return;
|
|
253
|
+
if (err) {
|
|
254
|
+
failed = true;
|
|
255
|
+
for (const w of workers) void w.terminate();
|
|
256
|
+
rejectAll(err);
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
if (active === 0 && next >= tasks.length) {
|
|
260
|
+
for (const w of workers) w.postMessage(null);
|
|
261
|
+
resolveAll();
|
|
262
|
+
}
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
const dispatch = (w: Worker): void => {
|
|
266
|
+
if (next >= tasks.length) return finish();
|
|
267
|
+
const batch = tasks.slice(next, next + TASK_BATCH);
|
|
268
|
+
next += batch.length;
|
|
269
|
+
active++;
|
|
270
|
+
w.postMessage(batch);
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
for (let i = 0; i < count; i++) {
|
|
274
|
+
const w = new Worker(workerPath(), { workerData: { maxFileSize: this.config.maxFileSize } });
|
|
275
|
+
workers.push(w);
|
|
276
|
+
w.on('message', (results: FileResult[]) => {
|
|
277
|
+
active--;
|
|
278
|
+
try {
|
|
279
|
+
for (const r of results) onResult(r);
|
|
280
|
+
} catch (err) {
|
|
281
|
+
return finish(err as Error);
|
|
282
|
+
}
|
|
283
|
+
dispatch(w);
|
|
284
|
+
});
|
|
285
|
+
w.on('error', err => finish(err));
|
|
286
|
+
// Two batches in flight per worker so it never idles while the main
|
|
287
|
+
// thread is busy writing a transaction.
|
|
288
|
+
dispatch(w);
|
|
289
|
+
dispatch(w);
|
|
290
|
+
}
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Re-index a single file inline. Returns true if the index changed.
|
|
296
|
+
* Accepts absolute or project-relative paths.
|
|
297
|
+
*/
|
|
298
|
+
async indexFile(path: string, opts: { force?: boolean } = {}): Promise<boolean> {
|
|
299
|
+
const rel = this.toRel(path);
|
|
300
|
+
if (rel.startsWith('..')) return false;
|
|
301
|
+
const abs = join(this.root, rel);
|
|
302
|
+
if (!this.rules.isSupported(rel) || this.rules.isIgnored(rel)) {
|
|
303
|
+
return this.removeFile(rel);
|
|
304
|
+
}
|
|
305
|
+
const row = this.db.fileByPath(rel);
|
|
306
|
+
const result = await processFile(
|
|
307
|
+
this.parser,
|
|
308
|
+
{ abs, rel, knownHash: opts.force ? undefined : row?.hash },
|
|
309
|
+
this.config.maxFileSize
|
|
310
|
+
);
|
|
311
|
+
let changed = false;
|
|
312
|
+
let fileId: number | undefined;
|
|
313
|
+
this.db.transaction(() => {
|
|
314
|
+
const outcome = this.apply(result);
|
|
315
|
+
changed = outcome.parsed || (result.status !== 'unchanged' && !!row);
|
|
316
|
+
fileId = outcome.fileId;
|
|
317
|
+
});
|
|
318
|
+
if (fileId !== undefined) {
|
|
319
|
+
// A new file can also satisfy other files' previously unresolved imports;
|
|
320
|
+
// only those that mention its name are retried (not the whole project).
|
|
321
|
+
resolveImports(this.db, [fileId], this.structureChanged ? [rel] : undefined);
|
|
322
|
+
this.structureChanged = false;
|
|
323
|
+
}
|
|
324
|
+
return changed;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
removeFile(path: string): boolean {
|
|
328
|
+
const rel = this.toRel(path);
|
|
329
|
+
let removed = false;
|
|
330
|
+
this.db.transaction(() => {
|
|
331
|
+
removed = this.db.deleteFile(rel);
|
|
332
|
+
});
|
|
333
|
+
return removed;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Make sure the given indexed files reflect what is on disk right now
|
|
338
|
+
* (cheap stat check, inline re-parse when stale). Query paths call this
|
|
339
|
+
* before returning line numbers or source text.
|
|
340
|
+
*/
|
|
341
|
+
async ensureFresh(paths: Iterable<string>): Promise<number> {
|
|
342
|
+
let updated = 0;
|
|
343
|
+
const seen = new Set<string>();
|
|
344
|
+
for (const rel of paths) {
|
|
345
|
+
if (seen.has(rel)) continue;
|
|
346
|
+
seen.add(rel);
|
|
347
|
+
const row = this.db.fileByPath(rel);
|
|
348
|
+
if (!row) continue;
|
|
349
|
+
let st;
|
|
350
|
+
try {
|
|
351
|
+
st = statSync(join(this.root, rel));
|
|
352
|
+
} catch {
|
|
353
|
+
if (this.removeFile(rel)) updated++;
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
356
|
+
if (st.mtimeMs === row.mtime && st.size === row.size) continue;
|
|
357
|
+
if (await this.indexFile(rel)) updated++;
|
|
358
|
+
}
|
|
359
|
+
return updated;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
scan(): Promise<ScannedFile[]> {
|
|
363
|
+
return this.rules.scan();
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function workerPath(): string {
|
|
368
|
+
return fileURLToPath(new URL('./worker.js', import.meta.url));
|
|
369
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { readFileSync, statSync } from 'node:fs';
|
|
3
|
+
import type { Extraction } from '../types.js';
|
|
4
|
+
import type { SourceParser } from '../parser/index.js';
|
|
5
|
+
|
|
6
|
+
export interface FileTask {
|
|
7
|
+
abs: string;
|
|
8
|
+
rel: string;
|
|
9
|
+
/** Hash currently stored in the index, if any. */
|
|
10
|
+
knownHash?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface FileResult {
|
|
14
|
+
rel: string;
|
|
15
|
+
/**
|
|
16
|
+
* ok: parsed, `extraction` is set. unchanged: content hash matches the index.
|
|
17
|
+
* skip: not indexable (binary, too large, unsupported). missing: file is gone.
|
|
18
|
+
*/
|
|
19
|
+
status: 'ok' | 'unchanged' | 'skip' | 'missing';
|
|
20
|
+
hash: string;
|
|
21
|
+
mtime: number;
|
|
22
|
+
size: number;
|
|
23
|
+
extraction?: Extraction;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Read, hash and (if changed) parse one file. Never throws. */
|
|
27
|
+
export async function processFile(
|
|
28
|
+
parser: SourceParser,
|
|
29
|
+
task: FileTask,
|
|
30
|
+
maxFileSize: number
|
|
31
|
+
): Promise<FileResult> {
|
|
32
|
+
const base = { rel: task.rel, hash: '', mtime: 0, size: 0 };
|
|
33
|
+
let buf: Buffer;
|
|
34
|
+
try {
|
|
35
|
+
const st = statSync(task.abs);
|
|
36
|
+
base.mtime = st.mtimeMs;
|
|
37
|
+
base.size = st.size;
|
|
38
|
+
if (!st.isFile()) return { ...base, status: 'missing' };
|
|
39
|
+
if (st.size > maxFileSize) return { ...base, status: 'skip' };
|
|
40
|
+
buf = readFileSync(task.abs);
|
|
41
|
+
} catch {
|
|
42
|
+
return { ...base, status: 'missing' };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Binary sniff: NUL byte in the first block.
|
|
46
|
+
const head = buf.subarray(0, 4096);
|
|
47
|
+
if (head.includes(0)) return { ...base, status: 'skip' };
|
|
48
|
+
|
|
49
|
+
base.hash = createHash('sha1').update(buf).digest('hex');
|
|
50
|
+
if (task.knownHash && task.knownHash === base.hash) return { ...base, status: 'unchanged' };
|
|
51
|
+
|
|
52
|
+
let source = buf.toString('utf-8');
|
|
53
|
+
if (source.charCodeAt(0) === 0xfeff) source = source.slice(1);
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
const extraction = await parser.extract(task.rel, source);
|
|
57
|
+
if (!extraction) return { ...base, status: 'skip' };
|
|
58
|
+
return { ...base, status: 'ok', extraction };
|
|
59
|
+
} catch (err) {
|
|
60
|
+
if (process.env.CLAST_DEBUG) console.error(`[clast] parse failed for ${task.rel}:`, err);
|
|
61
|
+
return { ...base, status: 'skip' };
|
|
62
|
+
}
|
|
63
|
+
}
|