Skip to content

Commit d364822

Browse files
authored
Merge pull request #92 from schedawg74/fix-map-exclude-filtering
fix(mapper): apply configured path excludes
2 parents 5de565c + 288e655 commit d364822

6 files changed

Lines changed: 181 additions & 82 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
- Improved Flask route mapping to preserve static blueprint URL prefixes, thanks @rohitjavvadi.
2828
- Improved Django route mapping to preserve literal `include()` route prefixes, thanks @rohitjavvadi.
2929
- Added conservative Rails route mapping for literal root and HTTP verb routes, thanks @rohitjavvadi.
30+
- Fixed heuristic feature mapping to honor configured path include/exclude filters, thanks @schedawg74.
3031
- Fixed Express route mapping for aliased Router imports that follow block comment banners, thanks @rohitjavvadi.
3132
- Fixed Laravel route mapping to include array-style `Route::group` prefixes, thanks @rohitjavvadi.
3233
- Fixed Fastify route-object mapping to emit static method arrays while ignoring dynamic entries, thanks @rohitjavvadi.

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/mapper.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,37 @@ import { fixtureRoot, writeFixture } from "./test-helpers.js";
1010
const symlinkIt = process.platform === "win32" ? it.skip : it;
1111

1212
describe("mapFeatures", () => {
13+
it("applies configured path excludes to heuristic feature mapping", async () => {
14+
const root = await fixtureRoot("clawpatch-map-exclude-");
15+
await writeFixture(root, "requirements.txt", "pytest\n");
16+
await writeFixture(root, "src/app/api_service.py", "def call_api(): pass\n");
17+
for (let index = 0; index < 13; index += 1) {
18+
await writeFixture(
19+
root,
20+
`src/client/generated/models/model_${index}.py`,
21+
`class Model${index}: pass\n`,
22+
);
23+
}
24+
25+
const project = await detectProject(root);
26+
const result = await mapFeatures(root, project, [], {
27+
filters: {
28+
include: ["**/*"],
29+
exclude: ["src/client/generated/**"],
30+
},
31+
});
32+
const featurePaths = result.features.flatMap((feature) => [
33+
...feature.entrypoints.map((entrypoint) => entrypoint.path),
34+
...feature.ownedFiles.map((file) => file.path),
35+
...feature.contextFiles.map((file) => file.path),
36+
...feature.tests.map((test) => test.path),
37+
]);
38+
39+
expect(featurePaths).toContain("src/app/api_service.py");
40+
expect(result.features.some((feature) => feature.title.includes("generated"))).toBe(false);
41+
expect(featurePaths.some((path) => path.startsWith("src/client/generated/"))).toBe(false);
42+
});
43+
1344
it("maps package bins, scripts, configs, and Next routes", async () => {
1445
const root = await fixtureRoot("clawpatch-map-");
1546
await writeFixture(

src/mapper.ts

Lines changed: 60 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import { reactSeeds } from "./mappers/react.js";
1717
import { discoverNodeProjects } from "./mappers/projects.js";
1818
import { rubySeeds } from "./mappers/ruby.js";
1919
import { rustSeeds } from "./mappers/rust.js";
20-
import { nearbyTests } from "./mappers/shared.js";
20+
import { nearbyTests, PathFilters, pathMatchesFilters } from "./mappers/shared.js";
2121
import { swiftSeeds } from "./mappers/swift.js";
2222
import { turboTaskGraph } from "./mappers/turbo.js";
2323
import { FeatureMapper, FeatureSeed, MapperContext } from "./mappers/types.js";
@@ -39,6 +39,7 @@ export type MapProgressEvent = {
3939

4040
export type MapOptions = {
4141
onProgress?: (event: MapProgressEvent) => void;
42+
filters?: PathFilters;
4243
};
4344

4445
const featureMappers: FeatureMapper[] = [
@@ -68,21 +69,26 @@ export async function mapFeatures(
6869
options: MapOptions = {},
6970
): Promise<MapResult> {
7071
const seeds = await collectSeeds(root, options);
71-
return mapFeatureSeeds(root, project, existing, seeds);
72+
return mapFeatureSeeds(root, project, existing, seeds, options);
7273
}
7374

7475
export async function mapFeatureSeeds(
7576
root: string,
7677
project: ProjectRecord,
7778
existing: FeatureRecord[],
7879
seeds: FeatureSeed[],
80+
options: MapOptions = {},
7981
): Promise<MapResult> {
8082
const existingById = new Map(existing.map((feature) => [feature.featureId, feature]));
8183
const features: FeatureRecord[] = [];
8284
let created = 0;
8385
let changed = 0;
8486
const now = nowIso();
85-
for (const seed of seeds) {
87+
for (const rawSeed of seeds) {
88+
const seed = filterSeed(rawSeed, options.filters);
89+
if (seed === null) {
90+
continue;
91+
}
8692
const identity = featureIdentity(seed, existingById);
8793
const featureId = identity.featureId;
8894
const previous = existingById.get(featureId);
@@ -100,9 +106,11 @@ export async function mapFeatureSeeds(
100106
(name): name is string => typeof name === "string",
101107
),
102108
);
103-
const tests = uniqueTests([...(seed.tests ?? []), ...discoveredTests]);
109+
const tests = uniqueTests(
110+
filterTests([...(seed.tests ?? []), ...discoveredTests], options.filters),
111+
);
104112
const contextFiles = uniqueFileRefs([
105-
...(seed.contextFiles ?? []),
113+
...filterFileRefs(seed.contextFiles ?? [], options.filters),
106114
...tests.map((test) => ({ path: test.path, reason: "nearby test" })),
107115
]);
108116
const feature: FeatureRecord = {
@@ -159,6 +167,53 @@ export async function mapFeatureSeeds(
159167
};
160168
}
161169

170+
function filterSeed(seed: FeatureSeed, filters: PathFilters | undefined): FeatureSeed | null {
171+
if (filters === undefined) {
172+
return seed;
173+
}
174+
const ownedFiles =
175+
seed.ownedFiles === undefined ? undefined : filterFileRefs(seed.ownedFiles, filters);
176+
if (seed.ownedFiles !== undefined && ownedFiles?.length === 0) {
177+
return null;
178+
}
179+
const entryPath = pathMatchesFilters(seed.entryPath, filters)
180+
? seed.entryPath
181+
: (ownedFiles?.[0]?.path ?? null);
182+
if (entryPath === null) {
183+
return null;
184+
}
185+
const filteredSeed = {
186+
...seed,
187+
entryPath,
188+
contextFiles: filterFileRefs(seed.contextFiles ?? [], filters),
189+
tests: filterTests(seed.tests ?? [], filters),
190+
};
191+
if (ownedFiles === undefined) {
192+
return filteredSeed;
193+
}
194+
return { ...filteredSeed, ownedFiles };
195+
}
196+
197+
function filterFileRefs(
198+
refs: Array<{ path: string; reason: string }>,
199+
filters: PathFilters | undefined,
200+
): Array<{ path: string; reason: string }> {
201+
if (filters === undefined) {
202+
return refs;
203+
}
204+
return refs.filter((ref) => pathMatchesFilters(ref.path, filters));
205+
}
206+
207+
function filterTests(
208+
tests: Array<{ path: string; command: string | null }>,
209+
filters: PathFilters | undefined,
210+
): Array<{ path: string; command: string | null }> {
211+
if (filters === undefined) {
212+
return tests;
213+
}
214+
return tests.filter((test) => pathMatchesFilters(test.path, filters));
215+
}
216+
162217
function featureIdentity(
163218
seed: FeatureSeed,
164219
existingById: Map<string, FeatureRecord>,

0 commit comments

Comments
 (0)