From e3caa205fdf2e002cda813daff3f49628e8a6867 Mon Sep 17 00:00:00 2001 From: Paco Cartones <253313177+pacocartones@users.noreply.github.com> Date: Fri, 28 Aug 2026 16:14:55 +0200 Subject: [PATCH] fix(ci): detect stale workspace lockfile entries (#11714) Removes stale nested browser-pool lock entries pinning Playwright 1.61.1/@types/node 22 despite the workspace declaring 1.62.1/26, and extends the lockfile gate with npm ls --workspaces --depth=0 so future manifest/lock drift fails visibly. 21/21 focused tests passing. Thanks! --- config/quality/eslint-suppressions.json | 5 - package-lock.json | 35 ------ scripts/check/check-lockfile.mjs | 78 +++++++++++++- tests/unit/build/check-lockfile.test.ts | 136 +++++++++++++++++++++++- 4 files changed, 209 insertions(+), 45 deletions(-) diff --git a/config/quality/eslint-suppressions.json b/config/quality/eslint-suppressions.json index a23cbbcfa6..890c2715d8 100644 --- a/config/quality/eslint-suppressions.json +++ b/config/quality/eslint-suppressions.json @@ -3761,11 +3761,6 @@ "count": 5 } }, - "tests/unit/build/check-lockfile.test.ts": { - "@typescript-eslint/no-unused-vars": { - "count": 1 - } - }, "tests/unit/bypass-handler.test.ts": { "@typescript-eslint/no-explicit-any": { "count": 2 diff --git a/package-lock.json b/package-lock.json index 6710cefe99..340b093d37 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38790,41 +38790,6 @@ "devDependencies": { "@types/node": "^26" } - }, - "packages/browser-pool/node_modules/@types/node": { - "version": "22.20.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", - "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" - } - }, - "packages/browser-pool/node_modules/playwright": { - "version": "1.61.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", - "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", - "license": "Apache-2.0", - "dependencies": { - "playwright-core": "1.61.1" - }, - "bin": { - "playwright": "cli.js" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "fsevents": "2.3.2" - } - }, - "packages/browser-pool/node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, - "license": "MIT" } } } diff --git a/scripts/check/check-lockfile.mjs b/scripts/check/check-lockfile.mjs index 61a7b35a8b..de22788475 100644 --- a/scripts/check/check-lockfile.mjs +++ b/scripts/check/check-lockfile.mjs @@ -7,6 +7,7 @@ // --validate-https → toda URL "resolved" deve usar HTTPS (bloqueia http://) // --validate-integrity → todo pacote deve ter hash de integridade sha512 // --allowed-hosts npm → apenas registry.npmjs.org é host permitido +// npm ls --workspaces → entradas do lockfile satisfazem cada workspace // // Complementa check-deps (Fase 2 / allowlist de nomes): aquele garante que só // nomes aprovados entram; este garante que os pacotes instalados vieram do registry @@ -57,10 +58,7 @@ export function getLockfileLintConfig() { * @returns {string[]} */ export function buildLockfileLintArgs(cfg) { - const args = [ - "--path", cfg.lockfilePath, - "--type", cfg.type, - ]; + const args = ["--path", cfg.lockfilePath, "--type", cfg.type]; if (cfg.validateHttps) args.push("--validate-https"); if (cfg.validateIntegrity) args.push("--validate-integrity"); if (cfg.allowedHosts.length) { @@ -69,6 +67,67 @@ export function buildLockfileLintArgs(cfg) { return args; } +/** + * Returns the cross-platform npm command that verifies direct workspace + * dependencies from package-lock.json, independent of the installed tree. + * + * @param {NodeJS.Platform} [platform] + * @param {string | undefined} [comSpec] + * @returns {{ command: string, args: string[] }} + */ +export function getWorkspaceDependencyCheckCommand( + platform = process.platform, + comSpec = process.env.ComSpec +) { + const npmArgs = ["ls", "--workspaces", "--depth=0", "--package-lock-only"]; + if (platform === "win32") { + return { + command: comSpec || "cmd.exe", + args: ["/d", "/s", "/c", "npm.cmd", ...npmArgs], + }; + } + + return { + command: "npm", + args: npmArgs, + }; +} + +/** + * Executes the workspace dependency consistency check while keeping the process + * boundary injectable for deterministic success and failure tests. + * + * @param {{ + * platform?: NodeJS.Platform, + * comSpec?: string, + * execFile?: typeof execFileSync, + * }} [options] + * @returns {{ ok: true } | { ok: false, stdout: string, stderr: string }} + */ +export function runWorkspaceDependencyCheck(options = {}) { + const { + platform = process.platform, + comSpec = process.env.ComSpec, + execFile = execFileSync, + } = options; + const workspaceCheck = getWorkspaceDependencyCheckCommand(platform, comSpec); + + try { + execFile(workspaceCheck.command, workspaceCheck.args, { + cwd: ROOT, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + return { ok: true }; + } catch (err) { + return { + ok: false, + stdout: err.stdout ?? "", + stderr: err.stderr ?? "", + }; + } +} + function main() { const cfg = getLockfileLintConfig(); @@ -111,6 +170,17 @@ function main() { ); process.exit(1); } + + const workspaceResult = runWorkspaceDependencyCheck(); + if (workspaceResult.ok) { + console.log("[check-lockfile] OK — workspace lock entries match their manifests"); + } else { + console.error("[check-lockfile] FAIL — workspace lock entries are inconsistent:"); + if (workspaceResult.stdout) console.error(workspaceResult.stdout); + if (workspaceResult.stderr) console.error(workspaceResult.stderr); + console.error("\n → Regenerate the affected lock entries and verify with a clean `npm ci`"); + process.exit(1); + } } if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main(); diff --git a/tests/unit/build/check-lockfile.test.ts b/tests/unit/build/check-lockfile.test.ts index 125a5e6525..860734a7d4 100644 --- a/tests/unit/build/check-lockfile.test.ts +++ b/tests/unit/build/check-lockfile.test.ts @@ -2,7 +2,8 @@ // TDD tests for check-lockfile.mjs — lockfile policy gate (Task 7.7). // // Strategy: the lockfile-lint binary is an external CLI tool; we do not spawn it -// in unit tests. Instead, we test the two exported pure functions: +// in unit tests. Instead, we test the exported policy helpers and inject the +// process boundary used by the workspace consistency runner: // - getLockfileLintConfig() — returns the policy configuration object // - buildLockfileLintArgs() — maps a config object to the argv array // @@ -11,10 +12,15 @@ import test from "node:test"; import assert from "node:assert/strict"; import path from "node:path"; +import { execFileSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import os from "node:os"; // @ts-expect-error — .mjs helper has no type declarations; runtime shape is known. import { getLockfileLintConfig, buildLockfileLintArgs, + getWorkspaceDependencyCheckCommand, + runWorkspaceDependencyCheck, } from "../../../scripts/check/check-lockfile.mjs"; // --------------------------------------------------------------------------- @@ -179,3 +185,131 @@ test("buildLockfileLintArgs: --allowed-hosts values follow immediately after the assert.equal(args[hostIdx + 1], "npm"); assert.equal(args[hostIdx + 2], "verdaccio"); }); + +// --------------------------------------------------------------------------- +// workspace dependency consistency +// --------------------------------------------------------------------------- + +test("getWorkspaceDependencyCheckCommand: checks every workspace at direct depth", () => { + const command = getWorkspaceDependencyCheckCommand("linux"); + assert.deepEqual(command.args, ["ls", "--workspaces", "--depth=0", "--package-lock-only"]); +}); + +test("getWorkspaceDependencyCheckCommand: invokes npm directly outside Windows", () => { + const command = getWorkspaceDependencyCheckCommand("linux"); + assert.equal(command.command, "npm"); +}); + +test("getWorkspaceDependencyCheckCommand: invokes npm.cmd through cmd.exe on Windows", () => { + const command = getWorkspaceDependencyCheckCommand("win32", "C:\\Windows\\System32\\cmd.exe"); + assert.equal(command.command, "C:\\Windows\\System32\\cmd.exe"); + assert.deepEqual(command.args, [ + "/d", + "/s", + "/c", + "npm.cmd", + "ls", + "--workspaces", + "--depth=0", + "--package-lock-only", + ]); +}); + +test("runWorkspaceDependencyCheck: executes the selected command and returns success", () => { + const calls: unknown[][] = []; + const result = runWorkspaceDependencyCheck({ + platform: "linux", + execFile: (...args: unknown[]) => { + calls.push(args); + return "tree is valid"; + }, + }); + + assert.deepEqual(result, { ok: true }); + assert.equal(calls.length, 1); + assert.equal(calls[0]?.[0], "npm"); + assert.deepEqual(calls[0]?.[1], ["ls", "--workspaces", "--depth=0", "--package-lock-only"]); +}); + +test("runWorkspaceDependencyCheck: reports npm ls failures without masking diagnostics", () => { + const failure = Object.assign(new Error("ELSPROBLEMS"), { + stdout: "invalid playwright", + stderr: "npm error code ELSPROBLEMS", + }); + + const result = runWorkspaceDependencyCheck({ + platform: "linux", + execFile: () => { + throw failure; + }, + }); + + assert.deepEqual(result, { + ok: false, + stdout: "invalid playwright", + stderr: "npm error code ELSPROBLEMS", + }); +}); + +test("workspace check validates lock entries independently of node_modules", (t) => { + const root = mkdtempSync(path.join(os.tmpdir(), "omniroute-lockfile-check-")); + t.after(() => rmSync(root, { recursive: true, force: true })); + mkdirSync(path.join(root, "packages", "example"), { recursive: true }); + writeFileSync( + path.join(root, "package.json"), + JSON.stringify({ name: "root", version: "1.0.0", private: true, workspaces: ["packages/*"] }) + ); + writeFileSync( + path.join(root, "packages", "example", "package.json"), + JSON.stringify({ name: "example", version: "1.0.0", dependencies: { semver: "7.7.4" } }) + ); + const lock = { + name: "root", + version: "1.0.0", + lockfileVersion: 3, + requires: true, + packages: { + "": { name: "root", version: "1.0.0", workspaces: ["packages/*"] }, + "node_modules/example": { resolved: "packages/example", link: true }, + "node_modules/semver": { version: "7.6.0" }, + "packages/example": { + name: "example", + version: "1.0.0", + dependencies: { semver: "7.7.4" }, + }, + }, + }; + const lockPath = path.join(root, "package-lock.json"); + writeFileSync(lockPath, JSON.stringify(lock)); + + const command = getWorkspaceDependencyCheckCommand(process.platform, process.env.ComSpec); + assert.throws( + () => + execFileSync(command.command, command.args, { + cwd: root, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }), + (error: unknown) => { + const diagnostics = `${String((error as { stdout?: string }).stdout ?? "")}\n${String( + (error as { stderr?: string }).stderr ?? "" + )}`; + return /ELSPROBLEMS|invalid/i.test(diagnostics); + } + ); + + lock.packages["node_modules/semver"].version = "7.7.4"; + writeFileSync(lockPath, JSON.stringify(lock)); + mkdirSync(path.join(root, "node_modules", "semver"), { recursive: true }); + writeFileSync( + path.join(root, "node_modules", "semver", "package.json"), + JSON.stringify({ name: "semver", version: "7.6.0" }) + ); + assert.doesNotThrow(() => + execFileSync(command.command, command.args, { + cwd: root, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }) + ); +});