diff --git a/desktop/scripts/check-web-pal-coverage.mjs b/desktop/scripts/check-web-pal-coverage.mjs index 9b3cecb2366..1eb646cefee 100644 --- a/desktop/scripts/check-web-pal-coverage.mjs +++ b/desktop/scripts/check-web-pal-coverage.mjs @@ -75,7 +75,7 @@ function coverageClaimsFromFile(coverage) { invalid.push(`${PENDING_FIELD} must be an object of name -> owner`); } else { for (const [name, owner] of Object.entries(pendingEntries)) { - if (typeof owner !== "string" || owner.length === 0) { + if (typeof owner !== "string" || owner.trim().length === 0) { invalid.push(`${PENDING_FIELD}.${name} must name an owning lane`); continue; } diff --git a/desktop/scripts/web-pal-census.mjs b/desktop/scripts/web-pal-census.mjs index 38d1197b2f7..527b3b1e233 100644 --- a/desktop/scripts/web-pal-census.mjs +++ b/desktop/scripts/web-pal-census.mjs @@ -31,7 +31,10 @@ function modulePathForImport(specifier, sourcePath) { } if (!specifier.startsWith(".")) return null; - const candidate = path.resolve(path.dirname(sourcePath), specifier); + const absoluteSourcePath = path.isAbsolute(sourcePath) + ? sourcePath + : path.resolve(REPO_DIR, sourcePath); + const candidate = path.resolve(path.dirname(absoluteSourcePath), specifier); const candidates = [ candidate, `${candidate}.ts`, @@ -521,16 +524,58 @@ export function buildManifest(renderer, rustCommands) { }; } -export async function generateManifest({ outputPath = MANIFEST_PATH } = {}) { +export async function createManifest() { const [renderer, rustCommands] = await Promise.all([ extractRendererCommands(), extractRustCommands(), ]); - const manifest = buildManifest(renderer, rustCommands); + return buildManifest(renderer, rustCommands); +} + +export async function generateManifest({ outputPath = MANIFEST_PATH } = {}) { + const manifest = await createManifest(); await writeFile(outputPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8"); return manifest; } +function namesForSection(manifest, section) { + const commands = manifest?.[section]?.commands; + if (!Array.isArray(commands)) { + throw new Error(`Manifest is missing ${section}.commands`); + } + return commands.map((command) => + typeof command === "string" ? command : command?.name, + ); +} + +function describeNameDrift(section, committedNames, currentNames) { + const committed = new Set(committedNames); + const current = new Set(currentNames); + const added = currentNames.filter((name) => !committed.has(name)); + const removed = committedNames.filter((name) => !current.has(name)); + return `${section} command drift (added: ${added.join(", ") || "none"}; removed: ${removed.join(", ") || "none"})`; +} + +export async function checkCommittedManifest({ + manifestPath = MANIFEST_PATH, +} = {}) { + const [committed, current] = await Promise.all([ + readFile(manifestPath, "utf8").then(JSON.parse), + createManifest(), + ]); + for (const section of ["renderer", "rust"]) { + const committedNames = namesForSection(committed, section); + const currentNames = namesForSection(current, section); + if ( + committedNames.length !== currentNames.length || + committedNames.some((name, index) => name !== currentNames[index]) + ) { + throw new Error(describeNameDrift(section, committedNames, currentNames)); + } + } + return current; +} + function printSummary(manifest) { const rendererCount = manifest.renderer.commandCount; const rustCount = manifest.rust.commandCount; @@ -560,10 +605,21 @@ const isMain = process.argv[1] && pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url; if (isMain) { - generateManifest() - .then(printSummary) - .catch((error) => { - console.error(error instanceof Error ? error.message : error); - process.exitCode = 1; - }); + const args = process.argv.slice(2); + const unknownArgs = args.filter((argument) => argument !== "--check"); + const action = + unknownArgs.length > 0 + ? Promise.reject( + new Error(`Unknown argument(s): ${unknownArgs.join(", ")}`), + ) + : args.includes("--check") + ? checkCommittedManifest().then((manifest) => { + printSummary(manifest); + console.log("Committed manifest matches current command names"); + }) + : generateManifest().then(printSummary); + action.catch((error) => { + console.error(error instanceof Error ? error.message : error); + process.exitCode = 1; + }); } diff --git a/desktop/src/platform/web/coverage-guard.test.mjs b/desktop/src/platform/web/coverage-guard.test.mjs index db169900d00..25367caee9f 100644 --- a/desktop/src/platform/web/coverage-guard.test.mjs +++ b/desktop/src/platform/web/coverage-guard.test.mjs @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import { readFile } from "node:fs/promises"; import test from "node:test"; import { checkCoverage } from "../../../scripts/check-web-pal-coverage.mjs"; +import { createManifest } from "../../../scripts/web-pal-census.mjs"; // Enforces that every renderer-invoked Tauri command has an explicit browser // classification (implemented / noop / capability-off). Adding a new invoke() @@ -16,6 +17,12 @@ test("web PAL coverage: every renderer command is classified", async () => { const coverage = JSON.parse( await readFile(new URL("./coverage.json", import.meta.url), "utf8"), ); + const currentManifest = await createManifest(); + assert.deepEqual( + currentManifest.renderer.commands.map((command) => command.name), + manifest.renderer.commands.map((command) => command.name), + "renderer command census drift; regenerate docs/web-pal-commands.json", + ); const result = checkCoverage(manifest, coverage); assert.deepEqual(result.missing, [], "unaccounted renderer commands"); assert.deepEqual( @@ -27,3 +34,18 @@ test("web PAL coverage: every renderer command is classified", async () => { assert.deepEqual(result.invalid, [], "invalid coverage entries"); assert.equal(result.ok, true); }); + +test("web PAL coverage rejects blank and unknown pending owners", () => { + const result = checkCoverage( + { renderer: { commands: [{ name: "known" }] } }, + { + implemented: [], + noop: [], + "capability-off": [], + pending: { known: " ", unknown: "lane" }, + }, + ); + assert.deepEqual(result.invalid, ["pending.known must name an owning lane"]); + assert.deepEqual(result.unknown, ["unknown"]); + assert.equal(result.ok, false); +}); diff --git a/desktop/src/platform/web/web-pal-census.test.mjs b/desktop/src/platform/web/web-pal-census.test.mjs index cfa1cb798f6..e749746dca2 100644 --- a/desktop/src/platform/web/web-pal-census.test.mjs +++ b/desktop/src/platform/web/web-pal-census.test.mjs @@ -1,11 +1,24 @@ import assert from "node:assert/strict"; -import { readFile } from "node:fs/promises"; +import { execFile } from "node:child_process"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; import test from "node:test"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; import { + checkCommittedManifest, extractRendererCommandsFromSource, extractRustCommandsFromSource, } from "../../../scripts/web-pal-census.mjs"; +const execFileAsync = promisify(execFile); +const DESKTOP_DIR = fileURLToPath(new URL("../../../", import.meta.url)); +const REPO_DIR = path.resolve(DESKTOP_DIR, ".."); +const CENSUS_SCRIPT = fileURLToPath( + new URL("../../../scripts/web-pal-census.mjs", import.meta.url), +); + test("AST census extracts single-line, multi-line, aliased, and raw invocations", async () => { const fixture = await readFile( new URL( @@ -55,3 +68,36 @@ test("Rust command extraction handles module paths and cfg attributes", () => { `); assert.deepEqual(commands, ["first_command", "second_command"]); }); + +test("census check is independent of the caller working directory", async () => { + for (const cwd of [REPO_DIR, DESKTOP_DIR]) { + const { stdout } = await execFileAsync( + process.execPath, + [CENSUS_SCRIPT, "--check"], + { cwd }, + ); + assert.match(stdout, /Renderer commands: 294 distinct/); + assert.match(stdout, /Committed manifest matches current command names/); + } +}); + +test("census check rejects committed command-name drift", async () => { + const directory = await mkdtemp(path.join(os.tmpdir(), "buzz-census-test-")); + const manifestPath = path.join(directory, "web-pal-commands.json"); + try { + const manifest = JSON.parse( + await readFile( + path.join(DESKTOP_DIR, "docs/web-pal-commands.json"), + "utf8", + ), + ); + manifest.renderer.commands.pop(); + await writeFile(manifestPath, JSON.stringify(manifest), "utf8"); + await assert.rejects( + checkCommittedManifest({ manifestPath }), + /renderer command drift/, + ); + } finally { + await rm(directory, { recursive: true, force: true }); + } +});