diff --git a/.github/workflows/code-qa.yml b/.github/workflows/code-qa.yml index cc5835e081..8c2d45d605 100644 --- a/.github/workflows/code-qa.yml +++ b/.github/workflows/code-qa.yml @@ -150,12 +150,20 @@ jobs: restore-keys: | ${{ runner.os }}-turbo-${{ hashFiles('**/pnpm-lock.yaml') }}- ${{ runner.os }}-turbo- - - name: Run non-core coverage - run: pnpm turbo run test:coverage --filter="!@roo-code/core" --log-order grouped --output-logs new-only + - name: Run non-extension package coverage + run: pnpm turbo run test:coverage --filter="!@roo-code/core" --filter="!zoo-code" --log-order grouped --output-logs new-only + - name: Run extension unit coverage + run: pnpm turbo run test:coverage:unit --filter="zoo-code" --log-order grouped --output-logs new-only + - name: Verify extension coverage contract + run: pnpm --dir src run verify:coverage-contract + - name: Run extension dist smoke test + run: pnpm turbo run test:dist --filter="zoo-code" --log-order grouped --output-logs new-only - name: Run core unit coverage run: pnpm turbo run test:coverage:unit --filter="@roo-code/core" --log-order grouped --output-logs new-only - name: Run core integration coverage run: pnpm turbo run test:coverage:integration --filter="@roo-code/core" --log-order grouped --output-logs new-only + - name: Verify extension unit coverage report + run: node src/scripts/verify-lcov.mjs src/coverage/unit/lcov.info - name: Save Turbo cache if: steps.turbo-cache.outputs.cache-hit != 'true' uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 @@ -177,7 +185,7 @@ jobs: uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 with: files: >- - src/coverage/lcov.info, + src/coverage/unit/lcov.info, packages/cloud/coverage/lcov.info, packages/telemetry/coverage/lcov.info, apps/cli/coverage/lcov.info @@ -214,7 +222,7 @@ jobs: with: name: coverage-reports-${{ matrix.name }} path: | - src/coverage/lcov.info + src/coverage/unit/lcov.info webview-ui/coverage/lcov.info packages/cloud/coverage/lcov.info packages/telemetry/coverage/lcov.info diff --git a/src/package.json b/src/package.json index 7467b50b7b..edf97d6f47 100644 --- a/src/package.json +++ b/src/package.json @@ -442,6 +442,8 @@ "lint": "eslint . --ext=ts --max-warnings=0", "check-types": "tsc --noEmit", "test": "vitest run", + "prepare:tree-sitter-wasms": "node scripts/copy-tree-sitter-wasms.mjs", + "verify:coverage-contract": "node scripts/verify-coverage-contract.mjs", "test:unit": "vitest run --config vitest.unit.config.ts", "test:dist": "vitest run --config vitest.dist.config.ts", "test:coverage": "vitest run --coverage", diff --git a/src/scripts/copy-tree-sitter-wasms.mjs b/src/scripts/copy-tree-sitter-wasms.mjs new file mode 100644 index 0000000000..9aa211df45 --- /dev/null +++ b/src/scripts/copy-tree-sitter-wasms.mjs @@ -0,0 +1,132 @@ +import fs from "node:fs" +import path from "node:path" +import process from "node:process" +import { fileURLToPath } from "node:url" + +const srcDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..") +const wasmDir = path.join(srcDir, "node_modules", "tree-sitter-wasms", "out") +const distDir = path.join(srcDir, "dist") +const wasmPattern = /^tree-sitter-.*\.wasm$/ +const temporaryPattern = /^tree-sitter-.*\.wasm\.\d+\.tmp$/ + +export function cleanPublishedTreeSitterWasms(destinationDir, filesystem = fs) { + if (!filesystem.existsSync(destinationDir)) return + for (const filename of filesystem.readdirSync(destinationDir)) { + if (wasmPattern.test(filename) || temporaryPattern.test(filename)) { + filesystem.rmSync(path.join(destinationDir, filename), { force: true }) + } + } +} + +export async function publishTreeSitterWasms( + sourceDir, + destinationDir, + { filesystem = fs.promises, onStep = async () => {}, signalState = { requested: undefined } } = {}, +) { + const transactionDir = `${destinationDir}.tree-sitter-wasms-transaction` + const stagedDir = path.join(transactionDir, "staged") + const backupDir = path.join(transactionDir, "backup") + const quarantineDir = path.join(transactionDir, "quarantine") + const sourceFiles = (await filesystem.readdir(sourceDir)).filter((filename) => wasmPattern.test(filename)).sort() + if (sourceFiles.length === 0) throw new Error("WASM source set is empty") + const step = async (name, filename) => { + await onStep(name, filename) + if (signalState.requested) throw Object.assign(new Error("WASM publication cancelled"), { code: "CANCELLED" }) + } + + let commitStarted = false + let committed = false + let ownsTransaction = false + const publishedFiles = [] + try { + await filesystem.mkdir(destinationDir, { recursive: true }) + await step("initialized", destinationDir) + await filesystem.mkdir(transactionDir) + ownsTransaction = true + await step("initialized", transactionDir) + await filesystem.mkdir(stagedDir) + await step("initialized", stagedDir) + await filesystem.mkdir(backupDir) + await step("initialized", backupDir) + await filesystem.mkdir(quarantineDir) + await step("initialized", quarantineDir) + + for (const filename of sourceFiles) { + await filesystem.copyFile(path.join(sourceDir, filename), path.join(stagedDir, filename)) + await step("staged", filename) + } + + const previousFiles = (await filesystem.readdir(destinationDir)).filter((filename) => + wasmPattern.test(filename), + ) + commitStarted = true + for (const filename of previousFiles) { + await filesystem.rename(path.join(destinationDir, filename), path.join(backupDir, filename)) + await step("backed-up", filename) + } + for (const filename of sourceFiles) { + await filesystem.rename(path.join(stagedDir, filename), path.join(destinationDir, filename)) + publishedFiles.push(filename) + await step("published", filename) + } + const destinationFiles = await filesystem.readdir(destinationDir) + await step("inspected-temporaries", destinationDir) + for (const filename of destinationFiles) { + if (temporaryPattern.test(filename)) { + await filesystem.rm(path.join(destinationDir, filename), { force: true }) + await step("removed-temporary", filename) + } + } + + committed = true + await filesystem.rm(transactionDir, { recursive: true, force: true }) + return { sourceFiles, cleanup: () => cleanPublishedTreeSitterWasms(destinationDir) } + } catch (error) { + if (committed) throw error + if (!commitStarted && ownsTransaction) { + await filesystem.rm(transactionDir, { recursive: true, force: true }) + } + if (!commitStarted) throw error + + const failures = [] + for (const filename of publishedFiles) { + try { + await filesystem.rename(path.join(destinationDir, filename), path.join(quarantineDir, filename)) + } catch (rollbackError) { + if (rollbackError.code !== "ENOENT") failures.push(rollbackError) + } + } + for (const filename of await filesystem.readdir(backupDir)) { + try { + await filesystem.rename(path.join(backupDir, filename), path.join(destinationDir, filename)) + await onStep("restored", filename) + } catch (rollbackError) { + failures.push(rollbackError) + } + } + if (failures.length > 0) { + throw new AggregateError( + [error, ...failures], + `WASM rollback incomplete; recovery retained at ${transactionDir}`, + ) + } + await filesystem.rm(transactionDir, { recursive: true, force: true }) + throw error + } +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + const signalState = { requested: undefined } + for (const signal of ["SIGINT", "SIGTERM"]) { + process.on(signal, () => { + signalState.requested ??= signal + }) + } + try { + await publishTreeSitterWasms(wasmDir, distDir, { signalState }) + if (signalState.requested) process.exitCode = signalState.requested === "SIGINT" ? 130 : 143 + } catch (error) { + if (!signalState.requested) throw error + process.exitCode = signalState.requested === "SIGINT" ? 130 : 143 + } +} diff --git a/src/scripts/copy-tree-sitter-wasms.spec.mjs b/src/scripts/copy-tree-sitter-wasms.spec.mjs new file mode 100644 index 0000000000..5445f630f9 --- /dev/null +++ b/src/scripts/copy-tree-sitter-wasms.spec.mjs @@ -0,0 +1,226 @@ +import fs from "node:fs" +import os from "node:os" +import path from "node:path" +import { afterEach, beforeEach, describe, expect, it } from "vitest" + +import { publishTreeSitterWasms } from "./copy-tree-sitter-wasms.mjs" + +describe("publishTreeSitterWasms", () => { + let root + + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), "tree-sitter-wasms-")) + }) + + afterEach(() => fs.rmSync(root, { recursive: true, force: true })) + + it("publishes the exact WASM set and removes stale outputs", async () => { + const source = path.join(root, "source") + const destination = path.join(root, "dist") + fs.mkdirSync(source) + fs.mkdirSync(destination) + fs.writeFileSync(path.join(source, "tree-sitter-a.wasm"), "a") + fs.writeFileSync(path.join(source, "ignored.txt"), "ignored") + fs.writeFileSync(path.join(destination, "tree-sitter-stale.wasm"), "stale") + fs.writeFileSync(path.join(destination, "tree-sitter-a.wasm.999.tmp"), "partial") + + await publishTreeSitterWasms(source, destination) + + expect(fs.readdirSync(destination)).toEqual(["tree-sitter-a.wasm"]) + expect(fs.readFileSync(path.join(destination, "tree-sitter-a.wasm"), "utf8")).toBe("a") + }) + + it("rejects an empty source set before touching published outputs", async () => { + const source = path.join(root, "source") + const destination = path.join(root, "dist") + fs.mkdirSync(source) + fs.mkdirSync(destination) + fs.writeFileSync(path.join(destination, "tree-sitter-a.wasm"), "existing") + + await expect(publishTreeSitterWasms(source, destination)).rejects.toThrow("WASM source set is empty") + expect(fs.readFileSync(path.join(destination, "tree-sitter-a.wasm"), "utf8")).toBe("existing") + expect(fs.existsSync(`${destination}.tree-sitter-wasms-transaction`)).toBe(false) + }) + + it("restores published outputs when publication fails", async () => { + const source = path.join(root, "source") + const destination = path.join(root, "dist") + fs.mkdirSync(source) + fs.mkdirSync(destination) + fs.writeFileSync(path.join(source, "tree-sitter-a.wasm"), "a") + fs.writeFileSync(path.join(source, "tree-sitter-b.wasm"), "b") + fs.writeFileSync(path.join(destination, "tree-sitter-existing.wasm"), "existing") + let copies = 0 + const filesystem = { + ...fs.promises, + copyFile(...args) { + if (++copies === 2) throw new Error("copy failed") + return fs.promises.copyFile(...args) + }, + } + + await expect(publishTreeSitterWasms(source, destination, { filesystem })).rejects.toThrow("copy failed") + expect(fs.readdirSync(destination)).toEqual(["tree-sitter-existing.wasm"]) + expect(fs.readFileSync(path.join(destination, "tree-sitter-existing.wasm"), "utf8")).toBe("existing") + }) + + it("restores published outputs when an atomic rename fails", async () => { + const source = path.join(root, "source") + const destination = path.join(root, "dist") + fs.mkdirSync(source) + fs.mkdirSync(destination) + fs.writeFileSync(path.join(source, "tree-sitter-a.wasm"), "a") + fs.writeFileSync(path.join(source, "tree-sitter-b.wasm"), "b") + fs.writeFileSync(path.join(destination, "tree-sitter-a.wasm"), "previous-a") + fs.writeFileSync(path.join(destination, "tree-sitter-b.wasm"), "previous-b") + let renames = 0 + const filesystem = { + ...fs.promises, + rename(...args) { + if (args[0].includes(`${path.sep}staged${path.sep}`) && ++renames === 2) + throw new Error("rename failed") + return fs.promises.rename(...args) + }, + } + + await expect(publishTreeSitterWasms(source, destination, { filesystem })).rejects.toThrow("rename failed") + expect(fs.readdirSync(destination)).toEqual(["tree-sitter-a.wasm", "tree-sitter-b.wasm"]) + expect(fs.readFileSync(path.join(destination, "tree-sitter-a.wasm"), "utf8")).toBe("previous-a") + expect(fs.readFileSync(path.join(destination, "tree-sitter-b.wasm"), "utf8")).toBe("previous-b") + }) + + it("restores published outputs when signalled during commit", async () => { + const source = path.join(root, "source") + const destination = path.join(root, "dist") + fs.mkdirSync(source) + fs.mkdirSync(destination) + fs.writeFileSync(path.join(source, "tree-sitter-a.wasm"), "new-a") + fs.writeFileSync(path.join(destination, "tree-sitter-a.wasm"), "previous-a") + const signalState = { requested: undefined } + + await expect( + publishTreeSitterWasms(source, destination, { + signalState, + onStep(name) { + if (name === "published") signalState.requested = "SIGTERM" + if (name === "restored") signalState.requested ??= "SIGINT" + }, + }), + ).rejects.toThrow("WASM publication cancelled") + expect(signalState.requested).toBe("SIGTERM") + expect(fs.readFileSync(path.join(destination, "tree-sitter-a.wasm"), "utf8")).toBe("previous-a") + expect(fs.existsSync(`${destination}.tree-sitter-wasms-transaction`)).toBe(false) + }) + + it("retains the backup when restoration fails", async () => { + const source = path.join(root, "source") + const destination = path.join(root, "dist") + const transaction = `${destination}.tree-sitter-wasms-transaction` + fs.mkdirSync(source) + fs.mkdirSync(destination) + fs.writeFileSync(path.join(source, "tree-sitter-a.wasm"), "new-a") + fs.writeFileSync(path.join(destination, "tree-sitter-a.wasm"), "previous-a") + const signalState = { requested: undefined } + const filesystem = { + ...fs.promises, + rename(sourcePath, destinationPath) { + if (sourcePath.includes(`${path.sep}backup${path.sep}`)) throw new Error("restore failed") + return fs.promises.rename(sourcePath, destinationPath) + }, + } + + await expect( + publishTreeSitterWasms(source, destination, { + filesystem, + signalState, + onStep(name) { + if (name === "published") signalState.requested = "SIGTERM" + }, + }), + ).rejects.toThrow(`WASM rollback incomplete; recovery retained at ${transaction}`) + expect(fs.readFileSync(path.join(transaction, "backup", "tree-sitter-a.wasm"), "utf8")).toBe("previous-a") + await expect(publishTreeSitterWasms(source, destination)).rejects.toMatchObject({ code: "EEXIST" }) + expect(fs.readFileSync(path.join(transaction, "backup", "tree-sitter-a.wasm"), "utf8")).toBe("previous-a") + await expect(publishTreeSitterWasms(source, destination)).rejects.toMatchObject({ code: "EEXIST" }) + }) + + it("cleans an incomplete transaction setup", async () => { + const source = path.join(root, "source") + const destination = path.join(root, "dist") + const transaction = `${destination}.tree-sitter-wasms-transaction` + fs.mkdirSync(source) + fs.writeFileSync(path.join(source, "tree-sitter-a.wasm"), "a") + const filesystem = { + ...fs.promises, + mkdir(directory, options) { + if (directory.endsWith(`${path.sep}backup`)) throw new Error("setup failed") + return fs.promises.mkdir(directory, options) + }, + } + + await expect(publishTreeSitterWasms(source, destination, { filesystem })).rejects.toThrow("setup failed") + expect(fs.existsSync(transaction)).toBe(false) + }) + + it("observes a signal during temporary cleanup", async () => { + const source = path.join(root, "source") + const destination = path.join(root, "dist") + fs.mkdirSync(source) + fs.mkdirSync(destination) + fs.writeFileSync(path.join(source, "tree-sitter-a.wasm"), "new-a") + fs.writeFileSync(path.join(destination, "tree-sitter-a.wasm"), "previous-a") + fs.writeFileSync(path.join(destination, "tree-sitter-a.wasm.999.tmp"), "partial") + const signalState = { requested: undefined } + + await expect( + publishTreeSitterWasms(source, destination, { + signalState, + onStep(name) { + if (name === "removed-temporary") signalState.requested = "SIGTERM" + }, + }), + ).rejects.toThrow("WASM publication cancelled") + expect(fs.readFileSync(path.join(destination, "tree-sitter-a.wasm"), "utf8")).toBe("previous-a") + }) + + it("observes a signal after inspecting a destination without temporary files", async () => { + const source = path.join(root, "source") + const destination = path.join(root, "dist") + fs.mkdirSync(source) + fs.mkdirSync(destination) + fs.writeFileSync(path.join(source, "tree-sitter-a.wasm"), "new-a") + fs.writeFileSync(path.join(destination, "tree-sitter-a.wasm"), "previous-a") + const signalState = { requested: undefined } + + await expect( + publishTreeSitterWasms(source, destination, { + signalState, + onStep(name) { + if (name === "inspected-temporaries") signalState.requested = "SIGTERM" + }, + }), + ).rejects.toThrow("WASM publication cancelled") + expect(fs.readFileSync(path.join(destination, "tree-sitter-a.wasm"), "utf8")).toBe("previous-a") + }) + + it("keeps committed outputs when transaction cleanup fails", async () => { + const source = path.join(root, "source") + const destination = path.join(root, "dist") + const transaction = `${destination}.tree-sitter-wasms-transaction` + fs.mkdirSync(source) + fs.mkdirSync(destination) + fs.writeFileSync(path.join(source, "tree-sitter-a.wasm"), "new-a") + fs.writeFileSync(path.join(destination, "tree-sitter-a.wasm"), "previous-a") + const filesystem = { + ...fs.promises, + rm(directory, options) { + if (directory === transaction) throw new Error("cleanup failed") + return fs.promises.rm(directory, options) + }, + } + + await expect(publishTreeSitterWasms(source, destination, { filesystem })).rejects.toThrow("cleanup failed") + expect(fs.readFileSync(path.join(destination, "tree-sitter-a.wasm"), "utf8")).toBe("new-a") + expect(fs.readFileSync(path.join(transaction, "backup", "tree-sitter-a.wasm"), "utf8")).toBe("previous-a") + }) +}) diff --git a/src/scripts/verify-coverage-contract.mjs b/src/scripts/verify-coverage-contract.mjs new file mode 100644 index 0000000000..5a81f8b15a --- /dev/null +++ b/src/scripts/verify-coverage-contract.mjs @@ -0,0 +1,117 @@ +import { spawnSync } from "node:child_process" +import fs from "node:fs" +import path from "node:path" +import process from "node:process" +import { fileURLToPath } from "node:url" + +import { assertMatchingFiles } from "./verify-wasm-files.mjs" +import { createWasmOutputSnapshot } from "./wasm-output-snapshot.mjs" + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..") +const pnpm = process.platform === "win32" ? process.env.npm_execpath : "pnpm" +if (!pnpm) throw new Error("pnpm executable path is unavailable") +const pnpmPrefix = process.platform === "win32" ? [pnpm] : [] +const run = (args, options = {}) => { + const { includeStderr = true, ...spawnOptions } = options + const command = process.platform === "win32" ? process.execPath : pnpm + const result = spawnSync(command, [...pnpmPrefix, ...args], { cwd: root, encoding: "utf8", ...spawnOptions }) + if (result.status !== 0) { + const details = [ + result.error?.message, + result.signal ? `terminated by ${result.signal}` : undefined, + result.stderr, + result.stdout, + ] + .filter(Boolean) + .join("\n") + throw new Error(details || `pnpm exited with status ${result.status ?? "unknown"}`) + } + return `${result.stdout || ""}${includeStderr ? result.stderr || "" : ""}` +} + +const graph = JSON.parse( + run(["turbo", "run", "test:coverage:unit", "--filter=zoo-code", "--dry=json"], { includeStderr: false }), +) +const coverageTask = graph.tasks.find(({ taskId }) => taskId === "zoo-code#test:coverage:unit") +const preparationTask = graph.tasks.find(({ taskId }) => taskId === "zoo-code#prepare:tree-sitter-wasms") +if (!coverageTask?.dependencies.includes("zoo-code#prepare:tree-sitter-wasms")) + throw new Error("WASM prerequisite missing") +if (coverageTask.dependencies.includes("zoo-code#bundle")) throw new Error("Unit coverage must not depend on bundle") +if (JSON.stringify(preparationTask?.outputs) !== JSON.stringify(["dist/tree-sitter-*.wasm"])) + throw new Error("WASM prerequisite outputs changed") + +const dist = path.join(root, "src", "dist") +const cacheDir = path.join(root, ".turbo", "coverage-contract") +fs.rmSync(cacheDir, { recursive: true, force: true }) +const state = { outputSnapshot: undefined } +for (const signal of ["SIGINT", "SIGTERM"]) { + process.once(signal, () => { + try { + state.outputSnapshot?.restore() + } finally { + fs.rmSync(cacheDir, { recursive: true, force: true }) + } + process.exit(1) + }) +} +state.outputSnapshot = createWasmOutputSnapshot(dist) + +try { + run(["turbo", "run", "prepare:tree-sitter-wasms", "--filter=zoo-code", "--cache-dir=.turbo/coverage-contract"]) + run(["--dir", "src", "exec", "vitest", "run", "services/tree-sitter/__tests__"], { stdio: "inherit" }) + + const source = fs + .readdirSync(path.join(root, "src", "node_modules", "tree-sitter-wasms", "out")) + .filter((filename) => /^tree-sitter-.*\.wasm$/.test(filename)) + .sort() + const published = fs + .readdirSync(dist) + .filter((filename) => /^tree-sitter-.*\.wasm$/.test(filename)) + .sort() + if (source.length === 0) throw new Error("Dependency contains no tree-sitter WASMs") + if (JSON.stringify(source) !== JSON.stringify(published)) + throw new Error("Published WASM set does not match dependency") + assertMatchingFiles( + path.join(root, "src", "node_modules", "tree-sitter-wasms", "out"), + dist, + source, + "Published WASM content does not match dependency", + ) + if (fs.readdirSync(dist).some((filename) => filename.endsWith(".tmp"))) + throw new Error("Temporary WASM files remain") + + for (const filename of published) fs.rmSync(path.join(dist, filename), { force: true }) + const warmGraph = JSON.parse( + run( + [ + "turbo", + "run", + "prepare:tree-sitter-wasms", + "--filter=zoo-code", + "--cache-dir=.turbo/coverage-contract", + "--dry=json", + ], + { includeStderr: false }, + ), + ) + const warmTask = warmGraph.tasks.find(({ taskId }) => taskId === "zoo-code#prepare:tree-sitter-wasms") + if (warmTask?.cache.status !== "HIT") throw new Error("WASM prerequisite is not available in the isolated cache") + run(["turbo", "run", "prepare:tree-sitter-wasms", "--filter=zoo-code", "--cache-dir=.turbo/coverage-contract"]) + const restored = fs + .readdirSync(dist) + .filter((filename) => /^tree-sitter-.*\.wasm$/.test(filename)) + .sort() + if (JSON.stringify(source) !== JSON.stringify(restored)) throw new Error("WASM cache did not restore exact outputs") + assertMatchingFiles( + path.join(root, "src", "node_modules", "tree-sitter-wasms", "out"), + dist, + source, + "WASM cache restored corrupted output", + ) +} finally { + try { + state.outputSnapshot.restore() + } finally { + fs.rmSync(cacheDir, { recursive: true, force: true }) + } +} diff --git a/src/scripts/verify-lcov.mjs b/src/scripts/verify-lcov.mjs new file mode 100644 index 0000000000..c4220dbf00 --- /dev/null +++ b/src/scripts/verify-lcov.mjs @@ -0,0 +1,46 @@ +import fs from "node:fs" +import process from "node:process" +import { fileURLToPath } from "node:url" + +export function verifyLcov(content) { + let inRecord = false + let anyCovered = false + let linesFound + let linesHit + + for (const line of content.split(/\r?\n/)) { + if (line.startsWith("SF:")) { + if (inRecord) throw new Error("LCOV source record is not terminated") + if (!line.slice(3)) throw new Error("LCOV source path is empty") + inRecord = true + linesFound = undefined + linesHit = undefined + } else if (line.startsWith("LF:")) { + if (!inRecord) throw new Error("LCOV line count is outside a source record") + if (linesFound !== undefined) throw new Error("LCOV source record has duplicate line counts") + const found = line.slice(3) + if (!/^\d+$/.test(found)) throw new Error("LCOV line count is not a decimal integer") + linesFound = BigInt(found) + } else if (line.startsWith("LH:")) { + if (!inRecord) throw new Error("LCOV hit count is outside a source record") + if (linesHit !== undefined) throw new Error("LCOV source record has duplicate hit counts") + const hits = line.slice(3) + if (!/^\d+$/.test(hits)) throw new Error("LCOV hit count is not a decimal integer") + linesHit = BigInt(hits) + } else if (line === "end_of_record") { + if (!inRecord) throw new Error("LCOV terminator is outside a source record") + if (linesFound === undefined || linesHit === undefined) + throw new Error("LCOV source record has incomplete line summaries") + if (linesHit > linesFound) throw new Error("LCOV hit count exceeds lines found") + if (linesHit > 0n) anyCovered = true + inRecord = false + } + } + + if (inRecord) throw new Error("LCOV source record is not terminated") + if (!anyCovered) throw new Error("LCOV report has no covered lines") +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + verifyLcov(fs.readFileSync(process.argv[2], "utf8")) +} diff --git a/src/scripts/verify-lcov.spec.mjs b/src/scripts/verify-lcov.spec.mjs new file mode 100644 index 0000000000..849c722979 --- /dev/null +++ b/src/scripts/verify-lcov.spec.mjs @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest" + +import { verifyLcov } from "./verify-lcov.mjs" + +describe("verifyLcov", () => { + it("accepts complete records with covered lines", () => { + expect(() => verifyLcov("SF:file.ts\nLF:1\nLH:1\nend_of_record\n")).not.toThrow() + }) + + it.each([ + ["an empty source path", "SF:\nLF:1\nLH:1\nend_of_record\n"], + ["an unterminated record", "SF:file.ts\nLH:1\n"], + ["a zero-hit report", "SF:file.ts\nLF:1\nLH:0\nend_of_record\n"], + ["a line count outside a record", "LF:1\n"], + ["a hit count outside a record", "LH:1\n"], + ["a terminator outside a record", "end_of_record\n"], + ["consecutive source records", "SF:first.ts\nSF:second.ts\nLF:1\nLH:1\nend_of_record\n"], + ["a record without lines found", "SF:file.ts\nLH:1\nend_of_record\n"], + ["an infinite line count", "SF:file.ts\nLF:Infinity\nLH:1\nend_of_record\n"], + ["a fractional line count", "SF:file.ts\nLF:1.5\nLH:1\nend_of_record\n"], + ["an exponential line count", "SF:file.ts\nLF:1e3\nLH:1\nend_of_record\n"], + ["an infinite hit count", "SF:file.ts\nLH:Infinity\nend_of_record\n"], + ["a fractional hit count", "SF:file.ts\nLH:1.5\nend_of_record\n"], + ["an exponential hit count", "SF:file.ts\nLH:1e3\nend_of_record\n"], + ["duplicate line counts", "SF:file.ts\nLF:1\nLF:0\nLH:0\nend_of_record\n"], + ["duplicate hit counts", "SF:file.ts\nLF:1\nLH:1\nLH:0\nend_of_record\n"], + ["more hit lines than found lines", "SF:file.ts\nLF:0\nLH:1\nend_of_record\n"], + ])("rejects %s", (_, content) => { + expect(() => verifyLcov(content)).toThrow() + }) +}) diff --git a/src/scripts/verify-wasm-files.mjs b/src/scripts/verify-wasm-files.mjs new file mode 100644 index 0000000000..73ef3737da --- /dev/null +++ b/src/scripts/verify-wasm-files.mjs @@ -0,0 +1,14 @@ +import fs from "node:fs" +import path from "node:path" + +export function assertMatchingFiles(expectedDir, actualDir, filenames, message, filesystem = fs) { + for (const filename of filenames) { + if ( + !filesystem + .readFileSync(path.join(expectedDir, filename)) + .equals(filesystem.readFileSync(path.join(actualDir, filename))) + ) { + throw new Error(`${message}: ${filename}`) + } + } +} diff --git a/src/scripts/verify-wasm-files.spec.mjs b/src/scripts/verify-wasm-files.spec.mjs new file mode 100644 index 0000000000..3beedc642d --- /dev/null +++ b/src/scripts/verify-wasm-files.spec.mjs @@ -0,0 +1,29 @@ +import fs from "node:fs" +import os from "node:os" +import path from "node:path" +import { afterEach, beforeEach, describe, expect, it } from "vitest" + +import { assertMatchingFiles } from "./verify-wasm-files.mjs" + +describe("assertMatchingFiles", () => { + let root + + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), "verify-wasm-files-")) + }) + + afterEach(() => fs.rmSync(root, { recursive: true, force: true })) + + it("rejects a restored WASM with corrupted content", () => { + const expected = path.join(root, "expected") + const actual = path.join(root, "actual") + fs.mkdirSync(expected) + fs.mkdirSync(actual) + fs.writeFileSync(path.join(expected, "tree-sitter-a.wasm"), "expected") + fs.writeFileSync(path.join(actual, "tree-sitter-a.wasm"), "corrupted") + + expect(() => assertMatchingFiles(expected, actual, ["tree-sitter-a.wasm"], "WASM mismatch")).toThrow( + "WASM mismatch: tree-sitter-a.wasm", + ) + }) +}) diff --git a/src/scripts/wasm-output-snapshot.mjs b/src/scripts/wasm-output-snapshot.mjs new file mode 100644 index 0000000000..2e3e8dbfe6 --- /dev/null +++ b/src/scripts/wasm-output-snapshot.mjs @@ -0,0 +1,61 @@ +import fs from "node:fs" +import path from "node:path" + +const wasmPattern = /^tree-sitter-.*\.wasm(?:\.\d+\.tmp)?$/ + +export function createWasmOutputSnapshot(destinationDir, filesystem = fs) { + const transactionDir = `${destinationDir}.coverage-contract-backup` + const backupDir = path.join(transactionDir, "backup") + const generatedDir = path.join(transactionDir, "generated") + filesystem.mkdirSync(destinationDir, { recursive: true }) + let ownsTransaction = false + const movedFiles = [] + try { + filesystem.mkdirSync(transactionDir) + ownsTransaction = true + filesystem.mkdirSync(backupDir) + filesystem.mkdirSync(generatedDir) + + for (const filename of filesystem.readdirSync(destinationDir)) { + if (!wasmPattern.test(filename)) continue + filesystem.renameSync(path.join(destinationDir, filename), path.join(backupDir, filename)) + movedFiles.push(filename) + } + } catch (error) { + const failures = [] + for (const filename of movedFiles.reverse()) { + try { + filesystem.renameSync(path.join(backupDir, filename), path.join(destinationDir, filename)) + } catch (rollbackError) { + failures.push(rollbackError) + } + } + if (failures.length > 0) { + throw new AggregateError( + [error, ...failures], + `WASM snapshot rollback incomplete; recovery retained at ${transactionDir}`, + ) + } + if (ownsTransaction) filesystem.rmSync(transactionDir, { recursive: true, force: true }) + throw error + } + + let restored = false + const restoredFiles = new Set() + return { + restore() { + if (restored) return + for (const filename of filesystem.readdirSync(destinationDir)) { + if (wasmPattern.test(filename) && !restoredFiles.has(filename)) { + filesystem.renameSync(path.join(destinationDir, filename), path.join(generatedDir, filename)) + } + } + for (const filename of filesystem.readdirSync(backupDir).sort()) { + filesystem.renameSync(path.join(backupDir, filename), path.join(destinationDir, filename)) + restoredFiles.add(filename) + } + restored = true + filesystem.rmSync(transactionDir, { recursive: true, force: true }) + }, + } +} diff --git a/src/scripts/wasm-output-snapshot.spec.mjs b/src/scripts/wasm-output-snapshot.spec.mjs new file mode 100644 index 0000000000..6cafc7330d --- /dev/null +++ b/src/scripts/wasm-output-snapshot.spec.mjs @@ -0,0 +1,94 @@ +import fs from "node:fs" +import os from "node:os" +import path from "node:path" +import { afterEach, beforeEach, describe, expect, it } from "vitest" + +import { createWasmOutputSnapshot } from "./wasm-output-snapshot.mjs" + +describe("createWasmOutputSnapshot", () => { + let root + let destination + + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), "wasm-output-snapshot-")) + destination = path.join(root, "dist") + fs.mkdirSync(destination) + fs.writeFileSync(path.join(destination, "tree-sitter-a.wasm"), "previous") + }) + + afterEach(() => fs.rmSync(root, { recursive: true, force: true })) + + it.each(["preparation", "dry run", "cache restoration", "signal"])( + "restores prior outputs after %s failure", + () => { + const snapshot = createWasmOutputSnapshot(destination) + fs.writeFileSync(path.join(destination, "tree-sitter-a.wasm"), "generated") + fs.writeFileSync(path.join(destination, "tree-sitter-partial.wasm.123.tmp"), "partial") + + snapshot.restore() + + expect(fs.readdirSync(destination)).toEqual(["tree-sitter-a.wasm"]) + expect(fs.readFileSync(path.join(destination, "tree-sitter-a.wasm"), "utf8")).toBe("previous") + expect(fs.existsSync(`${destination}.coverage-contract-backup`)).toBe(false) + }, + ) + + it("retains the backup when restoration is interrupted", () => { + const transaction = `${destination}.coverage-contract-backup` + const filesystem = { + ...fs, + renameSync(source, target) { + if (source.includes(`${path.sep}backup${path.sep}`)) throw new Error("restore interrupted") + return fs.renameSync(source, target) + }, + } + const snapshot = createWasmOutputSnapshot(destination, filesystem) + fs.writeFileSync(path.join(destination, "tree-sitter-a.wasm"), "generated") + + expect(() => snapshot.restore()).toThrow("restore interrupted") + expect(fs.readFileSync(path.join(transaction, "backup", "tree-sitter-a.wasm"), "utf8")).toBe("previous") + }) + + it("restores files when snapshot creation fails during a later move", () => { + fs.writeFileSync(path.join(destination, "tree-sitter-b.wasm"), "previous-b") + let moves = 0 + const filesystem = { + ...fs, + renameSync(source, target) { + if (source.startsWith(destination) && ++moves === 2) throw new Error("snapshot failed") + return fs.renameSync(source, target) + }, + } + + expect(() => createWasmOutputSnapshot(destination, filesystem)).toThrow("snapshot failed") + expect(fs.readdirSync(destination)).toEqual(["tree-sitter-a.wasm", "tree-sitter-b.wasm"]) + expect(fs.readFileSync(path.join(destination, "tree-sitter-a.wasm"), "utf8")).toBe("previous") + expect(fs.readFileSync(path.join(destination, "tree-sitter-b.wasm"), "utf8")).toBe("previous-b") + expect(fs.existsSync(`${destination}.coverage-contract-backup`)).toBe(false) + }) + + it("retries a partial restoration without deleting an already restored original", () => { + fs.writeFileSync(path.join(destination, "tree-sitter-b.wasm"), "previous-b") + let restoreMoves = 0 + let failRestore = true + const filesystem = { + ...fs, + renameSync(source, target) { + if (source.includes(`${path.sep}backup${path.sep}`) && ++restoreMoves === 2 && failRestore) { + failRestore = false + throw new Error("restore interrupted") + } + return fs.renameSync(source, target) + }, + } + const snapshot = createWasmOutputSnapshot(destination, filesystem) + fs.writeFileSync(path.join(destination, "tree-sitter-a.wasm"), "generated") + + expect(() => snapshot.restore()).toThrow("restore interrupted") + expect(() => snapshot.restore()).not.toThrow() + expect(fs.readdirSync(destination)).toEqual(["tree-sitter-a.wasm", "tree-sitter-b.wasm"]) + expect(fs.readFileSync(path.join(destination, "tree-sitter-a.wasm"), "utf8")).toBe("previous") + expect(fs.readFileSync(path.join(destination, "tree-sitter-b.wasm"), "utf8")).toBe("previous-b") + expect(fs.existsSync(`${destination}.coverage-contract-backup`)).toBe(false) + }) +}) diff --git a/src/turbo.json b/src/turbo.json index 024971987f..8ab25798a6 100644 --- a/src/turbo.json +++ b/src/turbo.json @@ -12,11 +12,14 @@ "test:dist": { "dependsOn": ["bundle"] }, + "prepare:tree-sitter-wasms": { + "outputs": ["dist/tree-sitter-*.wasm"] + }, "test:coverage": { "dependsOn": ["$TURBO_EXTENDS$", "bundle"] }, "test:coverage:unit": { - "dependsOn": ["@roo-code/types#build"], + "dependsOn": ["@roo-code/types#build", "prepare:tree-sitter-wasms"], "inputs": ["$TURBO_DEFAULT$", "!__tests__/dist_assets.spec.ts"], "outputs": ["coverage/unit/**"] },