From 18239bd30f7c209b9eb5b01aeb407b6b2cb9c58a Mon Sep 17 00:00:00 2001 From: Josh Boys Date: Tue, 11 Aug 2026 03:37:01 +0800 Subject: [PATCH 1/3] feat(mapper): add rust source-group slices Map package modules under each crate src/ into bounded review features so Cargo workspaces get module-level ownership without re-owning lib/main/bin entrypoints. --- CHANGELOG.md | 3 ++ docs/feature-mapping.md | 4 +- src/mapper.test.ts | 114 ++++++++++++++++++++++++++++++++++++++++ src/mappers/rust.ts | 100 +++++++++++++++++++++++++++++++++++ 4 files changed, 220 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 530dd33..d7bf10b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## 0.7.3 - Unreleased +- Added Rust source-group mapping for package modules under each crate `src/`, + partitioned into bounded review slices while leaving `lib.rs`, `main.rs`, and + bin entrypoints on the existing command and library features. - Reworked the README around a verified install and quickstart path, with deeper command, mapper, provider, and safety details linked to the existing docs. - Updated transitive Vitest dependencies. - Updated pnpm, formatter and linter tooling, and GitHub Actions dependencies. diff --git a/docs/feature-mapping.md b/docs/feature-mapping.md index 1f08194..dab0364 100644 --- a/docs/feature-mapping.md +++ b/docs/feature-mapping.md @@ -53,7 +53,9 @@ Supported deterministic mappers today: groups including Hilt, Dagger, Koin, and Metro - Ruby project metadata, executables, source groups, RSpec/Minitest suites, Rails configs, routes, views, assets, and database files -- Rust Cargo commands, libraries, workspace crates, and integration tests +- Rust Cargo commands, libraries, workspace crates, integration tests, and + bounded source groups under each package `src/` (entrypoint `lib.rs` / + `main.rs` / bin files stay on command and library features) - C/C++/CUDA standalone `main()` files, CMake targets, autotools targets, and bounded loose source groups - C#/.NET projects from `.sln`, `.slnx`, `.csproj`, `.fsproj`, and `.vbproj`, diff --git a/src/mapper.test.ts b/src/mapper.test.ts index e321017..1fa0575 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -11030,6 +11030,120 @@ let package = Package(name: "HybridApp", targets: [.target(name: "HybridApp")]) expect(integrationTests).toHaveLength(8); }); + it("maps Rust source groups for crate modules without re-owning entrypoints", async () => { + const root = await fixtureRoot("clawpatch-rust-source-groups-"); + await writeFixture( + root, + "Cargo.toml", + '[workspace]\nmembers = ["crates/core", "crates/cli"]\n', + ); + await writeFixture(root, "crates/core/Cargo.toml", '[package]\nname = "core"\n'); + await writeFixture(root, "crates/core/src/lib.rs", "pub mod auth;\npub mod storage;\n"); + await writeFixture(root, "crates/core/src/auth/mod.rs", "pub fn login() {}\n"); + await writeFixture(root, "crates/core/src/auth/token.rs", "pub fn mint() {}\n"); + await writeFixture(root, "crates/core/src/storage/mod.rs", "pub fn open() {}\n"); + await writeFixture(root, "crates/core/src/storage/db.rs", "pub fn connect() {}\n"); + await writeFixture(root, "crates/cli/Cargo.toml", '[package]\nname = "cli"\n'); + await writeFixture(root, "crates/cli/src/main.rs", "fn main() {}\n"); + await writeFixture(root, "crates/cli/src/commands.rs", "pub fn run() {}\n"); + await writeFixture(root, "crates/cli/src/bin/worker.rs", "fn main() {}\n"); + await writeFixture(root, "crates/cli/src/bin/admin/main.rs", "fn main() {}\n"); + await writeFixture(root, "crates/cli/src/bin/admin/helpers.rs", "pub fn prep() {}\n"); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const sourceGroups = result.features.filter( + (feature) => feature.source === "rust-source-group", + ); + const owned = sourceGroups.flatMap((feature) => feature.ownedFiles.map((file) => file.path)); + const titles = sourceGroups.map((feature) => feature.title); + + expect(sourceGroups.length).toBeGreaterThanOrEqual(2); + expect(owned).toContain("crates/core/src/auth/mod.rs"); + expect(owned).toContain("crates/core/src/auth/token.rs"); + expect(owned).toContain("crates/core/src/storage/mod.rs"); + expect(owned).toContain("crates/core/src/storage/db.rs"); + expect(owned).toContain("crates/cli/src/commands.rs"); + expect(owned).toContain("crates/cli/src/bin/admin/helpers.rs"); + expect(owned).not.toContain("crates/core/src/lib.rs"); + expect(owned).not.toContain("crates/cli/src/main.rs"); + expect(owned).not.toContain("crates/cli/src/bin/worker.rs"); + expect(owned).not.toContain("crates/cli/src/bin/admin/main.rs"); + expect(titles.some((title) => title.includes("crates/core/src"))).toBe(true); + expect( + sourceGroups.find((feature) => + feature.ownedFiles.some((file) => file.path === "crates/core/src/auth/mod.rs"), + ), + ).toMatchObject({ + kind: "library", + confidence: "medium", + tags: expect.arrayContaining(["rust", "source-group"]), + contextFiles: [{ path: "crates/core/Cargo.toml", reason: "cargo package manifest" }], + tests: [], + }); + expect(sourceGroups.every((feature) => feature.ownedFiles.length <= 12)).toBe(true); + expect(result.features.some((feature) => feature.title === "Rust library core")).toBe(true); + expect(result.features.some((feature) => feature.title === "Rust command cli")).toBe(true); + expect(result.features.some((feature) => feature.title === "Rust command worker")).toBe(true); + }); + + it("partitions oversized Rust source directories into bounded groups", async () => { + const root = await fixtureRoot("clawpatch-rust-source-chunk-"); + await writeFixture(root, "Cargo.toml", '[package]\nname = "chunky"\n'); + await writeFixture(root, "src/lib.rs", "pub fn root() {}\n"); + for (let index = 1; index <= 20; index += 1) { + await writeFixture(root, `src/module_${index}.rs`, `pub fn f${index}() {}\n`); + } + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const sourceGroups = result.features.filter( + (feature) => feature.source === "rust-source-group", + ); + const owned = sourceGroups.flatMap((feature) => feature.ownedFiles.map((file) => file.path)); + + expect(sourceGroups.length).toBeGreaterThan(1); + expect(owned).toHaveLength(20); + expect(owned).not.toContain("src/lib.rs"); + expect(sourceGroups.every((feature) => feature.ownedFiles.length <= 12)).toBe(true); + expect(sourceGroups.every((feature) => feature.entrypoints[0]?.path.endsWith(".rs"))).toBe( + true, + ); + expect( + sourceGroups.every((feature) => + feature.contextFiles.some( + (file) => file.path === "Cargo.toml" && file.reason === "cargo package manifest", + ), + ), + ).toBe(true); + }); + + it("keeps Rust source group identities stable when modules are added", async () => { + const root = await fixtureRoot("clawpatch-rust-source-stable-"); + await writeFixture(root, "Cargo.toml", '[package]\nname = "stable"\n'); + await writeFixture(root, "src/lib.rs", "pub mod alpha;\n"); + await writeFixture(root, "src/alpha.rs", "pub fn a() {}\n"); + await writeFixture(root, "src/beta.rs", "pub fn b() {}\n"); + + const project = await detectProject(root); + const first = await mapFeatures(root, project, []); + const firstGroups = first.features.filter((feature) => feature.source === "rust-source-group"); + const firstIds = firstGroups.map((feature) => feature.featureId).toSorted(); + + await writeFixture(root, "src/gamma.rs", "pub fn c() {}\n"); + const second = await mapFeatures(root, project, first.features); + const secondGroups = second.features.filter( + (feature) => feature.source === "rust-source-group", + ); + const secondIds = secondGroups.map((feature) => feature.featureId).toSorted(); + + expect(firstIds.length).toBeGreaterThan(0); + for (const id of firstIds) { + expect(secondIds).toContain(id); + } + expect(second.stale).toBe(0); + }); + it("maps CMake C and C++ targets without duplicating main files", async () => { const root = await fixtureRoot("clawpatch-cmake-cpp-map-"); const cmakeRoot = root.replaceAll("\\", "/"); diff --git a/src/mappers/rust.ts b/src/mappers/rust.ts index 26090f6..4b5d99b 100644 --- a/src/mappers/rust.ts +++ b/src/mappers/rust.ts @@ -2,9 +2,11 @@ import { readFile, readdir } from "node:fs/promises"; import { join } from "node:path"; import { pathExists } from "../fs.js"; import { shellQuotePath } from "../shell.js"; +import { partitionFileGroups } from "./grouping.js"; import { isSafeDirectory, isSafeFile, + packageKind, packageTrustBoundaries, normalize, stripLineComments, @@ -13,6 +15,7 @@ import { import { FeatureSeed, SeedFileRef } from "./types.js"; const rustFeatureTestLimit = 5; +const sourceGroupMaxOwnedFiles = 12; type RustTestRef = { path: string; @@ -60,6 +63,14 @@ export async function rustSeeds(root: string): Promise { ]), ); } + seeds.push( + ...(await rustSourceGroupSeeds(root, { + sourceRoot: "src", + packageName, + manifestPath: "Cargo.toml", + testCommand: rustTestCommand, + })), + ); } for (const member of await rustMemberDirs(root)) { const memberDir = member.dir; @@ -106,10 +117,99 @@ export async function rustSeeds(root: string): Promise { ]), ); } + seeds.push( + ...(await rustSourceGroupSeeds(root, { + sourceRoot: `${memberDir}/src`, + packageName: memberName, + manifestPath: `${memberDir}/Cargo.toml`, + testCommand: member.testCommand, + })), + ); + } + return seeds; +} + +type RustSourceGroupOptions = { + sourceRoot: string; + packageName: string; + manifestPath: string; + testCommand: string | null; +}; + +async function rustSourceGroupSeeds( + root: string, + options: RustSourceGroupOptions, +): Promise { + const { sourceRoot, packageName, manifestPath, testCommand } = options; + if (!(await isSafeDirectory(root, join(root, sourceRoot)))) { + return []; + } + const files = (await walk(root, [sourceRoot])).filter( + (path) => isRustSourceFile(path) && !isRustPackageEntrypoint(path, sourceRoot), + ); + if (files.length === 0) { + return []; + } + + const contextFiles = await rustManifestContextFiles(root, manifestPath); + const seeds: FeatureSeed[] = []; + for (const group of partitionFileGroups(sourceRoot, files, sourceGroupMaxOwnedFiles)) { + const entryPath = group.files[0] ?? sourceRoot; + seeds.push({ + title: `Rust source ${group.label}`, + summary: + group.files.length === 1 + ? `Rust source file ${group.files[0]}.` + : `Rust source group ${group.label} with ${group.files.length} files.`, + kind: packageKind(`${packageName} ${group.label}`), + source: "rust-source-group", + confidence: "medium", + entryPath, + identityKey: group.label, + symbol: group.label, + route: null, + command: null, + ownedFiles: group.files.map((path) => ({ + path, + reason: `source group ${group.label}`, + })), + contextFiles, + tags: ["rust", "source-group"], + trustBoundaries: packageTrustBoundaries(`${packageName} ${group.label}`), + testCommand, + skipNearbyTests: true, + }); } return seeds; } +function isRustSourceFile(path: string): boolean { + return path.endsWith(".rs"); +} + +/** + * Package entrypoints already mapped as command/library/bin features. + * Remaining modules under src/ become reviewable source groups. + */ +function isRustPackageEntrypoint(path: string, sourceRoot: string): boolean { + if (path === `${sourceRoot}/lib.rs` || path === `${sourceRoot}/main.rs`) { + return true; + } + return new RegExp(`^${escapeRegExp(sourceRoot)}/bin/([^/]+\\.rs|[^/]+/main\\.rs)$`, "u").test( + path, + ); +} + +async function rustManifestContextFiles( + root: string, + manifestPath: string, +): Promise { + if (!(await isSafeFile(root, join(root, manifestPath)))) { + return []; + } + return [{ path: manifestPath, reason: "cargo package manifest" }]; +} + type RustMemberDir = { dir: string; testCommand: string | null; From a72a494ec588305c230b6518736728c814aa2d3c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 10 Aug 2026 18:32:17 -0700 Subject: [PATCH 2/3] fix(mapper): stabilize Rust source group identity --- CHANGELOG.md | 4 +--- src/mapper.test.ts | 4 ++-- src/mappers/rust.test.ts | 28 +++++++++++++++++++++++----- src/mappers/rust.ts | 3 +-- 4 files changed, 27 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d7bf10b..964a238 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,9 +2,7 @@ ## 0.7.3 - Unreleased -- Added Rust source-group mapping for package modules under each crate `src/`, - partitioned into bounded review slices while leaving `lib.rs`, `main.rs`, and - bin entrypoints on the existing command and library features. +- Added bounded Rust source-group mapping for package modules while leaving crate entrypoints on existing command and library features, thanks @joshuaboys. - Reworked the README around a verified install and quickstart path, with deeper command, mapper, provider, and safety details linked to the existing docs. - Updated transitive Vitest dependencies. - Updated pnpm, formatter and linter tooling, and GitHub Actions dependencies. diff --git a/src/mapper.test.ts b/src/mapper.test.ts index 1fa0575..1be127d 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -11106,7 +11106,7 @@ let package = Package(name: "HybridApp", targets: [.target(name: "HybridApp")]) expect(owned).toHaveLength(20); expect(owned).not.toContain("src/lib.rs"); expect(sourceGroups.every((feature) => feature.ownedFiles.length <= 12)).toBe(true); - expect(sourceGroups.every((feature) => feature.entrypoints[0]?.path.endsWith(".rs"))).toBe( + expect(sourceGroups.every((feature) => feature.entrypoints[0]?.path === "Cargo.toml")).toBe( true, ); expect( @@ -11130,7 +11130,7 @@ let package = Package(name: "HybridApp", targets: [.target(name: "HybridApp")]) const firstGroups = first.features.filter((feature) => feature.source === "rust-source-group"); const firstIds = firstGroups.map((feature) => feature.featureId).toSorted(); - await writeFixture(root, "src/gamma.rs", "pub fn c() {}\n"); + await writeFixture(root, "src/aardvark.rs", "pub fn first() {}\n"); const second = await mapFeatures(root, project, first.features); const secondGroups = second.features.filter( (feature) => feature.source === "rust-source-group", diff --git a/src/mappers/rust.test.ts b/src/mappers/rust.test.ts index e644e91..b8643ce 100644 --- a/src/mappers/rust.test.ts +++ b/src/mappers/rust.test.ts @@ -34,10 +34,21 @@ fn main() {} await writeFile(join(root, "src/utils/mod.rs"), "fn utils() {}"); const seeds = await rustSeeds(root); - expect(seeds).toHaveLength(1); + expect(seeds).toHaveLength(2); - const contextPaths = seeds[0]!.contextFiles?.map((f) => f.path); + const command = seeds.find((seed) => seed.source === "rust-command"); + const contextPaths = command?.contextFiles?.map((f) => f.path); expect(contextPaths).toEqual(["Cargo.toml", "src/api.rs", "src/utils/mod.rs"]); + + const sourceGroup = seeds.find((seed) => seed.source === "rust-source-group"); + expect(sourceGroup).toMatchObject({ + entryPath: "Cargo.toml", + identityKey: "src", + ownedFiles: [ + { path: "src/api.rs", reason: "source group src" }, + { path: "src/utils/mod.rs", reason: "source group src" }, + ], + }); }); it("cross-links lib.rs and main.rs", async () => { @@ -119,18 +130,25 @@ members = ["crates/web", "crates/db"] await writeFile(join(root, "crates/db/src/models.rs"), ""); const seeds = await rustSeeds(root); - expect(seeds).toHaveLength(2); + expect(seeds).toHaveLength(4); - const webSeed = seeds.find((s) => s.entryPath === "crates/web/src/main.rs")!; + const webSeed = seeds.find((seed) => seed.source === "rust-command")!; expect(webSeed.contextFiles?.map((f) => f.path)).toEqual([ "crates/web/Cargo.toml", "crates/web/src/routes.rs", ]); - const dbSeed = seeds.find((s) => s.entryPath === "crates/db/src/lib.rs")!; + const dbSeed = seeds.find((seed) => seed.source === "rust-library")!; expect(dbSeed.contextFiles?.map((f) => f.path)).toEqual([ "crates/db/Cargo.toml", "crates/db/src/models.rs", ]); + + const sourceGroups = seeds.filter((seed) => seed.source === "rust-source-group"); + expect(sourceGroups).toHaveLength(2); + expect(sourceGroups.map((seed) => seed.entryPath).toSorted()).toEqual([ + "crates/db/Cargo.toml", + "crates/web/Cargo.toml", + ]); }); }); diff --git a/src/mappers/rust.ts b/src/mappers/rust.ts index 4b5d99b..11bf6ea 100644 --- a/src/mappers/rust.ts +++ b/src/mappers/rust.ts @@ -154,7 +154,6 @@ async function rustSourceGroupSeeds( const contextFiles = await rustManifestContextFiles(root, manifestPath); const seeds: FeatureSeed[] = []; for (const group of partitionFileGroups(sourceRoot, files, sourceGroupMaxOwnedFiles)) { - const entryPath = group.files[0] ?? sourceRoot; seeds.push({ title: `Rust source ${group.label}`, summary: @@ -164,7 +163,7 @@ async function rustSourceGroupSeeds( kind: packageKind(`${packageName} ${group.label}`), source: "rust-source-group", confidence: "medium", - entryPath, + entryPath: manifestPath, identityKey: group.label, symbol: group.label, route: null, From 37b7c410203cbb4d317d143672c8aea0718fff92 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 10 Aug 2026 18:41:10 -0700 Subject: [PATCH 3/3] chore(mapper): leave changelog to release process --- CHANGELOG.md | 1 - 1 file changed, 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 964a238..530dd33 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,6 @@ ## 0.7.3 - Unreleased -- Added bounded Rust source-group mapping for package modules while leaving crate entrypoints on existing command and library features, thanks @joshuaboys. - Reworked the README around a verified install and quickstart path, with deeper command, mapper, provider, and safety details linked to the existing docs. - Updated transitive Vitest dependencies. - Updated pnpm, formatter and linter tooling, and GitHub Actions dependencies.