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
8 changes: 7 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
# Changelog

## 0.8.4 - Unreleased
## 0.8.5 - Unreleased

## 0.8.4 - 2026-10-21

**Highlights:** Package-less nested TypeScript sources receive deterministic ownership without crossing explicit project boundaries.

- Fixed deterministic mapping of package-less nested Node/TypeScript sources under `packages/*`, assigning unowned files to bounded source groups while preserving explicit project boundaries, thanks @ptahdunbar for the report (#243).

## 0.8.3 - 2026-10-20

Expand Down
8 changes: 8 additions & 0 deletions docs/feature-mapping.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,14 @@ clawpatch next --project web
When an Nx project target is available, nearby tests use the project-scoped
command, such as `yarn nx test web`, instead of a repository-wide test command.

Package-less projects under `packages/*` also cover nested Node/TypeScript
sources outside conventional folders, such as `packages/core/governance/types.ts`.
After conventional and framework mapping, remaining sources get one bounded
source-group owner in the nearest discovered package-like directory. Explicit
`package.json` and `project.json` boundaries retain control of their subtrees;
fallback mapping does not expand an explicit project's declared source scope.
Workspace and configured excludes, generated files, and symlinks remain excluded.

When Turborepo metadata is available, mapped workspace features use filtered
Turbo validation commands such as `pnpm turbo run test --filter web`. Clawpatch
does not execute Turbo during mapping and leaves task dependency expansion to
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "clawpatch",
"version": "0.8.3",
"version": "0.8.4",
"description": "Automated code review that lands fixes.",
"license": "MIT",
"repository": {
Expand Down
9 changes: 7 additions & 2 deletions src/mapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import { laravelSeeds } from "./mappers/laravel.js";
import { mavenSeeds } from "./mappers/maven.js";
import { nextSeeds } from "./mappers/next.js";
import { nodeRouteSeeds } from "./mappers/node-routes.js";
import { nodeSeeds } from "./mappers/node.js";
import { nodeSeeds, packageLessSourceSeeds } from "./mappers/node.js";
import { pythonSeeds } from "./mappers/python.js";
import { reactSeeds } from "./mappers/react.js";
import { createMapperContext } from "./mappers/context.js";
Expand Down Expand Up @@ -329,7 +329,12 @@ async function collectSeeds(
return seeds;
}),
);
return dedupeFeatureSeeds(groups.flat());
const seeds = groups.flat();
// Fill only ownership gaps after all conventional and framework mappers have run.
if (await runNodeMappers) {
seeds.push(...(await packageLessSourceSeeds(root, context, seeds)));
}
return dedupeFeatureSeeds(seeds);
}

