@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/modules.ts
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { posix } from "node:path";
|
|
2
|
+
import type { FileRecord, Tier } from "./types.js";
|
|
3
|
+
import type { RepoScan } from "./scan.js";
|
|
4
|
+
import { slugify } from "./util.js";
|
|
5
|
+
import { sha1 } from "./hash.js";
|
|
6
|
+
import { byStr } from "./sort.js";
|
|
7
|
+
|
|
8
|
+
// A module is a directory's worth of files — the unit a developer thinks in
|
|
9
|
+
// ("src/lang is the language extractors"). Grouping by immediate parent dir is
|
|
10
|
+
// deterministic and maps cleanly onto how repos are organized.
|
|
11
|
+
export interface ModuleInfo {
|
|
12
|
+
slug: string;
|
|
13
|
+
path: string; // directory path, or "(root)" for top-level files
|
|
14
|
+
title: string;
|
|
15
|
+
tier: Tier;
|
|
16
|
+
members: string[]; // member file rels, sorted
|
|
17
|
+
summary: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const ROOT_PATH = "(root)";
|
|
21
|
+
|
|
22
|
+
// Foundations: shared building blocks features depend on (matched on the leaf).
|
|
23
|
+
const TIER0 = /(^|\/)(types?|util|utils|lib|libs|common|core|config|configs|constants|shared|helpers|internal)$/i;
|
|
24
|
+
// Tail: supporting material, not the product itself. Matched on ANY path
|
|
25
|
+
// segment — `a/__tests__/b` is still test material, not a feature. Covers both
|
|
26
|
+
// singular/plural jest dunder dirs and e2e suites.
|
|
27
|
+
const TIER2_ANY = /(^|\/)(tests?|__tests?__|__mocks?__|__snapshots?__|spec|specs|e2e|examples?|example|benchmark|benchmarks|fixtures?|docs?|documentation|\.github)(\/|$)/i;
|
|
28
|
+
// Scripts/CI only count as tail when they're the leaf (a `scripts/` dir), not
|
|
29
|
+
// when "scripts" merely appears mid-path.
|
|
30
|
+
const TIER2_LEAF = /(^|\/)(scripts?|bin|\.storybook)$/i;
|
|
31
|
+
// A file that is itself a test/spec/e2e, by basename — so a directory of only
|
|
32
|
+
// these is tail even when its own name is unconventional.
|
|
33
|
+
const TEST_FILE = /\.(test|spec|e2e|stories|story)\.[cm]?[jt]sx?$/i;
|
|
34
|
+
|
|
35
|
+
export function isTestFile(rel: string): boolean {
|
|
36
|
+
return TEST_FILE.test(rel.split("/").pop()!);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function dirOf(rel: string): string {
|
|
40
|
+
return rel.includes("/") ? posix.dirname(rel) : ROOT_PATH;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Path-only tier decision; returns null when the path alone is undecided.
|
|
44
|
+
export function tierForPath(path: string): Tier | null {
|
|
45
|
+
if (path === ROOT_PATH) return 0; // root manifests/READMEs are foundational context
|
|
46
|
+
if (TIER2_ANY.test(path) || TIER2_LEAF.test(path)) return 2;
|
|
47
|
+
if (TIER0.test(path)) return 0;
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function tierOf(path: string, members: FileRecord[]): Tier {
|
|
52
|
+
const byPath = tierForPath(path);
|
|
53
|
+
if (byPath !== null) return byPath;
|
|
54
|
+
// A directory that is entirely docs/config/tests is tail material regardless
|
|
55
|
+
// of its (unconventional) name.
|
|
56
|
+
if (members.every((m) => m.kind === "doc" || m.kind === "config" || isTestFile(m.rel))) return 2;
|
|
57
|
+
return 1;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Deterministic one-line summary for a module, with no model involvement:
|
|
61
|
+
// prefer a directory README/index's own summary, else the richest member's
|
|
62
|
+
// doc-comment, else a structural fallback.
|
|
63
|
+
function summaryOf(path: string, members: FileRecord[]): string {
|
|
64
|
+
const readme = members.find((m) => /^(readme|index)\.(md|mdx)$/i.test(m.rel.split("/").pop()!));
|
|
65
|
+
if (readme?.summary) return readme.summary;
|
|
66
|
+
if (readme?.title) return readme.title; // a README with only a title is still a better name than a file count
|
|
67
|
+
const withSummary = members
|
|
68
|
+
.filter((m) => m.summary)
|
|
69
|
+
.sort((a, b) => (b.summary?.length ?? 0) - (a.summary?.length ?? 0));
|
|
70
|
+
if (withSummary[0]?.summary) return withSummary[0].summary;
|
|
71
|
+
const langs = [...new Set(members.map((m) => m.lang))].filter((l) => l !== "other");
|
|
72
|
+
const where = path === ROOT_PATH ? "the repository root" : `\`${path}/\``;
|
|
73
|
+
return `${members.length} file(s) in ${where}${langs.length ? ` (${langs.slice(0, 3).join(", ")})` : ""}.`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Group all scanned files into modules. Returns the modules (sorted by slug) and
|
|
77
|
+
// a rel → slug lookup the graph builder uses to lift file edges to module edges.
|
|
78
|
+
export function buildModules(scan: RepoScan): {
|
|
79
|
+
modules: ModuleInfo[];
|
|
80
|
+
moduleOf: Map<string, string>;
|
|
81
|
+
} {
|
|
82
|
+
const byDir = new Map<string, FileRecord[]>();
|
|
83
|
+
for (const f of scan.files) {
|
|
84
|
+
const dir = dirOf(f.rel);
|
|
85
|
+
let list = byDir.get(dir);
|
|
86
|
+
if (!list) byDir.set(dir, (list = []));
|
|
87
|
+
list.push(f);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const dirs = [...byDir.keys()].sort(byStr);
|
|
91
|
+
// Slug per directory: slugify the path, but when that is empty (an all-non-ASCII
|
|
92
|
+
// dir) or collides with another dir (slugify truncates at 120 chars, and two
|
|
93
|
+
// packages can share a leaf dir name), append a short STABLE hash of the full
|
|
94
|
+
// path. This keeps slugs injective AND order-independent — the slug is the
|
|
95
|
+
// persistence key for human prose, and the old positional "module"/"-2" scheme
|
|
96
|
+
// shuffled (re-gluing prose onto the wrong module) when a sibling changed.
|
|
97
|
+
const baseOf = new Map<string, string>();
|
|
98
|
+
const baseCount = new Map<string, number>();
|
|
99
|
+
for (const dir of dirs) {
|
|
100
|
+
const b = dir === ROOT_PATH ? "root" : slugify(dir);
|
|
101
|
+
baseOf.set(dir, b);
|
|
102
|
+
baseCount.set(b, (baseCount.get(b) ?? 0) + 1);
|
|
103
|
+
}
|
|
104
|
+
const slugForDir = (dir: string): string => {
|
|
105
|
+
const b = baseOf.get(dir)!;
|
|
106
|
+
return b && baseCount.get(b) === 1 ? b : `${b || "module"}-${sha1(dir).slice(0, 8)}`;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const modules: ModuleInfo[] = [];
|
|
110
|
+
const moduleOf = new Map<string, string>();
|
|
111
|
+
for (const dir of dirs) {
|
|
112
|
+
const members = byDir.get(dir)!.slice().sort((a, b) => byStr(a.rel, b.rel));
|
|
113
|
+
const slug = slugForDir(dir);
|
|
114
|
+
const info: ModuleInfo = {
|
|
115
|
+
slug,
|
|
116
|
+
path: dir,
|
|
117
|
+
title: dir,
|
|
118
|
+
tier: tierOf(dir, members),
|
|
119
|
+
members: members.map((m) => m.rel),
|
|
120
|
+
summary: summaryOf(dir, members),
|
|
121
|
+
};
|
|
122
|
+
modules.push(info);
|
|
123
|
+
for (const m of members) moduleOf.set(m.rel, slug);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
modules.sort((a, b) => byStr(a.slug, b.slug));
|
|
127
|
+
return { modules, moduleOf };
|
|
128
|
+
}
|
package/src/pipeline.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { Graph, SymbolIndex } from "./types.js";
|
|
2
|
+
import { scanRepo, type RepoScan, type ScanOptions } from "./scan.js";
|
|
3
|
+
import { buildResolveContext } from "./resolve.js";
|
|
4
|
+
import { buildModules } from "./modules.js";
|
|
5
|
+
import { buildGraph } from "./graph.js";
|
|
6
|
+
import { detectCommunities } from "./community.js";
|
|
7
|
+
import { applyCentrality } from "./centrality.js";
|
|
8
|
+
import { computeTestMap } from "./tests-map.js";
|
|
9
|
+
import { computeSurprises } from "./surprise.js";
|
|
10
|
+
import { buildSymbolIndex, computeSymbolRefs } from "./render/symbols-json.js";
|
|
11
|
+
|
|
12
|
+
export interface BuildIndexOptions extends ScanOptions {
|
|
13
|
+
// Stamped into the graph a consumer persists — lets it carry its own
|
|
14
|
+
// version/schema instead of the engine's (see buildGraph).
|
|
15
|
+
meta?: { version?: string; schemaVersion?: number };
|
|
16
|
+
// Community ids from a previous build (manifest `communities` shape), so an
|
|
17
|
+
// unchanged partition keeps stable ids across rebuilds.
|
|
18
|
+
previousCommunities?: Record<string, string[]>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface IndexArtifacts {
|
|
22
|
+
scan: RepoScan;
|
|
23
|
+
graph: Graph;
|
|
24
|
+
symbols: SymbolIndex;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// The full deterministic pipeline in one call: scan → resolve → group → graph →
|
|
28
|
+
// communities → centrality → tests-map → surprises → symbol index. Mirrors the
|
|
29
|
+
// exact composition (and mutation order — it matters for byte-stable output)
|
|
30
|
+
// that ultraindex's build performs before its prose rendering.
|
|
31
|
+
export function buildIndexArtifacts(repo: string, opts: BuildIndexOptions = {}): IndexArtifacts {
|
|
32
|
+
const scan = scanRepo(repo, opts);
|
|
33
|
+
const ctx = buildResolveContext(scan);
|
|
34
|
+
const { modules, moduleOf } = buildModules(scan);
|
|
35
|
+
const graph = buildGraph(scan, ctx, modules, moduleOf, opts.meta);
|
|
36
|
+
|
|
37
|
+
const communities = detectCommunities(graph.modules, graph.moduleEdges, opts.previousCommunities);
|
|
38
|
+
for (const m of graph.modules) {
|
|
39
|
+
const id = communities.get(m.slug);
|
|
40
|
+
if (id !== undefined) m.community = id;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
applyCentrality(graph);
|
|
44
|
+
|
|
45
|
+
const testMap = computeTestMap(graph);
|
|
46
|
+
for (const f of graph.files) {
|
|
47
|
+
if (testMap.testFiles.has(f.rel)) f.testFile = true;
|
|
48
|
+
}
|
|
49
|
+
for (const m of graph.modules) {
|
|
50
|
+
const t = testMap.testedByModule.get(m.slug);
|
|
51
|
+
if (t?.length) m.testedBy = t;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const surprises = computeSurprises(graph);
|
|
55
|
+
if (surprises.length) graph.surprises = surprises;
|
|
56
|
+
|
|
57
|
+
const symbols = buildSymbolIndex(scan, computeSymbolRefs(scan));
|
|
58
|
+
return { scan, graph, symbols };
|
|
59
|
+
}
|
package/src/query.ts
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// Symbol-level query API (Serena-parity tier, static edition): overview of a
|
|
2
|
+
// file's symbols, name-path symbol lookup with optional bodies, and reference
|
|
3
|
+
// finding that merges line-precise caller sites with file-level identifier
|
|
4
|
+
// references. Everything is computed from the deterministic scan — no language
|
|
5
|
+
// server, no daemon; precision is honestly labeled per result so an agent
|
|
6
|
+
// knows when it is looking at compiler-grade truth vs a name-based match.
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import type { CodeSymbol } from "./types.js";
|
|
9
|
+
import type { RepoScan } from "./scan.js";
|
|
10
|
+
import { readText } from "./walk.js";
|
|
11
|
+
import { buildCallerIndex, type CallerSite } from "./callers.js";
|
|
12
|
+
import { uniqueSymbolDefs } from "./graph.js";
|
|
13
|
+
import { byStr } from "./sort.js";
|
|
14
|
+
|
|
15
|
+
const REFERENCE_KINDS = new Set(["reexport", "reexport-all", "default"]);
|
|
16
|
+
|
|
17
|
+
// All symbols declared in one file, in declaration order — the fastest way to
|
|
18
|
+
// understand a file without reading it.
|
|
19
|
+
export function symbolsOverview(scan: RepoScan, rel: string): CodeSymbol[] {
|
|
20
|
+
const f = scan.files.find((x) => x.rel === rel);
|
|
21
|
+
if (!f) return [];
|
|
22
|
+
return [...f.symbols].filter((s) => !REFERENCE_KINDS.has(s.kind)).sort((a, b) => a.line - b.line || byStr(a.name, b.name));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface SymbolMatch extends CodeSymbol {
|
|
26
|
+
body?: string; // the declaration's source lines (AST spans; absent without endLine unless the decl is one line)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface FindSymbolOptions {
|
|
30
|
+
substring?: boolean; // match name segments by inclusion instead of equality
|
|
31
|
+
includeBody?: boolean;
|
|
32
|
+
maxResults?: number; // default 50
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Look up symbols by name or name path ("Class/method" matches a `method`
|
|
36
|
+
// whose enclosing symbol is `Class`). Deterministic ordering: exact name
|
|
37
|
+
// matches first, then (file, line).
|
|
38
|
+
export function findSymbol(scan: RepoScan, namePath: string, opts: FindSymbolOptions = {}): SymbolMatch[] {
|
|
39
|
+
const segments = namePath.split("/").filter(Boolean);
|
|
40
|
+
if (!segments.length) return [];
|
|
41
|
+
const leaf = segments[segments.length - 1]!;
|
|
42
|
+
const parents = segments.slice(0, -1);
|
|
43
|
+
const matchName = (name: string, wanted: string): boolean =>
|
|
44
|
+
opts.substring ? name.toLowerCase().includes(wanted.toLowerCase()) : name === wanted;
|
|
45
|
+
|
|
46
|
+
const out: SymbolMatch[] = [];
|
|
47
|
+
for (const f of scan.files) {
|
|
48
|
+
for (const s of f.symbols) {
|
|
49
|
+
if (REFERENCE_KINDS.has(s.kind)) continue;
|
|
50
|
+
if (!matchName(s.name, leaf)) continue;
|
|
51
|
+
// Walk the parent chain (single level in practice — extractor records the
|
|
52
|
+
// enclosing symbol name) against the requested path suffix. Substring
|
|
53
|
+
// matching applies to the LAST segment only (Serena's contract): parent
|
|
54
|
+
// segments always match exactly.
|
|
55
|
+
if (parents.length) {
|
|
56
|
+
const parent = parents[parents.length - 1]!;
|
|
57
|
+
if (!s.parent || s.parent !== parent) continue;
|
|
58
|
+
}
|
|
59
|
+
out.push({ ...s });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
out.sort(
|
|
63
|
+
(a, b) => Number(b.name === leaf) - Number(a.name === leaf) || byStr(a.file, b.file) || a.line - b.line,
|
|
64
|
+
);
|
|
65
|
+
const capped = out.slice(0, opts.maxResults ?? 50);
|
|
66
|
+
if (opts.includeBody) {
|
|
67
|
+
for (const m of capped) {
|
|
68
|
+
const end = m.endLine ?? m.line;
|
|
69
|
+
const content = readText(join(scan.root, m.file));
|
|
70
|
+
if (!content) continue;
|
|
71
|
+
m.body = content.split("\n").slice(m.line - 1, end).join("\n");
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return capped;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface SymbolReferences {
|
|
78
|
+
defs: CodeSymbol[]; // where the name is declared
|
|
79
|
+
// Line-precise call sites bound by the caller index (family-gated, import-
|
|
80
|
+
// corroborated for JS/TS) — the highest-confidence reference tier.
|
|
81
|
+
callSites: CallerSite[];
|
|
82
|
+
// Files whose collected identifiers reference the name (AST idents / doc
|
|
83
|
+
// mentions) — file-level, name-based: may include homonym false positives.
|
|
84
|
+
referencingFiles: string[];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Who references this symbol? Merges the caller index (line-precise) with the
|
|
88
|
+
// identifier/mention pass (file-level), each tier labeled by its field.
|
|
89
|
+
export function findReferences(scan: RepoScan, name: string): SymbolReferences {
|
|
90
|
+
const defs: CodeSymbol[] = [];
|
|
91
|
+
for (const f of scan.files) {
|
|
92
|
+
for (const s of f.symbols) {
|
|
93
|
+
if (s.name === name && !REFERENCE_KINDS.has(s.kind)) defs.push(s);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
defs.sort((a, b) => byStr(a.file, b.file) || a.line - b.line);
|
|
97
|
+
|
|
98
|
+
const index = buildCallerIndex(scan);
|
|
99
|
+
const entry = index.get(name);
|
|
100
|
+
const callSites = entry ? entry.callers : [];
|
|
101
|
+
|
|
102
|
+
const referencingFiles = new Set<string>();
|
|
103
|
+
const unique = uniqueSymbolDefs(scan);
|
|
104
|
+
const defFile = unique.get(name);
|
|
105
|
+
for (const f of scan.files) {
|
|
106
|
+
if (f.rel === defFile) continue;
|
|
107
|
+
if (f.kind === "code" && f.idents?.includes(name)) referencingFiles.add(f.rel);
|
|
108
|
+
else if (f.kind === "doc") {
|
|
109
|
+
const content = scan.docText.get(f.rel);
|
|
110
|
+
if (content && new RegExp(`\\b${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`).test(content)) {
|
|
111
|
+
referencingFiles.add(f.rel);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
for (const site of callSites) referencingFiles.add(site.file);
|
|
116
|
+
|
|
117
|
+
return { defs, callSites, referencingFiles: [...referencingFiles].sort(byStr) };
|
|
118
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Graph } from "../types.js";
|
|
2
|
+
import { byStr } from "../sort.js";
|
|
3
|
+
|
|
4
|
+
function sortObject(obj: Record<string, number>): Record<string, number> {
|
|
5
|
+
const out: Record<string, number> = {};
|
|
6
|
+
for (const k of Object.keys(obj).sort(byStr)) out[k] = obj[k]!;
|
|
7
|
+
return out;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// Serialize the graph to stable, pretty JSON. Every array is already sorted
|
|
11
|
+
// deterministically by the graph builder; we only normalize the language map's
|
|
12
|
+
// key order here so two builds of an unchanged repo are byte-identical.
|
|
13
|
+
export function renderGraphJson(graph: Graph): string {
|
|
14
|
+
const ordered: Graph = { ...graph, languages: sortObject(graph.languages) };
|
|
15
|
+
return JSON.stringify(ordered, null, 2) + "\n";
|
|
16
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { SCHEMA_VERSION } from "../types.js";
|
|
2
|
+
import type { SymbolIndex } from "../types.js";
|
|
3
|
+
import type { RepoScan } from "../scan.js";
|
|
4
|
+
import { byStr } from "../sort.js";
|
|
5
|
+
import { uniqueSymbolDefs } from "../graph.js";
|
|
6
|
+
|
|
7
|
+
// Files that REFERENCE each unique exported symbol — code files via their AST
|
|
8
|
+
// identifiers, doc files via naming the symbol. Feeds symbols.json `refs` so
|
|
9
|
+
// `symbols <name>` can answer "where is X used?". Mirrors the graph's use/mention
|
|
10
|
+
// eligibility (unique + distinctive), so refs and edges stay consistent.
|
|
11
|
+
export function computeSymbolRefs(scan: RepoScan): Map<string, Set<string>> {
|
|
12
|
+
const unique = uniqueSymbolDefs(scan);
|
|
13
|
+
const refs = new Map<string, Set<string>>();
|
|
14
|
+
if (!unique.size) return refs;
|
|
15
|
+
const add = (name: string, file: string): void => {
|
|
16
|
+
let set = refs.get(name);
|
|
17
|
+
if (!set) refs.set(name, (set = new Set()));
|
|
18
|
+
set.add(file);
|
|
19
|
+
};
|
|
20
|
+
for (const f of scan.files) {
|
|
21
|
+
if (f.kind === "code" && f.idents) {
|
|
22
|
+
for (const id of f.idents) {
|
|
23
|
+
const target = unique.get(id);
|
|
24
|
+
if (target && target !== f.rel) add(id, f.rel);
|
|
25
|
+
}
|
|
26
|
+
} else if (f.kind === "doc") {
|
|
27
|
+
const content = scan.docText.get(f.rel);
|
|
28
|
+
if (!content) continue;
|
|
29
|
+
for (const tok of content.split(/[^A-Za-z0-9_]+/)) {
|
|
30
|
+
const target = unique.get(tok);
|
|
31
|
+
if (target && target !== f.rel) add(tok, f.rel);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return refs;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Build the persisted symbol table from the scan. `defs` collects every declared
|
|
39
|
+
// symbol by name; `refs` (files that USE a name) is filled by the graph's
|
|
40
|
+
// use/mention pass and merged in here. Ordering is fully deterministic so
|
|
41
|
+
// symbols.json is byte-stable across rebuilds.
|
|
42
|
+
export function buildSymbolIndex(scan: RepoScan, refs: Map<string, Set<string>> = new Map()): SymbolIndex {
|
|
43
|
+
const defsByName = new Map<string, SymbolIndex["defs"][string]>();
|
|
44
|
+
for (const f of scan.files) {
|
|
45
|
+
for (const s of f.symbols) {
|
|
46
|
+
let arr = defsByName.get(s.name);
|
|
47
|
+
if (!arr) defsByName.set(s.name, (arr = []));
|
|
48
|
+
arr.push({
|
|
49
|
+
file: s.file,
|
|
50
|
+
line: s.line,
|
|
51
|
+
...(s.endLine !== undefined ? { endLine: s.endLine } : {}),
|
|
52
|
+
kind: s.kind,
|
|
53
|
+
exported: s.exported,
|
|
54
|
+
lang: s.lang,
|
|
55
|
+
...(s.parent ? { parent: s.parent } : {}),
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const defs: SymbolIndex["defs"] = {};
|
|
61
|
+
for (const name of [...defsByName.keys()].sort(byStr)) {
|
|
62
|
+
defs[name] = defsByName
|
|
63
|
+
.get(name)!
|
|
64
|
+
.slice()
|
|
65
|
+
.sort((a, b) => byStr(a.file, b.file) || a.line - b.line || byStr(a.kind, b.kind));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const refsOut: SymbolIndex["refs"] = {};
|
|
69
|
+
for (const name of [...refs.keys()].sort(byStr)) {
|
|
70
|
+
const files = [...refs.get(name)!].sort(byStr);
|
|
71
|
+
if (files.length) refsOut[name] = files;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return { schemaVersion: SCHEMA_VERSION, defs, refs: refsOut };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function renderSymbolsJson(index: SymbolIndex): string {
|
|
78
|
+
return JSON.stringify(index, null, 2) + "\n";
|
|
79
|
+
}
|
package/src/repomap.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// Token-budgeted repo map (Aider-lineage, but deterministic and served from
|
|
2
|
+
// the persisted index): the highest-PageRank files with their key exported
|
|
3
|
+
// signatures, rendered to fit a token budget — the densest possible "what is
|
|
4
|
+
// this codebase" context an agent can load in one read. Unlike Aider's map it
|
|
5
|
+
// never varies with conversation state: same repo → same bytes.
|
|
6
|
+
import type { Graph } from "./types.js";
|
|
7
|
+
import type { RepoScan } from "./scan.js";
|
|
8
|
+
import { byStr } from "./sort.js";
|
|
9
|
+
|
|
10
|
+
export interface RepoMapOptions {
|
|
11
|
+
// Approximate token budget (chars/4 heuristic — deterministic, tokenizer-
|
|
12
|
+
// free). Default 1024 tokens, Aider's default map size.
|
|
13
|
+
budgetTokens?: number;
|
|
14
|
+
maxSymbolsPerFile?: number; // default 8
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const CHARS_PER_TOKEN = 4;
|
|
18
|
+
|
|
19
|
+
export function renderRepoMap(scan: RepoScan, graph: Graph, opts: RepoMapOptions = {}): string {
|
|
20
|
+
const budgetChars = (opts.budgetTokens ?? 1024) * CHARS_PER_TOKEN;
|
|
21
|
+
const maxSymbols = opts.maxSymbolsPerFile ?? 8;
|
|
22
|
+
|
|
23
|
+
// Rank: PageRank first (stamped by the pipeline's centrality pass), then
|
|
24
|
+
// symbol count, then path — all deterministic tie-breaks.
|
|
25
|
+
const ranked = [...graph.files]
|
|
26
|
+
.filter((f) => f.fileKind === "code")
|
|
27
|
+
.sort((a, b) => (b.pagerank ?? 0) - (a.pagerank ?? 0) || b.symbols - a.symbols || byStr(a.rel, b.rel));
|
|
28
|
+
const records = new Map(scan.files.map((f) => [f.rel, f]));
|
|
29
|
+
|
|
30
|
+
const header = `# repo map — ${graph.fileCount} files\n`;
|
|
31
|
+
let out = header;
|
|
32
|
+
let files = 0;
|
|
33
|
+
for (const node of ranked) {
|
|
34
|
+
const rec = records.get(node.rel);
|
|
35
|
+
if (!rec) continue;
|
|
36
|
+
// Exported symbols first, declaration order within each group; reference
|
|
37
|
+
// kinds (re-exports) carry no signature worth budget.
|
|
38
|
+
const symbols = [...rec.symbols]
|
|
39
|
+
.filter((s) => s.kind !== "reexport" && s.kind !== "reexport-all")
|
|
40
|
+
.sort((a, b) => Number(b.exported) - Number(a.exported) || a.line - b.line)
|
|
41
|
+
.slice(0, maxSymbols);
|
|
42
|
+
let block = `\n${node.rel}:\n`;
|
|
43
|
+
for (const s of symbols) {
|
|
44
|
+
const sig = (s.signature ?? `${s.kind} ${s.name}`).replace(/\s+/g, " ").trim().slice(0, 120);
|
|
45
|
+
block += ` ${s.line}: ${sig}\n`;
|
|
46
|
+
}
|
|
47
|
+
if (out.length + block.length > budgetChars) break;
|
|
48
|
+
out += block;
|
|
49
|
+
files++;
|
|
50
|
+
}
|
|
51
|
+
return `${out}\n(${files} of ${ranked.length} code files shown, ~${Math.ceil(out.length / CHARS_PER_TOKEN)} tokens)\n`;
|
|
52
|
+
}
|