codecartographer-pi 0.22.3 → 0.23.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.
@@ -86,6 +86,21 @@ Submits pre-flight the chosen model: pricing comes from the live catalog
86
86
  model that does not advertise structured-output support is refused outright,
87
87
  because every lens depends on `json_schema` response_format.
88
88
 
89
+ Each lens reads a scope of the repository. Architecture reads the manifest,
90
+ entry point, README, and file tree; defect, conventions, and porting read
91
+ every source file; the **security** and **API** lenses target where the
92
+ trust boundary usually lives — `server/**`, `**/auth*`, `**/middleware/**`,
93
+ `SECURITY.md` (security) and `server/**`, `api/**`, `src/server/**`,
94
+ `src/api/**`, `**/*routes*`, `**/*router*`, `**/*handler*`, `**/*endpoint*`
95
+ (API). A repository whose server is `src/server.js` matches none of those, so
96
+ when the targeted patterns find nothing those two lenses **fall back to every
97
+ source file** — priced as such, chunked at the lens's slice size rather than
98
+ truncated, and said so on the lens line of the estimate, the submit report,
99
+ `status`, and the prompt the model receives. A lens whose targeted patterns
100
+ and fallback both find nothing (only test files, say) is skipped with a line
101
+ naming both. `max_cost` is the guard against a fallback scan on a large
102
+ repository being more than you meant to spend.
103
+
89
104
  Collect runs two cross-lens post-passes by default: **synthesis** (the
90
105
  executive report) and **triage** (the prioritized work order). Pass
91
106
  `include_synthesis: false` or `include_triage: false` on collect to skip one.
@@ -3,4 +3,4 @@
3
3
  # workspace's framework-owned files (GUIDE.md, templates/, workflow/ pipelines
4
4
  # and VALIDATE.md) predate the running release. Written at release time and
5
5
  # copied verbatim by init — never edit by hand.
