Skip to content

Commit 77da75b

Browse files
srnmsteipete
andauthored
fix(mapper): preserve uv workspace root context (#138)
* fix(mapper): preserve uv workspace root context * docs(changelog): credit uv workspace fix --------- Co-authored-by: Peter Steinberger <steipete@gmail.com>
1 parent 3753a33 commit 77da75b

3 files changed

Lines changed: 125 additions & 11 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

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

89
## 0.6.0 - 2026-06-11
910

src/mapper.test.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12700,6 +12700,32 @@ exclude = ["packages/legacy"]
1270012700
).toBe(false);
1270112701
});
1270212702

12703+
it("adds workspace root runtime metadata to uv workspace member features", async () => {
12704+
const root = await fixtureRoot("clawpatch-python-uv-workspace-runtime-context-");
12705+
await writeFixture(
12706+
root,
12707+
"pyproject.toml",
12708+
'[project]\nname = "workspace-root"\n\n[tool.uv.workspace]\nmembers = ["packages/backend"]\n',
12709+
);
12710+
await writeFixture(root, ".python-version", "3.14\n");
12711+
await writeFixture(root, "packages/backend/pyproject.toml", '[project]\nname = "backend"\n');
12712+
await writeFixture(root, "packages/backend/src/backend/app.py", "def run():\n pass\n");
12713+
12714+
const project = await detectProject(root);
12715+
const result = await mapFeatures(root, project, []);
12716+
const backendSource = result.features.find(
12717+
(feature) => feature.title === "Python source packages/backend/src",
12718+
);
12719+
12720+
expect(backendSource?.contextFiles).toEqual(
12721+
expect.arrayContaining([
12722+
{ path: "packages/backend/pyproject.toml", reason: "python target runtime metadata" },
12723+
{ path: "pyproject.toml", reason: "python target runtime metadata" },
12724+
{ path: ".python-version", reason: "python target runtime metadata" },
12725+
]),
12726+
);
12727+
});
12728+
1270312729
it("does not duplicate uv workspace members from root-level Python mapping", async () => {
1270412730
const root = await fixtureRoot("clawpatch-python-uv-workspace-root-dedupe-");
1270512731
await writeFixture(
@@ -12728,6 +12754,40 @@ exclude = ["packages/legacy"]
1272812754
).toBe(false);
1272912755
});
1273012756

