codecartographer-pi 0.25.0 → 0.26.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/.codecarto/broadside/SKILL.md +4 -1
- package/.codecarto/workflow/scaffold-version.yaml +1 -1
- package/README.md +2 -2
- package/dist/core/broadside/client.d.ts +56 -0
- package/dist/core/broadside/client.js +200 -0
- package/dist/core/broadside/collect.d.ts +68 -0
- package/dist/core/broadside/collect.js +676 -0
- package/dist/core/broadside/constants.d.ts +51 -0
- package/dist/core/broadside/constants.js +74 -0
- package/dist/core/broadside/lenses.d.ts +31 -0
- package/dist/core/broadside/lenses.js +312 -0
- package/dist/core/broadside/models.d.ts +46 -0
- package/dist/core/broadside/models.js +321 -0
- package/dist/core/broadside/render.d.ts +20 -0
- package/dist/core/broadside/render.js +285 -0
- package/dist/core/broadside/repo.d.ts +58 -0
- package/dist/core/broadside/repo.js +592 -0
- package/dist/core/broadside/requests.d.ts +23 -0
- package/dist/core/broadside/requests.js +71 -0
- package/dist/core/broadside/results.d.ts +36 -0
- package/dist/core/broadside/results.js +163 -0
- package/dist/core/broadside/schemas.d.ts +2 -0
- package/dist/core/broadside/schemas.js +342 -0
- package/dist/core/broadside/state.d.ts +99 -0
- package/dist/core/broadside/state.js +384 -0
- package/dist/core/broadside/submit.d.ts +30 -0
- package/dist/core/broadside/submit.js +350 -0
- package/dist/core/broadside/types.d.ts +491 -0
- package/dist/core/broadside/types.js +107 -0
- package/dist/core/{broadside-verify.d.ts → broadside/verify.d.ts} +23 -2
- package/dist/core/{broadside-verify.js → broadside/verify.js} +43 -5
- package/dist/core/broadside.d.ts +14 -952
- package/dist/core/broadside.js +25 -3726
- package/dist/core/completion.js +91 -72
- package/dist/core/dashboard-writer.js +9 -1
- package/dist/core/index.d.ts +0 -1
- package/dist/core/index.js +0 -1
- package/dist/core/library.d.ts +24 -1
- package/dist/core/library.js +46 -15
- package/dist/core/orchestrator-config.js +22 -8
- package/dist/core/status.d.ts +42 -23
- package/dist/core/status.js +163 -137
- package/dist/core/workspace.d.ts +2 -0
- package/dist/core/workspace.js +49 -25
- package/dist/core/yaml.js +9 -3
- package/dist/extensions/codecarto/auto-runner.js +41 -23
- package/dist/extensions/codecarto/index.js +9 -4
- package/dist/extensions/codecarto/phase-compaction.js +6 -2
- package/dist/mcp-server/server.js +15 -4
- package/package.json +1 -1
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { type FileSlice, type RepoInfo, type RepoSnapshotSource } from "./types.ts";
|
|
2
|
+
import { type LensDefinition } from "./lenses.ts";
|
|
3
|
+
/**
|
|
4
|
+
* Manifest files and the languages each one can mean. `package.json` covers
|
|
5
|
+
* both TypeScript and JavaScript; which of the two a repository is comes from
|
|
6
|
+
* counting its source files, not from the manifest.
|
|
7
|
+
*/
|
|
8
|
+
export declare const MANIFEST_CANDIDATES: ReadonlyArray<readonly [string, readonly string[]]>;
|
|
9
|
+
/** The languages Broad-Side can scan; anything else is refused at submit. */
|
|
10
|
+
export declare const BROADSIDE_LANGUAGES: readonly ["go", "python", "rust", "typescript", "javascript"];
|
|
11
|
+
/**
|
|
12
|
+
* The files a run scans, and where they came from. Contents are always read
|
|
13
|
+
* from the working tree, so the list is the working tree's too: tracked files
|
|
14
|
+
* plus untracked ones git does not ignore, minus files deleted on disk. The
|
|
15
|
+
* list used to come from `git ls-tree HEAD`, so a run mixed the committed
|
|
16
|
+
* file list with uncommitted contents and never saw an untracked file (#248).
|
|
17
|
+
* A target that is not a git repository gets a bounded walk.
|
|
18
|
+
*/
|
|
19
|
+
export declare function listRepoFiles(targetDir: string): Promise<{
|
|
20
|
+
files: string[];
|
|
21
|
+
snapshot: RepoSnapshotSource;
|
|
22
|
+
}>;
|
|
23
|
+
export declare function gitHead(targetDir: string): Promise<string | null>;
|
|
24
|
+
export declare function gitDirty(targetDir: string): Promise<boolean>;
|
|
25
|
+
/**
|
|
26
|
+
* Repo-relative paths changed since `baseHead` (or all files when there is
|
|
27
|
+
* no base). Returns null when the diff cannot be computed (non-git tree,
|
|
28
|
+
* missing base commit) so callers fall back to a full scan.
|
|
29
|
+
*/
|
|
30
|
+
export declare function changedFilesSince(targetDir: string, baseHead: string | null): Promise<Set<string> | null>;
|
|
31
|
+
export declare function collectRepoInfo(targetDir: string, opts?: {
|
|
32
|
+
redact?: boolean;
|
|
33
|
+
}): Promise<RepoInfo>;
|
|
34
|
+
export declare function isSlurpable(relPath: string): boolean;
|
|
35
|
+
export declare function sanitizeId(segment: string): string;
|
|
36
|
+
type CollectedFile = {
|
|
37
|
+
relPath: string;
|
|
38
|
+
moduleName: string;
|
|
39
|
+
};
|
|
40
|
+
/**
|
|
41
|
+
* The files a lens will read: its targeted globs, or — when those match no
|
|
42
|
+
* source file and the lens declares a fallback — the fallback globs on top
|
|
43
|
+
* of whatever did match, with a sentence saying so (#319). The sentence
|
|
44
|
+
* travels to the estimate, the batch entry, and the prompt, so a fallback
|
|
45
|
+
* scan is never a silent one.
|
|
46
|
+
*
|
|
47
|
+
* "No source file" rather than "no file": a policy document or a config
|
|
48
|
+
* file under a targeted path satisfies the globs and leaves the lens with
|
|
49
|
+
* nothing to review, and the coverage note it writes back is the only sign.
|
|
50
|
+
*/
|
|
51
|
+
export declare function selectLensFiles(allFiles: string[], lens: LensDefinition, info: RepoInfo): {
|
|
52
|
+
files: CollectedFile[];
|
|
53
|
+
fallback?: string;
|
|
54
|
+
};
|
|
55
|
+
export declare function gatherSlices(targetDir: string, lens: LensDefinition, info: RepoInfo, opts?: {
|
|
56
|
+
redact?: boolean;
|
|
57
|
+
}): Promise<FileSlice[]>;
|
|
58
|
+
export {};
|
|
@@ -0,0 +1,592 @@
|
|
|
1
|
+
// Repository intake: file listing, language detection, repo info, glob matching, lens scoping and fallback, file slurping into slices.
|
|
2
|
+
//
|
|
3
|
+
// Split out of core/broadside.ts (#339); the barrel there re-exports every
|
|
4
|
+
// name, so `core/index.ts` and the tests see one module as before.
|
|
5
|
+
import { readFile, readdir } from "node:fs/promises";
|
|
6
|
+
import { execFile } from "node:child_process";
|
|
7
|
+
import { promisify } from "node:util";
|
|
8
|
+
import { join, relative } from "node:path";
|
|
9
|
+
import { GIT_TIMEOUT_MS, pathExists } from "../utils.js";
|
|
10
|
+
import { isSecretFile, redactSecrets } from "../secrets.js";
|
|
11
|
+
const execFileAsync = promisify(execFile);
|
|
12
|
+
// ---------- repo info ----------
|
|
13
|
+
const SKIP_DIR_NAMES = new Set([
|
|
14
|
+
".git",
|
|
15
|
+
".github",
|
|
16
|
+
".claude",
|
|
17
|
+
".opencode",
|
|
18
|
+
".codecarto",
|
|
19
|
+
"node_modules",
|
|
20
|
+
"vendor",
|
|
21
|
+
"dist",
|
|
22
|
+
"build",
|
|
23
|
+
"target",
|
|
24
|
+
"testdata",
|
|
25
|
+
"__pycache__",
|
|
26
|
+
]);
|
|
27
|
+
const SKIP_FILE_EXTENSIONS = new Set([
|
|
28
|
+
".png",
|
|
29
|
+
".jpg",
|
|
30
|
+
".jpeg",
|
|
31
|
+
".gif",
|
|
32
|
+
".svg",
|
|
33
|
+
".ico",
|
|
34
|
+
".icns",
|
|
35
|
+
".bmp",
|
|
36
|
+
".webp",
|
|
37
|
+
".mp3",
|
|
38
|
+
".mp4",
|
|
39
|
+
".mov",
|
|
40
|
+
".avi",
|
|
41
|
+
".wav",
|
|
42
|
+
".ogg",
|
|
43
|
+
".zip",
|
|
44
|
+
".gz",
|
|
45
|
+
".tar",
|
|
46
|
+
".bz2",
|
|
47
|
+
".xz",
|
|
48
|
+
".7z",
|
|
49
|
+
".pdf",
|
|
50
|
+
".woff",
|
|
51
|
+
".woff2",
|
|
52
|
+
".ttf",
|
|
53
|
+
".eot",
|
|
54
|
+
".otf",
|
|
55
|
+
".bin",
|
|
56
|
+
".exe",
|
|
57
|
+
".dll",
|
|
58
|
+
".so",
|
|
59
|
+
".dylib",
|
|
60
|
+
".a",
|
|
61
|
+
".o",
|
|
62
|
+
".obj",
|
|
63
|
+
".class",
|
|
64
|
+
".jar",
|
|
65
|
+
".war",
|
|
66
|
+
".pyc",
|
|
67
|
+
".wasm",
|
|
68
|
+
".model",
|
|
69
|
+
".bpe",
|
|
70
|
+
]);
|
|
71
|
+
/**
|
|
72
|
+
* Manifest files and the languages each one can mean. `package.json` covers
|
|
73
|
+
* both TypeScript and JavaScript; which of the two a repository is comes from
|
|
74
|
+
* counting its source files, not from the manifest.
|
|
75
|
+
*/
|
|
76
|
+
export const MANIFEST_CANDIDATES = [
|
|
77
|
+
["go.mod", ["go"]],
|
|
78
|
+
["package.json", ["typescript", "javascript"]],
|
|
79
|
+
["Cargo.toml", ["rust"]],
|
|
80
|
+
["pyproject.toml", ["python"]],
|
|
81
|
+
["setup.py", ["python"]],
|
|
82
|
+
["requirements.txt", ["python"]],
|
|
83
|
+
];
|
|
84
|
+
/** The languages Broad-Side can scan; anything else is refused at submit. */
|
|
85
|
+
export const BROADSIDE_LANGUAGES = ["go", "python", "rust", "typescript", "javascript"];
|
|
86
|
+
/** Chars of the entry-point file and the manifest that ride in the architecture prompt (#249). */
|
|
87
|
+
const REPO_INFO_FILE_CAP = 20_000;
|
|
88
|
+
const SOURCE_SPECS = {
|
|
89
|
+
go: { glob: "**/*.go", exts: [".go"] },
|
|
90
|
+
python: { glob: "**/*.py", exts: [".py"] },
|
|
91
|
+
rust: { glob: "**/*.rs", exts: [".rs"] },
|
|
92
|
+
typescript: { glob: "**/*.ts", exts: [".ts", ".tsx"] },
|
|
93
|
+
javascript: { glob: "**/*.js", exts: [".js", ".jsx"] },
|
|
94
|
+
};
|
|
95
|
+
/**
|
|
96
|
+
* The files a run scans, and where they came from. Contents are always read
|
|
97
|
+
* from the working tree, so the list is the working tree's too: tracked files
|
|
98
|
+
* plus untracked ones git does not ignore, minus files deleted on disk. The
|
|
99
|
+
* list used to come from `git ls-tree HEAD`, so a run mixed the committed
|
|
100
|
+
* file list with uncommitted contents and never saw an untracked file (#248).
|
|
101
|
+
* A target that is not a git repository gets a bounded walk.
|
|
102
|
+
*/
|
|
103
|
+
export async function listRepoFiles(targetDir) {
|
|
104
|
+
try {
|
|
105
|
+
const listed = await execFileAsync("git", ["-C", targetDir, "ls-files", "-z", "--cached", "--others", "--exclude-standard"], { maxBuffer: 64 * 1024 * 1024, timeout: GIT_TIMEOUT_MS });
|
|
106
|
+
const deleted = await execFileAsync("git", ["-C", targetDir, "ls-files", "-z", "--deleted"], {
|
|
107
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
108
|
+
timeout: GIT_TIMEOUT_MS,
|
|
109
|
+
});
|
|
110
|
+
const gone = new Set(deleted.stdout.split("\0").filter(Boolean));
|
|
111
|
+
const files = listed.stdout.split("\0").filter((path) => path && !gone.has(path));
|
|
112
|
+
return { files, snapshot: "working-tree" };
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
return { files: await walkFiles(targetDir, targetDir, 0, 30_000), snapshot: "walk" };
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
export async function gitHead(targetDir) {
|
|
119
|
+
try {
|
|
120
|
+
const { stdout } = await execFileAsync("git", ["-C", targetDir, "rev-parse", "HEAD"], { maxBuffer: 1024 * 1024, timeout: GIT_TIMEOUT_MS });
|
|
121
|
+
return stdout.trim() || null;
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
export async function gitDirty(targetDir) {
|
|
128
|
+
try {
|
|
129
|
+
const { stdout } = await execFileAsync("git", ["-C", targetDir, "status", "--porcelain"], { maxBuffer: 1024 * 1024, timeout: GIT_TIMEOUT_MS });
|
|
130
|
+
return stdout.trim().length > 0;
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Repo-relative paths changed since `baseHead` (or all files when there is
|
|
138
|
+
* no base). Returns null when the diff cannot be computed (non-git tree,
|
|
139
|
+
* missing base commit) so callers fall back to a full scan.
|
|
140
|
+
*/
|
|
141
|
+
export async function changedFilesSince(targetDir, baseHead) {
|
|
142
|
+
if (!baseHead)
|
|
143
|
+
return null;
|
|
144
|
+
try {
|
|
145
|
+
const { stdout } = await execFileAsync("git", ["-C", targetDir, "diff", "--name-only", baseHead, "HEAD"], { maxBuffer: 64 * 1024 * 1024, timeout: GIT_TIMEOUT_MS });
|
|
146
|
+
return new Set(stdout.split("\n").filter(Boolean));
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
async function walkFiles(rootDir, dir, depth, remaining) {
|
|
153
|
+
if (remaining <= 0)
|
|
154
|
+
return [];
|
|
155
|
+
let out = [];
|
|
156
|
+
let entries = [];
|
|
157
|
+
try {
|
|
158
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
return out;
|
|
162
|
+
}
|
|
163
|
+
for (const entry of entries) {
|
|
164
|
+
if (entry.name.startsWith("."))
|
|
165
|
+
continue;
|
|
166
|
+
if (entry.isDirectory()) {
|
|
167
|
+
if (SKIP_DIR_NAMES.has(entry.name))
|
|
168
|
+
continue;
|
|
169
|
+
if (depth > 8)
|
|
170
|
+
continue;
|
|
171
|
+
const children = await walkFiles(rootDir, join(dir, entry.name), depth + 1, remaining - out.length);
|
|
172
|
+
out = out.concat(children);
|
|
173
|
+
}
|
|
174
|
+
else if (entry.isFile()) {
|
|
175
|
+
// relative() rather than slice(rootDir.length + 1): the hand-rolled
|
|
176
|
+
// slice cut one character too many whenever rootDir carried a trailing
|
|
177
|
+
// separator, and mangled every path outright when rootDir was "/".
|
|
178
|
+
const rel = relative(rootDir, join(dir, entry.name)).split("\\").join("/");
|
|
179
|
+
out.push(rel);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return out;
|
|
183
|
+
}
|
|
184
|
+
function sourceFileCount(language, fileCounts) {
|
|
185
|
+
return (SOURCE_SPECS[language]?.exts ?? []).reduce((sum, ext) => sum + (fileCounts[ext] ?? 0), 0);
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* The language the lenses scan as. The manifests present name the candidates
|
|
189
|
+
* (all of them, not the first one found: a Python service with a
|
|
190
|
+
* `package.json` for its docs tooling is not a TypeScript repository), and
|
|
191
|
+
* among candidates the one with the most source files wins; without a
|
|
192
|
+
* manifest, the language with the most source files; without any source
|
|
193
|
+
* file, `unknown` — which submit refuses rather than scanning nothing and
|
|
194
|
+
* paying for it (#250). Ties keep manifest order.
|
|
195
|
+
*/
|
|
196
|
+
function detectLanguage(fileCounts, manifestPaths) {
|
|
197
|
+
const candidates = [];
|
|
198
|
+
for (const [candidate, languages] of MANIFEST_CANDIDATES) {
|
|
199
|
+
if (!manifestPaths.includes(candidate))
|
|
200
|
+
continue;
|
|
201
|
+
for (const language of languages)
|
|
202
|
+
if (!candidates.includes(language))
|
|
203
|
+
candidates.push(language);
|
|
204
|
+
}
|
|
205
|
+
const pool = candidates.length > 0 ? candidates : [...BROADSIDE_LANGUAGES];
|
|
206
|
+
let best = null;
|
|
207
|
+
let bestCount = -1;
|
|
208
|
+
for (const language of pool) {
|
|
209
|
+
const count = sourceFileCount(language, fileCounts);
|
|
210
|
+
if (count > bestCount) {
|
|
211
|
+
best = language;
|
|
212
|
+
bestCount = count;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
if (bestCount > 0)
|
|
216
|
+
return best;
|
|
217
|
+
// A manifest with no source files behind it still names the language;
|
|
218
|
+
// submit reports the empty count. No manifest and no source: unknown.
|
|
219
|
+
return candidates[0] ?? "unknown";
|
|
220
|
+
}
|
|
221
|
+
/** Cut a file that rides whole in a prompt down to the cap, saying so (#249). */
|
|
222
|
+
function capForPrompt(content, cap) {
|
|
223
|
+
if (content.length <= cap)
|
|
224
|
+
return content;
|
|
225
|
+
return `${content.slice(0, cap)}\n… [truncated: ${cap.toLocaleString()} of ${content.length.toLocaleString()} chars shown]\n`;
|
|
226
|
+
}
|
|
227
|
+
export async function collectRepoInfo(targetDir, opts = {}) {
|
|
228
|
+
const redact = opts.redact ?? true;
|
|
229
|
+
const { files: allFiles, snapshot } = await listRepoFiles(targetDir);
|
|
230
|
+
// Named credential stores are out of every lens (isSlurpable); listed here
|
|
231
|
+
// so the submit report can say so.
|
|
232
|
+
const secretFilesSkipped = allFiles.filter((path) => isSecretFile(path)).sort();
|
|
233
|
+
let redactedValues = 0;
|
|
234
|
+
// The entry point, manifest, and README ride in the architecture prompt
|
|
235
|
+
// as text, so they get the same pass the slices do (#252).
|
|
236
|
+
const clean = (text) => {
|
|
237
|
+
if (!redact)
|
|
238
|
+
return text;
|
|
239
|
+
const redaction = redactSecrets(text);
|
|
240
|
+
redactedValues += redaction.count;
|
|
241
|
+
return redaction.text;
|
|
242
|
+
};
|
|
243
|
+
const fileCounts = {};
|
|
244
|
+
for (const f of allFiles) {
|
|
245
|
+
const slash = f.lastIndexOf("/");
|
|
246
|
+
const base = slash >= 0 ? f.slice(slash + 1) : f;
|
|
247
|
+
const dot = base.lastIndexOf(".");
|
|
248
|
+
const ext = dot > 0 ? base.slice(dot).toLowerCase() : "(no ext)";
|
|
249
|
+
fileCounts[ext] = (fileCounts[ext] ?? 0) + 1;
|
|
250
|
+
}
|
|
251
|
+
const sortedCounts = {};
|
|
252
|
+
for (const [ext, n] of Object.entries(fileCounts).sort((a, b) => b[1] - a[1])) {
|
|
253
|
+
sortedCounts[ext] = n;
|
|
254
|
+
}
|
|
255
|
+
// Every manifest present counts toward language detection; the first one
|
|
256
|
+
// found is the one the architecture prompt shows.
|
|
257
|
+
const manifestPaths = [];
|
|
258
|
+
for (const [candidate] of MANIFEST_CANDIDATES) {
|
|
259
|
+
if (await pathExists(join(targetDir, candidate)))
|
|
260
|
+
manifestPaths.push(candidate);
|
|
261
|
+
}
|
|
262
|
+
const language = detectLanguage(sortedCounts, manifestPaths);
|
|
263
|
+
// Show the manifest that belongs to the detected language when there is
|
|
264
|
+
// one, so a polyglot repo's prompt does not open with the other stack's file.
|
|
265
|
+
const manifestPath = manifestPaths.find((path) => MANIFEST_CANDIDATES.find(([candidate]) => candidate === path)?.[1].includes(language))
|
|
266
|
+
?? manifestPaths[0]
|
|
267
|
+
?? null;
|
|
268
|
+
let manifest = null;
|
|
269
|
+
if (manifestPath) {
|
|
270
|
+
try {
|
|
271
|
+
manifest = { path: manifestPath, content: capForPrompt(clean(await readFile(join(targetDir, manifestPath), "utf8")), REPO_INFO_FILE_CAP) };
|
|
272
|
+
}
|
|
273
|
+
catch {
|
|
274
|
+
manifest = null;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
// Read whole and unbounded before, and then estimated at a flat 6,000
|
|
278
|
+
// chars: a large entry point shipped in full while the cap was checked
|
|
279
|
+
// against a number that had nothing to do with it (#249).
|
|
280
|
+
let mainFile = "";
|
|
281
|
+
for (const candidate of ["main.go", "main.py", "src/main.rs", "src/index.ts", "index.ts", "src/index.js", "index.js"]) {
|
|
282
|
+
const p = join(targetDir, candidate);
|
|
283
|
+
if (await pathExists(p)) {
|
|
284
|
+
try {
|
|
285
|
+
mainFile = capForPrompt(clean(await readFile(p, "utf8")), REPO_INFO_FILE_CAP);
|
|
286
|
+
}
|
|
287
|
+
catch {
|
|
288
|
+
mainFile = "";
|
|
289
|
+
}
|
|
290
|
+
break;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
let readmeFirst = "";
|
|
294
|
+
const readmePath = join(targetDir, "README.md");
|
|
295
|
+
if (await pathExists(readmePath)) {
|
|
296
|
+
try {
|
|
297
|
+
readmeFirst = clean((await readFile(readmePath, "utf8")).slice(0, 4000));
|
|
298
|
+
}
|
|
299
|
+
catch {
|
|
300
|
+
readmeFirst = "";
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
const fileTree = buildFileTree(allFiles);
|
|
304
|
+
// An unknown language used to fall through to Go's globs, so the code
|
|
305
|
+
// lenses matched nothing and the run paid for empty batches (#250).
|
|
306
|
+
const sourceSpec = SOURCE_SPECS[language] ?? { glob: "", exts: [] };
|
|
307
|
+
const name = targetDir.split(/[\\/]/).filter(Boolean).pop() ?? "repo";
|
|
308
|
+
const sourceFiles = allFiles.filter((path) => isSlurpable(path) && sourceSpec.exts.some((ext) => path.toLowerCase().endsWith(ext))).length;
|
|
309
|
+
return {
|
|
310
|
+
name,
|
|
311
|
+
path: targetDir,
|
|
312
|
+
language,
|
|
313
|
+
manifest,
|
|
314
|
+
mainFile,
|
|
315
|
+
readmeFirst,
|
|
316
|
+
fileTree,
|
|
317
|
+
fileCounts: sortedCounts,
|
|
318
|
+
sourceGlob: sourceSpec.glob,
|
|
319
|
+
sourceExts: sourceSpec.exts,
|
|
320
|
+
sourceFileCount: sourceFiles,
|
|
321
|
+
snapshot,
|
|
322
|
+
secretFilesSkipped,
|
|
323
|
+
redactedValues,
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
function buildFileTree(allFiles, maxDepth = 3, maxLines = 200) {
|
|
327
|
+
const lines = [];
|
|
328
|
+
let count = 0;
|
|
329
|
+
for (const f of allFiles) {
|
|
330
|
+
if (f.split("/").length - 1 > maxDepth)
|
|
331
|
+
continue;
|
|
332
|
+
if (f.startsWith(".git/") || f.startsWith(".github/"))
|
|
333
|
+
continue;
|
|
334
|
+
if (f.endsWith(".sum") || f.endsWith(".lock"))
|
|
335
|
+
continue;
|
|
336
|
+
lines.push(f);
|
|
337
|
+
count += 1;
|
|
338
|
+
if (count >= maxLines) {
|
|
339
|
+
lines.push(`... (${allFiles.length} total files, showing first ${maxLines})`);
|
|
340
|
+
break;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
return lines.join("\n");
|
|
344
|
+
}
|
|
345
|
+
// ---------- glob matching & file slurping ----------
|
|
346
|
+
function globToRegExp(glob) {
|
|
347
|
+
let re = "";
|
|
348
|
+
for (let i = 0; i < glob.length; i++) {
|
|
349
|
+
const c = glob[i];
|
|
350
|
+
if (c === "*") {
|
|
351
|
+
if (glob[i + 1] === "*") {
|
|
352
|
+
// `**/` matches zero or more directories; a trailing `**`
|
|
353
|
+
// matches anything including slashes.
|
|
354
|
+
if (glob[i + 2] === "/") {
|
|
355
|
+
re += "(?:.*/)?";
|
|
356
|
+
i += 2;
|
|
357
|
+
}
|
|
358
|
+
else {
|
|
359
|
+
re += ".*";
|
|
360
|
+
i += 1;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
else {
|
|
364
|
+
re += "[^/]*";
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
else if (c === "?") {
|
|
368
|
+
re += "[^/]";
|
|
369
|
+
}
|
|
370
|
+
else {
|
|
371
|
+
re += c.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
return new RegExp(`^${re}$`);
|
|
375
|
+
}
|
|
376
|
+
function matchesAnyGlob(path, globs) {
|
|
377
|
+
for (const glob of globs) {
|
|
378
|
+
if (globToRegExp(glob).test(path))
|
|
379
|
+
return true;
|
|
380
|
+
}
|
|
381
|
+
return false;
|
|
382
|
+
}
|
|
383
|
+
export function isSlurpable(relPath) {
|
|
384
|
+
// A credential store is never a lens input, whatever its globs say (#252).
|
|
385
|
+
if (isSecretFile(relPath))
|
|
386
|
+
return false;
|
|
387
|
+
const segments = relPath.split("/");
|
|
388
|
+
for (const seg of segments) {
|
|
389
|
+
if (SKIP_DIR_NAMES.has(seg))
|
|
390
|
+
return false;
|
|
391
|
+
}
|
|
392
|
+
const slash = relPath.lastIndexOf("/");
|
|
393
|
+
const base = slash >= 0 ? relPath.slice(slash + 1) : relPath;
|
|
394
|
+
const dot = base.lastIndexOf(".");
|
|
395
|
+
if (dot > 0 && SKIP_FILE_EXTENSIONS.has(base.slice(dot).toLowerCase()))
|
|
396
|
+
return false;
|
|
397
|
+
return true;
|
|
398
|
+
}
|
|
399
|
+
export function sanitizeId(segment) {
|
|
400
|
+
return segment.replace(/[^a-zA-Z0-9_-]+/g, "-").replace(/^-+|-+$/g, "") || "root";
|
|
401
|
+
}
|
|
402
|
+
function topLevelModule(relPath) {
|
|
403
|
+
const slash = relPath.indexOf("/");
|
|
404
|
+
return slash >= 0 ? relPath.slice(0, slash) : "root";
|
|
405
|
+
}
|
|
406
|
+
function isTestFile(relPath) {
|
|
407
|
+
const base = relPath.slice(relPath.lastIndexOf("/") + 1);
|
|
408
|
+
return /[._](test|spec)\.[a-z]+$/i.test(base) || base.includes("_test.");
|
|
409
|
+
}
|
|
410
|
+
/**
|
|
411
|
+
* "auto" slicing: directory-slice when the repo is large enough that a
|
|
412
|
+
* single whole-repo slice would overflow the lens's char cap, otherwise a
|
|
413
|
+
* single slice. The threshold is the lens's own cap — a repo whose matching
|
|
414
|
+
* files fit in one slice gains nothing from per-module splitting, and a
|
|
415
|
+
* small repo pays for it in extra requests.
|
|
416
|
+
*/
|
|
417
|
+
function resolveSliceMode(lens, files, totalChars) {
|
|
418
|
+
if (lens.sliceBy !== "auto")
|
|
419
|
+
return lens.sliceBy;
|
|
420
|
+
return totalChars > lens.maxChars ? "directory" : "none";
|
|
421
|
+
}
|
|
422
|
+
function collectFilesMatching(allFiles, lens, globs) {
|
|
423
|
+
if (globs.length === 0)
|
|
424
|
+
return [];
|
|
425
|
+
const out = [];
|
|
426
|
+
for (const f of allFiles) {
|
|
427
|
+
if (!isSlurpable(f))
|
|
428
|
+
continue;
|
|
429
|
+
if (lens.skipTestFiles && isTestFile(f))
|
|
430
|
+
continue;
|
|
431
|
+
if (!matchesAnyGlob(f, globs))
|
|
432
|
+
continue;
|
|
433
|
+
out.push({ relPath: f, moduleName: topLevelModule(f) });
|
|
434
|
+
}
|
|
435
|
+
return out;
|
|
436
|
+
}
|
|
437
|
+
/** Code in any language Broad-Side scans as, whatever this repo's is. */
|
|
438
|
+
const SOURCE_EXTENSIONS = new Set(Object.values(SOURCE_SPECS).flatMap((spec) => spec.exts));
|
|
439
|
+
function isSourceFile(relPath) {
|
|
440
|
+
const dot = relPath.lastIndexOf(".");
|
|
441
|
+
return dot > relPath.lastIndexOf("/") && SOURCE_EXTENSIONS.has(relPath.slice(dot).toLowerCase());
|
|
442
|
+
}
|
|
443
|
+
/** `a, b, c and 4 more` — a matched-file list short enough for a status line. */
|
|
444
|
+
function listSome(paths, max = 3) {
|
|
445
|
+
if (paths.length <= max)
|
|
446
|
+
return paths.join(", ");
|
|
447
|
+
return `${paths.slice(0, max).join(", ")} and ${paths.length - max} more`;
|
|
448
|
+
}
|
|
449
|
+
/**
|
|
450
|
+
* The files a lens will read: its targeted globs, or — when those match no
|
|
451
|
+
* source file and the lens declares a fallback — the fallback globs on top
|
|
452
|
+
* of whatever did match, with a sentence saying so (#319). The sentence
|
|
453
|
+
* travels to the estimate, the batch entry, and the prompt, so a fallback
|
|
454
|
+
* scan is never a silent one.
|
|
455
|
+
*
|
|
456
|
+
* "No source file" rather than "no file": a policy document or a config
|
|
457
|
+
* file under a targeted path satisfies the globs and leaves the lens with
|
|
458
|
+
* nothing to review, and the coverage note it writes back is the only sign.
|
|
459
|
+
*/
|
|
460
|
+
export function selectLensFiles(allFiles, lens, info) {
|
|
461
|
+
const globs = lens.globsFor(info).filter(Boolean);
|
|
462
|
+
const targeted = collectFilesMatching(allFiles, lens, globs);
|
|
463
|
+
if (globs.length === 0 || !lens.fallbackGlobsFor)
|
|
464
|
+
return { files: targeted };
|
|
465
|
+
if (targeted.some((f) => isSourceFile(f.relPath)))
|
|
466
|
+
return { files: targeted };
|
|
467
|
+
const fallbackGlobs = lens.fallbackGlobsFor(info).filter(Boolean);
|
|
468
|
+
const matched = new Set(targeted.map((f) => f.relPath));
|
|
469
|
+
const sources = collectFilesMatching(allFiles, lens, fallbackGlobs).filter((f) => !matched.has(f.relPath));
|
|
470
|
+
if (sources.length === 0)
|
|
471
|
+
return { files: targeted };
|
|
472
|
+
const excluded = lens.skipTestFiles ? "test files excluded" : "";
|
|
473
|
+
const scanned = `scanned all ${info.language} sources (${fallbackGlobs.join(", ")})`;
|
|
474
|
+
return {
|
|
475
|
+
// What did match rides first: the policy the model is about to check
|
|
476
|
+
// the code against, ahead of the code.
|
|
477
|
+
files: [...targeted, ...sources],
|
|
478
|
+
fallback: targeted.length === 0
|
|
479
|
+
? `no files matched ${globs.join(", ")}${excluded ? ` (${excluded})` : ""}; ${scanned} instead`
|
|
480
|
+
: `no source files matched ${globs.join(", ")} (only ${listSome(targeted.map((f) => f.relPath))}` +
|
|
481
|
+
`${excluded ? `; ${excluded}` : ""}); ${scanned} as well`,
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
async function slurpFileList(targetDir, files, maxChars, redact = true) {
|
|
485
|
+
const slices = [];
|
|
486
|
+
let currentModule = "";
|
|
487
|
+
let parts = [];
|
|
488
|
+
let running = 0;
|
|
489
|
+
let fileCount = 0;
|
|
490
|
+
let filePaths = [];
|
|
491
|
+
let redactedValues = 0;
|
|
492
|
+
let redactedFiles = [];
|
|
493
|
+
const flush = () => {
|
|
494
|
+
if (parts.length === 0)
|
|
495
|
+
return;
|
|
496
|
+
slices.push({
|
|
497
|
+
moduleName: currentModule,
|
|
498
|
+
content: parts.join("\n"),
|
|
499
|
+
fileCount,
|
|
500
|
+
chars: running,
|
|
501
|
+
files: filePaths,
|
|
502
|
+
redactedValues,
|
|
503
|
+
redactedFiles,
|
|
504
|
+
});
|
|
505
|
+
parts = [];
|
|
506
|
+
running = 0;
|
|
507
|
+
fileCount = 0;
|
|
508
|
+
filePaths = [];
|
|
509
|
+
redactedValues = 0;
|
|
510
|
+
redactedFiles = [];
|
|
511
|
+
};
|
|
512
|
+
for (const file of files) {
|
|
513
|
+
let content = "";
|
|
514
|
+
try {
|
|
515
|
+
content = await readFile(join(targetDir, file.relPath), "utf8");
|
|
516
|
+
}
|
|
517
|
+
catch (error) {
|
|
518
|
+
// The listing is the working tree's, so this is a race with a
|
|
519
|
+
// concurrent delete rather than a listed-but-deleted file; skip it.
|
|
520
|
+
if (error.code === "ENOENT")
|
|
521
|
+
continue;
|
|
522
|
+
content = "[BINARY or UNREADABLE]";
|
|
523
|
+
}
|
|
524
|
+
if (redact) {
|
|
525
|
+
// Before the slice is built, so the count and the chars the estimate
|
|
526
|
+
// sees are of what is actually sent (#252).
|
|
527
|
+
const redaction = redactSecrets(content);
|
|
528
|
+
if (redaction.count > 0) {
|
|
529
|
+
content = redaction.text;
|
|
530
|
+
redactedValues += redaction.count;
|
|
531
|
+
redactedFiles.push(file.relPath);
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
const block = `=== ${file.relPath} ===\n${content}\n`;
|
|
535
|
+
if (file.moduleName !== currentModule && parts.length > 0) {
|
|
536
|
+
flush();
|
|
537
|
+
}
|
|
538
|
+
currentModule = file.moduleName;
|
|
539
|
+
if (running + block.length > maxChars && parts.length > 0) {
|
|
540
|
+
// Slice is full: flush it and start another slice for the same module
|
|
541
|
+
// rather than truncating, so big modules get full coverage.
|
|
542
|
+
flush();
|
|
543
|
+
currentModule = file.moduleName;
|
|
544
|
+
}
|
|
545
|
+
parts.push(block);
|
|
546
|
+
running += block.length;
|
|
547
|
+
fileCount += 1;
|
|
548
|
+
filePaths.push(file.relPath);
|
|
549
|
+
}
|
|
550
|
+
flush();
|
|
551
|
+
return slices;
|
|
552
|
+
}
|
|
553
|
+
export async function gatherSlices(targetDir, lens, info, opts = {}) {
|
|
554
|
+
const redact = opts.redact ?? true;
|
|
555
|
+
if (lens.sliceBy === "none" && lens.globsFor(info).length === 0) {
|
|
556
|
+
// Repo-info lens (architecture): the prompt is built from info alone.
|
|
557
|
+
return [{ moduleName: "root", content: "", fileCount: 0, chars: 0, files: [] }];
|
|
558
|
+
}
|
|
559
|
+
const { files: allFiles } = await listRepoFiles(targetDir);
|
|
560
|
+
const { files, fallback } = selectLensFiles(allFiles, lens, info);
|
|
561
|
+
const totalChars = await sumFileChars(targetDir, files);
|
|
562
|
+
const mode = resolveSliceMode(lens, files, totalChars);
|
|
563
|
+
const slices = mode === "none"
|
|
564
|
+
// Whole-repo slice: one module named after the repo, so a small
|
|
565
|
+
// repo produces a single request instead of one per directory.
|
|
566
|
+
? await slurpFileList(targetDir, files.map((f) => ({ ...f, moduleName: info.name })), lens.maxChars, redact)
|
|
567
|
+
: await slurpFileList(targetDir, files, lens.maxChars, redact);
|
|
568
|
+
if (fallback)
|
|
569
|
+
for (const slice of slices)
|
|
570
|
+
slice.fallback = fallback;
|
|
571
|
+
return slices;
|
|
572
|
+
}
|
|
573
|
+
/**
|
|
574
|
+
* The files' total length in the unit the slices are capped in — UTF-16
|
|
575
|
+
* code units, what `slurpFileList` counts — not bytes (#368): a byte total
|
|
576
|
+
* split a non-ASCII repository per directory when one slice would have
|
|
577
|
+
* held it. The read is repeated by slurpFileList; the files are the ones
|
|
578
|
+
* about to be uploaded, so the cost is a second pass over what is read
|
|
579
|
+
* anyway.
|
|
580
|
+
*/
|
|
581
|
+
async function sumFileChars(targetDir, files) {
|
|
582
|
+
let total = 0;
|
|
583
|
+
for (const f of files) {
|
|
584
|
+
try {
|
|
585
|
+
total += (await readFile(join(targetDir, f.relPath), "utf8")).length;
|
|
586
|
+
}
|
|
587
|
+
catch {
|
|
588
|
+
// Unreadable file — slurpFileList substitutes a placeholder.
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
return total;
|
|
592
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { type BatchRequest, type BroadsideReasoning, type FileSlice, type ModelPricing, type RepoInfo } from "./types.ts";
|
|
2
|
+
import { type LensDefinition } from "./lenses.ts";
|
|
3
|
+
export declare function buildBatchRequest(lens: LensDefinition, info: RepoInfo, slice: FileSlice, index: number, sliceCount: number, model?: string, maxTokensOverride?: number, reasoningOverride?: BroadsideReasoning): BatchRequest;
|
|
4
|
+
/**
|
|
5
|
+
* Pre-flight cost estimate for one lens.
|
|
6
|
+
*
|
|
7
|
+
* Every slice is its own batch request, so both halves scale with the slice
|
|
8
|
+
* count. The output half used to be a single `maxTokens * 0.75` for the whole
|
|
9
|
+
* lens no matter how many requests it sent — on a repository that sliced into
|
|
10
|
+
* 13 modules that budgeted one request's output and shipped thirteen, and a
|
|
11
|
+
* live run came in at roughly 3x its estimate. Since this number is what
|
|
12
|
+
* `max_cost` binds against, under-counting it lets a run outspend the cap the
|
|
13
|
+
* user set.
|
|
14
|
+
*
|
|
15
|
+
* @param info - Repo info, when the caller has it: lets the estimate include
|
|
16
|
+
* the system prompt and JSON schema each request carries. Omitted, the
|
|
17
|
+
* estimate covers slice content only, which is what the old signature did.
|
|
18
|
+
*/
|
|
19
|
+
export declare function estimateCost(lens: LensDefinition, slices: FileSlice[], pricing: ModelPricing, maxTokensOverride?: number, info?: RepoInfo): {
|
|
20
|
+
inputTokens: number;
|
|
21
|
+
outputTokens: number;
|
|
22
|
+
cost: number;
|
|
23
|
+
};
|