@maxgfr/codeindex 2.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +115 -0
- package/docs/MIGRATION.md +100 -0
- package/package.json +89 -0
- package/scripts/cli.mjs +10 -0
- package/scripts/engine.d.mts +618 -0
- package/scripts/engine.mjs +10655 -0
- package/scripts/grammars/bash.wasm +0 -0
- package/scripts/grammars/c.wasm +0 -0
- package/scripts/grammars/c_sharp.wasm +0 -0
- package/scripts/grammars/cpp.wasm +0 -0
- package/scripts/grammars/go.wasm +0 -0
- package/scripts/grammars/java.wasm +0 -0
- package/scripts/grammars/javascript.wasm +0 -0
- package/scripts/grammars/lua.wasm +0 -0
- package/scripts/grammars/php.wasm +0 -0
- package/scripts/grammars/python.wasm +0 -0
- package/scripts/grammars/ruby.wasm +0 -0
- package/scripts/grammars/rust.wasm +0 -0
- package/scripts/grammars/scala.wasm +0 -0
- package/scripts/grammars/tsx.wasm +0 -0
- package/scripts/grammars/typescript.wasm +0 -0
- package/scripts/grammars/web-tree-sitter.wasm +0 -0
- package/src/ast/extract.ts +713 -0
- package/src/ast/loader.ts +104 -0
- package/src/bm25.ts +156 -0
- package/src/callers.ts +0 -0
- package/src/calls.ts +131 -0
- package/src/categorize.ts +80 -0
- package/src/centrality.ts +148 -0
- package/src/classify.ts +53 -0
- package/src/cli-entry.ts +16 -0
- package/src/community.ts +345 -0
- package/src/complexity.ts +69 -0
- package/src/coupling.ts +0 -0
- package/src/deadcode.ts +46 -0
- package/src/edit.ts +93 -0
- package/src/engine-cli.ts +278 -0
- package/src/engine.ts +148 -0
- package/src/extract/code.ts +376 -0
- package/src/extract/markdown.ts +135 -0
- package/src/git.ts +205 -0
- package/src/glob.ts +56 -0
- package/src/graph.ts +0 -0
- package/src/grep.ts +115 -0
- package/src/hash.ts +13 -0
- package/src/ignore.ts +134 -0
- package/src/lang/c.ts +26 -0
- package/src/lang/common.ts +68 -0
- package/src/lang/csharp.ts +22 -0
- package/src/lang/elixir.ts +18 -0
- package/src/lang/go.ts +22 -0
- package/src/lang/java.ts +19 -0
- package/src/lang/js-ts.ts +112 -0
- package/src/lang/kotlin.ts +20 -0
- package/src/lang/lua.ts +18 -0
- package/src/lang/php.ts +24 -0
- package/src/lang/python.ts +22 -0
- package/src/lang/registry.ts +54 -0
- package/src/lang/ruby.ts +17 -0
- package/src/lang/rust.ts +22 -0
- package/src/lang/scala.ts +19 -0
- package/src/lang/shell.ts +17 -0
- package/src/lang/swift.ts +23 -0
- package/src/mcp.ts +512 -0
- package/src/memory.ts +75 -0
- package/src/modules.ts +128 -0
- package/src/pipeline.ts +59 -0
- package/src/query.ts +118 -0
- package/src/render/graph-json.ts +16 -0
- package/src/render/symbols-json.ts +79 -0
- package/src/repomap.ts +52 -0
- package/src/resolve.ts +950 -0
- package/src/rules.ts +249 -0
- package/src/scan.ts +172 -0
- package/src/sort.ts +12 -0
- package/src/surprise.ts +58 -0
- package/src/tests-map.ts +105 -0
- package/src/types.ts +201 -0
- package/src/util.ts +169 -0
- package/src/viz.ts +44 -0
- package/src/walk.ts +216 -0
- package/src/workspaces.ts +748 -0
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { Parser, Language } from "web-tree-sitter";
|
|
5
|
+
|
|
6
|
+
// Extension → committed grammar wasm key (scripts/grammars/<key>.wasm). Only the
|
|
7
|
+
// languages we ship a grammar for appear here; everything else falls back to the
|
|
8
|
+
// regex extractors (still fully searchable, just no AST-exact symbols/imports).
|
|
9
|
+
export const EXT_GRAMMAR: Record<string, string> = {
|
|
10
|
+
".ts": "typescript", ".mts": "typescript", ".cts": "typescript",
|
|
11
|
+
".tsx": "tsx",
|
|
12
|
+
".js": "javascript", ".jsx": "javascript", ".mjs": "javascript", ".cjs": "javascript",
|
|
13
|
+
".py": "python", ".pyi": "python",
|
|
14
|
+
".go": "go",
|
|
15
|
+
".rs": "rust",
|
|
16
|
+
".java": "java",
|
|
17
|
+
".rb": "ruby", ".rake": "ruby",
|
|
18
|
+
".c": "c", ".h": "c",
|
|
19
|
+
".cc": "cpp", ".cpp": "cpp", ".cxx": "cpp", ".hpp": "cpp", ".hh": "cpp",
|
|
20
|
+
".cs": "c_sharp",
|
|
21
|
+
".php": "php",
|
|
22
|
+
".scala": "scala", ".sc": "scala",
|
|
23
|
+
".sh": "bash", ".bash": "bash",
|
|
24
|
+
".lua": "lua",
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export function grammarKeyForExt(ext: string): string | undefined {
|
|
28
|
+
return EXT_GRAMMAR[ext];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Where the committed wasms live. Resolved relative to this module so the
|
|
32
|
+
// SAME logic works whether we run from the tsup bundle (scripts/engine.mjs →
|
|
33
|
+
// scripts/grammars), from a consumer's vendored copy (src/vendor →
|
|
34
|
+
// ../../scripts/grammars), or from source under vitest (src/ast →
|
|
35
|
+
// ../../scripts/grammars). CODEINDEX_GRAMMAR_DIR overrides for tests/tooling
|
|
36
|
+
// (ULTRAINDEX_GRAMMAR_DIR kept as a legacy alias).
|
|
37
|
+
function resolveGrammarDir(): string {
|
|
38
|
+
const env = process.env.CODEINDEX_GRAMMAR_DIR ?? process.env.ULTRAINDEX_GRAMMAR_DIR;
|
|
39
|
+
if (env && existsSync(env)) return env;
|
|
40
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
41
|
+
const candidates = [
|
|
42
|
+
join(here, "grammars"), // bundle: <...>/scripts/grammars
|
|
43
|
+
join(here, "..", "..", "scripts", "grammars"), // dev: src/ast → <repo>/scripts/grammars
|
|
44
|
+
join(here, "..", "scripts", "grammars"),
|
|
45
|
+
];
|
|
46
|
+
for (const c of candidates) if (existsSync(c)) return c;
|
|
47
|
+
return join(here, "grammars");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// tree-sitter's runtime + grammars must be initialised asynchronously (wasm
|
|
51
|
+
// instantiation). We do that ONCE at the CLI/test boundary, then extraction is
|
|
52
|
+
// fully synchronous (parser.parse is sync) — so the scan pipeline itself never
|
|
53
|
+
// becomes async. No worker_threads: parsing is CPU-bound but per-file cheap, and
|
|
54
|
+
// the incremental cache removes the repeated cost; a single deterministic thread
|
|
55
|
+
// keeps byte-identical rebuilds trivially guaranteed.
|
|
56
|
+
let runtimeReady = false;
|
|
57
|
+
let parser: Parser | null = null;
|
|
58
|
+
const loaded = new Map<string, Language>();
|
|
59
|
+
const failed = new Set<string>();
|
|
60
|
+
|
|
61
|
+
// Load the runtime (once) and the requested grammar keys (each once). Idempotent
|
|
62
|
+
// and safe to call repeatedly. A missing/broken wasm is remembered as failed so
|
|
63
|
+
// the caller silently falls back to regex rather than retrying every file.
|
|
64
|
+
export async function ensureGrammars(keys: Iterable<string>): Promise<void> {
|
|
65
|
+
const dir = resolveGrammarDir();
|
|
66
|
+
if (!runtimeReady) {
|
|
67
|
+
const runtime = join(dir, "web-tree-sitter.wasm");
|
|
68
|
+
if (!existsSync(runtime)) return; // no committed grammars → regex fallback everywhere
|
|
69
|
+
await Parser.init({ wasmBinary: readFileSync(runtime) as unknown as Uint8Array });
|
|
70
|
+
runtimeReady = true;
|
|
71
|
+
parser = new Parser();
|
|
72
|
+
}
|
|
73
|
+
for (const key of new Set(keys)) {
|
|
74
|
+
if (loaded.has(key) || failed.has(key)) continue;
|
|
75
|
+
const wasm = join(dir, `${key}.wasm`);
|
|
76
|
+
if (!existsSync(wasm)) {
|
|
77
|
+
failed.add(key);
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
try {
|
|
81
|
+
loaded.set(key, await Language.load(new Uint8Array(readFileSync(wasm))));
|
|
82
|
+
} catch {
|
|
83
|
+
failed.add(key);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// All grammar keys we ship — used by the CLI/tests to warm every grammar upfront.
|
|
89
|
+
export function allGrammarKeys(): string[] {
|
|
90
|
+
return [...new Set(Object.values(EXT_GRAMMAR))];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function grammarReady(key: string): boolean {
|
|
94
|
+
return loaded.has(key);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// The shared parser, with `key`'s grammar selected. Returns null when the grammar
|
|
98
|
+
// is not loaded (caller uses the regex extractor). Sync — parse happens after.
|
|
99
|
+
export function parserFor(key: string): Parser | null {
|
|
100
|
+
const lang = loaded.get(key);
|
|
101
|
+
if (!parser || !lang) return null;
|
|
102
|
+
parser.setLanguage(lang);
|
|
103
|
+
return parser;
|
|
104
|
+
}
|
package/src/bm25.ts
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// Keyless lexical search (issue #4): BM25 ranking over deterministic per-file
|
|
2
|
+
// documents, so "where is auth handled?"-style questions get a ranked answer
|
|
3
|
+
// with ZERO API keys — no embeddings, no network, no model. Each file's
|
|
4
|
+
// document is built from its symbol names (split into camelCase/snake_case
|
|
5
|
+
// subtokens, originals kept), its path segments, its markdown headings, and its
|
|
6
|
+
// one-line summary/doc-comment. Scoring is textbook BM25 (k1=1.2, b=0.75) with
|
|
7
|
+
// a non-negative Robertson idf; both sides fold diacritics (util foldText) and
|
|
8
|
+
// the query reuses util keywords, so query and haystack always tokenize alike.
|
|
9
|
+
// Deterministic: files are scored in scan order (sorted by rel), scores are
|
|
10
|
+
// fixed to 4 decimal places, and ties break by path.
|
|
11
|
+
import type { RepoScan } from "./scan.js";
|
|
12
|
+
import { foldText, keywords } from "./util.js";
|
|
13
|
+
import { byStr } from "./sort.js";
|
|
14
|
+
|
|
15
|
+
const K1 = 1.2;
|
|
16
|
+
const B = 0.75;
|
|
17
|
+
const DEFAULT_LIMIT = 20;
|
|
18
|
+
const TOP_SYMBOLS = 5;
|
|
19
|
+
|
|
20
|
+
export interface SearchOptions {
|
|
21
|
+
// Maximum results returned (default 20).
|
|
22
|
+
limit?: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface SearchResult {
|
|
26
|
+
file: string; // repo-relative path
|
|
27
|
+
score: number; // BM25 score, fixed to 4 decimal places
|
|
28
|
+
matchedTerms: string[]; // query tokens present in this file's document, sorted
|
|
29
|
+
topSymbols: string[]; // symbols whose name matches the most query tokens (cap 5)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Split an identifier/phrase into lowercase, diacritic-folded subtokens:
|
|
33
|
+
// camelCase and ACRONYMWord boundaries become spaces, then any non-alphanumeric
|
|
34
|
+
// run splits (snake_case, kebab-case, dots, prose whitespace). The ORIGINAL
|
|
35
|
+
// token (lowercased) is kept alongside its parts so an exact identifier query
|
|
36
|
+
// ("HttpClient") still matches a compound definition. 1-char fragments are
|
|
37
|
+
// dropped as noise; letter↔digit runs stay together ("sha1", "bm25").
|
|
38
|
+
export function subtokens(raw: string): string[] {
|
|
39
|
+
const folded = foldText(raw)
|
|
40
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
|
41
|
+
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2");
|
|
42
|
+
const out: string[] = [];
|
|
43
|
+
const seen = new Set<string>();
|
|
44
|
+
const push = (t: string): void => {
|
|
45
|
+
if (t.length < 2 || seen.has(t)) return;
|
|
46
|
+
seen.add(t);
|
|
47
|
+
out.push(t);
|
|
48
|
+
};
|
|
49
|
+
// The whole identifier (lowercased) is a term of its own — but only for
|
|
50
|
+
// single tokens: gluing a prose heading into one mega-token would index junk.
|
|
51
|
+
if (!/\s/.test(raw.trim())) push(foldText(raw).toLowerCase().replace(/[^a-z0-9_]+/g, ""));
|
|
52
|
+
for (const part of folded.split(/[^A-Za-z0-9]+/)) push(part.toLowerCase());
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface Doc {
|
|
57
|
+
file: string;
|
|
58
|
+
tf: Map<string, number>;
|
|
59
|
+
len: number; // total token occurrences (the BM25 length normalizer)
|
|
60
|
+
symbols: string[]; // deduped symbol names, declaration order
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function addTerms(doc: Doc, text: string): void {
|
|
64
|
+
for (const t of subtokens(text)) {
|
|
65
|
+
doc.tf.set(t, (doc.tf.get(t) ?? 0) + 1);
|
|
66
|
+
doc.len++;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function buildDocs(scan: RepoScan): Doc[] {
|
|
71
|
+
const docs: Doc[] = [];
|
|
72
|
+
for (const f of scan.files) {
|
|
73
|
+
const doc: Doc = { file: f.rel, tf: new Map(), len: 0, symbols: [] };
|
|
74
|
+
const seenSym = new Set<string>();
|
|
75
|
+
for (const s of f.symbols) {
|
|
76
|
+
addTerms(doc, s.name);
|
|
77
|
+
if (!seenSym.has(s.name)) {
|
|
78
|
+
seenSym.add(s.name);
|
|
79
|
+
doc.symbols.push(s.name);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
for (const seg of f.rel.split("/")) addTerms(doc, seg);
|
|
83
|
+
for (const h of f.headings) addTerms(doc, h);
|
|
84
|
+
if (f.summary) addTerms(doc, f.summary);
|
|
85
|
+
docs.push(doc);
|
|
86
|
+
}
|
|
87
|
+
return docs;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Rank the scanned files against a natural-language (or identifier) query.
|
|
91
|
+
// Pure and deterministic: same scan + query → the same results, byte-for-byte.
|
|
92
|
+
export function searchIndex(scan: RepoScan, query: string, opts: SearchOptions = {}): SearchResult[] {
|
|
93
|
+
// Query tokens: util keywords (stopwords dropped, identifiers kept) expanded
|
|
94
|
+
// through the SAME subtoken splitter the documents use.
|
|
95
|
+
const terms: string[] = [];
|
|
96
|
+
const seen = new Set<string>();
|
|
97
|
+
for (const kw of keywords(query)) {
|
|
98
|
+
for (const t of subtokens(kw)) {
|
|
99
|
+
if (seen.has(t)) continue;
|
|
100
|
+
seen.add(t);
|
|
101
|
+
terms.push(t);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
if (!terms.length) return [];
|
|
105
|
+
|
|
106
|
+
const docs = buildDocs(scan);
|
|
107
|
+
const n = docs.length;
|
|
108
|
+
if (!n) return [];
|
|
109
|
+
let totalLen = 0;
|
|
110
|
+
for (const d of docs) totalLen += d.len;
|
|
111
|
+
const avgLen = totalLen / n || 1;
|
|
112
|
+
|
|
113
|
+
// Document frequency per query term, for idf.
|
|
114
|
+
const df = new Map<string, number>();
|
|
115
|
+
for (const t of terms) {
|
|
116
|
+
let count = 0;
|
|
117
|
+
for (const d of docs) if (d.tf.has(t)) count++;
|
|
118
|
+
df.set(t, count);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const results: SearchResult[] = [];
|
|
122
|
+
for (const d of docs) {
|
|
123
|
+
let score = 0;
|
|
124
|
+
const matched: string[] = [];
|
|
125
|
+
for (const t of terms) {
|
|
126
|
+
const tf = d.tf.get(t);
|
|
127
|
+
if (!tf) continue;
|
|
128
|
+
matched.push(t);
|
|
129
|
+
const idf = Math.log(1 + (n - df.get(t)! + 0.5) / (df.get(t)! + 0.5));
|
|
130
|
+
score += (idf * (tf * (K1 + 1))) / (tf + K1 * (1 - B + (B * d.len) / avgLen));
|
|
131
|
+
}
|
|
132
|
+
if (!matched.length) continue;
|
|
133
|
+
|
|
134
|
+
// Symbols ranked by how many query tokens their name carries, then by name.
|
|
135
|
+
const scored = d.symbols
|
|
136
|
+
.map((name) => {
|
|
137
|
+
const toks = new Set(subtokens(name));
|
|
138
|
+
let hits = 0;
|
|
139
|
+
for (const t of matched) if (toks.has(t)) hits++;
|
|
140
|
+
return { name, hits };
|
|
141
|
+
})
|
|
142
|
+
.filter((s) => s.hits > 0)
|
|
143
|
+
.sort((a, b) => b.hits - a.hits || byStr(a.name, b.name));
|
|
144
|
+
|
|
145
|
+
results.push({
|
|
146
|
+
file: d.file,
|
|
147
|
+
score: Number(score.toFixed(4)),
|
|
148
|
+
matchedTerms: matched.sort(byStr),
|
|
149
|
+
topSymbols: scored.slice(0, TOP_SYMBOLS).map((s) => s.name),
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Rounded score first (so 4-dp ties resolve stably), then path.
|
|
154
|
+
results.sort((a, b) => b.score - a.score || byStr(a.file, b.file));
|
|
155
|
+
return results.slice(0, opts.limit ?? DEFAULT_LIMIT);
|
|
156
|
+
}
|
package/src/callers.ts
ADDED
|
Binary file
|
package/src/calls.ts
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import type { Edge } from "./types.js";
|
|
2
|
+
import type { RepoScan } from "./scan.js";
|
|
3
|
+
import { byStr } from "./sort.js";
|
|
4
|
+
|
|
5
|
+
// Symbol kinds that are references to a definition elsewhere (a barrel re-export
|
|
6
|
+
// or `export default Foo`) — they must NOT count as a call target, which should
|
|
7
|
+
// resolve to where a symbol is actually declared. Re-declared here (canonical
|
|
8
|
+
// copy lives in graph.ts) so this module has no import cycle with the graph
|
|
9
|
+
// builder, which imports resolveCallEdges.
|
|
10
|
+
const REFERENCE_KINDS = new Set(["reexport", "reexport-all", "default"]);
|
|
11
|
+
|
|
12
|
+
// Collapse TypeScript/JavaScript to one family so a call in a `.ts` file can bind
|
|
13
|
+
// to a def in a `.js` file (and vice versa) but never crosses into an unrelated
|
|
14
|
+
// language. Every other language is its own family. Exported for callers.ts,
|
|
15
|
+
// which mirrors this binding logic at call-site granularity.
|
|
16
|
+
export function familyOf(lang: string): string {
|
|
17
|
+
if (lang === "typescript" || lang === "javascript") return "js";
|
|
18
|
+
// C and C++ interoperate through headers (.h files classify as "c" while
|
|
19
|
+
// their consumers are often .cpp) — one family, like the JS/TS pair.
|
|
20
|
+
if (lang === "c" || lang === "cpp") return "c";
|
|
21
|
+
return lang;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Leading path segments two repo-relative paths share (the filename never counts,
|
|
25
|
+
// as it always differs between distinct files). Higher = closer in the tree.
|
|
26
|
+
function sharedSegments(a: string, b: string): number {
|
|
27
|
+
const as = a.split("/");
|
|
28
|
+
const bs = b.split("/");
|
|
29
|
+
let n = 0;
|
|
30
|
+
while (n < as.length && n < bs.length && as[n] === bs[n]) n++;
|
|
31
|
+
return n;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface Cand {
|
|
35
|
+
file: string;
|
|
36
|
+
lang: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Pick a single candidate for a call: the sole candidate, else the one sharing
|
|
40
|
+
// the strictly-most leading path segments with the caller. A tie at the maximum
|
|
41
|
+
// (or an empty list) is unresolvable — return undefined so the caller skips it.
|
|
42
|
+
export function pickCandidate(callerRel: string, cands: Cand[]): Cand | undefined {
|
|
43
|
+
if (cands.length === 1) return cands[0];
|
|
44
|
+
if (cands.length === 0) return undefined;
|
|
45
|
+
let best: Cand | undefined;
|
|
46
|
+
let bestScore = -1;
|
|
47
|
+
let tied = false;
|
|
48
|
+
for (const c of cands) {
|
|
49
|
+
const s = sharedSegments(callerRel, c.file);
|
|
50
|
+
if (s > bestScore) {
|
|
51
|
+
bestScore = s;
|
|
52
|
+
best = c;
|
|
53
|
+
tied = false;
|
|
54
|
+
} else if (s === bestScore) {
|
|
55
|
+
tied = true;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return tied ? undefined : best;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Resolve every collected call site to a cross-file `call` edge in a global second
|
|
62
|
+
// pass. An import between the two files promotes the edge to `extracted`; a unique
|
|
63
|
+
// repo-wide name match with no import yields `inferred`. JS/TS is import-gated (no
|
|
64
|
+
// import ⇒ no edge) because its bare identifiers are too ambiguous to infer safely;
|
|
65
|
+
// other languages fall back to a unique-name inference. Deterministic: the emitted
|
|
66
|
+
// array is sorted and never depends on Map iteration order.
|
|
67
|
+
export function resolveCallEdges(scan: RepoScan, importPairs: Set<string>): Edge[] {
|
|
68
|
+
// name → distinct def sites (deduped per file; overloads collapse to one file).
|
|
69
|
+
const defs = new Map<string, Cand[]>();
|
|
70
|
+
const seen = new Set<string>();
|
|
71
|
+
for (const f of scan.files) {
|
|
72
|
+
for (const s of f.symbols) {
|
|
73
|
+
if (!s.exported || REFERENCE_KINDS.has(s.kind)) continue;
|
|
74
|
+
const dedup = `${s.name} ${s.file}`;
|
|
75
|
+
if (seen.has(dedup)) continue;
|
|
76
|
+
seen.add(dedup);
|
|
77
|
+
let arr = defs.get(s.name);
|
|
78
|
+
if (!arr) defs.set(s.name, (arr = []));
|
|
79
|
+
arr.push({ file: s.file, lang: s.lang });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// (from|to) → aggregated edge. Strongest confidence wins; counts sum.
|
|
84
|
+
const agg = new Map<string, { from: string; to: string; weight: number; confidence: "extracted" | "inferred" }>();
|
|
85
|
+
for (const f of scan.files) {
|
|
86
|
+
if (!f.calls?.length) continue;
|
|
87
|
+
const family = familyOf(f.lang);
|
|
88
|
+
const ownNames = new Set(f.symbols.map((s) => s.name));
|
|
89
|
+
const counts = new Map<string, number>();
|
|
90
|
+
for (const c of f.calls) counts.set(c.name, (counts.get(c.name) ?? 0) + 1);
|
|
91
|
+
|
|
92
|
+
for (const [name, count] of counts) {
|
|
93
|
+
if (ownNames.has(name)) continue; // same-file call — not a cross-file edge
|
|
94
|
+
const cands = (defs.get(name) ?? []).filter((d) => familyOf(d.lang) === family && d.file !== f.rel);
|
|
95
|
+
if (!cands.length) continue;
|
|
96
|
+
const imported = cands.filter((d) => importPairs.has(`${f.rel}|${d.file}`));
|
|
97
|
+
|
|
98
|
+
let chosen: Cand | undefined;
|
|
99
|
+
let confidence: "extracted" | "inferred";
|
|
100
|
+
if (family === "js") {
|
|
101
|
+
// JS/TS gate: without an import corroborating the call, drop it entirely.
|
|
102
|
+
// A named-import binding (f.importedNames) corroborates a name but not the
|
|
103
|
+
// file it came from, so it can't narrow `imported` further — pick among
|
|
104
|
+
// the imported candidates by proximity.
|
|
105
|
+
if (!imported.length) continue;
|
|
106
|
+
chosen = pickCandidate(f.rel, imported);
|
|
107
|
+
confidence = "extracted";
|
|
108
|
+
} else if (imported.length) {
|
|
109
|
+
chosen = pickCandidate(f.rel, imported);
|
|
110
|
+
confidence = "extracted";
|
|
111
|
+
} else {
|
|
112
|
+
chosen = pickCandidate(f.rel, cands);
|
|
113
|
+
confidence = "inferred";
|
|
114
|
+
}
|
|
115
|
+
if (!chosen) continue;
|
|
116
|
+
|
|
117
|
+
const key = `${f.rel}|${chosen.file}`;
|
|
118
|
+
const prev = agg.get(key);
|
|
119
|
+
if (prev) {
|
|
120
|
+
prev.weight += count;
|
|
121
|
+
if (confidence === "extracted") prev.confidence = "extracted";
|
|
122
|
+
} else {
|
|
123
|
+
agg.set(key, { from: f.rel, to: chosen.file, weight: count, confidence });
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return [...agg.values()]
|
|
129
|
+
.map((e) => ({ from: e.from, to: e.to, kind: "call" as const, weight: Math.min(e.weight, 5), confidence: e.confidence }))
|
|
130
|
+
.sort((a, b) => byStr(a.from, b.from) || byStr(a.to, b.to));
|
|
131
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// Nine-way file categorization (ported from reconstruct): a coarser, purpose-
|
|
2
|
+
// oriented view than classify()'s FileKind — "what role does this file play in
|
|
3
|
+
// the project" rather than "how should the indexer parse it". Pure path/ext
|
|
4
|
+
// logic; consumers call it themselves (it is not stamped on FileRecord).
|
|
5
|
+
import { basename } from "node:path";
|
|
6
|
+
|
|
7
|
+
export type FileCategory =
|
|
8
|
+
| "code"
|
|
9
|
+
| "test"
|
|
10
|
+
| "config"
|
|
11
|
+
| "schema"
|
|
12
|
+
| "i18n"
|
|
13
|
+
| "doc"
|
|
14
|
+
| "style"
|
|
15
|
+
| "asset"
|
|
16
|
+
| "data"
|
|
17
|
+
| "other";
|
|
18
|
+
|
|
19
|
+
const CODE_EXTS = new Set([
|
|
20
|
+
".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs", ".vue", ".svelte", ".astro",
|
|
21
|
+
".py", ".rb", ".go", ".rs", ".java", ".kt", ".kts", ".php", ".c", ".cc", ".cpp", ".h", ".hpp",
|
|
22
|
+
".cs", ".swift", ".scala", ".clj", ".ex", ".exs", ".dart", ".lua", ".sh", ".bash", ".zig", ".elm",
|
|
23
|
+
]);
|
|
24
|
+
const STYLE_EXTS = new Set([".css", ".scss", ".sass", ".less", ".styl", ".pcss"]);
|
|
25
|
+
const DOC_EXTS = new Set([".md", ".mdx", ".rst", ".adoc", ".txt"]);
|
|
26
|
+
const DATA_EXTS = new Set([".json", ".yaml", ".yml", ".toml", ".csv", ".xml", ".env"]);
|
|
27
|
+
// `.svg` is deliberately "asset" here: in this purpose-oriented view it plays
|
|
28
|
+
// the same image role as .png/.ico. reconstruct's own bundle categorizes it
|
|
29
|
+
// as "other" (SVG is text, and its asset set is exactly its binary-extension
|
|
30
|
+
// set) and keeps that as a local override on top of the engine — a documented
|
|
31
|
+
// divergence, not an accident.
|
|
32
|
+
const ASSET_EXTS = new Set([
|
|
33
|
+
".png", ".jpg", ".jpeg", ".gif", ".webp", ".avif", ".ico", ".bmp", ".tiff", ".svg", ".pdf",
|
|
34
|
+
".woff", ".woff2", ".ttf", ".otf", ".eot", ".mp3", ".mp4", ".mov", ".avi", ".webm",
|
|
35
|
+
// Archives / compiled binaries: reconstruct's bundle files these under
|
|
36
|
+
// "asset" (opaque blob shipped with the repo, not code/data) — the engine
|
|
37
|
+
// matches instead of letting them fall through to "other".
|
|
38
|
+
".zip", ".gz", ".tar", ".rar", ".7z", ".wasm", ".so", ".dylib", ".dll", ".exe", ".bin",
|
|
39
|
+
".class", ".jar", ".pyc", ".node",
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
const I18N_DIRS = ["locales", "locale", "i18n", "lang", "langs", "translations", "messages"];
|
|
43
|
+
const I18N_EXTS = new Set([".json", ".yaml", ".yml", ".po", ".properties"]);
|
|
44
|
+
const TEST_DIRS = ["__tests__", "test", "tests", "spec", "e2e", "__mocks__"];
|
|
45
|
+
const SCHEMA_DIRS = ["migrations", "entities", "models"];
|
|
46
|
+
|
|
47
|
+
const CONFIG_BASES = new Set([
|
|
48
|
+
"package.json", "tsconfig.json", "dockerfile", "makefile", "pyproject.toml", "cargo.toml",
|
|
49
|
+
"go.mod", "requirements.txt", "gemfile", "composer.json", "pubspec.yaml",
|
|
50
|
+
]);
|
|
51
|
+
|
|
52
|
+
export function categorize(rel: string, ext: string): FileCategory {
|
|
53
|
+
const lower = rel.toLowerCase();
|
|
54
|
+
const base = basename(lower);
|
|
55
|
+
const segments = lower.split("/");
|
|
56
|
+
const inDir = (names: readonly string[]): boolean => names.some((n) => segments.includes(n));
|
|
57
|
+
|
|
58
|
+
if (inDir(I18N_DIRS) && I18N_EXTS.has(ext)) return "i18n";
|
|
59
|
+
if (
|
|
60
|
+
ext === ".prisma" || ext === ".sql" || ext === ".graphql" || ext === ".gql" ||
|
|
61
|
+
base.startsWith("schema.") || base === "models.py" || inDir(SCHEMA_DIRS)
|
|
62
|
+
) {
|
|
63
|
+
return "schema";
|
|
64
|
+
}
|
|
65
|
+
if (lower.includes(".test.") || lower.includes(".spec.") || inDir(TEST_DIRS)) return "test";
|
|
66
|
+
if (
|
|
67
|
+
CONFIG_BASES.has(base) ||
|
|
68
|
+
base.endsWith(".config.js") || base.endsWith(".config.ts") || base.endsWith(".config.mjs") ||
|
|
69
|
+
base.startsWith(".eslintrc") || base.startsWith(".prettierrc") || base.startsWith(".env") ||
|
|
70
|
+
base.startsWith("docker-compose")
|
|
71
|
+
) {
|
|
72
|
+
return "config";
|
|
73
|
+
}
|
|
74
|
+
if (DOC_EXTS.has(ext)) return "doc";
|
|
75
|
+
if (STYLE_EXTS.has(ext)) return "style";
|
|
76
|
+
if (CODE_EXTS.has(ext)) return "code";
|
|
77
|
+
if (ASSET_EXTS.has(ext)) return "asset";
|
|
78
|
+
if (DATA_EXTS.has(ext)) return "data";
|
|
79
|
+
return "other";
|
|
80
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import type { Edge, Graph } from "./types.js";
|
|
2
|
+
|
|
3
|
+
// Deterministic centrality over the link-graph, feeding hub/bridge ranking and
|
|
4
|
+
// the delta risk panel. Like the community layer, determinism is load-bearing:
|
|
5
|
+
// scores land in graph.json (byte-identical rebuild guarantee), so node arrays
|
|
6
|
+
// are consumed in their stored (sorted) order, adjacency is built in edge order,
|
|
7
|
+
// and every float is rounded once at assignment — a fixed op order plus
|
|
8
|
+
// ECMA-262's specified number→string conversion keeps bytes stable across
|
|
9
|
+
// platforms. No Math.random anywhere.
|
|
10
|
+
|
|
11
|
+
// PageRank: standard damping, L1-convergence threshold, iteration safety cap.
|
|
12
|
+
const DAMPING = 0.85;
|
|
13
|
+
const MAX_ITERS = 100;
|
|
14
|
+
const CONVERGENCE = 1e-10;
|
|
15
|
+
|
|
16
|
+
// Brandes betweenness is O(V·E); module graphs are small, but a pathological
|
|
17
|
+
// repo (thousands of single-file modules) could stall a build. Past this cap
|
|
18
|
+
// the field is left absent and a build note says so.
|
|
19
|
+
export const BETWEENNESS_MAX_NODES = 3000;
|
|
20
|
+
|
|
21
|
+
// Weighted, directed PageRank over the given node ids. Edges are used exactly
|
|
22
|
+
// as stored (from → to), so a heavily-imported node ranks high. Dangling edges,
|
|
23
|
+
// self-loops, and edges whose endpoints are not in `ids` are skipped — the same
|
|
24
|
+
// graph view the degree fields and the Louvain adjacency use. Nodes with no
|
|
25
|
+
// outgoing weight redistribute their mass uniformly each iteration (the classic
|
|
26
|
+
// dangling-node fix), so the returned values always sum to 1.
|
|
27
|
+
export function pagerankOf(ids: string[], edges: Edge[], damping = DAMPING): Map<string, number> {
|
|
28
|
+
const out = new Map<string, number>();
|
|
29
|
+
const n = ids.length;
|
|
30
|
+
if (n === 0) return out;
|
|
31
|
+
const idx = new Map(ids.map((s, i) => [s, i]));
|
|
32
|
+
const adj: [number, number][][] = Array.from({ length: n }, () => []);
|
|
33
|
+
const outW = new Array<number>(n).fill(0);
|
|
34
|
+
for (const e of edges) {
|
|
35
|
+
if (e.dangling) continue;
|
|
36
|
+
const a = idx.get(e.from);
|
|
37
|
+
const b = idx.get(e.to);
|
|
38
|
+
if (a === undefined || b === undefined || a === b) continue;
|
|
39
|
+
adj[a]!.push([b, e.weight]);
|
|
40
|
+
outW[a]! += e.weight;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
let pr = new Array<number>(n).fill(1 / n);
|
|
44
|
+
for (let iter = 0; iter < MAX_ITERS; iter++) {
|
|
45
|
+
let dangling = 0;
|
|
46
|
+
for (let i = 0; i < n; i++) if (outW[i] === 0) dangling += pr[i]!;
|
|
47
|
+
const base = (1 - damping) / n + (damping * dangling) / n;
|
|
48
|
+
const next = new Array<number>(n).fill(base);
|
|
49
|
+
for (let i = 0; i < n; i++) {
|
|
50
|
+
if (outW[i] === 0) continue;
|
|
51
|
+
const share = (damping * pr[i]!) / outW[i]!;
|
|
52
|
+
for (const [j, w] of adj[i]!) next[j]! += share * w;
|
|
53
|
+
}
|
|
54
|
+
let delta = 0;
|
|
55
|
+
for (let i = 0; i < n; i++) delta += Math.abs(next[i]! - pr[i]!);
|
|
56
|
+
pr = next;
|
|
57
|
+
if (delta < CONVERGENCE) break;
|
|
58
|
+
}
|
|
59
|
+
ids.forEach((s, i) => out.set(s, pr[i]!));
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Brandes betweenness over the UNDIRECTED, UNWEIGHTED view of the edges —
|
|
64
|
+
// hop-count BFS keeps the path counts σ integral, so there are no float
|
|
65
|
+
// shortest-path ties to break. Neighbour lists are deduped and iterated in
|
|
66
|
+
// ascending index order; the source loop runs in index order — the accumulation
|
|
67
|
+
// order is therefore a pure function of the (sorted) input. Normalized by the
|
|
68
|
+
// pair count (n-1)(n-2)/2 into [0,1]; below 3 nodes every value is 0.
|
|
69
|
+
export function betweennessOf(ids: string[], edges: Edge[]): Map<string, number> {
|
|
70
|
+
const out = new Map<string, number>();
|
|
71
|
+
for (const s of ids) out.set(s, 0);
|
|
72
|
+
const n = ids.length;
|
|
73
|
+
if (n < 3) return out;
|
|
74
|
+
const idx = new Map(ids.map((s, i) => [s, i]));
|
|
75
|
+
const nbSets: Set<number>[] = Array.from({ length: n }, () => new Set<number>());
|
|
76
|
+
for (const e of edges) {
|
|
77
|
+
if (e.dangling) continue;
|
|
78
|
+
const a = idx.get(e.from);
|
|
79
|
+
const b = idx.get(e.to);
|
|
80
|
+
if (a === undefined || b === undefined || a === b) continue;
|
|
81
|
+
nbSets[a]!.add(b);
|
|
82
|
+
nbSets[b]!.add(a);
|
|
83
|
+
}
|
|
84
|
+
const adj = nbSets.map((s) => [...s].sort((x, y) => x - y));
|
|
85
|
+
|
|
86
|
+
const cb = new Array<number>(n).fill(0);
|
|
87
|
+
for (let s = 0; s < n; s++) {
|
|
88
|
+
const stack: number[] = [];
|
|
89
|
+
const pred: number[][] = Array.from({ length: n }, () => []);
|
|
90
|
+
const sigma = new Array<number>(n).fill(0);
|
|
91
|
+
const dist = new Array<number>(n).fill(-1);
|
|
92
|
+
sigma[s] = 1;
|
|
93
|
+
dist[s] = 0;
|
|
94
|
+
const queue: number[] = [s];
|
|
95
|
+
for (let qi = 0; qi < queue.length; qi++) {
|
|
96
|
+
const v = queue[qi]!;
|
|
97
|
+
stack.push(v);
|
|
98
|
+
for (const w of adj[v]!) {
|
|
99
|
+
if (dist[w]! < 0) {
|
|
100
|
+
dist[w] = dist[v]! + 1;
|
|
101
|
+
queue.push(w);
|
|
102
|
+
}
|
|
103
|
+
if (dist[w] === dist[v]! + 1) {
|
|
104
|
+
sigma[w]! += sigma[v]!;
|
|
105
|
+
pred[w]!.push(v);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
const delta = new Array<number>(n).fill(0);
|
|
110
|
+
for (let si = stack.length - 1; si >= 0; si--) {
|
|
111
|
+
const w = stack[si]!;
|
|
112
|
+
for (const v of pred[w]!) delta[v]! += (sigma[v]! / sigma[w]!) * (1 + delta[w]!);
|
|
113
|
+
if (w !== s) cb[w]! += delta[w]!;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
// Each undirected pair was counted from both endpoints → halve, then normalize.
|
|
117
|
+
const norm = ((n - 1) * (n - 2)) / 2;
|
|
118
|
+
ids.forEach((id, i) => out.set(id, cb[i]! / 2 / norm));
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Stamp centrality onto the graph's nodes in place (the `m.community` pattern:
|
|
123
|
+
// fields assigned in fixed code order so graph.json key order stays stable).
|
|
124
|
+
// Pagerank is stored SCALED by the node count — the average node is 1.0, hubs
|
|
125
|
+
// read as "3.2× the average" — and survives 4-dp rounding on graphs where the
|
|
126
|
+
// raw probability would round to zero. Returns build notes (skipped passes).
|
|
127
|
+
export function applyCentrality(graph: Graph): string[] {
|
|
128
|
+
const notes: string[] = [];
|
|
129
|
+
const nM = graph.modules.length;
|
|
130
|
+
if (nM > 0) {
|
|
131
|
+
const mIds = graph.modules.map((m) => m.id);
|
|
132
|
+
const mPr = pagerankOf(mIds, graph.moduleEdges);
|
|
133
|
+
for (const m of graph.modules) m.pagerank = Number(((mPr.get(m.id) ?? 0) * nM).toFixed(4));
|
|
134
|
+
if (nM > BETWEENNESS_MAX_NODES) {
|
|
135
|
+
notes.push(`betweenness skipped (${nM} modules > ${BETWEENNESS_MAX_NODES})`);
|
|
136
|
+
} else {
|
|
137
|
+
const bt = betweennessOf(mIds, graph.moduleEdges);
|
|
138
|
+
for (const m of graph.modules) m.betweenness = Number((bt.get(m.id) ?? 0).toFixed(6));
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
const nF = graph.files.length;
|
|
142
|
+
if (nF > 0) {
|
|
143
|
+
const fIds = graph.files.map((f) => f.id);
|
|
144
|
+
const fPr = pagerankOf(fIds, graph.fileEdges);
|
|
145
|
+
for (const f of graph.files) f.pagerank = Number(((fPr.get(f.id) ?? 0) * nF).toFixed(4));
|
|
146
|
+
}
|
|
147
|
+
return notes;
|
|
148
|
+
}
|
package/src/classify.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { FileKind } from "./types.js";
|
|
2
|
+
import { languageOf } from "./lang/registry.js";
|
|
3
|
+
|
|
4
|
+
// Documentation: conventional top-level docs, anything under a docs tree, and
|
|
5
|
+
// prose extensions. (Same heuristics ultradoc uses to feed its `docs` source.)
|
|
6
|
+
const DOC_BASENAME =
|
|
7
|
+
/^(readme|changelog|contributing|history|news|authors|notice|security|code_of_conduct|faq|getting[-_]?started|usage|guide|tutorial)\b/i;
|
|
8
|
+
const DOC_EXT = new Set([".md", ".mdx", ".rst", ".adoc", ".txt"]);
|
|
9
|
+
const DOC_DIR = /^(docs?|documentation|wiki|guides?|website|site|book)\//i;
|
|
10
|
+
|
|
11
|
+
// Manifests / config that reveal the stack, deps, scripts and entry points.
|
|
12
|
+
const CONFIG_BASENAME = new Set([
|
|
13
|
+
"package.json", "pnpm-workspace.yaml", "tsconfig.json", "jsconfig.json", "pyproject.toml",
|
|
14
|
+
"setup.py", "setup.cfg", "requirements.txt", "pipfile", "go.mod", "cargo.toml", "gemfile",
|
|
15
|
+
"pom.xml", "build.gradle", "build.gradle.kts", "composer.json", "mix.exs", "pubspec.yaml",
|
|
16
|
+
"build.sbt", "dockerfile", "docker-compose.yml", "docker-compose.yaml", "makefile",
|
|
17
|
+
".env.example", "manifest.json",
|
|
18
|
+
]);
|
|
19
|
+
const CONFIG_EXT = new Set([".json", ".yaml", ".yml", ".toml", ".ini", ".cfg"]);
|
|
20
|
+
|
|
21
|
+
// Markdown extraction only makes sense for actual markdown.
|
|
22
|
+
export const MARKDOWN_EXT = new Set([".md", ".mdx"]);
|
|
23
|
+
|
|
24
|
+
export function isDoc(rel: string, ext: string): boolean {
|
|
25
|
+
const base = rel.split("/").pop()!.toLowerCase();
|
|
26
|
+
return DOC_EXT.has(ext) || DOC_BASENAME.test(base) || DOC_DIR.test(rel);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function isConfig(rel: string, ext: string): boolean {
|
|
30
|
+
const base = rel.split("/").pop()!.toLowerCase();
|
|
31
|
+
return CONFIG_BASENAME.has(base) || CONFIG_EXT.has(ext);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// A code file is one an extractor (or the language table) recognizes as a
|
|
35
|
+
// programming language — not prose, not data/config.
|
|
36
|
+
const NON_CODE_LANGS = new Set([
|
|
37
|
+
"markdown", "restructuredtext", "text", "json", "yaml", "toml", "ini", "other",
|
|
38
|
+
"html", "css", "scss",
|
|
39
|
+
]);
|
|
40
|
+
|
|
41
|
+
export function isCode(ext: string): boolean {
|
|
42
|
+
return !NON_CODE_LANGS.has(languageOf(ext));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Classify a walked file. A real code extension ALWAYS wins — a `.ts`/`.tsx`
|
|
46
|
+
// file named News.tsx or under docs/ is code, not documentation (otherwise its
|
|
47
|
+
// imports are never extracted). After that: docs, then config, then other.
|
|
48
|
+
export function classify(rel: string, ext: string): FileKind {
|
|
49
|
+
if (isCode(ext)) return "code";
|
|
50
|
+
if (isDoc(rel, ext)) return "doc";
|
|
51
|
+
if (isConfig(rel, ext)) return "config";
|
|
52
|
+
return "other";
|
|
53
|
+
}
|