12757+
it("preserves root routes that only touch uv members through associated tests", async () => {
12758+
const root = await fixtureRoot("clawpatch-python-uv-workspace-root-route-tests-");
12759+
await writeFixture(
12760+
root,
12761+
"pyproject.toml",
12762+
'[project]\nname = "workspace-root"\ndependencies = ["fastapi"]\n\n[tool.uv.workspace]\nmembers = ["packages/backend"]\n',
12763+
);
12764+
await writeFixture(root, "packages/__init__.py", "");
12765+
await writeFixture(
12766+
root,
12767+
"packages/shared.py",
12768+
"from fastapi import FastAPI\n\napp = FastAPI()\n\n@app.get('/shared')\ndef shared():\n return {'ok': True}\n",
12769+
);
12770+
await writeFixture(root, "packages/backend/pyproject.toml", '[project]\nname = "backend"\n');
12771+
await writeFixture(
12772+
root,
12773+
"packages/backend/tests/test_member.py",
12774+
"def test_member():\n pass\n",
12775+
);
12776+
12777+
const project = await detectProject(root);
12778+
const result = await mapFeatures(root, project, []);
12779+
const route = result.features.find((feature) => feature.title === "FastAPI route GET /shared");
12780+
12781+
expect(route?.ownedFiles).toEqual([
12782+
{ path: "packages/shared.py", reason: "FastAPI route handler shared" },
12783+
]);
12784+
expect(route?.tests).toEqual([]);
12785+
expect(route?.contextFiles).not.toContainEqual({
12786+
path: "packages/backend/tests/test_member.py",
12787+
reason: "associated test",
12788+
});
12789+
});
12790+
1273112791
it("preserves non-member files from mixed uv workspace root source groups", async () => {
1273212792
const root = await fixtureRoot("clawpatch-python-uv-workspace-root-mixed-");
1273312793
await writeFixture(

src/mappers/python.ts

Lines changed: 64 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,9 @@ export async function pythonSeeds(root: string): Promise<FeatureSeed[]> {
9898
const memberSeeds = await pythonProjectSeeds(join(root, member), {
9999
testCommandOverride: uvWorkspaceMemberTestCommand(member),
100100
});
101-
seeds.push(...memberSeeds.map((seed) => workspaceMemberSeed(seed, member)));
101+
for (const seed of memberSeeds) {
102+
seeds.push(await workspaceMemberSeed(root, seed, member));
103+
}
102104
}
103105
return seeds;
104106
}
@@ -256,7 +258,10 @@ function pruneRootSeedUvMemberPaths(
256258
(seed.source !== "python-source-group" && seed.source !== "python-test-suite") ||
257259
seed.ownedFiles === undefined
258260
) {
259-
return null;
261+
if (seedOwnedOrEntryTouchesUvMember(seed, members)) {
262+
return null;
263+
}
264+
return pruneSeedUvMemberReferences(seed, members);
260265
}
261266
const ownedFiles = seed.ownedFiles.filter((file) => !pathTouchesUvMember(file.path, members));
262267
if (ownedFiles.length === 0) {
@@ -301,6 +306,31 @@ function pruneRootSeedUvMemberPaths(
301306
return pruned;
302307
}
303308

309+
function seedOwnedOrEntryTouchesUvMember(seed: FeatureSeed, members: readonly string[]): boolean {
310+
return (
311+
pathTouchesUvMember(seed.entryPath, members) ||
312+
(seed.ownedFiles?.some((file) => pathTouchesUvMember(file.path, members)) ?? false)
313+
);
314+
}
315+
316+
function pruneSeedUvMemberReferences(seed: FeatureSeed, members: readonly string[]): FeatureSeed {
317+
const pruned: FeatureSeed = { ...seed };
318+
if (seed.contextFiles !== undefined) {
319+
pruned.contextFiles = seed.contextFiles.filter(
320+
(file) => !pathTouchesUvMember(file.path, members),
321+
);
322+
}
323+
if (seed.tests !== undefined) {
324+
pruned.tests = seed.tests.filter((test) => !pathTouchesUvMember(test.path, members));
325+
}
326+
if (seed.testPrefixes !== undefined) {
327+
pruned.testPrefixes = seed.testPrefixes.filter(
328+
(prefix) => !pathTouchesUvMember(prefix, members),
329+
);
330+
}
331+
return pruned;
332+
}
333+
304334
function pathTouchesUvMember(path: string, members: readonly string[]): boolean {
305335
return members.some((member) => pathMatchesPrefix(path, member));
306336
}
@@ -315,11 +345,28 @@ function seedRepoPaths(seed: FeatureSeed): string[] {
315345
]);
316346
}
317347

318-
function workspaceMemberSeed(seed: FeatureSeed, member: string): FeatureSeed {
348+
async function workspaceMemberSeed(
349+
workspaceRoot: string,
350+
seed: FeatureSeed,
351+
member: string,
352+
): Promise<FeatureSeed> {
319353
const prefixPath = (path: string): string => `${member}/${path}`;
320354
const genericSource = seed.source === "python-source-group";
321355
const genericTestSuite = seed.source === "python-test-suite";
322356
const entryPath = prefixPath(seed.entryPath);
357+
const contextFiles =
358+
seed.contextFiles === undefined
359+
? undefined
360+
: seed.contextFiles.map((file) => ({
361+
...file,
362+
path: prefixPath(file.path),
363+
}));
364+
const workspaceRuntimeContext = await pythonRuntimeContextFiles(
365+
workspaceRoot,
366+
seed.ownedFiles === undefined
367+
? [entryPath]
368+
: seed.ownedFiles.map((file) => prefixPath(file.path)),
369+
);
323370
return {
324371
...seed,
325372
title: genericSource
@@ -337,21 +384,27 @@ function workspaceMemberSeed(seed: FeatureSeed, member: string): FeatureSeed {
337384
...(seed.ownedFiles === undefined
338385
? {}
339386
: { ownedFiles: seed.ownedFiles.map((file) => ({ ...file, path: prefixPath(file.path) })) }),
340-
...(seed.contextFiles === undefined
341-
? {}
342-
: {
343-
contextFiles: seed.contextFiles.map((file) => ({
344-
...file,
345-
path: prefixPath(file.path),
346-
})),
347-
}),
387+
contextFiles: uniqueSeedFileRefs([...(contextFiles ?? []), ...workspaceRuntimeContext]),
348388
...(seed.tests === undefined
349389
? {}
350390
: { tests: seed.tests.map((test) => ({ ...test, path: prefixPath(test.path) })) }),
351391
...(seed.testPrefixes === undefined ? {} : { testPrefixes: seed.testPrefixes.map(prefixPath) }),
352392
};
353393
}
354394

395+
function uniqueSeedFileRefs(refs: SeedFileRef[]): SeedFileRef[] {
396+
const seen = new Set<string>();
397+
const output: SeedFileRef[] = [];
398+
for (const ref of refs) {
399+
if (seen.has(ref.path)) {
400+
continue;
401+
}
402+
seen.add(ref.path);
403+
output.push(ref);
404+
}
405+
return output;
406+
}
407+
355408
function workspaceMemberSummary(seed: FeatureSeed, member: string): string {
356409
if (seed.source === "python-source-group") {
357410
return prefixedPythonSourceSummary(seed, member);

0 commit comments

Comments
 (0)