@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.
Files changed (82) hide show
  1. package/README.md +115 -0
  2. package/docs/MIGRATION.md +100 -0
  3. package/package.json +89 -0
  4. package/scripts/cli.mjs +10 -0
  5. package/scripts/engine.d.mts +618 -0
  6. package/scripts/engine.mjs +10655 -0
  7. package/scripts/grammars/bash.wasm +0 -0
  8. package/scripts/grammars/c.wasm +0 -0
  9. package/scripts/grammars/c_sharp.wasm +0 -0
  10. package/scripts/grammars/cpp.wasm +0 -0
  11. package/scripts/grammars/go.wasm +0 -0
  12. package/scripts/grammars/java.wasm +0 -0
  13. package/scripts/grammars/javascript.wasm +0 -0
  14. package/scripts/grammars/lua.wasm +0 -0
  15. package/scripts/grammars/php.wasm +0 -0
  16. package/scripts/grammars/python.wasm +0 -0
  17. package/scripts/grammars/ruby.wasm +0 -0
  18. package/scripts/grammars/rust.wasm +0 -0
  19. package/scripts/grammars/scala.wasm +0 -0
  20. package/scripts/grammars/tsx.wasm +0 -0
  21. package/scripts/grammars/typescript.wasm +0 -0
  22. package/scripts/grammars/web-tree-sitter.wasm +0 -0
  23. package/src/ast/extract.ts +713 -0
  24. package/src/ast/loader.ts +104 -0
  25. package/src/bm25.ts +156 -0
  26. package/src/callers.ts +0 -0
  27. package/src/calls.ts +131 -0
  28. package/src/categorize.ts +80 -0
  29. package/src/centrality.ts +148 -0
  30. package/src/classify.ts +53 -0
  31. package/src/cli-entry.ts +16 -0
  32. package/src/community.ts +345 -0
  33. package/src/complexity.ts +69 -0
  34. package/src/coupling.ts +0 -0
  35. package/src/deadcode.ts +46 -0
  36. package/src/edit.ts +93 -0
  37. package/src/engine-cli.ts +278 -0
  38. package/src/engine.ts +148 -0
  39. package/src/extract/code.ts +376 -0
  40. package/src/extract/markdown.ts +135 -0
  41. package/src/git.ts +205 -0
  42. package/src/glob.ts +56 -0
  43. package/src/graph.ts +0 -0
  44. package/src/grep.ts +115 -0
  45. package/src/hash.ts +13 -0
  46. package/src/ignore.ts +134 -0
  47. package/src/lang/c.ts +26 -0
  48. package/src/lang/common.ts +68 -0
  49. package/src/lang/csharp.ts +22 -0
  50. package/src/lang/elixir.ts +18 -0
  51. package/src/lang/go.ts +22 -0
  52. package/src/lang/java.ts +19 -0
  53. package/src/lang/js-ts.ts +112 -0
  54. package/src/lang/kotlin.ts +20 -0
  55. package/src/lang/lua.ts +18 -0
  56. package/src/lang/php.ts +24 -0
  57. package/src/lang/python.ts +22 -0
  58. package/src/lang/registry.ts +54 -0
  59. package/src/lang/ruby.ts +17 -0
  60. package/src/lang/rust.ts +22 -0
  61. package/src/lang/scala.ts +19 -0
  62. package/src/lang/shell.ts +17 -0
  63. package/src/lang/swift.ts +23 -0
  64. package/src/mcp.ts +512 -0
  65. package/src/memory.ts +75 -0
  66. package/src/modules.ts +128 -0
  67. package/src/pipeline.ts +59 -0
  68. package/src/query.ts +118 -0
  69. package/src/render/graph-json.ts +16 -0
  70. package/src/render/symbols-json.ts +79 -0
  71. package/src/repomap.ts +52 -0
  72. package/src/resolve.ts +950 -0
  73. package/src/rules.ts +249 -0
  74. package/src/scan.ts +172 -0
  75. package/src/sort.ts +12 -0
  76. package/src/surprise.ts +58 -0
  77. package/src/tests-map.ts +105 -0
  78. package/src/types.ts +201 -0
  79. package/src/util.ts +169 -0
  80. package/src/viz.ts +44 -0
  81. package/src/walk.ts +216 -0
  82. 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
+ }