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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

- Removed the direct MiniMax HTTP provider and its transport dependency; provider integrations are now explicitly limited to coding harnesses and agent CLIs.
- Added uv workspace member mapping with repository-relative paths and member-local test commands while preserving mixed root source and test groups, thanks @srnm.
- Fixed uv workspace mapping to preserve root features with member-associated tests and include workspace-root runtime metadata in member features, thanks @srnm.

## 0.6.0 - 2026-06-11

Expand Down
60 changes: 60 additions & 0 deletions src/mapper.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12700,6 +12700,32 @@ exclude = ["packages/legacy"]
).toBe(false);
});

it("adds workspace root runtime metadata to uv workspace member features", async () => {
const root = await fixtureRoot("clawpatch-python-uv-workspace-runtime-context-");
await writeFixture(
root,
"pyproject.toml",
'[project]\nname = "workspace-root"\n\n[tool.uv.workspace]\nmembers = ["packages/backend"]\n',
);
await writeFixture(root, ".python-version", "3.14\n");
await writeFixture(root, "packages/backend/pyproject.toml", '[project]\nname = "backend"\n');
await writeFixture(root, "packages/backend/src/backend/app.py", "def run():\n pass\n");

const project = await detectProject(root);
const result = await mapFeatures(root, project, []);
const backendSource = result.features.find(
(feature) => feature.title === "Python source packages/backend/src",
);

expect(backendSource?.contextFiles).toEqual(
expect.arrayContaining([
{ path: "packages/backend/pyproject.toml", reason: "python target runtime metadata" },
{ path: "pyproject.toml", reason: "python target runtime metadata" },
{ path: ".python-version", reason: "python target runtime metadata" },
]),
);
});

it("does not duplicate uv workspace members from root-level Python mapping", async () => {
const root = await fixtureRoot("clawpatch-python-uv-workspace-root-dedupe-");
await writeFixture(
Expand Down Expand Up @@ -12728,6 +12754,40 @@ exclude = ["packages/legacy"]
).toBe(false);
});

it("preserves root routes that only touch uv members through associated tests", async () => {
const root = await fixtureRoot("clawpatch-python-uv-workspace-root-route-tests-");
await writeFixture(
root,
"pyproject.toml",
'[project]\nname = "workspace-root"\ndependencies = ["fastapi"]\n\n[tool.uv.workspace]\nmembers = ["packages/backend"]\n',
);
await writeFixture(root, "packages/__init__.py", "");
await writeFixture(
root,
"packages/shared.py",
"from fastapi import FastAPI\n\napp = FastAPI()\n\n@app.get('/shared')\ndef shared():\n return {'ok': True}\n",
);
await writeFixture(root, "packages/backend/pyproject.toml", '[project]\nname = "backend"\n');
await writeFixture(
root,
"packages/backend/tests/test_member.py",
"def test_member():\n pass\n",
);

const project = await detectProject(root);
const result = await mapFeatures(root, project, []);
const route = result.features.find((feature) => feature.title === "FastAPI route GET /shared");

expect(route?.ownedFiles).toEqual([
{ path: "packages/shared.py", reason: "FastAPI route handler shared" },
]);
expect(route?.tests).toEqual([]);
expect(route?.contextFiles).not.toContainEqual({
path: "packages/backend/tests/test_member.py",
reason: "associated test",
});
});

