From 99d8316cf3e283b21d215607719a130af4796efa Mon Sep 17 00:00:00 2001 From: Meng Xuan Xia Date: Fri, 15 May 2026 21:14:08 -0400 Subject: [PATCH 01/18] Add Python feature mapping --- CHANGELOG.md | 1 + docs/feature-mapping.md | 7 + docs/quickstart.md | 1 + src/detect.ts | 318 +++++++++++++++++++++- src/mapper.test.ts | 146 +++++++++- src/mapper.ts | 2 + src/mappers/python.ts | 583 ++++++++++++++++++++++++++++++++++++++++ src/mappers/shared.ts | 9 +- 8 files changed, 1062 insertions(+), 5 deletions(-) create mode 100644 src/mappers/python.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b13858..895ec35 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- Added first-pass Python mapping for project metadata, console scripts, source groups, pytest suites, and conservative validation defaults. - Improved Node/TypeScript mapping for large workspaces by splitting package source trees into bounded review groups with package-local tests. - Added generic nested SwiftPM, Apple/Xcode, and Gradle/Android app mapping. diff --git a/docs/feature-mapping.md b/docs/feature-mapping.md index e1725ad..2c6dcc9 100644 --- a/docs/feature-mapping.md +++ b/docs/feature-mapping.md @@ -33,6 +33,7 @@ Supported deterministic mappers today: - Next.js `app/` and `pages/` routes - Go `cmd/*/main.go` - Go `internal/*` packages +- Python project metadata, console scripts, bounded source groups, and pytest suites - Rust Cargo commands, libraries, workspace crates, and integration tests - SwiftPM executable targets, library targets, and test suites - nested SwiftPM packages @@ -52,8 +53,14 @@ Native app mappers use the same bounded grouping model. SwiftPM packages can be discovered below the repo root, Apple projects are grouped by Swift source area, and Gradle modules are grouped from `src/main`, `src/test`, and `src/androidTest`. +Python mapping covers `pyproject.toml` metadata, `[project.scripts]` and +`[tool.poetry.scripts]` console scripts, source groups under common Python +source roots, and pytest files. Framework-specific route mapping for FastAPI, +Flask, and Django is not implemented yet. + Known gaps: - no Express/Fastify/Hono route mapper yet +- no FastAPI/Flask/Django route mapper yet - no import graph expansion beyond nearby tests yet - no agent enrichment yet diff --git a/docs/quickstart.md b/docs/quickstart.md index ce63e2b..a57ad96 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -45,6 +45,7 @@ This discovers reviewable features: - npm package bins and scripts - Next.js routes - Go packages and commands +- Python packages, console scripts, and pytest suites - Rust crates and binaries - SwiftPM targets and tests - Config files diff --git a/src/detect.ts b/src/detect.ts index 0d153c7..6317c0d 100644 --- a/src/detect.ts +++ b/src/detect.ts @@ -13,6 +13,12 @@ type PackageJson = { bin?: unknown; }; +type PythonProjectInfo = { + dependencies: Set; + tools: Set; + hasPytestConfig: boolean; +}; + export async function detectProject(root: string): Promise { const git = await discoverGit(root); const pkg = await readPackageJson(root); @@ -135,6 +141,9 @@ async function languageDefaultCommands( test: (await hasSwiftTests(root)) ? "swift test" : null, }; } + if (languages.includes("python")) { + return pythonDefaultCommands(root); + } return { typecheck: null, @@ -207,9 +216,285 @@ async function detectPackageManagers(root: string): Promise { ) { found.push("gradle"); } + const pythonManagers: Array<[string, string]> = [ + ["uv", "uv.lock"], + ["poetry", "poetry.lock"], + ["pdm", "pdm.lock"], + ["hatch", "hatch.toml"], + ]; + for (const [name, file] of pythonManagers) { + if (await pathExists(join(root, file))) { + found.push(name); + } + } + if (!found.some((name) => pythonPackageManagers.has(name)) && (await isPythonProject(root))) { + found.push((await pathExists(join(root, "requirements.txt"))) ? "pip" : "python"); + } return found; } +const pythonPackageManagers = new Set(["uv", "poetry", "pdm", "hatch", "pip", "python"]); + +async function pythonDefaultCommands(root: string): Promise { + const info = await pythonProjectInfo(root); + const runner = await pythonRunner(root); + const hasPytest = + info.hasPytestConfig || + info.dependencies.has("pytest") || + (await containsPythonTestFile(root, 5)); + const hasRuff = info.tools.has("ruff") || info.dependencies.has("ruff"); + const hasPyright = info.tools.has("pyright") || info.dependencies.has("pyright"); + const hasMypy = info.tools.has("mypy") || info.dependencies.has("mypy"); + return { + typecheck: hasPyright + ? pythonRunCommand(runner, "pyright") + : hasMypy + ? pythonRunCommand(runner, "mypy .") + : hasRuff + ? pythonRunCommand(runner, "ruff check .") + : null, + lint: hasRuff ? pythonRunCommand(runner, "ruff check .") : null, + format: hasRuff ? pythonRunCommand(runner, "ruff format --check .") : null, + test: hasPytest ? pythonRunCommand(runner, "pytest") : null, + }; +} + +async function pythonRunner(root: string): Promise { + if (await pathExists(join(root, "uv.lock"))) { + return "uv"; + } + if (await pathExists(join(root, "poetry.lock"))) { + return "poetry"; + } + if (await pathExists(join(root, "pdm.lock"))) { + return "pdm"; + } + return null; +} + +function pythonRunCommand(runner: string | null, command: string): string { + if (runner === "uv") { + return `uv run ${command}`; + } + if (runner === "poetry") { + return `poetry run ${command}`; + } + if (runner === "pdm") { + return `pdm run ${command}`; + } + return command; +} + +async function pythonProjectInfo(root: string): Promise { + const info: PythonProjectInfo = { + dependencies: new Set(), + tools: new Set(), + hasPytestConfig: false, + }; + if (await pathExists(join(root, "pyproject.toml"))) { + const pyproject = await readFile(join(root, "pyproject.toml"), "utf8"); + for (const dependency of pythonDependencyNames(pyproject)) { + info.dependencies.add(dependency); + } + for (const tool of pythonToolSections(pyproject)) { + info.tools.add(tool); + } + info.hasPytestConfig = info.tools.has("pytest") || info.tools.has("pytest.ini_options"); + } + for (const file of ["requirements.txt", "setup.cfg"]) { + if (await pathExists(join(root, file))) { + const source = await readFile(join(root, file), "utf8"); + for (const dependency of pythonRequirementNames(source)) { + info.dependencies.add(dependency); + } + if (/^\s*\[tool:pytest\]|\[pytest\]/mu.test(source)) { + info.hasPytestConfig = true; + } + const toolMatch = /^\s*\[(mypy|pyright|ruff)\]/mu.exec(source); + if (toolMatch?.[1] !== undefined) { + info.tools.add(toolMatch[1]); + } + } + } + return info; +} + +function pythonDependencyNames(source: string): string[] { + const names = new Set(); + for (const table of [ + pythonTomlTable(source, "project"), + pythonTomlTable(source, "tool.poetry"), + pythonTomlTable(source, "tool.poetry.group.dev"), + ]) { + for (const section of pythonTomlArraySections(table, ["dependencies", "dev-dependencies"])) { + for (const value of pythonTomlArrayValues(section)) { + const name = pythonRequirementName(value); + if (name !== null) { + names.add(name); + } + } + } + } + for (const table of pythonTomlTables(source, [ + "tool.poetry.dependencies", + "tool.poetry.dev-dependencies", + "tool.poetry.group.dev.dependencies", + ])) { + for (const value of pythonTomlAssignedKeysAndValues(table)) { + const name = pythonRequirementName(value); + if (name !== null) { + names.add(name); + } + } + } + for (const table of pythonTomlTables(source, [ + "project.optional-dependencies", + "dependency-groups", + ])) { + for (const value of pythonTomlAssignedValues(table)) { + const name = pythonRequirementName(value); + if (name !== null) { + names.add(name); + } + } + } + return [...names]; +} + +function pythonTomlTable(source: string, name: string): string { + const escaped = name.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); + const match = new RegExp(`^\\s*\\[${escaped}\\]\\s*$`, "mu").exec(source); + if (match?.index === undefined) { + return ""; + } + const rest = source.slice(match.index + match[0].length); + const next = /^\s*\[[^\]]+\]\s*$/mu.exec(rest); + return next?.index === undefined ? rest : rest.slice(0, next.index); +} + +function pythonToolSections(source: string): string[] { + const tools = new Set(); + for (const match of source.matchAll(/^\s*\[tool\.([A-Za-z0-9_.-]+)[^\]]*\]\s*$/gmu)) { + const name = match[1]?.split(".")[0]; + if (name !== undefined) { + tools.add(name); + } + } + return [...tools]; +} + +function pythonTomlArraySections(source: string, keys: string[]): string[] { + const sections: string[] = []; + for (const key of keys) { + const escaped = key.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); + for (const match of source.matchAll(new RegExp(`^\\s*${escaped}\\s*=\\s*\\[`, "gmu"))) { + sections.push(readTomlBracketValue(source, match.index + match[0].lastIndexOf("["))); + } + } + return sections; +} + +function pythonTomlTables(source: string, names: string[]): string[] { + const tables: string[] = []; + for (const name of names) { + const escaped = name.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); + const pattern = new RegExp(`^\\s*\\[${escaped}\\]\\s*$`, "gmu"); + for (const match of source.matchAll(pattern)) { + const start = match.index + match[0].length; + const rest = source.slice(start); + const next = /^\s*\[[^\]]+\]\s*$/mu.exec(rest); + tables.push(next?.index === undefined ? rest : rest.slice(0, next.index)); + } + } + return tables; +} + +function pythonTomlAssignedValues(source: string): string[] { + const values: string[] = []; + for (const line of source.split("\n")) { + const match = /^\s*["']?[^"'=\s]+["']?\s*=\s*(.+?)\s*(?:#.*)?$/u.exec(line); + if (match?.[1] !== undefined) { + values.push(...pythonTomlArrayValues(match[1])); + const stringValue = /^["']([^"']+)["']/u.exec(match[1])?.[1]; + if (stringValue !== undefined) { + values.push(stringValue); + } + } + } + return values; +} + +function pythonTomlAssignedKeysAndValues(source: string): string[] { + const values = pythonTomlAssignedValues(source); + for (const line of source.split("\n")) { + const key = /^\s*["']?([^"'=\s]+)["']?\s*=/u.exec(line)?.[1]; + if (key !== undefined) { + values.push(key); + } + } + return values; +} + +function pythonTomlArrayValues(source: string): string[] { + return [...source.matchAll(/(["'])([^"']+)\1/gu)].flatMap((match) => + match[2] === undefined ? [] : [match[2]], + ); +} + +function readTomlBracketValue(source: string, bracketIndex: number): string { + let depth = 0; + let quote: string | null = null; + let escaped = false; + for (let index = bracketIndex; index < source.length; index += 1) { + const char = source[index]; + if (quote !== null) { + if (escaped) { + escaped = false; + } else if (char === "\\") { + escaped = true; + } else if (char === quote) { + quote = null; + } + continue; + } + if (char === '"' || char === "'") { + quote = char; + } else if (char === "[") { + depth += 1; + } else if (char === "]") { + depth -= 1; + if (depth === 0) { + return source.slice(bracketIndex, index + 1); + } + } + } + return source.slice(bracketIndex); +} + +function pythonRequirementNames(source: string): string[] { + return source + .split("\n") + .map((line) => pythonRequirementName(line)) + .filter((name): name is string => name !== null); +} + +function pythonRequirementName(value: string): string | null { + const trimmed = value.trim().replace(/^["']|["']$/gu, ""); + if (trimmed.length === 0 || trimmed.startsWith("#") || trimmed.startsWith("-")) { + return null; + } + const match = /^([A-Za-z0-9_.-]+)/u.exec(trimmed); + return match?.[1]?.toLowerCase().replace(/_/gu, "-") ?? null; +} + +async function containsPythonTestFile(root: string, maxDepth: number): Promise { + return containsFileMatching( + root, + maxDepth, + (entry) => /^test_.+\.py$/u.test(entry) || entry.endsWith("_test.py"), + ); +} + async function hasSwiftTests(root: string): Promise { if (!(await pathExists(join(root, "Package.swift")))) { return false; @@ -278,13 +563,19 @@ async function detectLanguages(root: string): Promise { ["rust", "Cargo.toml"], ["swift", "Package.swift"], ["python", "pyproject.toml"], + ["python", "setup.py"], + ["python", "setup.cfg"], + ["python", "requirements.txt"], ]; const languages: string[] = []; for (const [language, file] of checks) { - if (await pathExists(join(root, file))) { + if ((await pathExists(join(root, file))) && !languages.includes(language)) { languages.push(language); } } + if (!languages.includes("python") && (await containsReviewablePythonFile(root))) { + languages.push("python"); + } if ( !languages.includes("swift") && ((await containsFileNamed(root, "Package.swift", 5)) || @@ -302,6 +593,25 @@ async function detectLanguages(root: string): Promise { return languages; } +async function isPythonProject(root: string): Promise { + return ( + (await pathExists(join(root, "pyproject.toml"))) || + (await pathExists(join(root, "setup.py"))) || + (await pathExists(join(root, "setup.cfg"))) || + (await pathExists(join(root, "requirements.txt"))) || + (await containsReviewablePythonFile(root)) + ); +} + +async function containsReviewablePythonFile(root: string): Promise { + for (const prefix of ["src", "app", "apps", "lib", "scripts"]) { + if (await containsFileWithExtension(join(root, prefix), ".py", 4)) { + return true; + } + } + return containsFileNamed(root, "__init__.py", 3); +} + async function containsFileNamed(root: string, name: string, maxDepth: number): Promise { return containsFileMatching(root, maxDepth, (entry) => entry === name); } @@ -338,6 +648,12 @@ async function containsFileMatching( ".git", ".clawpatch", ".worktrees", + ".venv", + "venv", + "__pycache__", + ".mypy_cache", + ".ruff_cache", + ".pytest_cache", "fixtures", "__fixtures__", "testdata", diff --git a/src/mapper.test.ts b/src/mapper.test.ts index 5c6c4dc..69203dc 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -815,6 +815,142 @@ let package = Package(name: "HybridApp", targets: [.target(name: "HybridApp")]) ]); }); + it("maps Python project metadata, console scripts, source groups, and tests", async () => { + const root = await fixtureRoot("clawpatch-python-map-"); + await writeFixture( + root, + "pyproject.toml", + '[project]\nname = "py-tool"\ndependencies = ["pytest", "ruff"]\n\n[project.scripts]\npytool = "py_tool.cli:main"\n', + ); + await writeFixture(root, "uv.lock", ""); + await writeFixture(root, "src/py_tool/__init__.py", ""); + await writeFixture(root, "src/py_tool/cli.py", "def main():\n pass\n"); + await writeFixture(root, "src/py_tool/store.py", "def get():\n pass\n"); + await writeFixture(root, "src/py_tool/store_test.py", "def test_get():\n pass\n"); + await writeFixture(root, "src/py_tool/generated_pb2.py", "generated = True\n"); + await writeFixture(root, ".venv/lib/site-packages/dep.py", "ignored = True\n"); + await writeFixture(root, "tests/test_cli.py", "def test_cli():\n pass\n"); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const titles = result.features.map((feature) => feature.title); + const cli = result.features.find((feature) => feature.title === "Python CLI command pytool"); + const source = result.features.find((feature) => feature.title === "Python source src"); + + expect(project.detected.languages).toContain("python"); + expect(project.detected.packageManagers).toContain("uv"); + expect(project.detected.commands.test).toBe("uv run pytest"); + expect(project.detected.commands.lint).toBe("uv run ruff check ."); + expect(project.detected.commands.format).toBe("uv run ruff format --check ."); + expect(titles).toContain("Python project py-tool"); + expect(titles).toContain("Python CLI command pytool"); + expect(titles).toContain("Python test suite tests"); + expect(cli?.entrypoints[0]?.path).toBe("src/py_tool/cli.py"); + expect(cli?.entrypoints[0]?.symbol).toBe("main"); + expect(cli?.tests).toEqual([ + { path: "src/py_tool/store_test.py", command: "uv run pytest" }, + { path: "tests/test_cli.py", command: "uv run pytest" }, + ]); + expect(source?.ownedFiles.map((file) => file.path).toSorted()).toEqual([ + "src/py_tool/__init__.py", + "src/py_tool/cli.py", + "src/py_tool/store.py", + ]); + expect(source?.ownedFiles.map((file) => file.path)).not.toContain( + "src/py_tool/generated_pb2.py", + ); + }); + + it("resolves Python console scripts and tests from non-src package roots", async () => { + const root = await fixtureRoot("clawpatch-python-roots-"); + await writeFixture( + root, + "pyproject.toml", + '[project]\nname = "rooted"\ndependencies = ["pytest"]\n\n[project.scripts]\nrooted = "rooted.cli:main"\nlibbed = "libbed.cli:main"\n', + ); + await writeFixture(root, "rooted/__init__.py", ""); + await writeFixture(root, "rooted/cli.py", "def main():\n pass\n"); + await writeFixture(root, "rooted/test_cli.py", "def test_cli():\n pass\n"); + await writeFixture(root, "lib/libbed/__init__.py", ""); + await writeFixture(root, "lib/libbed/cli.py", "def main():\n pass\n"); + await writeFixture(root, "lib/libbed/test_cli.py", "def test_cli():\n pass\n"); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const rooted = result.features.find((feature) => feature.title === "Python CLI command rooted"); + const libbed = result.features.find((feature) => feature.title === "Python CLI command libbed"); + + expect(rooted?.entrypoints[0]?.path).toBe("rooted/cli.py"); + expect(rooted?.tests).toEqual([{ path: "rooted/test_cli.py", command: "pytest" }]); + expect(libbed?.entrypoints[0]?.path).toBe("lib/libbed/cli.py"); + expect(libbed?.tests).toEqual([{ path: "lib/libbed/test_cli.py", command: "pytest" }]); + }); + + it("detects Python projects and conservative command defaults", async () => { + const uvRoot = await fixtureRoot("clawpatch-python-uv-"); + await writeFixture( + uvRoot, + "pyproject.toml", + '[project]\nname = "uv-app"\ndependencies = ["pytest", "pyright"]\n', + ); + await writeFixture(uvRoot, "uv.lock", ""); + expect((await detectProject(uvRoot)).detected.commands).toMatchObject({ + typecheck: "uv run pyright", + test: "uv run pytest", + }); + + const poetryRoot = await fixtureRoot("clawpatch-python-poetry-"); + await writeFixture( + poetryRoot, + "pyproject.toml", + '[tool.poetry]\nname = "poetry-app"\n\n[tool.poetry.dependencies]\npython = "^3.12"\npytest = "^8"\nmypy = "^1"\n', + ); + await writeFixture(poetryRoot, "poetry.lock", ""); + expect((await detectProject(poetryRoot)).detected.commands).toMatchObject({ + typecheck: "poetry run mypy .", + test: "poetry run pytest", + }); + + const pdmRoot = await fixtureRoot("clawpatch-python-pdm-"); + await writeFixture(pdmRoot, "requirements.txt", "pytest\nruff\n"); + await writeFixture(pdmRoot, "pdm.lock", ""); + expect((await detectProject(pdmRoot)).detected.commands).toMatchObject({ + typecheck: "pdm run ruff check .", + lint: "pdm run ruff check .", + test: "pdm run pytest", + }); + + const directRoot = await fixtureRoot("clawpatch-python-direct-"); + await writeFixture(directRoot, "setup.py", "from setuptools import setup\n"); + await writeFixture(directRoot, "tests/test_app.py", "def test_app():\n pass\n"); + expect((await detectProject(directRoot)).detected.commands.test).toBe("pytest"); + + const nullRoot = await fixtureRoot("clawpatch-python-null-"); + await writeFixture(nullRoot, "src/app/main.py", "def main():\n pass\n"); + const nullProject = await detectProject(nullRoot); + expect(nullProject.detected.languages).toContain("python"); + expect(nullProject.detected.packageManagers).toContain("python"); + expect(nullProject.detected.commands).toEqual({ + typecheck: null, + lint: null, + format: null, + test: null, + }); + + const groupNameRoot = await fixtureRoot("clawpatch-python-group-names-"); + await writeFixture( + groupNameRoot, + "pyproject.toml", + '[project]\nname = "groups"\n\n[project.optional-dependencies]\npytest = ["httpx"]\nruff = ["typing-extensions"]\n', + ); + expect((await detectProject(groupNameRoot)).detected.commands).toEqual({ + typecheck: null, + lint: null, + format: null, + test: null, + }); + }); + it("keeps Node scripts and native defaults in mixed package repos", async () => { const root = await fixtureRoot("clawpatch-mixed-map-"); await writeFixture( @@ -827,15 +963,23 @@ let package = Package(name: "HybridApp", targets: [.target(name: "HybridApp")]) await writeFixture(root, "Cargo.toml", '[package]\nname = "mixed"\n'); await writeFixture(root, "src/lib.rs", "pub fn run() {}\n"); await writeFixture(root, "tests/integration.rs", "#[test]\nfn works() {}\n"); + await writeFixture( + root, + "pyproject.toml", + '[project]\nname = "mixed-py"\ndependencies = ["pytest"]\n', + ); + await writeFixture(root, "scripts/tool.py", "def main():\n pass\n"); const project = await detectProject(root); const result = await mapFeatures(root, project, []); - expect(project.detected.packageManagers).toEqual(["node", "cargo"]); + expect(project.detected.packageManagers).toEqual(["node", "cargo", "python"]); + expect(project.detected.languages).toContain("python"); expect(project.detected.commands.typecheck).toBe("go test ./..."); expect(project.detected.commands.lint).toBe("npm run lint"); expect(project.detected.commands.format).toBeNull(); expect(project.detected.commands.test).toBe("go test ./..."); + expect(result.features.map((feature) => feature.title)).toContain("Python project mixed-py"); expect( result.features.find((feature) => feature.title === "Rust library mixed")?.tests, ).toEqual([{ path: "tests/integration.rs", command: "cargo test --workspace" }]); diff --git a/src/mapper.ts b/src/mapper.ts index 8bc81b9..897089f 100644 --- a/src/mapper.ts +++ b/src/mapper.ts @@ -6,6 +6,7 @@ import { appleSeeds } from "./mappers/apple.js"; import { gradleSeeds } from "./mappers/gradle.js"; import { nextSeeds } from "./mappers/next.js"; import { nodeSeeds } from "./mappers/node.js"; +import { pythonSeeds } from "./mappers/python.js"; import { rustSeeds } from "./mappers/rust.js"; import { nearbyTests } from "./mappers/shared.js"; import { swiftSeeds } from "./mappers/swift.js"; @@ -23,6 +24,7 @@ const featureMappers: FeatureMapper[] = [ { name: "node", map: nodeSeeds }, { name: "next", map: nextSeeds }, { name: "go", map: goSeeds }, + { name: "python", map: pythonSeeds }, { name: "rust", map: rustSeeds }, { name: "swift", map: swiftSeeds }, { name: "apple", map: appleSeeds }, diff --git a/src/mappers/python.ts b/src/mappers/python.ts new file mode 100644 index 0000000..9d15287 --- /dev/null +++ b/src/mappers/python.ts @@ -0,0 +1,583 @@ +import { readFile, readdir } from "node:fs/promises"; +import { basename, dirname, join } from "node:path"; +import { pathExists } from "../fs.js"; +import { + isSafeDirectory, + packageKind, + packageTrustBoundaries, + pathMatchesPrefix, + shouldSkip, + walk, +} from "./shared.js"; +import { FeatureSeed, SeedFileRef, SeedTestRef } from "./types.js"; + +type PythonScript = { + name: string; + target: string; +}; + +type SourceGroup = { + label: string; + files: string[]; +}; + +type PyprojectInfo = { + name: string | null; + scripts: PythonScript[]; + hasPytest: boolean; +}; + +const sourceRoots = ["src", "app", "apps", "lib", "scripts"] as const; +const sourceGroupMaxOwnedFiles = 12; +const sourceGroupMaxTests = 8; + +export async function pythonSeeds(root: string): Promise { + if (!(await isPythonProject(root))) { + return []; + } + const pyproject = await readPyproject(root); + const testCommand = await pythonTestCommand(root, pyproject); + const testFiles = await pythonTestFiles(root); + const seeds: FeatureSeed[] = []; + + if (await pathExists(join(root, "pyproject.toml"))) { + seeds.push({ + title: `Python project ${pyproject.name ?? basename(root)}`, + summary: "Python project metadata in pyproject.toml.", + kind: packageKind(pyproject.name ?? basename(root)), + source: "python-project", + confidence: "medium", + entryPath: "pyproject.toml", + symbol: pyproject.name, + route: null, + command: null, + ownedFiles: [{ path: "pyproject.toml", reason: "python project metadata" }], + contextFiles: await pythonProjectContextFiles(root), + tags: ["python", "package"], + trustBoundaries: packageTrustBoundaries(pyproject.name ?? basename(root)), + skipNearbyTests: true, + }); + } + + for (const script of pyproject.scripts) { + const resolved = await resolvePythonScript(root, script.target); + const tests = + resolved.entryPath === "pyproject.toml" + ? [] + : associatedTests([resolved.entryPath], testFiles, testCommand); + seeds.push({ + title: `Python CLI command ${script.name}`, + summary: + resolved.entryPath === "pyproject.toml" + ? `Python console script '${script.name}' targets ${script.target}.` + : `Python console script '${script.name}' targets ${script.target}, source ${resolved.entryPath}.`, + kind: "cli-command", + source: "python-console-script", + confidence: resolved.entryPath === "pyproject.toml" ? "medium" : "high", + entryPath: resolved.entryPath, + symbol: resolved.symbol, + route: null, + command: script.name, + ownedFiles: + resolved.entryPath === "pyproject.toml" + ? [{ path: "pyproject.toml", reason: "console script metadata" }] + : [{ path: resolved.entryPath, reason: "console script source" }], + contextFiles: tests.map((test) => ({ path: test.path, reason: "associated test" })), + tests, + tags: ["python", "cli"], + trustBoundaries: ["user-input", "filesystem", "process-exec"], + testCommand, + skipNearbyTests: true, + }); + } + + for (const group of await pythonSourceGroups(root)) { + const tests = associatedTests(group.files, testFiles, testCommand); + seeds.push({ + title: `Python source ${group.label}`, + summary: + group.files.length === 1 + ? `Python source file ${group.files[0]}.` + : `Python source group ${group.label} with ${group.files.length} files.`, + kind: packageKind(group.label), + source: "python-source-group", + confidence: "medium", + entryPath: group.files[0] ?? group.label, + symbol: group.label, + 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" })), + tests, + tags: ["python", "source-group"], + trustBoundaries: packageTrustBoundaries(group.label), + testCommand, + skipNearbyTests: true, + }); + } + + for (const test of standaloneTestSuites(testFiles, testCommand)) { + seeds.push(test); + } + + return seeds; +} + +async function isPythonProject(root: string): Promise { + return ( + (await pathExists(join(root, "pyproject.toml"))) || + (await pathExists(join(root, "setup.py"))) || + (await pathExists(join(root, "setup.cfg"))) || + (await pathExists(join(root, "requirements.txt"))) || + (await pythonSourceGroups(root)).length > 0 + ); +} + +async function readPyproject(root: string): Promise { + if (!(await pathExists(join(root, "pyproject.toml")))) { + return { name: null, scripts: [], hasPytest: false }; + } + const source = await readFile(join(root, "pyproject.toml"), "utf8"); + return { + name: + tomlStringValue(table(source, "project"), "name") ?? + tomlStringValue(table(source, "tool.poetry"), "name"), + scripts: [ + ...scriptsFromTable(table(source, "project.scripts")), + ...scriptsFromTable(table(source, "tool.poetry.scripts")), + ], + hasPytest: + table(source, "tool.pytest.ini_options").length > 0 || dependencyNames(source).has("pytest"), + }; +} + +async function pythonTestCommand(root: string, pyproject: PyprojectInfo): Promise { + if ( + !pyproject.hasPytest && + !(await dependencyFileHas(root, "pytest")) && + (await pythonTestFiles(root)).length === 0 + ) { + return null; + } + if (await pathExists(join(root, "uv.lock"))) { + return "uv run pytest"; + } + if (await pathExists(join(root, "poetry.lock"))) { + return "poetry run pytest"; + } + if (await pathExists(join(root, "pdm.lock"))) { + return "pdm run pytest"; + } + return "pytest"; +} + +async function dependencyFileHas(root: string, dependency: string): Promise { + for (const file of ["requirements.txt", "setup.cfg"]) { + if (!(await pathExists(join(root, file)))) { + continue; + } + const source = await readFile(join(root, file), "utf8"); + if (requirementNames(source).has(dependency)) { + return true; + } + } + return false; +} + +async function pythonSourceGroups(root: string): Promise { + const groups: SourceGroup[] = []; + const seenRoots = new Set(); + for (const sourceRoot of await pythonSourceRoots(root)) { + if (seenRoots.has(sourceRoot)) { + continue; + } + seenRoots.add(sourceRoot); + const files = (await walk(root, [sourceRoot])).filter(isReviewablePythonSourceFile); + for (const group of partitionSourceFiles(sourceRoot, files, sourceGroupMaxOwnedFiles)) { + groups.push(group); + } + } + return groups; +} + +async function pythonSourceRoots(root: string): Promise { + const roots: string[] = []; + for (const sourceRoot of sourceRoots) { + if (await isSafeDirectory(root, join(root, sourceRoot))) { + roots.push(sourceRoot); + } + } + for (const entry of await readdir(root).catch(() => [])) { + const packageRoot = join(root, entry); + if ( + !pythonShouldSkip(entry) && + (await isSafeDirectory(root, packageRoot)) && + (await pathExists(join(packageRoot, "__init__.py"))) + ) { + roots.push(entry); + } + } + return roots.toSorted(); +} + +async function pythonTestFiles(root: string): Promise { + return (await walk(root, ["tests", "test", ...(await pythonSourceRoots(root))])) + .filter(isPythonTestPath) + .filter((path) => !pythonShouldSkip(path)) + .slice(0, 200); +} + +async function pythonProjectContextFiles(root: string): Promise { + const refs: SeedFileRef[] = []; + for (const path of ["requirements.txt", "setup.cfg", "setup.py", "README.md"]) { + if (await pathExists(join(root, path))) { + refs.push({ path, reason: "python project context" }); + } + } + return refs; +} + +async function resolvePythonScript( + root: string, + target: string, +): Promise<{ entryPath: string; symbol: string | null }> { + const [moduleName, symbol = null] = target.split(":"); + if (moduleName === undefined || moduleName.length === 0) { + return { entryPath: "pyproject.toml", symbol }; + } + const modulePath = `${moduleName.replace(/\./gu, "/")}.py`; + const packageInitPath = `${moduleName.replace(/\./gu, "/")}/__init__.py`; + const candidates = new Set([modulePath, packageInitPath]); + for (const sourceRoot of await pythonSourceRoots(root)) { + candidates.add(`${sourceRoot}/${modulePath}`); + candidates.add(`${sourceRoot}/${packageInitPath}`); + } + for (const candidate of candidates) { + if (await pathExists(join(root, candidate))) { + return { entryPath: candidate, symbol }; + } + } + return { entryPath: "pyproject.toml", symbol }; +} + +function standaloneTestSuites(testFiles: string[], command: string | null): FeatureSeed[] { + if (testFiles.length === 0) { + return []; + } + return partitionSourceFiles("tests", testFiles, sourceGroupMaxOwnedFiles).map((group) => ({ + title: `Python test suite ${group.label}`, + summary: `Python pytest files in ${group.label}.`, + kind: "test-suite", + source: "python-test-suite", + confidence: "medium", + entryPath: group.files[0] ?? group.label, + symbol: group.label, + route: null, + command: null, + ownedFiles: group.files.map((path) => ({ path, reason: "pytest file" })), + contextFiles: [], + tests: group.files.map((path) => ({ path, command })), + tags: ["python", "test"], + trustBoundaries: [], + testCommand: command, + skipNearbyTests: true, + })); +} + +function partitionSourceFiles( + sourceRoot: string, + files: string[], + maxFiles: number, +): SourceGroup[] { + return partitionAt(sourceRoot, files.toSorted(), maxFiles, 0); +} + +function partitionAt( + sourceRoot: string, + files: string[], + maxFiles: number, + depth: number, +): SourceGroup[] { + if (files.length === 0) { + return []; + } + if (files.length <= maxFiles) { + return [{ label: commonLabel(sourceRoot, files, depth), files }]; + } + const directFiles: string[] = []; + const buckets = new Map(); + for (const file of files) { + const relativePath = file.slice(sourceRoot.length + 1); + const parts = relativePath.split("/"); + if (parts.length <= depth + 1) { + directFiles.push(file); + continue; + } + const segment = parts[depth]; + if (segment === undefined) { + directFiles.push(file); + continue; + } + const bucket = buckets.get(segment) ?? []; + bucket.push(file); + buckets.set(segment, bucket); + } + const groups = chunkFiles(currentLabel(sourceRoot, files, depth), directFiles, maxFiles); + for (const [segment, bucketFiles] of [...buckets.entries()].toSorted(([left], [right]) => + left.localeCompare(right), + )) { + if (bucketFiles.length <= maxFiles) { + groups.push({ + label: `${sourceRoot}/${bucketPrefix(bucketFiles, sourceRoot, depth, segment)}`, + files: bucketFiles, + }); + } else { + groups.push(...partitionAt(sourceRoot, bucketFiles, maxFiles, depth + 1)); + } + } + return groups; +} + +function chunkFiles(label: string, files: string[], maxFiles: number): SourceGroup[] { + const groups: SourceGroup[] = []; + for (let index = 0; index < files.length; index += maxFiles) { + const part = Math.floor(index / maxFiles) + 1; + groups.push({ + label: files.length <= maxFiles ? label : `${label}#${part}`, + files: files.slice(index, index + maxFiles), + }); + } + return groups; +} + +function currentLabel(sourceRoot: string, files: string[], depth: number): string { + if (depth === 0) { + return sourceRoot; + } + const first = files[0]; + if (first === undefined) { + return sourceRoot; + } + const parts = first + .slice(sourceRoot.length + 1) + .split("/") + .slice(0, depth); + return parts.length === 0 ? sourceRoot : `${sourceRoot}/${parts.join("/")}`; +} + +function commonLabel(sourceRoot: string, files: string[], depth: number): string { + if (depth === 0 || files.length === 1) { + return files.length === 1 ? (files[0] ?? sourceRoot) : sourceRoot; + } + return currentLabel(sourceRoot, files, depth); +} + +function bucketPrefix(files: string[], sourceRoot: string, depth: number, segment: string): string { + const first = files[0]; + if (first === undefined || depth === 0) { + return segment; + } + const parts = first + .slice(sourceRoot.length + 1) + .split("/") + .slice(0, depth); + return [...parts, segment].join("/"); +} + +function associatedTests(files: string[], tests: string[], command: string | null): SeedTestRef[] { + const fileStems = new Set(files.map((file) => basename(file).replace(/\.py$/u, ""))); + const dirs = new Set(files.map((file) => dirname(file))); + return tests + .filter((test) => { + const testStem = basename(test) + .replace(/^test_/u, "") + .replace(/_test\.py$/u, "") + .replace(/\.py$/u, ""); + return ( + [...dirs].some((dir) => pathMatchesPrefix(test, dir)) || + (fileStems.has(testStem) && /^(tests?|__tests__)\//u.test(test)) + ); + }) + .slice(0, sourceGroupMaxTests) + .map((path) => ({ path, command })); +} + +function isReviewablePythonSourceFile(path: string): boolean { + return ( + path.endsWith(".py") && + !isPythonTestPath(path) && + !pythonShouldSkip(path) && + !/(^|\/)(__fixtures__|fixtures|testdata)(\/|$)/u.test(path) && + !/(^|\/)[^/]*(?:generated|_pb2|_pb2_grpc|\.gen)\.py$/iu.test(path) + ); +} + +function isPythonTestPath(path: string): boolean { + return ( + path.endsWith(".py") && + (/(^|\/)tests?\//u.test(path) || + /(^|\/)test_[^/]+\.py$/u.test(path) || + path.endsWith("_test.py")) + ); +} + +function pythonShouldSkip(path: string): boolean { + return ( + shouldSkip(path) || + /(^|\/)(\.venv|venv|__pycache__|\.mypy_cache|\.ruff_cache|\.pytest_cache)(\/|$)/u.test(path) + ); +} + +function table(source: string, name: string): string { + const escapedName = name.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); + const match = new RegExp(`^\\s*\\[${escapedName}\\]\\s*$`, "mu").exec(source); + if (match?.index === undefined) { + return ""; + } + const rest = source.slice(match.index + match[0].length); + const nextSection = /^\s*\[[^\]]+\]\s*$/mu.exec(rest); + return nextSection?.index === undefined ? rest : rest.slice(0, nextSection.index); +} + +function tomlStringValue(source: string, key: string): string | null { + const escapedKey = key.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); + return new RegExp(`^\\s*${escapedKey}\\s*=\\s*(["'])([^"']+)\\1`, "mu").exec(source)?.[2] ?? null; +} + +function scriptsFromTable(source: string): PythonScript[] { + const scripts: PythonScript[] = []; + for (const line of source.split("\n")) { + const match = /^\s*["']?([^"'=\s]+)["']?\s*=\s*(["'])([^"']+)\2/u.exec(line); + if (match?.[1] !== undefined && match[3] !== undefined) { + scripts.push({ name: match[1], target: match[3] }); + } + } + return scripts; +} + +function dependencyNames(source: string): Set { + const names = new Set(); + for (const array of tomlArrayAssignments(source, ["dependencies", "dev-dependencies"])) { + for (const value of arrayValues(array)) { + const name = requirementName(value); + if (name !== null) { + names.add(name); + } + } + } + for (const dependencyTable of [ + table(source, "tool.poetry.dependencies"), + table(source, "tool.poetry.dev-dependencies"), + table(source, "tool.poetry.group.dev.dependencies"), + ]) { + for (const value of assignedKeysAndValues(dependencyTable)) { + const name = requirementName(value); + if (name !== null) { + names.add(name); + } + } + } + for (const dependencyTable of [ + table(source, "project.optional-dependencies"), + table(source, "dependency-groups"), + ]) { + for (const value of assignedValues(dependencyTable)) { + const name = requirementName(value); + if (name !== null) { + names.add(name); + } + } + } + return names; +} + +function tomlArrayAssignments(source: string, keys: string[]): string[] { + const arrays: string[] = []; + for (const key of keys) { + const escaped = key.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); + for (const match of source.matchAll(new RegExp(`^\\s*${escaped}\\s*=\\s*\\[`, "gmu"))) { + arrays.push(readBracketValue(source, match.index + match[0].lastIndexOf("["))); + } + } + return arrays; +} + +function assignedValues(source: string): string[] { + const values: string[] = []; + for (const line of source.split("\n")) { + const match = /^\s*["']?[^"'=\s]+["']?\s*=\s*(.+?)\s*(?:#.*)?$/u.exec(line); + if (match?.[1] !== undefined) { + values.push(...arrayValues(match[1])); + const stringValue = /^["']([^"']+)["']/u.exec(match[1])?.[1]; + if (stringValue !== undefined) { + values.push(stringValue); + } + } + } + return values; +} + +function assignedKeysAndValues(source: string): string[] { + const values = assignedValues(source); + for (const line of source.split("\n")) { + const key = /^\s*["']?([^"'=\s]+)["']?\s*=/u.exec(line)?.[1]; + if (key !== undefined) { + values.push(key); + } + } + return values; +} + +function arrayValues(source: string): string[] { + return [...source.matchAll(/(["'])([^"']+)\1/gu)].flatMap((match) => + match[2] === undefined ? [] : [match[2]], + ); +} + +function readBracketValue(source: string, bracketIndex: number): string { + let depth = 0; + let quote: string | null = null; + let escaped = false; + for (let index = bracketIndex; index < source.length; index += 1) { + const char = source[index]; + if (quote !== null) { + if (escaped) { + escaped = false; + } else if (char === "\\") { + escaped = true; + } else if (char === quote) { + quote = null; + } + continue; + } + if (char === '"' || char === "'") { + quote = char; + } else if (char === "[") { + depth += 1; + } else if (char === "]") { + depth -= 1; + if (depth === 0) { + return source.slice(bracketIndex, index + 1); + } + } + } + return source.slice(bracketIndex); +} + +function requirementNames(source: string): Set { + return new Set( + source + .split("\n") + .map((line) => requirementName(line)) + .filter((name): name is string => name !== null), + ); +} + +function requirementName(value: string): string | null { + const trimmed = value.trim().replace(/^["']|["']$/gu, ""); + if (trimmed.length === 0 || trimmed.startsWith("#") || trimmed.startsWith("-")) { + return null; + } + const match = /^([A-Za-z0-9_.-]+)/u.exec(trimmed); + return match?.[1]?.toLowerCase().replace(/_/gu, "-") ?? null; +} diff --git a/src/mappers/shared.ts b/src/mappers/shared.ts index ff02970..cf27a9a 100644 --- a/src/mappers/shared.ts +++ b/src/mappers/shared.ts @@ -72,11 +72,14 @@ export async function walk(root: string, prefixes: string[]): Promise if (!(await pathExists(start))) { continue; } - const info = await lstat(start); - if (info.isSymbolicLink()) { + let info = await lstat(start); + const canonicalStart = await realpath(start).catch(() => start); + if (info.isSymbolicLink() && prefix !== "") { continue; } - const canonicalStart = await realpath(start).catch(() => start); + if (info.isSymbolicLink()) { + info = await lstat(canonicalStart).catch(() => info); + } if (!pathInsideRoot(realRoot, canonicalStart)) { continue; } From 8528d7a7e3c4d2e91fb542985bd2360a77e59e3b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 16 May 2026 03:20:07 +0100 Subject: [PATCH 02/18] fix: cover python root tests and dependency groups --- src/detect.ts | 23 +++++++++++++++-------- src/mapper.test.ts | 28 ++++++++++++++++++++++++++++ src/mappers/python.ts | 42 +++++++++++++++++++++++++++++++----------- 3 files changed, 74 insertions(+), 19 deletions(-) diff --git a/src/detect.ts b/src/detect.ts index 6317c0d..77056c9 100644 --- a/src/detect.ts +++ b/src/detect.ts @@ -411,14 +411,21 @@ function pythonTomlTables(source: string, names: string[]): string[] { function pythonTomlAssignedValues(source: string): string[] { const values: string[] = []; - for (const line of source.split("\n")) { - const match = /^\s*["']?[^"'=\s]+["']?\s*=\s*(.+?)\s*(?:#.*)?$/u.exec(line); - if (match?.[1] !== undefined) { - values.push(...pythonTomlArrayValues(match[1])); - const stringValue = /^["']([^"']+)["']/u.exec(match[1])?.[1]; - if (stringValue !== undefined) { - values.push(stringValue); - } + for (const match of source.matchAll(/^\s*["']?[^"'=\s]+["']?\s*=\s*/gmu)) { + if (match.index === undefined) { + continue; + } + const valueStart = match.index + match[0].length; + const lineEnd = source.indexOf("\n", valueStart); + const rawValue = source.slice(valueStart, lineEnd === -1 ? source.length : lineEnd).trim(); + if (rawValue.startsWith("[")) { + values.push(...pythonTomlArrayValues(readTomlBracketValue(source, valueStart))); + continue; + } + values.push(...pythonTomlArrayValues(rawValue)); + const stringValue = /^["']([^"']+)["']/u.exec(rawValue)?.[1]; + if (stringValue !== undefined) { + values.push(stringValue); } } return values; diff --git a/src/mapper.test.ts b/src/mapper.test.ts index e1c3967..0a75cbd 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -1047,6 +1047,34 @@ let package = Package(name: "HybridApp", targets: [.target(name: "HybridApp")]) format: null, test: null, }); + + const dependencyGroupRoot = await fixtureRoot("clawpatch-python-dependency-groups-"); + await writeFixture( + dependencyGroupRoot, + "pyproject.toml", + '[project]\nname = "dependency-groups"\n\n[dependency-groups]\ndev = [\n "pytest",\n "ruff",\n]\n', + ); + expect((await detectProject(dependencyGroupRoot)).detected.commands).toMatchObject({ + lint: "ruff check .", + format: "ruff format --check .", + test: "pytest", + }); + }); + + it("maps root-level Python pytest files", async () => { + const root = await fixtureRoot("clawpatch-python-root-tests-"); + await writeFixture(root, "pyproject.toml", '[project]\nname = "root-tests"\n'); + await writeFixture(root, "test_app.py", "def test_app():\n pass\n"); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const suite = result.features.find( + (feature) => feature.title === "Python test suite test_app.py", + ); + + expect(project.detected.commands.test).toBe("pytest"); + expect(suite?.ownedFiles).toEqual([{ path: "test_app.py", reason: "pytest file" }]); + expect(suite?.tests).toEqual([{ path: "test_app.py", command: "pytest" }]); }); it("keeps Node scripts and native defaults in mixed package repos", async () => { diff --git a/src/mappers/python.ts b/src/mappers/python.ts index 9d15287..0012b03 100644 --- a/src/mappers/python.ts +++ b/src/mappers/python.ts @@ -221,10 +221,19 @@ async function pythonSourceRoots(root: string): Promise { } async function pythonTestFiles(root: string): Promise { - return (await walk(root, ["tests", "test", ...(await pythonSourceRoots(root))])) + const rootTests = await rootPythonTestFiles(root); + const nestedTests = (await walk(root, ["tests", "test", ...(await pythonSourceRoots(root))])) .filter(isPythonTestPath) - .filter((path) => !pythonShouldSkip(path)) - .slice(0, 200); + .filter((path) => !pythonShouldSkip(path)); + return uniquePaths([...rootTests, ...nestedTests]).slice(0, 200); +} + +async function rootPythonTestFiles(root: string): Promise { + const entries = await readdir(root, { withFileTypes: true }).catch(() => []); + return entries + .filter((entry) => entry.isFile() && isPythonTestPath(entry.name)) + .map((entry) => entry.name) + .toSorted(); } async function pythonProjectContextFiles(root: string): Promise { @@ -504,14 +513,21 @@ function tomlArrayAssignments(source: string, keys: string[]): string[] { function assignedValues(source: string): string[] { const values: string[] = []; - for (const line of source.split("\n")) { - const match = /^\s*["']?[^"'=\s]+["']?\s*=\s*(.+?)\s*(?:#.*)?$/u.exec(line); - if (match?.[1] !== undefined) { - values.push(...arrayValues(match[1])); - const stringValue = /^["']([^"']+)["']/u.exec(match[1])?.[1]; - if (stringValue !== undefined) { - values.push(stringValue); - } + for (const match of source.matchAll(/^\s*["']?[^"'=\s]+["']?\s*=\s*/gmu)) { + if (match.index === undefined) { + continue; + } + const valueStart = match.index + match[0].length; + const lineEnd = source.indexOf("\n", valueStart); + const rawValue = source.slice(valueStart, lineEnd === -1 ? source.length : lineEnd).trim(); + if (rawValue.startsWith("[")) { + values.push(...arrayValues(readBracketValue(source, valueStart))); + continue; + } + values.push(...arrayValues(rawValue)); + const stringValue = /^["']([^"']+)["']/u.exec(rawValue)?.[1]; + if (stringValue !== undefined) { + values.push(stringValue); } } return values; @@ -581,3 +597,7 @@ function requirementName(value: string): string | null { const match = /^([A-Za-z0-9_.-]+)/u.exec(trimmed); return match?.[1]?.toLowerCase().replace(/_/gu, "-") ?? null; } + +function uniquePaths(paths: string[]): string[] { + return [...new Set(paths)]; +} From 65c5339720411111a1713eec9fcd1246d428ff5e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 16 May 2026 03:28:56 +0100 Subject: [PATCH 03/18] fix: stabilize python mapper seeds --- src/mapper.test.ts | 36 +++++++++++++++++++++++++++++++++ src/mappers/python.ts | 47 ++++++++++++++++++++++++++++++++++--------- 2 files changed, 73 insertions(+), 10 deletions(-) diff --git a/src/mapper.test.ts b/src/mapper.test.ts index 0a75cbd..66bd84c 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -1077,6 +1077,42 @@ let package = Package(name: "HybridApp", targets: [.target(name: "HybridApp")]) expect(suite?.tests).toEqual([{ path: "test_app.py", command: "pytest" }]); }); + it("maps Python metadata-only projects without pyproject", async () => { + const root = await fixtureRoot("clawpatch-python-legacy-metadata-"); + await writeFixture(root, "setup.cfg", "[metadata]\nname = legacy\n"); + await writeFixture(root, "requirements.txt", "pytest\n"); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const metadata = result.features.find((feature) => feature.source === "python-project"); + + expect(project.detected.languages).toContain("python"); + expect(metadata?.entrypoints[0]?.path).toBe("setup.cfg"); + expect(metadata?.ownedFiles).toEqual([ + { path: "setup.cfg", reason: "python project metadata" }, + { path: "requirements.txt", reason: "python project metadata" }, + ]); + }); + + it("keeps Python source group ids stable when a root gains files", async () => { + const root = await fixtureRoot("clawpatch-python-stable-source-id-"); + await writeFixture(root, "pyproject.toml", '[project]\nname = "stable-source"\n'); + await writeFixture(root, "scripts/tool.py", "def main():\n pass\n"); + + const project = await detectProject(root); + const first = await mapFeatures(root, project, []); + const firstSource = first.features.find((feature) => feature.title === "Python source scripts"); + await writeFixture(root, "scripts/other.py", "def other():\n pass\n"); + const second = await mapFeatures(root, project, first.features); + const secondSource = second.features.find( + (feature) => feature.title === "Python source scripts", + ); + + expect(firstSource?.featureId).toBeDefined(); + expect(secondSource?.featureId).toBe(firstSource?.featureId); + expect(second.stale).toBe(0); + }); + it("keeps Node scripts and native defaults in mixed package repos", async () => { const root = await fixtureRoot("clawpatch-mixed-map-"); await writeFixture( diff --git a/src/mappers/python.ts b/src/mappers/python.ts index 0012b03..5185708 100644 --- a/src/mappers/python.ts +++ b/src/mappers/python.ts @@ -28,6 +28,12 @@ type PyprojectInfo = { }; const sourceRoots = ["src", "app", "apps", "lib", "scripts"] as const; +const projectMetadataFiles = [ + "pyproject.toml", + "setup.py", + "setup.cfg", + "requirements.txt", +] as const; const sourceGroupMaxOwnedFiles = 12; const sourceGroupMaxTests = 8; @@ -36,23 +42,24 @@ export async function pythonSeeds(root: string): Promise { return []; } const pyproject = await readPyproject(root); + const metadataFiles = await pythonMetadataFiles(root); const testCommand = await pythonTestCommand(root, pyproject); const testFiles = await pythonTestFiles(root); const seeds: FeatureSeed[] = []; - if (await pathExists(join(root, "pyproject.toml"))) { + if (metadataFiles.length > 0) { seeds.push({ title: `Python project ${pyproject.name ?? basename(root)}`, - summary: "Python project metadata in pyproject.toml.", + summary: `Python project metadata in ${metadataFiles.join(", ")}.`, kind: packageKind(pyproject.name ?? basename(root)), source: "python-project", confidence: "medium", - entryPath: "pyproject.toml", + entryPath: metadataFiles[0] ?? "pyproject.toml", symbol: pyproject.name, route: null, command: null, - ownedFiles: [{ path: "pyproject.toml", reason: "python project metadata" }], - contextFiles: await pythonProjectContextFiles(root), + ownedFiles: metadataFiles.map((path) => ({ path, reason: "python project metadata" })), + contextFiles: await pythonProjectContextFiles(root, metadataFiles), tags: ["python", "package"], trustBoundaries: packageTrustBoundaries(pyproject.name ?? basename(root)), skipNearbyTests: true, @@ -102,7 +109,7 @@ export async function pythonSeeds(root: string): Promise { kind: packageKind(group.label), source: "python-source-group", confidence: "medium", - entryPath: group.files[0] ?? group.label, + entryPath: group.label, symbol: group.label, route: null, command: null, @@ -151,6 +158,16 @@ async function readPyproject(root: string): Promise { }; } +async function pythonMetadataFiles(root: string): Promise { + const files: string[] = []; + for (const path of projectMetadataFiles) { + if (await pathExists(join(root, path))) { + files.push(path); + } + } + return files; +} + async function pythonTestCommand(root: string, pyproject: PyprojectInfo): Promise { if ( !pyproject.hasPytest && @@ -236,10 +253,14 @@ async function rootPythonTestFiles(root: string): Promise { .toSorted(); } -async function pythonProjectContextFiles(root: string): Promise { +async function pythonProjectContextFiles( + root: string, + ownedMetadataFiles: readonly string[], +): Promise { const refs: SeedFileRef[] = []; + const owned = new Set(ownedMetadataFiles); for (const path of ["requirements.txt", "setup.cfg", "setup.py", "README.md"]) { - if (await pathExists(join(root, path))) { + if (!owned.has(path) && (await pathExists(join(root, path)))) { refs.push({ path, reason: "python project context" }); } } @@ -375,8 +396,14 @@ function currentLabel(sourceRoot: string, files: string[], depth: number): strin } function commonLabel(sourceRoot: string, files: string[], depth: number): string { - if (depth === 0 || files.length === 1) { - return files.length === 1 ? (files[0] ?? sourceRoot) : sourceRoot; + if (depth === 0) { + const first = files[0]; + return files.length === 1 && first !== undefined && !first.startsWith(`${sourceRoot}/`) + ? first + : sourceRoot; + } + if (files.length === 1) { + return files[0] ?? sourceRoot; } return currentLabel(sourceRoot, files, depth); } From 43b2147fee963b8b315b1a83d7893a1d27739a9e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 16 May 2026 03:36:55 +0100 Subject: [PATCH 04/18] fix: harden python dependency mapping --- src/detect.ts | 68 +++++++++++++++++++++++++++++++++++++------ src/mapper.test.ts | 49 +++++++++++++++++++++++++++++-- src/mappers/python.ts | 63 +++++++++++++++++++++++++++++++++------ 3 files changed, 160 insertions(+), 20 deletions(-) diff --git a/src/detect.ts b/src/detect.ts index 77056c9..7a7cb9e 100644 --- a/src/detect.ts +++ b/src/detect.ts @@ -269,6 +269,9 @@ async function pythonRunner(root: string): Promise { if (await pathExists(join(root, "pdm.lock"))) { return "pdm"; } + if (await pathExists(join(root, "hatch.toml"))) { + return "hatch"; + } return null; } @@ -282,6 +285,9 @@ function pythonRunCommand(runner: string | null, command: string): string { if (runner === "pdm") { return `pdm run ${command}`; } + if (runner === "hatch") { + return `hatch run ${command}`; + } return command; } @@ -338,8 +344,7 @@ function pythonDependencyNames(source: string): string[] { for (const table of pythonTomlTables(source, [ "tool.poetry.dependencies", "tool.poetry.dev-dependencies", - "tool.poetry.group.dev.dependencies", - ])) { + ]).concat(pythonTomlTablesMatching(source, /^tool\.poetry\.group\.[^.]+\.dependencies$/u))) { for (const value of pythonTomlAssignedKeysAndValues(table)) { const name = pythonRequirementName(value); if (name !== null) { @@ -409,6 +414,21 @@ function pythonTomlTables(source: string, names: string[]): string[] { return tables; } +function pythonTomlTablesMatching(source: string, pattern: RegExp): string[] { + const tables: string[] = []; + for (const match of source.matchAll(/^\s*\[([^\]]+)\]\s*$/gmu)) { + const name = match[1]; + if (name === undefined || !pattern.test(name)) { + continue; + } + const start = match.index + match[0].length; + const rest = source.slice(start); + const next = /^\s*\[[^\]]+\]\s*$/mu.exec(rest); + tables.push(next?.index === undefined ? rest : rest.slice(0, next.index)); + } + return tables; +} + function pythonTomlAssignedValues(source: string): string[] { const values: string[] = []; for (const match of source.matchAll(/^\s*["']?[^"'=\s]+["']?\s*=\s*/gmu)) { @@ -423,10 +443,6 @@ function pythonTomlAssignedValues(source: string): string[] { continue; } values.push(...pythonTomlArrayValues(rawValue)); - const stringValue = /^["']([^"']+)["']/u.exec(rawValue)?.[1]; - if (stringValue !== undefined) { - values.push(stringValue); - } } return values; } @@ -443,9 +459,43 @@ function pythonTomlAssignedKeysAndValues(source: string): string[] { } function pythonTomlArrayValues(source: string): string[] { - return [...source.matchAll(/(["'])([^"']+)\1/gu)].flatMap((match) => - match[2] === undefined ? [] : [match[2]], - ); + return pythonTomlStringValues(source); +} + +function pythonTomlStringValues(source: string): string[] { + const values: string[] = []; + let quote: string | null = null; + let value = ""; + let escaped = false; + for (let index = 0; index < source.length; index += 1) { + const char = source[index]; + if (quote !== null) { + if (escaped) { + value += char; + escaped = false; + } else if (char === "\\" && quote === '"') { + escaped = true; + } else if (char === quote) { + values.push(value); + quote = null; + value = ""; + } else { + value += char; + } + continue; + } + if (char === "#") { + const nextNewline = source.indexOf("\n", index + 1); + if (nextNewline === -1) { + break; + } + index = nextNewline; + } else if (char === '"' || char === "'") { + quote = char; + value = ""; + } + } + return values; } function readTomlBracketValue(source: string, bracketIndex: number): string { diff --git a/src/mapper.test.ts b/src/mapper.test.ts index 66bd84c..5aac7f9 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -918,7 +918,7 @@ let package = Package(name: "HybridApp", targets: [.target(name: "HybridApp")]) await writeFixture( root, "pyproject.toml", - '[project]\nname = "py-tool"\ndependencies = ["pytest", "ruff"]\n\n[project.scripts]\npytool = "py_tool.cli:main"\n', + '[project]\nname = "py-tool"\ndependencies = ["pytest; python_version >= \'3.12\'", "ruff"]\n# "mypy"\n\n[project.scripts]\npytool = "py_tool.cli:main"\n', ); await writeFixture(root, "uv.lock", ""); await writeFixture(root, "src/py_tool/__init__.py", ""); @@ -1001,14 +1001,38 @@ let package = Package(name: "HybridApp", targets: [.target(name: "HybridApp")]) await writeFixture( poetryRoot, "pyproject.toml", - '[tool.poetry]\nname = "poetry-app"\n\n[tool.poetry.dependencies]\npython = "^3.12"\npytest = "^8"\nmypy = "^1"\n', + '[tool.poetry]\nname = "poetry-app"\n\n[tool.poetry.dependencies]\npython = "^3.12"\nmypy = "^1"\n\n[tool.poetry.group.test.dependencies]\npytest = "^8"\n\n[tool.poetry.group.lint.dependencies]\nruff = "^0.5"\n', ); await writeFixture(poetryRoot, "poetry.lock", ""); expect((await detectProject(poetryRoot)).detected.commands).toMatchObject({ typecheck: "poetry run mypy .", + lint: "poetry run ruff check .", test: "poetry run pytest", }); + const hatchRoot = await fixtureRoot("clawpatch-python-hatch-"); + await writeFixture( + hatchRoot, + "pyproject.toml", + '[project]\nname = "hatch-app"\ndependencies = ["pytest", "ruff"]\n', + ); + await writeFixture(hatchRoot, "hatch.toml", ""); + expect((await detectProject(hatchRoot)).detected.commands).toMatchObject({ + lint: "hatch run ruff check .", + test: "hatch run pytest", + }); + + const markerRoot = await fixtureRoot("clawpatch-python-marker-deps-"); + await writeFixture( + markerRoot, + "pyproject.toml", + '[project]\nname = "markers"\ndependencies = ["ruff; python_version < \'3.13\'", "pytest"]\n# "mypy"\n', + ); + expect((await detectProject(markerRoot)).detected.commands).toMatchObject({ + lint: "ruff check .", + test: "pytest", + }); + const pdmRoot = await fixtureRoot("clawpatch-python-pdm-"); await writeFixture(pdmRoot, "requirements.txt", "pytest\nruff\n"); await writeFixture(pdmRoot, "pdm.lock", ""); @@ -1113,6 +1137,27 @@ let package = Package(name: "HybridApp", targets: [.target(name: "HybridApp")]) expect(second.stale).toBe(0); }); + it("keeps Python pytest suite ids stable when tests are added", async () => { + const root = await fixtureRoot("clawpatch-python-stable-test-id-"); + await writeFixture(root, "pyproject.toml", '[project]\nname = "stable-tests"\n'); + await writeFixture(root, "tests/test_b.py", "def test_b():\n pass\n"); + + const project = await detectProject(root); + const first = await mapFeatures(root, project, []); + const firstSuite = first.features.find( + (feature) => feature.title === "Python test suite tests", + ); + await writeFixture(root, "tests/test_a.py", "def test_a():\n pass\n"); + const second = await mapFeatures(root, project, first.features); + const secondSuite = second.features.find( + (feature) => feature.title === "Python test suite tests", + ); + + expect(firstSuite?.featureId).toBeDefined(); + expect(secondSuite?.featureId).toBe(firstSuite?.featureId); + expect(second.stale).toBe(0); + }); + it("keeps Node scripts and native defaults in mixed package repos", async () => { const root = await fixtureRoot("clawpatch-mixed-map-"); await writeFixture( diff --git a/src/mappers/python.ts b/src/mappers/python.ts index 5185708..20e8b1f 100644 --- a/src/mappers/python.ts +++ b/src/mappers/python.ts @@ -300,7 +300,7 @@ function standaloneTestSuites(testFiles: string[], command: string | null): Feat kind: "test-suite", source: "python-test-suite", confidence: "medium", - entryPath: group.files[0] ?? group.label, + entryPath: group.label, symbol: group.label, route: null, command: null, @@ -475,6 +475,21 @@ function table(source: string, name: string): string { return nextSection?.index === undefined ? rest : rest.slice(0, nextSection.index); } +function tablesMatching(source: string, pattern: RegExp): string[] { + const tables: string[] = []; + for (const match of source.matchAll(/^\s*\[([^\]]+)\]\s*$/gmu)) { + const name = match[1]; + if (name === undefined || !pattern.test(name)) { + continue; + } + const start = match.index + match[0].length; + const rest = source.slice(start); + const next = /^\s*\[[^\]]+\]\s*$/mu.exec(rest); + tables.push(next?.index === undefined ? rest : rest.slice(0, next.index)); + } + return tables; +} + function tomlStringValue(source: string, key: string): string | null { const escapedKey = key.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); return new RegExp(`^\\s*${escapedKey}\\s*=\\s*(["'])([^"']+)\\1`, "mu").exec(source)?.[2] ?? null; @@ -504,7 +519,7 @@ function dependencyNames(source: string): Set { for (const dependencyTable of [ table(source, "tool.poetry.dependencies"), table(source, "tool.poetry.dev-dependencies"), - table(source, "tool.poetry.group.dev.dependencies"), + ...tablesMatching(source, /^tool\.poetry\.group\.[^.]+\.dependencies$/u), ]) { for (const value of assignedKeysAndValues(dependencyTable)) { const name = requirementName(value); @@ -552,10 +567,6 @@ function assignedValues(source: string): string[] { continue; } values.push(...arrayValues(rawValue)); - const stringValue = /^["']([^"']+)["']/u.exec(rawValue)?.[1]; - if (stringValue !== undefined) { - values.push(stringValue); - } } return values; } @@ -572,9 +583,43 @@ function assignedKeysAndValues(source: string): string[] { } function arrayValues(source: string): string[] { - return [...source.matchAll(/(["'])([^"']+)\1/gu)].flatMap((match) => - match[2] === undefined ? [] : [match[2]], - ); + return stringValues(source); +} + +function stringValues(source: string): string[] { + const values: string[] = []; + let quote: string | null = null; + let value = ""; + let escaped = false; + for (let index = 0; index < source.length; index += 1) { + const char = source[index]; + if (quote !== null) { + if (escaped) { + value += char; + escaped = false; + } else if (char === "\\" && quote === '"') { + escaped = true; + } else if (char === quote) { + values.push(value); + quote = null; + value = ""; + } else { + value += char; + } + continue; + } + if (char === "#") { + const nextNewline = source.indexOf("\n", index + 1); + if (nextNewline === -1) { + break; + } + index = nextNewline; + } else if (char === '"' || char === "'") { + quote = char; + value = ""; + } + } + return values; } function readBracketValue(source: string, bracketIndex: number): string { From 0ebc27108ea867c1818ca3e51a8e3be3a104324f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 16 May 2026 03:41:44 +0100 Subject: [PATCH 05/18] fix: align python validation commands --- src/detect.ts | 7 ++++--- src/mapper.test.ts | 33 +++++++++++++++++++++++++++++++++ src/mappers/python.ts | 3 +++ 3 files changed, 40 insertions(+), 3 deletions(-) diff --git a/src/detect.ts b/src/detect.ts index 7a7cb9e..2c5b867 100644 --- a/src/detect.ts +++ b/src/detect.ts @@ -316,9 +316,10 @@ async function pythonProjectInfo(root: string): Promise { if (/^\s*\[tool:pytest\]|\[pytest\]/mu.test(source)) { info.hasPytestConfig = true; } - const toolMatch = /^\s*\[(mypy|pyright|ruff)\]/mu.exec(source); - if (toolMatch?.[1] !== undefined) { - info.tools.add(toolMatch[1]); + for (const toolMatch of source.matchAll(/^\s*\[(mypy|pyright|ruff)\]/gmu)) { + if (toolMatch[1] !== undefined) { + info.tools.add(toolMatch[1]); + } } } } diff --git a/src/mapper.test.ts b/src/mapper.test.ts index 5aac7f9..7d1012d 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -1022,6 +1022,18 @@ let package = Package(name: "HybridApp", targets: [.target(name: "HybridApp")]) test: "hatch run pytest", }); + const setupCfgRoot = await fixtureRoot("clawpatch-python-setup-cfg-tools-"); + await writeFixture( + setupCfgRoot, + "setup.cfg", + "[mypy]\nstrict = True\n\n[ruff]\nline-length = 100\n", + ); + expect((await detectProject(setupCfgRoot)).detected.commands).toMatchObject({ + typecheck: "mypy .", + lint: "ruff check .", + format: "ruff format --check .", + }); + const markerRoot = await fixtureRoot("clawpatch-python-marker-deps-"); await writeFixture( markerRoot, @@ -1101,6 +1113,27 @@ let package = Package(name: "HybridApp", targets: [.target(name: "HybridApp")]) expect(suite?.tests).toEqual([{ path: "test_app.py", command: "pytest" }]); }); + it("uses Hatch pytest commands in mapped Python features", async () => { + const root = await fixtureRoot("clawpatch-python-hatch-map-"); + await writeFixture( + root, + "pyproject.toml", + '[project]\nname = "hatch-map"\ndependencies = ["pytest"]\n', + ); + await writeFixture(root, "hatch.toml", ""); + await writeFixture(root, "src/hatch_map/app.py", "def app():\n pass\n"); + await writeFixture(root, "src/hatch_map/test_app.py", "def test_app():\n pass\n"); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const source = result.features.find((feature) => feature.title === "Python source src"); + + expect(project.detected.commands.test).toBe("hatch run pytest"); + expect(source?.tests).toEqual([ + { path: "src/hatch_map/test_app.py", command: "hatch run pytest" }, + ]); + }); + it("maps Python metadata-only projects without pyproject", async () => { const root = await fixtureRoot("clawpatch-python-legacy-metadata-"); await writeFixture(root, "setup.cfg", "[metadata]\nname = legacy\n"); diff --git a/src/mappers/python.ts b/src/mappers/python.ts index 20e8b1f..7d4eb50 100644 --- a/src/mappers/python.ts +++ b/src/mappers/python.ts @@ -185,6 +185,9 @@ async function pythonTestCommand(root: string, pyproject: PyprojectInfo): Promis if (await pathExists(join(root, "pdm.lock"))) { return "pdm run pytest"; } + if (await pathExists(join(root, "hatch.toml"))) { + return "hatch run pytest"; + } return "pytest"; } From f6b42d397b21d986eaa72302827d1e0d77e24dac Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 16 May 2026 03:48:43 +0100 Subject: [PATCH 06/18] fix: parse python toml variants --- src/detect.ts | 16 +++++++++------- src/mapper.test.ts | 15 ++++++++++++++- src/mappers/python.ts | 10 ++++++---- 3 files changed, 29 insertions(+), 12 deletions(-) diff --git a/src/detect.ts b/src/detect.ts index 2c5b867..8accccc 100644 --- a/src/detect.ts +++ b/src/detect.ts @@ -332,6 +332,7 @@ function pythonDependencyNames(source: string): string[] { pythonTomlTable(source, "project"), pythonTomlTable(source, "tool.poetry"), pythonTomlTable(source, "tool.poetry.group.dev"), + pythonTomlTable(source, "tool.pdm.dev-dependencies"), ]) { for (const section of pythonTomlArraySections(table, ["dependencies", "dev-dependencies"])) { for (const value of pythonTomlArrayValues(section)) { @@ -356,6 +357,7 @@ function pythonDependencyNames(source: string): string[] { for (const table of pythonTomlTables(source, [ "project.optional-dependencies", "dependency-groups", + "tool.pdm.dev-dependencies", ])) { for (const value of pythonTomlAssignedValues(table)) { const name = pythonRequirementName(value); @@ -369,18 +371,18 @@ function pythonDependencyNames(source: string): string[] { function pythonTomlTable(source: string, name: string): string { const escaped = name.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); - const match = new RegExp(`^\\s*\\[${escaped}\\]\\s*$`, "mu").exec(source); + const match = new RegExp(`^\\s*\\[${escaped}\\]\\s*(?:#.*)?$`, "mu").exec(source); if (match?.index === undefined) { return ""; } const rest = source.slice(match.index + match[0].length); - const next = /^\s*\[[^\]]+\]\s*$/mu.exec(rest); + const next = /^\s*\[[^\]]+\]\s*(?:#.*)?$/mu.exec(rest); return next?.index === undefined ? rest : rest.slice(0, next.index); } function pythonToolSections(source: string): string[] { const tools = new Set(); - for (const match of source.matchAll(/^\s*\[tool\.([A-Za-z0-9_.-]+)[^\]]*\]\s*$/gmu)) { + for (const match of source.matchAll(/^\s*\[tool\.([A-Za-z0-9_.-]+)[^\]]*\]\s*(?:#.*)?$/gmu)) { const name = match[1]?.split(".")[0]; if (name !== undefined) { tools.add(name); @@ -404,11 +406,11 @@ function pythonTomlTables(source: string, names: string[]): string[] { const tables: string[] = []; for (const name of names) { const escaped = name.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); - const pattern = new RegExp(`^\\s*\\[${escaped}\\]\\s*$`, "gmu"); + const pattern = new RegExp(`^\\s*\\[${escaped}\\]\\s*(?:#.*)?$`, "gmu"); for (const match of source.matchAll(pattern)) { const start = match.index + match[0].length; const rest = source.slice(start); - const next = /^\s*\[[^\]]+\]\s*$/mu.exec(rest); + const next = /^\s*\[[^\]]+\]\s*(?:#.*)?$/mu.exec(rest); tables.push(next?.index === undefined ? rest : rest.slice(0, next.index)); } } @@ -417,14 +419,14 @@ function pythonTomlTables(source: string, names: string[]): string[] { function pythonTomlTablesMatching(source: string, pattern: RegExp): string[] { const tables: string[] = []; - for (const match of source.matchAll(/^\s*\[([^\]]+)\]\s*$/gmu)) { + for (const match of source.matchAll(/^\s*\[([^\]]+)\]\s*(?:#.*)?$/gmu)) { const name = match[1]; if (name === undefined || !pattern.test(name)) { continue; } const start = match.index + match[0].length; const rest = source.slice(start); - const next = /^\s*\[[^\]]+\]\s*$/mu.exec(rest); + const next = /^\s*\[[^\]]+\]\s*(?:#.*)?$/mu.exec(rest); tables.push(next?.index === undefined ? rest : rest.slice(0, next.index)); } return tables; diff --git a/src/mapper.test.ts b/src/mapper.test.ts index 7d1012d..ec06c90 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -918,7 +918,7 @@ let package = Package(name: "HybridApp", targets: [.target(name: "HybridApp")]) await writeFixture( root, "pyproject.toml", - '[project]\nname = "py-tool"\ndependencies = ["pytest; python_version >= \'3.12\'", "ruff"]\n# "mypy"\n\n[project.scripts]\npytool = "py_tool.cli:main"\n', + '[project] # package metadata\nname = "py-tool"\ndependencies = ["pytest; python_version >= \'3.12\'", "ruff"]\n# "mypy"\n\n[project.scripts] # console scripts\npytool = "py_tool.cli:main"\n', ); await writeFixture(root, "uv.lock", ""); await writeFixture(root, "src/py_tool/__init__.py", ""); @@ -1054,6 +1054,19 @@ let package = Package(name: "HybridApp", targets: [.target(name: "HybridApp")]) test: "pdm run pytest", }); + const pdmPyprojectRoot = await fixtureRoot("clawpatch-python-pdm-pyproject-"); + await writeFixture( + pdmPyprojectRoot, + "pyproject.toml", + '[tool.pdm.dev-dependencies]\ndev = ["pytest", "ruff", "pyright"]\n', + ); + await writeFixture(pdmPyprojectRoot, "pdm.lock", ""); + expect((await detectProject(pdmPyprojectRoot)).detected.commands).toMatchObject({ + typecheck: "pdm run pyright", + lint: "pdm run ruff check .", + test: "pdm run pytest", + }); + const directRoot = await fixtureRoot("clawpatch-python-direct-"); await writeFixture(directRoot, "setup.py", "from setuptools import setup\n"); await writeFixture(directRoot, "tests/test_app.py", "def test_app():\n pass\n"); diff --git a/src/mappers/python.ts b/src/mappers/python.ts index 7d4eb50..466d70e 100644 --- a/src/mappers/python.ts +++ b/src/mappers/python.ts @@ -469,25 +469,25 @@ function pythonShouldSkip(path: string): boolean { function table(source: string, name: string): string { const escapedName = name.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); - const match = new RegExp(`^\\s*\\[${escapedName}\\]\\s*$`, "mu").exec(source); + const match = new RegExp(`^\\s*\\[${escapedName}\\]\\s*(?:#.*)?$`, "mu").exec(source); if (match?.index === undefined) { return ""; } const rest = source.slice(match.index + match[0].length); - const nextSection = /^\s*\[[^\]]+\]\s*$/mu.exec(rest); + const nextSection = /^\s*\[[^\]]+\]\s*(?:#.*)?$/mu.exec(rest); return nextSection?.index === undefined ? rest : rest.slice(0, nextSection.index); } function tablesMatching(source: string, pattern: RegExp): string[] { const tables: string[] = []; - for (const match of source.matchAll(/^\s*\[([^\]]+)\]\s*$/gmu)) { + for (const match of source.matchAll(/^\s*\[([^\]]+)\]\s*(?:#.*)?$/gmu)) { const name = match[1]; if (name === undefined || !pattern.test(name)) { continue; } const start = match.index + match[0].length; const rest = source.slice(start); - const next = /^\s*\[[^\]]+\]\s*$/mu.exec(rest); + const next = /^\s*\[[^\]]+\]\s*(?:#.*)?$/mu.exec(rest); tables.push(next?.index === undefined ? rest : rest.slice(0, next.index)); } return tables; @@ -520,6 +520,7 @@ function dependencyNames(source: string): Set { } } for (const dependencyTable of [ + table(source, "tool.pdm.dev-dependencies"), table(source, "tool.poetry.dependencies"), table(source, "tool.poetry.dev-dependencies"), ...tablesMatching(source, /^tool\.poetry\.group\.[^.]+\.dependencies$/u), @@ -534,6 +535,7 @@ function dependencyNames(source: string): Set { for (const dependencyTable of [ table(source, "project.optional-dependencies"), table(source, "dependency-groups"), + table(source, "tool.pdm.dev-dependencies"), ]) { for (const value of assignedValues(dependencyTable)) { const name = requirementName(value); From 3b93dc9efd893eb17368c6fa0ae3445436ad3db9 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 16 May 2026 03:57:05 +0100 Subject: [PATCH 07/18] fix: stabilize python project probing --- src/mapper.test.ts | 39 ++++++++++++++++++++++++++++--- src/mappers/python.ts | 53 ++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 88 insertions(+), 4 deletions(-) diff --git a/src/mapper.test.ts b/src/mapper.test.ts index ec06c90..ae17c58 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -1117,9 +1117,7 @@ let package = Package(name: "HybridApp", targets: [.target(name: "HybridApp")]) const project = await detectProject(root); const result = await mapFeatures(root, project, []); - const suite = result.features.find( - (feature) => feature.title === "Python test suite test_app.py", - ); + const suite = result.features.find((feature) => feature.title === "Python test suite tests"); expect(project.detected.commands.test).toBe("pytest"); expect(suite?.ownedFiles).toEqual([{ path: "test_app.py", reason: "pytest file" }]); @@ -1204,6 +1202,41 @@ let package = Package(name: "HybridApp", targets: [.target(name: "HybridApp")]) expect(second.stale).toBe(0); }); + it("keeps root-level Python pytest suite ids stable when tests are added", async () => { + const root = await fixtureRoot("clawpatch-python-stable-root-test-id-"); + await writeFixture(root, "pyproject.toml", '[project]\nname = "stable-root-tests"\n'); + await writeFixture(root, "test_b.py", "def test_b():\n pass\n"); + + const project = await detectProject(root); + const first = await mapFeatures(root, project, []); + const firstSuite = first.features.find( + (feature) => feature.title === "Python test suite tests", + ); + await writeFixture(root, "test_a.py", "def test_a():\n pass\n"); + const second = await mapFeatures(root, project, first.features); + const secondSuite = second.features.find( + (feature) => feature.title === "Python test suite tests", + ); + + expect(firstSuite?.featureId).toBeDefined(); + expect(secondSuite?.featureId).toBe(firstSuite?.featureId); + expect(second.stale).toBe(0); + }); + + it("maps Python source-only projects without a full source-group pre-scan", async () => { + const root = await fixtureRoot("clawpatch-python-source-only-"); + await writeFixture(root, "src/source_only/app.py", "def app():\n pass\n"); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const source = result.features.find((feature) => feature.title === "Python source src"); + + expect(project.detected.languages).toContain("python"); + expect(source?.ownedFiles).toEqual([ + { path: "src/source_only/app.py", reason: "source group src" }, + ]); + }); + it("keeps Node scripts and native defaults in mixed package repos", async () => { const root = await fixtureRoot("clawpatch-mixed-map-"); await writeFixture( diff --git a/src/mappers/python.ts b/src/mappers/python.ts index 466d70e..d38f4d9 100644 --- a/src/mappers/python.ts +++ b/src/mappers/python.ts @@ -136,7 +136,7 @@ async function isPythonProject(root: string): Promise { (await pathExists(join(root, "setup.py"))) || (await pathExists(join(root, "setup.cfg"))) || (await pathExists(join(root, "requirements.txt"))) || - (await pythonSourceGroups(root)).length > 0 + (await containsReviewablePythonSource(root)) ); } @@ -400,6 +400,9 @@ function currentLabel(sourceRoot: string, files: string[], depth: number): strin function commonLabel(sourceRoot: string, files: string[], depth: number): string { if (depth === 0) { + if (sourceRoot === "tests") { + return sourceRoot; + } const first = files[0]; return files.length === 1 && first !== undefined && !first.startsWith(`${sourceRoot}/`) ? first @@ -467,6 +470,54 @@ function pythonShouldSkip(path: string): boolean { ); } +async function containsReviewablePythonSource(root: string): Promise { + for (const sourceRoot of sourceRoots) { + if (await containsPythonSourceInDirectory(root, sourceRoot, 4)) { + return true; + } + } + for (const entry of await readdir(root, { withFileTypes: true }).catch(() => [])) { + if ( + entry.isDirectory() && + !pythonShouldSkip(entry.name) && + (await pathExists(join(root, entry.name, "__init__.py"))) + ) { + return true; + } + } + return false; +} + +async function containsPythonSourceInDirectory( + root: string, + prefix: string, + remainingDepth: number, +): Promise { + if (remainingDepth < 0 || pythonShouldSkip(prefix)) { + return false; + } + const dir = join(root, prefix); + if (!(await isSafeDirectory(root, dir))) { + return false; + } + for (const entry of await readdir(dir, { withFileTypes: true }).catch(() => [])) { + const rel = `${prefix}/${entry.name}`; + if (pythonShouldSkip(rel)) { + continue; + } + if (entry.isFile() && isReviewablePythonSourceFile(rel)) { + return true; + } + if ( + entry.isDirectory() && + (await containsPythonSourceInDirectory(root, rel, remainingDepth - 1)) + ) { + return true; + } + } + return false; +} + function table(source: string, name: string): string { const escapedName = name.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); const match = new RegExp(`^\\s*\\[${escapedName}\\]\\s*(?:#.*)?$`, "mu").exec(source); From 01bd65aeb70f795abcb7fad307a14fc8bcdca88d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 16 May 2026 04:04:40 +0100 Subject: [PATCH 08/18] fix: tighten python test and toml mapping --- src/detect.ts | 8 +++++--- src/mapper.test.ts | 41 +++++++++++++++++++++++++++++++++++++++++ src/mappers/python.ts | 34 +++++++++++++++++++++++++++++++--- 3 files changed, 77 insertions(+), 6 deletions(-) diff --git a/src/detect.ts b/src/detect.ts index 8accccc..9f31f96 100644 --- a/src/detect.ts +++ b/src/detect.ts @@ -376,7 +376,7 @@ function pythonTomlTable(source: string, name: string): string { return ""; } const rest = source.slice(match.index + match[0].length); - const next = /^\s*\[[^\]]+\]\s*(?:#.*)?$/mu.exec(rest); + const next = pythonTomlHeaderPattern.exec(rest); return next?.index === undefined ? rest : rest.slice(0, next.index); } @@ -410,7 +410,7 @@ function pythonTomlTables(source: string, names: string[]): string[] { for (const match of source.matchAll(pattern)) { const start = match.index + match[0].length; const rest = source.slice(start); - const next = /^\s*\[[^\]]+\]\s*(?:#.*)?$/mu.exec(rest); + const next = pythonTomlHeaderPattern.exec(rest); tables.push(next?.index === undefined ? rest : rest.slice(0, next.index)); } } @@ -426,12 +426,14 @@ function pythonTomlTablesMatching(source: string, pattern: RegExp): string[] { } const start = match.index + match[0].length; const rest = source.slice(start); - const next = /^\s*\[[^\]]+\]\s*(?:#.*)?$/mu.exec(rest); + const next = pythonTomlHeaderPattern.exec(rest); tables.push(next?.index === undefined ? rest : rest.slice(0, next.index)); } return tables; } +const pythonTomlHeaderPattern = /^\s*\[\[?[^\]]+\]\]?\s*(?:#.*)?$/mu; + function pythonTomlAssignedValues(source: string): string[] { const values: string[] = []; for (const match of source.matchAll(/^\s*["']?[^"'=\s]+["']?\s*=\s*/gmu)) { diff --git a/src/mapper.test.ts b/src/mapper.test.ts index ae17c58..58a54e2 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -1223,6 +1223,47 @@ let package = Package(name: "HybridApp", targets: [.target(name: "HybridApp")]) expect(second.stale).toBe(0); }); + it("stops Python script parsing at TOML array-table headers", async () => { + const root = await fixtureRoot("clawpatch-python-array-table-script-"); + await writeFixture( + root, + "pyproject.toml", + '[project]\nname = "array-table"\n\n[project.scripts]\nreal = "pkg.cli:main"\n\n[[tool.uv.index]]\nname = "private"\nurl = "https://example.invalid/simple"\n', + ); + await writeFixture(root, "pkg/__init__.py", ""); + await writeFixture(root, "pkg/cli.py", "def main():\n pass\n"); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const commands = result.features + .filter((feature) => feature.source === "python-console-script") + .map((feature) => feature.entrypoints[0]?.command); + + expect(commands).toEqual(["real"]); + }); + + it("groups colocated Python pytest suites by their actual directory", async () => { + const root = await fixtureRoot("clawpatch-python-colocated-test-groups-"); + await writeFixture(root, "pyproject.toml", '[project]\nname = "colocated-tests"\n'); + for (let index = 0; index < 13; index += 1) { + await writeFixture(root, `src/pkg/test_${index}.py`, `def test_${index}():\n pass\n`); + } + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const suites = result.features.filter((feature) => feature.source === "python-test-suite"); + + expect(suites.map((feature) => feature.title)).toEqual([ + "Python test suite src/pkg#1", + "Python test suite src/pkg#2", + ]); + expect( + suites + .flatMap((feature) => feature.ownedFiles) + .every((file) => file.path.startsWith("src/pkg/")), + ).toBe(true); + }); + it("maps Python source-only projects without a full source-group pre-scan", async () => { const root = await fixtureRoot("clawpatch-python-source-only-"); await writeFixture(root, "src/source_only/app.py", "def app():\n pass\n"); diff --git a/src/mappers/python.ts b/src/mappers/python.ts index d38f4d9..5bfa738 100644 --- a/src/mappers/python.ts +++ b/src/mappers/python.ts @@ -297,7 +297,11 @@ function standaloneTestSuites(testFiles: string[], command: string | null): Feat if (testFiles.length === 0) { return []; } - return partitionSourceFiles("tests", testFiles, sourceGroupMaxOwnedFiles).map((group) => ({ + const groups: SourceGroup[] = []; + for (const [root, files] of groupedTestFiles(testFiles)) { + groups.push(...partitionSourceFiles(root, files, sourceGroupMaxOwnedFiles)); + } + return groups.map((group) => ({ title: `Python test suite ${group.label}`, summary: `Python pytest files in ${group.label}.`, kind: "test-suite", @@ -317,6 +321,28 @@ function standaloneTestSuites(testFiles: string[], command: string | null): Feat })); } +function groupedTestFiles(testFiles: string[]): Map { + const groups = new Map(); + for (const path of testFiles) { + const root = testSuiteRoot(path); + const files = groups.get(root) ?? []; + files.push(path); + groups.set(root, files); + } + return new Map([...groups.entries()].toSorted(([left], [right]) => left.localeCompare(right))); +} + +function testSuiteRoot(path: string): string { + if (/^test_[^/]+\.py$/u.test(path) || path.endsWith("_test.py")) { + return "tests"; + } + const first = path.split("/")[0]; + if (first === "test" || first === "tests") { + return first; + } + return dirname(path); +} + function partitionSourceFiles( sourceRoot: string, files: string[], @@ -525,7 +551,7 @@ function table(source: string, name: string): string { return ""; } const rest = source.slice(match.index + match[0].length); - const nextSection = /^\s*\[[^\]]+\]\s*(?:#.*)?$/mu.exec(rest); + const nextSection = tomlHeaderPattern.exec(rest); return nextSection?.index === undefined ? rest : rest.slice(0, nextSection.index); } @@ -538,12 +564,14 @@ function tablesMatching(source: string, pattern: RegExp): string[] { } const start = match.index + match[0].length; const rest = source.slice(start); - const next = /^\s*\[[^\]]+\]\s*(?:#.*)?$/mu.exec(rest); + const next = tomlHeaderPattern.exec(rest); tables.push(next?.index === undefined ? rest : rest.slice(0, next.index)); } return tables; } +const tomlHeaderPattern = /^\s*\[\[?[^\]]+\]\]?\s*(?:#.*)?$/mu; + function tomlStringValue(source: string, key: string): string | null { const escapedKey = key.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); return new RegExp(`^\\s*${escapedKey}\\s*=\\s*(["'])([^"']+)\\1`, "mu").exec(source)?.[2] ?? null; From aeec2140f6fda395d90e14acb5de8c23a21812c0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 16 May 2026 04:12:13 +0100 Subject: [PATCH 09/18] fix: keep nested python tests grouped locally --- src/mapper.test.ts | 14 ++++++++++++++ src/mappers/python.ts | 2 +- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/mapper.test.ts b/src/mapper.test.ts index 58a54e2..3f2f9c4 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -1264,6 +1264,20 @@ let package = Package(name: "HybridApp", targets: [.target(name: "HybridApp")]) ).toBe(true); }); + it("groups nested Python star-test files by their actual directory", async () => { + const root = await fixtureRoot("clawpatch-python-nested-star-test-"); + await writeFixture(root, "pyproject.toml", '[project]\nname = "nested-star-tests"\n'); + await writeFixture(root, "src/pkg/store_test.py", "def test_store():\n pass\n"); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const suite = result.features.find((feature) => feature.source === "python-test-suite"); + + expect(suite?.title).toBe("Python test suite src/pkg"); + expect(suite?.entrypoints[0]?.path).toBe("src/pkg"); + expect(suite?.ownedFiles).toEqual([{ path: "src/pkg/store_test.py", reason: "pytest file" }]); + }); + it("maps Python source-only projects without a full source-group pre-scan", async () => { const root = await fixtureRoot("clawpatch-python-source-only-"); await writeFixture(root, "src/source_only/app.py", "def app():\n pass\n"); diff --git a/src/mappers/python.ts b/src/mappers/python.ts index 5bfa738..2e39d83 100644 --- a/src/mappers/python.ts +++ b/src/mappers/python.ts @@ -333,7 +333,7 @@ function groupedTestFiles(testFiles: string[]): Map { } function testSuiteRoot(path: string): string { - if (/^test_[^/]+\.py$/u.test(path) || path.endsWith("_test.py")) { + if (!path.includes("/") && (/^test_[^/]+\.py$/u.test(path) || path.endsWith("_test.py"))) { return "tests"; } const first = path.split("/")[0]; From 21109c05681b22902069c633a5421a68cd537bbd Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 16 May 2026 04:18:25 +0100 Subject: [PATCH 10/18] fix: restrict python pytest file detection --- src/mapper.test.ts | 14 ++++++++++++++ src/mappers/python.ts | 8 ++------ 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/src/mapper.test.ts b/src/mapper.test.ts index 3f2f9c4..1174215 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -1278,6 +1278,20 @@ let package = Package(name: "HybridApp", targets: [.target(name: "HybridApp")]) expect(suite?.ownedFiles).toEqual([{ path: "src/pkg/store_test.py", reason: "pytest file" }]); }); + it("does not map Python test support modules as pytest suites", async () => { + const root = await fixtureRoot("clawpatch-python-test-support-"); + await writeFixture(root, "pyproject.toml", '[project]\nname = "support-only"\n'); + await writeFixture(root, "tests/helpers.py", "def helper():\n pass\n"); + await writeFixture(root, "tests/conftest.py", "def pytest_configure():\n pass\n"); + await writeFixture(root, "tests/__init__.py", ""); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + + expect(project.detected.commands.test).toBeNull(); + expect(result.features.some((feature) => feature.source === "python-test-suite")).toBe(false); + }); + it("maps Python source-only projects without a full source-group pre-scan", async () => { const root = await fixtureRoot("clawpatch-python-source-only-"); await writeFixture(root, "src/source_only/app.py", "def app():\n pass\n"); diff --git a/src/mappers/python.ts b/src/mappers/python.ts index 2e39d83..be45bc1 100644 --- a/src/mappers/python.ts +++ b/src/mappers/python.ts @@ -481,12 +481,8 @@ function isReviewablePythonSourceFile(path: string): boolean { } function isPythonTestPath(path: string): boolean { - return ( - path.endsWith(".py") && - (/(^|\/)tests?\//u.test(path) || - /(^|\/)test_[^/]+\.py$/u.test(path) || - path.endsWith("_test.py")) - ); + const name = basename(path); + return path.endsWith(".py") && (/^test_[^/]+\.py$/u.test(name) || name.endsWith("_test.py")); } function pythonShouldSkip(path: string): boolean { From eac8598f1aea44b898c98bc230e5dc6e91556ee9 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 16 May 2026 04:31:43 +0100 Subject: [PATCH 11/18] fix: attach flat python tests to script features --- src/mapper.test.ts | 18 ++++++++++++++++++ src/mappers/python.ts | 2 +- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/mapper.test.ts b/src/mapper.test.ts index 1174215..d66b092 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -984,6 +984,24 @@ let package = Package(name: "HybridApp", targets: [.target(name: "HybridApp")]) expect(libbed?.tests).toEqual([{ path: "lib/libbed/test_cli.py", command: "pytest" }]); }); + it("associates root-level pytest files with flat Python console scripts", async () => { + const root = await fixtureRoot("clawpatch-python-flat-tests-"); + await writeFixture( + root, + "pyproject.toml", + '[project]\nname = "flat"\ndependencies = ["pytest"]\n\n[project.scripts]\nflat = "cli:main"\n', + ); + await writeFixture(root, "cli.py", "def main():\n pass\n"); + await writeFixture(root, "test_cli.py", "def test_main():\n pass\n"); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const cli = result.features.find((feature) => feature.title === "Python CLI command flat"); + + expect(cli?.entrypoints[0]?.path).toBe("cli.py"); + expect(cli?.tests).toEqual([{ path: "test_cli.py", command: "pytest" }]); + }); + it("detects Python projects and conservative command defaults", async () => { const uvRoot = await fixtureRoot("clawpatch-python-uv-"); await writeFixture( diff --git a/src/mappers/python.ts b/src/mappers/python.ts index be45bc1..a2dfd0e 100644 --- a/src/mappers/python.ts +++ b/src/mappers/python.ts @@ -463,7 +463,7 @@ function associatedTests(files: string[], tests: string[], command: string | nul .replace(/\.py$/u, ""); return ( [...dirs].some((dir) => pathMatchesPrefix(test, dir)) || - (fileStems.has(testStem) && /^(tests?|__tests__)\//u.test(test)) + (fileStems.has(testStem) && (/^(tests?|__tests__)\//u.test(test) || !test.includes("/"))) ); }) .slice(0, sourceGroupMaxTests) From 7fd872827724db374088c0c8e191a97c820f28fe Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 16 May 2026 04:42:49 +0100 Subject: [PATCH 12/18] fix: parse setup cfg python dependencies --- src/detect.ts | 76 +++++++++++++++++++++++++++++++++++-------- src/mapper.test.ts | 24 ++++++++++++++ src/mappers/python.ts | 59 ++++++++++++++++++++++++++++++--- 3 files changed, 141 insertions(+), 18 deletions(-) diff --git a/src/detect.ts b/src/detect.ts index 9f31f96..ae67548 100644 --- a/src/detect.ts +++ b/src/detect.ts @@ -307,19 +307,23 @@ async function pythonProjectInfo(root: string): Promise { } info.hasPytestConfig = info.tools.has("pytest") || info.tools.has("pytest.ini_options"); } - for (const file of ["requirements.txt", "setup.cfg"]) { - if (await pathExists(join(root, file))) { - const source = await readFile(join(root, file), "utf8"); - for (const dependency of pythonRequirementNames(source)) { - info.dependencies.add(dependency); - } - if (/^\s*\[tool:pytest\]|\[pytest\]/mu.test(source)) { - info.hasPytestConfig = true; - } - for (const toolMatch of source.matchAll(/^\s*\[(mypy|pyright|ruff)\]/gmu)) { - if (toolMatch[1] !== undefined) { - info.tools.add(toolMatch[1]); - } + if (await pathExists(join(root, "requirements.txt"))) { + const source = await readFile(join(root, "requirements.txt"), "utf8"); + for (const dependency of pythonRequirementNames(source)) { + info.dependencies.add(dependency); + } + } + if (await pathExists(join(root, "setup.cfg"))) { + const source = await readFile(join(root, "setup.cfg"), "utf8"); + for (const dependency of pythonSetupCfgRequirementNames(source)) { + info.dependencies.add(dependency); + } + if (/^\s*\[tool:pytest\]|\[pytest\]/mu.test(source)) { + info.hasPytestConfig = true; + } + for (const toolMatch of source.matchAll(/^\s*\[(mypy|pyright|ruff)\]/gmu)) { + if (toolMatch[1] !== undefined) { + info.tools.add(toolMatch[1]); } } } @@ -540,6 +544,52 @@ function pythonRequirementNames(source: string): string[] { .filter((name): name is string => name !== null); } +function pythonSetupCfgRequirementNames(source: string): string[] { + const names = new Set(); + let section = ""; + let collecting = false; + for (const rawLine of source.split("\n")) { + const line = rawLine.replace(/\r$/u, ""); + if (/^\s*(?:#|;|$)/u.test(line)) { + continue; + } + const header = /^\s*\[([^\]]+)\]\s*$/u.exec(line); + if (header?.[1] !== undefined) { + section = header[1].toLowerCase(); + collecting = false; + continue; + } + if (section !== "options" && section !== "options.extras_require") { + continue; + } + const assignment = /^\s*([A-Za-z0-9_.-]+)\s*=\s*(.*)$/u.exec(line); + if (assignment !== null) { + const key = assignment[1]?.toLowerCase().replace(/-/gu, "_") ?? ""; + collecting = + section === "options" + ? ["install_requires", "setup_requires", "tests_require"].includes(key) + : true; + if (collecting && assignment[2] !== undefined) { + addPythonRequirementNames(names, assignment[2]); + } + continue; + } + if (collecting && /^\s+/u.test(line)) { + addPythonRequirementNames(names, line); + } + } + return [...names]; +} + +function addPythonRequirementNames(names: Set, value: string): void { + for (const part of value.split(",")) { + const name = pythonRequirementName(part); + if (name !== null) { + names.add(name); + } + } +} + function pythonRequirementName(value: string): string | null { const trimmed = value.trim().replace(/^["']|["']$/gu, ""); if (trimmed.length === 0 || trimmed.startsWith("#") || trimmed.startsWith("-")) { diff --git a/src/mapper.test.ts b/src/mapper.test.ts index d66b092..f0db7df 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -1052,6 +1052,30 @@ let package = Package(name: "HybridApp", targets: [.target(name: "HybridApp")]) format: "ruff format --check .", }); + const setupCfgExtrasNameRoot = await fixtureRoot("clawpatch-python-setup-cfg-extras-name-"); + await writeFixture( + setupCfgExtrasNameRoot, + "setup.cfg", + "[metadata]\nname = extras-name\n\n[options.extras_require]\npytest =\n httpx\nruff =\n typing-extensions\n", + ); + expect((await detectProject(setupCfgExtrasNameRoot)).detected.commands).toEqual({ + typecheck: null, + lint: null, + format: null, + test: null, + }); + + const setupCfgExtrasValueRoot = await fixtureRoot("clawpatch-python-setup-cfg-extras-value-"); + await writeFixture( + setupCfgExtrasValueRoot, + "setup.cfg", + "[metadata]\nname = extras-value\n\n[options.extras_require]\ndev =\n pytest\n ruff\n", + ); + expect((await detectProject(setupCfgExtrasValueRoot)).detected.commands).toMatchObject({ + lint: "ruff check .", + test: "pytest", + }); + const markerRoot = await fixtureRoot("clawpatch-python-marker-deps-"); await writeFixture( markerRoot, diff --git a/src/mappers/python.ts b/src/mappers/python.ts index a2dfd0e..c01614c 100644 --- a/src/mappers/python.ts +++ b/src/mappers/python.ts @@ -192,15 +192,18 @@ async function pythonTestCommand(root: string, pyproject: PyprojectInfo): Promis } async function dependencyFileHas(root: string, dependency: string): Promise { - for (const file of ["requirements.txt", "setup.cfg"]) { - if (!(await pathExists(join(root, file)))) { - continue; - } - const source = await readFile(join(root, file), "utf8"); + if (await pathExists(join(root, "requirements.txt"))) { + const source = await readFile(join(root, "requirements.txt"), "utf8"); if (requirementNames(source).has(dependency)) { return true; } } + if (await pathExists(join(root, "setup.cfg"))) { + const source = await readFile(join(root, "setup.cfg"), "utf8"); + if (setupCfgRequirementNames(source).has(dependency)) { + return true; + } + } return false; } @@ -741,6 +744,52 @@ function requirementNames(source: string): Set { ); } +function setupCfgRequirementNames(source: string): Set { + const names = new Set(); + let section = ""; + let collecting = false; + for (const rawLine of source.split("\n")) { + const line = rawLine.replace(/\r$/u, ""); + if (/^\s*(?:#|;|$)/u.test(line)) { + continue; + } + const header = /^\s*\[([^\]]+)\]\s*$/u.exec(line); + if (header?.[1] !== undefined) { + section = header[1].toLowerCase(); + collecting = false; + continue; + } + if (section !== "options" && section !== "options.extras_require") { + continue; + } + const assignment = /^\s*([A-Za-z0-9_.-]+)\s*=\s*(.*)$/u.exec(line); + if (assignment !== null) { + const key = assignment[1]?.toLowerCase().replace(/-/gu, "_") ?? ""; + collecting = + section === "options" + ? ["install_requires", "setup_requires", "tests_require"].includes(key) + : true; + if (collecting && assignment[2] !== undefined) { + addRequirementNames(names, assignment[2]); + } + continue; + } + if (collecting && /^\s+/u.test(line)) { + addRequirementNames(names, line); + } + } + return names; +} + +function addRequirementNames(names: Set, value: string): void { + for (const part of value.split(",")) { + const name = requirementName(part); + if (name !== null) { + names.add(name); + } + } +} + function requirementName(value: string): string | null { const trimmed = value.trim().replace(/^["']|["']$/gu, ""); if (trimmed.length === 0 || trimmed.startsWith("#") || trimmed.startsWith("-")) { From b0a49f9034b8fb490cfff98bbfb47a91bc5dece1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 16 May 2026 04:56:17 +0100 Subject: [PATCH 13/18] fix: reject symlinked python script targets --- src/mapper.test.ts | 21 +++++++++++++++++++++ src/mappers/python.ts | 3 ++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/src/mapper.test.ts b/src/mapper.test.ts index f0db7df..b5cf317 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -1002,6 +1002,27 @@ let package = Package(name: "HybridApp", targets: [.target(name: "HybridApp")]) expect(cli?.tests).toEqual([{ path: "test_cli.py", command: "pytest" }]); }); + it("does not resolve Python console scripts through symlinked package dirs", async () => { + const root = await fixtureRoot("clawpatch-python-script-symlink-root-"); + const external = await fixtureRoot("clawpatch-python-script-symlink-external-"); + await writeFixture( + root, + "pyproject.toml", + '[project]\nname = "linked-script"\n\n[project.scripts]\nlinked = "pkg.cli:main"\n', + ); + await writeFixture(external, "pkg/cli.py", "def main():\n pass\n"); + await symlink(join(external, "pkg"), join(root, "pkg"), "dir"); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const cli = result.features.find((feature) => feature.title === "Python CLI command linked"); + + expect(cli?.entrypoints[0]?.path).toBe("pyproject.toml"); + expect(cli?.ownedFiles).toEqual([ + { path: "pyproject.toml", reason: "console script metadata" }, + ]); + }); + it("detects Python projects and conservative command defaults", async () => { const uvRoot = await fixtureRoot("clawpatch-python-uv-"); await writeFixture( diff --git a/src/mappers/python.ts b/src/mappers/python.ts index c01614c..84f1b48 100644 --- a/src/mappers/python.ts +++ b/src/mappers/python.ts @@ -3,6 +3,7 @@ import { basename, dirname, join } from "node:path"; import { pathExists } from "../fs.js"; import { isSafeDirectory, + isSafeFile, packageKind, packageTrustBoundaries, pathMatchesPrefix, @@ -289,7 +290,7 @@ async function resolvePythonScript( candidates.add(`${sourceRoot}/${packageInitPath}`); } for (const candidate of candidates) { - if (await pathExists(join(root, candidate))) { + if (await isSafeFile(root, join(root, candidate))) { return { entryPath: candidate, symbol }; } } From b89f7c6e1ff5fe3ba3c7974d1859eaed4ac35c9d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 16 May 2026 05:02:32 +0100 Subject: [PATCH 14/18] fix: skip python fixture test files --- src/mapper.test.ts | 14 ++++++++++++++ src/mappers/python.ts | 8 ++++++-- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/src/mapper.test.ts b/src/mapper.test.ts index b5cf317..f990413 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -1355,6 +1355,20 @@ let package = Package(name: "HybridApp", targets: [.target(name: "HybridApp")]) expect(result.features.some((feature) => feature.source === "python-test-suite")).toBe(false); }); + it("does not map Python fixture sample tests as pytest suites", async () => { + const root = await fixtureRoot("clawpatch-python-fixture-tests-"); + await writeFixture(root, "pyproject.toml", '[project]\nname = "fixture-only"\n'); + await writeFixture(root, "tests/fixtures/test_sample.py", "def test_sample():\n pass\n"); + await writeFixture(root, "tests/__fixtures__/test_sample.py", "def test_sample():\n pass\n"); + await writeFixture(root, "testdata/test_sample.py", "def test_sample():\n pass\n"); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + + expect(project.detected.commands.test).toBeNull(); + expect(result.features.some((feature) => feature.source === "python-test-suite")).toBe(false); + }); + it("maps Python source-only projects without a full source-group pre-scan", async () => { const root = await fixtureRoot("clawpatch-python-source-only-"); await writeFixture(root, "src/source_only/app.py", "def app():\n pass\n"); diff --git a/src/mappers/python.ts b/src/mappers/python.ts index 84f1b48..8f51e2c 100644 --- a/src/mappers/python.ts +++ b/src/mappers/python.ts @@ -248,7 +248,7 @@ async function pythonTestFiles(root: string): Promise { const rootTests = await rootPythonTestFiles(root); const nestedTests = (await walk(root, ["tests", "test", ...(await pythonSourceRoots(root))])) .filter(isPythonTestPath) - .filter((path) => !pythonShouldSkip(path)); + .filter((path) => !pythonShouldSkip(path) && !isPythonFixturePath(path)); return uniquePaths([...rootTests, ...nestedTests]).slice(0, 200); } @@ -479,11 +479,15 @@ function isReviewablePythonSourceFile(path: string): boolean { path.endsWith(".py") && !isPythonTestPath(path) && !pythonShouldSkip(path) && - !/(^|\/)(__fixtures__|fixtures|testdata)(\/|$)/u.test(path) && + !isPythonFixturePath(path) && !/(^|\/)[^/]*(?:generated|_pb2|_pb2_grpc|\.gen)\.py$/iu.test(path) ); } +function isPythonFixturePath(path: string): boolean { + return /(^|\/)(__fixtures__|fixtures|testdata)(\/|$)/u.test(path); +} + function isPythonTestPath(path: string): boolean { const name = basename(path); return path.endsWith(".py") && (/^test_[^/]+\.py$/u.test(name) || name.endsWith("_test.py")); From e7e23587b6a2e6a5f16b17c90e64f20aa95d1d99 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 16 May 2026 05:11:21 +0100 Subject: [PATCH 15/18] fix: detect python tool command defaults --- src/detect.ts | 25 ++++++++++++++++++++++--- src/mapper.test.ts | 38 ++++++++++++++++++++++++++++++++++++-- src/mappers/python.ts | 16 +++++++++++++++- 3 files changed, 73 insertions(+), 6 deletions(-) diff --git a/src/detect.ts b/src/detect.ts index ae67548..3140a80 100644 --- a/src/detect.ts +++ b/src/detect.ts @@ -227,6 +227,11 @@ async function detectPackageManagers(root: string): Promise { found.push(name); } } + for (const tool of ["uv", "hatch"]) { + if (!found.includes(tool) && (await pyprojectHasToolSection(root, tool))) { + found.push(tool); + } + } if (!found.some((name) => pythonPackageManagers.has(name)) && (await isPythonProject(root))) { found.push((await pathExists(join(root, "requirements.txt"))) ? "pip" : "python"); } @@ -260,7 +265,7 @@ async function pythonDefaultCommands(root: string): Promise { } async function pythonRunner(root: string): Promise { - if (await pathExists(join(root, "uv.lock"))) { + if ((await pathExists(join(root, "uv.lock"))) || (await pyprojectHasToolSection(root, "uv"))) { return "uv"; } if (await pathExists(join(root, "poetry.lock"))) { @@ -269,12 +274,24 @@ async function pythonRunner(root: string): Promise { if (await pathExists(join(root, "pdm.lock"))) { return "pdm"; } - if (await pathExists(join(root, "hatch.toml"))) { + if ( + (await pathExists(join(root, "hatch.toml"))) || + (await pyprojectHasToolSection(root, "hatch")) + ) { return "hatch"; } return null; } +async function pyprojectHasToolSection(root: string, tool: string): Promise { + if (!(await pathExists(join(root, "pyproject.toml")))) { + return false; + } + const source = await readFile(join(root, "pyproject.toml"), "utf8"); + const escaped = tool.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); + return new RegExp(`^\\s*\\[tool\\.${escaped}(?:\\.|\\])`, "mu").test(source); +} + function pythonRunCommand(runner: string | null, command: string): string { if (runner === "uv") { return `uv run ${command}`; @@ -318,7 +335,7 @@ async function pythonProjectInfo(root: string): Promise { for (const dependency of pythonSetupCfgRequirementNames(source)) { info.dependencies.add(dependency); } - if (/^\s*\[tool:pytest\]|\[pytest\]/mu.test(source)) { + if (/^\s*(?:\[tool:pytest\]|\[pytest\])\s*(?:#.*)?$/mu.test(source)) { info.hasPytestConfig = true; } for (const toolMatch of source.matchAll(/^\s*\[(mypy|pyright|ruff)\]/gmu)) { @@ -334,9 +351,11 @@ function pythonDependencyNames(source: string): string[] { const names = new Set(); for (const table of [ pythonTomlTable(source, "project"), + pythonTomlTable(source, "tool.uv"), pythonTomlTable(source, "tool.poetry"), pythonTomlTable(source, "tool.poetry.group.dev"), pythonTomlTable(source, "tool.pdm.dev-dependencies"), + ...pythonTomlTablesMatching(source, /^tool\.hatch\.envs\.[^.]+$/u), ]) { for (const section of pythonTomlArraySections(table, ["dependencies", "dev-dependencies"])) { for (const value of pythonTomlArrayValues(section)) { diff --git a/src/mapper.test.ts b/src/mapper.test.ts index f990413..c3c3e29 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -1036,6 +1036,19 @@ let package = Package(name: "HybridApp", targets: [.target(name: "HybridApp")]) test: "uv run pytest", }); + const uvDevRoot = await fixtureRoot("clawpatch-python-uv-dev-"); + await writeFixture( + uvDevRoot, + "pyproject.toml", + '[project]\nname = "uv-dev"\n\n[tool.uv]\ndev-dependencies = ["pytest", "ruff", "pyright"]\n', + ); + await writeFixture(uvDevRoot, "uv.lock", ""); + expect((await detectProject(uvDevRoot)).detected.commands).toMatchObject({ + typecheck: "uv run pyright", + lint: "uv run ruff check .", + test: "uv run pytest", + }); + const poetryRoot = await fixtureRoot("clawpatch-python-poetry-"); await writeFixture( poetryRoot, @@ -1061,6 +1074,20 @@ let package = Package(name: "HybridApp", targets: [.target(name: "HybridApp")]) test: "hatch run pytest", }); + const hatchPyprojectRoot = await fixtureRoot("clawpatch-python-hatch-pyproject-"); + await writeFixture( + hatchPyprojectRoot, + "pyproject.toml", + '[project]\nname = "hatch-pyproject"\n\n[tool.hatch.envs.default]\ndependencies = ["pytest", "ruff"]\n', + ); + expect((await detectProject(hatchPyprojectRoot)).detected).toMatchObject({ + packageManagers: ["hatch"], + commands: { + lint: "hatch run ruff check .", + test: "hatch run pytest", + }, + }); + const setupCfgRoot = await fixtureRoot("clawpatch-python-setup-cfg-tools-"); await writeFixture( setupCfgRoot, @@ -1086,6 +1113,14 @@ let package = Package(name: "HybridApp", targets: [.target(name: "HybridApp")]) test: null, }); + const setupCfgCommentRoot = await fixtureRoot("clawpatch-python-setup-cfg-pytest-comment-"); + await writeFixture( + setupCfgCommentRoot, + "setup.cfg", + "[metadata]\nname = comment-only\n# [pytest]\ndescription = mentions [pytest]\n", + ); + expect((await detectProject(setupCfgCommentRoot)).detected.commands.test).toBeNull(); + const setupCfgExtrasValueRoot = await fixtureRoot("clawpatch-python-setup-cfg-extras-value-"); await writeFixture( setupCfgExtrasValueRoot, @@ -1192,9 +1227,8 @@ let package = Package(name: "HybridApp", targets: [.target(name: "HybridApp")]) await writeFixture( root, "pyproject.toml", - '[project]\nname = "hatch-map"\ndependencies = ["pytest"]\n', + '[project]\nname = "hatch-map"\n\n[tool.hatch.envs.default]\ndependencies = ["pytest"]\n', ); - await writeFixture(root, "hatch.toml", ""); await writeFixture(root, "src/hatch_map/app.py", "def app():\n pass\n"); await writeFixture(root, "src/hatch_map/test_app.py", "def test_app():\n pass\n"); diff --git a/src/mappers/python.ts b/src/mappers/python.ts index 8f51e2c..0bebb86 100644 --- a/src/mappers/python.ts +++ b/src/mappers/python.ts @@ -186,12 +186,24 @@ async function pythonTestCommand(root: string, pyproject: PyprojectInfo): Promis if (await pathExists(join(root, "pdm.lock"))) { return "pdm run pytest"; } - if (await pathExists(join(root, "hatch.toml"))) { + if ( + (await pathExists(join(root, "hatch.toml"))) || + (await pyprojectHasToolSection(root, "hatch")) + ) { return "hatch run pytest"; } return "pytest"; } +async function pyprojectHasToolSection(root: string, tool: string): Promise { + if (!(await pathExists(join(root, "pyproject.toml")))) { + return false; + } + const source = await readFile(join(root, "pyproject.toml"), "utf8"); + const escaped = tool.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); + return new RegExp(`^\\s*\\[tool\\.${escaped}(?:\\.|\\])`, "mu").test(source); +} + async function dependencyFileHas(root: string, dependency: string): Promise { if (await pathExists(join(root, "requirements.txt"))) { const source = await readFile(join(root, "requirements.txt"), "utf8"); @@ -603,9 +615,11 @@ function dependencyNames(source: string): Set { } } for (const dependencyTable of [ + table(source, "tool.uv"), table(source, "tool.pdm.dev-dependencies"), table(source, "tool.poetry.dependencies"), table(source, "tool.poetry.dev-dependencies"), + ...tablesMatching(source, /^tool\.hatch\.envs\.[^.]+$/u), ...tablesMatching(source, /^tool\.poetry\.group\.[^.]+\.dependencies$/u), ]) { for (const value of assignedKeysAndValues(dependencyTable)) { From affa7673f1e64055129144794448a66fb17a413e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 16 May 2026 05:18:45 +0100 Subject: [PATCH 16/18] fix: ignore commented python metadata --- src/detect.ts | 4 ++-- src/mapper.test.ts | 50 +++++++++++++++++++++++++++++++++++++++++++ src/mappers/python.ts | 8 +++---- 3 files changed, 56 insertions(+), 6 deletions(-) diff --git a/src/detect.ts b/src/detect.ts index 3140a80..7a99d71 100644 --- a/src/detect.ts +++ b/src/detect.ts @@ -459,7 +459,7 @@ const pythonTomlHeaderPattern = /^\s*\[\[?[^\]]+\]\]?\s*(?:#.*)?$/mu; function pythonTomlAssignedValues(source: string): string[] { const values: string[] = []; - for (const match of source.matchAll(/^\s*["']?[^"'=\s]+["']?\s*=\s*/gmu)) { + for (const match of source.matchAll(/^\s*["']?[^#"'=\s]+["']?\s*=\s*/gmu)) { if (match.index === undefined) { continue; } @@ -478,7 +478,7 @@ function pythonTomlAssignedValues(source: string): string[] { function pythonTomlAssignedKeysAndValues(source: string): string[] { const values = pythonTomlAssignedValues(source); for (const line of source.split("\n")) { - const key = /^\s*["']?([^"'=\s]+)["']?\s*=/u.exec(line)?.[1]; + const key = /^\s*["']?([^#"'=\s]+)["']?\s*=/u.exec(line)?.[1]; if (key !== undefined) { values.push(key); } diff --git a/src/mapper.test.ts b/src/mapper.test.ts index c3c3e29..e9763f5 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -1195,6 +1195,19 @@ let package = Package(name: "HybridApp", targets: [.target(name: "HybridApp")]) test: null, }); + const commentedGroupRoot = await fixtureRoot("clawpatch-python-commented-groups-"); + await writeFixture( + commentedGroupRoot, + "pyproject.toml", + '[project]\nname = "commented-groups"\n\n[dependency-groups]\n#dev = ["pytest", "ruff"]\n', + ); + expect((await detectProject(commentedGroupRoot)).detected.commands).toEqual({ + typecheck: null, + lint: null, + format: null, + test: null, + }); + const dependencyGroupRoot = await fixtureRoot("clawpatch-python-dependency-groups-"); await writeFixture( dependencyGroupRoot, @@ -1242,6 +1255,24 @@ let package = Package(name: "HybridApp", targets: [.target(name: "HybridApp")]) ]); }); + it("uses uv pytest commands from pyproject uv config in mapped Python features", async () => { + const root = await fixtureRoot("clawpatch-python-uv-pyproject-map-"); + await writeFixture( + root, + "pyproject.toml", + '[project]\nname = "uv-map"\n\n[tool.uv]\ndev-dependencies = ["pytest"]\n', + ); + await writeFixture(root, "src/uv_map/app.py", "def app():\n pass\n"); + await writeFixture(root, "src/uv_map/test_app.py", "def test_app():\n pass\n"); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const source = result.features.find((feature) => feature.title === "Python source src"); + + expect(project.detected.commands.test).toBe("uv run pytest"); + expect(source?.tests).toEqual([{ path: "src/uv_map/test_app.py", command: "uv run pytest" }]); + }); + it("maps Python metadata-only projects without pyproject", async () => { const root = await fixtureRoot("clawpatch-python-legacy-metadata-"); await writeFixture(root, "setup.cfg", "[metadata]\nname = legacy\n"); @@ -1339,6 +1370,25 @@ let package = Package(name: "HybridApp", targets: [.target(name: "HybridApp")]) expect(commands).toEqual(["real"]); }); + it("does not map commented Python console scripts", async () => { + const root = await fixtureRoot("clawpatch-python-commented-script-"); + await writeFixture( + root, + "pyproject.toml", + '[project]\nname = "commented-script"\n\n[project.scripts]\n#old = "pkg.old:main"\nreal = "pkg.cli:main"\n', + ); + await writeFixture(root, "pkg/__init__.py", ""); + await writeFixture(root, "pkg/cli.py", "def main():\n pass\n"); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const commands = result.features + .filter((feature) => feature.source === "python-console-script") + .map((feature) => feature.entrypoints[0]?.command); + + expect(commands).toEqual(["real"]); + }); + it("groups colocated Python pytest suites by their actual directory", async () => { const root = await fixtureRoot("clawpatch-python-colocated-test-groups-"); await writeFixture(root, "pyproject.toml", '[project]\nname = "colocated-tests"\n'); diff --git a/src/mappers/python.ts b/src/mappers/python.ts index 0bebb86..ebc8360 100644 --- a/src/mappers/python.ts +++ b/src/mappers/python.ts @@ -177,7 +177,7 @@ async function pythonTestCommand(root: string, pyproject: PyprojectInfo): Promis ) { return null; } - if (await pathExists(join(root, "uv.lock"))) { + if ((await pathExists(join(root, "uv.lock"))) || (await pyprojectHasToolSection(root, "uv"))) { return "uv run pytest"; } if (await pathExists(join(root, "poetry.lock"))) { @@ -596,7 +596,7 @@ function tomlStringValue(source: string, key: string): string | null { function scriptsFromTable(source: string): PythonScript[] { const scripts: PythonScript[] = []; for (const line of source.split("\n")) { - const match = /^\s*["']?([^"'=\s]+)["']?\s*=\s*(["'])([^"']+)\2/u.exec(line); + const match = /^\s*["']?([^#"'=\s]+)["']?\s*=\s*(["'])([^"']+)\2/u.exec(line); if (match?.[1] !== undefined && match[3] !== undefined) { scripts.push({ name: match[1], target: match[3] }); } @@ -657,7 +657,7 @@ function tomlArrayAssignments(source: string, keys: string[]): string[] { function assignedValues(source: string): string[] { const values: string[] = []; - for (const match of source.matchAll(/^\s*["']?[^"'=\s]+["']?\s*=\s*/gmu)) { + for (const match of source.matchAll(/^\s*["']?[^#"'=\s]+["']?\s*=\s*/gmu)) { if (match.index === undefined) { continue; } @@ -676,7 +676,7 @@ function assignedValues(source: string): string[] { function assignedKeysAndValues(source: string): string[] { const values = assignedValues(source); for (const line of source.split("\n")) { - const key = /^\s*["']?([^"'=\s]+)["']?\s*=/u.exec(line)?.[1]; + const key = /^\s*["']?([^#"'=\s]+)["']?\s*=/u.exec(line)?.[1]; if (key !== undefined) { values.push(key); } From edfc402f2a553318260f6349359f80ee51916bc9 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 16 May 2026 05:24:50 +0100 Subject: [PATCH 17/18] fix: honor python pyproject runners --- src/detect.ts | 9 ++++-- src/mapper.test.ts | 64 +++++++++++++++++++++++++++++++++++++++++++ src/mappers/python.ts | 7 +++-- 3 files changed, 75 insertions(+), 5 deletions(-) diff --git a/src/detect.ts b/src/detect.ts index 7a99d71..bd3ad3b 100644 --- a/src/detect.ts +++ b/src/detect.ts @@ -227,7 +227,7 @@ async function detectPackageManagers(root: string): Promise { found.push(name); } } - for (const tool of ["uv", "hatch"]) { + for (const tool of ["uv", "poetry", "pdm", "hatch"]) { if (!found.includes(tool) && (await pyprojectHasToolSection(root, tool))) { found.push(tool); } @@ -268,10 +268,13 @@ async function pythonRunner(root: string): Promise { if ((await pathExists(join(root, "uv.lock"))) || (await pyprojectHasToolSection(root, "uv"))) { return "uv"; } - if (await pathExists(join(root, "poetry.lock"))) { + if ( + (await pathExists(join(root, "poetry.lock"))) || + (await pyprojectHasToolSection(root, "poetry")) + ) { return "poetry"; } - if (await pathExists(join(root, "pdm.lock"))) { + if ((await pathExists(join(root, "pdm.lock"))) || (await pyprojectHasToolSection(root, "pdm"))) { return "pdm"; } if ( diff --git a/src/mapper.test.ts b/src/mapper.test.ts index e9763f5..09f3f61 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -1062,6 +1062,20 @@ let package = Package(name: "HybridApp", targets: [.target(name: "HybridApp")]) test: "poetry run pytest", }); + const poetryPyprojectRoot = await fixtureRoot("clawpatch-python-poetry-pyproject-"); + await writeFixture( + poetryPyprojectRoot, + "pyproject.toml", + '[tool.poetry]\nname = "poetry-pyproject"\n\n[tool.poetry.group.dev.dependencies]\npytest = "^8"\nruff = "^0.5"\n', + ); + expect((await detectProject(poetryPyprojectRoot)).detected).toMatchObject({ + packageManagers: ["poetry"], + commands: { + lint: "poetry run ruff check .", + test: "poetry run pytest", + }, + }); + const hatchRoot = await fixtureRoot("clawpatch-python-hatch-"); await writeFixture( hatchRoot, @@ -1165,6 +1179,20 @@ let package = Package(name: "HybridApp", targets: [.target(name: "HybridApp")]) test: "pdm run pytest", }); + const pdmPyprojectNoLockRoot = await fixtureRoot("clawpatch-python-pdm-pyproject-no-lock-"); + await writeFixture( + pdmPyprojectNoLockRoot, + "pyproject.toml", + '[tool.pdm.dev-dependencies]\ndev = ["pytest", "ruff"]\n', + ); + expect((await detectProject(pdmPyprojectNoLockRoot)).detected).toMatchObject({ + packageManagers: ["pdm"], + commands: { + lint: "pdm run ruff check .", + test: "pdm run pytest", + }, + }); + const directRoot = await fixtureRoot("clawpatch-python-direct-"); await writeFixture(directRoot, "setup.py", "from setuptools import setup\n"); await writeFixture(directRoot, "tests/test_app.py", "def test_app():\n pass\n"); @@ -1273,6 +1301,42 @@ let package = Package(name: "HybridApp", targets: [.target(name: "HybridApp")]) expect(source?.tests).toEqual([{ path: "src/uv_map/test_app.py", command: "uv run pytest" }]); }); + it("uses Poetry and PDM pytest commands from pyproject tool config in mapped Python features", async () => { + const poetryRoot = await fixtureRoot("clawpatch-python-poetry-pyproject-map-"); + await writeFixture( + poetryRoot, + "pyproject.toml", + '[tool.poetry]\nname = "poetry-map"\n\n[tool.poetry.group.dev.dependencies]\npytest = "^8"\n', + ); + await writeFixture(poetryRoot, "src/poetry_map/app.py", "def app():\n pass\n"); + await writeFixture(poetryRoot, "src/poetry_map/test_app.py", "def test_app():\n pass\n"); + + const poetryProject = await detectProject(poetryRoot); + const poetryResult = await mapFeatures(poetryRoot, poetryProject, []); + const poetrySource = poetryResult.features.find( + (feature) => feature.title === "Python source src", + ); + expect(poetrySource?.tests).toEqual([ + { path: "src/poetry_map/test_app.py", command: "poetry run pytest" }, + ]); + + const pdmRoot = await fixtureRoot("clawpatch-python-pdm-pyproject-map-"); + await writeFixture( + pdmRoot, + "pyproject.toml", + '[tool.pdm.dev-dependencies]\ndev = ["pytest"]\n', + ); + await writeFixture(pdmRoot, "src/pdm_map/app.py", "def app():\n pass\n"); + await writeFixture(pdmRoot, "src/pdm_map/test_app.py", "def test_app():\n pass\n"); + + const pdmProject = await detectProject(pdmRoot); + const pdmResult = await mapFeatures(pdmRoot, pdmProject, []); + const pdmSource = pdmResult.features.find((feature) => feature.title === "Python source src"); + expect(pdmSource?.tests).toEqual([ + { path: "src/pdm_map/test_app.py", command: "pdm run pytest" }, + ]); + }); + it("maps Python metadata-only projects without pyproject", async () => { const root = await fixtureRoot("clawpatch-python-legacy-metadata-"); await writeFixture(root, "setup.cfg", "[metadata]\nname = legacy\n"); diff --git a/src/mappers/python.ts b/src/mappers/python.ts index ebc8360..f889ee3 100644 --- a/src/mappers/python.ts +++ b/src/mappers/python.ts @@ -180,10 +180,13 @@ async function pythonTestCommand(root: string, pyproject: PyprojectInfo): Promis if ((await pathExists(join(root, "uv.lock"))) || (await pyprojectHasToolSection(root, "uv"))) { return "uv run pytest"; } - if (await pathExists(join(root, "poetry.lock"))) { + if ( + (await pathExists(join(root, "poetry.lock"))) || + (await pyprojectHasToolSection(root, "poetry")) + ) { return "poetry run pytest"; } - if (await pathExists(join(root, "pdm.lock"))) { + if ((await pathExists(join(root, "pdm.lock"))) || (await pyprojectHasToolSection(root, "pdm"))) { return "pdm run pytest"; } if ( From 4a306961486f7590ce9a4d3c3e405dacd7187690 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 16 May 2026 05:31:53 +0100 Subject: [PATCH 18/18] fix: detect python array-table tool config --- src/detect.ts | 6 ++++-- src/mapper.test.ts | 33 +++++++++++++++++++++++++++++++++ src/mappers/python.ts | 2 +- 3 files changed, 38 insertions(+), 3 deletions(-) diff --git a/src/detect.ts b/src/detect.ts index bd3ad3b..b4f69c1 100644 --- a/src/detect.ts +++ b/src/detect.ts @@ -292,7 +292,7 @@ async function pyprojectHasToolSection(root: string, tool: string): Promise(); - for (const match of source.matchAll(/^\s*\[tool\.([A-Za-z0-9_.-]+)[^\]]*\]\s*(?:#.*)?$/gmu)) { + for (const match of source.matchAll( + /^\s*\[\[?tool\.([A-Za-z0-9_.-]+)[^\]]*\]\]?\s*(?:#.*)?$/gmu, + )) { const name = match[1]?.split(".")[0]; if (name !== undefined) { tools.add(name); diff --git a/src/mapper.test.ts b/src/mapper.test.ts index 09f3f61..3ef283e 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -1049,6 +1049,19 @@ let package = Package(name: "HybridApp", targets: [.target(name: "HybridApp")]) test: "uv run pytest", }); + const uvArrayRoot = await fixtureRoot("clawpatch-python-uv-array-table-"); + await writeFixture( + uvArrayRoot, + "pyproject.toml", + '[project]\nname = "uv-array"\ndependencies = ["pytest"]\n\n[[tool.uv.index]]\nname = "private"\nurl = "https://example.invalid/simple"\n', + ); + expect((await detectProject(uvArrayRoot)).detected).toMatchObject({ + packageManagers: ["uv"], + commands: { + test: "uv run pytest", + }, + }); + const poetryRoot = await fixtureRoot("clawpatch-python-poetry-"); await writeFixture( poetryRoot, @@ -1301,6 +1314,26 @@ let package = Package(name: "HybridApp", targets: [.target(name: "HybridApp")]) expect(source?.tests).toEqual([{ path: "src/uv_map/test_app.py", command: "uv run pytest" }]); }); + it("uses uv pytest commands from pyproject uv array-table config in mapped Python features", async () => { + const root = await fixtureRoot("clawpatch-python-uv-array-map-"); + await writeFixture( + root, + "pyproject.toml", + '[project]\nname = "uv-array-map"\ndependencies = ["pytest"]\n\n[[tool.uv.index]]\nname = "private"\nurl = "https://example.invalid/simple"\n', + ); + await writeFixture(root, "src/uv_array_map/app.py", "def app():\n pass\n"); + await writeFixture(root, "src/uv_array_map/test_app.py", "def test_app():\n pass\n"); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const source = result.features.find((feature) => feature.title === "Python source src"); + + expect(project.detected.commands.test).toBe("uv run pytest"); + expect(source?.tests).toEqual([ + { path: "src/uv_array_map/test_app.py", command: "uv run pytest" }, + ]); + }); + it("uses Poetry and PDM pytest commands from pyproject tool config in mapped Python features", async () => { const poetryRoot = await fixtureRoot("clawpatch-python-poetry-pyproject-map-"); await writeFixture( diff --git a/src/mappers/python.ts b/src/mappers/python.ts index f889ee3..fb51e6c 100644 --- a/src/mappers/python.ts +++ b/src/mappers/python.ts @@ -204,7 +204,7 @@ async function pyprojectHasToolSection(root: string, tool: string): Promise {