Skip to content
Closed
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
28 changes: 18 additions & 10 deletions .github/scripts/audit-autofix.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ import {
type AuditAdvisory,
} from "./audit-autofix";

function advisory(overrides: Partial<AuditAdvisory> = {}): AuditAdvisory {
function advisory(
overrides: Readonly<Partial<AuditAdvisory>> = {},
): AuditAdvisory {
return {
module_name: "undici",
vulnerable_versions: ">=7.0.0 <7.29.0",
Expand All @@ -27,11 +29,14 @@ function advisory(overrides: Partial<AuditAdvisory> = {}): AuditAdvisory {
};
}

// The fixture's own minimumReleaseAge, named rather than repeated as a bare literal at every assertion that reads it back out of WORKSPACE below.
const FIXTURE_MINIMUM_RELEASE_AGE_MINUTES = 60;

// Shaped like this workspace's real pnpm-workspace.yaml: an explanatory comment attached to the `overrides` key itself, and a second comment attached to one entry inside the map. Those two comments live in different places and do not survive the same operations, which is the whole reason the fix path patches child keys.
const WORKSPACE = `packages:
- "packages/*"

minimumReleaseAge: 60
minimumReleaseAge: ${String(FIXTURE_MINIMUM_RELEASE_AGE_MINUTES)}

# fast-uri < 3.1.5 is vulnerable to host confusion via a backslash authority introducer (GHSA-hht2-r2mx-9j9m). A transitive devDependency, so only an override can force a patched version.
overrides:
Expand Down Expand Up @@ -90,7 +95,9 @@ describe("isAuditServiceError", () => {

describe("minimumReleaseAgeMinutes", () => {
it("reads the configured window as minutes, not days", () => {
expect(minimumReleaseAgeMinutes(WORKSPACE)).toBe(60);
expect(minimumReleaseAgeMinutes(WORKSPACE)).toBe(
FIXTURE_MINIMUM_RELEASE_AGE_MINUTES,
);
});

it("throws rather than defaulting when the key is absent", () => {
Expand Down Expand Up @@ -265,7 +272,7 @@ describe("classifyAdvisories", () => {
});

describe("override pruning", () => {
// Single-document, as this workspace's lockfile is, and with the multi-importer shape a thirteen-package workspace produces: `packages` is the union of every importer's resolutions, which is what an inertness check has to read.
// The project's own document within the real (multi-document, once pnpm pins its own binary through packageManagerDependencies) lockfile, with the multi-importer shape a many-package workspace produces: `packages` is the union of every importer's resolutions, which is what an inertness check has to read.
const LOCKFILE = `lockfileVersion: '9.0'

importers:
Expand Down Expand Up @@ -296,12 +303,13 @@ packages:
expect(resolved.get("@scope/pkg")).toEqual(new Set(["1.0.0"]));
});

