@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
package/src/types.ts
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
// Single source of truth for the engine version the bundle reports. Kept in
|
|
2
|
+
// lockstep with package.json by the release pipeline. Do not edit by hand
|
|
3
|
+
// outside a release.
|
|
4
|
+
export const ENGINE_VERSION = "2.7.0";
|
|
5
|
+
|
|
6
|
+
// Bumped whenever the on-disk artifact shape changes, so a consumer can reject
|
|
7
|
+
// an index written by an incompatible engine instead of misreading it. The
|
|
8
|
+
// numbering continues ultraindex's lineage (this core was extracted from
|
|
9
|
+
// ultraindex 5.1.0 at schema v4): v2 added symbols.json, the `use` edge kind,
|
|
10
|
+
// per-symbol parent/endLine, and the extraction cache; v3 added the `call` edge
|
|
11
|
+
// kind, `Edge.confidence`, and `ModuleNode.community`; v4 added node centrality
|
|
12
|
+
// (`pagerank`/`betweenness`), the tests→code fields (`FileNode.testFile`/
|
|
13
|
+
// `ModuleNode.testedBy`), and symbols.json `endLine`.
|
|
14
|
+
export const SCHEMA_VERSION = 4;
|
|
15
|
+
|
|
16
|
+
// Identifies the extraction engine's output shape independently of the artifact
|
|
17
|
+
// schema. Incremental caches key reused FileRecords on (content hash,
|
|
18
|
+
// EXTRACTOR_VERSION); bump this whenever symbol/import extraction changes so a
|
|
19
|
+
// stale cache is discarded wholesale rather than mixing old and new records. v3
|
|
20
|
+
// added FileRecord.calls (call-site callee names) and importedNames; v4 added
|
|
21
|
+
// CommonJS assignment-style JS/TS definitions (`x.y = function () {}`); v5
|
|
22
|
+
// populates `calls` on the regex tier too (files without an AST grammar), so
|
|
23
|
+
// caller indexes work without the wasm sidecar; v6 added call-site `receiver`
|
|
24
|
+
// (the immediate receiver of a qualified call, both tiers) and JS/TS export
|
|
25
|
+
// parity with ultradoc (CJS `exports.foo =` / `module.exports = {…}` named
|
|
26
|
+
// exports, `export { a, b as c }` local marking, anonymous `export default`
|
|
27
|
+
// named after the file stem, `export default Foo` marking the declaration).
|
|
28
|
+
export const EXTRACTOR_VERSION = 6;
|
|
29
|
+
|
|
30
|
+
// How a file is classified. `code` gets symbol/import extraction; `doc` gets
|
|
31
|
+
// link/heading extraction; the rest are catalogued but not deeply parsed.
|
|
32
|
+
export type FileKind = "code" | "doc" | "config" | "asset" | "other";
|
|
33
|
+
|
|
34
|
+
// Edge kinds in the link-graph. `contains` is the module→member hierarchy;
|
|
35
|
+
// `doc-link` a markdown link; `import` a resolved local code import; `call` a
|
|
36
|
+
// resolved cross-file function/method/constructor call (a global second pass over
|
|
37
|
+
// collected call sites); `use` a code file referencing another file's unique
|
|
38
|
+
// exported symbol (AST-derived, suppressed when an `import` or `call` edge already
|
|
39
|
+
// covers the same pair); `mention` a doc naming an exported symbol.
|
|
40
|
+
export type EdgeKind = "contains" | "doc-link" | "import" | "call" | "use" | "mention";
|
|
41
|
+
|
|
42
|
+
// Dependency tier: 0 = foundations (types, utils, config), 1 = features,
|
|
43
|
+
// 2 = tail (tests, docs, examples, scripts).
|
|
44
|
+
export type Tier = 0 | 1 | 2;
|
|
45
|
+
|
|
46
|
+
// A symbol extracted deterministically from source (no LLM). Shape matches the
|
|
47
|
+
// lifted lang/* extractors.
|
|
48
|
+
export interface CodeSymbol {
|
|
49
|
+
name: string;
|
|
50
|
+
kind: string; // function | class | method | const | type | interface | enum | struct | trait | def
|
|
51
|
+
file: string; // relative to repo root
|
|
52
|
+
line: number; // 1-based
|
|
53
|
+
endLine?: number; // 1-based end of the declaration node (AST extractor only)
|
|
54
|
+
parent?: string; // enclosing symbol name for a nested member (AST extractor only)
|
|
55
|
+
signature?: string;
|
|
56
|
+
exported: boolean;
|
|
57
|
+
lang: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// A raw, UNRESOLVED outbound reference found in a file: a markdown link target,
|
|
61
|
+
// or an import specifier as written. Resolution to a real file happens later in
|
|
62
|
+
// the graph builder, which is where language/path context lives.
|
|
63
|
+
export interface RawRef {
|
|
64
|
+
kind: "doc-link" | "import";
|
|
65
|
+
spec: string; // the target/specifier exactly as written
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Everything extracted from one file in a single pass. The unit the graph and
|
|
69
|
+
// renderers consume; nothing here requires the model.
|
|
70
|
+
export interface FileRecord {
|
|
71
|
+
rel: string; // posix path relative to repo root
|
|
72
|
+
ext: string;
|
|
73
|
+
size: number;
|
|
74
|
+
lines: number;
|
|
75
|
+
hash: string; // sha1 of content — the staleness oracle
|
|
76
|
+
kind: FileKind;
|
|
77
|
+
lang: string;
|
|
78
|
+
title?: string; // markdown H1, or basename for code
|
|
79
|
+
summary?: string; // one-line: first doc paragraph / top doc-comment
|
|
80
|
+
headings: string[]; // markdown section headings
|
|
81
|
+
symbols: CodeSymbol[]; // declared symbols (capped per file)
|
|
82
|
+
refs: RawRef[]; // unresolved outbound links/imports
|
|
83
|
+
pkg?: string; // Java: the file's `package` declaration — anchors source roots
|
|
84
|
+
idents?: string[]; // distinctive identifiers referenced (transient — feeds `use` edges, not persisted)
|
|
85
|
+
// Unresolved call-site callee names (cap 512, deduped by name+line, sorted by
|
|
86
|
+
// name then line). Transient-ish: consumed by the graph builder's global call
|
|
87
|
+
// resolution pass, not surfaced in the graph itself. `receiver` is the simple
|
|
88
|
+
// name of the IMMEDIATE receiver of a qualified call — `axios.get(...)` →
|
|
89
|
+
// {name: "get", receiver: "axios"}, `a.b.c(...)` → {name: "c", receiver: "b"}
|
|
90
|
+
// — absent for a bare call (`get()`) or a computed/complex receiver
|
|
91
|
+
// (`fetch().then(...)`). Receiver-gated sink catalogs (ultrasec) key on it.
|
|
92
|
+
calls?: { name: string; line: number; receiver?: string }[];
|
|
93
|
+
// JS/TS named-import bindings (cap 256, deduped, sorted) — feeds the JS/TS
|
|
94
|
+
// import-evidence gate in call resolution.
|
|
95
|
+
importedNames?: string[];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// A node in the link-graph. Files and modules are both nodes.
|
|
99
|
+
export interface FileNode {
|
|
100
|
+
id: string; // == rel
|
|
101
|
+
kind: "file";
|
|
102
|
+
rel: string;
|
|
103
|
+
fileKind: FileKind;
|
|
104
|
+
lang: string;
|
|
105
|
+
module: string; // owning module slug
|
|
106
|
+
title?: string;
|
|
107
|
+
summary?: string;
|
|
108
|
+
symbols: number;
|
|
109
|
+
lines: number;
|
|
110
|
+
degIn: number;
|
|
111
|
+
degOut: number;
|
|
112
|
+
// File-graph PageRank scaled by the file count (average file ≈ 1.0), 4 dp.
|
|
113
|
+
// Absent only on graphs built before centrality existed.
|
|
114
|
+
pagerank?: number;
|
|
115
|
+
// Present (true) only when the path classifies as a test file (tests-map.ts).
|
|
116
|
+
testFile?: true;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export interface ModuleNode {
|
|
120
|
+
id: string; // == slug
|
|
121
|
+
kind: "module";
|
|
122
|
+
slug: string;
|
|
123
|
+
path: string; // directory path (or "(root)")
|
|
124
|
+
title: string;
|
|
125
|
+
summary: string;
|
|
126
|
+
tier: Tier;
|
|
127
|
+
members: string[]; // member file rels, sorted
|
|
128
|
+
symbols: number; // total declared symbols across members
|
|
129
|
+
degIn: number;
|
|
130
|
+
degOut: number;
|
|
131
|
+
// Navigation community (a Louvain cluster of related modules), 0-based; id 0 is
|
|
132
|
+
// the largest cluster. OPTIONAL/additive: never affects slugs or lexical
|
|
133
|
+
// ranking. Absent only on graphs built before communities existed.
|
|
134
|
+
community?: number;
|
|
135
|
+
// Module-graph PageRank scaled by the module count (average ≈ 1.0), 4 dp.
|
|
136
|
+
pagerank?: number;
|
|
137
|
+
// Normalized undirected Brandes betweenness, [0,1], 6 dp. Absent when the
|
|
138
|
+
// BETWEENNESS_MAX_NODES guard skipped the pass.
|
|
139
|
+
betweenness?: number;
|
|
140
|
+
// Sorted test-file rels with a resolved import/call/use edge into a member —
|
|
141
|
+
// "which tests cover this module". Absent when none do.
|
|
142
|
+
testedBy?: string[];
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// A directed edge. For a resolved edge `to` is a node id; for a dangling edge
|
|
146
|
+
// `to` is the unresolved spec and `dangling` is set with a `reason`.
|
|
147
|
+
export interface Edge {
|
|
148
|
+
from: string;
|
|
149
|
+
to: string;
|
|
150
|
+
kind: EdgeKind;
|
|
151
|
+
weight: number;
|
|
152
|
+
dangling?: boolean;
|
|
153
|
+
reason?: string;
|
|
154
|
+
// Only `call` edges set this. extracted = an import between the files
|
|
155
|
+
// corroborates the call; inferred = resolved by a unique name match without
|
|
156
|
+
// import evidence.
|
|
157
|
+
confidence?: "extracted" | "inferred";
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// The full machine graph, persisted as graph.json by consumers. Holds BOTH
|
|
161
|
+
// file-level and module-level nodes/edges. Deliberately carries NO wall-clock
|
|
162
|
+
// timestamp so two builds of an unchanged repo are byte-identical.
|
|
163
|
+
export interface Graph {
|
|
164
|
+
schemaVersion: number;
|
|
165
|
+
version: string;
|
|
166
|
+
commit?: string; // stable for a given HEAD
|
|
167
|
+
fileCount: number;
|
|
168
|
+
languages: Record<string, number>;
|
|
169
|
+
files: FileNode[];
|
|
170
|
+
modules: ModuleNode[];
|
|
171
|
+
fileEdges: Edge[];
|
|
172
|
+
moduleEdges: Edge[];
|
|
173
|
+
// Surprising cross-community couplings (see surprise.ts), capped and sorted
|
|
174
|
+
// (pairEdges asc, from, to). Absent when none were found.
|
|
175
|
+
surprises?: SurpriseEdge[];
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// A dependency edge that is one of at most 2 links between two otherwise-
|
|
179
|
+
// separate communities — an architectural leak worth extra review attention.
|
|
180
|
+
export interface SurpriseEdge {
|
|
181
|
+
from: string;
|
|
182
|
+
to: string;
|
|
183
|
+
kind: EdgeKind;
|
|
184
|
+
weight: number;
|
|
185
|
+
communities: [number, number]; // [community(from), community(to)]
|
|
186
|
+
pairEdges: number; // total module edges between the two communities
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// A persisted symbol table (symbols.json), emitted so `symbols <name>` queries
|
|
190
|
+
// can answer "where is X defined?" without re-scanning the repo. `defs` maps a
|
|
191
|
+
// symbol name to its definition sites; `refs` maps a name to the files that
|
|
192
|
+
// reference it (populated by the use/mention pass). Deterministically ordered.
|
|
193
|
+
export interface SymbolIndex {
|
|
194
|
+
schemaVersion: number;
|
|
195
|
+
// `endLine` mirrors CodeSymbol.endLine (AST extractor only).
|
|
196
|
+
defs: Record<
|
|
197
|
+
string,
|
|
198
|
+
{ file: string; line: number; endLine?: number; kind: string; exported: boolean; lang: string; parent?: string }[]
|
|
199
|
+
>;
|
|
200
|
+
refs: Record<string, string[]>;
|
|
201
|
+
}
|
package/src/util.ts
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
// Result of a subprocess call. `ok` is true on exit code 0 with the binary
|
|
4
|
+
// found; `missing` is true when the binary isn't on PATH (so callers can fall
|
|
5
|
+
// back gracefully instead of crashing — e.g. no ripgrep, no gh, no docker).
|
|
6
|
+
export interface ShResult {
|
|
7
|
+
ok: boolean;
|
|
8
|
+
status: number | null;
|
|
9
|
+
stdout: string;
|
|
10
|
+
stderr: string;
|
|
11
|
+
missing: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Run a command synchronously. Sync keeps the CLI simple and deterministic
|
|
15
|
+
// (mirrors how the engine is structured); the work is I/O-bound git/rg/gh calls
|
|
16
|
+
// where parallelism buys little. `input` feeds stdin; `maxBuffer` is generous
|
|
17
|
+
// for large `rg --json` / `git log` output.
|
|
18
|
+
export function sh(
|
|
19
|
+
cmd: string,
|
|
20
|
+
args: string[],
|
|
21
|
+
opts: { cwd?: string; input?: string; timeoutMs?: number; env?: Record<string, string | undefined> } = {},
|
|
22
|
+
): ShResult {
|
|
23
|
+
const res = spawnSync(cmd, args, {
|
|
24
|
+
cwd: opts.cwd,
|
|
25
|
+
input: opts.input,
|
|
26
|
+
encoding: "utf8",
|
|
27
|
+
timeout: opts.timeoutMs ?? 120_000,
|
|
28
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
29
|
+
env: opts.env ?? process.env,
|
|
30
|
+
});
|
|
31
|
+
const missing = !!res.error && (res.error as NodeJS.ErrnoException).code === "ENOENT";
|
|
32
|
+
return {
|
|
33
|
+
ok: !res.error && res.status === 0,
|
|
34
|
+
status: res.status,
|
|
35
|
+
stdout: res.stdout ?? "",
|
|
36
|
+
stderr: res.stderr ?? (res.error ? String(res.error.message) : ""),
|
|
37
|
+
missing,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Is a binary available on PATH? Cached because we probe the same few tools
|
|
42
|
+
// (rg, gh, git, docker) repeatedly within a run.
|
|
43
|
+
const whichCache = new Map<string, boolean>();
|
|
44
|
+
export function have(cmd: string): boolean {
|
|
45
|
+
const cached = whichCache.get(cmd);
|
|
46
|
+
if (cached !== undefined) return cached;
|
|
47
|
+
const probe = sh(process.platform === "win32" ? "where" : "which", [cmd]);
|
|
48
|
+
const found = probe.ok && probe.stdout.trim().length > 0;
|
|
49
|
+
whichCache.set(cmd, found);
|
|
50
|
+
return found;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Turn an arbitrary repo identifier into a filesystem-safe cache slug, e.g.
|
|
54
|
+
// "github.com/expressjs/express" -> "github.com-expressjs-express".
|
|
55
|
+
export function slugify(input: string): string {
|
|
56
|
+
return input
|
|
57
|
+
.toLowerCase()
|
|
58
|
+
.replace(/^https?:\/\//, "")
|
|
59
|
+
.replace(/^git@/, "")
|
|
60
|
+
.replace(/\.git$/, "")
|
|
61
|
+
.replace(/[^a-z0-9._-]+/g, "-")
|
|
62
|
+
.replace(/^-+|-+$/g, "")
|
|
63
|
+
.slice(0, 120);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Truncate a string to a max length with an ellipsis marker, for snippets.
|
|
67
|
+
export function clip(s: string, max: number): string {
|
|
68
|
+
if (s.length <= max) return s;
|
|
69
|
+
return s.slice(0, max) + `\n… [truncated ${s.length - max} chars]`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Truncate a string for INLINE display (a single line): collapse whitespace, cut
|
|
73
|
+
// at a word boundary, and never leave a dangling inline-code backtick — so a
|
|
74
|
+
// clipped module summary like "… in `path/` (typescript)" stays valid markdown
|
|
75
|
+
// instead of the broken "… in `path/` (types" a raw slice produces.
|
|
76
|
+
export function clipInline(s: string, max: number): string {
|
|
77
|
+
const flat = s.replace(/\s+/g, " ").trim();
|
|
78
|
+
if (flat.length <= max) return flat;
|
|
79
|
+
let cut = flat.slice(0, max).replace(/\s+\S*$/, ""); // back off to a word boundary
|
|
80
|
+
if (!cut) cut = flat.slice(0, max); // a single token longer than max — hard cut
|
|
81
|
+
// A half-open inline-code span: drop the severed backtick (and its partial
|
|
82
|
+
// contents) rather than appending one, which would emit an empty `` span.
|
|
83
|
+
if ((cut.match(/`/g)?.length ?? 0) % 2 === 1) cut = cut.replace(/`[^`]*$/, "");
|
|
84
|
+
// A half-open markdown link `[text…`: drop from the unmatched `[`.
|
|
85
|
+
if (cut.lastIndexOf("[") > cut.lastIndexOf("]")) cut = cut.slice(0, cut.lastIndexOf("["));
|
|
86
|
+
return cut.replace(/\s+$/, "") + "…";
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Escape a string for safe inclusion as a literal inside a RegExp.
|
|
90
|
+
export function escapeRegExp(s: string): string {
|
|
91
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Pull the meaningful keywords out of a natural-language question: lowercase,
|
|
95
|
+
// split on non-word chars, drop stopwords and very short tokens, dedupe. Used
|
|
96
|
+
// to drive lexical search and symbol ranking deterministically (no LLM).
|
|
97
|
+
const STOPWORDS = new Set([
|
|
98
|
+
"the","a","an","is","are","was","were","be","been","being","do","does","did","how","what",
|
|
99
|
+
"why","when","where","which","who","whom","this","that","these","those","of","in","on","to",
|
|
100
|
+
"for","with","and","or","but","if","then","else","than","as","at","by","from","into","about",
|
|
101
|
+
"it","its","i","you","we","they","he","she","there","here","can","could","should","would",
|
|
102
|
+
"will","shall","may","might","must","have","has","had","not","no","yes","so","such","only",
|
|
103
|
+
"any","some","all","get","set","use","used","using","work","works","working","handle","handled",
|
|
104
|
+
"happen","happens","default","value","values","please","explain","tell","me","my","our",
|
|
105
|
+
]);
|
|
106
|
+
|
|
107
|
+
// Fold diacritics to their base letters (NFKD decomposition, then drop the
|
|
108
|
+
// combining marks in the U+0300–U+036F block) so "café" and "cafe" tokenize
|
|
109
|
+
// alike. Query and haystack must both pass through this so the two sides agree.
|
|
110
|
+
export function foldText(s: string): string {
|
|
111
|
+
return s.normalize("NFKD").replace(/[̀-ͯ]/g, "");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function keywords(question: string): string[] {
|
|
115
|
+
const seen = new Set<string>();
|
|
116
|
+
const out: string[] = [];
|
|
117
|
+
for (const raw of foldText(question).split(/[^A-Za-z0-9_]+/)) {
|
|
118
|
+
if (!raw) continue;
|
|
119
|
+
const lower = raw.toLowerCase();
|
|
120
|
+
// Keep identifiers as-is (camelCase/snake_case often carry the real signal),
|
|
121
|
+
// but filter generic English stopwords and 1-char noise.
|
|
122
|
+
if (raw.length < 2) continue;
|
|
123
|
+
if (STOPWORDS.has(lower)) continue;
|
|
124
|
+
if (seen.has(lower)) continue;
|
|
125
|
+
seen.add(lower);
|
|
126
|
+
out.push(raw);
|
|
127
|
+
}
|
|
128
|
+
return out;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Keywords ordered by how *distinctive* they are, most-specific first. Numbers
|
|
132
|
+
// (status codes like 429), camelCase/snake_case identifiers, and long tokens
|
|
133
|
+
// carry more signal than short generic words. Narrow search APIs (GitHub/GitLab
|
|
134
|
+
// issue search, StackOverflow) AND their terms, so feeding them the few most
|
|
135
|
+
// specific keywords — rather than the first N — dramatically improves recall.
|
|
136
|
+
export function rankedKeywords(question: string): string[] {
|
|
137
|
+
const base = keywords(question);
|
|
138
|
+
const score = (raw: string): number => {
|
|
139
|
+
let s = 0;
|
|
140
|
+
if (/\d/.test(raw)) s += 3;
|
|
141
|
+
if (/[A-Z]/.test(raw) && !/^[A-Z0-9]+$/.test(raw)) s += 2; // camelCase/PascalCase
|
|
142
|
+
if (/_/.test(raw)) s += 2;
|
|
143
|
+
if (raw.length >= 8) s += 1.5;
|
|
144
|
+
else if (raw.length >= 5) s += 0.5;
|
|
145
|
+
return s;
|
|
146
|
+
};
|
|
147
|
+
return base
|
|
148
|
+
.map((k, i) => ({ k, s: score(k), i }))
|
|
149
|
+
.sort((a, b) => b.s - a.s || a.i - b.i)
|
|
150
|
+
.map((x) => x.k);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Reciprocal Rank Fusion: merge several ranked lists into one robust ranking
|
|
154
|
+
// without needing comparable scores across lists. `k` damps the contribution of
|
|
155
|
+
// low ranks. Returns keys ordered best-first with a fused score.
|
|
156
|
+
export function rrf<T>(
|
|
157
|
+
lists: T[][],
|
|
158
|
+
keyOf: (item: T) => string,
|
|
159
|
+
k = 60,
|
|
160
|
+
): Map<string, number> {
|
|
161
|
+
const score = new Map<string, number>();
|
|
162
|
+
for (const list of lists) {
|
|
163
|
+
list.forEach((item, idx) => {
|
|
164
|
+
const key = keyOf(item);
|
|
165
|
+
score.set(key, (score.get(key) ?? 0) + 1 / (k + idx + 1));
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
return score;
|
|
169
|
+
}
|
package/src/viz.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Mermaid rendering of the module graph — inline-renderable in Claude, GitHub
|
|
2
|
+
// and most markdown viewers with zero infrastructure (the counter to graph-DB
|
|
3
|
+
// browsers). Deterministic: sorted nodes/edges, stable ids.
|
|
4
|
+
import type { Graph } from "./types.js";
|
|
5
|
+
import { byStr } from "./sort.js";
|
|
6
|
+
|
|
7
|
+
export interface MermaidOptions {
|
|
8
|
+
// Restrict to one module's neighborhood (the module plus every module it
|
|
9
|
+
// touches, either direction).
|
|
10
|
+
module?: string;
|
|
11
|
+
maxEdges?: number; // default 80 — keeps diagrams renderable
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const sanitizeId = (slug: string): string => slug.replace(/[^\w]/g, "_");
|
|
15
|
+
|
|
16
|
+
export function renderMermaid(graph: Graph, opts: MermaidOptions = {}): string {
|
|
17
|
+
const maxEdges = opts.maxEdges ?? 80;
|
|
18
|
+
let edges = [...graph.moduleEdges].filter((e) => !e.dangling);
|
|
19
|
+
if (opts.module) {
|
|
20
|
+
edges = edges.filter((e) => e.from === opts.module || e.to === opts.module);
|
|
21
|
+
}
|
|
22
|
+
edges.sort((a, b) => b.weight - a.weight || byStr(a.from, b.from) || byStr(a.to, b.to));
|
|
23
|
+
const dropped = Math.max(0, edges.length - maxEdges);
|
|
24
|
+
edges = edges.slice(0, maxEdges);
|
|
25
|
+
|
|
26
|
+
const shown = new Set<string>();
|
|
27
|
+
for (const e of edges) {
|
|
28
|
+
shown.add(e.from);
|
|
29
|
+
shown.add(e.to);
|
|
30
|
+
}
|
|
31
|
+
if (opts.module) shown.add(opts.module);
|
|
32
|
+
|
|
33
|
+
const lines: string[] = ["graph LR"];
|
|
34
|
+
for (const m of [...graph.modules].sort((a, b) => byStr(a.slug, b.slug))) {
|
|
35
|
+
if (!shown.has(m.slug)) continue;
|
|
36
|
+
lines.push(` ${sanitizeId(m.slug)}["${m.slug}${m.tier === 0 ? " (core)" : ""}"]`);
|
|
37
|
+
}
|
|
38
|
+
for (const e of edges) {
|
|
39
|
+
const label = e.kind === "import" ? "" : `|${e.kind}|`;
|
|
40
|
+
lines.push(` ${sanitizeId(e.from)} -->${label} ${sanitizeId(e.to)}`);
|
|
41
|
+
}
|
|
42
|
+
if (dropped) lines.push(` %% ${dropped} lighter edges omitted (maxEdges=${maxEdges})`);
|
|
43
|
+
return lines.join("\n") + "\n";
|
|
44
|
+
}
|
package/src/walk.ts
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { readdirSync, statSync, lstatSync, readFileSync, realpathSync } from "node:fs";
|
|
2
|
+
import { join, relative, sep, extname } from "node:path";
|
|
3
|
+
import { parseGitignore, isIgnored, type IgnoreRule } from "./ignore.js";
|
|
4
|
+
|
|
5
|
+
// Directories that never carry signal for a documentation/code question and
|
|
6
|
+
// would bloat the index (dependencies, build output, VCS internals, caches).
|
|
7
|
+
// Exported so grep.ts can align ripgrep's universe with the walker's.
|
|
8
|
+
export const IGNORE_DIRS = new Set([
|
|
9
|
+
".git", "node_modules", ".pnpm", "bower_components", "vendor", "dist", "build", "out",
|
|
10
|
+
"target", ".next", ".nuxt", ".svelte-kit", ".turbo", "coverage", "__pycache__", ".venv",
|
|
11
|
+
"venv", ".tox", ".mypy_cache", ".pytest_cache", ".gradle", ".idea", ".vscode", ".cache",
|
|
12
|
+
"tmp", ".ultraindex", "Pods", "DerivedData", ".terraform", "elm-stuff", ".dart_tool",
|
|
13
|
+
]);
|
|
14
|
+
|
|
15
|
+
// Lockfiles: huge, machine-generated, and pure noise for a code/docs question —
|
|
16
|
+
// they'd otherwise rank as keyword-dense "code" hits (e.g. package-lock.json
|
|
17
|
+
// matching a dependency name). Skipped entirely.
|
|
18
|
+
export const LOCKFILES = new Set([
|
|
19
|
+
"package-lock.json", "npm-shrinkwrap.json", "yarn.lock", "pnpm-lock.yaml", "bun.lockb",
|
|
20
|
+
"composer.lock", "cargo.lock", "poetry.lock", "pipfile.lock", "gemfile.lock", "go.sum",
|
|
21
|
+
"flake.lock", "packages.lock.json", "podfile.lock", "mix.lock",
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
// Binary / non-source extensions to skip when reading file contents.
|
|
25
|
+
export const BINARY_EXT = new Set([
|
|
26
|
+
".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".ico", ".icns", ".svg", ".pdf", ".zip",
|
|
27
|
+
".gz", ".tar", ".tgz", ".bz2", ".xz", ".7z", ".rar", ".jar", ".war", ".class", ".so", ".dylib",
|
|
28
|
+
".dll", ".exe", ".bin", ".o", ".a", ".wasm", ".woff", ".woff2", ".ttf", ".otf", ".eot", ".mp3",
|
|
29
|
+
".mp4", ".mov", ".avi", ".webm", ".wav", ".flac", ".ogg", ".lock", ".min.js", ".map",
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
export interface WalkOptions {
|
|
33
|
+
maxFileBytes?: number; // skip files larger than this (default 1 MiB)
|
|
34
|
+
maxFiles?: number; // hard cap on indexed files (default 20000)
|
|
35
|
+
// Honor .gitignore files (root and nested, with negation/anchoring/dir-only
|
|
36
|
+
// semantics — see ignore.ts). Default TRUE: an ignored file is noise for
|
|
37
|
+
// every consumer; pass false to index generated/ignored trees deliberately.
|
|
38
|
+
gitignore?: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface WalkedFile {
|
|
42
|
+
rel: string; // path relative to root, posix-style
|
|
43
|
+
abs: string;
|
|
44
|
+
size: number;
|
|
45
|
+
ext: string;
|
|
46
|
+
mtimeMs: number; // last-modified ms — the stat fastpath's freshness key with size
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface WalkResult {
|
|
50
|
+
files: WalkedFile[];
|
|
51
|
+
capped: boolean; // true when the maxFiles cap was hit and the walk stopped early
|
|
52
|
+
// Files that were SEEN and rejected by the size/lockfile/binary/minified/
|
|
53
|
+
// gitignore rules. Ignored DIRECTORIES (node_modules, gitignored trees…)
|
|
54
|
+
// are not counted — their contents were never even listed.
|
|
55
|
+
excluded: number;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export const DEFAULT_MAX_FILES = 20_000;
|
|
59
|
+
|
|
60
|
+
// Recursively list source-like files under `root`, applying ignore rules. Pure
|
|
61
|
+
// filesystem walk — no git dependency, so it works on any directory. Returns a
|
|
62
|
+
// `capped` flag (never a silent truncation) so the caller can warn when the
|
|
63
|
+
// maxFiles cap stopped the walk with files still unindexed.
|
|
64
|
+
export function walk(root: string, opts: WalkOptions = {}): WalkResult {
|
|
65
|
+
const maxFileBytes = opts.maxFileBytes ?? 1024 * 1024;
|
|
66
|
+
const maxFiles = opts.maxFiles ?? DEFAULT_MAX_FILES;
|
|
67
|
+
const useGitignore = opts.gitignore !== false;
|
|
68
|
+
const out: WalkedFile[] = [];
|
|
69
|
+
let capped = false;
|
|
70
|
+
let excluded = 0;
|
|
71
|
+
|
|
72
|
+
// Containment root for the symlink-escape guard: a symlinked file or
|
|
73
|
+
// directory whose real path leaves the repo must not be indexed (it would
|
|
74
|
+
// read foreign content and emit citations no one can open).
|
|
75
|
+
let rootReal: string;
|
|
76
|
+
try {
|
|
77
|
+
rootReal = realpathSync(root);
|
|
78
|
+
} catch {
|
|
79
|
+
return { files: out, capped, excluded };
|
|
80
|
+
}
|
|
81
|
+
const contained = (real: string): boolean => real === rootReal || real.startsWith(rootReal + sep);
|
|
82
|
+
|
|
83
|
+
// Each frame carries the ignore-rule chain inherited from its ancestors;
|
|
84
|
+
// rules from deeper .gitignore files are appended after (later rules win).
|
|
85
|
+
const stack: { dir: string; rel: string; rules: readonly IgnoreRule[] }[] = [
|
|
86
|
+
{ dir: root, rel: "", rules: [] },
|
|
87
|
+
];
|
|
88
|
+
const seenDirs = new Set<string>(); // resolved real dirs already walked
|
|
89
|
+
walking: while (stack.length) {
|
|
90
|
+
const frame = stack.pop()!;
|
|
91
|
+
// Cycle guard: a directory symlink pointing at an ancestor would otherwise
|
|
92
|
+
// make walk() loop, flooding the index with phantom duplicate files. Resolve
|
|
93
|
+
// the real path and skip any directory we've already descended into.
|
|
94
|
+
let real: string;
|
|
95
|
+
try {
|
|
96
|
+
real = realpathSync(frame.dir);
|
|
97
|
+
} catch {
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
if (seenDirs.has(real)) continue;
|
|
101
|
+
seenDirs.add(real);
|
|
102
|
+
if (!contained(real)) continue; // dir symlink escaping the repo
|
|
103
|
+
let entries: string[];
|
|
104
|
+
try {
|
|
105
|
+
// Sorted so the walk order — and therefore WHICH files survive a
|
|
106
|
+
// maxFiles cap — is identical across filesystems and machines.
|
|
107
|
+
entries = readdirSync(frame.dir).sort();
|
|
108
|
+
} catch {
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
let rules = frame.rules;
|
|
112
|
+
if (useGitignore && entries.includes(".gitignore")) {
|
|
113
|
+
const parsed = parseGitignore(readText(join(frame.dir, ".gitignore")), frame.rel);
|
|
114
|
+
if (parsed.length) rules = [...rules, ...parsed];
|
|
115
|
+
}
|
|
116
|
+
for (const name of entries) {
|
|
117
|
+
const abs = join(frame.dir, name);
|
|
118
|
+
const rel = frame.rel ? `${frame.rel}/${name}` : name;
|
|
119
|
+
let st;
|
|
120
|
+
let isLink: boolean;
|
|
121
|
+
try {
|
|
122
|
+
st = statSync(abs);
|
|
123
|
+
isLink = lstatSync(abs).isSymbolicLink();
|
|
124
|
+
} catch {
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
if (st.isDirectory()) {
|
|
128
|
+
if (IGNORE_DIRS.has(name)) continue;
|
|
129
|
+
// An in-repo DIRECTORY symlink is skipped entirely: its target is (or
|
|
130
|
+
// will be) walked under its canonical name, and letting both paths race
|
|
131
|
+
// through the cycle guard would keep whichever readdir served first —
|
|
132
|
+
// aliased, filesystem-order-dependent indexes. Out-of-repo links are
|
|
133
|
+
// covered by the containment guard above.
|
|
134
|
+
if (isLink) continue;
|
|
135
|
+
if (useGitignore && rules.length && isIgnored(rules, rel, true)) continue;
|
|
136
|
+
stack.push({ dir: abs, rel, rules });
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
if (!st.isFile()) continue;
|
|
140
|
+
// Each rejection below is a file the walk SAW and dropped — counted in
|
|
141
|
+
// `excluded` so consumers can report how much was filtered, not capped.
|
|
142
|
+
if (st.size > maxFileBytes) {
|
|
143
|
+
excluded++;
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
if (LOCKFILES.has(name.toLowerCase())) {
|
|
147
|
+
excluded++;
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
const ext = extname(name).toLowerCase();
|
|
151
|
+
if (BINARY_EXT.has(ext)) {
|
|
152
|
+
excluded++;
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
if (name.endsWith(".min.js") || name.endsWith(".min.css")) {
|
|
156
|
+
excluded++;
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
if (useGitignore && rules.length && isIgnored(rules, rel, false)) {
|
|
160
|
+
excluded++;
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
// Symlink-escape guard for files (statSync above follows links).
|
|
164
|
+
if (isLink) {
|
|
165
|
+
try {
|
|
166
|
+
if (!contained(realpathSync(abs))) continue;
|
|
167
|
+
} catch {
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
// The cap is enforced HERE, on kept files, so a flat directory cannot
|
|
172
|
+
// silently overshoot it and `capped` is set exactly when a file was
|
|
173
|
+
// actually dropped (never a silent truncation).
|
|
174
|
+
if (out.length >= maxFiles) {
|
|
175
|
+
capped = true;
|
|
176
|
+
break walking;
|
|
177
|
+
}
|
|
178
|
+
out.push({ rel: rel.split(sep).join("/"), abs, size: st.size, ext, mtimeMs: st.mtimeMs });
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return { files: out, capped, excluded };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Read a file as text, returning "" on any error (unreadable, vanished). Honours
|
|
185
|
+
// a Unicode BOM before the binary sniff — a UTF-16 source file is full of NUL
|
|
186
|
+
// bytes and would otherwise be misread as binary and dropped, and a UTF-8 BOM
|
|
187
|
+
// would otherwise glue "" onto the first token (breaking line-1 extraction
|
|
188
|
+
// and a `[file:1]` citation). Otherwise UTF-8, with a Latin-1 fallback and a
|
|
189
|
+
// whole-buffer NUL sniff for genuinely-binary content.
|
|
190
|
+
export function readText(abs: string): string {
|
|
191
|
+
try {
|
|
192
|
+
const buf = readFileSync(abs);
|
|
193
|
+
// UTF-16LE/BE BOM. Truncate to an even byte length first so an odd trailing
|
|
194
|
+
// byte can't make swap16() throw (toString already tolerates it; mirror that).
|
|
195
|
+
if (buf.length >= 2 && buf[0] === 0xff && buf[1] === 0xfe) {
|
|
196
|
+
return buf.subarray(2, 2 + ((buf.length - 2) & ~1)).toString("utf16le");
|
|
197
|
+
}
|
|
198
|
+
if (buf.length >= 2 && buf[0] === 0xfe && buf[1] === 0xff) {
|
|
199
|
+
const swapped = Buffer.from(buf.subarray(2, 2 + ((buf.length - 2) & ~1)));
|
|
200
|
+
swapped.swap16(); // UTF-16BE → LE so Node can decode it
|
|
201
|
+
return swapped.toString("utf16le");
|
|
202
|
+
}
|
|
203
|
+
if (buf.length >= 3 && buf[0] === 0xef && buf[1] === 0xbb && buf[2] === 0xbf) return buf.subarray(3).toString("utf8");
|
|
204
|
+
// Binary sniff over the WHOLE buffer, not just the first 4 KiB — a NUL after
|
|
205
|
+
// 4 KiB still means binary (else the symbol right after it is dropped and the
|
|
206
|
+
// content hash is poisoned).
|
|
207
|
+
if (buf.includes(0)) return "";
|
|
208
|
+
const text = buf.toString("utf8");
|
|
209
|
+
// Invalid UTF-8 surfaces as U+FFFD; a Latin-1/Windows-1252 source decodes
|
|
210
|
+
// cleanly there (every byte maps to a code point), so prefer that over baking
|
|
211
|
+
// mojibake into symbols, signatures, and the content hash.
|
|
212
|
+
return text.includes("�") ? buf.toString("latin1") : text;
|
|
213
|
+
} catch {
|
|
214
|
+
return "";
|
|
215
|
+
}
|
|
216
|
+
}
|