From aec351dce4c7dbd501e44980cb19c77a0fc131f8 Mon Sep 17 00:00:00 2001 From: abhinav-phi Date: Wed, 9 Sep 2026 05:46:44 +0530 Subject: [PATCH] feat(drift): check dependency claims against pyproject.toml MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit loadAllDependencies read only package.json files, so Python dependency claims (FastAPI, Celery, SQLAlchemy...) were reported as DEPENDENCY_MISSING whenever the project declared them in pyproject.toml instead. Add a bounded line-scan parser over the shapes a claim checker needs: the dependencies array inside [project], per-extra arrays in [project.optional-dependencies], and key-value pairs in [tool.poetry.dependencies]. No TOML dependency — the package name is the identity and the raw version specifier rides along as evidence. PEP 508 extras (celery[redis]) parse to the base package name. Per the issue's one-ecosystem-at-a-time guidance, this ships pyproject only; Cargo.toml and go.mod stay stubs for a follow-up. Resolves #3 --- src/drift/checkers/dependency.ts | 68 ++++++++++++++++++++++++++++++++ test/checkers.test.ts | 34 ++++++++++++++++ 2 files changed, 102 insertions(+) diff --git a/src/drift/checkers/dependency.ts b/src/drift/checkers/dependency.ts index 738de608..e994c66f 100644 --- a/src/drift/checkers/dependency.ts +++ b/src/drift/checkers/dependency.ts @@ -125,6 +125,16 @@ function loadAllDependencies(projectRoot: string): DepEntry[] | null { } } + // pyproject.toml (#3): [project] dependencies and optional-dependencies, + // plus [tool.poetry.dependencies]. Python claims ("FastAPI", "Celery") were + // reported missing whenever the project declared them here instead of a + // package.json. Version specifiers are kept verbatim — the version-claims + // checker treats them as substrings, and PEP 508 names are the identity. + const pyprojectPath = resolve(projectRoot, "pyproject.toml"); + if (existsSync(pyprojectPath)) { + entries.push(...parsePyprojectDependencies(readFileSync(pyprojectPath, "utf-8"))); + } + // A repository often keeps a second application in a subdirectory without // declaring workspaces, and that application's packages are declared in its // own manifest. Reading only the root one reported every dependency the @@ -148,3 +158,61 @@ function loadAllDependencies(projectRoot: string): DepEntry[] | null { return entries.length ? entries : null; } + +/** + * Extract dependency names from a pyproject.toml without a TOML dependency. + * + * Bounded line-scan over the shapes the drift checker cares about: the + * `dependencies` array inside `[project]`, the per-extra arrays inside + * `[project.optional-dependencies]`, and the key-value pairs inside + * `[tool.poetry.dependencies]`. The package name is the identity; the raw + * version specifier is kept as evidence. Dynamic declarations + * (`dynamic = ["dependencies"]`) and everything outside these tables are out + * of scope for a checker that only needs name identity. + */ +export function parsePyprojectDependencies(content: string): DepEntry[] { + const entries: DepEntry[] = []; + let table = ""; + for (const rawLine of content.split(/\r?\n/)) { + const line = rawLine.trim(); + const header = /^\[([^\]]+)\]\s*(?:#.*)?$/.exec(line); + if (header) { + table = header[1]!.trim(); + continue; + } + if (!line || line.startsWith("#") || line.startsWith("[")) continue; + + const keyValue = /^["']?([A-Za-z0-9][\w.-]*)["']?\s*=\s*(.*)$/.exec(line); + if (!keyValue) continue; + const key = keyValue[1]!; + const value = keyValue[2]!.trim(); + + if (table === "project" && key === "dependencies") { + for (const { name, version } of parseDependencyArray(value)) entries.push({ name, version }); + continue; + } + if (table === "project.optional-dependencies") { + for (const { name, version } of parseDependencyArray(value)) entries.push({ name, version }); + continue; + } + if (table === "tool.poetry.dependencies") { + // `python = "^3.12"` is the interpreter constraint, not a package. + if (key.toLowerCase() === "python") continue; + entries.push({ name: key, version: value.replace(/^["']|["'],?\s*$/g, "") || "*" }); + continue; + } + } + return entries; +} + +/** Names out of `["pkg>=1", "pkg2"]`-style arrays (PEP 508 specs included). */ +function parseDependencyArray(value: string): Array<{ name: string; version: string }> { + const inner = /\[\s*(.*)\]/.exec(value)?.[1] ?? value; + const out: Array<{ name: string; version: string }> = []; + for (const item of inner.matchAll(/["']([^"']+)["']/g)) { + const spec = item[1]!; + const name = /^([A-Za-z0-9][\w.-]*)/.exec(spec)?.[1]; + if (name) out.push({ name, version: spec.slice(name.length) || "*" }); + } + return out; +} diff --git a/test/checkers.test.ts b/test/checkers.test.ts index b59eaf96..75592c0a 100644 --- a/test/checkers.test.ts +++ b/test/checkers.test.ts @@ -452,6 +452,40 @@ describe("checkDependencies", () => { const issues = checkDependencies(claims, tmpDir); expect(issues).toHaveLength(0); }); + + it("checks claims against pyproject.toml [project] dependencies (#3)", () => { + writeFileSync(join(tmpDir, "pyproject.toml"), [ + "[project]", + 'name = "svc"', + 'dependencies = ["fastapi>=0.115", "celery[redis]==5.4.0"]', + "", + ].join("\n")); + const issues = checkDependencies([ + claim({ kind: "dependency", value: "FastAPI" }), + claim({ kind: "dependency", value: "celery" }), + claim({ kind: "dependency", value: "boto3" }), + ], tmpDir); + expect(issues).toHaveLength(1); + expect(issues[0].claim.value).toBe("boto3"); + }); + + it("reads pyproject optional-dependencies and poetry tables", () => { + writeFileSync(join(tmpDir, "pyproject.toml"), [ + "[project.optional-dependencies]", + 'dev = ["pytest>=8.0", "httpx"]', + "", + "[tool.poetry.dependencies]", + 'python = "^3.12"', + 'SQLAlchemy = "^2.0"', + "", + ].join("\n")); + const issues = checkDependencies([ + claim({ kind: "dependency", value: "pytest" }), + claim({ kind: "dependency", value: "httpx" }), + claim({ kind: "dependency", value: "SQLAlchemy" }), + ], tmpDir); + expect(issues).toHaveLength(0); + }); }); // ── Cross-file Checker ──