@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/git.ts
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { sh } from "./util.js";
|
|
2
|
+
|
|
3
|
+
// The short HEAD commit of a working tree, when it is a git repo. Recorded in
|
|
4
|
+
// the manifest so an index is pinned to an exact revision. Returns undefined
|
|
5
|
+
// when `git` is absent or the directory isn't a repo — the index still works.
|
|
6
|
+
export function headCommit(dir: string): string | undefined {
|
|
7
|
+
const res = sh("git", ["-C", dir, "rev-parse", "--short", "HEAD"]);
|
|
8
|
+
return res.ok ? res.stdout.trim() : undefined;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// Diff plumbing for `delta`. All calls disable core.quotePath so non-ASCII
|
|
13
|
+
// paths come back verbatim, and use -z (NUL separators) wherever a path could
|
|
14
|
+
// contain anything surprising.
|
|
15
|
+
|
|
16
|
+
export interface DiffFile {
|
|
17
|
+
path: string;
|
|
18
|
+
status: "added" | "modified" | "deleted" | "renamed";
|
|
19
|
+
oldPath?: string; // renames only
|
|
20
|
+
binary?: boolean;
|
|
21
|
+
linesAdded?: number;
|
|
22
|
+
linesDeleted?: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// A changed line range on the NEW side of the diff. A pure deletion has no new
|
|
26
|
+
// lines, so it maps to the touch-point line and is flagged approx.
|
|
27
|
+
export interface Hunk {
|
|
28
|
+
start: number;
|
|
29
|
+
end: number;
|
|
30
|
+
approx?: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// The diff to take: a merge-base for branch review, or the staged changeset.
|
|
34
|
+
export interface DiffSpec {
|
|
35
|
+
mergeBase?: string;
|
|
36
|
+
staged?: boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const gitArgs = (dir: string): string[] => ["-C", dir, "-c", "core.quotePath=false"];
|
|
40
|
+
const rangeArgs = (spec: DiffSpec): string[] => (spec.staged ? ["--cached"] : [spec.mergeBase!]);
|
|
41
|
+
|
|
42
|
+
export function isGitWorktree(dir: string): boolean {
|
|
43
|
+
return sh("git", ["-C", dir, "rev-parse", "--is-inside-work-tree"]).ok;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Resolve the review base. An explicit ref must exist; otherwise the first of
|
|
47
|
+
// origin/HEAD → origin/main → origin/master → main → master that resolves is
|
|
48
|
+
// taken, and the comparison point is its MERGE-BASE with HEAD (PR semantics —
|
|
49
|
+
// commits landed on the base branch never count as yours). With no candidate
|
|
50
|
+
// (fresh repo, detached CI clone) the base falls back to HEAD with a note.
|
|
51
|
+
export function resolveBaseRef(
|
|
52
|
+
dir: string,
|
|
53
|
+
base?: string,
|
|
54
|
+
): { ref: string; mergeBase: string; note?: string } | { error: string } {
|
|
55
|
+
const verify = (ref: string): boolean =>
|
|
56
|
+
sh("git", [...gitArgs(dir), "rev-parse", "--verify", "--quiet", `${ref}^{commit}`]).ok;
|
|
57
|
+
const mergeBase = (ref: string): string | undefined => {
|
|
58
|
+
const mb = sh("git", [...gitArgs(dir), "merge-base", ref, "HEAD"]);
|
|
59
|
+
return mb.ok ? mb.stdout.trim() : undefined;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
if (base) {
|
|
63
|
+
if (!verify(base)) return { error: `base ref "${base}" not found (tried git rev-parse --verify)` };
|
|
64
|
+
const mb = mergeBase(base);
|
|
65
|
+
if (!mb) return { error: `no merge-base between "${base}" and HEAD` };
|
|
66
|
+
return { ref: base, mergeBase: mb };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const originHead = sh("git", [...gitArgs(dir), "symbolic-ref", "--quiet", "refs/remotes/origin/HEAD"]);
|
|
70
|
+
const candidates = [
|
|
71
|
+
...(originHead.ok ? [originHead.stdout.trim().replace("refs/remotes/", "")] : []),
|
|
72
|
+
"origin/main",
|
|
73
|
+
"origin/master",
|
|
74
|
+
"main",
|
|
75
|
+
"master",
|
|
76
|
+
];
|
|
77
|
+
for (const c of candidates) {
|
|
78
|
+
if (!verify(c)) continue;
|
|
79
|
+
const mb = mergeBase(c);
|
|
80
|
+
if (mb) return { ref: c, mergeBase: mb };
|
|
81
|
+
}
|
|
82
|
+
const head = sh("git", [...gitArgs(dir), "rev-parse", "HEAD"]);
|
|
83
|
+
if (!head.ok) return { error: "cannot resolve HEAD — empty repository?" };
|
|
84
|
+
return {
|
|
85
|
+
ref: "HEAD",
|
|
86
|
+
mergeBase: head.stdout.trim(),
|
|
87
|
+
note: "base: HEAD (no default branch found — reviewing uncommitted work)",
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Changed files with statuses (rename-aware) plus per-file churn/binary info
|
|
92
|
+
// from --numstat. Default spec compares the merge-base against the WORKTREE —
|
|
93
|
+
// committed + staged + unstaged, "review my branch as it sits".
|
|
94
|
+
export function diffFiles(dir: string, spec: DiffSpec): DiffFile[] {
|
|
95
|
+
const out: DiffFile[] = [];
|
|
96
|
+
const ns = sh("git", [...gitArgs(dir), "diff", "-z", "-M", "--name-status", ...rangeArgs(spec)]);
|
|
97
|
+
if (ns.ok) {
|
|
98
|
+
const toks = ns.stdout.split("\0");
|
|
99
|
+
let i = 0;
|
|
100
|
+
while (i < toks.length) {
|
|
101
|
+
const st = toks[i++];
|
|
102
|
+
if (!st) break;
|
|
103
|
+
const code = st[0]!;
|
|
104
|
+
if (code === "R" || code === "C") {
|
|
105
|
+
const oldPath = toks[i++];
|
|
106
|
+
const path = toks[i++];
|
|
107
|
+
if (path) out.push({ path, status: "renamed", oldPath });
|
|
108
|
+
} else {
|
|
109
|
+
const path = toks[i++];
|
|
110
|
+
if (!path) break;
|
|
111
|
+
const status = code === "A" ? "added" : code === "D" ? "deleted" : "modified"; // M/T/U fold into modified
|
|
112
|
+
out.push({ path, status });
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const byPath = new Map(out.map((f) => [f.path, f]));
|
|
118
|
+
const num = sh("git", [...gitArgs(dir), "diff", "-z", "-M", "--numstat", ...rangeArgs(spec)]);
|
|
119
|
+
if (num.ok) {
|
|
120
|
+
const toks = num.stdout.split("\0");
|
|
121
|
+
let i = 0;
|
|
122
|
+
while (i < toks.length) {
|
|
123
|
+
const head = toks[i++];
|
|
124
|
+
if (!head) break;
|
|
125
|
+
const m = head.match(/^(-|\d+)\t(-|\d+)\t([\s\S]*)$/);
|
|
126
|
+
if (!m) continue;
|
|
127
|
+
let path = m[3]!;
|
|
128
|
+
if (path === "") {
|
|
129
|
+
i++; // rename record: skip the old-path token
|
|
130
|
+
path = toks[i++] ?? "";
|
|
131
|
+
}
|
|
132
|
+
const rec = byPath.get(path);
|
|
133
|
+
if (!rec) continue;
|
|
134
|
+
if (m[1] === "-") rec.binary = true;
|
|
135
|
+
else {
|
|
136
|
+
rec.linesAdded = Number(m[1]);
|
|
137
|
+
rec.linesDeleted = Number(m[2]);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return out;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Changed NEW-side line ranges per file, from one --unified=0 diff call. Pure
|
|
145
|
+
// deletions map to their touch-point line, flagged approx. Files with no
|
|
146
|
+
// content hunks (pure renames, binaries) are absent.
|
|
147
|
+
export function diffHunks(dir: string, spec: DiffSpec): Map<string, Hunk[]> {
|
|
148
|
+
const map = new Map<string, Hunk[]>();
|
|
149
|
+
const res = sh("git", [...gitArgs(dir), "diff", "-M", "--unified=0", ...rangeArgs(spec)]);
|
|
150
|
+
if (!res.ok) return map;
|
|
151
|
+
let current: Hunk[] | undefined;
|
|
152
|
+
for (const line of res.stdout.split("\n")) {
|
|
153
|
+
if (line.startsWith("+++ ")) {
|
|
154
|
+
const p = line.slice(4).trim();
|
|
155
|
+
if (p === "/dev/null") {
|
|
156
|
+
current = undefined;
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
const path = p.startsWith("b/") ? p.slice(2) : p;
|
|
160
|
+
current = map.get(path) ?? [];
|
|
161
|
+
map.set(path, current);
|
|
162
|
+
} else if (current && line.startsWith("@@")) {
|
|
163
|
+
const m = line.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/);
|
|
164
|
+
if (!m) continue;
|
|
165
|
+
const start = Number(m[1]);
|
|
166
|
+
const count = m[2] === undefined ? 1 : Number(m[2]);
|
|
167
|
+
if (count === 0) current.push({ start: Math.max(start, 1), end: Math.max(start, 1), approx: true });
|
|
168
|
+
else current.push({ start, end: start + count - 1 });
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return map;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Untracked (but not ignored) files — part of "the branch as it sits".
|
|
175
|
+
export function untrackedFiles(dir: string): string[] {
|
|
176
|
+
const res = sh("git", [...gitArgs(dir), "ls-files", "--others", "--exclude-standard", "-z"]);
|
|
177
|
+
if (!res.ok) return [];
|
|
178
|
+
return res.stdout.split("\0").filter((p) => p.length > 0);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Per-file commit counts over the whole history (or since a ref) — the churn
|
|
182
|
+
// half of hotspot analysis. `ok: false` means git was unavailable or the dir
|
|
183
|
+
// isn't a repo: degrade loudly (callers can say "no churn data"), never
|
|
184
|
+
// silently return an empty map that reads as "nothing ever changed".
|
|
185
|
+
export function gitChurn(dir: string, opts: { since?: string } = {}): { churn: Map<string, number>; ok: boolean } {
|
|
186
|
+
const churn = new Map<string, number>();
|
|
187
|
+
const range = opts.since ? [`${opts.since}..HEAD`] : [];
|
|
188
|
+
const res = sh("git", [...gitArgs(dir), "log", ...range, "--pretty=format:", "--name-only", "-z"]);
|
|
189
|
+
if (!res.ok) return { churn, ok: false };
|
|
190
|
+
for (const tok of res.stdout.split("\0")) {
|
|
191
|
+
const f = tok.replace(/^\n+/, "").trim();
|
|
192
|
+
if (f) churn.set(f, (churn.get(f) ?? 0) + 1);
|
|
193
|
+
}
|
|
194
|
+
return { churn, ok: true };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Files changed since a ref (worktree vs ref) plus untracked files — "what did
|
|
198
|
+
// I touch". Returns an empty set outside a git repo.
|
|
199
|
+
export function changedSince(dir: string, ref: string): Set<string> {
|
|
200
|
+
const out = new Set<string>();
|
|
201
|
+
const diff = sh("git", [...gitArgs(dir), "diff", "-z", "--name-only", ref, "--"]);
|
|
202
|
+
if (diff.ok) for (const p of diff.stdout.split("\0")) if (p) out.add(p);
|
|
203
|
+
for (const p of untrackedFiles(dir)) out.add(p);
|
|
204
|
+
return out;
|
|
205
|
+
}
|
package/src/glob.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { escapeRegExp } from "./util.js";
|
|
2
|
+
|
|
3
|
+
// Minimal glob → RegExp for --include/--exclude. Supports `**` (any path,
|
|
4
|
+
// crossing `/`), `*` (any run within a segment), and `?` (one non-`/` char).
|
|
5
|
+
// Patterns match against the posix path relative to the repo root. Anything
|
|
6
|
+
// fancier (brace expansion, extglob) is intentionally out of scope — keep it
|
|
7
|
+
// dependency-free and predictable.
|
|
8
|
+
function globToRegExp(glob: string): RegExp {
|
|
9
|
+
let re = "";
|
|
10
|
+
for (let i = 0; i < glob.length; i++) {
|
|
11
|
+
const c = glob[i]!;
|
|
12
|
+
if (c === "*") {
|
|
13
|
+
if (glob[i + 1] === "*") {
|
|
14
|
+
// `**` — match across directory separators.
|
|
15
|
+
i++;
|
|
16
|
+
if (glob[i + 1] === "/") {
|
|
17
|
+
// `a/**/b` should also match `a/b` → the segment is optional.
|
|
18
|
+
i++;
|
|
19
|
+
re += "(?:.*/)?";
|
|
20
|
+
} else {
|
|
21
|
+
// Trailing `**` (e.g. `src/**`) must match everything beneath, files
|
|
22
|
+
// included. `(?:.*/)?` only matches dir-like paths ending in `/`, so a
|
|
23
|
+
// bare trailing `**` would match ZERO files — use `.*` instead.
|
|
24
|
+
re += ".*";
|
|
25
|
+
}
|
|
26
|
+
} else {
|
|
27
|
+
re += "[^/]*";
|
|
28
|
+
}
|
|
29
|
+
} else if (c === "?") {
|
|
30
|
+
re += "[^/]";
|
|
31
|
+
} else {
|
|
32
|
+
re += escapeRegExp(c);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return new RegExp(`^${re}$`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Compile a list of globs into a single predicate (matches if ANY glob matches).
|
|
39
|
+
// An empty/undefined list yields `null` so callers can skip the test entirely.
|
|
40
|
+
export function compileGlobs(globs: string[] | undefined): ((rel: string) => boolean) | null {
|
|
41
|
+
if (!globs || globs.length === 0) return null;
|
|
42
|
+
const res = globs.map(globToRegExp);
|
|
43
|
+
return (rel: string) => res.some((r) => r.test(rel));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Negation-aware variant: `!`-prefixed globs EXCLUDE. A path passes when it
|
|
47
|
+
// matches at least one positive glob (or none are given — negations alone
|
|
48
|
+
// mean "everything but") AND matches no negated glob. Exclusion wins over
|
|
49
|
+
// inclusion regardless of list order — grep.ts feeds ripgrep the same way
|
|
50
|
+
// (positives first, negations last) so both backends agree.
|
|
51
|
+
export function compileGlobFilter(globs: string[] | undefined): ((rel: string) => boolean) | null {
|
|
52
|
+
if (!globs || globs.length === 0) return null;
|
|
53
|
+
const include = compileGlobs(globs.filter((g) => !g.startsWith("!")));
|
|
54
|
+
const exclude = compileGlobs(globs.filter((g) => g.startsWith("!")).map((g) => g.slice(1)));
|
|
55
|
+
return (rel: string) => (!include || include(rel)) && !exclude?.(rel);
|
|
56
|
+
}
|
package/src/graph.ts
ADDED
|
Binary file
|
package/src/grep.ts
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// Repo text search: ripgrep when it's on PATH (fast), a pure-JS scan over
|
|
2
|
+
// walk() otherwise (always available). Both backends return the SAME shape,
|
|
3
|
+
// sorted by (file, line) with the cap applied after sorting, so a consumer
|
|
4
|
+
// cannot tell which backend ran — asserted by the backend-parity test.
|
|
5
|
+
import { walk, readText, IGNORE_DIRS, LOCKFILES, BINARY_EXT } from "./walk.js";
|
|
6
|
+
import { compileGlobFilter } from "./glob.js";
|
|
7
|
+
import { sh, have } from "./util.js";
|
|
8
|
+
import { byStr } from "./sort.js";
|
|
9
|
+
|
|
10
|
+
export interface SearchHit {
|
|
11
|
+
file: string; // repo-relative posix path
|
|
12
|
+
line: number; // 1-based
|
|
13
|
+
text: string; // the matching line, trimmed of the trailing newline
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface GrepOptions {
|
|
17
|
+
// Restrict to matching paths (repo-relative, rooted dialect). A `!` prefix
|
|
18
|
+
// NEGATES the glob: `!sub/**` excludes that tree; exclusion beats inclusion
|
|
19
|
+
// regardless of list order, identically on both backends.
|
|
20
|
+
globs?: string[];
|
|
21
|
+
maxHits?: number; // cap AFTER sorting (default 200)
|
|
22
|
+
ignoreCase?: boolean;
|
|
23
|
+
// Force the JS backend even when ripgrep is available (tests, determinism).
|
|
24
|
+
noRipgrep?: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const DEFAULT_MAX_HITS = 200;
|
|
28
|
+
|
|
29
|
+
function sortHits(hits: SearchHit[]): SearchHit[] {
|
|
30
|
+
return hits.sort((a, b) => byStr(a.file, b.file) || a.line - b.line);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function rgBackend(root: string, pattern: string, opts: GrepOptions): SearchHit[] | undefined {
|
|
34
|
+
// Align ripgrep's universe with walk()'s exactly: search hidden files (the
|
|
35
|
+
// walker does), honor .gitignore even outside a git worktree, but NOT the
|
|
36
|
+
// user/global/exclude/parent/.ignore layers the walker never reads; skip the
|
|
37
|
+
// same junk dirs, lockfiles and binary extensions; same 1 MiB size cap.
|
|
38
|
+
const args = [
|
|
39
|
+
"--no-heading",
|
|
40
|
+
"--line-number",
|
|
41
|
+
"--null", // path\0line:text — a `:12:` inside a filename can't corrupt parsing
|
|
42
|
+
"--color=never",
|
|
43
|
+
"--no-messages",
|
|
44
|
+
"--hidden",
|
|
45
|
+
"--no-require-git",
|
|
46
|
+
"--no-ignore-global",
|
|
47
|
+
"--no-ignore-exclude",
|
|
48
|
+
"--no-ignore-parent",
|
|
49
|
+
"--no-ignore-dot",
|
|
50
|
+
"--max-filesize",
|
|
51
|
+
"1M",
|
|
52
|
+
];
|
|
53
|
+
for (const d of IGNORE_DIRS) args.push("--glob", `!**/${d}/**`);
|
|
54
|
+
// Lockfiles match at any depth, case-insensitively (the walker lowercases).
|
|
55
|
+
for (const l of LOCKFILES) args.push("--iglob", `!**/${l}`);
|
|
56
|
+
for (const ext of BINARY_EXT) args.push("--iglob", `!**/*${ext}`);
|
|
57
|
+
args.push("--glob", "!*.min.js", "--glob", "!*.min.css");
|
|
58
|
+
if (opts.ignoreCase) args.push("--ignore-case");
|
|
59
|
+
// User globs use the engine's rooted dialect (compileGlobFilter anchors at
|
|
60
|
+
// the repo root); a leading `/` makes rg anchor the same way instead of
|
|
61
|
+
// basename-matching slash-less patterns. Positives go first and `!`-negated
|
|
62
|
+
// globs LAST: rg resolves overlapping globs by last-match-wins, so this
|
|
63
|
+
// ordering makes exclusion beat inclusion — the JS backend's semantics —
|
|
64
|
+
// regardless of how the caller ordered the list.
|
|
65
|
+
const user = opts.globs ?? [];
|
|
66
|
+
const anchor = (g: string): string => (g.startsWith("/") ? g : `/${g}`);
|
|
67
|
+
for (const g of user.filter((g) => !g.startsWith("!"))) args.push("--glob", anchor(g));
|
|
68
|
+
for (const g of user.filter((g) => g.startsWith("!"))) args.push("--glob", `!${anchor(g.slice(1))}`);
|
|
69
|
+
args.push("--regexp", pattern, "./");
|
|
70
|
+
const res = sh("rg", args, { cwd: root });
|
|
71
|
+
// status 1 = no matches (fine); anything else (crash, unsupported flag on an
|
|
72
|
+
// old rg) → let the JS backend give the authoritative answer instead.
|
|
73
|
+
if (res.missing || (!res.ok && res.status !== 1)) return undefined;
|
|
74
|
+
const hits: SearchHit[] = [];
|
|
75
|
+
for (const line of res.stdout.split("\n")) {
|
|
76
|
+
if (!line) continue;
|
|
77
|
+
const nul = line.indexOf("\0");
|
|
78
|
+
if (nul === -1) continue;
|
|
79
|
+
const file = line.slice(0, nul).replace(/^\.\//, "");
|
|
80
|
+
const rest = line.slice(nul + 1);
|
|
81
|
+
const colon = rest.indexOf(":");
|
|
82
|
+
if (colon === -1) continue;
|
|
83
|
+
hits.push({ file, line: Number(rest.slice(0, colon)), text: rest.slice(colon + 1) });
|
|
84
|
+
}
|
|
85
|
+
return hits;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function jsBackend(root: string, re: RegExp, opts: GrepOptions): SearchHit[] {
|
|
89
|
+
// Accept the same optionally-`/`-anchored spelling the rg path takes (the
|
|
90
|
+
// anchor may follow the `!` of a negated glob: `!/sub/**` ≡ `!sub/**`).
|
|
91
|
+
const filter = compileGlobFilter(opts.globs?.map((g) => g.replace(/^(!?)\//, "$1")));
|
|
92
|
+
const hits: SearchHit[] = [];
|
|
93
|
+
for (const f of walk(root).files) {
|
|
94
|
+
if (filter && !filter(f.rel)) continue;
|
|
95
|
+
const content = readText(f.abs);
|
|
96
|
+
if (!content) continue;
|
|
97
|
+
const lines = content.split("\n");
|
|
98
|
+
for (let i = 0; i < lines.length; i++) {
|
|
99
|
+
if (re.test(lines[i]!)) hits.push({ file: f.rel, line: i + 1, text: lines[i]! });
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return hits;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function grepRepo(root: string, pattern: string, opts: GrepOptions = {}): SearchHit[] {
|
|
106
|
+
// The pattern dialect is JS RegExp on BOTH backends: validate up front and
|
|
107
|
+
// throw the same error regardless of which backend runs, instead of one
|
|
108
|
+
// backend silently returning [] on syntax the other accepts.
|
|
109
|
+
const re = new RegExp(pattern, opts.ignoreCase ? "i" : "");
|
|
110
|
+
const max = opts.maxHits ?? DEFAULT_MAX_HITS;
|
|
111
|
+
let hits: SearchHit[] | undefined;
|
|
112
|
+
if (!opts.noRipgrep && have("rg")) hits = rgBackend(root, pattern, opts);
|
|
113
|
+
hits ??= jsBackend(root, re, opts);
|
|
114
|
+
return sortHits(hits).slice(0, max);
|
|
115
|
+
}
|
package/src/hash.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
// Stable content hash used for the manifest's staleness oracle and for the
|
|
4
|
+
// `ui:gen` region fingerprints. sha1 is plenty for change detection (not
|
|
5
|
+
// security) and keeps the manifest compact.
|
|
6
|
+
export function sha1(s: string): string {
|
|
7
|
+
return createHash("sha1").update(s).digest("hex");
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// A short fingerprint for embedding in region fences without bloating the file.
|
|
11
|
+
export function shortHash(s: string, n = 8): string {
|
|
12
|
+
return sha1(s).slice(0, n);
|
|
13
|
+
}
|
package/src/ignore.ts
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// .gitignore support for the walker. Semantics follow git's core rules: per-
|
|
2
|
+
// directory files apply to their subtree, later rules win, `!` negates, a
|
|
3
|
+
// trailing `/` restricts to directories, a `/` anywhere else anchors the
|
|
4
|
+
// pattern to the .gitignore's own directory, `*`/`**`/`?` glob (never crossing
|
|
5
|
+
// `/` except `**`), `[...]` character classes, and `\x` fnmatch escapes.
|
|
6
|
+
// Verified by differential testing against `git check-ignore`. Two deliberate
|
|
7
|
+
// deviations: (1) once a directory is ignored the walk never descends into it,
|
|
8
|
+
// so a negation cannot re-include a file inside it — git behaves the same;
|
|
9
|
+
// (2) matching is ALWAYS case-sensitive (gitignore(5) semantics) even where
|
|
10
|
+
// git's platform default is core.ignorecase=true — a platform-dependent match
|
|
11
|
+
// would break cross-machine byte-identical builds.
|
|
12
|
+
import { escapeRegExp } from "./util.js";
|
|
13
|
+
|
|
14
|
+
export interface IgnoreRule {
|
|
15
|
+
re: RegExp; // tested against the path RELATIVE TO THE REPO ROOT (posix)
|
|
16
|
+
negated: boolean;
|
|
17
|
+
dirOnly: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Compile one gitignore pattern segment-wise. Differs from glob.ts: `**` here
|
|
21
|
+
// follows gitignore's spec (`**/` leading, `/**` trailing, `/**/` mid-pattern),
|
|
22
|
+
// `\x` escapes the next character (fnmatch), and `[...]` character classes are
|
|
23
|
+
// supported (`*.py[cod]`, `[Tt]humbs.db`).
|
|
24
|
+
function patternToRegExpSource(pattern: string): string {
|
|
25
|
+
let re = "";
|
|
26
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
27
|
+
const c = pattern[i]!;
|
|
28
|
+
if (c === "\\" && i + 1 < pattern.length) {
|
|
29
|
+
// fnmatch escaping: the next character is literal (`\*`, `\?`, `\ `, `\\`).
|
|
30
|
+
re += escapeRegExp(pattern[++i]!);
|
|
31
|
+
} else if (c === "*") {
|
|
32
|
+
if (pattern[i + 1] === "*") {
|
|
33
|
+
// `**` is special only at segment boundaries (`**/`, `/**`, `/**/`,
|
|
34
|
+
// or the whole pattern); anywhere else — `a**b` — git treats each
|
|
35
|
+
// star as a regular single-segment `*`.
|
|
36
|
+
const atStart = i === 0 || pattern[i - 1] === "/";
|
|
37
|
+
let j = i;
|
|
38
|
+
while (pattern[j + 1] === "*") j++;
|
|
39
|
+
const next = pattern[j + 1];
|
|
40
|
+
if (atStart && next === "/") {
|
|
41
|
+
i = j + 1;
|
|
42
|
+
re += "(?:[^/]+/)*"; // `**/` — zero or more whole segments
|
|
43
|
+
} else if (atStart && next === undefined) {
|
|
44
|
+
i = j;
|
|
45
|
+
re += ".*"; // trailing `/**` or bare `**` — anything, crossing `/`
|
|
46
|
+
} else {
|
|
47
|
+
i = j;
|
|
48
|
+
re += "[^/]*"; // mid-segment run of stars — one segment, like `*`
|
|
49
|
+
}
|
|
50
|
+
} else {
|
|
51
|
+
re += "[^/]*";
|
|
52
|
+
}
|
|
53
|
+
} else if (c === "?") {
|
|
54
|
+
re += "[^/]";
|
|
55
|
+
} else if (c === "[") {
|
|
56
|
+
// Character class: consume up to the closing `]` (a leading `]` is
|
|
57
|
+
// literal, `!` negates). Falls back to a literal `[` when unclosed.
|
|
58
|
+
let j = i + 1;
|
|
59
|
+
let body = "";
|
|
60
|
+
if (pattern[j] === "!") {
|
|
61
|
+
body += "^";
|
|
62
|
+
j++;
|
|
63
|
+
}
|
|
64
|
+
if (pattern[j] === "]") {
|
|
65
|
+
body += "\\]";
|
|
66
|
+
j++;
|
|
67
|
+
}
|
|
68
|
+
while (j < pattern.length && pattern[j] !== "]") {
|
|
69
|
+
const ch = pattern[j]!;
|
|
70
|
+
body += ch === "\\" || ch === "^" ? "\\" + ch : ch;
|
|
71
|
+
j++;
|
|
72
|
+
}
|
|
73
|
+
if (j < pattern.length && body !== "" && body !== "^") {
|
|
74
|
+
re += `[${body}]`;
|
|
75
|
+
i = j;
|
|
76
|
+
} else {
|
|
77
|
+
re += "\\[";
|
|
78
|
+
}
|
|
79
|
+
} else {
|
|
80
|
+
re += escapeRegExp(c);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return re;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Parse one .gitignore file. `baseRel` is the directory holding the file,
|
|
87
|
+
// relative to the repo root ("" for the root .gitignore), posix-style.
|
|
88
|
+
export function parseGitignore(content: string, baseRel: string): IgnoreRule[] {
|
|
89
|
+
const rules: IgnoreRule[] = [];
|
|
90
|
+
const prefix = baseRel ? escapeRegExp(baseRel) + "/" : "";
|
|
91
|
+
for (const rawLine of content.split(/\r?\n/)) {
|
|
92
|
+
// Trailing SPACES are ignored unless backslash-escaped (git trims only
|
|
93
|
+
// 0x20 — a trailing tab is significant). Blank lines and comments carry no
|
|
94
|
+
// rule. Escapes (`\ `, `\*`, `\#`…) are consumed by the pattern compiler.
|
|
95
|
+
let line = rawLine.replace(/(?<!\\) +$/, "");
|
|
96
|
+
if (!line || line.startsWith("#")) continue;
|
|
97
|
+
let negated = false;
|
|
98
|
+
if (line.startsWith("!")) {
|
|
99
|
+
negated = true;
|
|
100
|
+
line = line.slice(1);
|
|
101
|
+
}
|
|
102
|
+
let dirOnly = false;
|
|
103
|
+
if (line.endsWith("/")) {
|
|
104
|
+
dirOnly = true;
|
|
105
|
+
line = line.slice(0, -1);
|
|
106
|
+
}
|
|
107
|
+
if (!line) continue;
|
|
108
|
+
// A slash anywhere (leading or interior) anchors the pattern to the base
|
|
109
|
+
// directory; otherwise it floats to any depth beneath it.
|
|
110
|
+
const anchored = line.includes("/");
|
|
111
|
+
if (line.startsWith("/")) line = line.slice(1);
|
|
112
|
+
const body = patternToRegExpSource(line);
|
|
113
|
+
const source = anchored ? `^${prefix}${body}$` : `^${prefix}(?:[^/]+/)*${body}$`;
|
|
114
|
+
try {
|
|
115
|
+
rules.push({ re: new RegExp(source), negated, dirOnly });
|
|
116
|
+
} catch {
|
|
117
|
+
// An unparsable pattern is dropped rather than crashing the walk.
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return rules;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Decide whether `rel` (posix, repo-root-relative) is ignored under an ordered
|
|
124
|
+
// rule chain (root rules first, deeper .gitignore rules appended after — which
|
|
125
|
+
// realizes "later rules win" across nesting levels too). Returns the verdict of
|
|
126
|
+
// the LAST matching rule, or false when none match.
|
|
127
|
+
export function isIgnored(rules: readonly IgnoreRule[], rel: string, isDir: boolean): boolean {
|
|
128
|
+
let ignored = false;
|
|
129
|
+
for (const rule of rules) {
|
|
130
|
+
if (rule.dirOnly && !isDir) continue;
|
|
131
|
+
if (rule.re.test(rel)) ignored = !rule.negated;
|
|
132
|
+
}
|
|
133
|
+
return ignored;
|
|
134
|
+
}
|
package/src/lang/c.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { CodeSymbol } from "../types.js";
|
|
2
|
+
import { scan, type Rule } from "./common.js";
|
|
3
|
+
|
|
4
|
+
// C / C++. Regex heuristics: function definitions (a return type then `name(…)`
|
|
5
|
+
// with the line ending in an opening brace or nothing — not a `;` prototype or a
|
|
6
|
+
// control statement), plus type declarations. Conservative to avoid matching
|
|
7
|
+
// calls and macros.
|
|
8
|
+
const NOT_KEYWORD = "(?!\\s*(?:if|for|while|switch|return|else|do|sizeof|typedef)\\b)";
|
|
9
|
+
|
|
10
|
+
const RULES: Rule[] = [
|
|
11
|
+
// C++ types
|
|
12
|
+
{ re: /^\s*(?:class|struct)\s+(?<name>[A-Za-z_]\w+)\s*(?:[:{]|$)/, kind: "class", exported: true },
|
|
13
|
+
{ re: /^\s*namespace\s+(?<name>[A-Za-z_]\w+)/, kind: "namespace", exported: true },
|
|
14
|
+
// typedef struct/enum/union NAME {
|
|
15
|
+
{ re: /^\s*(?:typedef\s+)?(?:struct|enum|union)\s+(?<name>[A-Za-z_]\w+)\s*\{/, kind: "struct", exported: true },
|
|
16
|
+
// function definition: <type ...> name(<args>) [const] {? at column 0-ish
|
|
17
|
+
{ re: new RegExp(`^${NOT_KEYWORD}[A-Za-z_][\\w\\s\\*&<>:,]*?\\b(?<name>[A-Za-z_]\\w+)\\s*\\([^;{]*\\)\\s*(?:const)?\\s*\\{?\\s*$`), kind: "function", exported: true },
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
export const c = {
|
|
21
|
+
lang: "c/cpp",
|
|
22
|
+
exts: [".c", ".h", ".cc", ".cpp", ".cxx", ".hpp", ".hh"],
|
|
23
|
+
extract(rel: string, content: string): CodeSymbol[] {
|
|
24
|
+
return scan(rel, content, rel.match(/\.(c|h)$/) ? "c" : "cpp", RULES);
|
|
25
|
+
},
|
|
26
|
+
};
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { CodeSymbol } from "../types.js";
|
|
2
|
+
|
|
3
|
+
// A line-level extraction rule. `re` must capture the symbol name in a named
|
|
4
|
+
// group `name` (or capture group 1). One symbol is emitted per matching line
|
|
5
|
+
// (first rule wins), which keeps the heuristics cheap and predictable.
|
|
6
|
+
export interface Rule {
|
|
7
|
+
re: RegExp;
|
|
8
|
+
kind: string;
|
|
9
|
+
exported?: boolean | ((m: RegExpExecArray, line: string) => boolean);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// Run a list of rules line-by-line over file content. Deterministic and
|
|
13
|
+
// zero-dep — no parser, no AST, no LLM. Good enough to locate declarations and
|
|
14
|
+
// rank them; ripgrep covers everything inside bodies.
|
|
15
|
+
export function scan(rel: string, content: string, lang: string, rules: Rule[]): CodeSymbol[] {
|
|
16
|
+
const out: CodeSymbol[] = [];
|
|
17
|
+
const lines = content.split(/\r?\n/);
|
|
18
|
+
for (let i = 0; i < lines.length; i++) {
|
|
19
|
+
const line = lines[i]!;
|
|
20
|
+
if (!line.trim()) continue;
|
|
21
|
+
for (const rule of rules) {
|
|
22
|
+
const m = rule.re.exec(line);
|
|
23
|
+
if (!m) continue;
|
|
24
|
+
const name = m.groups?.name ?? m[1];
|
|
25
|
+
if (!name) continue;
|
|
26
|
+
const exported =
|
|
27
|
+
typeof rule.exported === "function" ? rule.exported(m, line) : rule.exported ?? false;
|
|
28
|
+
out.push({
|
|
29
|
+
name,
|
|
30
|
+
kind: rule.kind,
|
|
31
|
+
file: rel,
|
|
32
|
+
line: i + 1,
|
|
33
|
+
signature: line.trim().slice(0, 200),
|
|
34
|
+
exported,
|
|
35
|
+
lang,
|
|
36
|
+
});
|
|
37
|
+
break;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Broad extension → language label table, used for the index's language
|
|
44
|
+
// histogram even when no symbol extractor exists for that language.
|
|
45
|
+
const EXT_LANG: Record<string, string> = {
|
|
46
|
+
".ts": "typescript", ".tsx": "typescript", ".mts": "typescript", ".cts": "typescript",
|
|
47
|
+
".js": "javascript", ".jsx": "javascript", ".mjs": "javascript", ".cjs": "javascript",
|
|
48
|
+
".py": "python", ".pyi": "python",
|
|
49
|
+
".go": "go",
|
|
50
|
+
".rb": "ruby", ".rake": "ruby",
|
|
51
|
+
".java": "java",
|
|
52
|
+
".rs": "rust",
|
|
53
|
+
".c": "c", ".h": "c", ".cc": "cpp", ".cpp": "cpp", ".cxx": "cpp", ".hpp": "cpp",
|
|
54
|
+
".cs": "csharp", ".php": "php", ".swift": "swift", ".kt": "kotlin", ".kts": "kotlin",
|
|
55
|
+
".scala": "scala", ".sc": "scala", ".clj": "clojure", ".ex": "elixir", ".exs": "elixir", ".erl": "erlang",
|
|
56
|
+
".hs": "haskell", ".dart": "dart", ".lua": "lua",
|
|
57
|
+
".sh": "shell", ".bash": "shell", ".zsh": "shell", ".ksh": "shell", ".fish": "shell",
|
|
58
|
+
".hh": "cpp", ".m": "objective-c", ".mm": "objective-c",
|
|
59
|
+
".sql": "sql", ".graphql": "graphql", ".gql": "graphql", ".proto": "protobuf",
|
|
60
|
+
".md": "markdown", ".mdx": "markdown", ".rst": "restructuredtext", ".txt": "text",
|
|
61
|
+
".json": "json", ".yaml": "yaml", ".yml": "yaml", ".toml": "toml", ".ini": "ini",
|
|
62
|
+
".html": "html", ".css": "css", ".scss": "scss", ".vue": "vue", ".svelte": "svelte",
|
|
63
|
+
".astro": "astro",
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export function extToLang(ext: string): string {
|
|
67
|
+
return EXT_LANG[ext] ?? "other";
|
|
68
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { CodeSymbol } from "../types.js";
|
|
2
|
+
import { scan, type Rule } from "./common.js";
|
|
3
|
+
|
|
4
|
+
// C#. `public`/`internal` mark the public surface. Covers types and methods.
|
|
5
|
+
const pub = (_m: RegExpExecArray, l: string) => /\b(public|internal)\b/.test(l);
|
|
6
|
+
|
|
7
|
+
const RULES: Rule[] = [
|
|
8
|
+
{ re: /^\s*(?:public|internal|protected|private)?\s*(?:static\s+|sealed\s+|abstract\s+|partial\s+)*(?:class|record)\s+(?<name>\w+)/, kind: "class", exported: pub },
|
|
9
|
+
{ re: /^\s*(?:public|internal|protected|private)?\s*(?:partial\s+)?interface\s+(?<name>\w+)/, kind: "interface", exported: pub },
|
|
10
|
+
{ re: /^\s*(?:public|internal|protected|private)?\s*(?:readonly\s+)?(?:ref\s+)?struct\s+(?<name>\w+)/, kind: "struct", exported: pub },
|
|
11
|
+
{ re: /^\s*(?:public|internal|protected|private)?\s*enum\s+(?<name>\w+)/, kind: "enum", exported: pub },
|
|
12
|
+
// method: a visibility modifier, a return type, then `name(`
|
|
13
|
+
{ re: /^\s*(?:public|internal|protected|private)\s+(?:static\s+|virtual\s+|override\s+|async\s+|sealed\s+|abstract\s+|new\s+)*[\w<>\[\],.?]+\s+(?<name>\w+)\s*(?:<[^>]*>)?\s*\(/, kind: "method", exported: pub },
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
export const csharp = {
|
|
17
|
+
lang: "csharp",
|
|
18
|
+
exts: [".cs"],
|
|
19
|
+
extract(rel: string, content: string): CodeSymbol[] {
|
|
20
|
+
return scan(rel, content, "csharp", RULES);
|
|
21
|
+
},
|
|
22
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { CodeSymbol } from "../types.js";
|
|
2
|
+
import { scan, type Rule } from "./common.js";
|
|
3
|
+
|
|
4
|
+
// Elixir. Modules and def/defp/defmacro. `defp` is private (not exported).
|
|
5
|
+
const RULES: Rule[] = [
|
|
6
|
+
{ re: /^\s*defmodule\s+(?<name>[\w.]+)/, kind: "module", exported: true },
|
|
7
|
+
{ re: /^\s*defp\s+(?<name>[\w?!]+)/, kind: "function", exported: false },
|
|
8
|
+
{ re: /^\s*def\s+(?<name>[\w?!]+)/, kind: "function", exported: true },
|
|
9
|
+
{ re: /^\s*defmacrop?\s+(?<name>[\w?!]+)/, kind: "macro", exported: true },
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
export const elixir = {
|
|
13
|
+
lang: "elixir",
|
|
14
|
+
exts: [".ex", ".exs"],
|
|
15
|
+
extract(rel: string, content: string): CodeSymbol[] {
|
|
16
|
+
return scan(rel, content, "elixir", RULES);
|
|
17
|
+
},
|
|
18
|
+
};
|