Skip to content
Open
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
78 changes: 78 additions & 0 deletions src/mapper.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13412,9 +13412,55 @@ add_executable(headerapp include/headers.hpp)

expect(project.detected.commands.test).toBe("pytest");
expect(suite?.ownedFiles).toEqual([{ path: "test_app.py", reason: "pytest file" }]);
expect(suite?.contextFiles).toEqual([
{ path: "pyproject.toml", reason: "python target runtime metadata" },
{ path: "test_app.py", reason: "nearby test" },
]);
expect(suite?.contextFiles).not.toContainEqual({
path: ".python-version",
reason: "python target runtime metadata",
});
expect(suite?.contextFiles).not.toContainEqual({
path: "runtime.txt",
reason: "python target runtime metadata",
});
expect(suite?.tests).toEqual([{ path: "test_app.py", command: "pytest" }]);
});

it("threads Python runtime metadata into standalone pytest suites", async () => {
const root = await fixtureRoot("clawpatch-python-test-runtime-context-");
await writeFixture(root, "pyproject.toml", '[project]\nname = "runtime-tests"\n');
await writeFixture(root, ".python-version", "3.14\n");
await writeFixture(root, "runtime.txt", "python-3.14\n");
await writeFixture(
root,
"tests/test_pep758.py",
[
"def test_pep758_exception_group():",
" try:",
" int('x')",
" except TypeError, ValueError:",
" assert True",
"",
].join("\n"),
);

const project = await detectProject(root);
const result = await mapFeatures(root, project, []);
const suite = result.features.find((feature) => feature.title === "Python test suite tests");

expect(suite?.contextFiles).toContainEqual({
path: ".python-version",
reason: "python target runtime metadata",
});
expect(suite?.contextFiles).toContainEqual({
path: "runtime.txt",
reason: "python target runtime metadata",
});
expect(suite?.ownedFiles).toEqual([{ path: "tests/test_pep758.py", reason: "pytest file" }]);
expect(suite?.tests).toEqual([{ path: "tests/test_pep758.py", command: "pytest" }]);
});

it("maps Flask routes under web source roots", async () => {
const root = await fixtureRoot("clawpatch-python-flask-routes-");
await writeFixture(root, "requirements.txt", "Flask\npytest\n");
Expand Down Expand Up @@ -14059,6 +14105,38 @@ add_executable(headerapp include/headers.hpp)
});
});

it("adds Python target runtime metadata to source review context", async () => {
const root = await fixtureRoot("clawpatch-python-runtime-context-");
await writeFixture(root, ".python-version", "3.14\n");
await writeFixture(
root,
"pyproject.toml",
'[project]\nname = "pep758-demo"\nrequires-python = ">=3.14"\n',
);
await writeFixture(
root,
"main.py",
[
"def parse(value):",
" try:",
" return float(value)",
" except TypeError, ValueError:",
" return 0",
"",
].join("\n"),
);

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

expect(source?.ownedFiles).toEqual([{ path: "main.py", reason: "source group root" }]);
expect(source?.contextFiles).toEqual([
{ path: "pyproject.toml", reason: "python target runtime metadata" },
{ path: ".python-version", reason: "python target runtime metadata" },
]);
});

