Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 14 additions & 2 deletions docs/spec-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand Down
17 changes: 15 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@reneza/skillgate",
"version": "0.13.0",
"version": "0.13.1",
"publishConfig": {
"access": "public"
},
Expand Down Expand Up @@ -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"
},
Expand Down
148 changes: 98 additions & 50 deletions src/deps.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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, "\\$&");
Expand All @@ -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);
Expand All @@ -57,37 +58,26 @@ 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<string>();
for (const importer of Object.values<any>(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);
}
// yarn.lock (classic and berry) and bun.lock: entries are keyed `name@range`.
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<string, any> {
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<string, any>;
}

function requirementName(requirement: string): string | null {
Expand All @@ -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<string>();
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<string, unknown>();
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<string>();
const visiting = new Set<string>();
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(", ")})` };
Expand All @@ -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}` };
}
Expand Down
3 changes: 3 additions & 0 deletions src/receipt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"]);
Expand Down Expand Up @@ -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);
}
}
}
Expand Down
Loading
Loading