From 05b8058a451084589c238bb7587e747dccd08d1f Mon Sep 17 00:00:00 2001 From: Cole Tebou Date: Mon, 13 Jul 2026 22:16:07 +0000 Subject: [PATCH 1/8] fix(mapper): tighten monorepo review slices Refresh project metadata during mapping, prefer specialized ownership, and bound review prompts so large workspaces produce accurate, reviewable features. --- docs/configuration.md | 1 + src/agent-mapper.ts | 3 + src/app.ts | 11 +- src/config.ts | 1 + src/detect.ts | 61 ++++++- src/mapper.test.ts | 290 +++++++++++++++++++--------------- src/mapper.ts | 40 +++-- src/mappers/node-routes.ts | 48 ++++-- src/mappers/node.ts | 14 +- src/mappers/projects.ts | 10 +- src/mappers/react.ts | 2 +- src/mappers/shared.test.ts | 13 +- src/mappers/shared.ts | 39 ++++- src/mappers/types.ts | 8 + src/prompt.test.ts | 33 ++++ src/prompt.ts | 28 ++-- src/review-validation.test.ts | 1 + src/types.ts | 1 + src/workflow.test.ts | 41 ++++- 19 files changed, 463 insertions(+), 182 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 3d37473e..63b47004 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -45,6 +45,7 @@ Default shape: "review": { "maxContextFiles": 24, "maxOwnedFiles": 12, + "maxPromptBytes": 180000, "maxFindingsPerFeature": 10, "minConfidenceToFix": "medium" }, diff --git a/src/agent-mapper.ts b/src/agent-mapper.ts index c8d2fcfc..d79a0647 100644 --- a/src/agent-mapper.ts +++ b/src/agent-mapper.ts @@ -261,6 +261,9 @@ async function toSeed( symbol: firstEntry?.symbol ?? null, route: firstEntry?.route ?? null, command: firstEntry?.command ?? null, + entrypoints: feature.entrypoints + .filter((candidate) => allowedFiles.has(normalize(candidate.path))) + .map((candidate) => ({ ...candidate, path: normalize(candidate.path) })), ownedFiles, contextFiles, tests, diff --git a/src/app.ts b/src/app.ts index 474f9a62..57be9f86 100644 --- a/src/app.ts +++ b/src/app.ts @@ -74,6 +74,8 @@ export async function mapCommand( ): Promise { const started = Date.now(); const loaded = await loadProjectState(context); + const detectedProject = await detectProject(loaded.root); + const project = { ...detectedProject, createdAt: loaded.project.createdAt }; const source = parseMapSource(flags); const config = applyProviderFlags(loaded.config, flags); const provider = source === "heuristic" ? null : providerByName(config.provider.name); @@ -84,7 +86,7 @@ export async function mapCommand( existing: existing.length, dryRun: flags["dryRun"] === true, }); - const heuristic = await mapFeatures(loaded.root, loaded.project, existing, { + const heuristic = await mapFeatures(loaded.root, project, existing, { filters, onProgress: (event) => { emitProgress(context, "map", event.event, { @@ -102,7 +104,7 @@ export async function mapCommand( changed: heuristic.changed, stale: heuristic.stale, }); - const result = await mapWithSource(loaded.root, loaded.project, existing, heuristic, { + const result = await mapWithSource(loaded.root, project, existing, heuristic, { source, provider, providerOptions: providerOptions(config), @@ -132,6 +134,7 @@ export async function mapCommand( emitProgress(context, "map", "write-start", { features: result.features.length, }); + await writeProject(loaded.paths, project); for (const feature of result.features) { await writeFeature(loaded.paths, feature); } @@ -177,11 +180,13 @@ export async function statusCommand(context: AppContext): Promise { for (const id of lockFileIds) { activeLockIds.add(id); } + const activeFeatures = features.filter((feature) => feature.status !== "skipped"); return { project: loaded.project.name, branch: git.currentBranch, dirty: git.dirty, - features: features.length, + features: activeFeatures.length, + staleFeatures: features.length - activeFeatures.length, findings: findings.length, openFindings: findings.filter((finding) => finding.status === "open").length, activeLocks: activeLockIds.size, diff --git a/src/config.ts b/src/config.ts index 3d12349d..def7f66c 100644 --- a/src/config.ts +++ b/src/config.ts @@ -53,6 +53,7 @@ export function defaultConfig(): ClawpatchConfig { review: { maxContextFiles: 24, maxOwnedFiles: 12, + maxPromptBytes: 180_000, maxFindingsPerFeature: 10, minConfidenceToFix: "medium", }, diff --git a/src/detect.ts b/src/detect.ts index 540933c9..78693066 100644 --- a/src/detect.ts +++ b/src/detect.ts @@ -14,6 +14,7 @@ import { ProjectRecord, ProjectCommands } from "./types.js"; type PackageJson = { name?: unknown; + packageManager?: unknown; scripts?: unknown; dependencies?: unknown; devDependencies?: unknown; @@ -162,7 +163,7 @@ async function detectCommands( const scripts = packageScripts(pkg); const composerScriptMap = composerScripts(composer); const defaults = await languageDefaultCommands(root, languages, composer); - const packageManager = packageScriptManager(packageManagers); + const packageManager = declaredNodePackageManager(pkg) ?? packageScriptManager(packageManagers); const composerTestCommand = composerValidationCommand(composerScriptMap, ["test"]); return { typecheck: @@ -288,6 +289,10 @@ function packageRunCommand(packageManager: string, script: string): string { async function detectPackageManagers(root: string): Promise { const found: string[] = []; + const declared = declaredNodePackageManager(await readPackageJson(root)); + if (declared !== null) { + found.push(declared); + } const nodeChecks: Array<[string, string]> = [ ["pnpm", "pnpm-lock.yaml"], ["npm", "package-lock.json"], @@ -381,6 +386,14 @@ async function detectPackageManagers(root: string): Promise { return found; } +export function declaredNodePackageManager(pkg: PackageJson | null): string | null { + if (typeof pkg?.packageManager !== "string") { + return null; + } + const name = pkg.packageManager.trim().split("@")[0]?.toLowerCase() ?? ""; + return ["npm", "pnpm", "yarn", "bun"].includes(name) ? name : null; +} + const pythonPackageManagers = new Set(["uv", "poetry", "pdm", "hatch", "pip", "python"]); const rubyPackageManagers = new Set(["bundler", "ruby"]); const dotnetLanguages = new Set(["csharp", "fsharp", "visual-basic"]); @@ -1094,10 +1107,10 @@ async function detectFrameworks( pkg: PackageJson | null, composer: ComposerJson | null, ): Promise { - const deps = dependencyNames(pkg); + const deps = new Set([...dependencyNames(pkg), ...(await nestedNodeDependencyNames(root, 6))]); const composerDeps = composerDependencyNames(composer); const frameworks: string[] = []; - for (const name of ["next", "express", "fastify", "hono", "vitest"]) { + for (const name of ["react", "next", "express", "fastify", "hono", "vitest"]) { if (deps.has(name)) { frameworks.push(name); } @@ -1147,6 +1160,48 @@ async function detectFrameworks( return uniqueStrings(frameworks); } +async function nestedNodeDependencyNames(root: string, maxDepth: number): Promise> { + const dependencies = new Set(); + await collectNestedNodeDependencies(root, root, maxDepth, dependencies); + return dependencies; +} + +async function collectNestedNodeDependencies( + root: string, + directory: string, + remainingDepth: number, + dependencies: Set, +): Promise { + if (remainingDepth < 0) { + return; + } + for (const entry of await readdir(directory).catch(() => [])) { + const full = join(directory, entry); + const relativePath = posix.normalize(full.slice(root.length + 1).replace(/\\/gu, "/")); + if (shouldSkipSearchEntry(entry, relativePath)) { + continue; + } + const info = await lstat(full).catch(() => null); + if (info === null || info.isSymbolicLink()) { + continue; + } + if (info.isFile() && entry === "package.json" && directory !== root) { + const parsed = await readFile(full, "utf8") + .then((source) => JSON.parse(source) as unknown) + .catch(() => null); + if (typeof parsed === "object" && parsed !== null) { + for (const dependency of dependencyNames(parsed as PackageJson)) { + dependencies.add(dependency); + } + } + continue; + } + if (info.isDirectory()) { + await collectNestedNodeDependencies(root, full, remainingDepth - 1, dependencies); + } + } +} + async function detectMavenFrameworks(root: string): Promise { const frameworks: string[] = []; for (const pom of await collectMavenPomFiles(root, 5)) { diff --git a/src/mapper.test.ts b/src/mapper.test.ts index 9c32fb32..90bfe7c5 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -796,17 +796,16 @@ describe("mapFeatures", () => { const project = await detectProject(root); const result = await mapFeatures(root, project, []); const route = result.features.find((feature) => feature.title === "web route /about"); - const source = result.features.find( - (feature) => feature.title === "Node source services/web/src", - ); expect(route?.entrypoints[0]?.path).toBe("services/web/src/app/about/page.tsx"); expect(route?.tags).toEqual( expect.arrayContaining(["project:web", "project-root:services/web"]), ); - expect(source?.tags).toEqual( - expect.arrayContaining(["project:web", "project-root:services/web"]), - ); + expect( + result.features.filter((feature) => + feature.ownedFiles.some((file) => file.path === "services/web/src/app/about/page.tsx"), + ), + ).toHaveLength(1); expect( result.features.some((feature) => feature.tags.includes("project-root:./services/web")), ).toBe(false); @@ -1747,7 +1746,7 @@ describe("mapFeatures", () => { expect(titles).toContain("Node source app/javascript"); expect(titles).toContain("Node source src"); expect(titles).toContain("Node source lib"); - expect(titles).toContain("Node source pages"); + expect(referencedFiles).toContain("pages/home.tsx"); expect(titles).toContain("Rails application configuration"); expect(titles).toContain("Rails database schema and migrations"); expect(titles).toContain("Rails database schema and migrations db/migrate#2"); @@ -2207,6 +2206,27 @@ describe("mapFeatures", () => { expect(project.detected.packageManagers).toContain("pnpm"); }); + it("does not classify client filenames as CLI semantic groups", async () => { + const root = await fixtureRoot("clawpatch-node-client-group-"); + await writeFixture(root, "package.json", JSON.stringify({ name: "client-group" })); + await writeFixture( + root, + "scripts/validate-client-build-order.ts", + "export const valid = true;\n", + ); + for (let index = 0; index < 12; index += 1) { + await writeFixture(root, `scripts/helper-${index}.ts`, `export const value = ${index};\n`); + } + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const feature = result.features.find((candidate) => + candidate.ownedFiles.some((file) => file.path === "scripts/validate-client-build-order.ts"), + ); + + expect(feature).toMatchObject({ kind: "library", trustBoundaries: [] }); + }); + it("maps workspace package metadata, entries, tests, and docs as package context", async () => { const root = await fixtureRoot("clawpatch-node-package-context-"); await writeFixture(root, "pnpm-workspace.yaml", "packages:\n - packages/*\n"); @@ -2520,16 +2540,15 @@ describe("mapFeatures", () => { const project = await detectProject(root); const result = await mapFeatures(root, project, []); const route = result.features.find((feature) => feature.title === "web route /"); - const webSource = result.features.find( - (feature) => feature.title === "Node source apps/web/app", - ); expect(route?.tests).toEqual([ { path: "apps/web/app/page.test.tsx", command: "pnpm turbo run test --filter web" }, ]); - expect(webSource?.tests).toEqual([ - { path: "apps/web/app/page.test.tsx", command: "pnpm turbo run test --filter web" }, - ]); + expect( + result.features.filter((feature) => + feature.ownedFiles.some((file) => file.path === "apps/web/app/page.tsx"), + ), + ).toHaveLength(1); }); it("quotes Turbo task filters with shell metacharacters", async () => { @@ -3071,81 +3090,80 @@ describe("mapFeatures", () => { const project = await detectProject(root); const result = await mapFeatures(root, project, []); const titles = result.features.map((feature) => feature.title); - const admin = result.features.find( - (feature) => feature.title === "Express route POST /admin/jobs", - ); - const webhook = result.features.find( - (feature) => feature.title === "Fastify route POST /webhook/github", - ); - const adminMiddleware = result.features.find( - (feature) => feature.title === "Express route GET /admin", - ); - const anonymousHandler = result.features.find( - (feature) => feature.title === "Express route GET /anonymous", - ); - const fastifyRouteObject = result.features.find( - (feature) => feature.title === "Fastify route GET /route-status", - ); - const session = result.features.find( - (feature) => feature.title === "Hono route DELETE /sessions/:id", + const routes = result.features.flatMap((feature) => + feature.entrypoints.flatMap((entrypoint) => + entrypoint.route === null ? [] : [entrypoint.route], + ), ); + const featureForRoute = (route: string) => + result.features.find((feature) => + feature.entrypoints.some((entrypoint) => entrypoint.route === route), + ); + const admin = featureForRoute("POST /admin/jobs"); + const webhook = featureForRoute("POST /webhook/github"); + const adminMiddleware = featureForRoute("GET /admin"); + const anonymousHandler = featureForRoute("GET /anonymous"); + const fastifyRouteObject = featureForRoute("GET /route-status"); + const session = featureForRoute("DELETE /sessions/:id"); expect(project.detected.frameworks).toEqual( expect.arrayContaining(["express", "fastify", "hono"]), ); - expect(titles).toEqual( - expect.arrayContaining([ - "Express route GET /health", - "Express route GET /after-postfix-division", - "Express route GET /admin", - "Express route GET /anonymous", - "Express route ALL /proxy", - "Express route POST /admin/jobs", - "Express route GET /aliased-router", - "Express route GET /banner-router", - "Express route GET /multiline-banner-router", - "Express route GET /semicolon-banner-router", - "Express route GET /from-binding-router", - "Express route GET /cjs-aliased-router", - "Express route GET /assigned-router", - "Express route GET /typed-assigned-router", - "Express route GET /required-router", - "Express route POST /typed-jobs", - "Express route PATCH /typed/:id", - "Express route GET /users", - "Express route DELETE /users", - "Express route GET /reports", - "Express route GET /projects/:projectId/items", - "Express route GET /bom-router", - "Express route GET /after-jsx-close", - "Express route GET /custom-file-real", - "Fastify route GET /status", - "Fastify route GET /typed-users/:id", - "Fastify route GET /route-status", - "Fastify route POST /webhook/github", - "Fastify route GET /plugin-users", - "Fastify route GET /plugin-app-users", - "Fastify route GET /plugin-typed-return-users", - "Fastify route GET /plugin-typed-object-return-users", - "Fastify route GET /plugin-aliased-type-users", - "Fastify route GET /plugin-server-users", - "Fastify route GET /plugin-server-return-users", - "Fastify route GET /plugin-arrow-users", - "Fastify route GET /plugin-bare-arrow-users", - "Fastify route GET /plugin-instance-users", - "Fastify route GET /plugin-comment-users", - "Fastify route GET /plugin-commented-argument-users", - "Fastify route GET /plugin-aliased-users", - "Fastify route GET /plugin-generic-users", - "Fastify route GET /plugin-import-equals-users", - "Fastify route GET /plugin-default-require-users", - "Fastify route GET /plugin-typed-arrow-users", - "Fastify route GET /plugin-inline-users", - "Fastify route GET /plugin-inline-arrow-users", - "Fastify route GET /plugin-multiline-users", - "Hono route GET /api/items", - "Hono route DELETE /sessions/:id", - ]), + expect(routes).toEqual( + expect.arrayContaining( + [ + "Express route GET /health", + "Express route GET /after-postfix-division", + "Express route GET /admin", + "Express route GET /anonymous", + "Express route ALL /proxy", + "Express route POST /admin/jobs", + "Express route GET /aliased-router", + "Express route GET /banner-router", + "Express route GET /multiline-banner-router", + "Express route GET /semicolon-banner-router", + "Express route GET /from-binding-router", + "Express route GET /cjs-aliased-router", + "Express route GET /assigned-router", + "Express route GET /typed-assigned-router", + "Express route GET /required-router", + "Express route POST /typed-jobs", + "Express route PATCH /typed/:id", + "Express route GET /users", + "Express route DELETE /users", + "Express route GET /reports", + "Express route GET /projects/:projectId/items", + "Express route GET /bom-router", + "Express route GET /after-jsx-close", + "Express route GET /custom-file-real", + "Fastify route GET /status", + "Fastify route GET /typed-users/:id", + "Fastify route GET /route-status", + "Fastify route POST /webhook/github", + "Fastify route GET /plugin-users", + "Fastify route GET /plugin-app-users", + "Fastify route GET /plugin-typed-return-users", + "Fastify route GET /plugin-typed-object-return-users", + "Fastify route GET /plugin-aliased-type-users", + "Fastify route GET /plugin-server-users", + "Fastify route GET /plugin-server-return-users", + "Fastify route GET /plugin-arrow-users", + "Fastify route GET /plugin-bare-arrow-users", + "Fastify route GET /plugin-instance-users", + "Fastify route GET /plugin-comment-users", + "Fastify route GET /plugin-commented-argument-users", + "Fastify route GET /plugin-aliased-users", + "Fastify route GET /plugin-generic-users", + "Fastify route GET /plugin-import-equals-users", + "Fastify route GET /plugin-default-require-users", + "Fastify route GET /plugin-typed-arrow-users", + "Fastify route GET /plugin-inline-users", + "Fastify route GET /plugin-inline-arrow-users", + "Fastify route GET /plugin-multiline-users", + "Hono route GET /api/items", + "Hono route DELETE /sessions/:id", + ].map((title) => title.replace(/^(?:Express|Fastify|Hono) route /u, "")), + ), ); expect(titles).not.toContain("Express route GET /commented"); expect(titles).not.toContain("Express route POST /string"); @@ -3173,7 +3191,9 @@ describe("mapFeatures", () => { expect(titles).not.toContain("Fastify route GET /concat-"); expect(titles).not.toContain("Express route DELETE /reports"); expect(admin?.source).toBe("express-route"); - expect(admin?.entrypoints[0]).toMatchObject({ + expect( + admin?.entrypoints.find((entrypoint) => entrypoint.route === "POST /admin/jobs"), + ).toMatchObject({ path: "src/server.ts", symbol: "createJob", route: "POST /admin/jobs", @@ -3181,9 +3201,17 @@ describe("mapFeatures", () => { expect(admin?.tests).toEqual([{ path: "src/server.test.ts", command: "npm run test" }]); expect(admin?.trustBoundaries).toContain("auth"); expect(webhook?.trustBoundaries).toEqual(expect.arrayContaining(["auth", "external-api"])); - expect(adminMiddleware?.entrypoints[0]?.symbol).toBe("showAdmin"); - expect(anonymousHandler?.entrypoints[0]?.symbol).toBeNull(); - expect(fastifyRouteObject?.entrypoints[0]?.symbol).toBe("routeStatus"); + expect( + adminMiddleware?.entrypoints.find((entrypoint) => entrypoint.route === "GET /admin")?.symbol, + ).toBe("showAdmin"); + expect( + anonymousHandler?.entrypoints.find((entrypoint) => entrypoint.route === "GET /anonymous") + ?.symbol, + ).toBeNull(); + expect( + fastifyRouteObject?.entrypoints.find((entrypoint) => entrypoint.route === "GET /route-status") + ?.symbol, + ).toBe("routeStatus"); expect(session?.trustBoundaries).toContain("auth"); }); @@ -3236,24 +3264,27 @@ describe("mapFeatures", () => { const project = await detectProject(root); const result = await mapFeatures(root, project, []); - const titles = result.features.map((feature) => feature.title); - const routes = result.features - .map((feature) => feature.entrypoints[0]?.route) - .filter((route): route is string => route !== undefined && route !== null); + const routes = result.features.flatMap((feature) => + feature.entrypoints.flatMap((entrypoint) => + entrypoint.route === null ? [] : [entrypoint.route], + ), + ); - expect(titles).toEqual( - expect.arrayContaining([ - "Fastify route GET /items", - "Fastify route POST /items", - "Fastify route DELETE /mixed", - "Fastify route GET /indexed-mixed", - "Fastify route PUT /const-items", - "Fastify route PATCH /const-items", - "Fastify route OPTIONS /satisfies-items", - "Fastify route PATCH /template-static", - "Fastify route GET /template-mixed", - "Fastify route HEAD /template-mixed-tail", - ]), + expect(routes).toEqual( + expect.arrayContaining( + [ + "Fastify route GET /items", + "Fastify route POST /items", + "Fastify route DELETE /mixed", + "Fastify route GET /indexed-mixed", + "Fastify route PUT /const-items", + "Fastify route PATCH /const-items", + "Fastify route OPTIONS /satisfies-items", + "Fastify route PATCH /template-static", + "Fastify route GET /template-mixed", + "Fastify route HEAD /template-mixed-tail", + ].map((title) => title.replace(/^Fastify route /u, "")), + ), ); expect(routes.some((route) => route.endsWith(" /dynamic-only"))).toBe(false); expect(routes.some((route) => route.endsWith(" /numeric-only"))).toBe(false); @@ -3427,29 +3458,36 @@ describe("mapFeatures", () => { const project = await detectProject(root); const result = await mapFeatures(root, project, []); const titles = result.features.map((feature) => feature.title); + const routes = result.features.flatMap((feature) => + feature.entrypoints.flatMap((entrypoint) => + entrypoint.route === null ? [] : [entrypoint.route], + ), + ); - expect(titles).toEqual( - expect.arrayContaining([ - "Express route GET /api/users", - "Express route GET /api/reports", - "Express route POST /api/v1/teams", - "Express route DELETE /service/sessions/:id", - "Express route GET /middleware/users", - "Express route GET /service/generic-middleware-users", - "Express route GET /service/async-middleware-users", - "Express route GET /service/json-users", - "Express route GET /service/imported-users", - "Express route GET /service/pathless-users", - "Express route GET /service/direct-pathless-users", - "Express route GET /service/first-pathless-users", - "Express route GET /service/second-pathless-users", - "Express route GET /array/array-users", - "Express route GET /alt-array/array-users", - "Express route GET /*/wildcard-users", - "Express route GET /member-users", - "Hono route GET /api/users", - "Hono route DELETE /api/v1/sessions/:id", - ]), + expect(routes).toEqual( + expect.arrayContaining( + [ + "Express route GET /api/users", + "Express route GET /api/reports", + "Express route POST /api/v1/teams", + "Express route DELETE /service/sessions/:id", + "Express route GET /middleware/users", + "Express route GET /service/generic-middleware-users", + "Express route GET /service/async-middleware-users", + "Express route GET /service/json-users", + "Express route GET /service/imported-users", + "Express route GET /service/pathless-users", + "Express route GET /service/direct-pathless-users", + "Express route GET /service/first-pathless-users", + "Express route GET /service/second-pathless-users", + "Express route GET /array/array-users", + "Express route GET /alt-array/array-users", + "Express route GET /*/wildcard-users", + "Express route GET /member-users", + "Hono route GET /api/users", + "Hono route DELETE /api/v1/sessions/:id", + ].map((title) => title.replace(/^(?:Express|Hono) route /u, "")), + ), ); expect(titles).not.toContain("Express route GET /users"); expect(titles).not.toContain("Express route GET /reports"); diff --git a/src/mapper.ts b/src/mapper.ts index dcaf8fb7..b358c4e5 100644 --- a/src/mapper.ts +++ b/src/mapper.ts @@ -127,14 +127,17 @@ export async function mapFeatureSeeds( kind: seed.kind, source: seed.source, confidence: seed.confidence, - entrypoints: [ - { - path: seed.entryPath, - symbol: identity.symbol, - route: seed.route, - command: seed.command, - }, - ], + entrypoints: + seed.entrypoints === undefined + ? [ + { + path: seed.entryPath, + symbol: identity.symbol, + route: seed.route, + command: seed.command, + }, + ] + : seed.entrypoints, ownedFiles: seed.ownedFiles ?? [{ path: seed.entryPath, reason: "entrypoint" }], contextFiles, tests, @@ -313,7 +316,26 @@ async function collectSeeds( return seeds; }), ); - return dedupeFeatureSeeds(groups.flat()); + return resolveGenericOwnership(dedupeFeatureSeeds(groups.flat())); +} + +function resolveGenericOwnership(seeds: FeatureSeed[]): FeatureSeed[] { + const specializedOwnedFiles = new Set( + seeds + .filter((seed) => seed.source !== "node-source-group") + .flatMap((seed) => seed.ownedFiles ?? [{ path: seed.entryPath, reason: "entrypoint" }]) + .map((ref) => ref.path), + ); + return seeds.flatMap((seed) => { + if (seed.source !== "node-source-group" || seed.ownedFiles === undefined) { + return [seed]; + } + const ownedFiles = seed.ownedFiles.filter((ref) => !specializedOwnedFiles.has(ref.path)); + if (ownedFiles.length === 0) { + return []; + } + return [{ ...seed, ownedFiles }]; + }); } async function shouldRunNodeMappers(root: string, project: ProjectRecord): Promise { diff --git a/src/mappers/node-routes.ts b/src/mappers/node-routes.ts index b99cc66b..a4652ee6 100644 --- a/src/mappers/node-routes.ts +++ b/src/mappers/node-routes.ts @@ -123,20 +123,42 @@ async function projectRouteSeeds( for (const file of files) { const source = await readFile(join(root, file), "utf8"); - for (const route of parseServerRoutes(source, file, frameworks)) { - const routeTests = associatedTests([route.filePath], tests, testCommand ?? null); - const frameworkLabel = frameworkTitle(route.framework); + const routes = parseServerRoutes(source, file, frameworks); + for (const framework of frameworks) { + const frameworkRoutes = routes.filter((route) => route.framework === framework); + if (frameworkRoutes.length === 0) { + continue; + } + const firstRoute = frameworkRoutes[0]; + if (firstRoute === undefined) { + continue; + } + const routeTests = associatedTests([file], tests, testCommand ?? null); + const frameworkLabel = frameworkTitle(framework); + const singleRoute = frameworkRoutes.length === 1; + const routeList = frameworkRoutes.map((route) => `${route.method} ${route.routePath}`); seeds.push({ - title: `${frameworkLabel} route ${route.method} ${route.routePath}`, - summary: `${frameworkLabel} route ${route.method} ${route.routePath} declared in ${route.filePath}.`, + title: singleRoute + ? `${frameworkLabel} route ${firstRoute.method} ${firstRoute.routePath}` + : `${frameworkLabel} routes ${file}`, + summary: singleRoute + ? `${frameworkLabel} route ${firstRoute.method} ${firstRoute.routePath} declared in ${file}.` + : `${frameworkLabel} route module ${file} declares ${frameworkRoutes.length} routes: ${routeList.join(", ")}.`, kind: "route", - source: `${route.framework}-route`, - confidence: "medium", - entryPath: route.filePath, - symbol: route.symbol, - route: `${route.method} ${route.routePath}`, + source: `${framework}-route`, + confidence: "high", + entryPath: file, + ...(singleRoute ? {} : { identityKey: `route-module:${framework}:${file}` }), + symbol: singleRoute ? firstRoute.symbol : null, + route: singleRoute ? `${firstRoute.method} ${firstRoute.routePath}` : null, command: null, - ownedFiles: [{ path: route.filePath, reason: `${frameworkLabel} route declaration` }], + entrypoints: frameworkRoutes.map((route) => ({ + path: route.filePath, + symbol: route.symbol, + route: `${route.method} ${route.routePath}`, + command: null, + })), + ownedFiles: [{ path: file, reason: `${frameworkLabel} route module` }], contextFiles: uniqueFileRefs([ ...projectContext, ...routeTests.map((test) => ({ path: test.path, reason: "associated test" })), @@ -144,13 +166,13 @@ async function projectRouteSeeds( tests: routeTests, tags: [ "node", - route.framework, + framework, "route", "api", ...projectTags(project), ...(testCommand === null ? [suppressedTestCommandTag] : []), ], - trustBoundaries: routeTrustBoundaries(route), + trustBoundaries: [...new Set(frameworkRoutes.flatMap(routeTrustBoundaries))], ...(testCommand === undefined ? {} : { testCommand }), skipNearbyTests: true, }); diff --git a/src/mappers/node.ts b/src/mappers/node.ts index c0749a60..f8f14c3d 100644 --- a/src/mappers/node.ts +++ b/src/mappers/node.ts @@ -37,7 +37,7 @@ const sourceDirectories = ["src", "lib", "app", "pages", "scripts", "server", "a const testDirectories = ["test", "tests", "__tests__"] as const; const sourceGroupMaxOwnedFiles = 12; const sourceGroupMaxTests = 8; -const packageOverviewMaxContextFiles = 40; +const packageOverviewMaxContextFiles = 16; const semanticSourceSegments = [ "monitor", "webhook", @@ -114,7 +114,7 @@ async function packageSeeds( summary: packageSummary, kind: packageKind(`${packageName} ${info.root}`), source: manifestSource, - confidence: "medium", + confidence: "high", entryPath: info.packageJsonPath, symbol: packageName, route: null, @@ -168,7 +168,7 @@ async function packageSeeds( : `Package script '${script}' in ${info.packageJsonPath}: ${command}`, kind: script === "test" ? "test-suite" : "release", source: "package-json-script", - confidence: "medium", + confidence: "high", entryPath: info.packageJsonPath, symbol: script, route: null, @@ -223,7 +223,7 @@ async function sourceGroupSeeds( : `Node/TypeScript source group ${group.label} with ${group.files.length} files.`, kind: packageKind(`${packageName} ${group.label}`), source: "node-source-group", - confidence: "medium", + confidence: group.files.length === 1 || group.label.includes("/:") ? "high" : "medium", entryPath, symbol: group.label, route: null, @@ -284,7 +284,7 @@ async function packageOverviewContextFiles( const entryRefs = await packageEntryContextFiles(root, info); const sourceRefs = await packageSourceOverviewRefs(root, info); const testRefs = (await packageTestFiles(root, info)) - .slice(0, 12) + .slice(0, 4) .map((path) => ({ path, reason: "package test" })); return uniqueFileRefs([...docs, ...entryRefs, ...sourceRefs, ...testRefs]).slice( 0, @@ -360,7 +360,7 @@ async function packageSourceOverviewRefs(root: string, info: PackageInfo): Promi ) .flat() .filter((path, index, all) => all.indexOf(path) === index) - .slice(0, 24); + .slice(0, 8); return files.map((path) => ({ path, reason: "package source overview" })); } @@ -654,7 +654,7 @@ function semanticSegmentForFile(path: string): string | null { basenameWithoutExtension.split(/[^a-z0-9]+/u).filter((token) => token.length > 0), ); for (const segment of semanticSourceSegments) { - if (tokens.has(segment) || basenameWithoutExtension.includes(segment)) { + if (tokens.has(segment) || (segment !== "cli" && basenameWithoutExtension.includes(segment))) { return segment === "command" ? "commands" : segment; } } diff --git a/src/mappers/projects.ts b/src/mappers/projects.ts index 8f518c66..8d9a95b7 100644 --- a/src/mappers/projects.ts +++ b/src/mappers/projects.ts @@ -1,6 +1,6 @@ import { lstat, readFile, readdir, realpath } from "node:fs/promises"; import { basename, dirname, join } from "node:path"; -import { packageScripts, readPackageJson } from "../detect.js"; +import { declaredNodePackageManager, packageScripts, readPackageJson } from "../detect.js"; import { pathExists } from "../fs.js"; import { shellQuotePath } from "../shell.js"; import { isSafeDirectory, normalize, pathMatchesPrefix, shouldSkip } from "./shared.js"; @@ -9,6 +9,7 @@ import type { SeedFileRef } from "./types.js"; export type NodePackageJson = { name?: unknown; + packageManager?: unknown; scripts?: unknown; dependencies?: unknown; devDependencies?: unknown; @@ -1061,12 +1062,19 @@ function packageDisplayName( } export async function detectNodePackageManager(root: string): Promise { + const declared = declaredNodePackageManager(await readPackageJson(root)); + if (declared !== null) { + return declared; + } if ( (await pathExists(join(root, "pnpm-lock.yaml"))) || (await pathExists(join(root, "pnpm-workspace.yaml"))) ) { return "pnpm"; } + if (await pathExists(join(root, "package-lock.json"))) { + return "npm"; + } if (await pathExists(join(root, "yarn.lock"))) { return "yarn"; } diff --git a/src/mappers/react.ts b/src/mappers/react.ts index 76ae93ac..490f7c7a 100644 --- a/src/mappers/react.ts +++ b/src/mappers/react.ts @@ -196,7 +196,7 @@ async function componentSeeds( summary: `React component implemented by ${file}.`, kind: "ui-flow", source: "react-component", - confidence: "medium", + confidence: "high", entryPath: file, symbol: componentName, route: null, diff --git a/src/mappers/shared.test.ts b/src/mappers/shared.test.ts index 59a70859..e5dd7b27 100644 --- a/src/mappers/shared.test.ts +++ b/src/mappers/shared.test.ts @@ -1,5 +1,16 @@ import { describe, expect, it, vi } from "vitest"; -import { createNearbyTestFinder } from "./shared.js"; +import { createNearbyTestFinder, packageKind, packageTrustBoundaries } from "./shared.js"; + +describe("package semantics", () => { + it("does not mistake client packages for CLI commands", () => { + expect(packageKind("@apex/app-code-client packages/apps/code/code-client")).toBe("library"); + expect( + packageTrustBoundaries("@apex/app-code-client packages/apps/code/code-client"), + ).not.toContain("process-exec"); + expect(packageKind("@scope/tool-cli packages/tool-cli")).toBe("cli-command"); + expect(packageTrustBoundaries("@scope/tool-cli packages/tool-cli")).toContain("process-exec"); + }); +}); describe("nearby test discovery", () => { it("caches shared directory walks for one mapping run", async () => { diff --git a/src/mappers/shared.ts b/src/mappers/shared.ts index 08b69c7a..0512278b 100644 --- a/src/mappers/shared.ts +++ b/src/mappers/shared.ts @@ -316,29 +316,58 @@ export function isSampleProjectPath(path: string): boolean { } export function packageKind(name: string): FeatureSeed["kind"] { - if (/config|store|db|github|openai|sync/iu.test(name)) { + const tokens = semanticNameTokens(name); + if ( + [ + "config", + "configuration", + "store", + "storage", + "db", + "database", + "github", + "openai", + "sync", + "service", + "server", + ].some((token) => tokens.has(token)) + ) { return "service"; } - if (/cli/iu.test(name)) { + if (["cli", "command", "commands"].some((token) => tokens.has(token))) { return "cli-command"; } return "library"; } export function packageTrustBoundaries(name: string): TrustBoundary[] { + const tokens = semanticNameTokens(name); const boundaries: TrustBoundary[] = []; - if (/config|store|db/iu.test(name)) { + if ( + ["config", "configuration", "store", "storage", "db", "database"].some((token) => + tokens.has(token), + ) + ) { boundaries.push("filesystem", "database"); } - if (/github|openai|sync/iu.test(name)) { + if (["github", "openai", "sync"].some((token) => tokens.has(token))) { boundaries.push("network", "external-api", "serialization"); } - if (/cli/iu.test(name)) { + if (["cli", "command", "commands"].some((token) => tokens.has(token))) { boundaries.push("user-input", "process-exec"); } return boundaries; } +function semanticNameTokens(name: string): Set { + return new Set( + name + .toLowerCase() + .split(/[^a-z0-9]+/u) + .filter(Boolean), + ); +} + export function normalize(path: string): string { return path.split(sep).join("/"); } diff --git a/src/mappers/types.ts b/src/mappers/types.ts index cdaf5785..96eca5d6 100644 --- a/src/mappers/types.ts +++ b/src/mappers/types.ts @@ -12,6 +12,13 @@ export type SeedTestRef = { command: string | null; }; +export type SeedEntrypoint = { + path: string; + symbol: string | null; + route: string | null; + command: string | null; +}; + export type FeatureSeed = { title: string; summary: string; @@ -23,6 +30,7 @@ export type FeatureSeed = { symbol: string | null; route: string | null; command: string | null; + entrypoints?: SeedEntrypoint[]; tags: string[]; trustBoundaries: TrustBoundary[]; ownedFiles?: SeedFileRef[]; diff --git a/src/prompt.test.ts b/src/prompt.test.ts index 45b28596..d327528e 100644 --- a/src/prompt.test.ts +++ b/src/prompt.test.ts @@ -10,6 +10,39 @@ import { fixtureRoot, writeFixture } from "./test-helpers.js"; import type { FeatureRecord, FindingRecord, ProjectRecord } from "./types.js"; describe("review prompt provenance", () => { + it("caps aggregate file context by the configured prompt budget", async () => { + const root = await fixtureRoot("clawpatch-prompt-total-budget-"); + await writeFixture(root, "src/index.ts", `${"a".repeat(30_000)}\n`); + await writeFixture(root, "src/second.ts", `${"b".repeat(30_000)}\n`); + await writeFixture(root, "docs/context.md", `${"c".repeat(30_000)}\n`); + const budgetedFeature = { + ...feature(), + ownedFiles: [ + { path: "src/index.ts", reason: "primary" }, + { path: "src/second.ts", reason: "secondary" }, + ], + contextFiles: [{ path: "docs/context.md", reason: "context" }], + tests: [], + }; + const config = defaultConfig(); + config.review.maxPromptBytes = 100_000; + + const bundle = await buildReviewPromptBundle(root, project(root), budgetedFeature, config); + + expect(bundle.manifest.maxPromptBytes).toBe(100_000); + expect(bundle.manifest.omittedFiles).toContainEqual({ + path: "src/second.ts", + role: "owned", + reason: "maxPromptBytes", + }); + expect(bundle.manifest.omittedFiles).toContainEqual({ + path: "docs/context.md", + role: "context", + reason: "maxPromptBytes", + }); + expect(bundle.manifest.promptBytes).toBeLessThanOrEqual(100_000); + }); + it("records included, omitted, and truncated review prompt context", async () => { const root = await fixtureRoot("clawpatch-prompt-provenance-"); await writeFixture(root, "src/index.ts", "export const value = 1;\n"); diff --git a/src/prompt.ts b/src/prompt.ts index 98789423..2d2f2ada 100644 --- a/src/prompt.ts +++ b/src/prompt.ts @@ -39,6 +39,7 @@ export type ReviewPromptFileManifest = { export type ReviewPromptManifest = { maxOwnedFiles: number; maxContextFiles: number; + maxPromptBytes: number; includedFiles: ReviewPromptFileManifest[]; omittedFiles: Array<{ path: string; role: ReviewPromptFileRole; reason: string }>; promptBytes: number; @@ -139,20 +140,22 @@ export async function buildReviewPromptBundle( ]; const fileBlocks: string[] = []; const includedFiles: ReviewPromptFileManifest[] = []; - for (const ref of owned) { - const file = await fileBlockWithManifest(root, ref.path, "owned"); - fileBlocks.push(file.block); - includedFiles.push(file.manifest); - } - for (const ref of context) { - const file = await fileBlockWithManifest(root, ref.path, "context"); - fileBlocks.push(file.block); - includedFiles.push(file.manifest); - } - for (const ref of tests) { - const file = await fileBlockWithManifest(root, ref.path, "test"); + const fileContentBudget = Math.max(0, config.review.maxPromptBytes - 64_000); + let includedFileBytes = 0; + for (const { ref, role } of [ + ...owned.map((promptRef) => ({ ref: promptRef, role: "owned" as const })), + ...context.map((promptRef) => ({ ref: promptRef, role: "context" as const })), + ...tests.map((promptRef) => ({ ref: promptRef, role: "test" as const })), + ]) { + const file = await fileBlockWithManifest(root, ref.path, role); + const blockBytes = Buffer.byteLength(file.block, "utf8"); + if (includedFileBytes + blockBytes > fileContentBudget && includedFiles.length > 0) { + omittedFiles.push({ path: ref.path, role, reason: "maxPromptBytes" }); + continue; + } fileBlocks.push(file.block); includedFiles.push(file.manifest); + includedFileBytes += blockBytes; } const customBlock = customPrompt !== null && customPrompt.trim() !== "" @@ -165,6 +168,7 @@ ${customPrompt.trim()} const promptContext = { maxOwnedFiles: config.review.maxOwnedFiles, maxContextFiles: config.review.maxContextFiles, + maxPromptBytes: config.review.maxPromptBytes, includedFiles: includedFiles.map( ({ path, diff --git a/src/review-validation.test.ts b/src/review-validation.test.ts index 0974acfc..0bfea28e 100644 --- a/src/review-validation.test.ts +++ b/src/review-validation.test.ts @@ -402,6 +402,7 @@ function manifest( return { maxOwnedFiles: defaultConfig().review.maxOwnedFiles, maxContextFiles: defaultConfig().review.maxContextFiles, + maxPromptBytes: defaultConfig().review.maxPromptBytes, includedFiles: [ { path, diff --git a/src/types.ts b/src/types.ts index c91304c0..19f8f800 100644 --- a/src/types.ts +++ b/src/types.ts @@ -140,6 +140,7 @@ export const configSchema = z.object({ review: z.object({ maxContextFiles: z.number().int().positive(), maxOwnedFiles: z.number().int().positive(), + maxPromptBytes: z.number().int().positive().optional().default(180_000), maxFindingsPerFeature: z.number().int().positive(), minConfidenceToFix: z.enum(["high", "medium", "low"]), }), diff --git a/src/workflow.test.ts b/src/workflow.test.ts index 65144c4b..a37d02b6 100644 --- a/src/workflow.test.ts +++ b/src/workflow.test.ts @@ -2289,8 +2289,47 @@ describe("workflow", () => { await writeFixture(root, "package.json", JSON.stringify({ name: "stale-cli" })); await mapCommand(context); const features = await readFeatures(statePaths(join(root, ".clawpatch"))); + const status = await statusCommand(context); expect(features.some((feature) => feature.status === "skipped")).toBe(true); + expect(status).toMatchObject({ features: 2, staleFeatures: 1 }); + }); + + it("refreshes detected project metadata on every map", async () => { + const root = await fixtureRoot("clawpatch-project-refresh-"); + await writeFixture( + root, + "package.json", + JSON.stringify({ + name: "refresh-project", + packageManager: "yarn@4.0.0", + scripts: { test: "vitest run" }, + }), + ); + await writeFixture(root, "src/index.ts", "export const value = 1;\n"); + const context = await makeContext(testOptions(root)); + + await initCommand(context, {}); + await writeFixture( + root, + "package.json", + JSON.stringify({ + name: "refresh-project", + packageManager: "npm@11.0.0", + scripts: { test: "vitest run" }, + }), + ); + await writeFixture( + root, + "packages/web/package.json", + JSON.stringify({ name: "web", dependencies: { react: "1.0.0", express: "1.0.0" } }), + ); + await mapCommand(context); + const project = await readProject(statePaths(join(root, ".clawpatch"))); + + expect(project?.detected.packageManagers[0]).toBe("npm"); + expect(project?.detected.commands.test).toBe("npm run test"); + expect(project?.detected.frameworks).toEqual(expect.arrayContaining(["react", "express"])); }); it("counts stale features by missing ids", async () => { @@ -4498,7 +4537,7 @@ describe("workflow", () => { const context = await makeContext(testOptions(root)); const paths = statePaths(join(root, ".clawpatch")); await initCommand(context, {}); - await checkCommand(root, "git add .clawpatch/config.json"); + await checkCommand(root, "git add -f .clawpatch/config.json"); await writeFixture(root, "docs/foo bar.md", "fixed\n"); const now = new Date().toISOString(); await writePatchAttempt(paths, { From c20c6dba29b3adb6e05d5a0b46727c96c54ec243 Mon Sep 17 00:00:00 2001 From: Cole Tebou Date: Mon, 13 Jul 2026 22:29:44 +0000 Subject: [PATCH 2/8] fix(review): enforce prompt and ownership bounds Apply the final prompt-byte limit to the fully rendered review and rebase generic entrypoints after specialized ownership is removed. --- src/mapper.test.ts | 10 ++++ src/mapper.ts | 5 +- src/prompt.test.ts | 25 ++++++++-- src/prompt.ts | 115 +++++++++++++++++++++++++-------------------- 4 files changed, 101 insertions(+), 54 deletions(-) diff --git a/src/mapper.test.ts b/src/mapper.test.ts index 90bfe7c5..3074ab78 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -492,6 +492,7 @@ describe("mapFeatures", () => { "apps/storefront/src/app/checkout/page.test.tsx", "test('checkout', () => {});\n", ); + await writeFixture(root, "apps/storefront/src/helper.ts", "export const helper = true;\n"); await writeFixture(root, "apps/worker/src/index.ts", "export const worker = true;\n"); await writeFixture(root, "apps/worker/src/index.test.ts", "test('worker', () => {});\n"); await writeFixture(root, "apps/api/server/index.ts", "export const api = true;\n"); @@ -514,6 +515,11 @@ describe("mapFeatures", () => { const worker = result.features.find( (feature) => feature.title === "Node source apps/worker/src", ); + const storefrontSource = result.features.find( + (feature) => + feature.source === "node-source-group" && + feature.ownedFiles.some((file) => file.path === "apps/storefront/src/helper.ts"), + ); const api = result.features.find((feature) => feature.title === "Node source apps/api/server"); expect(route?.entrypoints[0]?.path).toBe("apps/storefront/src/app/checkout/page.tsx"); @@ -524,6 +530,10 @@ describe("mapFeatures", () => { path: "apps/storefront/src/app/checkout/page.test.tsx", command: null, }); + expect(storefrontSource?.entrypoints[0]?.path).toBe("apps/storefront/src/helper.ts"); + expect(storefrontSource?.ownedFiles).not.toContainEqual( + expect.objectContaining({ path: "apps/storefront/src/app/checkout/page.tsx" }), + ); expect(worker?.ownedFiles).toContainEqual({ path: "apps/worker/src/index.ts", reason: "source group apps/worker/src", diff --git a/src/mapper.ts b/src/mapper.ts index b358c4e5..53afc73c 100644 --- a/src/mapper.ts +++ b/src/mapper.ts @@ -334,7 +334,10 @@ function resolveGenericOwnership(seeds: FeatureSeed[]): FeatureSeed[] { if (ownedFiles.length === 0) { return []; } - return [{ ...seed, ownedFiles }]; + const entryPath = specializedOwnedFiles.has(seed.entryPath) + ? (ownedFiles[0]?.path ?? seed.entryPath) + : seed.entryPath; + return [{ ...seed, entryPath, ownedFiles }]; }); } diff --git a/src/prompt.test.ts b/src/prompt.test.ts index d327528e..e1eb9213 100644 --- a/src/prompt.test.ts +++ b/src/prompt.test.ts @@ -25,11 +25,11 @@ describe("review prompt provenance", () => { tests: [], }; const config = defaultConfig(); - config.review.maxPromptBytes = 100_000; + config.review.maxPromptBytes = 50_000; const bundle = await buildReviewPromptBundle(root, project(root), budgetedFeature, config); - expect(bundle.manifest.maxPromptBytes).toBe(100_000); + expect(bundle.manifest.maxPromptBytes).toBe(50_000); expect(bundle.manifest.omittedFiles).toContainEqual({ path: "src/second.ts", role: "owned", @@ -40,7 +40,26 @@ describe("review prompt provenance", () => { role: "context", reason: "maxPromptBytes", }); - expect(bundle.manifest.promptBytes).toBeLessThanOrEqual(100_000); + expect(bundle.manifest.promptBytes).toBeLessThanOrEqual(50_000); + }); + + it("omits the first file when even its truncated block exceeds the final prompt budget", async () => { + const root = await fixtureRoot("clawpatch-prompt-first-file-budget-"); + await writeFixture(root, "src/index.ts", `${"a".repeat(30_000)}\n`); + const config = defaultConfig(); + const withoutFiles = { ...feature(), ownedFiles: [], contextFiles: [], tests: [] }; + const baseline = await buildReviewPromptBundle(root, project(root), withoutFiles, config); + config.review.maxPromptBytes = baseline.manifest.promptBytes + 1_000; + + const bundle = await buildReviewPromptBundle(root, project(root), feature(), config); + + expect(bundle.manifest.includedFiles).toEqual([]); + expect(bundle.manifest.omittedFiles).toContainEqual({ + path: "src/index.ts", + role: "owned", + reason: "maxPromptBytes", + }); + expect(bundle.manifest.promptBytes).toBeLessThanOrEqual(config.review.maxPromptBytes); }); it("records included, omitted, and truncated review prompt context", async () => { diff --git a/src/prompt.ts b/src/prompt.ts index 2d2f2ada..3b441536 100644 --- a/src/prompt.ts +++ b/src/prompt.ts @@ -8,6 +8,7 @@ import { PatchAttempt, ProjectRecord, } from "./types.js"; +import { ClawpatchError } from "./errors.js"; import { validationCommandsForFeature } from "./validation.js"; export type ReviewMode = "default" | "deslopify"; @@ -140,65 +141,58 @@ export async function buildReviewPromptBundle( ]; const fileBlocks: string[] = []; const includedFiles: ReviewPromptFileManifest[] = []; - const fileContentBudget = Math.max(0, config.review.maxPromptBytes - 64_000); - let includedFileBytes = 0; for (const { ref, role } of [ ...owned.map((promptRef) => ({ ref: promptRef, role: "owned" as const })), ...context.map((promptRef) => ({ ref: promptRef, role: "context" as const })), ...tests.map((promptRef) => ({ ref: promptRef, role: "test" as const })), ]) { const file = await fileBlockWithManifest(root, ref.path, role); - const blockBytes = Buffer.byteLength(file.block, "utf8"); - if (includedFileBytes + blockBytes > fileContentBudget && includedFiles.length > 0) { - omittedFiles.push({ path: ref.path, role, reason: "maxPromptBytes" }); - continue; - } fileBlocks.push(file.block); includedFiles.push(file.manifest); - includedFileBytes += blockBytes; } - const customBlock = - customPrompt !== null && customPrompt.trim() !== "" - ? `Additional reviewer guidance (provided via --prompt-file): + const renderPrompt = () => { + const customBlock = + customPrompt !== null && customPrompt.trim() !== "" + ? `Additional reviewer guidance (provided via --prompt-file): ${customPrompt.trim()} ` - : ""; - const promptContext = { - maxOwnedFiles: config.review.maxOwnedFiles, - maxContextFiles: config.review.maxContextFiles, - maxPromptBytes: config.review.maxPromptBytes, - includedFiles: includedFiles.map( - ({ - path, - role, - bytes, - includedBytes, - includedStartLine, - includedEndLine, - includedLineRanges, - truncated, - }) => ({ - path, - role, - bytes, - includedBytes, - includedStartLine, - includedEndLine, - includedLineRanges, - truncated, - }), - ), - omittedFiles, - }; - const validEvidencePaths = [ - ...new Set(includedFiles.filter((file) => file.readable).map((file) => file.path)), - ]; - const languageGuidance = reviewLanguageGuidance(project); - const cudaBlock = - mode === "default" && featureIncludesCuda(feature) ? `\n${cudaGuidance()}\n` : ""; - const prompt = `You are reviewing one semantic feature for clawpatch. + : ""; + const promptContext = { + maxOwnedFiles: config.review.maxOwnedFiles, + maxContextFiles: config.review.maxContextFiles, + maxPromptBytes: config.review.maxPromptBytes, + includedFiles: includedFiles.map( + ({ + path, + role, + bytes, + includedBytes, + includedStartLine, + includedEndLine, + includedLineRanges, + truncated, + }) => ({ + path, + role, + bytes, + includedBytes, + includedStartLine, + includedEndLine, + includedLineRanges, + truncated, + }), + ), + omittedFiles, + }; + const validEvidencePaths = [ + ...new Set(includedFiles.filter((file) => file.readable).map((file) => file.path)), + ]; + const languageGuidance = reviewLanguageGuidance(project); + const cudaBlock = + mode === "default" && featureIncludesCuda(feature) ? `\n${cudaGuidance()}\n` : ""; + const prompt = `You are reviewing one semantic feature for clawpatch. Return strict JSON only. No markdown fences. @@ -272,15 +266,36 @@ JSON shape: Files: ${fileBlocks.join("\n\n")}`; - const promptBytes = Buffer.byteLength(prompt, "utf8"); + return { prompt, promptContext }; + }; + let rendered = renderPrompt(); + while ( + Buffer.byteLength(rendered.prompt, "utf8") > config.review.maxPromptBytes && + includedFiles.length > 0 + ) { + const omitted = includedFiles.pop(); + fileBlocks.pop(); + if (omitted !== undefined) { + omittedFiles.push({ path: omitted.path, role: omitted.role, reason: "maxPromptBytes" }); + } + rendered = renderPrompt(); + } + const promptBytes = Buffer.byteLength(rendered.prompt, "utf8"); + if (promptBytes > config.review.maxPromptBytes) { + throw new ClawpatchError( + `review prompt metadata exceeds maxPromptBytes (${promptBytes} > ${config.review.maxPromptBytes})`, + 2, + "invalid-input", + ); + } return { - prompt, + prompt: rendered.prompt, manifest: { - ...promptContext, + ...rendered.promptContext, includedFiles, omittedFiles, promptBytes, - approximateTokens: Math.ceil(prompt.length / 4), + approximateTokens: Math.ceil(rendered.prompt.length / 4), }, }; } From 3a3fef2f86ac564c66c6247dec481dd125eff125 Mon Sep 17 00:00:00 2001 From: Cole Tebou Date: Mon, 13 Jul 2026 22:39:50 +0000 Subject: [PATCH 3/8] fix(mapper): retain agent entrypoint fallback Keep validated owned files usable when every provider-suggested entrypoint is rejected by repository path filtering. --- src/agent-mapper.ts | 7 ++++--- src/workflow.test.ts | 37 +++++++++++++++++++++++++++++++++++-- 2 files changed, 39 insertions(+), 5 deletions(-) diff --git a/src/agent-mapper.ts b/src/agent-mapper.ts index d79a0647..76b9cf80 100644 --- a/src/agent-mapper.ts +++ b/src/agent-mapper.ts @@ -248,6 +248,9 @@ async function toSeed( return null; } const firstEntry = feature.entrypoints[0] ?? null; + const entrypoints = feature.entrypoints + .filter((candidate) => allowedFiles.has(normalize(candidate.path))) + .map((candidate) => ({ ...candidate, path: normalize(candidate.path) })); const reason = feature.reason.trim(); return { title: feature.title, @@ -261,9 +264,7 @@ async function toSeed( symbol: firstEntry?.symbol ?? null, route: firstEntry?.route ?? null, command: firstEntry?.command ?? null, - entrypoints: feature.entrypoints - .filter((candidate) => allowedFiles.has(normalize(candidate.path))) - .map((candidate) => ({ ...candidate, path: normalize(candidate.path) })), + ...(entrypoints.length === 0 ? {} : { entrypoints }), ownedFiles, contextFiles, tests, diff --git a/src/workflow.test.ts b/src/workflow.test.ts index a37d02b6..587c26d7 100644 --- a/src/workflow.test.ts +++ b/src/workflow.test.ts @@ -84,13 +84,13 @@ async function sinceFixture(prefix: string): Promise { return root; } -function agentMapProvider(title: () => string): Provider { +function agentMapProvider(title: () => string, entrypointPath = "agent/worker.custom"): Provider { const feature = () => ({ title: title(), summary: "Provider grouped custom agent files.", kind: "library" as const, confidence: "medium" as const, - entrypoints: [{ path: "agent/worker.custom", symbol: null, route: null, command: null }], + entrypoints: [{ path: entrypointPath, symbol: null, route: null, command: null }], ownedFiles: [ { path: "agent/worker.custom", reason: "worker" }, { path: "agent/scheduler.custom", reason: "scheduler" }, @@ -2093,6 +2093,39 @@ describe("workflow", () => { expect(second.stale).toBe(0); }); + it("falls back to an owned file when all agent entrypoints are rejected", async () => { + const root = await fixtureRoot("clawpatch-agent-map-entrypoint-fallback-"); + await writeFixture(root, "agent/worker.custom", "worker source\n"); + await writeFixture(root, "agent/scheduler.custom", "scheduler source\n"); + const context = await makeContext(testOptions(root)); + await initCommand(context, {}); + const paths = statePaths(join(root, ".clawpatch")); + const project = await readProject(paths); + if (project === null) { + throw new Error("missing project"); + } + const heuristic = await mapFeatures(root, project, []); + const result = await mapWithSource(root, project, [], heuristic, { + source: "agent", + provider: agentMapProvider(() => "Agent worker group", "dist/agent/generated.custom"), + providerOptions: { + model: null, + reasoningEffort: null, + skipGitRepoCheck: false, + }, + }); + + expect(result.features).toHaveLength(1); + expect(result.features[0]?.entrypoints).toEqual([ + { + path: "agent/worker.custom", + symbol: null, + route: null, + command: null, + }, + ]); + }); + it("augments deterministic features when forced agent mapping returns partial coverage", async () => { const root = await fixtureRoot("clawpatch-agent-map-merge-"); await writeFixture( From ee76c5ca7cf5e0b0bce14d072f3c2c26670a1f8c Mon Sep 17 00:00:00 2001 From: Cole Tebou Date: Wed, 15 Jul 2026 23:22:10 -0400 Subject: [PATCH 4/8] fix(mapper): align validated entrypoint metadata --- src/agent-mapper.ts | 20 +++++----- src/mappers/shared.test.ts | 12 ++++++ src/mappers/shared.ts | 2 + src/workflow.test.ts | 77 ++++++++++++++++++++++++++++++++++++-- 4 files changed, 99 insertions(+), 12 deletions(-) diff --git a/src/agent-mapper.ts b/src/agent-mapper.ts index 76b9cf80..cc895eb3 100644 --- a/src/agent-mapper.ts +++ b/src/agent-mapper.ts @@ -7,7 +7,7 @@ import { pathExists } from "./fs.js"; import { runCommandArgs } from "./exec.js"; import { mapFeatureSeeds, MapResult } from "./mapper.js"; import { dedupeFeatureSeeds, stableFeatureJson } from "./mapper-reconciliation.js"; -import { FeatureSeed, SeedFileRef, SeedTestRef } from "./mappers/types.js"; +import { FeatureSeed, SeedEntrypoint, SeedFileRef, SeedTestRef } from "./mappers/types.js"; import { applyPathFilters, isSafeFile, @@ -240,17 +240,19 @@ async function toSeed( } const contextFiles = await validFileRefs(root, feature.contextFiles, allowedFiles, 80); const tests = await validTests(root, feature.tests, allowedFiles, 20); - const entrypoint = - (await validEntrypoint(root, feature.entrypoints[0]?.path, allowedFiles)) ?? - ownedFiles[0]?.path ?? - null; + const entrypoints = ( + await Promise.all( + feature.entrypoints.map(async (candidate) => { + const path = await validEntrypoint(root, candidate.path, allowedFiles); + return path === null ? null : { ...candidate, path }; + }), + ) + ).filter((candidate): candidate is SeedEntrypoint => candidate !== null); + const firstEntry = entrypoints[0] ?? null; + const entrypoint = firstEntry?.path ?? ownedFiles[0]?.path ?? null; if (entrypoint === null) { return null; } - const firstEntry = feature.entrypoints[0] ?? null; - const entrypoints = feature.entrypoints - .filter((candidate) => allowedFiles.has(normalize(candidate.path))) - .map((candidate) => ({ ...candidate, path: normalize(candidate.path) })); const reason = feature.reason.trim(); return { title: feature.title, diff --git a/src/mappers/shared.test.ts b/src/mappers/shared.test.ts index e5dd7b27..3cb7f549 100644 --- a/src/mappers/shared.test.ts +++ b/src/mappers/shared.test.ts @@ -10,6 +10,18 @@ describe("package semantics", () => { expect(packageKind("@scope/tool-cli packages/tool-cli")).toBe("cli-command"); expect(packageTrustBoundaries("@scope/tool-cli packages/tool-cli")).toContain("process-exec"); }); + + it("preserves semantic boundaries in camel-case names", () => { + expect(packageKind("DbClient")).toBe("service"); + expect(packageTrustBoundaries("DbClient")).toEqual( + expect.arrayContaining(["filesystem", "database"]), + ); + expect(packageKind("ToolCli")).toBe("cli-command"); + expect(packageTrustBoundaries("ToolCli")).toContain("process-exec"); + expect(packageTrustBoundaries("GitHubSync")).toEqual( + expect.arrayContaining(["network", "external-api", "serialization"]), + ); + }); }); describe("nearby test discovery", () => { diff --git a/src/mappers/shared.ts b/src/mappers/shared.ts index 0512278b..08328e5b 100644 --- a/src/mappers/shared.ts +++ b/src/mappers/shared.ts @@ -362,6 +362,8 @@ export function packageTrustBoundaries(name: string): TrustBoundary[] { function semanticNameTokens(name: string): Set { return new Set( name + .replace(/([a-z0-9])([A-Z])/gu, "$1 $2") + .replace(/([A-Z]+)([A-Z][a-z])/gu, "$1 $2") .toLowerCase() .split(/[^a-z0-9]+/u) .filter(Boolean), diff --git a/src/workflow.test.ts b/src/workflow.test.ts index 587c26d7..c04854e1 100644 --- a/src/workflow.test.ts +++ b/src/workflow.test.ts @@ -84,13 +84,25 @@ async function sinceFixture(prefix: string): Promise { return root; } -function agentMapProvider(title: () => string, entrypointPath = "agent/worker.custom"): Provider { +type AgentMapEntrypoint = { + path: string; + symbol: string | null; + route: string | null; + command: string | null; +}; + +function agentMapProvider( + title: () => string, + entrypoints: AgentMapEntrypoint[] = [ + { path: "agent/worker.custom", symbol: null, route: null, command: null }, + ], +): Provider { const feature = () => ({ title: title(), summary: "Provider grouped custom agent files.", kind: "library" as const, confidence: "medium" as const, - entrypoints: [{ path: entrypointPath, symbol: null, route: null, command: null }], + entrypoints, ownedFiles: [ { path: "agent/worker.custom", reason: "worker" }, { path: "agent/scheduler.custom", reason: "scheduler" }, @@ -2107,7 +2119,17 @@ describe("workflow", () => { const heuristic = await mapFeatures(root, project, []); const result = await mapWithSource(root, project, [], heuristic, { source: "agent", - provider: agentMapProvider(() => "Agent worker group", "dist/agent/generated.custom"), + provider: agentMapProvider( + () => "Agent worker group", + [ + { + path: "dist/agent/generated.custom", + symbol: "rejectedSymbol", + route: "/rejected", + command: "rejected-command", + }, + ], + ), providerOptions: { model: null, reasoningEffort: null, @@ -2126,6 +2148,55 @@ describe("workflow", () => { ]); }); + it("uses metadata from the first accepted agent entrypoint", async () => { + const root = await fixtureRoot("clawpatch-agent-map-accepted-entrypoint-"); + await writeFixture(root, "agent/worker.custom", "worker source\n"); + await writeFixture(root, "agent/scheduler.custom", "scheduler source\n"); + const context = await makeContext(testOptions(root)); + await initCommand(context, {}); + const paths = statePaths(join(root, ".clawpatch")); + const project = await readProject(paths); + if (project === null) { + throw new Error("missing project"); + } + const heuristic = await mapFeatures(root, project, []); + const result = await mapWithSource(root, project, [], heuristic, { + source: "agent", + provider: agentMapProvider( + () => "Agent worker group", + [ + { + path: "dist/agent/generated.custom", + symbol: "rejectedSymbol", + route: "/rejected", + command: "rejected-command", + }, + { + path: "agent/scheduler.custom", + symbol: "acceptedSymbol", + route: "/accepted", + command: "accepted-command", + }, + ], + ), + providerOptions: { + model: null, + reasoningEffort: null, + skipGitRepoCheck: false, + }, + }); + + expect(result.features).toHaveLength(1); + expect(result.features[0]?.entrypoints).toEqual([ + { + path: "agent/scheduler.custom", + symbol: "acceptedSymbol", + route: "/accepted", + command: "accepted-command", + }, + ]); + }); + it("augments deterministic features when forced agent mapping returns partial coverage", async () => { const root = await fixtureRoot("clawpatch-agent-map-merge-"); await writeFixture( From 43d3cfbb38374096db2344bcddeb704a67597941 Mon Sep 17 00:00:00 2001 From: Cole Tebou Date: Thu, 16 Jul 2026 00:06:03 -0400 Subject: [PATCH 5/8] fix(mapper): preserve semantic compound classification --- src/mapper.test.ts | 50 ++++++++++++++++++-------------------- src/mappers/shared.test.ts | 7 +++++- src/mappers/shared.ts | 23 ++++++++++++------ src/workflow.test.ts | 3 ++- 4 files changed, 47 insertions(+), 36 deletions(-) diff --git a/src/mapper.test.ts b/src/mapper.test.ts index 3074ab78..ea17276a 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -3099,7 +3099,6 @@ describe("mapFeatures", () => { const project = await detectProject(root); const result = await mapFeatures(root, project, []); - const titles = result.features.map((feature) => feature.title); const routes = result.features.flatMap((feature) => feature.entrypoints.flatMap((entrypoint) => entrypoint.route === null ? [] : [entrypoint.route], @@ -3175,31 +3174,30 @@ describe("mapFeatures", () => { ].map((title) => title.replace(/^(?:Express|Fastify|Hono) route /u, "")), ), ); - expect(titles).not.toContain("Express route GET /commented"); - expect(titles).not.toContain("Express route POST /string"); - expect(titles).not.toContain("Express route GET /regex-health"); - expect(titles).not.toContain("Express route GET /arrow-regex"); - expect(titles).not.toContain("Express route GET /returned-regex"); - expect(titles).not.toContain("Express route GET /other-router"); - expect(titles).not.toContain("Express route GET /commented-out-router"); - expect(titles).not.toContain("Express route GET /commented-type-router"); - expect(titles).not.toContain("Express route GET /exported-type-router"); - expect(titles).not.toContain("Express route GET /regex-import-router"); - expect(titles).not.toContain("Express route GET /jsx-import-router"); - expect(titles).not.toContain("Express route GET /custom-import-router"); - expect(titles).not.toContain("Express route GET /custom-router"); - expect(titles).not.toContain("Express route GET /custom-alias-router"); - expect(titles).not.toContain("Express route GET /cjs-not-router"); - expect(titles).not.toContain("Express route GET /assigned-not-router"); - expect(titles).not.toContain("Express route GET /dynamic/"); - expect(titles).not.toContain("Fastify route GET /dynamic/"); - expect(titles).not.toContain("Fastify route GET /not-plugin-app"); - expect(titles).not.toContain("Fastify route GET /not-plugin-app-typed"); - expect(titles).not.toContain("Fastify route GET /not-plugin-server"); - expect(titles).not.toContain("Fastify route GET /not-plugin-options"); - expect(titles).not.toContain("Fastify route GET /shadow-fastify-instance"); - expect(titles).not.toContain("Fastify route GET /concat-"); - expect(titles).not.toContain("Express route DELETE /reports"); + expect(routes).not.toContain("GET /commented"); + expect(routes).not.toContain("POST /string"); + expect(routes).not.toContain("GET /regex-health"); + expect(routes).not.toContain("GET /arrow-regex"); + expect(routes).not.toContain("GET /returned-regex"); + expect(routes).not.toContain("GET /other-router"); + expect(routes).not.toContain("GET /commented-out-router"); + expect(routes).not.toContain("GET /commented-type-router"); + expect(routes).not.toContain("GET /exported-type-router"); + expect(routes).not.toContain("GET /regex-import-router"); + expect(routes).not.toContain("GET /jsx-import-router"); + expect(routes).not.toContain("GET /custom-import-router"); + expect(routes).not.toContain("GET /custom-router"); + expect(routes).not.toContain("GET /custom-alias-router"); + expect(routes).not.toContain("GET /cjs-not-router"); + expect(routes).not.toContain("GET /assigned-not-router"); + expect(routes).not.toContain("GET /dynamic/"); + expect(routes).not.toContain("GET /not-plugin-app"); + expect(routes).not.toContain("GET /not-plugin-app-typed"); + expect(routes).not.toContain("GET /not-plugin-server"); + expect(routes).not.toContain("GET /not-plugin-options"); + expect(routes).not.toContain("GET /shadow-fastify-instance"); + expect(routes).not.toContain("GET /concat-"); + expect(routes).not.toContain("DELETE /reports"); expect(admin?.source).toBe("express-route"); expect( admin?.entrypoints.find((entrypoint) => entrypoint.route === "POST /admin/jobs"), diff --git a/src/mappers/shared.test.ts b/src/mappers/shared.test.ts index 3cb7f549..ffec6955 100644 --- a/src/mappers/shared.test.ts +++ b/src/mappers/shared.test.ts @@ -18,7 +18,12 @@ describe("package semantics", () => { ); expect(packageKind("ToolCli")).toBe("cli-command"); expect(packageTrustBoundaries("ToolCli")).toContain("process-exec"); - expect(packageTrustBoundaries("GitHubSync")).toEqual( + expect(packageKind("GitHubClient")).toBe("service"); + expect(packageTrustBoundaries("GitHubClient")).toEqual( + expect.arrayContaining(["network", "external-api", "serialization"]), + ); + expect(packageKind("OpenAIClient")).toBe("service"); + expect(packageTrustBoundaries("OpenAIClient")).toEqual( expect.arrayContaining(["network", "external-api", "serialization"]), ); }); diff --git a/src/mappers/shared.ts b/src/mappers/shared.ts index 08328e5b..07ea3f40 100644 --- a/src/mappers/shared.ts +++ b/src/mappers/shared.ts @@ -360,14 +360,21 @@ export function packageTrustBoundaries(name: string): TrustBoundary[] { } function semanticNameTokens(name: string): Set { - return new Set( - name - .replace(/([a-z0-9])([A-Z])/gu, "$1 $2") - .replace(/([A-Z]+)([A-Z][a-z])/gu, "$1 $2") - .toLowerCase() - .split(/[^a-z0-9]+/u) - .filter(Boolean), - ); + const parts = name + .replace(/([a-z0-9])([A-Z])/gu, "$1 $2") + .replace(/([A-Z]+)([A-Z][a-z])/gu, "$1 $2") + .toLowerCase() + .split(/[^a-z0-9]+/u) + .filter(Boolean); + const tokens = new Set(parts); + const knownCompounds = new Set(["github", "openai"]); + for (let index = 0; index < parts.length - 1; index += 1) { + const compound = `${parts[index]}${parts[index + 1]}`; + if (knownCompounds.has(compound)) { + tokens.add(compound); + } + } + return tokens; } export function normalize(path: string): string { diff --git a/src/workflow.test.ts b/src/workflow.test.ts index c04854e1..f2c7f8f4 100644 --- a/src/workflow.test.ts +++ b/src/workflow.test.ts @@ -2423,6 +2423,7 @@ describe("workflow", () => { scripts: { test: "vitest run" }, }), ); + await writeFixture(root, "package-lock.json", "{}\n"); await writeFixture( root, "packages/web/package.json", @@ -2431,7 +2432,7 @@ describe("workflow", () => { await mapCommand(context); const project = await readProject(statePaths(join(root, ".clawpatch"))); - expect(project?.detected.packageManagers[0]).toBe("npm"); + expect(project?.detected.packageManagers).toEqual(["npm"]); expect(project?.detected.commands.test).toBe("npm run test"); expect(project?.detected.frameworks).toEqual(expect.arrayContaining(["react", "express"])); }); From 93abe9dfa144a06c91ad52452fdc7c2047858c94 Mon Sep 17 00:00:00 2001 From: Cole Tebou Date: Thu, 16 Jul 2026 00:40:15 -0400 Subject: [PATCH 6/8] fix(mapper): reconcile specialized test ownership --- src/mapper.test.ts | 11 +++++++++++ src/mapper.ts | 37 ++++++++++++++++++++++++++++++++++--- 2 files changed, 45 insertions(+), 3 deletions(-) diff --git a/src/mapper.test.ts b/src/mapper.test.ts index ea17276a..32f6df2b 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -495,6 +495,7 @@ describe("mapFeatures", () => { await writeFixture(root, "apps/storefront/src/helper.ts", "export const helper = true;\n"); await writeFixture(root, "apps/worker/src/index.ts", "export const worker = true;\n"); await writeFixture(root, "apps/worker/src/index.test.ts", "test('worker', () => {});\n"); + await writeFixture(root, "apps/worker/src/page.test.tsx", "test('worker page', () => {});\n"); await writeFixture(root, "apps/api/server/index.ts", "export const api = true;\n"); await writeFixture(root, "apps/api/server/index.test.ts", "test('api', () => {});\n"); await writeFixture( @@ -534,6 +535,12 @@ describe("mapFeatures", () => { expect(storefrontSource?.ownedFiles).not.toContainEqual( expect.objectContaining({ path: "apps/storefront/src/app/checkout/page.tsx" }), ); + expect(storefrontSource?.tests).not.toContainEqual( + expect.objectContaining({ path: "apps/storefront/src/app/checkout/page.test.tsx" }), + ); + expect(storefrontSource?.contextFiles).not.toContainEqual( + expect.objectContaining({ path: "apps/storefront/src/app/checkout/page.test.tsx" }), + ); expect(worker?.ownedFiles).toContainEqual({ path: "apps/worker/src/index.ts", reason: "source group apps/worker/src", @@ -545,6 +552,10 @@ describe("mapFeatures", () => { path: "apps/worker/src/index.test.ts", command: null, }); + expect(worker?.tests).toContainEqual({ + path: "apps/worker/src/page.test.tsx", + command: null, + }); expect(api?.ownedFiles).toContainEqual({ path: "apps/api/server/index.ts", reason: "source group apps/api/server", diff --git a/src/mapper.ts b/src/mapper.ts index 53afc73c..33366b86 100644 --- a/src/mapper.ts +++ b/src/mapper.ts @@ -1,3 +1,4 @@ +import { basename, dirname } from "node:path"; import { nowIso } from "./fs.js"; import { stableId } from "./id.js"; import { @@ -320,12 +321,20 @@ async function collectSeeds( } function resolveGenericOwnership(seeds: FeatureSeed[]): FeatureSeed[] { + const specializedSeeds = seeds.filter((seed) => seed.source !== "node-source-group"); const specializedOwnedFiles = new Set( - seeds - .filter((seed) => seed.source !== "node-source-group") + specializedSeeds .flatMap((seed) => seed.ownedFiles ?? [{ path: seed.entryPath, reason: "entrypoint" }]) .map((ref) => ref.path), ); + const specializedTestFiles = new Set( + specializedSeeds.flatMap((seed) => seed.tests ?? []).map((test) => test.path), + ); + const specializedDiscoverableTestKeys = new Set( + specializedSeeds + .filter((seed) => seed.skipNearbyTests !== true) + .map((seed) => ownedFileTestKey(seed.entryPath)), + ); return seeds.flatMap((seed) => { if (seed.source !== "node-source-group" || seed.ownedFiles === undefined) { return [seed]; @@ -337,10 +346,32 @@ function resolveGenericOwnership(seeds: FeatureSeed[]): FeatureSeed[] { const entryPath = specializedOwnedFiles.has(seed.entryPath) ? (ownedFiles[0]?.path ?? seed.entryPath) : seed.entryPath; - return [{ ...seed, entryPath, ownedFiles }]; + const isSpecializedTest = (path: string) => + specializedTestFiles.has(path) || specializedDiscoverableTestKeys.has(testFileKey(path)); + const tests = seed.tests?.filter((test) => !isSpecializedTest(test.path)); + const contextFiles = seed.contextFiles?.filter( + (ref) => !specializedOwnedFiles.has(ref.path) && !isSpecializedTest(ref.path), + ); + return [ + { + ...seed, + entryPath, + ownedFiles, + ...(tests === undefined ? {} : { tests }), + ...(contextFiles === undefined ? {} : { contextFiles }), + }, + ]; }); } +function ownedFileTestKey(path: string): string { + return `${dirname(path)}\0${basename(path).replace(/\.[^.]+$/u, "")}`; +} + +function testFileKey(path: string): string { + return `${dirname(path)}\0${basename(path).replace(/\.(?:test|spec)\.[^.]+$/u, "")}`; +} + async function shouldRunNodeMappers(root: string, project: ProjectRecord): Promise { if ( project.detected.languages.some((language) => From d82200892dd12f30cfc37f53272b294ac40e1f8c Mon Sep 17 00:00:00 2001 From: Cole Tebou Date: Thu, 16 Jul 2026 00:48:49 -0400 Subject: [PATCH 7/8] fix(mapper): recognize camel-case CLI filenames --- src/mapper.test.ts | 8 ++++++++ src/mappers/node.ts | 12 +++++------- src/mappers/shared.ts | 2 +- 3 files changed, 14 insertions(+), 8 deletions(-) diff --git a/src/mapper.test.ts b/src/mapper.test.ts index 32f6df2b..47cd17b3 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -2235,6 +2235,7 @@ describe("mapFeatures", () => { "scripts/validate-client-build-order.ts", "export const valid = true;\n", ); + await writeFixture(root, "scripts/runCli.ts", "export const run = true;\n"); for (let index = 0; index < 12; index += 1) { await writeFixture(root, `scripts/helper-${index}.ts`, `export const value = ${index};\n`); } @@ -2244,8 +2245,15 @@ describe("mapFeatures", () => { const feature = result.features.find((candidate) => candidate.ownedFiles.some((file) => file.path === "scripts/validate-client-build-order.ts"), ); + const cli = result.features.find((candidate) => + candidate.ownedFiles.some((file) => file.path === "scripts/runCli.ts"), + ); expect(feature).toMatchObject({ kind: "library", trustBoundaries: [] }); + expect(cli).toMatchObject({ + kind: "cli-command", + trustBoundaries: expect.arrayContaining(["user-input", "process-exec"]), + }); }); it("maps workspace package metadata, entries, tests, and docs as package context", async () => { diff --git a/src/mappers/node.ts b/src/mappers/node.ts index f8f14c3d..c017feb2 100644 --- a/src/mappers/node.ts +++ b/src/mappers/node.ts @@ -9,6 +9,7 @@ import { packageKind, packageTrustBoundaries, pathMatchesPrefix, + semanticNameTokens, walk, } from "./shared.js"; import { @@ -647,14 +648,11 @@ function chunkSemanticGroup( } function semanticSegmentForFile(path: string): string | null { - const basenameWithoutExtension = basename(path) - .replace(/\.[^.]+$/u, "") - .toLowerCase(); - const tokens = new Set( - basenameWithoutExtension.split(/[^a-z0-9]+/u).filter((token) => token.length > 0), - ); + const basenameWithoutExtension = basename(path).replace(/\.[^.]+$/u, ""); + const tokens = semanticNameTokens(basenameWithoutExtension); + const normalizedBasename = basenameWithoutExtension.toLowerCase(); for (const segment of semanticSourceSegments) { - if (tokens.has(segment) || (segment !== "cli" && basenameWithoutExtension.includes(segment))) { + if (tokens.has(segment) || (segment !== "cli" && normalizedBasename.includes(segment))) { return segment === "command" ? "commands" : segment; } } diff --git a/src/mappers/shared.ts b/src/mappers/shared.ts index 07ea3f40..a34b361a 100644 --- a/src/mappers/shared.ts +++ b/src/mappers/shared.ts @@ -359,7 +359,7 @@ export function packageTrustBoundaries(name: string): TrustBoundary[] { return boundaries; } -function semanticNameTokens(name: string): Set { +export function semanticNameTokens(name: string): Set { const parts = name .replace(/([a-z0-9])([A-Z])/gu, "$1 $2") .replace(/([A-Z]+)([A-Z][a-z])/gu, "$1 $2") From e6643f7f1cc063b4f3c0a507cca436b75247877c Mon Sep 17 00:00:00 2001 From: Cole Tebou Date: Thu, 16 Jul 2026 00:56:11 -0400 Subject: [PATCH 8/8] test(mapper): assert mounted route exclusions --- src/mapper.test.ts | 50 +++++++++++++++++++++------------------------- 1 file changed, 23 insertions(+), 27 deletions(-) diff --git a/src/mapper.test.ts b/src/mapper.test.ts index 47cd17b3..6bf11fb6 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -3484,7 +3484,6 @@ describe("mapFeatures", () => { const project = await detectProject(root); const result = await mapFeatures(root, project, []); - const titles = result.features.map((feature) => feature.title); const routes = result.features.flatMap((feature) => feature.entrypoints.flatMap((entrypoint) => entrypoint.route === null ? [] : [entrypoint.route], @@ -3516,32 +3515,29 @@ describe("mapFeatures", () => { ].map((title) => title.replace(/^(?:Express|Hono) route /u, "")), ), ); - expect(titles).not.toContain("Express route GET /users"); - expect(titles).not.toContain("Express route GET /reports"); - expect(titles).not.toContain("Express route POST /v1/teams"); - expect(titles).not.toContain("Express route DELETE /sessions/:id"); - expect(titles).not.toContain("Express route GET /false/false-users"); - expect(titles).not.toContain("Express route GET /generic-middleware-users"); - expect(titles).not.toContain("Express route GET /async-middleware-users"); - expect(titles).not.toContain("Express route GET /json-users"); - expect(titles).not.toContain("Express route GET /imported-users"); - expect(titles).not.toContain("Express route GET /pathless-users"); - expect(titles).not.toContain("Express route GET /direct-pathless-users"); - expect(titles).not.toContain("Express route GET /first-pathless-users"); - expect(titles).not.toContain("Express route GET /second-pathless-users"); - expect(titles).not.toContain("Express route GET /array-users"); - expect(titles).not.toContain("Express route GET /wildcard-users"); - expect(titles).not.toContain("Express route GET /dynamic-users"); - expect(titles).not.toContain("Express route GET /dynamic-child-users"); - expect(titles).not.toContain("Express route GET /auth-path-users"); - expect(titles).not.toContain("Express route GET /dynamic-mw-users"); - expect(titles).not.toContain("Express route GET /tenant-users"); - expect(titles).not.toContain("Express route GET /member/member-users"); - expect(titles).not.toContain("Express route GET /v1/dynamic-child-users"); - expect(titles).not.toContain("Hono route GET /users"); - expect(titles).not.toContain("Hono route GET /dynamic-users"); - expect(titles).not.toContain("Hono route DELETE /v1/sessions/:id"); - expect(titles).not.toContain("Hono route GET /false/false-users"); + expect(routes).not.toContain("GET /users"); + expect(routes).not.toContain("GET /reports"); + expect(routes).not.toContain("POST /v1/teams"); + expect(routes).not.toContain("DELETE /sessions/:id"); + expect(routes).not.toContain("GET /false/false-users"); + expect(routes).not.toContain("GET /generic-middleware-users"); + expect(routes).not.toContain("GET /async-middleware-users"); + expect(routes).not.toContain("GET /json-users"); + expect(routes).not.toContain("GET /imported-users"); + expect(routes).not.toContain("GET /pathless-users"); + expect(routes).not.toContain("GET /direct-pathless-users"); + expect(routes).not.toContain("GET /first-pathless-users"); + expect(routes).not.toContain("GET /second-pathless-users"); + expect(routes).not.toContain("GET /array-users"); + expect(routes).not.toContain("GET /wildcard-users"); + expect(routes).not.toContain("GET /dynamic-users"); + expect(routes).not.toContain("GET /dynamic-child-users"); + expect(routes).not.toContain("GET /auth-path-users"); + expect(routes).not.toContain("GET /dynamic-mw-users"); + expect(routes).not.toContain("GET /tenant-users"); + expect(routes).not.toContain("GET /member/member-users"); + expect(routes).not.toContain("GET /v1/dynamic-child-users"); + expect(routes).not.toContain("DELETE /v1/sessions/:id"); }); it("keeps index route tests scoped to their route directory", async () => {