Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 105 additions & 1 deletion src/mapper.test.ts
Original file line number Diff line number Diff line change
@@ -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(
Expand Down Expand Up @@ -1120,13 +1126,17 @@ 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");

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 () => {
Expand Down Expand Up @@ -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<boolean>();
vi.spyOn(projectsModule, "hasFallbackNodeProjectSignal").mockReturnValue(signal.promise);
const projectsSpy = vi.spyOn(projectsModule, "discoverNodeProjects");
const goDone = Promise.withResolvers<void>();
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);
});
});
48 changes: 35 additions & 13 deletions src/mapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 },
Expand All @@ -73,7 +74,7 @@ export async function mapFeatures(
existing: FeatureRecord[],
options: MapOptions = {},
): Promise<MapResult> {
const seeds = await collectSeeds(root, options);
const seeds = await collectSeeds(root, project, options);
return mapFeatureSeeds(root, project, existing, seeds, options);
}

Expand Down Expand Up @@ -285,17 +286,24 @@ function uniqueTests(tests: Array<{ path: string; command: string | null }>): Ar
return output;
}

async function collectSeeds(root: string, options: MapOptions): Promise<FeatureSeed[]> {
const projects = await discoverNodeProjects(root);
const context: MapperContext = {
projects,
taskGraph: await turboTaskGraph(root, projects),
};
async function collectSeeds(
root: string,
project: ProjectRecord,
options: MapOptions,
): Promise<FeatureSeed[]> {
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,
Expand All @@ -308,6 +316,20 @@ async function collectSeeds(root: string, options: MapOptions): Promise<FeatureS
return dedupeFeatureSeeds(groups.flat());
}

async function shouldRunNodeMappers(root: string, project: ProjectRecord): Promise<boolean> {
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";
Expand Down
77 changes: 77 additions & 0 deletions src/mappers/context.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
22 changes: 22 additions & 0 deletions src/mappers/context.ts
Original file line number Diff line number Diff line change
@@ -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<NodeProjectInfo[]>;
buildNodeTaskGraph(projects: NodeProjectInfo[]): Promise<WorkspaceTaskGraph>;
};

export function createMapperContext(loaders: MapperContextLoaders): MapperContext {
const nodeProjects = memoizeAsync(loaders.discoverNodeProjects);
const nodeTaskGraph = memoizeAsync(async () => loaders.buildNodeTaskGraph(await nodeProjects()));
return { nodeProjects, nodeTaskGraph };
}

function memoizeAsync<T>(loader: () => Promise<T>): () => Promise<T> {
let promise: Promise<T> | null = null;
return () => {
promise ??= Promise.resolve().then(loader);
return promise;
};
}
8 changes: 4 additions & 4 deletions src/mappers/next.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<FeatureSeed[]> {
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();
}
Expand Down
22 changes: 13 additions & 9 deletions src/mappers/node-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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<FeatureSeed[]> {
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;
}
Expand All @@ -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<FeatureSeed[]> {
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[] = [];

Expand Down
Loading