Skip to content

Commit 72b4a00

Browse files
committed
Merge remote-tracking branch 'origin/main' into codex/debug-clawpatch-site
2 parents a83b8da + 9dfc86a commit 72b4a00

17 files changed

Lines changed: 2351 additions & 151 deletions

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,16 @@
1919
- Added generic package-less monorepo app-root mapping for Node/Next projects under roots such as `apps/*` and `packages/*` when positive source or framework signals are present.
2020
- Added Maven project mapping for root, nested, and multi-module Java/Kotlin projects with Spring role slices, Maven validation defaults, and `pom.xml` detection, thanks @julianshess.
2121
- Added a release-prep checklist for auditing changelog, package metadata, and dry-run package contents without publishing.
22+
- Improved bounded source grouping so large flat directories split repeated filename families like command, plugin, doctor, and runtime files into more coherent review slices.
23+
- Fixed acpx provider error reporting by reading the terminal `result.stopReason` envelope and surfacing non-`end_turn` reasons as typed `ClawpatchError` codes (`agent-cancelled`, `agent-refused`, `agent-truncated`) instead of opaque `malformed-output`, thanks @coletebou.
2224
- Improved OpenCode malformed JSON diagnostics with output length, event kinds, and a bounded preview, thanks @rohitjavvadi.
25+
- Fixed finding signatures so equivalent evidence remains stable across re-reviews, thanks @rohitjavvadi.
26+
- Fixed provider exit-code classification for stdout-only authentication and quota failures, thanks @rohitjavvadi.
27+
- Improved Node route mapping to preserve literal Express and Hono mount prefixes, thanks @rohitjavvadi.
28+
- Improved Flask route mapping to preserve static blueprint URL prefixes, thanks @rohitjavvadi.
29+
- Improved Django route mapping to preserve literal `include()` route prefixes, thanks @rohitjavvadi.
30+
- Added conservative Rails route mapping for literal root and HTTP verb routes, thanks @rohitjavvadi.
31+
- Fixed heuristic feature mapping to honor configured path include/exclude filters, thanks @schedawg74.
2332
- Fixed Express route mapping for aliased Router imports that follow block comment banners, thanks @rohitjavvadi.
2433
- Fixed Laravel route mapping to include array-style `Route::group` prefixes, thanks @rohitjavvadi.
2534
- Fixed Fastify route-object mapping to emit static method arrays while ignoring dynamic entries, thanks @rohitjavvadi.

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,8 @@ validation commands and records a patch attempt under `.clawpatch/`.
5858
- generic extension/plugin packages under workspace roots such as `extensions/*`
5959
and `plugins/*`, including package metadata, source, docs, and nearby tests
6060
- semantic Node source groups for large packages, including runtime, commands,
61-
auth, storage, monitor, webhook, setup, server, and client slices
61+
auth, storage, monitor, webhook, setup, server, client, and repeated filename
62+
family slices
6263
- Nx project metadata from `project.json`, including project-scoped validation
6364
targets
6465
- Turborepo task metadata for workspace-aware validation commands and feature

docs/feature-mapping.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,8 @@ Supported deterministic mappers today:
3737
- Nx project metadata from `project.json`, including project names, source roots, project types, and target names
3838
- Turborepo `turbo.json` metadata for workspace-aware validation commands and feature context
3939
- bounded Node/TypeScript source groups under `src/`, `lib/`, `app/`,
40-
`pages/`, `scripts/`, `server/`, and `api/`
40+
`pages/`, `scripts/`, `server/`, and `api/`, with oversized flat
41+
directories split by repeated filename families
4142
- React Router `<Route path element>` declarations and React components in
4243
root or nested frontend packages such as `frontend/`, `client/`, `web/`,
4344
workspaces, and packages under `apps/` or `packages/`
@@ -165,12 +166,13 @@ pytest files; Flask `@*.route(...)` handlers; FastAPI `@*.get(...)` /
165166
`re_path(...)`, and legacy `url(...)` declarations. Flask and FastAPI route
166167
methods are read from list, tuple, or set literals. FastAPI paths can be
167168
positional strings or literal `path=` keywords. Django route paths are normalized
168-
from literal route strings and simple named regex groups; includes are mapped as
169-
their own URL groups without recursively expanding imported URL configs. Default
169+
from literal route strings and simple named regex groups, and literal
170+
`include("module.urls")` routes are expanded under their mount prefixes. Default
170171
Python command detection covers pytest, ruff, mypy, pyright, and black.
171172

