@mainahq/core 0.2.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 +31 -0
- package/package.json +37 -0
- package/src/ai/__tests__/ai.test.ts +207 -0
- package/src/ai/__tests__/design-approaches.test.ts +192 -0
- package/src/ai/__tests__/spec-questions.test.ts +191 -0
- package/src/ai/__tests__/tiers.test.ts +110 -0
- package/src/ai/commit-msg.ts +28 -0
- package/src/ai/design-approaches.ts +76 -0
- package/src/ai/index.ts +205 -0
- package/src/ai/pr-summary.ts +60 -0
- package/src/ai/spec-questions.ts +74 -0
- package/src/ai/tiers.ts +52 -0
- package/src/ai/try-generate.ts +89 -0
- package/src/ai/validate.ts +66 -0
- package/src/benchmark/__tests__/reporter.test.ts +525 -0
- package/src/benchmark/__tests__/runner.test.ts +113 -0
- package/src/benchmark/__tests__/story-loader.test.ts +152 -0
- package/src/benchmark/reporter.ts +332 -0
- package/src/benchmark/runner.ts +91 -0
- package/src/benchmark/story-loader.ts +88 -0
- package/src/benchmark/types.ts +95 -0
- package/src/cache/__tests__/keys.test.ts +97 -0
- package/src/cache/__tests__/manager.test.ts +312 -0
- package/src/cache/__tests__/ttl.test.ts +94 -0
- package/src/cache/keys.ts +44 -0
- package/src/cache/manager.ts +231 -0
- package/src/cache/ttl.ts +77 -0
- package/src/config/__tests__/config.test.ts +376 -0
- package/src/config/index.ts +198 -0
- package/src/context/__tests__/budget.test.ts +179 -0
- package/src/context/__tests__/engine.test.ts +163 -0
- package/src/context/__tests__/episodic.test.ts +291 -0
- package/src/context/__tests__/relevance.test.ts +323 -0
- package/src/context/__tests__/retrieval.test.ts +143 -0
- package/src/context/__tests__/selector.test.ts +174 -0
- package/src/context/__tests__/semantic.test.ts +252 -0
- package/src/context/__tests__/treesitter.test.ts +229 -0
- package/src/context/__tests__/working.test.ts +236 -0
- package/src/context/budget.ts +130 -0
- package/src/context/engine.ts +394 -0
- package/src/context/episodic.ts +251 -0
- package/src/context/relevance.ts +325 -0
- package/src/context/retrieval.ts +325 -0
- package/src/context/selector.ts +93 -0
- package/src/context/semantic.ts +331 -0
- package/src/context/treesitter.ts +216 -0
- package/src/context/working.ts +192 -0
- package/src/db/__tests__/db.test.ts +151 -0
- package/src/db/index.ts +211 -0
- package/src/db/schema.ts +84 -0
- package/src/design/__tests__/design.test.ts +310 -0
- package/src/design/__tests__/generate-hld-lld.test.ts +109 -0
- package/src/design/__tests__/review.test.ts +561 -0
- package/src/design/index.ts +297 -0
- package/src/design/review.ts +327 -0
- package/src/explain/__tests__/explain.test.ts +173 -0
- package/src/explain/index.ts +181 -0
- package/src/features/__tests__/analyzer.test.ts +358 -0
- package/src/features/__tests__/checklist.test.ts +454 -0
- package/src/features/__tests__/numbering.test.ts +319 -0
- package/src/features/__tests__/quality.test.ts +295 -0
- package/src/features/__tests__/traceability.test.ts +147 -0
- package/src/features/analyzer.ts +445 -0
- package/src/features/checklist.ts +366 -0
- package/src/features/index.ts +18 -0
- package/src/features/numbering.ts +404 -0
- package/src/features/quality.ts +349 -0
- package/src/features/test-stubs.ts +157 -0
- package/src/features/traceability.ts +260 -0
- package/src/feedback/__tests__/async-feedback.test.ts +52 -0
- package/src/feedback/__tests__/collector.test.ts +219 -0
- package/src/feedback/__tests__/compress.test.ts +150 -0
- package/src/feedback/__tests__/preferences.test.ts +169 -0
- package/src/feedback/collector.ts +135 -0
- package/src/feedback/compress.ts +92 -0
- package/src/feedback/preferences.ts +108 -0
- package/src/git/__tests__/git.test.ts +62 -0
- package/src/git/index.ts +110 -0
- package/src/hooks/__tests__/runner.test.ts +266 -0
- package/src/hooks/index.ts +8 -0
- package/src/hooks/runner.ts +130 -0
- package/src/index.ts +356 -0
- package/src/init/__tests__/init.test.ts +228 -0
- package/src/init/index.ts +364 -0
- package/src/language/__tests__/detect.test.ts +77 -0
- package/src/language/__tests__/profile.test.ts +51 -0
- package/src/language/detect.ts +70 -0
- package/src/language/profile.ts +110 -0
- package/src/prompts/__tests__/defaults.test.ts +52 -0
- package/src/prompts/__tests__/engine.test.ts +183 -0
- package/src/prompts/__tests__/evolution-resolve.test.ts +169 -0
- package/src/prompts/__tests__/evolution.test.ts +187 -0
- package/src/prompts/__tests__/loader.test.ts +105 -0
- package/src/prompts/candidates/review-v2.md +55 -0
- package/src/prompts/defaults/ai-review.md +49 -0
- package/src/prompts/defaults/commit.md +30 -0
- package/src/prompts/defaults/context.md +26 -0
- package/src/prompts/defaults/design-approaches.md +57 -0
- package/src/prompts/defaults/design-hld-lld.md +55 -0
- package/src/prompts/defaults/design.md +53 -0
- package/src/prompts/defaults/explain.md +31 -0
- package/src/prompts/defaults/fix.md +32 -0
- package/src/prompts/defaults/index.ts +38 -0
- package/src/prompts/defaults/review.md +41 -0
- package/src/prompts/defaults/spec-questions.md +59 -0
- package/src/prompts/defaults/tests.md +72 -0
- package/src/prompts/engine.ts +137 -0
- package/src/prompts/evolution.ts +409 -0
- package/src/prompts/loader.ts +71 -0
- package/src/review/__tests__/review.test.ts +288 -0
- package/src/review/comprehensive.ts +362 -0
- package/src/review/index.ts +417 -0
- package/src/stats/__tests__/tracker.test.ts +323 -0
- package/src/stats/index.ts +11 -0
- package/src/stats/tracker.ts +492 -0
- package/src/ticket/__tests__/ticket.test.ts +273 -0
- package/src/ticket/index.ts +185 -0
- package/src/utils.ts +87 -0
- package/src/verify/__tests__/ai-review.test.ts +242 -0
- package/src/verify/__tests__/coverage.test.ts +83 -0
- package/src/verify/__tests__/detect.test.ts +175 -0
- package/src/verify/__tests__/diff-filter.test.ts +338 -0
- package/src/verify/__tests__/fix.test.ts +478 -0
- package/src/verify/__tests__/linters/clippy.test.ts +45 -0
- package/src/verify/__tests__/linters/go-vet.test.ts +27 -0
- package/src/verify/__tests__/linters/ruff.test.ts +64 -0
- package/src/verify/__tests__/mutation.test.ts +141 -0
- package/src/verify/__tests__/pipeline.test.ts +553 -0
- package/src/verify/__tests__/proof.test.ts +97 -0
- package/src/verify/__tests__/secretlint.test.ts +190 -0
- package/src/verify/__tests__/semgrep.test.ts +217 -0
- package/src/verify/__tests__/slop.test.ts +366 -0
- package/src/verify/__tests__/sonar.test.ts +113 -0
- package/src/verify/__tests__/syntax-guard.test.ts +227 -0
- package/src/verify/__tests__/trivy.test.ts +191 -0
- package/src/verify/__tests__/visual.test.ts +139 -0
- package/src/verify/ai-review.ts +276 -0
- package/src/verify/coverage.ts +134 -0
- package/src/verify/detect.ts +171 -0
- package/src/verify/diff-filter.ts +183 -0
- package/src/verify/fix.ts +317 -0
- package/src/verify/linters/clippy.ts +52 -0
- package/src/verify/linters/go-vet.ts +32 -0
- package/src/verify/linters/ruff.ts +47 -0
- package/src/verify/mutation.ts +143 -0
- package/src/verify/pipeline.ts +328 -0
- package/src/verify/proof.ts +277 -0
- package/src/verify/secretlint.ts +168 -0
- package/src/verify/semgrep.ts +170 -0
- package/src/verify/slop.ts +493 -0
- package/src/verify/sonar.ts +146 -0
- package/src/verify/syntax-guard.ts +251 -0
- package/src/verify/trivy.ts +161 -0
- package/src/verify/visual.ts +460 -0
- package/src/workflow/__tests__/context.test.ts +110 -0
- package/src/workflow/context.ts +81 -0
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
import { dirname, extname, resolve } from "node:path";
|
|
2
|
+
import { parseFile } from "./treesitter";
|
|
3
|
+
|
|
4
|
+
export interface DependencyGraph {
|
|
5
|
+
nodes: Set<string>; // file paths
|
|
6
|
+
edges: Map<string, Map<string, number>>; // source -> target -> weight
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface TaskContext {
|
|
10
|
+
touchedFiles: string[];
|
|
11
|
+
mentionedFiles: string[];
|
|
12
|
+
currentTicketTerms: string[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Resolves a relative import source to an absolute file path,
|
|
17
|
+
* trying common extensions if needed.
|
|
18
|
+
*/
|
|
19
|
+
function resolveImportPath(
|
|
20
|
+
importSource: string,
|
|
21
|
+
sourceFile: string,
|
|
22
|
+
knownFiles: Set<string>,
|
|
23
|
+
): string | null {
|
|
24
|
+
// Only handle relative imports
|
|
25
|
+
if (!importSource.startsWith(".")) {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const sourceDir = dirname(sourceFile);
|
|
30
|
+
const base = resolve(sourceDir, importSource);
|
|
31
|
+
|
|
32
|
+
// Try exact path first, then with extensions
|
|
33
|
+
const candidates = [
|
|
34
|
+
base,
|
|
35
|
+
// TypeScript/JavaScript
|
|
36
|
+
`${base}.ts`,
|
|
37
|
+
`${base}.tsx`,
|
|
38
|
+
`${base}.js`,
|
|
39
|
+
`${base}.jsx`,
|
|
40
|
+
`${base}/index.ts`,
|
|
41
|
+
`${base}/index.js`,
|
|
42
|
+
// Python
|
|
43
|
+
`${base}.py`,
|
|
44
|
+
`${base}/__init__.py`,
|
|
45
|
+
// Go
|
|
46
|
+
`${base}.go`,
|
|
47
|
+
// Rust
|
|
48
|
+
`${base}.rs`,
|
|
49
|
+
`${base}/mod.rs`,
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
for (const candidate of candidates) {
|
|
53
|
+
if (knownFiles.has(candidate)) {
|
|
54
|
+
return candidate;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Determines if an import is type-only based on the import text in the file.
|
|
63
|
+
* Since parseFile doesn't directly expose type-only info, we check specifiers.
|
|
64
|
+
* A heuristic: all specifiers start with uppercase AND source is not a runtime dep.
|
|
65
|
+
* More accurately, we need to re-read to detect "import type".
|
|
66
|
+
*/
|
|
67
|
+
async function getImportTypeInfo(
|
|
68
|
+
filePath: string,
|
|
69
|
+
): Promise<{ typeOnlySources: Set<string>; privateSources: Set<string> }> {
|
|
70
|
+
try {
|
|
71
|
+
const content = await Bun.file(filePath).text();
|
|
72
|
+
const typeOnlySources = new Set<string>();
|
|
73
|
+
const privateSources = new Set<string>();
|
|
74
|
+
|
|
75
|
+
// Detect "import type { ... } from '...'"
|
|
76
|
+
const typeImportRe =
|
|
77
|
+
/^import\s+type\s+\{[^}]+\}\s+from\s+["']([^"']+)["']/gm;
|
|
78
|
+
for (const match of content.matchAll(typeImportRe)) {
|
|
79
|
+
const source = match[1];
|
|
80
|
+
if (source) typeOnlySources.add(source);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Detect imports of private names (specifiers starting with _)
|
|
84
|
+
const namedImportRe =
|
|
85
|
+
/^import\s+(?:type\s+)?\{\s*([^}]+)\}\s+from\s+["']([^"']+)["']/gm;
|
|
86
|
+
for (const match of content.matchAll(namedImportRe)) {
|
|
87
|
+
const specifiers = match[1];
|
|
88
|
+
const source = match[2];
|
|
89
|
+
if (specifiers && source) {
|
|
90
|
+
const names = specifiers.split(",").map((s) => s.trim());
|
|
91
|
+
const allPrivate = names.every((n) => n.startsWith("_") || n === "");
|
|
92
|
+
const hasPrivate = names.some((n) => n.startsWith("_"));
|
|
93
|
+
if (allPrivate && hasPrivate) {
|
|
94
|
+
privateSources.add(source);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Default imports of private names
|
|
100
|
+
const defaultImportRe = /^import\s+(_\w+)\s+from\s+["']([^"']+)["']/gm;
|
|
101
|
+
for (const match of content.matchAll(defaultImportRe)) {
|
|
102
|
+
const source = match[2];
|
|
103
|
+
if (source) privateSources.add(source);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return { typeOnlySources, privateSources };
|
|
107
|
+
} catch {
|
|
108
|
+
return { typeOnlySources: new Set(), privateSources: new Set() };
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Builds a dependency graph from a list of .ts/.js files.
|
|
114
|
+
* Creates directed edges from source -> target based on imports.
|
|
115
|
+
* Weights: 1.0 normal, 0.5 type-only, 0.1 private names (starting with _).
|
|
116
|
+
*/
|
|
117
|
+
export async function buildGraph(files: string[]): Promise<DependencyGraph> {
|
|
118
|
+
const nodes = new Set<string>(files);
|
|
119
|
+
const edges = new Map<string, Map<string, number>>();
|
|
120
|
+
|
|
121
|
+
for (const file of files) {
|
|
122
|
+
const ext = extname(file);
|
|
123
|
+
if (ext !== ".ts" && ext !== ".js") continue;
|
|
124
|
+
|
|
125
|
+
let parsed: Awaited<ReturnType<typeof parseFile>> | undefined;
|
|
126
|
+
try {
|
|
127
|
+
parsed = await parseFile(file);
|
|
128
|
+
} catch {
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const { typeOnlySources, privateSources } = await getImportTypeInfo(file);
|
|
133
|
+
|
|
134
|
+
for (const imp of parsed.imports) {
|
|
135
|
+
const target = resolveImportPath(imp.source, file, nodes);
|
|
136
|
+
if (!target) continue;
|
|
137
|
+
|
|
138
|
+
// Determine weight
|
|
139
|
+
let weight = 1.0;
|
|
140
|
+
if (typeOnlySources.has(imp.source)) {
|
|
141
|
+
weight = 0.5;
|
|
142
|
+
} else if (privateSources.has(imp.source)) {
|
|
143
|
+
weight = 0.1;
|
|
144
|
+
} else {
|
|
145
|
+
// Check if all specifiers are private
|
|
146
|
+
const allPrivate =
|
|
147
|
+
imp.specifiers.length > 0 &&
|
|
148
|
+
imp.specifiers.every((s) => s.startsWith("_"));
|
|
149
|
+
if (allPrivate) {
|
|
150
|
+
weight = 0.1;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (!edges.has(file)) {
|
|
155
|
+
edges.set(file, new Map());
|
|
156
|
+
}
|
|
157
|
+
// Use max weight if there are multiple imports from same source
|
|
158
|
+
const existing = edges.get(file)?.get(target) ?? 0;
|
|
159
|
+
edges.get(file)?.set(target, Math.max(existing, weight));
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return { nodes, edges };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Implements the PageRank algorithm over a dependency graph.
|
|
168
|
+
* Returns a map of file -> score, where scores sum to approximately 1.
|
|
169
|
+
*
|
|
170
|
+
* Formula per iteration:
|
|
171
|
+
* score[n] = (1 - d) * personalWeight[n] / totalPersonalWeight
|
|
172
|
+
* + d * sum(score[m] / outDegree(m) for m that links to n)
|
|
173
|
+
*/
|
|
174
|
+
export function pageRank(
|
|
175
|
+
graph: DependencyGraph,
|
|
176
|
+
options?: {
|
|
177
|
+
personalization?: Map<string, number>;
|
|
178
|
+
dampingFactor?: number;
|
|
179
|
+
iterations?: number;
|
|
180
|
+
},
|
|
181
|
+
): Map<string, number> {
|
|
182
|
+
const nodes = Array.from(graph.nodes);
|
|
183
|
+
const n = nodes.length;
|
|
184
|
+
|
|
185
|
+
if (n === 0) {
|
|
186
|
+
return new Map();
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const d = options?.dampingFactor ?? 0.85;
|
|
190
|
+
const iters = options?.iterations ?? 20;
|
|
191
|
+
const personalization = options?.personalization;
|
|
192
|
+
|
|
193
|
+
// Build personalization weights
|
|
194
|
+
const personalWeights = new Map<string, number>();
|
|
195
|
+
let totalPersonalWeight = 0;
|
|
196
|
+
|
|
197
|
+
if (personalization && personalization.size > 0) {
|
|
198
|
+
for (const node of nodes) {
|
|
199
|
+
const w = personalization.get(node) ?? 0;
|
|
200
|
+
personalWeights.set(node, w);
|
|
201
|
+
totalPersonalWeight += w;
|
|
202
|
+
}
|
|
203
|
+
// If all personalized nodes are not in graph, fall back to uniform
|
|
204
|
+
if (totalPersonalWeight === 0) {
|
|
205
|
+
for (const node of nodes) {
|
|
206
|
+
personalWeights.set(node, 1);
|
|
207
|
+
totalPersonalWeight += 1;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
} else {
|
|
211
|
+
// Uniform personalization
|
|
212
|
+
for (const node of nodes) {
|
|
213
|
+
personalWeights.set(node, 1);
|
|
214
|
+
}
|
|
215
|
+
totalPersonalWeight = n;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Compute out-degrees (weighted)
|
|
219
|
+
const outDegree = new Map<string, number>();
|
|
220
|
+
for (const node of nodes) {
|
|
221
|
+
const targets = graph.edges.get(node);
|
|
222
|
+
if (targets && targets.size > 0) {
|
|
223
|
+
let total = 0;
|
|
224
|
+
for (const w of targets.values()) {
|
|
225
|
+
total += w;
|
|
226
|
+
}
|
|
227
|
+
outDegree.set(node, total);
|
|
228
|
+
} else {
|
|
229
|
+
outDegree.set(node, 0);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Build reverse adjacency: target -> list of (source, weight)
|
|
234
|
+
const inLinks = new Map<string, Array<[string, number]>>();
|
|
235
|
+
for (const node of nodes) {
|
|
236
|
+
inLinks.set(node, []);
|
|
237
|
+
}
|
|
238
|
+
for (const [source, targets] of graph.edges) {
|
|
239
|
+
for (const [target, weight] of targets) {
|
|
240
|
+
if (inLinks.has(target)) {
|
|
241
|
+
inLinks.get(target)?.push([source, weight]);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Initialize scores
|
|
247
|
+
let scores = new Map<string, number>();
|
|
248
|
+
for (const node of nodes) {
|
|
249
|
+
scores.set(node, 1 / n);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Dangling nodes (no outgoing edges) — redistribute uniformly
|
|
253
|
+
const danglingNodes = nodes.filter((node) => outDegree.get(node) === 0);
|
|
254
|
+
|
|
255
|
+
// Iterate
|
|
256
|
+
for (let iter = 0; iter < iters; iter++) {
|
|
257
|
+
const newScores = new Map<string, number>();
|
|
258
|
+
|
|
259
|
+
// Sum of dangling node scores
|
|
260
|
+
let danglingSum = 0;
|
|
261
|
+
for (const node of danglingNodes) {
|
|
262
|
+
danglingSum += scores.get(node) ?? 0;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
for (const node of nodes) {
|
|
266
|
+
const personalBase =
|
|
267
|
+
((1 - d) * (personalWeights.get(node) ?? 0)) / totalPersonalWeight;
|
|
268
|
+
|
|
269
|
+
// Dangling node contribution distributed by personalization
|
|
270
|
+
const danglingContrib =
|
|
271
|
+
d *
|
|
272
|
+
danglingSum *
|
|
273
|
+
((personalWeights.get(node) ?? 0) / totalPersonalWeight);
|
|
274
|
+
|
|
275
|
+
// Link contributions
|
|
276
|
+
let linkSum = 0;
|
|
277
|
+
const incoming = inLinks.get(node) ?? [];
|
|
278
|
+
for (const [source, weight] of incoming) {
|
|
279
|
+
const sourceOut = outDegree.get(source) ?? 0;
|
|
280
|
+
if (sourceOut > 0) {
|
|
281
|
+
linkSum += d * (scores.get(source) ?? 0) * (weight / sourceOut);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
newScores.set(node, personalBase + danglingContrib + linkSum);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
scores = newScores;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Normalize so scores sum to 1
|
|
292
|
+
let total = 0;
|
|
293
|
+
for (const v of scores.values()) {
|
|
294
|
+
total += v;
|
|
295
|
+
}
|
|
296
|
+
if (total > 0) {
|
|
297
|
+
for (const [node, v] of scores) {
|
|
298
|
+
scores.set(node, v / total);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
return scores;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Convenience wrapper: builds personalization vector from taskContext
|
|
307
|
+
* (touched files get weight 50, mentioned files get weight 10),
|
|
308
|
+
* then runs pageRank.
|
|
309
|
+
*/
|
|
310
|
+
export function scoreRelevance(
|
|
311
|
+
graph: DependencyGraph,
|
|
312
|
+
taskContext: TaskContext,
|
|
313
|
+
): Map<string, number> {
|
|
314
|
+
const personalization = new Map<string, number>();
|
|
315
|
+
|
|
316
|
+
for (const file of taskContext.touchedFiles) {
|
|
317
|
+
personalization.set(file, (personalization.get(file) ?? 0) + 50);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
for (const file of taskContext.mentionedFiles) {
|
|
321
|
+
personalization.set(file, (personalization.get(file) ?? 0) + 10);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
return pageRank(graph, { personalization });
|
|
325
|
+
}
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
export interface SearchResult {
|
|
2
|
+
filePath: string;
|
|
3
|
+
line: number;
|
|
4
|
+
content: string;
|
|
5
|
+
matchLength: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface RetrievalOptions {
|
|
9
|
+
maxResults?: number; // default 20
|
|
10
|
+
tokenBudget?: number; // max tokens for results
|
|
11
|
+
cwd?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Check if a CLI tool is available by trying to run it with --version.
|
|
16
|
+
* Uses --version instead of `which` because tools may be shell functions
|
|
17
|
+
* (e.g., Claude Code wraps rg as a function).
|
|
18
|
+
*/
|
|
19
|
+
export async function isToolAvailable(tool: string): Promise<boolean> {
|
|
20
|
+
try {
|
|
21
|
+
const proc = Bun.spawn([tool, "--version"], {
|
|
22
|
+
stdout: "pipe",
|
|
23
|
+
stderr: "pipe",
|
|
24
|
+
});
|
|
25
|
+
await proc.exited;
|
|
26
|
+
return proc.exitCode === 0;
|
|
27
|
+
} catch {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Parse ripgrep JSON output (rg --json) into SearchResult[].
|
|
34
|
+
* Each line is a JSON object; we only care about lines with type "match".
|
|
35
|
+
*/
|
|
36
|
+
function parseRipgrepJson(output: string): SearchResult[] {
|
|
37
|
+
const results: SearchResult[] = [];
|
|
38
|
+
const lines = output.split("\n");
|
|
39
|
+
for (const line of lines) {
|
|
40
|
+
if (!line.trim()) continue;
|
|
41
|
+
try {
|
|
42
|
+
const obj = JSON.parse(line);
|
|
43
|
+
if (obj.type !== "match") continue;
|
|
44
|
+
const data = obj.data;
|
|
45
|
+
const filePath: string = data?.path?.text ?? "";
|
|
46
|
+
const lineNumber: number = data?.line_number ?? 0;
|
|
47
|
+
const content: string = (data?.lines?.text ?? "").replace(/\n$/, "");
|
|
48
|
+
// matchLength: sum of all submatches lengths
|
|
49
|
+
let matchLength = 0;
|
|
50
|
+
if (Array.isArray(data?.submatches)) {
|
|
51
|
+
for (const sub of data.submatches) {
|
|
52
|
+
if (typeof sub?.match?.text === "string") {
|
|
53
|
+
matchLength += (sub.match.text as string).length;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
if (filePath && lineNumber) {
|
|
58
|
+
results.push({ filePath, line: lineNumber, content, matchLength });
|
|
59
|
+
}
|
|
60
|
+
} catch {
|
|
61
|
+
// skip malformed lines
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return results;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Parse plain `rg -n` or `grep -rn` output (format: filePath:lineNum:content).
|
|
69
|
+
*/
|
|
70
|
+
function parsePlainOutput(output: string): SearchResult[] {
|
|
71
|
+
const results: SearchResult[] = [];
|
|
72
|
+
const lines = output.split("\n");
|
|
73
|
+
for (const line of lines) {
|
|
74
|
+
if (!line.trim()) continue;
|
|
75
|
+
// Format: filePath:lineNumber:content
|
|
76
|
+
const firstColon = line.indexOf(":");
|
|
77
|
+
if (firstColon === -1) continue;
|
|
78
|
+
const afterFirst = line.indexOf(":", firstColon + 1);
|
|
79
|
+
if (afterFirst === -1) continue;
|
|
80
|
+
const filePath = line.slice(0, firstColon);
|
|
81
|
+
const lineNum = Number.parseInt(line.slice(firstColon + 1, afterFirst), 10);
|
|
82
|
+
const content = line.slice(afterFirst + 1);
|
|
83
|
+
if (filePath && !Number.isNaN(lineNum)) {
|
|
84
|
+
results.push({ filePath, line: lineNum, content, matchLength: 0 });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return results;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Parse Zoekt search output into SearchResult[].
|
|
92
|
+
* Zoekt outputs in format: filePath:lineNum:content
|
|
93
|
+
* Similar to grep/rg plain output but may include header lines.
|
|
94
|
+
*/
|
|
95
|
+
export function parseZoektOutput(output: string): SearchResult[] {
|
|
96
|
+
const results: SearchResult[] = [];
|
|
97
|
+
const lines = output.split("\n");
|
|
98
|
+
for (const line of lines) {
|
|
99
|
+
if (!line.trim()) continue;
|
|
100
|
+
// Skip Zoekt header lines (e.g., "Repository: name")
|
|
101
|
+
if (!line.includes(":") || line.startsWith("Repository:")) continue;
|
|
102
|
+
const firstColon = line.indexOf(":");
|
|
103
|
+
if (firstColon === -1) continue;
|
|
104
|
+
const afterFirst = line.indexOf(":", firstColon + 1);
|
|
105
|
+
if (afterFirst === -1) continue;
|
|
106
|
+
const filePath = line.slice(0, firstColon);
|
|
107
|
+
const lineNum = Number.parseInt(line.slice(firstColon + 1, afterFirst), 10);
|
|
108
|
+
const content = line.slice(afterFirst + 1);
|
|
109
|
+
if (filePath && !Number.isNaN(lineNum) && lineNum > 0) {
|
|
110
|
+
results.push({ filePath, line: lineNum, content, matchLength: 0 });
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return results;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Search using Zoekt (Google's code search).
|
|
118
|
+
* Zoekt provides fast indexed search across the entire repo.
|
|
119
|
+
* Falls back gracefully if zoekt is not available.
|
|
120
|
+
*/
|
|
121
|
+
export async function searchWithZoekt(
|
|
122
|
+
query: string,
|
|
123
|
+
options: RetrievalOptions,
|
|
124
|
+
): Promise<SearchResult[]> {
|
|
125
|
+
const cwd = options.cwd ?? process.cwd();
|
|
126
|
+
const maxResults = options.maxResults ?? 20;
|
|
127
|
+
|
|
128
|
+
const zoektAvailable = await isToolAvailable("zoekt");
|
|
129
|
+
if (!zoektAvailable) {
|
|
130
|
+
return [];
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
try {
|
|
134
|
+
const proc = Bun.spawn(["zoekt", "-n", String(maxResults), query], {
|
|
135
|
+
cwd,
|
|
136
|
+
stdout: "pipe",
|
|
137
|
+
stderr: "pipe",
|
|
138
|
+
});
|
|
139
|
+
const output = await new Response(proc.stdout).text();
|
|
140
|
+
await proc.exited;
|
|
141
|
+
return parseZoektOutput(output);
|
|
142
|
+
} catch {
|
|
143
|
+
return [];
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Search using ripgrep (rg). Uses --json format if available.
|
|
149
|
+
* Respects .gitignore. Excludes node_modules, dist, .git.
|
|
150
|
+
*/
|
|
151
|
+
export async function searchWithRipgrep(
|
|
152
|
+
query: string,
|
|
153
|
+
options: RetrievalOptions,
|
|
154
|
+
): Promise<SearchResult[]> {
|
|
155
|
+
const cwd = options.cwd ?? process.cwd();
|
|
156
|
+
const maxResults = options.maxResults ?? 20;
|
|
157
|
+
|
|
158
|
+
try {
|
|
159
|
+
// Try JSON mode first (rg --json)
|
|
160
|
+
const proc = Bun.spawn(
|
|
161
|
+
[
|
|
162
|
+
"rg",
|
|
163
|
+
"--json",
|
|
164
|
+
"--max-count",
|
|
165
|
+
String(maxResults),
|
|
166
|
+
"--glob",
|
|
167
|
+
"!node_modules",
|
|
168
|
+
"--glob",
|
|
169
|
+
"!dist",
|
|
170
|
+
"--glob",
|
|
171
|
+
"!.git",
|
|
172
|
+
query,
|
|
173
|
+
],
|
|
174
|
+
{
|
|
175
|
+
cwd,
|
|
176
|
+
stdout: "pipe",
|
|
177
|
+
stderr: "pipe",
|
|
178
|
+
},
|
|
179
|
+
);
|
|
180
|
+
const output = await new Response(proc.stdout).text();
|
|
181
|
+
await proc.exited;
|
|
182
|
+
return parseRipgrepJson(output);
|
|
183
|
+
} catch {
|
|
184
|
+
// Try plain mode (rg -n)
|
|
185
|
+
try {
|
|
186
|
+
const proc = Bun.spawn(
|
|
187
|
+
[
|
|
188
|
+
"rg",
|
|
189
|
+
"-n",
|
|
190
|
+
"--max-count",
|
|
191
|
+
String(maxResults),
|
|
192
|
+
"--glob",
|
|
193
|
+
"!node_modules",
|
|
194
|
+
"--glob",
|
|
195
|
+
"!dist",
|
|
196
|
+
"--glob",
|
|
197
|
+
"!.git",
|
|
198
|
+
query,
|
|
199
|
+
],
|
|
200
|
+
{
|
|
201
|
+
cwd,
|
|
202
|
+
stdout: "pipe",
|
|
203
|
+
stderr: "pipe",
|
|
204
|
+
},
|
|
205
|
+
);
|
|
206
|
+
const output = await new Response(proc.stdout).text();
|
|
207
|
+
await proc.exited;
|
|
208
|
+
return parsePlainOutput(output);
|
|
209
|
+
} catch {
|
|
210
|
+
return [];
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Fallback search using grep.
|
|
217
|
+
* Searches .ts, .js, .tsx, .jsx files. Excludes node_modules, dist, .git.
|
|
218
|
+
*/
|
|
219
|
+
export async function searchWithGrep(
|
|
220
|
+
query: string,
|
|
221
|
+
options: RetrievalOptions,
|
|
222
|
+
): Promise<SearchResult[]> {
|
|
223
|
+
const cwd = options.cwd ?? process.cwd();
|
|
224
|
+
const maxResults = options.maxResults ?? 20;
|
|
225
|
+
|
|
226
|
+
try {
|
|
227
|
+
const proc = Bun.spawn(
|
|
228
|
+
[
|
|
229
|
+
"grep",
|
|
230
|
+
"-rn",
|
|
231
|
+
"-E",
|
|
232
|
+
"--include=*.ts",
|
|
233
|
+
"--include=*.tsx",
|
|
234
|
+
"--include=*.js",
|
|
235
|
+
"--include=*.jsx",
|
|
236
|
+
"--include=*.py",
|
|
237
|
+
"--include=*.go",
|
|
238
|
+
"--include=*.rs",
|
|
239
|
+
"--exclude-dir=node_modules",
|
|
240
|
+
"--exclude-dir=dist",
|
|
241
|
+
"--exclude-dir=.git",
|
|
242
|
+
query,
|
|
243
|
+
".",
|
|
244
|
+
],
|
|
245
|
+
{
|
|
246
|
+
cwd,
|
|
247
|
+
stdout: "pipe",
|
|
248
|
+
stderr: "pipe",
|
|
249
|
+
},
|
|
250
|
+
);
|
|
251
|
+
const output = await new Response(proc.stdout).text();
|
|
252
|
+
await proc.exited;
|
|
253
|
+
const results = parsePlainOutput(output);
|
|
254
|
+
return results.slice(0, maxResults);
|
|
255
|
+
} catch {
|
|
256
|
+
return [];
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Approximate token count for a string (~1 token per 3.5 chars).
|
|
262
|
+
*/
|
|
263
|
+
function countTokens(text: string): number {
|
|
264
|
+
return Math.ceil(text.length / 3.5);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Search for a query using ripgrep (preferred) or grep (fallback).
|
|
269
|
+
* Limits results by maxResults and optionally by tokenBudget.
|
|
270
|
+
*/
|
|
271
|
+
export async function search(
|
|
272
|
+
query: string,
|
|
273
|
+
options: RetrievalOptions = {},
|
|
274
|
+
): Promise<SearchResult[]> {
|
|
275
|
+
const maxResults = options.maxResults ?? 20;
|
|
276
|
+
const tokenBudget = options.tokenBudget;
|
|
277
|
+
|
|
278
|
+
let results: SearchResult[] = [];
|
|
279
|
+
|
|
280
|
+
try {
|
|
281
|
+
// Try Zoekt first (indexed, fastest), then rg, then grep
|
|
282
|
+
const zoektResults = await searchWithZoekt(query, options);
|
|
283
|
+
if (zoektResults.length > 0) {
|
|
284
|
+
results = zoektResults;
|
|
285
|
+
} else {
|
|
286
|
+
const rgAvailable = await isToolAvailable("rg");
|
|
287
|
+
if (rgAvailable) {
|
|
288
|
+
results = await searchWithRipgrep(query, options);
|
|
289
|
+
} else {
|
|
290
|
+
results = await searchWithGrep(query, options);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
} catch {
|
|
294
|
+
return [];
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Enforce maxResults
|
|
298
|
+
results = results.slice(0, maxResults);
|
|
299
|
+
|
|
300
|
+
// Enforce tokenBudget if specified
|
|
301
|
+
if (tokenBudget !== undefined) {
|
|
302
|
+
let usedTokens = 0;
|
|
303
|
+
const budgeted: SearchResult[] = [];
|
|
304
|
+
for (const result of results) {
|
|
305
|
+
const tokens = countTokens(
|
|
306
|
+
`${result.filePath}:${result.line}: ${result.content}`,
|
|
307
|
+
);
|
|
308
|
+
if (usedTokens + tokens > tokenBudget) break;
|
|
309
|
+
budgeted.push(result);
|
|
310
|
+
usedTokens += tokens;
|
|
311
|
+
}
|
|
312
|
+
return budgeted;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
return results;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Format search results as text for LLM consumption.
|
|
320
|
+
* Each result is formatted as "filePath:line: content".
|
|
321
|
+
*/
|
|
322
|
+
export function assembleRetrievalText(results: SearchResult[]): string {
|
|
323
|
+
if (results.length === 0) return "";
|
|
324
|
+
return results.map((r) => `${r.filePath}:${r.line}: ${r.content}`).join("\n");
|
|
325
|
+
}
|