codecartographer-pi 0.24.1 → 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.
Files changed (55) hide show
  1. package/.codecarto/broadside/SKILL.md +20 -1
  2. package/.codecarto/workflow/scaffold-version.yaml +1 -1
  3. package/README.md +5 -4
  4. package/agent-skill/codecartographer/references/broadside.md +5 -1
  5. package/dist/core/broadside/client.d.ts +56 -0
  6. package/dist/core/broadside/client.js +200 -0
  7. package/dist/core/broadside/collect.d.ts +68 -0
  8. package/dist/core/broadside/collect.js +676 -0
  9. package/dist/core/broadside/constants.d.ts +51 -0
  10. package/dist/core/broadside/constants.js +74 -0
  11. package/dist/core/broadside/lenses.d.ts +31 -0
  12. package/dist/core/broadside/lenses.js +312 -0
  13. package/dist/core/broadside/models.d.ts +46 -0
  14. package/dist/core/broadside/models.js +321 -0
  15. package/dist/core/broadside/render.d.ts +20 -0
  16. package/dist/core/broadside/render.js +285 -0
  17. package/dist/core/broadside/repo.d.ts +58 -0
  18. package/dist/core/broadside/repo.js +592 -0
  19. package/dist/core/broadside/requests.d.ts +23 -0
  20. package/dist/core/broadside/requests.js +71 -0
  21. package/dist/core/broadside/results.d.ts +36 -0
  22. package/dist/core/broadside/results.js +163 -0
  23. package/dist/core/broadside/schemas.d.ts +2 -0
  24. package/dist/core/broadside/schemas.js +342 -0
  25. package/dist/core/broadside/state.d.ts +99 -0
  26. package/dist/core/broadside/state.js +384 -0
  27. package/dist/core/broadside/submit.d.ts +30 -0
  28. package/dist/core/broadside/submit.js +350 -0
  29. package/dist/core/broadside/types.d.ts +491 -0
  30. package/dist/core/broadside/types.js +107 -0
  31. package/dist/core/{broadside-verify.d.ts → broadside/verify.d.ts} +23 -2
  32. package/dist/core/{broadside-verify.js → broadside/verify.js} +43 -5
  33. package/dist/core/broadside.d.ts +14 -890
  34. package/dist/core/broadside.js +25 -3564
  35. package/dist/core/completion.js +91 -72
  36. package/dist/core/dashboard-writer.js +9 -1
  37. package/dist/core/index.d.ts +0 -1
  38. package/dist/core/index.js +0 -1
  39. package/dist/core/library.d.ts +24 -1
  40. package/dist/core/library.js +46 -15
  41. package/dist/core/orchestrator-config.js +22 -8
  42. package/dist/core/status.d.ts +42 -23
  43. package/dist/core/status.js +163 -137
  44. package/dist/core/workspace.d.ts +2 -0
  45. package/dist/core/workspace.js +49 -25
  46. package/dist/core/yaml.js +9 -3
  47. package/dist/extensions/codecarto/auto-runner.d.ts +7 -0
  48. package/dist/extensions/codecarto/auto-runner.js +54 -23
  49. package/dist/extensions/codecarto/broadside-flags.d.ts +3 -1
  50. package/dist/extensions/codecarto/broadside-flags.js +13 -0
  51. package/dist/extensions/codecarto/index.js +13 -7
  52. package/dist/extensions/codecarto/phase-compaction.js +6 -2
  53. package/dist/mcp-server/server.d.ts +1 -0
  54. package/dist/mcp-server/server.js +28 -5
  55. 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
+ };