diff --git a/src/mapper.test.ts b/src/mapper.test.ts index 0b0889f..c9b754e 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -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"); @@ -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( diff --git a/src/mappers/python.ts b/src/mappers/python.ts index f2badbc..30ad7c6 100644 --- a/src/mappers/python.ts +++ b/src/mappers/python.ts @@ -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 = [ @@ -83,6 +91,7 @@ export async function pythonSeeds(root: string): Promise { } 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[] = []; @@ -129,7 +138,10 @@ export async function pythonSeeds(root: string): Promise { 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"], @@ -166,7 +178,10 @@ export async function pythonSeeds(root: string): Promise { 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), @@ -175,7 +190,7 @@ export async function pythonSeeds(root: string): Promise { }); } - for (const test of standaloneTestSuites(testFiles, testCommand)) { + for (const test of standaloneTestSuites(testFiles, testCommand, runtimeContextFiles)) { seeds.push(test); } @@ -230,6 +245,16 @@ async function pythonMetadataFiles(root: string): Promise { return files; } +async function pythonRuntimeContextFiles(root: string): Promise { + 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 { if ( !pyproject.hasPytest && @@ -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(); for (const filePath of routeFiles) { @@ -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), @@ -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"); @@ -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), @@ -1255,6 +1288,7 @@ async function flaskRouteSeeds( ): Promise { 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"); @@ -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), @@ -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 []; } @@ -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: [], diff --git a/src/prompt.test.ts b/src/prompt.test.ts index f6ea20a..b92d5d8 100644 --- a/src/prompt.test.ts +++ b/src/prompt.test.ts @@ -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", @@ -208,7 +243,7 @@ function project(root: string): ProjectRecord { headSha: null, }, detected: { - languages: ["typescript"], + languages, frameworks: [], packageManagers: ["npm"], commands: defaultConfig().commands, diff --git a/src/prompt.ts b/src/prompt.ts index e0e4283..b6a354a 100644 --- a/src/prompt.ts +++ b/src/prompt.ts @@ -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. @@ -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 @@ -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( refs: readonly T[], limit: number,