it("throws on a multi-document lockfile rather than reading only the first document", () => {
expect(() =>
resolvedVersionsFromLockfileText(
`---\npackages:\n pnpm@11.6.0: {}\n---\n${LOCKFILE}`,
),
).toThrow(/multiple documents/);
it("unions every document's packages map on a multi-document lockfile, not just the first", () => {
const resolved = resolvedVersionsFromLockfileText(
`packages:\n pnpm@11.6.0: {}\n---\n${LOCKFILE}`,
);
expect(resolved.get("pnpm")).toEqual(new Set(["11.6.0"]));
expect(resolved.get("undici")).toEqual(new Set(["6.28.0", "7.29.0"]));
expect(resolved.get("@scope/pkg")).toEqual(new Set(["1.0.0"]));
});

it("throws when the lockfile has no packages map", () => {
Expand Down
59 changes: 33 additions & 26 deletions .github/scripts/audit-autofix.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { spawnSync } from "node:child_process";
import { pathToFileURL } from "node:url";
import { satisfies } from "semver";
import { type Document, parse, parseDocument } from "yaml";
import { type Document, parse, parseAllDocuments, parseDocument } from "yaml";
import { appendFileSync, readFileSync, writeFileSync } from "node:fs";

export interface AuditAdvisory {
Expand Down Expand Up @@ -160,7 +160,7 @@ function writeWorkspaceDoc(doc: Document): void {
// `overrides` is passed as the complete desired state, not a patch: keys it omits are deleted individually, which is what the prune pass at the end of a run relies on. Entries whose value is not a string are left alone rather than removed, since currentOverrides only reports string-valued keys -- a value this script does not understand is not a value it should destroy.
export function withOverrides(
workspace: Document,
overrides: Record<string, string>,
overrides: Readonly<Record<string, string>>,
): Document {
const cloned = workspace.clone();
// An empty map is written as a bare `overrides: {}` line that never goes away on its own -- deleting the key when there is nothing to override keeps a fully-pruned file clean instead of accumulating dead boilerplate. The comment above the key goes with it, which is correct: it documents overrides that no longer exist.
Expand Down Expand Up @@ -198,13 +198,13 @@ function restoreFromGit(): void {

function setOutput(name: string, value: string): void {
const outputFile = process.env.GITHUB_OUTPUT;
if (!outputFile) return;
if (outputFile === undefined || outputFile === "") return;
appendFileSync(outputFile, `${name}=${value}\n`);
}

function appendSummary(markdown: string): void {
const summaryFile = process.env.GITHUB_STEP_SUMMARY;
if (!summaryFile) return;
if (summaryFile === undefined || summaryFile === "") return;
appendFileSync(summaryFile, markdown);
}

Expand All @@ -227,8 +227,8 @@ function hasUncommittedChanges(): boolean {
// `-r`, unlike the single-importer form this was ported from: `overrides` in pnpm-workspace.yaml is a workspace-wide setting, but a non-recursive `pnpm update` re-resolves the root importer's dependencies only, leaving every one of this workspace's package importers on the resolutions already in the lockfile. Since almost everything an advisory names here is transitive to a package rather than to the root, the update would report success while the vulnerable entries the audit found stayed exactly where they were -- and the re-audit below would then correctly refuse to call the candidate fixed. Recursive is the form that matches where the overrides apply.
function attemptBatch(
workspace: Document,
baseOverrides: Record<string, string>,
batch: Candidate[],
baseOverrides: Readonly<Record<string, string>>,
batch: readonly Candidate[],
): { succeeded: Candidate[]; conflicted: boolean } {
const overrides = { ...baseOverrides };
for (const c of batch) overrides[c.overrideKey] = c.range;
Expand All @@ -254,8 +254,8 @@ function attemptBatch(
// A single candidate whose override the registry can never satisfy (or that conflicts with peers) must not sink every other, independently-fixable candidate in the same batch. attemptBatch's own audit check already tells us exactly which candidates in a batch succeeded when the update itself ran cleanly, so bisection is only needed to isolate a genuine `conflicted` (non-zero exit) failure; if two halves that each update fine independently still conflict combined, fall back to a linear greedy pass, which always terminates with a verified-working subset.
function resolveMaximalSubset(
workspace: Document,
baseOverrides: Record<string, string>,
batch: Candidate[],
baseOverrides: Readonly<Record<string, string>>,
batch: readonly Candidate[],
): Candidate[] {
if (batch.length === 0) return [];

Expand Down Expand Up @@ -285,8 +285,8 @@ function resolveMaximalSubset(

function greedyResolve(
workspace: Document,
baseOverrides: Record<string, string>,
batch: Candidate[],
baseOverrides: Readonly<Record<string, string>>,
batch: readonly Candidate[],
): Candidate[] {
const working: Candidate[] = [];
for (const c of batch) {
Expand All @@ -299,7 +299,9 @@ function greedyResolve(
}

// Splits audit advisories into fixable candidates (grouped by override selector) and deferred entries with a reason each. Pure — no filesystem, no subprocesses — so the unit tests cover grouping, dedup, and the not-overridable and no-patch deferral paths through it.
export function classifyAdvisories(advisories: AuditAdvisory[]): Classified {
export function classifyAdvisories(
advisories: readonly AuditAdvisory[],
): Classified {
const deferred: { advisory: AuditAdvisory; reason: string }[] = [];
const candidatesByKey = new Map<string, Candidate>();

Expand Down Expand Up @@ -342,7 +344,7 @@ export function classifyAdvisories(advisories: AuditAdvisory[]): Classified {

// An override is inert when no version its selector could rewrite is present: the selector is the vulnerable range on the key (`pkg@<range>`), and the override only acts on resolutions matching that range. If nothing resolved matches the selector, the override forces nothing today -- regardless of what the package resolves outside the selector. The autofix only ever adds overrides, so without this pass the map accumulates one entry per historical advisory forever. Dropping inert entries is self-correcting rather than risky: if a future update resolves back into a vulnerable range, the next audit run re-adds the override through the same fix path.
export function inertOverrideKeys(
overrides: Record<string, string>,
overrides: Readonly<Record<string, string>>,
resolvedVersions: Map<string, Set<string>>,
): string[] {
const inert: string[] = [];
Expand All @@ -361,23 +363,28 @@ export function inertOverrideKeys(
return inert;
}

// The resolved package@version set from the lockfile's `packages` map, minus peer-dependency suffixes. This workspace's pnpm-lock.yaml is a single YAML document, so there is no stream to search: the multi-document form the reference implementation handled is what pnpm writes when a project pins its own pnpm binary through packageManagerDependencies, which adds a self-management lockfile as a document of its own ahead of the project's; this workspace pins pnpm through package.json's packageManager field alone and gets one document. `parse` is deliberate rather than incidental -- it throws outright on a multi-document source, so if that ever changes this fails loudly instead of silently reading whichever document happened to come first. The map is the union of every importer's resolutions, which is what an inertness check needs across thirteen packages.
// The resolved package@version set from the lockfile's `packages` map(s), minus peer-dependency suffixes. pnpm writes a multi-document lockfile once a project pins its own pnpm binary through packageManagerDependencies -- a self-management document listing the pinned pnpm build's own per-platform packages, ahead of the project's own document -- and this workspace does exactly that (package.json's packageManager field, pnpm 12), so the single-document assumption this function used to make no longer holds. Every document in the stream that carries a `packages` map has its entries unioned into the same result, rather than picking one: the self-management document's own entries (`@pnpm/exe.*` platform binaries) are real resolved packages too, just never ones `pnpm audit` has advisories against, so including them changes nothing about correctness and needs no guess about which document is "the real" project lockfile. A document with no `packages` map at all is skipped rather than treated as an error on its own; only a stream where no document anywhere has a packages map is an error, since that means the lockfile as a whole resolves nothing. The map is the union of every importer's resolutions, which is what an inertness check needs across every package in the workspace.
export function resolvedVersionsFromLockfileText(
yamlText: string,
): Map<string, Set<string>> {
const parsed: unknown = parse(yamlText);
if (!isRecord(parsed) || !isRecord(parsed.packages)) {
throw new Error("lockfile had no packages map");
}
const byPackage = new Map<string, Set<string>>();
for (const key of Object.keys(parsed.packages)) {
const stripped = key.replace(/(\([^)]*\))+$/, "");
const at = stripped.lastIndexOf("@");
const pkg = stripped.slice(0, at);
const version = stripped.slice(at + 1);
const existing = byPackage.get(pkg) ?? new Set<string>();
existing.add(version);
byPackage.set(pkg, existing);
let sawPackagesMap = false;
for (const document of parseAllDocuments(yamlText)) {
const parsed: unknown = document.toJS();
if (!isRecord(parsed) || !isRecord(parsed.packages)) continue;
sawPackagesMap = true;
for (const key of Object.keys(parsed.packages)) {
const stripped = key.replace(/(\([^)]*\))+$/, "");
const at = stripped.lastIndexOf("@");
const pkg = stripped.slice(0, at);
const version = stripped.slice(at + 1);
const existing = byPackage.get(pkg) ?? new Set<string>();
existing.add(version);
byPackage.set(pkg, existing);
}
}
if (!sawPackagesMap) {
throw new Error("lockfile had no packages map");
}
return byPackage;
}
Expand Down Expand Up @@ -549,7 +556,7 @@ function main(): void {
}

if (
process.argv[1] &&
process.argv[1] !== undefined &&
import.meta.url === pathToFileURL(process.argv[1]).href
) {
main();
Expand Down
3 changes: 3 additions & 0 deletions .npmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# pnpm 11 reads project-level saveExact/minimumReleaseAgeExclude from pnpm-workspace.yaml only -- .npmrc there is auth/registry-only from that version on. These two lines are a fallback for a pnpm 10.x release older than 10.17 (before those settings moved to YAML), and are otherwise inert. See pnpm-workspace.yaml for the settings that actually govern this workspace.
save-exact=true
minimum-release-age-exclude[]=@exadev/eslint-config
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
"engines": {
"node": ">=20"
},
"packageManager": "pnpm@11.6.0",
"packageManager": "pnpm@12.4.1+sha512.2e81e399d73fe8390dab25e06aa788ab7a5908248d2f5a370f82b481147a6a7a367bf8048f9a6fdb6460f21a66f0542dedb8b94ca2c8723596741920b1656d4c",
"scripts": {
"build": "turbo run _build",
"lint": "turbo run _lint",
Expand Down Expand Up @@ -43,7 +43,7 @@
"@eslint/js": "10.0.1",
"@eslint/json": "2.0.1",
"@eslint/markdown": "8.0.3",
"@exadev/eslint-config": "2.1.2",
"@exadev/eslint-config": "2.12.1",
"@exadev/semantic-release-workspace": "1.2.3",
"@semantic-release/changelog": "7.0.0",
"@semantic-release/commit-analyzer": "13.0.1",
Expand Down
2 changes: 1 addition & 1 deletion packages/archive-codec/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@
"_test:smoke": "vitest run --project smoke",
"prepare": "husky"
},
"packageManager": "pnpm@11.6.0",
"packageManager": "pnpm@12.4.1+sha512.2e81e399d73fe8390dab25e06aa788ab7a5908248d2f5a370f82b481147a6a7a367bf8048f9a6fdb6460f21a66f0542dedb8b94ca2c8723596741920b1656d4c",
"dependencies": {
"document-schema.js": "7.11.2",
"fflate": "0.8.3"
Expand Down
2 changes: 1 addition & 1 deletion packages/archive-codec/stryker.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@ import { packageStrykerConfig } from "../../stryker.shared.ts";

export default packageStrykerConfig({
vitestConfigFile: "vitest.mutation.config.ts",
// Every valid mutant is genuinely killed by a real test, or the code has been restructured so the mutation opportunity no longer exists as an AST node (a redundant guard removed, a manually bounds-checked loop replaced by one relying on the language's own out-of-range-is-undefined semantics, an algebraic-identity comparison restated as an explicit named branch) -- no Stryker disable comments anywhere in this package. So the gate is the literal maximum rather than a derived-with-slack figure.
// Every valid mutant is genuinely killed by a real test, or the code has been restructured so the mutation opportunity no longer exists as an AST node (a redundant guard removed, a manually bounds-checked loop replaced by one relying on the language's own out-of-range-is-undefined semantics, an algebraic-identity comparison restated as an explicit named branch) -- no Stryker-suppression comments anywhere in this package. So the gate is the literal maximum rather than a derived-with-slack figure.
breakThreshold: 100,
});
2 changes: 1 addition & 1 deletion packages/byte-codec/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@
"_test:workers": "vitest run --config vitest.workers.config.ts",
"prepare": "husky"
},
"packageManager": "pnpm@11.6.0",
"packageManager": "pnpm@12.4.1+sha512.2e81e399d73fe8390dab25e06aa788ab7a5908248d2f5a370f82b481147a6a7a367bf8048f9a6fdb6460f21a66f0542dedb8b94ca2c8723596741920b1656d4c",
"dependencies": {
"fflate": "0.8.3"
},
Expand Down
2 changes: 1 addition & 1 deletion packages/doc-codec/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@
"ms-cfb"
],
"license": "MIT",
"packageManager": "pnpm@11.6.0",
"packageManager": "pnpm@12.4.1+sha512.2e81e399d73fe8390dab25e06aa788ab7a5908248d2f5a370f82b481147a6a7a367bf8048f9a6fdb6460f21a66f0542dedb8b94ca2c8723596741920b1656d4c",
"dependencies": {
"archive-codec": "1.11.3",
"document-schema.js": "7.11.2"
Expand Down
2 changes: 1 addition & 1 deletion packages/doc-codec/stryker.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@ import { packageStrykerConfig } from "../../stryker.shared.ts";

export default packageStrykerConfig({
vitestConfigFile: "vitest.mutation.config.ts",
// Genuine 100% mutation score across every valid mutant, confirmed by a forced (non-incremental) full run: 0 survived, 0 no-coverage. Every mutant is either killed by a real isolating test or the code was restructured so the mutation opportunity no longer exists as an AST node — no Stryker disable comments anywhere in src/.
// Genuine 100% mutation score across every valid mutant, confirmed by a forced (non-incremental) full run: 0 survived, 0 no-coverage. Every mutant is either killed by a real isolating test or the code was restructured so the mutation opportunity no longer exists as an AST node — no Stryker-suppression comments anywhere in src/.
breakThreshold: 100,
});
2 changes: 1 addition & 1 deletion packages/document-cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@
"ink"
],
"license": "MIT",
"packageManager": "pnpm@11.6.0",
"packageManager": "pnpm@12.4.1+sha512.2e81e399d73fe8390dab25e06aa788ab7a5908248d2f5a370f82b481147a6a7a367bf8048f9a6fdb6460f21a66f0542dedb8b94ca2c8723596741920b1656d4c",
"dependencies": {
"commander": "15.0.0",
"document-outline.js": "3.9.0",
Expand Down
2 changes: 1 addition & 1 deletion packages/document-compute.js/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@
"test:corpus": "turbo run _test:corpus",
"_test:corpus": "vitest run --project corpus"
},
"packageManager": "pnpm@11.6.0",
"packageManager": "pnpm@12.4.1+sha512.2e81e399d73fe8390dab25e06aa788ab7a5908248d2f5a370f82b481147a6a7a367bf8048f9a6fdb6460f21a66f0542dedb8b94ca2c8723596741920b1656d4c",
"dependencies": {
"document-schema.js": "7.11.2"
},
Expand Down
2 changes: 1 addition & 1 deletion packages/document-mcp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@
"document-conversion"
],
"license": "MIT",
"packageManager": "pnpm@11.6.0",
"packageManager": "pnpm@12.4.1+sha512.2e81e399d73fe8390dab25e06aa788ab7a5908248d2f5a370f82b481147a6a7a367bf8048f9a6fdb6460f21a66f0542dedb8b94ca2c8723596741920b1656d4c",
"dependencies": {
"@modelcontextprotocol/node": "2.0.0",
"@modelcontextprotocol/server": "2.0.0",
Expand Down
2 changes: 1 addition & 1 deletion packages/document-mcp/stryker.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@ import { packageStrykerConfig } from "../../stryker.shared.ts";

export default packageStrykerConfig({
vitestConfigFile: "vitest.mutation.config.ts",
// Every valid mutant is genuinely killed by a real test, or the code has been restructured so the mutation opportunity no longer exists as an AST node (a redundant type-narrowing guard removed once the value it defended against was actually unreachable, an inline builder split into a directly-testable pure function) -- no Stryker disable comments anywhere in this package. So the gate is the literal maximum rather than a derived-with-slack figure.
// Every valid mutant is genuinely killed by a real test, or the code has been restructured so the mutation opportunity no longer exists as an AST node (a redundant type-narrowing guard removed once the value it defended against was actually unreachable, an inline builder split into a directly-testable pure function) -- no Stryker-suppression comments anywhere in this package. So the gate is the literal maximum rather than a derived-with-slack figure.
breakThreshold: 100,
});
2 changes: 1 addition & 1 deletion packages/document-operations/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@
"zod"
],
"license": "MIT",
"packageManager": "pnpm@11.6.0",
"packageManager": "pnpm@12.4.1+sha512.2e81e399d73fe8390dab25e06aa788ab7a5908248d2f5a370f82b481147a6a7a367bf8048f9a6fdb6460f21a66f0542dedb8b94ca2c8723596741920b1656d4c",
"dependencies": {
"document-compute.js": "1.5.13",
"document-outline.js": "3.9.0",
Expand Down
Loading
Loading