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
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
11 changes: 7 additions & 4 deletions .github/scripts/check-dependency-age.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export function minimumReleaseAgeMinutes(workspaceYamlText: string): number {

// Pure, and exported for the unit tests, because the minutes-vs-days unit is the one thing here a test can actually pin down: at a 60-minute window a package published two days ago is old enough, and would be far too new if the same 60 were read as days.
export function isTooNew(
publishedAt: Date,
publishedAt: Readonly<Date>,
now: number,
minimumAgeMinutes: number,
): boolean {
Expand Down Expand Up @@ -70,11 +70,11 @@ export function splitNameAndVersion(nameAtVersion: string): PackageVersion {
};
}

function git(args: string[]): string {
function git(args: readonly string[]): string {
return execFileSync("git", args, { encoding: "utf8" });
}

function publishedAt(name: string, version: string): Date {
function fetchPublishedAt(name: string, version: string): Date {
let raw: string;
try {
raw = execFileSync("pnpm", ["info", name, "time", "--json"], {
Expand Down Expand Up @@ -138,7 +138,10 @@ function main(): void {

const now = Date.now();
const tooNew = introduced
.map((pkg) => ({ pkg, publishedAt: publishedAt(pkg.name, pkg.version) }))
.map((pkg) => ({
pkg,
publishedAt: fetchPublishedAt(pkg.name, pkg.version),
}))
.filter(({ publishedAt }) =>
isTooNew(publishedAt, now, minimumAgeMinutes),
);
Expand Down
2 changes: 1 addition & 1 deletion .github/scripts/check-npm-registration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ export function touchedPackageDirectories(
return directories;
}

function git(args: string[]): string {
function git(args: readonly string[]): string {
return execFileSync("git", args, { encoding: "utf8" });
}

Expand Down
2 changes: 2 additions & 0 deletions .npmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
save-exact=true
minimum-release-age-exclude[]=@exadev/eslint-config
2 changes: 1 addition & 1 deletion commitlint.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import releaseConfig from "./release-workspace.config";
/**
* Commit-message validation for the whole workspace. Commit messages are a property of the repository, not of a package, so this config lives at the root: every package carried an identical copy, and in one repository only one of those could ever have run.
*
* The allowed type list is derived from release-workspace.config.ts's own releaseRules rather than restated here, preserving the invariant every package's own config was built around: a conventional-commit type cannot trigger a release without also being accepted by commit-msg validation, or the reverse. That file is the canonical release configuration -- @exadev/semantic-release-workspace reads it directly via `--config` -- so deriving from it means there is exactly one place a type gets added.
* The allowed type list is derived from release-workspace.config.ts's own releaseRules rather than restated here, preserving the invariant every package's own config was built around: a conventional-commit type cannot trigger a release without also being accepted by commit-msg validation, or the reverse. That file is the canonical release configuration -- \@exadev/semantic-release-workspace reads it directly via `--config` -- so deriving from it means there is exactly one place a type gets added.
*
* A plain import, not a JSON import: release-workspace.config.ts is itself a TypeScript module now, so there is no import-attribute inconsistency across loaders to guard against here the way a `.json` import would have -- commitlint's own TypeScript loader resolves this exactly as it resolves this file.
*/
Expand Down
8 changes: 8 additions & 0 deletions eslint.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,16 @@ export default tseslint.config(
"error",
{ fixStyle: "inline-type-imports" },
],
// Off: 39 sites across this workspace root's own tooling files are debt from the @exadev/eslint-config 2.1.2->2.12.1 bump (ExaDev/documents.js#1275) that this bump's own PR does not fix -- @typescript-eslint/no-magic-numbers (34, enabled in 2.4.0, never previously enforced here) and @typescript-eslint/strict-boolean-expressions (5, enabled in 2.9.0). See PackageLintOptions.magicNumbers and .newRuleDebt in eslint.shared.ts for the equivalent per-package mechanism; the root config has no such options since it is not built via packageLintConfig, so the same two rules are switched off directly here instead.
"@typescript-eslint/no-magic-numbers": "off",
"@typescript-eslint/strict-boolean-expressions": "off",
},
},
{
// The config files themselves call `tseslint.config()`, which typescript-eslint deprecated in favour of ESLint core's `defineConfig()`. Migrating is blocked upstream rather than by choice: `defineConfig`'s stricter `Plugin` type rejects eslint-plugin-react-hooks@7, whose `configs.flat` is a nested record of configs where ESLint's own index signature admits only a config or an array of them. packageLintConfig in eslint.shared.ts is called by the web UI's own eslint.config.ts, which registers that plugin, so `defineConfig` there fails `tsc` outright -- and the only way through is a type assertion this workspace bans. Scoped to the two config files alone, so a deprecated API anywhere in real source still reports. Revisit when eslint-plugin-react-hooks' types satisfy ESLint's `Plugin`.
files: ["eslint.config.ts", "eslint.shared.ts"],
rules: { "@typescript-eslint/no-deprecated": "off" },
},

...dataFileLintConfig,

Expand Down
Loading