From e08864989c80df7e3ceb9d10ecc2a8ced0b156a9 Mon Sep 17 00:00:00 2001 From: Tanmay-008 Date: Sun, 12 Jul 2026 12:14:42 +0530 Subject: [PATCH 1/4] perf(mapper): lazily evaluate Node.js project discovery in MapperContext Converts eager and on to lazy, memoized async getters. This avoids executing costly disk I/O for Node.js project discovery on non-Node repositories (like pure Go/Python/Rust), removing startup blockage for other mappers while maintaining shared concurrency for Node-ecosystem mappers. --- src/mapper.test.ts | 130 ++++++++++++++++++++----------------- src/mapper.ts | 17 ++++- src/mappers/next.ts | 8 ++- src/mappers/node-routes.ts | 18 +++-- src/mappers/node.ts | 8 ++- src/mappers/react.ts | 2 +- src/mappers/types.ts | 4 +- 7 files changed, 110 insertions(+), 77 deletions(-) diff --git a/src/mapper.test.ts b/src/mapper.test.ts index cc203dd2..7f0356bc 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -1,8 +1,9 @@ import { mkdir, symlink } from "node:fs/promises"; import { basename, join } from "node:path"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { detectProject } from "./detect.js"; import { mapFeatures } from "./mapper.js"; +import * as projectsModule from "./mappers/projects.js"; import { discoverNodeProjects, scriptCommand } from "./mappers/projects.js"; import { turboTaskGraph } from "./mappers/turbo.js"; import { fixtureRoot, writeFixture } from "./test-helpers.js"; @@ -12923,19 +12924,19 @@ exclude = ["packages/legacy"] root, "routes/web.php", "middleware('auth')->get('/users', UserController::class);\n", + "use App\\Http\\Controllers\\Admin\\{UserController};\n" + + "Route::prefix('admin')->middleware('auth')->get('/users', UserController::class);\n", ); await writeFixture( root, "routes/api.php", "get('/dashboard', DashboardController::class);\n", + "use App\\Http\\Controllers\\DashboardController;\n" + + "Route::prefix('{tenant}')->get('/dashboard', DashboardController::class);\n", ); await writeFixture( root, @@ -13315,10 +13316,10 @@ exclude = ["packages/legacy"] root, "routes/web.php", " "admin"], function () {\n' + - ' Route::get("/users", UserController::class);\n' + - "});\n", + "use App\\Http\\Controllers\\UserController;\n" + + 'Route::group(["prefix" => "admin"], function () {\n' + + ' Route::get("/users", UserController::class);\n' + + "});\n", ); await writeFixture( root, @@ -13361,12 +13362,12 @@ exclude = ["packages/legacy"] root, "routes/web.php", " 'admin'], function () {\n" + - " Route::controller(UserController::class)->group(function () {\n" + - " Route::get('/users', 'index');\n" + - " });\n" + - "});\n", + "use App\\Http\\Controllers\\UserController;\n" + + "Route::group(['prefix' => 'admin'], function () {\n" + + " Route::controller(UserController::class)->group(function () {\n" + + " Route::get('/users', 'index');\n" + + " });\n" + + "});\n", ); await writeFixture( root, @@ -13405,12 +13406,12 @@ exclude = ["packages/legacy"] root, "routes/web.php", " 'auth'], function () {\n" + - " Route::group(['prefix' => 'admin'], function () {\n" + - " Route::get('/users', UserController::class);\n" + - " });\n" + - "});\n", + "use App\\Http\\Controllers\\UserController;\n" + + "Route::group(['middleware' => 'auth'], function () {\n" + + " Route::group(['prefix' => 'admin'], function () {\n" + + " Route::get('/users', UserController::class);\n" + + " });\n" + + "});\n", ); await writeFixture( root, @@ -13449,11 +13450,11 @@ exclude = ["packages/legacy"] root, "routes/web.php", "controller(UserController::class)->group(function () {\n" + - " Route::get('/users', 'index');\n" + - " Route::post('/users', 'store');\n" + - "});\n", + "use App\\Http\\Controllers\\UserController;\n" + + "Route::prefix('admin')->controller(UserController::class)->group(function () {\n" + + " Route::get('/users', 'index');\n" + + " Route::post('/users', 'store');\n" + + "});\n", ); await writeFixture( root, @@ -13502,9 +13503,9 @@ exclude = ["packages/legacy"] root, "routes/web.php", " { + const root = await fixtureRoot("clawpatch-map-node-coupling-"); + await writeFixture(root, "go.mod", "module example.com/go-app\n"); + await writeFixture(root, "main.go", "package main\nfunc main() {}\n"); + + const spy = vi.spyOn(projectsModule, "discoverNodeProjects"); + + const project = await detectProject(root); + await mapFeatures(root, project, []); + + expect(spy).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/mapper.ts b/src/mapper.ts index 0e1e946f..073c24af 100644 --- a/src/mapper.ts +++ b/src/mapper.ts @@ -286,10 +286,21 @@ function uniqueTests(tests: Array<{ path: string; command: string | null }>): Ar } async function collectSeeds(root: string, options: MapOptions): Promise { - const projects = await discoverNodeProjects(root); + let projectsPromise: ReturnType | null = null; + let taskGraphPromise: ReturnType | null = null; const context: MapperContext = { - projects, - taskGraph: await turboTaskGraph(root, projects), + projects() { + if (projectsPromise === null) { + projectsPromise = discoverNodeProjects(root); + } + return projectsPromise; + }, + taskGraph() { + if (taskGraphPromise === null) { + taskGraphPromise = this.projects().then((projects) => turboTaskGraph(root, projects)); + } + return taskGraphPromise; + }, }; const groups = await Promise.all( featureMappers.map(async (mapper) => { diff --git a/src/mappers/next.ts b/src/mappers/next.ts index 100a51a6..7e424ae2 100644 --- a/src/mappers/next.ts +++ b/src/mappers/next.ts @@ -13,11 +13,13 @@ import type { WorkspaceTaskGraph } from "./task-graph.js"; import { FeatureSeed, MapperContext, suppressedTestCommandTag } from "./types.js"; export async function nextSeeds(root: string, context: MapperContext): Promise { - const rootProject = context.projects.find((project) => project.root === "."); + const projects = await context.projects(); + const taskGraph = await context.taskGraph(); + const rootProject = projects.find((project) => project.root === "."); const rootHasNext = rootProject === undefined ? false : hasNextDependency(rootProject); const seedGroups = await Promise.all( - context.projects.map(async (project) => - projectNextSeeds(root, project, context.taskGraph, rootHasNext), + projects.map(async (project) => + projectNextSeeds(root, project, taskGraph, rootHasNext), ), ); return seedGroups.flat(); diff --git a/src/mappers/node-routes.ts b/src/mappers/node-routes.ts index 13d925db..6ee1ede3 100644 --- a/src/mappers/node-routes.ts +++ b/src/mappers/node-routes.ts @@ -16,6 +16,7 @@ import { suppressedTestCommandTag, } from "./types.js"; import type { NodeProjectInfo } from "./projects.js"; +import { WorkspaceTaskGraph } from "./task-graph.js"; type ServerFramework = "express" | "fastify" | "hono"; @@ -80,18 +81,20 @@ const routeChainPattern = /(^|[^A-Za-z0-9_$])([A-Za-z_$][A-Za-z0-9_$]*(?:\.[A-Za-z_$][A-Za-z0-9_$]*)*)\s*\.\s*route\s*\(/gu; export async function nodeRouteSeeds(root: string, context: MapperContext): Promise { + const projects = await context.projects(); + const taskGraph = await context.taskGraph(); const seeds: FeatureSeed[] = []; const rootFrameworks = serverFrameworks( - context.projects.find((project) => project.root === ".") ?? null, + projects.find((project) => project.root === ".") ?? null, ); - for (const project of context.projects) { + for (const project of projects) { const frameworks = serverFrameworks(project); const effectiveFrameworks = frameworks.length > 0 ? frameworks : project.packageJson === null ? rootFrameworks : []; if (effectiveFrameworks.length === 0) { continue; } - seeds.push(...(await projectRouteSeeds(root, project, context, effectiveFrameworks))); + seeds.push(...(await projectRouteSeeds(root, project, projects, taskGraph, effectiveFrameworks))); } return seeds; } @@ -108,12 +111,13 @@ function serverFrameworks(project: NodeProjectInfo | null): ServerFramework[] { async function projectRouteSeeds( root: string, project: NodeProjectInfo, - context: MapperContext, + projects: NodeProjectInfo[], + taskGraph: WorkspaceTaskGraph, frameworks: ServerFramework[], ): Promise { - const files = await packageSourceFiles(root, project, context.projects); - const tests = await packageTestFiles(root, project, context.projects); - const testCommand = projectTargetCommand(project, "test", context.taskGraph); + const files = await packageSourceFiles(root, project, projects); + const tests = await packageTestFiles(root, project, projects); + const testCommand = projectTargetCommand(project, "test", taskGraph); const projectContext = await projectContextFiles(root, project); const seeds: FeatureSeed[] = []; diff --git a/src/mappers/node.ts b/src/mappers/node.ts index 1e893c29..474aa454 100644 --- a/src/mappers/node.ts +++ b/src/mappers/node.ts @@ -67,11 +67,13 @@ const semanticSourceSegments = [ export async function nodeSeeds(root: string, context: MapperContext): Promise { const seeds: FeatureSeed[] = []; - for (const info of context.projects) { + const projects = await context.projects(); + const taskGraph = await context.taskGraph(); + for (const info of projects) { if (hasNodePackage(info)) { - seeds.push(...(await packageSeeds(root, info, context.taskGraph))); + seeds.push(...(await packageSeeds(root, info, taskGraph))); } - seeds.push(...(await sourceGroupSeeds(root, info, context.taskGraph))); + seeds.push(...(await sourceGroupSeeds(root, info, taskGraph))); } return seeds; diff --git a/src/mappers/react.ts b/src/mappers/react.ts index 6207ac84..62c0b906 100644 --- a/src/mappers/react.ts +++ b/src/mappers/react.ts @@ -82,7 +82,7 @@ const contextImportExtensions = new Set([ ]); export async function reactSeeds(root: string, context: MapperContext): Promise { - const packages = await discoverReactPackages(root, context.projects, context.taskGraph); + const packages = await discoverReactPackages(root, await context.projects(), await context.taskGraph()); const importResolver = createReactImportResolver(root); const seeds: FeatureSeed[] = []; for (const info of packages) { diff --git a/src/mappers/types.ts b/src/mappers/types.ts index 364da6f6..b3b39d12 100644 --- a/src/mappers/types.ts +++ b/src/mappers/types.ts @@ -41,6 +41,6 @@ export type FeatureMapper = { }; export type MapperContext = { - projects: NodeProjectInfo[]; - taskGraph: WorkspaceTaskGraph; + projects(): Promise; + taskGraph(): Promise; }; From abffe02e3eb749154d2bfb18573c5de5c6337fbb Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 12 Jul 2026 06:25:56 -0700 Subject: [PATCH 2/4] perf: skip unused Node mapper context Keep contributor's lazy-context direction while gating Node-family mappers behind bounded project signals and preserving package-less workspace discovery.\n\nCo-authored-by: Tanmay-008 --- CHANGELOG.md | 1 + src/mapper.test.ts | 200 ++++++++++++++++++++++++------------ src/mapper.ts | 59 ++++++----- src/mappers/context.test.ts | 77 ++++++++++++++ src/mappers/context.ts | 22 ++++ src/mappers/next.ts | 8 +- src/mappers/node-routes.ts | 14 +-- src/mappers/node.ts | 4 +- src/mappers/projects.ts | 46 +++++++++ src/mappers/react.ts | 6 +- src/mappers/types.ts | 5 +- 11 files changed, 338 insertions(+), 104 deletions(-) create mode 100644 src/mappers/context.test.ts create mode 100644 src/mappers/context.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 6fa9a87a..0b3a80ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## 0.7.1 - Unreleased +- Improved feature mapping so pure non-Node repositories skip Node workspace and Turbo discovery while Node consumers share one lazy context, thanks @Tanmay-008. - Fixed revalidation prompts to compact historical and feature metadata and hard-cap metadata lists even when configured file limits are high, preventing provider input overflows, thanks @pai-scaffolde. - Added an opt-in Claude host auth context that preserves the default-deny environment, uses Claude Code safe mode, validates auth through doctor, and reports redacted OAuth failure signals, thanks @grantjayy. diff --git a/src/mapper.test.ts b/src/mapper.test.ts index 7f0356bc..c80219f1 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -1,15 +1,20 @@ import { mkdir, symlink } from "node:fs/promises"; import { basename, join } from "node:path"; -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { detectProject } from "./detect.js"; import { mapFeatures } from "./mapper.js"; import * as projectsModule from "./mappers/projects.js"; import { discoverNodeProjects, scriptCommand } from "./mappers/projects.js"; +import * as turboModule from "./mappers/turbo.js"; import { turboTaskGraph } from "./mappers/turbo.js"; import { fixtureRoot, writeFixture } from "./test-helpers.js"; const symlinkIt = process.platform === "win32" ? it.skip : it; +afterEach(() => { + vi.restoreAllMocks(); +}); + describe("mapFeatures", () => { it("quotes dynamic Node validation command parts", () => { expect(scriptCommand("pnpm", "packages/app; touch INJECTED", "test")).toBe( @@ -1121,6 +1126,8 @@ describe("mapFeatures", () => { ); await writeFixture(root, "apps/web/src/pages/HomePage.test.tsx", "test('home', () => {});\n"); + const projectsSpy = vi.spyOn(projectsModule, "discoverNodeProjects"); + const taskGraphSpy = vi.spyOn(turboModule, "turboTaskGraph"); const project = await detectProject(root); const result = await mapFeatures(root, project, []); const route = result.features.find((feature) => feature.title === "React route /home"); @@ -1128,6 +1135,8 @@ describe("mapFeatures", () => { expect(route?.tests).toEqual([ { path: "apps/web/src/pages/HomePage.test.tsx", command: "pnpm turbo run test --filter web" }, ]); + expect(projectsSpy).toHaveBeenCalledTimes(1); + expect(taskGraphSpy).toHaveBeenCalledTimes(1); }); it("suppresses fallback validation commands for persistent Turbo tasks", async () => { @@ -12924,19 +12933,19 @@ exclude = ["packages/legacy"] root, "routes/web.php", "middleware('auth')->get('/users', UserController::class);\n", + "use App\\Http\\Controllers\\Admin\\{UserController};\n" + + "Route::prefix('admin')->middleware('auth')->get('/users', UserController::class);\n", ); await writeFixture( root, "routes/api.php", "get('/dashboard', DashboardController::class);\n", + "use App\\Http\\Controllers\\DashboardController;\n" + + "Route::prefix('{tenant}')->get('/dashboard', DashboardController::class);\n", ); await writeFixture( root, @@ -13316,10 +13325,10 @@ exclude = ["packages/legacy"] root, "routes/web.php", " "admin"], function () {\n' + - ' Route::get("/users", UserController::class);\n' + - "});\n", + "use App\\Http\\Controllers\\UserController;\n" + + 'Route::group(["prefix" => "admin"], function () {\n' + + ' Route::get("/users", UserController::class);\n' + + "});\n", ); await writeFixture( root, @@ -13362,12 +13371,12 @@ exclude = ["packages/legacy"] root, "routes/web.php", " 'admin'], function () {\n" + - " Route::controller(UserController::class)->group(function () {\n" + - " Route::get('/users', 'index');\n" + - " });\n" + - "});\n", + "use App\\Http\\Controllers\\UserController;\n" + + "Route::group(['prefix' => 'admin'], function () {\n" + + " Route::controller(UserController::class)->group(function () {\n" + + " Route::get('/users', 'index');\n" + + " });\n" + + "});\n", ); await writeFixture( root, @@ -13406,12 +13415,12 @@ exclude = ["packages/legacy"] root, "routes/web.php", " 'auth'], function () {\n" + - " Route::group(['prefix' => 'admin'], function () {\n" + - " Route::get('/users', UserController::class);\n" + - " });\n" + - "});\n", + "use App\\Http\\Controllers\\UserController;\n" + + "Route::group(['middleware' => 'auth'], function () {\n" + + " Route::group(['prefix' => 'admin'], function () {\n" + + " Route::get('/users', UserController::class);\n" + + " });\n" + + "});\n", ); await writeFixture( root, @@ -13450,11 +13459,11 @@ exclude = ["packages/legacy"] root, "routes/web.php", "controller(UserController::class)->group(function () {\n" + - " Route::get('/users', 'index');\n" + - " Route::post('/users', 'store');\n" + - "});\n", + "use App\\Http\\Controllers\\UserController;\n" + + "Route::prefix('admin')->controller(UserController::class)->group(function () {\n" + + " Route::get('/users', 'index');\n" + + " Route::post('/users', 'store');\n" + + "});\n", ); await writeFixture( root, @@ -13503,9 +13512,9 @@ exclude = ["packages/legacy"] root, "routes/web.php", " { - const root = await fixtureRoot("clawpatch-map-node-coupling-"); + it.each([ + { + name: "Go", + manifest: ["go.mod", "module example.com/go-app\n"], + source: ["main.go", "package main\nfunc main() {}\n"], + }, + { + name: "Python", + manifest: ["pyproject.toml", "[project]\nname = 'python-app'\nversion = '1.0.0'\n"], + source: ["src/app.py", "def main(): pass\n"], + }, + ] as const)( + "skips Node project and Turbo I/O for pure $name projects", + async ({ manifest, source }) => { + const root = await fixtureRoot("clawpatch-map-node-coupling-"); + await writeFixture(root, manifest[0], manifest[1]); + await writeFixture(root, source[0], source[1]); + const projectsSpy = vi.spyOn(projectsModule, "discoverNodeProjects"); + const taskGraphSpy = vi.spyOn(turboModule, "turboTaskGraph"); + + const project = await detectProject(root); + await mapFeatures(root, project, []); + + expect(projectsSpy).not.toHaveBeenCalled(); + expect(taskGraphSpy).not.toHaveBeenCalled(); + }, + ); + + it("does not block non-Node mappers on the fallback Node signal", async () => { + const root = await fixtureRoot("clawpatch-map-node-signal-"); await writeFixture(root, "go.mod", "module example.com/go-app\n"); await writeFixture(root, "main.go", "package main\nfunc main() {}\n"); + const signal = Promise.withResolvers(); + vi.spyOn(projectsModule, "hasFallbackNodeProjectSignal").mockReturnValue(signal.promise); + const projectsSpy = vi.spyOn(projectsModule, "discoverNodeProjects"); + const goDone = Promise.withResolvers(); + const project = await detectProject(root); + + const mapping = mapFeatures(root, project, [], { + onProgress(event) { + if (event.event === "mapper-done" && event.mapper === "go") { + goDone.resolve(); + } + }, + }); + await goDone.promise; + + expect(projectsSpy).not.toHaveBeenCalled(); + signal.resolve(false); + await expect(mapping).resolves.toBeDefined(); + }); - const spy = vi.spyOn(projectsModule, "discoverNodeProjects"); + it("preserves package-less Node projects under conventional roots", async () => { + const root = await fixtureRoot("clawpatch-map-package-less-node-"); + await writeFixture(root, "apps/web/src/index.ts", "export function start() {}\n"); const project = await detectProject(root); - await mapFeatures(root, project, []); + const result = await mapFeatures(root, project, []); + + expect(project.detected.languages).not.toContain("typescript"); + expect(result.features.map((feature) => feature.title)).toContain("Node source apps/web/src"); + }); + + it("propagates Turbo failures and retries with a fresh mapping context", async () => { + const root = await fixtureRoot("clawpatch-map-turbo-failure-"); + await writeFixture(root, "package.json", JSON.stringify({ name: "node-app" })); + await writeFixture(root, "src/index.ts", "export const value = true;\n"); + await writeFixture(root, "turbo.json", "not-json\n"); + const taskGraphSpy = vi.spyOn(turboModule, "turboTaskGraph"); + const project = await detectProject(root); + + await expect(mapFeatures(root, project, [])).rejects.toThrow(); + await writeFixture(root, "turbo.json", JSON.stringify({ tasks: {} })); + await expect(mapFeatures(root, project, [])).resolves.toBeDefined(); - expect(spy).toHaveBeenCalledTimes(1); + expect(taskGraphSpy).toHaveBeenCalledTimes(2); }); }); diff --git a/src/mapper.ts b/src/mapper.ts index 073c24af..dcaf8fb7 100644 --- a/src/mapper.ts +++ b/src/mapper.ts @@ -19,7 +19,8 @@ import { nodeRouteSeeds } from "./mappers/node-routes.js"; import { nodeSeeds } from "./mappers/node.js"; import { pythonSeeds } from "./mappers/python.js"; import { reactSeeds } from "./mappers/react.js"; -import { discoverNodeProjects } from "./mappers/projects.js"; +import { createMapperContext } from "./mappers/context.js"; +import { discoverNodeProjects, hasFallbackNodeProjectSignal } from "./mappers/projects.js"; import { rubySeeds } from "./mappers/ruby.js"; import { rustSeeds } from "./mappers/rust.js"; import { createNearbyTestFinder, PathFilters, pathMatchesFilters } from "./mappers/shared.js"; @@ -48,10 +49,10 @@ export type MapOptions = { }; const featureMappers: FeatureMapper[] = [ - { name: "node", map: nodeSeeds }, - { name: "next", map: nextSeeds }, - { name: "react", map: reactSeeds }, - { name: "node-routes", map: nodeRouteSeeds }, + { name: "node", usesNodeContext: true, map: nodeSeeds }, + { name: "next", usesNodeContext: true, map: nextSeeds }, + { name: "react", usesNodeContext: true, map: reactSeeds }, + { name: "node-routes", usesNodeContext: true, map: nodeRouteSeeds }, { name: "go", map: goSeeds }, { name: "python", map: pythonSeeds }, { name: "ruby", map: rubySeeds }, @@ -73,7 +74,7 @@ export async function mapFeatures( existing: FeatureRecord[], options: MapOptions = {}, ): Promise { - const seeds = await collectSeeds(root, options); + const seeds = await collectSeeds(root, project, options); return mapFeatureSeeds(root, project, existing, seeds, options); } @@ -285,28 +286,24 @@ function uniqueTests(tests: Array<{ path: string; command: string | null }>): Ar return output; } -async function collectSeeds(root: string, options: MapOptions): Promise { - let projectsPromise: ReturnType | null = null; - let taskGraphPromise: ReturnType | null = null; - const context: MapperContext = { - projects() { - if (projectsPromise === null) { - projectsPromise = discoverNodeProjects(root); - } - return projectsPromise; - }, - taskGraph() { - if (taskGraphPromise === null) { - taskGraphPromise = this.projects().then((projects) => turboTaskGraph(root, projects)); - } - return taskGraphPromise; - }, - }; +async function collectSeeds( + root: string, + project: ProjectRecord, + options: MapOptions, +): Promise { + const context: MapperContext = createMapperContext({ + discoverNodeProjects: () => discoverNodeProjects(root), + buildNodeTaskGraph: (projects) => turboTaskGraph(root, projects), + }); + const runNodeMappers = shouldRunNodeMappers(root, project); const groups = await Promise.all( featureMappers.map(async (mapper) => { const started = Date.now(); options.onProgress?.({ event: "mapper-start", mapper: mapper.name }); - const seeds = await mapper.map(root, context); + const seeds = + mapper.usesNodeContext === true && !(await runNodeMappers) + ? [] + : await mapper.map(root, context); options.onProgress?.({ event: "mapper-done", mapper: mapper.name, @@ -319,6 +316,20 @@ async function collectSeeds(root: string, options: MapOptions): Promise { + if ( + project.detected.languages.some((language) => + ["javascript", "typescript"].includes(language), + ) || + project.detected.packageManagers.some((manager) => + ["node", "npm", "pnpm", "yarn", "bun"].includes(manager), + ) + ) { + return true; + } + return hasFallbackNodeProjectSignal(root); +} + function statusForChangedFeature(status: FeatureRecord["status"]): FeatureRecord["status"] { if (["reviewed", "revalidated", "fixed", "skipped"].includes(status)) { return "pending"; diff --git a/src/mappers/context.test.ts b/src/mappers/context.test.ts new file mode 100644 index 00000000..9abaf158 --- /dev/null +++ b/src/mappers/context.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it, vi } from "vitest"; +import { createMapperContext } from "./context.js"; +import { emptyTaskGraph } from "./task-graph.js"; + +describe("createMapperContext", () => { + it("shares concurrent first access across all Node consumers", async () => { + const projects: [] = []; + const graph = emptyTaskGraph(); + const discoverNodeProjects = vi.fn(async () => projects); + const buildNodeTaskGraph = vi.fn(async () => graph); + const context = createMapperContext({ discoverNodeProjects, buildNodeTaskGraph }); + + const results = await Promise.all([ + context.nodeProjects(), + context.nodeProjects(), + context.nodeTaskGraph(), + context.nodeTaskGraph(), + ]); + + expect(results).toEqual([projects, projects, graph, graph]); + expect(discoverNodeProjects).toHaveBeenCalledTimes(1); + expect(buildNodeTaskGraph).toHaveBeenCalledTimes(1); + expect(buildNodeTaskGraph).toHaveBeenCalledWith(projects); + }); + + it("shares project discovery failures without starting the task graph", async () => { + const failure = new Error("project discovery failed"); + const discoverNodeProjects = vi.fn(async () => { + throw failure; + }); + const buildNodeTaskGraph = vi.fn(async () => emptyTaskGraph()); + const context = createMapperContext({ discoverNodeProjects, buildNodeTaskGraph }); + + const results = await Promise.allSettled([ + context.nodeProjects(), + context.nodeProjects(), + context.nodeTaskGraph(), + ]); + + expect(results).toEqual([ + { status: "rejected", reason: failure }, + { status: "rejected", reason: failure }, + { status: "rejected", reason: failure }, + ]); + expect(discoverNodeProjects).toHaveBeenCalledTimes(1); + expect(buildNodeTaskGraph).not.toHaveBeenCalled(); + }); + + it("shares task graph failures", async () => { + const failure = new Error("task graph failed"); + const discoverNodeProjects = vi.fn(async () => []); + const buildNodeTaskGraph = vi.fn(async () => { + throw failure; + }); + const context = createMapperContext({ discoverNodeProjects, buildNodeTaskGraph }); + + const results = await Promise.allSettled([context.nodeTaskGraph(), context.nodeTaskGraph()]); + + expect(results).toEqual([ + { status: "rejected", reason: failure }, + { status: "rejected", reason: failure }, + ]); + expect(discoverNodeProjects).toHaveBeenCalledTimes(1); + expect(buildNodeTaskGraph).toHaveBeenCalledTimes(1); + }); + + it("invalidates memoized data with each mapping context", async () => { + const discoverNodeProjects = vi.fn(async () => []); + const buildNodeTaskGraph = vi.fn(async () => emptyTaskGraph()); + + await createMapperContext({ discoverNodeProjects, buildNodeTaskGraph }).nodeTaskGraph(); + await createMapperContext({ discoverNodeProjects, buildNodeTaskGraph }).nodeTaskGraph(); + + expect(discoverNodeProjects).toHaveBeenCalledTimes(2); + expect(buildNodeTaskGraph).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/mappers/context.ts b/src/mappers/context.ts new file mode 100644 index 00000000..98d0364c --- /dev/null +++ b/src/mappers/context.ts @@ -0,0 +1,22 @@ +import type { NodeProjectInfo } from "./projects.js"; +import type { WorkspaceTaskGraph } from "./task-graph.js"; +import type { MapperContext } from "./types.js"; + +export type MapperContextLoaders = { + discoverNodeProjects(): Promise; + buildNodeTaskGraph(projects: NodeProjectInfo[]): Promise; +}; + +export function createMapperContext(loaders: MapperContextLoaders): MapperContext { + const nodeProjects = memoizeAsync(loaders.discoverNodeProjects); + const nodeTaskGraph = memoizeAsync(async () => loaders.buildNodeTaskGraph(await nodeProjects())); + return { nodeProjects, nodeTaskGraph }; +} + +function memoizeAsync(loader: () => Promise): () => Promise { + let promise: Promise | null = null; + return () => { + promise ??= Promise.resolve().then(loader); + return promise; + }; +} diff --git a/src/mappers/next.ts b/src/mappers/next.ts index 7e424ae2..600230fc 100644 --- a/src/mappers/next.ts +++ b/src/mappers/next.ts @@ -13,14 +13,12 @@ import type { WorkspaceTaskGraph } from "./task-graph.js"; import { FeatureSeed, MapperContext, suppressedTestCommandTag } from "./types.js"; export async function nextSeeds(root: string, context: MapperContext): Promise { - const projects = await context.projects(); - const taskGraph = await context.taskGraph(); + const projects = await context.nodeProjects(); + const taskGraph = await context.nodeTaskGraph(); const rootProject = projects.find((project) => project.root === "."); const rootHasNext = rootProject === undefined ? false : hasNextDependency(rootProject); const seedGroups = await Promise.all( - projects.map(async (project) => - projectNextSeeds(root, project, taskGraph, rootHasNext), - ), + projects.map(async (project) => projectNextSeeds(root, project, taskGraph, rootHasNext)), ); return seedGroups.flat(); } diff --git a/src/mappers/node-routes.ts b/src/mappers/node-routes.ts index 6ee1ede3..b99cc66b 100644 --- a/src/mappers/node-routes.ts +++ b/src/mappers/node-routes.ts @@ -16,7 +16,7 @@ import { suppressedTestCommandTag, } from "./types.js"; import type { NodeProjectInfo } from "./projects.js"; -import { WorkspaceTaskGraph } from "./task-graph.js"; +import type { WorkspaceTaskGraph } from "./task-graph.js"; type ServerFramework = "express" | "fastify" | "hono"; @@ -81,12 +81,10 @@ const routeChainPattern = /(^|[^A-Za-z0-9_$])([A-Za-z_$][A-Za-z0-9_$]*(?:\.[A-Za-z_$][A-Za-z0-9_$]*)*)\s*\.\s*route\s*\(/gu; export async function nodeRouteSeeds(root: string, context: MapperContext): Promise { - const projects = await context.projects(); - const taskGraph = await context.taskGraph(); + const projects = await context.nodeProjects(); + const taskGraph = await context.nodeTaskGraph(); const seeds: FeatureSeed[] = []; - const rootFrameworks = serverFrameworks( - projects.find((project) => project.root === ".") ?? null, - ); + const rootFrameworks = serverFrameworks(projects.find((project) => project.root === ".") ?? null); for (const project of projects) { const frameworks = serverFrameworks(project); const effectiveFrameworks = @@ -94,7 +92,9 @@ export async function nodeRouteSeeds(root: string, context: MapperContext): Prom if (effectiveFrameworks.length === 0) { continue; } - seeds.push(...(await projectRouteSeeds(root, project, projects, taskGraph, effectiveFrameworks))); + seeds.push( + ...(await projectRouteSeeds(root, project, projects, taskGraph, effectiveFrameworks)), + ); } return seeds; } diff --git a/src/mappers/node.ts b/src/mappers/node.ts index 474aa454..c0749a60 100644 --- a/src/mappers/node.ts +++ b/src/mappers/node.ts @@ -67,8 +67,8 @@ const semanticSourceSegments = [ export async function nodeSeeds(root: string, context: MapperContext): Promise { const seeds: FeatureSeed[] = []; - const projects = await context.projects(); - const taskGraph = await context.taskGraph(); + const projects = await context.nodeProjects(); + const taskGraph = await context.nodeTaskGraph(); for (const info of projects) { if (hasNodePackage(info)) { seeds.push(...(await packageSeeds(root, info, taskGraph))); diff --git a/src/mappers/projects.ts b/src/mappers/projects.ts index 80ef26a2..19f5ef27 100644 --- a/src/mappers/projects.ts +++ b/src/mappers/projects.ts @@ -126,6 +126,52 @@ export async function discoverNodeProjects(root: string): Promise left.root.localeCompare(right.root)); } +export async function hasFallbackNodeProjectSignal(root: string): Promise { + if (await pathExists(join(root, "nx.json"))) { + return true; + } + for (const prefix of ["apps", "packages", "frontend", "client", "web"]) { + if (await hasNestedPackageJson(root, prefix, 4)) { + return true; + } + } + const candidates = ["frontend", "client", "web", "ui"]; + for (const parent of ["apps", "packages", "extensions", "plugins"]) { + for (const entry of await safeDirectoryEntries(root, parent)) { + candidates.push(`${parent}/${entry}`); + } + } + for (const candidate of candidates) { + if ( + (await pathExists(join(root, candidate, "package.json"))) || + (await pathExists(join(root, candidate, "project.json"))) || + (await hasGenericProjectSignal(root, null, candidate)) + ) { + return true; + } + } + return false; +} + +async function hasNestedPackageJson( + root: string, + prefix: string, + remainingDepth: number, +): Promise { + if (remainingDepth < 0 || shouldSkipProjectDir(prefix)) { + return false; + } + if (await pathExists(join(root, prefix, "package.json"))) { + return true; + } + for (const entry of await safeDirectoryEntries(root, prefix)) { + if (await hasNestedPackageJson(root, `${prefix}/${entry}`, remainingDepth - 1)) { + return true; + } + } + return false; +} + async function discoverDeclaredPackageRoots( root: string, rootPackage: NodePackageJson | null, diff --git a/src/mappers/react.ts b/src/mappers/react.ts index 62c0b906..76ae93ac 100644 --- a/src/mappers/react.ts +++ b/src/mappers/react.ts @@ -82,7 +82,11 @@ const contextImportExtensions = new Set([ ]); export async function reactSeeds(root: string, context: MapperContext): Promise { - const packages = await discoverReactPackages(root, await context.projects(), await context.taskGraph()); + const packages = await discoverReactPackages( + root, + await context.nodeProjects(), + await context.nodeTaskGraph(), + ); const importResolver = createReactImportResolver(root); const seeds: FeatureSeed[] = []; for (const info of packages) { diff --git a/src/mappers/types.ts b/src/mappers/types.ts index b3b39d12..cdaf5785 100644 --- a/src/mappers/types.ts +++ b/src/mappers/types.ts @@ -37,10 +37,11 @@ export const suppressedTestCommandTag = "validation:test-suppressed"; export type FeatureMapper = { name: string; + usesNodeContext?: boolean; map(root: string, context: MapperContext): Promise; }; export type MapperContext = { - projects(): Promise; - taskGraph(): Promise; + nodeProjects(): Promise; + nodeTaskGraph(): Promise; }; From 7cecde2a11bb3e06832f4407ba6308c541ff1a47 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 12 Jul 2026 06:32:27 -0700 Subject: [PATCH 3/4] fix: preserve package-less Nx mapping Match the existing bounded project.json discovery scope when deciding whether Node-family mappers are needed. --- src/mapper.test.ts | 16 ++++++++++++++++ src/mappers/projects.ts | 22 +++++++++++++++++++++- 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/src/mapper.test.ts b/src/mapper.test.ts index c80219f1..9c32fb32 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -17263,6 +17263,22 @@ EndProject expect(result.features.map((feature) => feature.title)).toContain("Node source apps/web/src"); }); + it("preserves package-less Nx projects outside conventional roots", async () => { + const root = await fixtureRoot("clawpatch-map-package-less-nx-"); + await writeFixture( + root, + "tools/cli/project.json", + JSON.stringify({ name: "cli", sourceRoot: "tools/cli/src" }), + ); + await writeFixture(root, "tools/cli/src/index.ts", "export function main() {}\n"); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + + expect(project.detected.languages).not.toContain("typescript"); + expect(result.features.map((feature) => feature.title)).toContain("Node source tools/cli/src"); + }); + it("propagates Turbo failures and retries with a fresh mapping context", async () => { const root = await fixtureRoot("clawpatch-map-turbo-failure-"); await writeFixture(root, "package.json", JSON.stringify({ name: "node-app" })); diff --git a/src/mappers/projects.ts b/src/mappers/projects.ts index 19f5ef27..8f518c66 100644 --- a/src/mappers/projects.ts +++ b/src/mappers/projects.ts @@ -127,7 +127,7 @@ export async function discoverNodeProjects(root: string): Promise { - if (await pathExists(join(root, "nx.json"))) { + if ((await pathExists(join(root, "nx.json"))) || (await hasNestedNxProject(root, "", 5))) { return true; } for (const prefix of ["apps", "packages", "frontend", "client", "web"]) { @@ -153,6 +153,26 @@ export async function hasFallbackNodeProjectSignal(root: string): Promise { + if (remainingDepth < 0 || shouldSkipProjectDir(prefix)) { + return false; + } + if (prefix.length > 0 && (await pathExists(join(root, prefix, "project.json")))) { + return true; + } + for (const entry of await safeDirectoryEntries(root, prefix)) { + const child = prefix.length === 0 ? entry : `${prefix}/${entry}`; + if (await hasNestedNxProject(root, child, remainingDepth - 1)) { + return true; + } + } + return false; +} + async function hasNestedPackageJson( root: string, prefix: string, From bda80c546507ff0e20a05758d3171b45d7e925d9 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 12 Jul 2026 06:49:27 -0700 Subject: [PATCH 4/4] chore: defer release note to release process --- CHANGELOG.md | 1 - 1 file changed, 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b3a80ef..6fa9a87a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,6 @@ ## 0.7.1 - Unreleased -- Improved feature mapping so pure non-Node repositories skip Node workspace and Turbo discovery while Node consumers share one lazy context, thanks @Tanmay-008. - Fixed revalidation prompts to compact historical and feature metadata and hard-cap metadata lists even when configured file limits are high, preventing provider input overflows, thanks @pai-scaffolde. - Added an opt-in Claude host auth context that preserves the default-deny environment, uses Claude Code safe mode, validates auth through doctor, and reports redacted OAuth failure signals, thanks @grantjayy.