From 43c4146fb5d7df48194cc4d5080c182d35d2c033 Mon Sep 17 00:00:00 2001 From: Rene Zander Date: Sat, 3 Oct 2026 12:41:18 +0000 Subject: [PATCH] Fix Python groups and pnpm workspace lock validation --- CHANGELOG.md | 11 +++ docs/spec-reference.md | 16 +++- package-lock.json | 17 +++- package.json | 3 +- src/deps.ts | 148 ++++++++++++++++++++----------- src/receipt.ts | 3 + test/dependency-lock.test.ts | 131 +++++++++++++++++++++++++++ test/enforcement-release.test.ts | 3 +- 8 files changed, 276 insertions(+), 56 deletions(-) create mode 100644 test/dependency-lock.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 42e77c6..c8185fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ ## Unreleased +## 0.13.1 - 2026-10-03 + +### Fixed +- Dependency checks parse Python manifests and lockfiles as TOML, include standard + dependency groups and every Poetry group, and validate group includes. Invalid + structures, unknown groups, duplicate normalized names and cycles fail closed. +- pnpm checks use the manifest's own importer, preventing dependencies from + another workspace from satisfying the gate. Nested manifests discover shared + workspace lockfiles, which also participate in receipt snapshots. Adjacent + lockfiles and legacy flat root locks retain their existing precedence. + ## 0.13.0 - 2026-10-03 ### Fixed diff --git a/docs/spec-reference.md b/docs/spec-reference.md index ef2b640..ac3284f 100644 --- a/docs/spec-reference.md +++ b/docs/spec-reference.md @@ -203,8 +203,20 @@ be in the lockfile, so this catches it offline and deterministically. Supported: `package.json` with `package-lock.json`, `npm-shrinkwrap.json`, `pnpm-lock.yaml`, `yarn.lock` or `bun.lock` (dependencies, devDependencies and optionalDependencies; `file:`/`link:` specs are skipped), and `pyproject.toml` -(`[project]` dependencies, optional dependencies and Poetry tables) with `uv.lock`, -`poetry.lock` or `pdm.lock`. No manifest or no lockfile fails the gate. +(`[project]` dependencies, optional dependencies, `[dependency-groups]`, and all +Poetry dependency groups) with `uv.lock`, `poetry.lock` or `pdm.lock`. Python +manifests and lockfiles are parsed as TOML, so comments and unrelated metadata +cannot supply package names. Dependency-group includes resolve normalized names; +unknown groups, cycles, malformed entries and duplicate normalized group names +fail closed. + +For pnpm, only the manifest's importer can satisfy its dependencies. Nested +manifests can use the nearest ancestor's shared `pnpm-lock.yaml`; importer keys +are relative to the lockfile directory. Lookup stops at a nested pnpm workspace +boundary without a lockfile. Adjacent lockfiles retain precedence, and legacy +flat pnpm locks remain supported for the root manifest. Receipts include the +selected shared lockfile even when it sits outside the policy directory. +No manifest or no lockfile fails the gate. ### `phase` diff --git a/package-lock.json b/package-lock.json index c822197..4c7a3ba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,17 @@ { "name": "@reneza/skillgate", - "version": "0.13.0", + "version": "0.13.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@reneza/skillgate", - "version": "0.13.0", + "version": "0.13.1", "license": "MIT", "dependencies": { "@mattrglobal/pairing-crypto": "^0.4.2", "picomatch": "^4.0.7", + "smol-toml": "^1.9.0", "tinyglobby": "^0.2.10", "yaml": "^2.6.0" }, @@ -421,6 +422,18 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/smol-toml": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.9.0.tgz", + "integrity": "sha512-hpd+HLON7HdZXqYchMM/+LaTTbdK0AU3NngIJ4KVyWbY9bfQqdL9cD+4yf6dUoU2Ap4VsU0JkQi6FxAI1B2mXQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">= 18" + }, + "funding": { + "url": "https://github.com/sponsors/cyyynthia" + } + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", diff --git a/package.json b/package.json index 0ef6d62..b6423b5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@reneza/skillgate", - "version": "0.13.0", + "version": "0.13.1", "publishConfig": { "access": "public" }, @@ -67,6 +67,7 @@ "dependencies": { "@mattrglobal/pairing-crypto": "^0.4.2", "picomatch": "^4.0.7", + "smol-toml": "^1.9.0", "tinyglobby": "^0.2.10", "yaml": "^2.6.0" }, diff --git a/src/deps.ts b/src/deps.ts index e47c2b1..7a7869d 100644 --- a/src/deps.ts +++ b/src/deps.ts @@ -1,6 +1,7 @@ import fs from "node:fs"; import path from "node:path"; import { parse as parseYaml } from "yaml"; +import { parse as parseToml } from "smol-toml"; /** * Declared-versus-locked dependency check for the `deps-locked` gate. Offline and @@ -21,7 +22,7 @@ export interface DepsReport { error?: string; } -const NODE_SECTIONS = ["dependencies", "devDependencies", "optionalDependencies", "peerDependencies"]; +const NODE_SECTIONS = ["dependencies", "devDependencies", "optionalDependencies"]; function escapeRegExp(s: string): string { return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); @@ -48,7 +49,7 @@ function nodeDeclared(manifest: string): string[] { return [...names].sort(); } -function nodeLocked(lockfile: string, text: string): (name: string) => boolean { +function nodeLocked(lockfile: string, text: string, manifest: string): (name: string) => boolean { const base = path.basename(lockfile); if (base === "package-lock.json" || base === "npm-shrinkwrap.json") { const lock = JSON.parse(text); @@ -57,12 +58,15 @@ function nodeLocked(lockfile: string, text: string): (name: string) => boolean { return (name) => packages[`node_modules/${name}`] != null || v1[name] != null; } if (base === "pnpm-lock.yaml") { - const lock: any = parseYaml(text) ?? {}; + const lock = table(parseYaml(text), "pnpm lockfile"); + const identity = path.relative(path.dirname(path.resolve(lockfile)), path.dirname(path.resolve(manifest))).split(path.sep).join("/") || "."; + const importers = lock.importers === undefined ? undefined : table(lock.importers, "pnpm importers"); + const importer = importers ? importers[identity] : identity === "." ? lock : undefined; + if (importer == null) throw new Error(`no pnpm importer for ${identity}`); + const project = table(importer, `pnpm importer ${identity}`); const importerNames = new Set(); - for (const importer of Object.values(lock.importers ?? { ".": lock })) { - for (const section of NODE_SECTIONS) { - for (const name of Object.keys(importer?.[section] ?? {})) importerNames.add(name); - } + for (const section of NODE_SECTIONS) { + for (const name of Object.keys(table(project[section], `pnpm ${section}`))) importerNames.add(name); } return (name) => importerNames.has(name); } @@ -70,24 +74,10 @@ function nodeLocked(lockfile: string, text: string): (name: string) => boolean { return (name) => new RegExp(`(^|[\\s"',/])${escapeRegExp(name)}@`, "m").test(text); } -/** Top-level `[section]` body of a TOML document (until the next table header). */ -function tomlSection(text: string, header: string): string | null { - const lines = text.split(/\r?\n/); - const start = lines.findIndex((line) => line.trim() === `[${header}]`); - if (start < 0) return null; - const body: string[] = []; - for (const line of lines.slice(start + 1)) { - if (/^\s*\[/.test(line)) break; - body.push(line); - } - return body.join("\n"); -} - -/** Strings inside the `key = [ ... ]` array of a TOML section body. */ -function tomlArray(body: string, key: string): string[] { - const match = new RegExp(`^\\s*${escapeRegExp(key)}\\s*=\\s*\\[([\\s\\S]*?)\\]`, "m").exec(body); - if (!match) return []; - return [...match[1].matchAll(/"((?:[^"\\]|\\.)*)"|'([^']*)'/g)].map((m) => m[1] ?? m[2]); +function table(value: unknown, label: string): Record { + if (value === undefined) return {}; + if (!value || typeof value !== "object" || Array.isArray(value) || value instanceof Date) throw new Error(`${label} must be a table`); + return value as Record; } function requirementName(requirement: string): string | null { @@ -96,43 +86,101 @@ function requirementName(requirement: string): string | null { } function pythonDeclared(manifest: string): string[] { - const text = fs.readFileSync(manifest, "utf8"); + const doc = parseToml(fs.readFileSync(manifest, "utf8")); const names = new Set(); - const project = tomlSection(text, "project"); - if (project) { - for (const req of tomlArray(project, "dependencies")) { - const name = requirementName(req); - if (name) names.add(name); - } + const add = (value: unknown) => { + const name = typeof value === "string" ? requirementName(value) : null; + if (!name) throw new Error("dependency requirement must be a named string"); + names.add(name); + }; + const addArray = (value: unknown, label: string) => { + if (value === undefined) return; + if (!Array.isArray(value)) throw new Error(`${label} must be an array`); + value.forEach(add); + }; + const project = table(doc.project, "project"); + addArray(project.dependencies, "project.dependencies"); + for (const [key, requirements] of Object.entries(table(project["optional-dependencies"], "project.optional-dependencies"))) { + addArray(requirements, `project.optional-dependencies.${key}`); } - const optional = tomlSection(text, "project.optional-dependencies"); - if (optional) { - for (const [, key] of optional.matchAll(/^\s*([A-Za-z0-9._-]+)\s*=\s*\[/gm)) { - for (const req of tomlArray(optional, key)) { - const name = requirementName(req); - if (name) names.add(name); - } - } + + const groups = new Map(); + for (const [key, value] of Object.entries(table(doc["dependency-groups"], "dependency-groups"))) { + if (!/^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/.test(key)) throw new Error(`invalid dependency group name: ${key}`); + const normalized = normalizePythonName(key); + if (groups.has(normalized)) throw new Error(`duplicate normalized dependency group: ${key}`); + groups.set(normalized, value); } - for (const header of ["tool.poetry.dependencies", "tool.poetry.dev-dependencies", "tool.poetry.group.dev.dependencies"]) { - const body = tomlSection(text, header); - if (!body) continue; - for (const [, key] of body.matchAll(/^\s*["']?([A-Za-z0-9][A-Za-z0-9._-]*)["']?\s*=/gm)) { - if (key.toLowerCase() !== "python") names.add(normalizePythonName(key)); + const visited = new Set(); + const visiting = new Set(); + const visit = (key: string): void => { + if (visiting.has(key)) throw new Error(`cyclic dependency group include: ${key}`); + if (visited.has(key)) return; + if (!groups.has(key)) throw new Error(`unknown included dependency group: ${key}`); + const requirements = groups.get(key); + if (!Array.isArray(requirements)) throw new Error(`dependency group ${key} must be an array`); + visiting.add(key); + for (const value of requirements) { + if (typeof value === "string") add(value); + else { + const include = table(value, `dependency group ${key} item`); + if (Object.keys(include).length !== 1 || typeof include["include-group"] !== "string") throw new Error(`invalid dependency group include: ${key}`); + visit(normalizePythonName(include["include-group"])); + } } + visiting.delete(key); + visited.add(key); + }; + for (const key of groups.keys()) visit(key); + + const poetry = table(table(doc.tool, "tool").poetry, "tool.poetry"); + const addPoetry = (value: unknown, label: string) => { + for (const key of Object.keys(table(value, label))) if (key.toLowerCase() !== "python") names.add(normalizePythonName(key)); + }; + addPoetry(poetry.dependencies, "tool.poetry.dependencies"); + addPoetry(poetry["dev-dependencies"], "tool.poetry.dev-dependencies"); + for (const [key, group] of Object.entries(table(poetry.group, "tool.poetry.group"))) { + addPoetry(table(group, `tool.poetry.group.${key}`).dependencies, `tool.poetry.group.${key}.dependencies`); } return [...names].sort(); } function pythonLocked(text: string): (name: string) => boolean { - const locked = new Set([...text.matchAll(/^name\s*=\s*"([^"]+)"/gm)].map((m) => normalizePythonName(m[1]))); + const doc = parseToml(text); + const packages = doc.package ?? []; + if (!Array.isArray(packages)) throw new Error("lockfile package entries must be an array"); + const locked = new Set(packages.map(value => { + const name = table(value, "lockfile package").name; + if (typeof name !== "string" || !name) throw new Error("lockfile package entry must have a name"); + return normalizePythonName(name); + })); return (name) => locked.has(name); } -/** Check one manifest against the first lockfile found next to it. */ +function workspacePnpmLock(dir: string): string | undefined { + let current = path.resolve(dir); + while (true) { + const lock = path.join(current, "pnpm-lock.yaml"); + if (fs.existsSync(lock)) return lock; + if (fs.existsSync(path.join(current, "pnpm-workspace.yaml"))) return undefined; + const parent = path.dirname(current); + if (parent === current) return undefined; + current = parent; + } +} + +/** Resolve the lockfile read for a supported manifest, including shared pnpm workspaces. */ +export function findDependencyLockfile(manifest: string): string | undefined { + const kind = path.basename(manifest); + const lockfiles = kind === "package.json" ? NODE_LOCKFILES : kind === "pyproject.toml" ? PYTHON_LOCKFILES : []; + const dir = path.dirname(manifest); + return lockfiles.map(name => path.join(dir, name)).find(file => fs.existsSync(file)) + ?? (kind === "package.json" ? workspacePnpmLock(dir) : undefined); +} + +/** Check one manifest against its adjacent lockfile or shared pnpm workspace lock. */ export function checkManifest(manifest: string): DepsReport { const rel = manifest; - const dir = path.dirname(manifest); const kind = path.basename(manifest); const lockfiles = kind === "package.json" ? NODE_LOCKFILES : kind === "pyproject.toml" ? PYTHON_LOCKFILES : null; if (!lockfiles) return { manifest: rel, declared: [], missing: [], error: `unsupported manifest ${kind} (supported: ${SUPPORTED_MANIFESTS.join(", ")})` }; @@ -144,12 +192,12 @@ export function checkManifest(manifest: string): DepsReport { return { manifest: rel, declared: [], missing: [], error: `cannot parse manifest: ${error.message}` }; } if (declared.length === 0) return { manifest: rel, declared, missing: [] }; - const lockfile = lockfiles.map((name) => path.join(dir, name)).find((file) => fs.existsSync(file)); + const lockfile = findDependencyLockfile(manifest); if (!lockfile) return { manifest: rel, declared, missing: declared, error: `no lockfile (looked for ${lockfiles.join(", ")})` }; let has: (name: string) => boolean; try { const text = fs.readFileSync(lockfile, "utf8"); - has = kind === "package.json" ? nodeLocked(lockfile, text) : pythonLocked(text); + has = kind === "package.json" ? nodeLocked(lockfile, text, manifest) : pythonLocked(text); } catch (error: any) { return { manifest: rel, lockfile, declared, missing: declared, error: `cannot parse ${path.basename(lockfile)}: ${error.message}` }; } diff --git a/src/receipt.ts b/src/receipt.ts index 976efb7..bcd299e 100644 --- a/src/receipt.ts +++ b/src/receipt.ts @@ -7,6 +7,7 @@ import type { RunResult } from "./core.js"; import type { Spec } from "./spec.js"; import { globSync } from "tinyglobby"; import { currentBranch, matchesGlob } from "./git.js"; +import { findDependencyLockfile } from "./deps.js"; const SCAN_IGNORE = ["**/node_modules/**", "**/.git/**", "dist/**"]; const CACHE_TYPES = new Set(["file-exists", "file-contains", "evidence", "not-empty", "absent", "no-new", "no-fewer", "no-deleted", "phase"]); @@ -34,6 +35,8 @@ function gateFiles(spec: Spec, cwd: string): string[] { for (const file of manifests) { add(file); for (const lock of ["package-lock.json", "npm-shrinkwrap.json", "pnpm-lock.yaml", "yarn.lock", "bun.lock", "uv.lock", "poetry.lock", "pdm.lock"]) add(path.join(path.dirname(file), lock)); + const selected = findDependencyLockfile(path.resolve(cwd, file)); + if (selected) add(selected); } } } diff --git a/test/dependency-lock.test.ts b/test/dependency-lock.test.ts new file mode 100644 index 0000000..5b6a80b --- /dev/null +++ b/test/dependency-lock.test.ts @@ -0,0 +1,131 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { checkManifest } from "../src/deps.js"; +import { snapshotKey } from "../src/receipt.js"; +import { runGates } from "../src/core.js"; + +function project(t: any, files: Record): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "skillgate-dependency-")); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + for (const [name, text] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(dir, name)), { recursive: true }); + fs.writeFileSync(path.join(dir, name), text); + } + return dir; +} +const pythonLock = (...names: string[]) => names.map(name => `[[package]]\nname = '${name}'\n`).join("\n"); + +test("Python groups include optional and development requirements without treating comments as packages", t => { + const dir = project(t, { + "pyproject.toml": `[project]\nname = 'example'\ndependencies = [\n 'Requests[socks]>=2', # \"comment-package\"\n]\n[project.optional-dependencies]\n'web.extra' = ['Flask>=3']\n[dependency-groups]\n'Docs.Build' = ['Sphinx>=7']\ntest = ['pytest>=8', {include-group = 'docs_build'}]\n`, + "uv.lock": pythonLock("requests", "flask", "pytest"), + }); + const report = checkManifest(path.join(dir, "pyproject.toml")); + assert.equal(report.error, undefined); + assert.deepEqual(report.declared, ["flask", "pytest", "requests", "sphinx"]); + assert.deepEqual(report.missing, ["sphinx"]); + fs.writeFileSync(path.join(dir, "uv.lock"), pythonLock(...report.declared)); + assert.equal(runGates({ gates: [{ id: "deps", type: "deps-locked" }] }, dir).passed, true); +}); + +test("Poetry checks every named group, including quoted groups and legacy development dependencies", t => { + const dir = project(t, { + "pyproject.toml": `[tool.poetry.dependencies]\npython = '^3.11'\nhttpx = {version = '^0.28'}\n[tool.poetry.dev-dependencies]\npytest = '^8'\n[tool.poetry.group.'docs.build'.dependencies]\nSphinx = '^7'\n[tool.poetry.group.typing.dependencies]\ntyping_extensions = '^4'\n`, + "poetry.lock": pythonLock("httpx", "pytest", "typing-extensions"), + }); + const report = checkManifest(path.join(dir, "pyproject.toml")); + assert.deepEqual(report.declared, ["httpx", "pytest", "sphinx", "typing-extensions"]); + assert.deepEqual(report.missing, ["sphinx"]); +}); + +test("malformed Python dependency structures and includes fail instead of declaring no dependencies", t => { + const invalid = [ + "[project\ndependencies = ['pytest']", + "[project]\ndependencies = 'pytest'", + "[project]\ndependencies = [42]", + "[project]\ndependencies = ['!!!']", + "[project]\noptional-dependencies = []", + "dependency-groups = []", + "[dependency-groups]\nBad_ = []", + "[dependency-groups]\n'Docs.Build' = []\ndocs_build = []", + "[dependency-groups]\ntest = 'pytest'", + "[dependency-groups]\ntest = [{include-group = 'missing'}]", + "[dependency-groups]\na = [{include-group = 'b'}]\nb = [{include-group = 'a'}]", + "[dependency-groups]\na = [{include-group = 'a', extra = true}]", + "[dependency-groups]\na = [42]", + "[dependency-groups]\na = [{include-group = 42}]", + "[tool.poetry.group]\ntest = 'invalid'", + ]; + const dir = project(t, { "pyproject.toml": "", "uv.lock": pythonLock("pytest") }); + for (const text of invalid) { + fs.writeFileSync(path.join(dir, "pyproject.toml"), text); + assert.match(checkManifest(path.join(dir, "pyproject.toml")).error ?? "", /cannot parse manifest/, text); + } +}); + +test("Python lockfile matching reads package entries and rejects invalid TOML", t => { + const dir = project(t, { "pyproject.toml": "[project]\ndependencies = ['pytest']\n", "uv.lock": "" }); + for (const text of ["[[package]\nname = 'pytest'", "[package]\nname = 'pytest'", "[[package]]\nversion = '8'", "[[package]]\nname = 42"]) { + fs.writeFileSync(path.join(dir, "uv.lock"), text); + assert.match(checkManifest(path.join(dir, "pyproject.toml")).error ?? "", /cannot parse uv.lock/); + } + fs.writeFileSync(path.join(dir, "uv.lock"), "[metadata]\nname = 'pytest'\n"); + assert.deepEqual(checkManifest(path.join(dir, "pyproject.toml")).missing, ["pytest"]); +}); + +test("pnpm cannot satisfy a root declaration from another workspace importer", t => { + const dir = project(t, { + "package.json": JSON.stringify({ dependencies: { yaml: "^2" } }), + "pnpm-lock.yaml": "lockfileVersion: '9.0'\nimporters:\n .: {}\n packages/other:\n dependencies:\n yaml: {specifier: ^2, version: 2.6.0}\n", + }); + assert.deepEqual(checkManifest(path.join(dir, "package.json")).missing, ["yaml"]); + fs.writeFileSync(path.join(dir, "pnpm-lock.yaml"), "lockfileVersion: '9.0'\nimporters:\n packages/other: {}\n"); + assert.match(checkManifest(path.join(dir, "package.json")).error ?? "", /no pnpm importer for \./); +}); + +test("nested manifests select their own shared pnpm importer and receipts include the shared lockfile", t => { + const dir = project(t, { + "pnpm-workspace.yaml": "packages: ['packages/*']\n", + "packages/my app/package.json": JSON.stringify({ dependencies: { yaml: "^2" } }), + "pnpm-lock.yaml": "lockfileVersion: '9.0'\nimporters:\n .:\n dependencies:\n yaml: {specifier: ^2, version: 2.6.0}\n packages/my app: {}\n", + }); + const workspace = path.join(dir, "packages/my app"); + const manifest = path.join(workspace, "package.json"); + assert.deepEqual(checkManifest(manifest).missing, ["yaml"]); + fs.writeFileSync(path.join(dir, "pnpm-lock.yaml"), "lockfileVersion: '9.0'\nimporters:\n .: {}\n packages/my app:\n dependencies:\n yaml: {specifier: ^2, version: 2.6.0}\n"); + const report = checkManifest(manifest); + assert.equal(report.error, undefined); + assert.deepEqual(report.missing, []); + assert.equal(report.lockfile, path.join(dir, "pnpm-lock.yaml")); + const spec = { gates: [{ id: "deps", type: "deps-locked" as const }] }; + const snapshot = snapshotKey(spec, workspace); + fs.appendFileSync(path.join(dir, "pnpm-lock.yaml"), "# lock changed\n"); + assert.notEqual(snapshotKey(spec, workspace), snapshot); +}); + +test("legacy flat pnpm locks remain valid at the root and malformed importers fail", t => { + const dir = project(t, { + "package.json": JSON.stringify({ dependencies: { yaml: "^2" } }), + "pnpm-lock.yaml": "lockfileVersion: 5.4\ndependencies:\n yaml: 2.6.0\n", + }); + assert.deepEqual(checkManifest(path.join(dir, "package.json")).missing, []); + for (const text of ["importers: null\ndependencies: {yaml: 2}", "importers: []", "importers:\n .: 42", "importers:\n .:\n dependencies: []", "importers:\n .:\n dependencies:\n yaml: 1\n yaml: 2"]) { + fs.writeFileSync(path.join(dir, "pnpm-lock.yaml"), text); + assert.match(checkManifest(path.join(dir, "package.json")).error ?? "", /cannot parse pnpm-lock.yaml/); + } +}); + +test("pnpm lookup respects nested workspace boundaries and prefers adjacent lockfiles", t => { + const dir = project(t, { + "pnpm-lock.yaml": "importers:\n nested/pkg:\n dependencies:\n yaml: {version: 2.6.0}\n", + "nested/pnpm-workspace.yaml": "packages: ['pkg']\n", + "nested/pkg/package.json": JSON.stringify({ dependencies: { yaml: "^2" } }), + }); + const manifest = path.join(dir, "nested/pkg/package.json"); + assert.match(checkManifest(manifest).error ?? "", /no lockfile/); + fs.writeFileSync(path.join(dir, "nested/pkg/package-lock.json"), JSON.stringify({ packages: { "node_modules/yaml": {} } })); + assert.deepEqual(checkManifest(manifest).missing, []); +}); diff --git a/test/enforcement-release.test.ts b/test/enforcement-release.test.ts index fb9b742..6bfef7f 100644 --- a/test/enforcement-release.test.ts +++ b/test/enforcement-release.test.ts @@ -132,7 +132,8 @@ test("pre-commit installation upgrades deletion-only coverage and preserves unre assert.equal(hooks[1].pass_filenames, false); assert.equal(hooks[1].custom, "preserved"); assert.equal(hooks[1].name, "pinned project policy"); - assert.match(hooks[1].entry, /npx '@reneza\/skillgate@0\.13\.0' check --pin --base main$/); + const version = JSON.parse(fs.readFileSync(new URL("../../package.json", import.meta.url), "utf8")).version; + assert.equal(hooks[1].entry, `npx '@reneza/skillgate@${version}' check --pin --base main`); assert.deepEqual(hooks[1].stages, ["pre-push", "pre-commit"]); assert.equal(doctor(dir, ["pre-commit"]).at(-1)?.ok, true); assert.equal(installIntegration("pre-commit", dir).changed, false);