6
- scaffold_version: 0.22.3
6
+ scaffold_version: 0.23.0
package/README.md CHANGED
@@ -383,7 +383,7 @@ Each workflow tool accepts an absolute `cwd` for the target repository. `codecar
383
383
 
384
384
  ## Broad-Side (batch reconnaissance)
385
385
 
386
- Broad-Side is the cheap sweep you run *before* the expensive interactive run. It fires six analysis lenses — architecture, API surface, security, mechanical defect scan, convention extraction, porting — at a repository as single-turn prompts over the [OpenRouter Batch API](https://openrouter.ai/docs), then cross-references them into one executive report (`synthesis.md`) and a prioritized P0–P3 work order (`triage.md`).
386
+ Broad-Side is the cheap sweep you run *before* the expensive interactive run. It fires six analysis lenses — architecture, API surface, security, mechanical defect scan, convention extraction, porting — at a repository as single-turn prompts over the [OpenRouter Batch API](https://openrouter.ai/docs), then cross-references them into one executive report (`synthesis.md`) and a prioritized P0–P3 work order (`triage.md`). The security and API lenses target the paths where a trust boundary usually lives (`server/`, `auth*`, `middleware/`, routers and handlers) and fall back to every source file when a repository has none of them — priced as such, and said so on the estimate — so a service whose server is `src/server.js` still gets its security review.
387
387
 
388
388
  **Broad-Side findings are unverified scouting leads, not evidence.** Each lens is one shot: no cross-file traversal, no runtime verification, no builds, no tests. Every finding is a `file:line` pointer that the interactive pipeline — or you — must confirm before it is a fact. That division of labor is the point: a sub-dollar unattended sweep that tells the expensive run where to look. Nothing downstream may cite a Broad-Side report as a source.
389
389
 
@@ -121,6 +121,12 @@ export type FileSlice = {
121
121
  redactedValues?: number;
122
122
  /** The files in this slice that had at least one value redacted. */
123
123
  redactedFiles?: string[];
124
+ /**
125
+ * Set when the lens's targeted globs matched nothing and the slice was
126
+ * built from its fallback globs instead (#319). The estimate, the batch
127
+ * entry, and the prompt all say so.
128
+ */
129
+ fallback?: string;
124
130
  };
125
131
  /**
126
132
  * OpenRouter's unified `reasoning` control, as sent on a lens request.
@@ -203,6 +209,12 @@ export type BroadsideBatchEntry = {
203
209
  error?: unknown;
204
210
  /** Why a `skipped` lens had nothing to submit: the globs that matched no file. */
205
211
  reason?: string;
212
+ /**
213
+ * Set when the lens scanned its fallback scope because its targeted globs
214
+ * matched nothing (#319): "no files matched …; scanned all javascript
215
+ * sources instead". Absent for a targeted scan.
216
+ */
217
+ fallback?: string;
206
218
  /** Set when this lens used a model other than the run default. */
207
219
  model?: string;
208
220
  /** The completion ceiling of this lens's model; bounds the truncation retry. */
@@ -348,6 +360,8 @@ export type BroadsideEstimate = {
348
360
  /** The model this lens would use — `model` unless a per-lens override applies. */
349
361
  model: string;
350
362
  pricing: ModelPricing;
363
+ /** Set when this lens is priced on its fallback scope (#319); see BroadsideBatchEntry.fallback. */
364
+ fallback?: string;
351
365
  }>;
352
366
  /** True when at least one lens uses a model other than the run default. */
353
367
  mixedModels: boolean;
@@ -490,6 +504,15 @@ type LensDefinition = {
490
504
  reasoning?: BroadsideReasoning;
491
505
  skipTestFiles?: boolean;
492
506
  globsFor: (info: RepoInfo) => string[];
507
+ /**
508
+ * Where to look when `globsFor` matches nothing (#319). The security and
509
+ * api lenses target server/, auth, and middleware paths because that is
510
+ * where the trust boundary usually lives; a service whose server is
511
+ * `src/server.js` matched none of them and got no security review at all.
512
+ * The fallback is the language's whole source set — priced as such, and
513
+ * said so in the estimate, the run record, and the prompt.
514
+ */
515
+ fallbackGlobsFor?: (info: RepoInfo) => string[];
493
516
  systemPrompt: (info: RepoInfo) => string;
494
517
  userPrompt: (info: RepoInfo, source: string, moduleName: string) => string;
495
518
  };
@@ -500,6 +523,20 @@ export declare const BROADSIDE_LANGUAGES: readonly ["go", "python", "rust", "typ
500
523
  export declare function collectRepoInfo(targetDir: string, opts?: {
501
524
  redact?: boolean;
502
525
  }): Promise<RepoInfo>;
526
+ type CollectedFile = {
527
+ relPath: string;
528
+ moduleName: string;
529
+ };
530
+ /**
531
+ * The files a lens will read: its targeted globs, or — when those match
532
+ * nothing and the lens declares a fallback — the fallback globs, with a
533
+ * sentence saying so (#319). The sentence travels to the estimate, the
534
+ * batch entry, and the prompt, so a fallback scan is never a silent one.
535
+ */
536
+ export declare function selectLensFiles(allFiles: string[], lens: LensDefinition, info: RepoInfo): {
537
+ files: CollectedFile[];
538
+ fallback?: string;
539
+ };
503
540
  export declare function gatherSlices(targetDir: string, lens: LensDefinition, info: RepoInfo, opts?: {
504
541
  redact?: boolean;
505
542
  }): Promise<FileSlice[]>;
@@ -698,6 +698,7 @@ const LENSES = {
698
698
  "**/*handler*",
699
699
  "**/*endpoint*",
700
700
  ],
701
+ fallbackGlobsFor: (info) => [info.sourceGlob],
701
702
  systemPrompt: () => "You are a senior API auditor. Given source files from an HTTP server, " +
702
703
  "extract every HTTP endpoint (method, path, handler function, auth requirement) " +
703
704
  "and every key request/response data type. Return a JSON object following the " +
@@ -718,6 +719,7 @@ const LENSES = {
718
719
  globsFor: (info) => info.language === "go"
719
720
  ? ["server/**/*.go", "server/*.go", "**/auth*.go", "**/middleware/**/*.go", "SECURITY.md"]
720
721
  : ["server/**", "**/auth*", "**/middleware/**", "SECURITY.md"],
722
+ fallbackGlobsFor: (info) => [info.sourceGlob],
721
723
  systemPrompt: () => "You are a security engineer performing a first-pass review of a codebase. " +
722
724
  "Given source files, identify potential security issues — focusing on " +
723
725
  "authentication, authorization, input validation, TLS, secrets handling, " +
@@ -1237,8 +1239,7 @@ function resolveSliceMode(lens, files, totalChars) {
1237
1239
  return lens.sliceBy;
1238
1240
  return totalChars > lens.maxChars ? "directory" : "none";
1239
1241
  }
1240
- function collectLensFiles(allFiles, lens, info) {
1241
- const globs = lens.globsFor(info).filter(Boolean);
1242
+ function collectFilesMatching(allFiles, lens, globs) {
1242
1243
  if (globs.length === 0)
1243
1244
  return [];
1244
1245
  const out = [];
@@ -1253,6 +1254,30 @@ function collectLensFiles(allFiles, lens, info) {
1253
1254
  }
1254
1255
  return out;
1255
1256
  }
1257
+ /**
1258
+ * The files a lens will read: its targeted globs, or — when those match
1259
+ * nothing and the lens declares a fallback — the fallback globs, with a
1260
+ * sentence saying so (#319). The sentence travels to the estimate, the
1261
+ * batch entry, and the prompt, so a fallback scan is never a silent one.
1262
+ */
1263
+ export function selectLensFiles(allFiles, lens, info) {
1264
+ const globs = lens.globsFor(info).filter(Boolean);
1265
+ const targeted = collectFilesMatching(allFiles, lens, globs);
1266
+ if (targeted.length > 0 || globs.length === 0 || !lens.fallbackGlobsFor)
1267
+ return { files: targeted };
1268
+ const fallbackGlobs = lens.fallbackGlobsFor(info).filter(Boolean);
1269
+ const files = collectFilesMatching(allFiles, lens, fallbackGlobs);
1270
+ if (files.length === 0)
1271
+ return { files };
1272
+ return {
1273
+ files,
1274
+ fallback: `no files matched ${globs.join(", ")}${lens.skipTestFiles ? " (test files excluded)" : ""}; ` +
1275
+ `scanned all ${info.language} sources (${fallbackGlobs.join(", ")}) instead`,
1276
+ };
1277
+ }
1278
+ function collectLensFiles(allFiles, lens, info) {
1279
+ return selectLensFiles(allFiles, lens, info).files;
1280
+ }
1256
1281
  async function slurpFileList(targetDir, files, maxChars, redact = true) {
1257
1282
  const slices = [];
1258
1283
  let currentModule = "";
@@ -1329,16 +1354,18 @@ export async function gatherSlices(targetDir, lens, info, opts = {}) {
1329
1354
  return [{ moduleName: "root", content: "", fileCount: 0, chars: 0, files: [] }];
1330
1355
  }
1331
1356
  const { files: allFiles } = await listRepoFiles(targetDir);
1332
- const files = collectLensFiles(allFiles, lens, info);
1357
+ const { files, fallback } = selectLensFiles(allFiles, lens, info);
1333
1358
  const totalChars = await sumFileSizes(targetDir, files);
1334
1359
  const mode = resolveSliceMode(lens, files, totalChars);
1335
- if (mode === "none") {
1360
+ const slices = mode === "none"
1336
1361
  // Whole-repo slice: one module named after the repo, so a small
1337
1362
  // repo produces a single request instead of one per directory.
1338
- const single = files.map((f) => ({ ...f, moduleName: info.name }));
1339
- return slurpFileList(targetDir, single, lens.maxChars, redact);
1340
- }
1341
- return slurpFileList(targetDir, files, lens.maxChars, redact);
1363
+ ? await slurpFileList(targetDir, files.map((f) => ({ ...f, moduleName: info.name })), lens.maxChars, redact)
1364
+ : await slurpFileList(targetDir, files, lens.maxChars, redact);
1365
+ if (fallback)
1366
+ for (const slice of slices)
1367
+ slice.fallback = fallback;
1368
+ return slices;
1342
1369
  }
1343
1370
  async function sumFileSizes(targetDir, files) {
1344
1371
  let total = 0;
@@ -1362,7 +1389,17 @@ export function buildBatchRequest(lens, info, slice, index, sliceCount, model =
1362
1389
  model,
1363
1390
  messages: [
1364
1391
  { role: "system", content: lens.systemPrompt(info) },
1365
- { role: "user", content: lens.userPrompt(info, slice.content, slice.moduleName) },
1392
+ {
1393
+ role: "user",
1394
+ content:
1395
+ // A fallback scan is not "server source files": say what it is,
1396
+ // so the model judges the trust boundary wherever it appears
1397
+ // and does not report the missing server/ as a finding (#319).
1398
+ (slice.fallback
1399
+ ? `NOTE: this repository has no files under the paths this lens usually reads (${slice.fallback}). ` +
1400
+ "What follows is every source file it has; locate the trust boundary and the request-handling code wherever they live.\n\n"
1401
+ : "") + lens.userPrompt(info, slice.content, slice.moduleName),
1402
+ },
1366
1403
  ],
1367
1404
  response_format: { type: "json_schema", json_schema: SCHEMAS[lens.schemaName] },
1368
1405
  max_tokens: maxTokensOverride ?? lens.maxTokens,
@@ -2320,11 +2357,14 @@ export async function runBroadsideSubmit(cwd, apiKey, opts = {}) {
2320
2357
  slicesByLens.set(lensId, slices);
2321
2358
  if (slices.length === 0) {
2322
2359
  const globs = lens.globsFor(info).filter(Boolean);
2360
+ const fallbackGlobs = lens.fallbackGlobsFor?.(info).filter(Boolean) ?? [];
2323
2361
  skipReasons.set(lensId, globs.length === 0
2324
2362
  ? "the lens has no file patterns for this language"
2325
2363
  : matchedBeforeIncremental > 0
2326
2364
  ? "incremental: none of this lens's files changed since the previous run"
2327
- : `no files matched ${globs.join(", ")}${lens.skipTestFiles ? " (test files excluded)" : ""}`);
2365
+ : `no files matched ${globs.join(", ")}` +
2366
+ (fallbackGlobs.length > 0 ? ` or the fallback ${fallbackGlobs.join(", ")}` : "") +
2367
+ (lens.skipTestFiles ? " (test files excluded)" : ""));
2328
2368
  }
2329
2369
  const lensModel = modelForLens(lensId);
2330
2370
  const { pricing: lensPricing, outputCap: lensOutputCap } = resolved.get(lensModel);
@@ -2347,15 +2387,19 @@ export async function runBroadsideSubmit(cwd, apiKey, opts = {}) {
2347
2387
  const approved = await opts.confirm({
2348
2388
  model,
2349
2389
  pricing,
2350
- lenses: perLensEstimate.map(({ lens, cost, maxTokens, lensModel, lensPricing }) => ({
2351
- lensId: lens.id,
2352
- name: lens.name,
2353
- slices: (slicesByLens.get(lens.id) ?? []).length,
2354
- maxTokens,
2355
- cost,
2356
- model: lensModel,
2357
- pricing: lensPricing,
2358
- })),
2390
+ lenses: perLensEstimate.map(({ lens, cost, maxTokens, lensModel, lensPricing }) => {
2391
+ const fallback = (slicesByLens.get(lens.id) ?? []).find((slice) => slice.fallback)?.fallback;
2392
+ return {
2393
+ lensId: lens.id,
2394
+ name: lens.name,
2395
+ slices: (slicesByLens.get(lens.id) ?? []).length,
2396
+ maxTokens,
2397
+ cost,
2398
+ model: lensModel,
2399
+ pricing: lensPricing,
2400
+ ...(fallback && { fallback }),
2401
+ };
2402
+ }),
2359
2403
  mixedModels: perLensEstimate.some(({ lensModel }) => lensModel !== model),
2360
2404
  totalCost: estimatedTotalCost,
2361
2405
  inputTokens: estimatedInputTokens,
@@ -2419,12 +2463,15 @@ export async function runBroadsideSubmit(cwd, apiKey, opts = {}) {
2419
2463
  const requests = slices.map((sl, i) => buildBatchRequest(lens, info, sl, i, slices.length, lensModel, maxTokens, config.reasoning ?? undefined));
2420
2464
  for (const request of requests)
2421
2465
  requestsByCustomId[request.custom_id] = request;
2466
+ const fallback = slices.find((slice) => slice.fallback)?.fallback;
2422
2467
  const entry = {
2423
2468
  batchId: "",
2424
2469
  requests: requests.length,
2425
2470
  status: "submitting",
2426
2471
  submittedAt: new Date().toISOString(),
2427
2472
  estimatedCost: priced.cost,
2473
+ // A scan of the fallback scope is recorded as such (#319).
2474
+ ...(fallback && { fallback }),
2428
2475
  // Recorded per lens so collect's truncation retry re-submits against
2429
2476
  // the model and ceiling this lens actually used, not the run default.
2430
2477
  ...(lensModel !== model && { model: lensModel }),
@@ -3256,7 +3303,9 @@ export function estimateSubmitText(result, lenses) {
3256
3303
  ? ` — ${explainBatchError(entry.error)}`
3257
3304
  : !entry.batchId && entry.reason
3258
3305
  ? ` — ${entry.reason}`
3259
- : "";
3306
+ : entry.fallback
3307
+ ? ` — ${entry.fallback}`
3308
+ : "";
3260
3309
  lines.push(` ${lens.name}: ${status} (${entry.requests} request(s), ~$${entry.estimatedCost.toFixed(4)})${override}${reason}`);
3261
3310
  }
3262
3311
  if (result.repo) {
@@ -3492,7 +3541,7 @@ export function statusText(state) {
3492
3541
  if (!entry)
3493
3542
  continue;
3494
3543
  lines.push(` ${lensId}: ${entry.status}${entry.batchId ? ` (${entry.batchId})` : ""}${entry.cost !== undefined ? `, $${entry.cost.toFixed(6)}` : ""}` +
3495
- (entry.status === "skipped" && entry.reason ? ` — ${entry.reason}` : ""));
3544
+ (entry.status === "skipped" && entry.reason ? ` — ${entry.reason}` : entry.fallback ? ` — ${entry.fallback}` : ""));
3496
3545
  }
3497
3546
  lines.push(` synthesis: ${run.synthesis.status}`);
3498
3547
  lines.push(` triage: ${run.triage?.status ?? "pending"}`);
@@ -130,11 +130,14 @@ function describeBroadsideEstimate(estimate) {
130
130
  `Rates: $${estimate.pricing.inputPerM.toFixed(4)}/M in · $${estimate.pricing.outputPerM.toFixed(4)}/M out`,
131
131
  "",
132
132
  "Per lens:",
133
- ...estimate.lenses.map(({ name, slices, cost, model }) => {
133
+ ...estimate.lenses.map(({ name, slices, cost, model, fallback }) => {
134
134
  // Naming the model only when it differs keeps the common case quiet
135
135
  // and makes a mixed-model run impossible to approve without noticing.
136
136
  const override = estimate.mixedModels && model !== estimate.model ? ` on ${model}` : "";
137
- return ` ${name}: ${slices} slice${slices === 1 ? "" : "s"} — ~$${cost.toFixed(4)}${override}`;
137
+ // A lens priced on its fallback scope says so here, where the
138
+ // spend is approved — every source file, not a server directory (#319).
139
+ const scope = fallback ? `\n ↳ ${fallback}` : "";
140
+ return ` ${name}: ${slices} slice${slices === 1 ? "" : "s"} — ~$${cost.toFixed(4)}${override}${scope}`;
138
141
  }),
139
142
  "",
140
143
  `Estimated total: ~$${estimate.totalCost.toFixed(4)} ` +
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codecartographer-pi",
3
- "version": "0.22.3",
3
+ "version": "0.23.0",
4
4
  "mcpName": "io.github.HuginnIndustries/codecartographer",
5
5
  "description": "Turn an unfamiliar codebase into a validated reimplementation spec, then synthesize confirmed specs and a product vision into a traceable plan.",
6
6
  "type": "module",