172173
Ruby mapping covers project metadata, executables, source groups, RSpec and
173-
Minitest suites, and Rails app structure. Rails legacy `config/secrets.yml`,
174+
Minitest suites, Rails app structure, and literal Rails root and HTTP verb
175+
routes. Rails legacy `config/secrets.yml`,
174176
`config/database.yml`, and `config/initializers/secret_token.rb` are not mapped
175177
as reviewable config because they can contain provider-sensitive secrets.
176178

src/agent-mapper.ts

Lines changed: 17 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,14 @@ import { pathExists } from "./fs.js";
77
import { runCommandArgs } from "./exec.js";
88
import { mapFeatureSeeds, MapResult } from "./mapper.js";
99
import { FeatureSeed, SeedFileRef, SeedTestRef } from "./mappers/types.js";
10-
import { isSafeFile, normalize, shouldSkip, walk } from "./mappers/shared.js";
10+
import {
11+
applyPathFilters,
12+
isSafeFile,
13+
normalize,
14+
PathFilters,
15+
shouldSkip,
16+
walk,
17+
} from "./mappers/shared.js";
1118

1219
type AgentMapMode = "heuristic" | "auto" | "agent";
1320

@@ -26,15 +33,10 @@ type AgentMapOptions = {
2633
source: AgentMapMode;
2734
provider: Provider | null;
2835
providerOptions: ProviderOptions;
29-
inventory?: InventoryFilters;
36+
inventory?: PathFilters;
3037
onProgress?: (event: string, fields: Record<string, string | number | boolean>) => void;
3138
};
3239

