Skip to content

Commit b93473f

Browse files
committed
feat(mapper): split repeated flat file families
1 parent dda4a35 commit b93473f

5 files changed

Lines changed: 146 additions & 4 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
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.
2223
- Improved OpenCode malformed JSON diagnostics with output length, event kinds, and a bounded preview, thanks @rohitjavvadi.
2324
- Fixed Express route mapping for aliased Router imports that follow block comment banners, thanks @rohitjavvadi.
2425
- Fixed Laravel route mapping to include array-style `Route::group` prefixes, 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: 2 additions & 1 deletion
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/`

‎src/mappers/grouping.test.ts‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { describe, expect, it } from "vitest";
2+
import { partitionFileGroups } from "./grouping.js";
3+
4+
describe("partitionFileGroups", () => {
5+
it("splits repeated direct file families before numeric chunks", () => {
6+
const groups = partitionFileGroups(
7+
"src",
8+
[
9+
"src/cli/command-alpha.ts",
10+
"src/cli/command-beta.ts",
11+
"src/cli/config-alpha.ts",
12+
"src/cli/config-beta.ts",
13+
"src/cli/other-a.ts",
14+
"src/cli/other-b.ts",
15+
"src/cli/solo.ts",
16+
],
17+
3,
18+
);
19+
20+
expect(groups).toContainEqual({
21+
label: "src/cli/:command",
22+
files: ["src/cli/command-alpha.ts", "src/cli/command-beta.ts"],
23+
});
24+
expect(groups).toContainEqual({
25+
label: "src/cli/:config",
26+
files: ["src/cli/config-alpha.ts", "src/cli/config-beta.ts"],
27+
});
28+
expect(groups).toContainEqual({
29+
label: "src/cli/:other",
30+
files: ["src/cli/other-a.ts", "src/cli/other-b.ts"],
31+
});
32+
expect(groups).toContainEqual({ label: "src/cli", files: ["src/cli/solo.ts"] });
33+
});
34+
35+
it("keeps singleton families in the fallback chunk", () => {
36+
const groups = partitionFileGroups(
37+
"src",
38+
["src/commands.ts", "src/config.ts", "src/index.ts", "src/runtime.ts", "src/session.ts"],
39+
3,
40+
);
41+
42+
expect(groups).toEqual([
43+
{ label: "src#1", files: ["src/commands.ts", "src/config.ts", "src/index.ts"] },
44+
{ label: "src#2", files: ["src/runtime.ts", "src/session.ts"] },
45+
]);
46+
});
47+
});

‎src/mappers/grouping.ts‎

Lines changed: 94 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,10 +44,22 @@ function partitionAt(
4444
}
4545

4646
if (buckets.size === 0) {
47-
return chunkFiles(labelFor(sourceRoot, files, depth), files, maxFiles);
47+
return partitionDirectFiles(
48+
sourceRoot,
49+
files,
50+
maxFiles,
51+
depth,
52+
labelFor(sourceRoot, files, depth),
53+
);
4854
}
4955

50-
const groups = chunkFiles(labelFor(sourceRoot, files, depth), directFiles, maxFiles);
56+
const groups = partitionDirectFiles(
57+
sourceRoot,
58+
directFiles,
59+
maxFiles,
60+
depth,
61+
labelFor(sourceRoot, files, depth),
62+
);
5163
for (const [segment, bucketFiles] of [...buckets.entries()].toSorted(([left], [right]) =>
5264
left.localeCompare(right),
5365
)) {
@@ -80,6 +92,86 @@ function chunkFiles(label: string, files: string[], maxFiles: number): FileGroup
8092
return groups;
8193
}
8294

95+
function partitionDirectFiles(
96+
sourceRoot: string,
97+
files: string[],
98+
maxFiles: number,
99+
depth: number,
100+
label: string,
101+
): FileGroup[] {
102+
if (files.length === 0) {
103+
return [];
104+
}
105+
if (files.length <= maxFiles) {
106+
return [{ label, files }];
107+
}
108+
109+
const buckets = new Map<string, string[]>();
110+
const fallbackFiles: string[] = [];
111+
for (const file of files) {
112+
const family = directFileFamily(sourceRoot, file, depth);
113+
if (family === null) {
114+
fallbackFiles.push(file);
115+
continue;
116+
}
117+
const bucket = buckets.get(family) ?? [];
118+
bucket.push(file);
119+
buckets.set(family, bucket);
120+
}
121+
122+
const groups: FileGroup[] = [];
123+
for (const [family, bucketFiles] of [...buckets.entries()].toSorted(([left], [right]) =>
124+
left.localeCompare(right),
125+
)) {
126+
if (bucketFiles.length < 2) {
127+
fallbackFiles.push(...bucketFiles);
128+
continue;
129+
}
130+
groups.push(...chunkFiles(`${label}/:${family}`, bucketFiles.toSorted(), maxFiles));
131+
}
132+
groups.push(...chunkFiles(label, fallbackFiles.toSorted(), maxFiles));
133+
return groups;
134+
}
135+
136+
function directFileFamily(sourceRoot: string, file: string, depth: number): string | null {
137+
const relativePath = file.startsWith(`${sourceRoot}/`) ? file.slice(sourceRoot.length + 1) : file;
138+
const leaf = relativePath.split("/")[depth];
139+
if (leaf === undefined) {
140+
return null;
141+
}
142+
const stem = leaf
143+
.replace(/\.[cm]?tsx?$/u, "")
144+
.replace(/\.[cm]?jsx?$/u, "")
145+
.replace(/\.[^.]+$/u, "")
146+
.toLowerCase();
147+
const dotIndex = stem.indexOf(".");
148+
const prefix = dotIndex < 0 ? stem : stem.slice(0, dotIndex);
149+
const family = dotIndex < 0 ? (prefix.split(/[-_]/u)[0] ?? null) : prefix;
150+
if (family === null || !isUsefulFamily(family)) {
151+
return null;
152+
}
153+
return family;
154+
}
155+
156+
const unhelpfulFamilies = new Set([
157+
"index",
158+
"main",
159+
"shared",
160+
"helper",
161+
"helpers",
162+
"util",
163+
"utils",
164+
"type",
165+
"types",
166+
"spec",
167+
"test",
168+
"tests",
169+
]);
170+
171+
function isUsefulFamily(family: string): boolean {
172+
return family.length >= 3 && !/^\d+$/u.test(family) && !unhelpfulFamilies.has(family);
173+
}
174+
83175
function labelFor(sourceRoot: string, files: string[], depth: number): string {
84176
if (depth === 0) {
85177
return sourceRoot;

0 commit comments

Comments
 (0)