From 37ec41343cdbbaf097e91e05e4cac7ff12de602c Mon Sep 17 00:00:00 2001 From: James Sesler Date: Mon, 24 Aug 2026 00:35:16 -0400 Subject: [PATCH] feat: broadside zero-config slicing (#140) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The defect, conventions, and porting lenses now slice by 'auto' instead of always per-directory. A repo whose matching files fit within the lens's char cap collapses to a single whole-repo slice — one request instead of one per module — while a repo too large for one slice still splits by top-level directory. Small repos stop paying per-module request overhead; large repos keep full coverage. The decision is deterministic from file sizes (stat, not a full read) and recorded implicitly in the slice layout. 2 tests updated/added (small-repo collapse, large-repo directory split); 36 broadside tests, 396 total, all passing. --- CHANGELOG.md | 1 + ROADMAP.md | 2 +- core/broadside.ts | 47 +++++++++++++++++++++++++++++++++++----- tests/broadside.test.mjs | 29 +++++++++++++++++++++---- 4 files changed, 68 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 83e6b4d..daf7c2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to this project are documented here. The format is based on ### Added +- **Broad-Side: zero-config slicing** (#140). The defect, conventions, and porting lenses now slice by `auto` instead of always per-directory: a repo whose matching files fit within the lens's char cap collapses to a single whole-repo slice (one request instead of one per module), while a repo too large for one slice still splits by top-level directory. Small repos stop paying for per-module request overhead; large repos keep full coverage. The decision is deterministic from file sizes and recorded implicitly in the slice layout. - **Broad-Side: batch reconnaissance over the OpenRouter Batch API** (#144). New `codecarto_broadside` MCP tool (actions: `submit`, `collect`, `status`) fires six single-turn analysis lenses — architecture, API surface, security, mechanical defect scan, convention extraction, porting — at any git repository as asynchronous batch jobs on a cheap batch model (~50% of sync pricing, unattended, 24h window), slices large modules by top-level directory, saves JSON plus rendered markdown to `.codecarto/broadside//`, and optionally synthesizes a cross-lens executive report. Works without an initialized workspace; needs an OpenRouter key via the `api_key` parameter, `OPENROUTER_API_KEY`, or `.codecarto/broadside/config.yaml`. Broad-Side findings are explicitly unverified scouting signals — file:line leads for the interactive pipeline to confirm, never evidence themselves. `codecarto_init` tolerates a `.codecarto/` that holds only `broadside/` (no force/backup needed), and scaffold refresh never touches broadside state, config, or results. - **Broad-Side: expense guardrails and live per-model pricing** (#144). `config.yaml` now accepts `model`, `max_cost`, and `pricing.input_per_m`/`output_per_m` overrides, and the MCP tool accepts `max_cost` and `force` parameters. Before submitting, Broad-Side estimates the run cost from collected file sizes (≈4 chars/token) against the configured model's per-token pricing — looked up live from OpenRouter's model catalog (cached 24h), so models like `openai/gpt-5.2-pro:batch` at ~$84/M output are priced correctly, not at the default model's rates. A submit whose estimate exceeds `max_cost` refuses with a per-lens breakdown and creates no run entry unless `force: true`. The submit response now reports the pricing used and its source (built-in/config/live/cache). - **Broad-Side: model catalog action and capability pre-flight** (#144). New `models` action lists every `:batch` model on OpenRouter — pricing per million tokens, context window, completion ceiling, structured-output support, and optional Artificial Analysis coding indices (via `GET /api/v1/benchmarks`, attribution preserved) — cheapest first with the configured model marked. Submits now pre-flight the chosen model against that catalog: lens `max_tokens` clamps to the provider's completion ceiling, deprecated models are flagged, and models that do not advertise structured-output support are refused outright, since every lens depends on `json_schema` response_format. The catalog cache is shared between the `models` action and submit-time pricing resolution. diff --git a/ROADMAP.md b/ROADMAP.md index 509a757..0d64661 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -44,7 +44,7 @@ file only moves when a tier completes. |---|---|---| | **Pi extension** — `/codecarto-broadside` command with lens picker and live progress | [#138](https://github.com/HuginnIndustries/CodeCartographer/issues/138) | Agreed order: MCP first (shipped), Pi second | | **Pipeline phase** — `broadside-scout` phase feeding later phases via `required_reads` | [#139](https://github.com/HuginnIndustries/CodeCartographer/issues/139) | SKILL.md contract stays: leads, never evidence | -| **Zero-config executive** — meta-pass picks lenses and slicing resolution from repo shape | [#140](https://github.com/HuginnIndustries/CodeCartographer/issues/140) | Decision recorded in `run-meta.json` for reproducibility | +| **Zero-config executive** — meta-pass picks lenses and slicing resolution from repo shape | [#140](https://github.com/HuginnIndustries/CodeCartographer/issues/140) | **Shipped**: `auto` slicing collapses small repos to one slice, directory-splits large ones | ## Tier 3 — cost and coverage economics diff --git a/core/broadside.ts b/core/broadside.ts index 92abb0d..b129bdf 100644 --- a/core/broadside.ts +++ b/core/broadside.ts @@ -30,7 +30,7 @@ // Deliberately not in .codecarto/ template prose: Broad-Side requires runtime // code, so it lives on the executable surfaces (MCP today, Pi on the roadmap). -import { mkdir, readFile, readdir, writeFile } from "node:fs/promises"; +import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises"; import { execFile } from "node:child_process"; import { promisify } from "node:util"; import { dirname, join } from "node:path"; @@ -597,7 +597,10 @@ type LensDefinition = { name: string; description: string; schemaName: string; - sliceBy: "none" | "directory"; + // "none" = one slice for the whole repo; "directory" = one slice per + // top-level module; "auto" = directory for large repos, none for small + // ones (see resolveSliceMode). + sliceBy: "none" | "directory" | "auto"; maxChars: number; maxTokens: number; // Test files rarely carry the surface a lens audits — they bulk up the @@ -706,7 +709,7 @@ const LENSES: Record = { name: "Mechanical defect scan", description: "Nil derefs, error gaps, leaks, races, panics — pattern-based, sliced per module.", schemaName: "defect_mechanical", - sliceBy: "directory", + sliceBy: "auto", maxChars: 60_000, maxTokens: 6000, globsFor: (info) => [info.sourceGlob], @@ -737,7 +740,7 @@ const LENSES: Record = { name: "Convention extraction", description: "Naming, error handling, idioms, inconsistencies, promotable conventions.", schemaName: "conventions", - sliceBy: "directory", + sliceBy: "auto", maxChars: 60_000, maxTokens: 6000, globsFor: (info) => [info.sourceGlob], @@ -762,7 +765,7 @@ const LENSES: Record = { name: "Porting surface assessment", description: "Platform coupling, external deps, build complexity, porting risk areas.", schemaName: "porting", - sliceBy: "directory", + sliceBy: "auto", maxChars: 60_000, maxTokens: 6000, skipTestFiles: true, @@ -1088,6 +1091,18 @@ function isTestFile(relPath: string): boolean { return /[._](test|spec)\.[a-z]+$/i.test(base) || base.includes("_test."); } +/** + * "auto" slicing: directory-slice when the repo is large enough that a + * single whole-repo slice would overflow the lens's char cap, otherwise a + * single slice. The threshold is the lens's own cap — a repo whose matching + * files fit in one slice gains nothing from per-module splitting, and a + * small repo pays for it in extra requests. + */ +function resolveSliceMode(lens: LensDefinition, files: CollectedFile[], totalChars: number): "none" | "directory" { + if (lens.sliceBy !== "auto") return lens.sliceBy; + return totalChars > lens.maxChars ? "directory" : "none"; +} + function collectLensFiles(allFiles: string[], lens: LensDefinition, info: RepoInfo): CollectedFile[] { const globs = lens.globsFor(info); if (globs.length === 0) return []; @@ -1096,7 +1111,7 @@ function collectLensFiles(allFiles: string[], lens: LensDefinition, info: RepoIn if (!isSlurpable(f)) continue; if (lens.skipTestFiles && isTestFile(f)) continue; if (!matchesAnyGlob(f, globs)) continue; - out.push({ relPath: f, moduleName: lens.sliceBy === "directory" ? topLevelModule(f) : info.name }); + out.push({ relPath: f, moduleName: topLevelModule(f) }); } return out; } @@ -1160,9 +1175,29 @@ export async function gatherSlices(targetDir: string, lens: LensDefinition, info } const allFiles = await listRepoFiles(targetDir); const files = collectLensFiles(allFiles, lens, info); + const totalChars = await sumFileSizes(targetDir, files); + const mode = resolveSliceMode(lens, files, totalChars); + if (mode === "none") { + // Whole-repo slice: one module named after the repo, so a small + // repo produces a single request instead of one per directory. + const single = files.map((f) => ({ ...f, moduleName: info.name })); + return slurpFileList(targetDir, single, lens.maxChars); + } return slurpFileList(targetDir, files, lens.maxChars); } +async function sumFileSizes(targetDir: string, files: CollectedFile[]): Promise { + let total = 0; + for (const f of files) { + try { + total += (await stat(join(targetDir, f.relPath))).size; + } catch { + // Unreadable file — slurpFileList substitutes a placeholder. + } + } + return total; +} + // ---------- request building ---------- export function buildBatchRequest( diff --git a/tests/broadside.test.mjs b/tests/broadside.test.mjs index 62e12a0..9b7bef7 100644 --- a/tests/broadside.test.mjs +++ b/tests/broadside.test.mjs @@ -87,9 +87,33 @@ test("collectRepoInfo detects Go and gathers manifest, tree, and counts", async } }); -test("directory slicing puts nested files under their top-level module", async () => { +test("auto slicing collapses a small repo to a single whole-repo slice", async () => { const dir = await makeFixture(); try { + const info = await collectRepoInfo(dir); + const lens = getLens("defect"); + const slices = await gatherSlices(dir, lens, info); + assert.equal(slices.length, 1, "a repo that fits one slice must not be split per directory"); + assert.equal(slices[0].moduleName, info.name); + assert.match(slices[0].content, /server\/routes\.go/); + assert.match(slices[0].content, /model\/deep\/nested\.go/); + assert.match(slices[0].content, /main\.go/); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("auto slicing directory-splits a repo too large for one slice", async () => { + const dir = await mkdtemp(join(tmpdir(), "broadside-auto-")); + try { + await mkdir(join(dir, "server")); + await mkdir(join(dir, "model")); + await writeFile(join(dir, "go.mod"), "module x\n"); + await writeFile(join(dir, "main.go"), "package main\n"); + for (let i = 0; i < 40; i++) { + await writeFile(join(dir, "server", `s${i}.go`), "package server\n" + `// ${"x".repeat(2000)}\n`); + await writeFile(join(dir, "model", `m${i}.go`), "package model\n" + `// ${"y".repeat(2000)}\n`); + } const info = await collectRepoInfo(dir); const lens = getLens("defect"); const slices = await gatherSlices(dir, lens, info); @@ -97,9 +121,6 @@ test("directory slicing puts nested files under their top-level module", async ( assert.ok(byModule.server, "server/ must be its own slice"); assert.ok(byModule.model, "model/ must be its own slice"); assert.ok(byModule.root, "top-level main.go must land in the root slice"); - assert.match(byModule.server.content, /server\/routes\.go/); - assert.match(byModule.model.content, /model\/deep\/nested\.go/, "nested files belong to the top-level module"); - assert.match(byModule.root.content, /main\.go/); for (const slice of slices) { assert.ok(slice.chars <= lens.maxChars); }