diff --git a/README.md b/README.md index d15039a..e6ad70f 100644 --- a/README.md +++ b/README.md @@ -53,13 +53,20 @@ V1 scans these local files without executing project commands: - `Makefile` targets - `Justfile` recipes - `Taskfile.yml` / `Taskfile.yaml` tasks -- `pyproject.toml` scripts/tasks +- `pyproject.toml` installed console scripts from `[project.scripts]` and + `[tool.poetry.scripts]`, plus string commands from `[tool.poe.tasks]` - `Cargo.toml` default cargo workflows - README command snippets - files under `scripts/` Every finding includes file and line evidence so humans and agents can inspect the source. +For standards-defined Python console scripts, the table key is the installed +runnable command: `verify = "demo.cli:main"` is reported as `verify`, not as the +Python object reference. Poe string tasks retain their command value, so +`check = "pytest -q"` is reported as `pytest -q`. Complex, multiline, malformed, +or otherwise unsupported pyproject values are ignored rather than guessed. + Malformed `package.json` files stop the scan instead of producing an incomplete command map. The diagnostic names the offending manifest; invalid `scripts` entries also identify the script and evidence line. Script values must be diff --git a/fixtures/polyrepo/pyproject.toml b/fixtures/polyrepo/pyproject.toml index 1990ef5..7343b27 100644 --- a/fixtures/polyrepo/pyproject.toml +++ b/fixtures/polyrepo/pyproject.toml @@ -2,5 +2,11 @@ name = "polyrepo-fixture" [project.scripts] +fixture-cli = "polyrepo.cli:main" + +[tool.poetry.scripts] +fixture-admin = "polyrepo.admin:main" + +[tool.poe.tasks] fixture-test = "pytest" fixture-secret = "python scripts/read_secret.py" diff --git a/src/parsers.ts b/src/parsers.ts index 42d3c83..90d20f7 100644 --- a/src/parsers.ts +++ b/src/parsers.ts @@ -94,9 +94,14 @@ function parsePyproject(file: CandidateFile): RawCommand[] { const line = file.lines[index].trim(); const header = /^\[([^\]]+)\]/.exec(line); if (header) section = header[1]; - const script = /^([A-Za-z0-9_.-]+)\s*=\s*["']([^"']+)["']/.exec(line); - if (script && ["project.scripts", "tool.poetry.scripts", "tool.poe.tasks"].includes(section)) { - out.push({ name: script[1], command: script[2], runner: "python", evidence: evidence(file, index + 1) }); + const script = /^(?:([A-Za-z0-9_.-]+)|"([^"]+)"|'([^']+)')\s*=\s*(["'])(.*?)\4\s*(?:#.*)?$/.exec(line); + if (!script) continue; + const name = script[1] ?? script[2] ?? script[3]; + const value = script[5]; + if (["project.scripts", "tool.poetry.scripts"].includes(section)) { + out.push({ name, command: name, runner: "python", evidence: evidence(file, index + 1) }); + } else if (section === "tool.poe.tasks") { + out.push({ name, command: value, runner: "poe", evidence: evidence(file, index + 1) }); } } return out; diff --git a/test/scan.test.ts b/test/scan.test.ts index cd58ea6..15ff4b2 100644 --- a/test/scan.test.ts +++ b/test/scan.test.ts @@ -35,6 +35,42 @@ async function withManifest(content: string, run: (root: string) => Promise Promise): Promise { + const root = await mkdtemp(path.join(os.tmpdir(), "cmdmap-pyproject-")); + try { + await writeFile(path.join(root, "pyproject.toml"), content); + await run(root); + } finally { + await rm(root, { recursive: true, force: true }); + } +} + +test("scan distinguishes installed Python scripts from Poe task commands", async () => { + const manifest = `[project.scripts]\nverify = "demo.cli:main"\n\n[tool.poetry.scripts]\n"serve-local" = "demo.server:start"\n\n[tool.poe.tasks]\ncheck = "pytest -q"\n`; + await withPyproject(manifest, async (root) => { + const first = await scan({ cwd: root }); + const second = await scan({ cwd: root }); + + assert.deepEqual(first.findings.map(({ name, command, runner, severity, evidence }) => ({ name, command, runner, severity, evidence })), [ + { name: "check", command: "pytest -q", runner: "poe", severity: "safe", evidence: { file: "pyproject.toml", line: 8, source: 'check = "pytest -q"' } }, + { name: "serve-local", command: "serve-local", runner: "python", severity: "caution", evidence: { file: "pyproject.toml", line: 5, source: '"serve-local" = "demo.server:start"' } }, + { name: "verify", command: "verify", runner: "python", severity: "caution", evidence: { file: "pyproject.toml", line: 2, source: 'verify = "demo.cli:main"' } }, + ]); + assert.deepEqual(first.findings.map((finding) => finding.id), second.findings.map((finding) => finding.id)); + }); +}); + +test("scan ignores unsupported and malformed pyproject script values", async () => { + const manifest = `[project.scripts]\nvalid = "demo.cli:main"\ninvalid-array = ["demo.cli:main"]\ninvalid-table = { call = "demo.cli:main" }\ninvalid-bare = demo.cli:main\n\n[tool.poe.tasks]\nvalid-task = "ruff check"\ncomplex-task = { cmd = "pytest" }\nunterminated = "pytest\n`; + await withPyproject(manifest, async (root) => { + const result = await scan({ cwd: root }); + assert.deepEqual(result.findings.map(({ name, command, runner }) => ({ name, command, runner })), [ + { name: "valid-task", command: "ruff check", runner: "poe" }, + { name: "valid", command: "valid", runner: "python" }, + ]); + }); +}); + test("scan identifies malformed package manifests", async () => { await withManifest('{"scripts": {"test": "node test.js"}', async (root) => { await assert.rejects(scan({ cwd: root }), /Invalid package\.json .*package\.json: .*position/);