diff --git a/CHANGELOG.md b/CHANGELOG.md index 8024871..980e7e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## Unreleased - Added Next.js route mapping for `src/app` and `src/pages` layouts, thanks @obatried. +- Added first-pass Python mapping for project metadata, console scripts, source groups, pytest suites, and conservative validation defaults, thanks @xiamx. - 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..b4f69c1 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,421 @@ 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); + } + } + for (const tool of ["uv", "poetry", "pdm", "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"); + } 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"))) || (await pyprojectHasToolSection(root, "uv"))) { + return "uv"; + } + if ( + (await pathExists(join(root, "poetry.lock"))) || + (await pyprojectHasToolSection(root, "poetry")) + ) { + return "poetry"; + } + if ((await pathExists(join(root, "pdm.lock"))) || (await pyprojectHasToolSection(root, "pdm"))) { + return "pdm"; + } + 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}`; + } + if (runner === "poetry") { + return `poetry run ${command}`; + } + if (runner === "pdm") { + return `pdm run ${command}`; + } + if (runner === "hatch") { + return `hatch 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"); + } + 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\])\s*(?:#.*)?$/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]); + } + } + } + return info; +} + +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)) { + const name = pythonRequirementName(value); + if (name !== null) { + names.add(name); + } + } + } + } + for (const table of pythonTomlTables(source, [ + "tool.poetry.dependencies", + "tool.poetry.dev-dependencies", + ]).concat(pythonTomlTablesMatching(source, /^tool\.poetry\.group\.[^.]+\.dependencies$/u))) { + 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", + "tool.pdm.dev-dependencies", + ])) { + 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 = pythonTomlHeaderPattern.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 = pythonTomlHeaderPattern.exec(rest); + tables.push(next?.index === undefined ? rest : rest.slice(0, next.index)); + } + } + 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 = 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)) { + 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)); + } + 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 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 { + 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 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("-")) { + 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 +699,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 +729,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 +784,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 7c723e2..3ef283e 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -913,6 +913,657 @@ 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] # 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", ""); + 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("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("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( + 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 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 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, + "pyproject.toml", + '[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 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, + "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 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, + "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 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 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, + "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, + "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", ""); + expect((await detectProject(pdmRoot)).detected.commands).toMatchObject({ + typecheck: "pdm run ruff check .", + lint: "pdm run ruff check .", + 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 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"); + 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, + }); + + 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, + "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 tests"); + + 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("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"\n\n[tool.hatch.envs.default]\ndependencies = ["pytest"]\n', + ); + 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("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("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( + 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"); + 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 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 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("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("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'); + 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("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("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("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"); + + 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( @@ -925,15 +1576,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..fb51e6c --- /dev/null +++ b/src/mappers/python.ts @@ -0,0 +1,826 @@ +import { readFile, readdir } from "node:fs/promises"; +import { basename, dirname, join } from "node:path"; +import { pathExists } from "../fs.js"; +import { + isSafeDirectory, + isSafeFile, + 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 projectMetadataFiles = [ + "pyproject.toml", + "setup.py", + "setup.cfg", + "requirements.txt", +] 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 metadataFiles = await pythonMetadataFiles(root); + const testCommand = await pythonTestCommand(root, pyproject); + const testFiles = await pythonTestFiles(root); + const seeds: FeatureSeed[] = []; + + if (metadataFiles.length > 0) { + seeds.push({ + title: `Python project ${pyproject.name ?? basename(root)}`, + summary: `Python project metadata in ${metadataFiles.join(", ")}.`, + kind: packageKind(pyproject.name ?? basename(root)), + source: "python-project", + confidence: "medium", + entryPath: metadataFiles[0] ?? "pyproject.toml", + symbol: pyproject.name, + route: null, + command: null, + 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, + }); + } + + 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.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 containsReviewablePythonSource(root)) + ); +} + +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 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 && + !(await dependencyFileHas(root, "pytest")) && + (await pythonTestFiles(root)).length === 0 + ) { + return null; + } + if ((await pathExists(join(root, "uv.lock"))) || (await pyprojectHasToolSection(root, "uv"))) { + return "uv run pytest"; + } + if ( + (await pathExists(join(root, "poetry.lock"))) || + (await pyprojectHasToolSection(root, "poetry")) + ) { + return "poetry run pytest"; + } + if ((await pathExists(join(root, "pdm.lock"))) || (await pyprojectHasToolSection(root, "pdm"))) { + return "pdm run pytest"; + } + 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"); + 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; +} + +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 { + const rootTests = await rootPythonTestFiles(root); + const nestedTests = (await walk(root, ["tests", "test", ...(await pythonSourceRoots(root))])) + .filter(isPythonTestPath) + .filter((path) => !pythonShouldSkip(path) && !isPythonFixturePath(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, + 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 (!owned.has(path) && (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 isSafeFile(root, 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 []; + } + 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", + source: "python-test-suite", + confidence: "medium", + entryPath: 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 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 (!path.includes("/") && (/^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[], + 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) { + if (sourceRoot === "tests") { + return sourceRoot; + } + 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); +} + +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) || !test.includes("/"))) + ); + }) + .slice(0, sourceGroupMaxTests) + .map((path) => ({ path, command })); +} + +function isReviewablePythonSourceFile(path: string): boolean { + return ( + path.endsWith(".py") && + !isPythonTestPath(path) && + !pythonShouldSkip(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")); +} + +function pythonShouldSkip(path: string): boolean { + return ( + shouldSkip(path) || + /(^|\/)(\.venv|venv|__pycache__|\.mypy_cache|\.ruff_cache|\.pytest_cache)(\/|$)/u.test(path) + ); +} + +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); + if (match?.index === undefined) { + return ""; + } + const rest = source.slice(match.index + match[0].length); + const nextSection = tomlHeaderPattern.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)) { + 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 = 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; +} + +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.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)) { + const name = requirementName(value); + if (name !== null) { + names.add(name); + } + } + } + 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); + 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 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)); + } + 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 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 { + 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 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("-")) { + return 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)]; +} 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; }