async function shouldRunNodeMappers(root: string, project: ProjectRecord): Promise<boolean> {
Expand Down
185 changes: 185 additions & 0 deletions src/mappers/node-package-less.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
import { symlink } from "node:fs/promises";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { detectProject } from "../detect.js";
import { mapFeatures } from "../mapper.js";
import { fixtureRoot, writeFixture } from "../test-helpers.js";
import type { FeatureRecord } from "../types.js";

function owners(features: FeatureRecord[], path: string): FeatureRecord[] {
return features.filter((feature) => feature.ownedFiles.some((file) => file.path === path));
}

describe("package-less nested Node sources", () => {
it.each([true, false])(
"maps the reproduction once with workspace metadata: %s",
async (workspace) => {
const root = await fixtureRoot("clawpatch-nested-package-less-");
if (workspace) {
await writeFixture(
root,
"package.json",
'{"name":"portable-mapping-fixture","private":true,"type":"module"}\n',
);
await writeFixture(root, "pnpm-workspace.yaml", "packages:\n - 'packages/*'\n");
}
const path = "packages/core/governance/types.ts";
await writeFixture(root, path, "export type Policy = { enabled: boolean };\n");
const project = await detectProject(root);
const first = await mapFeatures(root, project, []);
expect(owners(first.features, path)).toHaveLength(1);
expect(owners(first.features, path)[0]?.tags).toContain("project-root:packages/core");
const second = await mapFeatures(root, project, first.features);
expect(second.created).toBe(0);
expect(second.changed).toBe(0);
expect(second.features.map((feature) => feature.featureId)).toEqual(
first.features.map((feature) => feature.featureId),
);
},
);

it("keeps conventional sources and explicit nested project owners", async () => {
const root = await fixtureRoot("clawpatch-nested-owners-");
await writeFixture(root, "pnpm-workspace.yaml", "packages:\n - 'packages/**'\n");
await writeFixture(root, "packages/core/src/index.ts", "export const core = true;\n");
await writeFixture(
root,
"packages/core/governance/types.ts",
"export type Policy = boolean;\n",
);
await writeFixture(
root,
"packages/core/governance/types.test.ts",
"test('policy', () => {});\n",
);
await writeFixture(
root,
"packages/core/governance/nested/project.json",
JSON.stringify({
name: "explicit-policy",
sourceRoot: "packages/core/governance/nested/implementation",
targets: { test: {} },
}),
);
await writeFixture(
root,
"packages/core/governance/nested/implementation/policy.ts",
"export const policy = true;\n",
);
await writeFixture(
root,
"packages/core/governance/nested/outside.ts",
"export const outside = true;\n",
);
await writeFixture(root, "packages/core/child/package.json", '{"name":"child"}\n');
await writeFixture(root, "packages/core/child/src/child.ts", "export const child = true;\n");
const result = await mapFeatures(root, await detectProject(root), []);
for (const path of [
"packages/core/src/index.ts",
"packages/core/governance/types.ts",
"packages/core/governance/nested/implementation/policy.ts",
"packages/core/child/src/child.ts",
]) {
expect(owners(result.features, path), path).toHaveLength(1);
}
expect(
owners(result.features, "packages/core/governance/nested/implementation/policy.ts")[0]?.tags,
).toContain("project:explicit-policy");
expect(owners(result.features, "packages/core/governance/nested/outside.ts")).toHaveLength(0);
expect(owners(result.features, "packages/core/governance/types.ts")[0]?.tests).toContainEqual({
path: "packages/core/governance/types.test.ts",
command: null,
});
});

it("respects configured and workspace excludes, generated files, and symlinks", async () => {
const root = await fixtureRoot("clawpatch-nested-excludes-");
await writeFixture(
root,
"pnpm-workspace.yaml",
"packages:\n - 'packages/*'\n - '!packages/ignored'\n - '!packages/core/excluded'\n",
);
const source = "packages/core/governance/types.ts";
await writeFixture(root, source, "export type Policy = boolean;\n");
const ignored = [
"packages/ignored/governance/types.ts",
"packages/core/private/secret.ts",
"packages/core/excluded/policy.ts",
"packages/core/dist/output.ts",
"packages/core/node_modules/dependency/index.ts",
"packages/core/generated/types.ts",
"packages/core/governance/types.generated.ts",
"packages/core/fixtures/sample.ts",
"packages/core/governance/types.d.ts",
"packages/core/governance/types.test.ts",
];
for (const path of ignored) await writeFixture(root, path, "export const ignored = true;\n");
if (process.platform !== "win32") {
await symlink(
join(root, "packages/core/governance"),
join(root, "packages/core/linked"),
"dir",
);
await symlink(join(root, source), join(root, "packages/core/link.ts"));
}
const result = await mapFeatures(root, await detectProject(root), [], {
filters: { include: ["**/*"], exclude: ["packages/core/private/**"] },
});
expect(owners(result.features, source)).toHaveLength(1);
for (const path of [...ignored, "packages/core/linked/types.ts", "packages/core/link.ts"])
expect(owners(result.features, path), path).toHaveLength(0);
});

it("bounds source groups and leaves package manifests authoritative", async () => {
const root = await fixtureRoot("clawpatch-nested-bounds-");
const paths = Array.from(
{ length: 29 },
(_, index) => `packages/core/governance/policy${index}.ts`,
);
for (const path of paths) await writeFixture(root, path, "export const policy = true;\n");
await writeFixture(root, "packages/explicit/package.json", '{"name":"explicit"}\n');
await writeFixture(
root,
"packages/explicit/governance/types.ts",
"export type Policy = boolean;\n",
);
const result = await mapFeatures(root, await detectProject(root), []);
for (const path of paths) expect(owners(result.features, path), path).toHaveLength(1);
expect(
result.features.every(
(feature) => feature.source !== "node-source-group" || feature.ownedFiles.length <= 12,
),
).toBe(true);
expect(owners(result.features, "packages/explicit/governance/types.ts")).toHaveLength(0);
});

it("respects nested manifests outside the default workspace discovery scope", async () => {
const root = await fixtureRoot("clawpatch-nested-manifest-boundary-");
await writeFixture(root, "pnpm-workspace.yaml", "packages:\n - 'packages/*'\n");
const path = "packages/core/governance/types.ts";
await writeFixture(root, path, "export type Policy = boolean;\n");
await writeFixture(root, "packages/core/child/package.json", '{"name":"child"}\n');
await writeFixture(
root,
"packages/core/child/governance/types.ts",
"export type Child = boolean;\n",
);
await writeFixture(
root,
"packages/core/scripts/child/package.json",
'{"name":"script-child"}\n',
);
await writeFixture(
root,
"packages/core/scripts/child/src/index.ts",
"export const child = true;\n",
);
await writeFixture(root, "packages/core/src/child/package.json", '{"name":"source-child"}\n');
await writeFixture(root, "packages/core/src/child/index.ts", "export const child = true;\n");
const result = await mapFeatures(root, await detectProject(root), []);
expect(owners(result.features, path)).toHaveLength(1);
expect(owners(result.features, "packages/core/child/governance/types.ts")).toHaveLength(0);
expect(owners(result.features, "packages/core/scripts/child/src/index.ts")).toHaveLength(0);
expect(owners(result.features, "packages/core/src/child/index.ts")).toHaveLength(0);
});
});
Loading
Loading