it("preserves non-member files from mixed uv workspace root source groups", async () => {
const root = await fixtureRoot("clawpatch-python-uv-workspace-root-mixed-");
await writeFixture(
Expand Down
75 changes: 64 additions & 11 deletions src/mappers/python.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,9 @@ export async function pythonSeeds(root: string): Promise<FeatureSeed[]> {
const memberSeeds = await pythonProjectSeeds(join(root, member), {
testCommandOverride: uvWorkspaceMemberTestCommand(member),
});
seeds.push(...memberSeeds.map((seed) => workspaceMemberSeed(seed, member)));
for (const seed of memberSeeds) {
seeds.push(await workspaceMemberSeed(root, seed, member));
}
}
return seeds;
}
Expand Down Expand Up @@ -256,7 +258,10 @@ function pruneRootSeedUvMemberPaths(
(seed.source !== "python-source-group" && seed.source !== "python-test-suite") ||
seed.ownedFiles === undefined
) {
return null;
if (seedOwnedOrEntryTouchesUvMember(seed, members)) {
return null;
}
return pruneSeedUvMemberReferences(seed, members);
}
const ownedFiles = seed.ownedFiles.filter((file) => !pathTouchesUvMember(file.path, members));
if (ownedFiles.length === 0) {
Expand Down Expand Up @@ -301,6 +306,31 @@ function pruneRootSeedUvMemberPaths(
return pruned;
}

function seedOwnedOrEntryTouchesUvMember(seed: FeatureSeed, members: readonly string[]): boolean {
return (
pathTouchesUvMember(seed.entryPath, members) ||
(seed.ownedFiles?.some((file) => pathTouchesUvMember(file.path, members)) ?? false)
);
}

function pruneSeedUvMemberReferences(seed: FeatureSeed, members: readonly string[]): FeatureSeed {
const pruned: FeatureSeed = { ...seed };
if (seed.contextFiles !== undefined) {
pruned.contextFiles = seed.contextFiles.filter(
(file) => !pathTouchesUvMember(file.path, members),
);
}
if (seed.tests !== undefined) {
pruned.tests = seed.tests.filter((test) => !pathTouchesUvMember(test.path, members));
}
if (seed.testPrefixes !== undefined) {
pruned.testPrefixes = seed.testPrefixes.filter(
(prefix) => !pathTouchesUvMember(prefix, members),
);
}
return pruned;
}

function pathTouchesUvMember(path: string, members: readonly string[]): boolean {
return members.some((member) => pathMatchesPrefix(path, member));
}
Expand All @@ -315,11 +345,28 @@ function seedRepoPaths(seed: FeatureSeed): string[] {
]);
}

function workspaceMemberSeed(seed: FeatureSeed, member: string): FeatureSeed {
async function workspaceMemberSeed(
workspaceRoot: string,
seed: FeatureSeed,
member: string,
): Promise<FeatureSeed> {
const prefixPath = (path: string): string => `${member}/${path}`;
const genericSource = seed.source === "python-source-group";
const genericTestSuite = seed.source === "python-test-suite";
const entryPath = prefixPath(seed.entryPath);
const contextFiles =
seed.contextFiles === undefined
? undefined
: seed.contextFiles.map((file) => ({
...file,
path: prefixPath(file.path),
}));
const workspaceRuntimeContext = await pythonRuntimeContextFiles(
workspaceRoot,
seed.ownedFiles === undefined
? [entryPath]
: seed.ownedFiles.map((file) => prefixPath(file.path)),
);
return {
...seed,
title: genericSource
Expand All @@ -337,21 +384,27 @@ function workspaceMemberSeed(seed: FeatureSeed, member: string): FeatureSeed {
...(seed.ownedFiles === undefined
? {}
: { ownedFiles: seed.ownedFiles.map((file) => ({ ...file, path: prefixPath(file.path) })) }),
...(seed.contextFiles === undefined
? {}
: {
contextFiles: seed.contextFiles.map((file) => ({
...file,
path: prefixPath(file.path),
})),
}),
contextFiles: uniqueSeedFileRefs([...(contextFiles ?? []), ...workspaceRuntimeContext]),
...(seed.tests === undefined
? {}
: { tests: seed.tests.map((test) => ({ ...test, path: prefixPath(test.path) })) }),
...(seed.testPrefixes === undefined ? {} : { testPrefixes: seed.testPrefixes.map(prefixPath) }),
};
}

function uniqueSeedFileRefs(refs: SeedFileRef[]): SeedFileRef[] {
const seen = new Set<string>();
const output: SeedFileRef[] = [];
for (const ref of refs) {
if (seen.has(ref.path)) {
continue;
}
seen.add(ref.path);
output.push(ref);
}
return output;
}

function workspaceMemberSummary(seed: FeatureSeed, member: string): string {
if (seed.source === "python-source-group") {
return prefixedPythonSourceSummary(seed, member);
Expand Down