diff --git a/src/mapper.test.ts b/src/mapper.test.ts index cc203dd..9c32fb3 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -1,14 +1,20 @@ import { mkdir, symlink } from "node:fs/promises"; import { basename, join } from "node:path"; -import { describe, expect, it } 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( @@ -1120,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"); @@ -1127,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 () => { @@ -17189,4 +17199,98 @@ EndProject expect(ownedFiles).not.toContain("fixtures/Sample/Sample.cs"); expect(ownedFiles).not.toContain("testdata/Example/Example.cs"); }); + + 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(); + }); + + 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); + 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("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" })); + 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(taskGraphSpy).toHaveBeenCalledTimes(2); + }); }); diff --git a/src/mapper.ts b/src/mapper.ts index 0e1e946..dcaf8fb 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,17 +286,24 @@ function uniqueTests(tests: Array<{ path: string; command: string | null }>): Ar return output; } -async function collectSeeds(root: string, options: MapOptions): Promise { - const projects = await discoverNodeProjects(root); - const context: MapperContext = { - projects, - taskGraph: await turboTaskGraph(root, projects), - }; +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, @@ -308,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 0000000..9abaf15 --- /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 0000000..98d0364 --- /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 100a51a..600230f 100644 --- a/src/mappers/next.ts +++ b/src/mappers/next.ts @@ -13,12 +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 rootProject = context.projects.find((project) => project.root === "."); + 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( - 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 13d925d..b99cc66 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 type { 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.nodeProjects(); + const taskGraph = await context.nodeTaskGraph(); const seeds: FeatureSeed[] = []; - const rootFrameworks = serverFrameworks( - context.projects.find((project) => project.root === ".") ?? null, - ); - for (const project of context.projects) { + const rootFrameworks = serverFrameworks(projects.find((project) => project.root === ".") ?? null); + 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 1e893c2..c0749a6 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.nodeProjects(); + const taskGraph = await context.nodeTaskGraph(); + 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/projects.ts b/src/mappers/projects.ts index 80ef26a..8f518c6 100644 --- a/src/mappers/projects.ts +++ b/src/mappers/projects.ts @@ -126,6 +126,72 @@ 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"))) || (await hasNestedNxProject(root, "", 5))) { + 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 hasNestedNxProject( + root: string, + prefix: string, + remainingDepth: number, +): 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, + 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 6207ac8..76ae93a 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, context.projects, 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 364da6f..cdaf578 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: NodeProjectInfo[]; - taskGraph: WorkspaceTaskGraph; + nodeProjects(): Promise; + nodeTaskGraph(): Promise; };