it("uses Hatch pytest commands in mapped Python features", async () => {
const root = await fixtureRoot("clawpatch-python-hatch-map-");
await writeFixture(
Expand Down
57 changes: 49 additions & 8 deletions src/mappers/python.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,14 @@ const projectMetadataFiles = [
"setup.cfg",
"requirements.txt",
] as const;
const runtimeMetadataFiles = [
"pyproject.toml",
"setup.py",
"setup.cfg",
".python-version",
"runtime.txt",
".tool-versions",
] as const;
const sourceGroupMaxOwnedFiles = 12;
const sourceGroupMaxTests = 8;
const flaskRootEntryFiles = [
Expand All @@ -83,6 +91,7 @@ export async function pythonSeeds(root: string): Promise<FeatureSeed[]> {
}
const metadata = await readPythonProjectMetadata(root);
const metadataFiles = await pythonMetadataFiles(root);
const runtimeContextFiles = await pythonRuntimeContextFiles(root);
const testCommand = await pythonTestCommand(root, metadata);
const testFiles = await pythonTestFiles(root);
const seeds: FeatureSeed[] = [];
Expand Down Expand Up @@ -129,7 +138,10 @@ export async function pythonSeeds(root: string): Promise<FeatureSeed[]> {
resolved.entryPath === script.metadataPath
? [{ path: script.metadataPath, reason: "console script metadata" }]
: [{ path: resolved.entryPath, reason: "console script source" }],
contextFiles: tests.map((test) => ({ path: test.path, reason: "associated test" })),
contextFiles: [
...runtimeContextFiles,
...tests.map((test) => ({ path: test.path, reason: "associated test" })),
],
tests,
tags: ["python", "cli"],
trustBoundaries: ["user-input", "filesystem", "process-exec"],
Expand Down Expand Up @@ -166,7 +178,10 @@ export async function pythonSeeds(root: string): Promise<FeatureSeed[]> {
route: null,
command: null,
ownedFiles: group.files.map((path) => ({ path, reason: `source group ${group.label}` })),
contextFiles: tests.map((test) => ({ path: test.path, reason: "associated test" })),
contextFiles: [
...runtimeContextFiles,
...tests.map((test) => ({ path: test.path, reason: "associated test" })),
],
tests,
tags: ["python", "source-group"],
trustBoundaries: packageTrustBoundaries(group.label),
Expand All @@ -175,7 +190,7 @@ export async function pythonSeeds(root: string): Promise<FeatureSeed[]> {
});
}

for (const test of standaloneTestSuites(testFiles, testCommand)) {
for (const test of standaloneTestSuites(testFiles, testCommand, runtimeContextFiles)) {
seeds.push(test);
}

Expand Down Expand Up @@ -230,6 +245,16 @@ async function pythonMetadataFiles(root: string): Promise<string[]> {
return files;
}

async function pythonRuntimeContextFiles(root: string): Promise<SeedFileRef[]> {
const files: SeedFileRef[] = [];
for (const path of runtimeMetadataFiles) {
if (await pathExists(join(root, path))) {
files.push({ path, reason: "python target runtime metadata" });
}
}
return files;
}

async function pythonTestCommand(root: string, pyproject: PyprojectInfo): Promise<string | null> {
if (
!pyproject.hasPytest &&
Expand Down Expand Up @@ -396,6 +421,7 @@ async function djangoRouteSeeds(
...(await rootPythonSourceFiles(root)),
...(await walk(root, await pythonSourceRoots(root))).filter(isReviewablePythonSourceFile),
]);
const runtimeContextFiles = await pythonRuntimeContextFiles(root);
const seeds: FeatureSeed[] = [];
const routesByFile = new Map<string, DjangoRoute[]>();
for (const filePath of routeFiles) {
Expand Down Expand Up @@ -430,7 +456,10 @@ async function djangoRouteSeeds(
route: expanded.routePath,
command: null,
ownedFiles: [{ path: expanded.filePath, reason: "Django URL route declaration" }],
contextFiles: tests.map((test) => ({ path: test.path, reason: "associated test" })),
contextFiles: [
...runtimeContextFiles,
...tests.map((test) => ({ path: test.path, reason: "associated test" })),
],
tests,
tags: ["python", "django", "route"],
trustBoundaries: djangoRouteTrustBoundaries(expanded),
Expand Down Expand Up @@ -1033,6 +1062,7 @@ async function fastApiRouteSeeds(
...(await rootPythonSourceFiles(root)),
...(await walk(root, await pythonSourceRoots(root))).filter(isReviewablePythonSourceFile),
]);
const runtimeContextFiles = await pythonRuntimeContextFiles(root);
const seeds: FeatureSeed[] = [];
for (const filePath of routeFiles) {
const source = await readFile(join(root, filePath), "utf8");
Expand All @@ -1058,7 +1088,10 @@ async function fastApiRouteSeeds(
ownedFiles: [
{ path: route.filePath, reason: `FastAPI route handler ${route.functionName}` },
],
contextFiles: tests.map((test) => ({ path: test.path, reason: "associated test" })),
contextFiles: [
...runtimeContextFiles,
...tests.map((test) => ({ path: test.path, reason: "associated test" })),
],
tests,
tags: ["python", "fastapi", "route"],
trustBoundaries: fastApiRouteTrustBoundaries(route),
Expand Down Expand Up @@ -1255,6 +1288,7 @@ async function flaskRouteSeeds(
): Promise<FeatureSeed[]> {
const hasFlaskDependency = await pythonDependencyHas(root, "flask");
const routeFiles = await flaskRouteFiles(root);
const runtimeContextFiles = await pythonRuntimeContextFiles(root);
const seeds: FeatureSeed[] = [];
for (const filePath of routeFiles) {
const source = await readFile(join(root, filePath), "utf8");
Expand All @@ -1276,7 +1310,10 @@ async function flaskRouteSeeds(
route: `${methodLabel} ${route.routePath}`,
command: null,
ownedFiles: [{ path: route.filePath, reason: `Flask route handler ${route.functionName}` }],
contextFiles: tests.map((test) => ({ path: test.path, reason: "associated test" })),
contextFiles: [
...runtimeContextFiles,
...tests.map((test) => ({ path: test.path, reason: "associated test" })),
],
tests,
tags: ["python", "flask", "route"],
trustBoundaries: flaskRouteTrustBoundaries(route),
Expand Down Expand Up @@ -1629,7 +1666,11 @@ function fastApiRouteTrustBoundaries(route: FastApiRoute): FeatureSeed["trustBou
return boundaries;
}

function standaloneTestSuites(testFiles: string[], command: string | null): FeatureSeed[] {
function standaloneTestSuites(
testFiles: string[],
command: string | null,
runtimeContextFiles: SeedFileRef[],
): FeatureSeed[] {
if (testFiles.length === 0) {
return [];
}
Expand All @@ -1648,7 +1689,7 @@ function standaloneTestSuites(testFiles: string[], command: string | null): Feat
route: null,
command: null,
ownedFiles: group.files.map((path) => ({ path, reason: "pytest file" })),
contextFiles: [],
contextFiles: [...runtimeContextFiles],
tests: group.files.map((path) => ({ path, command })),
tags: ["python", "test"],
trustBoundaries: [],
Expand Down
39 changes: 37 additions & 2 deletions src/prompt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,9 +193,44 @@ describe("review prompt provenance", () => {
expect(prompt).not.toContain("1 | export const value = 1;");
expect(prompt).not.toContain("--- src/other.ts");
});

it("includes Python runtime syntax guidance in review prompts", async () => {
const root = await fixtureRoot("clawpatch-prompt-python-runtime-guidance-");
await writeFixture(
root,
"main.py",
[
"def parse(value):",
" try:",
" return float(value)",
" except TypeError, ValueError:",
" return 0",
"",
].join("\n"),
);
await writeFixture(root, ".python-version", "3.14\n");
const pythonFeature = {
...feature(),
ownedFiles: [{ path: "main.py", reason: "source group root" }],
contextFiles: [{ path: ".python-version", reason: "python target runtime metadata" }],
tests: [],
};

const bundle = await buildReviewPromptBundle(
root,
project(root, ["python"]),
pythonFeature,
defaultConfig(),
);

expect(bundle.prompt).toContain("Python compatibility guidance:");
expect(bundle.prompt).toContain(".python-version");
expect(bundle.prompt).toContain("PEP 758 permits unparenthesized multiple exception types");
expect(bundle.prompt).toContain("--- .python-version (context, lines 1-1)");
});
});

function project(root: string): ProjectRecord {
function project(root: string, languages: string[] = ["typescript"]): ProjectRecord {
return {
schemaVersion: 1,
projectId: "proj_prompt",
Expand All @@ -208,7 +243,7 @@ function project(root: string): ProjectRecord {
headSha: null,
},
detected: {
languages: ["typescript"],
languages,
frameworks: [],
packageManagers: ["npm"],
commands: defaultConfig().commands,
Expand Down
12 changes: 12 additions & 0 deletions src/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,7 @@ ${customPrompt.trim()}
const validEvidencePaths = [
...new Set(includedFiles.filter((file) => file.readable).map((file) => file.path)),
];
const languageGuidance = reviewLanguageGuidance(project);
const prompt = `You are reviewing one semantic feature for clawpatch.

Return strict JSON only. No markdown fences.
Expand All @@ -206,6 +207,8 @@ ${customBlock}Review categories:

${reviewModeInstructions(mode)}

${languageGuidance}

Inspect owned files, context files, and linked tests. Treat included tests as first-class
evidence of intended behavior. If tests contradict a suspected bug, either skip it or
downgrade confidence and explain the uncertainty. Avoid reporting behavior as a bug
Expand Down Expand Up @@ -277,6 +280,15 @@ function reviewFeatureView(feature: FeatureRecord): object {
};
}

function reviewLanguageGuidance(project: ProjectRecord): string {
if (!project.detected.languages.includes("python")) {
return "";
}
return `Python compatibility guidance:
- Treat included target-runtime metadata such as .python-version, pyproject.toml requires-python, setup.cfg/setup.py python_requires, runtime.txt, and .tool-versions as authoritative when assessing syntax compatibility.
- For Python 3.14 or newer, PEP 758 permits unparenthesized multiple exception types in except/except* clauses when no as target is used; do not report that syntax as invalid for Python 3.14+ projects.`;
}

function uniquePromptRefs<T extends { path: string }>(
refs: readonly T[],
limit: number,
Expand Down