33-
type InventoryFilters = {
34-
include: string[];
35-
exclude: string[];
36-
};
37-
3840
type RepoInventorySummary = {
3941
files: number;
4042
sourceFiles: number;
@@ -148,6 +150,7 @@ export async function mapWithSource(
148150
options.provider,
149151
options.providerOptions,
150152
inventory,
153+
options.inventory,
151154
);
152155
options.onProgress?.("agent-done", {
153156
features: agent.features.length,
@@ -199,6 +202,7 @@ async function agentMap(
199202
provider: Provider,
200203
providerOptions: ProviderOptions,
201204
inventory: RepoInventory,
205+
filters: PathFilters | undefined,
202206
): Promise<MapResult> {
203207
const prompt = buildAgentMapPrompt(project, {
204208
manifests: inventory.manifests,
@@ -212,12 +216,10 @@ async function agentMap(
212216
const seeds = await Promise.all(
213217
output.features.map((feature) => toSeed(root, feature, inventory.allFiles)),
214218
);
215-
return mapFeatureSeeds(
216-
root,
217-
project,
218-
existing,
219-
uniqueSeeds(seeds.filter((seed): seed is FeatureSeed => seed !== null)),
220-
);
219+
const mappedSeeds = uniqueSeeds(seeds.filter((seed): seed is FeatureSeed => seed !== null));
220+
return filters === undefined
221+
? mapFeatureSeeds(root, project, existing, mappedSeeds)
222+
: mapFeatureSeeds(root, project, existing, mappedSeeds, { filters });
221223
}
222224

223225
async function toSeed(
@@ -385,10 +387,10 @@ async function repoInventory(
385387
root: string,
386388
project: ProjectRecord,
387389
features: FeatureRecord[],
388-
filters: InventoryFilters | undefined,
390+
filters: PathFilters | undefined,
389391
): Promise<RepoInventory> {
390392
const skipPath = await inventorySkipPath(root, project, features);
391-
const files = applyInventoryFilters(
393+
const files = applyPathFilters(
392394
((await gitInventoryFiles(root)) ?? (await walk(root, [""], skipPath))).filter(
393395
(path) => !skipPath(path),
394396
),
@@ -443,73 +445,12 @@ async function gitInventoryFiles(root: string): Promise<string[] | null> {
443445
return existing.filter((path): path is string => path !== null);
444446
}
445447

446-
function applyInventoryFilters(files: string[], filters: InventoryFilters | undefined): string[] {
447-
if (filters === undefined) {
448-
return files;
449-
}
450-
return files.filter(
451-
(file) =>
452-
filters.include.some((pattern) => inventoryPatternMatches(pattern, file)) &&
453-
!filters.exclude.some((pattern) => inventoryPatternMatches(pattern, file)),
454-
);
455-
}
456-
457448
function isInventoryPath(path: string): boolean {
458449
return (
459450
path.length > 0 && !isAbsolute(path) && !path.includes("\0") && !path.split("/").includes("..")
460451
);
461452
}
462453

463-
function inventoryPatternMatches(pattern: string, path: string): boolean {
464-
const normalized = pattern.replace(/\\/gu, "/").replace(/^\.\//u, "");
465-
if (normalized === "**" || normalized === "**/*") {
466-
return true;
467-
}
468-
if (normalized.length === 0) {
469-
return false;
470-
}
471-
if (!/[?*]/u.test(normalized)) {
472-
return path === normalized || path.startsWith(`${normalized}/`);
473-
}
474-
if (normalized.endsWith("/**")) {
475-
const prefix = normalized.slice(0, -3);
476-
if (/[?*]/u.test(prefix)) {
477-
return new RegExp(`^${globPatternRegExp(prefix)}(?:/.*)?$`, "u").test(path);
478-
}
479-
return prefix.length === 0 || path === prefix || path.startsWith(`${prefix}/`);
480-
}
481-
return new RegExp(`^${globPatternRegExp(normalized)}$`, "u").test(path);
482-
}
483-
484-
function globPatternRegExp(pattern: string): string {
485-
let source = "";
486-
for (let index = 0; index < pattern.length; index += 1) {
487-
const char = pattern[index];
488-
if (char === "*") {
489-
if (pattern[index + 1] === "*") {
490-
if (pattern[index + 2] === "/") {
491-
source += "(?:.*/)?";
492-
index += 2;
493-
} else {
494-
source += ".*";
495-
index += 1;
496-
}
497-
} else {
498-
source += "[^/]*";
499-
}
500-
} else if (char === "?") {
501-
source += "[^/]";
502-
} else {
503-
source += regexpEscape(char ?? "");
504-
}
505-
}
506-
return source;
507-
}
508-
509-
function regexpEscape(value: string): string {
510-
return value.replace(/[\\^$.*+?()[\]{}|]/gu, "\\$&");
511-
}
512-
513454
async function inventorySkipPath(
514455
root: string,
515456
project: ProjectRecord,

src/app.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,12 +123,14 @@ export async function mapCommand(
123123
const config = applyProviderFlags(loaded.config, flags);
124124
const provider = source === "heuristic" ? null : providerByName(config.provider.name);
125125
const existing = await readFeatures(loaded.paths);
126+
const filters = { include: config.include, exclude: config.exclude };
126127
emitProgress(context, "map", "start", {
127128
source,
128129
existing: existing.length,
129130
dryRun: flags["dryRun"] === true,
130131
});
131132
const heuristic = await mapFeatures(loaded.root, loaded.project, existing, {
133+
filters,
132134
onProgress: (event) => {
133135
emitProgress(context, "map", event.event, {
134136
mapper: event.mapper,
@@ -149,7 +151,7 @@ export async function mapCommand(
149151
source,
150152
provider,
151153
providerOptions: providerOptions(config),
152-
inventory: { include: config.include, exclude: config.exclude },
154+
inventory: filters,
153155
onProgress: (event, fields) => {
154156
emitProgress(context, "map", event, fields);
155157
},

src/findings.test.ts

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import { describe, expect, it } from "vitest";
2+
import { findingFromOutput } from "./findings.js";
3+
import type { ReviewOutput } from "./types.js";
4+
5+
describe("findingFromOutput", () => {
6+
it("keeps signatures stable for equivalent evidence with different key insertion order", () => {
7+
const orderedEvidence = {
8+
path: "src/app.ts",
9+
startLine: 10,
10+
endLine: 12,
11+
symbol: "runWorkflow",
12+
quote: "await runWorkflow();",
13+
};
14+
const reorderedEvidence = {} as Record<string, unknown>;
15+
reorderedEvidence["quote"] = "await runWorkflow();";
16+
reorderedEvidence["symbol"] = "runWorkflow";
17+
reorderedEvidence["endLine"] = 12;
18+
reorderedEvidence["startLine"] = 10;
19+
reorderedEvidence["path"] = "src/app.ts";
20+
21+
const first = findingFromOutput(finding([orderedEvidence]), "feature_1", "run_1");
22+
const second = findingFromOutput(
23+
finding([reorderedEvidence as ReviewOutput["findings"][number]["evidence"][number]]),
24+
"feature_1",
25+
"run_1",
26+
);
27+
28+
expect(second.signature).toBe(first.signature);
29+
expect(second.findingId).toBe(first.findingId);
30+
});
31+
32+
it("keeps signatures stable for equivalent evidence with different reference order", () => {
33+
const first = findingFromOutput(
34+
finding([
35+
{
36+
path: "src/app.ts",
37+
startLine: 10,
38+
endLine: 12,
39+
symbol: "runWorkflow",
40+
quote: "await runWorkflow();",
41+
},
42+
{
43+
path: "src/provider.ts",
44+
startLine: 20,
45+
endLine: 22,
46+
symbol: null,
47+
quote: null,
48+
},
49+
]),
50+
"feature_1",
51+
"run_1",
52+
);
53+
const second = findingFromOutput(
54+
finding([
55+
{
56+
path: "src/provider.ts",
57+
startLine: 20,
58+
endLine: 22,
59+
symbol: null,
60+
quote: null,
61+
},
62+
{
63+
path: "src/app.ts",
64+
startLine: 10,
65+
endLine: 12,
66+
symbol: "runWorkflow",
67+
quote: "await runWorkflow();",
68+
},
69+
]),
70+
"feature_1",
71+
"run_1",
72+
);
73+
74+
expect(second.signature).toBe(first.signature);
75+
expect(second.findingId).toBe(first.findingId);
76+
});
77+
78+
it("keeps signatures distinct when evidence content changes", () => {
79+
const first = findingFromOutput(
80+
finding([
81+
{
82+
path: "src/app.ts",
83+
startLine: 10,
84+
endLine: 12,
85+
symbol: "runWorkflow",
86+
quote: "await runWorkflow();",
87+
},
88+
]),
89+
"feature_1",
90+
"run_1",
91+
);
92+
const second = findingFromOutput(
93+
finding([
94+
{
95+
path: "src/app.ts",
96+
startLine: 10,
97+
endLine: 12,
98+
symbol: "runWorkflow",
99+
quote: "await runWorkflowSafely();",
100+
},
101+
]),
102+
"feature_1",
103+
"run_1",
104+
);
105+
106+
expect(second.signature).not.toBe(first.signature);
107+
expect(second.findingId).not.toBe(first.findingId);
108+
});
109+
});
110+
111+
function finding(
112+
evidence: ReviewOutput["findings"][number]["evidence"],
113+
): ReviewOutput["findings"][number] {
114+
return {
115+
title: "Finding title",
116+
category: "bug",
117+
severity: "medium",
118+
confidence: "high",
119+
evidence,
120+
reasoning: "Reasoning.",
121+
reproduction: null,
122+
recommendation: "Recommendation.",
123+
whyTestsDoNotAlreadyCoverThis: "No regression coverage exists.",
124+
suggestedRegressionTest: null,
125+
minimumFixScope: "Keep the fix narrow.",
126+
};
127+
}

0 commit comments

Comments
 (0)