diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 53cfd90926..3c1a9f4f72 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -340,6 +340,9 @@ jobs: - name: Miner package check if: ${{ github.event_name == 'push' || needs.changes.outputs.miner == 'true' }} run: npm run test:miner-pack + - name: Miner deployment docs audit + if: ${{ github.event_name == 'push' || needs.changes.outputs.miner == 'true' }} + run: npm run test:miner-deployment-docs-audit # review-enrichment is not an npm workspace member (its own package-lock.json), so it needs its own # cache entry -- same restore/save-after-success pattern and fork/trusted key split as the root # install above, for the same reasons. diff --git a/package.json b/package.json index e6e0116479..b79bcde1e9 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "build:miner": "npm --workspace @loopover/engine run build && npm --workspace @loopover/miner run build", "test:mcp-pack": "node scripts/check-mcp-package.mjs", "test:miner-pack": "node scripts/check-miner-package.mjs", + "test:miner-deployment-docs-audit": "node packages/loopover-miner/scripts/check-deployment-docs-audit.mjs", "rees:install": "npm ci --prefix review-enrichment --prefer-offline --no-audit --no-fund", "rees:test": "npm run rees:install && npm --prefix review-enrichment test", "rees:metadata": "npm --prefix review-enrichment run metadata", diff --git a/packages/loopover-miner/scripts/check-deployment-docs-audit.mjs b/packages/loopover-miner/scripts/check-deployment-docs-audit.mjs new file mode 100644 index 0000000000..a4534778ca --- /dev/null +++ b/packages/loopover-miner/scripts/check-deployment-docs-audit.mjs @@ -0,0 +1,72 @@ +#!/usr/bin/env node +import { existsSync, readFileSync, readdirSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { + assertDeploymentDocsInSync, + extractEnvVarClaims, + extractFilePathClaims, + extractSubcommandClaims, + scanEnvVarTokens, + scanRegisteredCommands, +} from "../lib/deployment-docs-audit.js"; + +const REPO_ROOT = resolve(import.meta.dirname, "../../.."); +const MINER_DIR = resolve(REPO_ROOT, "packages/loopover-miner"); +const DEPLOYMENT_MD = resolve(MINER_DIR, "DEPLOYMENT.md"); +const BIN_DIR = resolve(MINER_DIR, "bin"); +const BIN_ENTRY = resolve(BIN_DIR, "loopover-miner.js"); +const LIB_DIR = resolve(MINER_DIR, "lib"); +const ENGINE_MINER_DIR = resolve(REPO_ROOT, "packages/loopover-engine/src/miner"); + +function readFilesWithExtension(dir, extension) { + return readdirSync(dir) + .filter((name) => name.endsWith(extension)) + .map((name) => readFileSync(join(dir, name), "utf8")); +} + +function buildLiveReality() { + const envReads = scanEnvVarTokens( + [ + ...readFilesWithExtension(LIB_DIR, ".js"), + ...readFilesWithExtension(BIN_DIR, ".js"), + ...readFilesWithExtension(ENGINE_MINER_DIR, ".ts"), + ].join("\n"), + ); + const registered = scanRegisteredCommands(readFileSync(BIN_ENTRY, "utf8")); + return { + hasEnvRead: (name) => envReads.has(name), + pathExists: (relativePath) => existsSync(resolve(MINER_DIR, relativePath)), + isRegisteredCommand: (name) => registered.has(name), + }; +} + +function applyTestMode(reality) { + const mode = process.env.CHECK_MINER_DEPLOYMENT_DOCS_AUDIT_TEST_MODE; + if (!mode) return reality; + if (mode === "missing-env") return { ...reality, hasEnvRead: () => false }; + if (mode === "missing-path") return { ...reality, pathExists: () => false }; + if (mode === "missing-command") return { ...reality, isRegisteredCommand: () => false }; + return reality; +} + +export function runDeploymentDocsAuditCheck() { + const markdown = readFileSync(DEPLOYMENT_MD, "utf8"); + const claims = { + envVars: extractEnvVarClaims(markdown), + filePaths: extractFilePathClaims(markdown), + subcommands: extractSubcommandClaims(markdown), + }; + const result = assertDeploymentDocsInSync(claims, applyTestMode(buildLiveReality())); + return `Miner deployment docs audit ok: ${claims.envVars.length} env vars, ${claims.filePaths.length} paths, ${claims.subcommands.length} subcommands checked.\n`; +} + +function main() { + try { + process.stdout.write(runDeploymentDocsAuditCheck()); + } catch (error) { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exit(1); + } +} + +if (import.meta.url === `file://${process.argv[1]}`) main(); diff --git a/test/unit/miner-deployment-docs-audit-script.test.ts b/test/unit/miner-deployment-docs-audit-script.test.ts new file mode 100644 index 0000000000..461b6d9c3e --- /dev/null +++ b/test/unit/miner-deployment-docs-audit-script.test.ts @@ -0,0 +1,30 @@ +import { execFileSync } from "node:child_process"; +import { describe, expect, it } from "vitest"; + +function runAudit(env: Record = {}): { status: number; out: string } { + try { + const stdout = execFileSync(process.execPath, ["packages/loopover-miner/scripts/check-deployment-docs-audit.mjs"], { + encoding: "utf8", + env: { ...process.env, ...env }, + }); + return { status: 0, out: stdout }; + } catch (error) { + const failure = error as { status?: number; stdout?: string; stderr?: string }; + return { status: failure.status ?? 1, out: `${failure.stdout ?? ""}${failure.stderr ?? ""}` }; + } +} + +describe("check-deployment-docs-audit script (#6158)", () => { + it("passes on the real miner deployment docs and live source tree", () => { + const result = runAudit(); + expect(result.status).toBe(0); + expect(result.out).toMatch(/^Miner deployment docs audit ok:/); + }); + + it("fails with a drift-style message when env-var backing reads are forced missing", () => { + const result = runAudit({ CHECK_MINER_DEPLOYMENT_DOCS_AUDIT_TEST_MODE: "missing-env" }); + expect(result.status).toBe(1); + expect(result.out).toContain("DEPLOYMENT.md is out of sync"); + expect(result.out).toContain("is documented in DEPLOYMENT.md but no read of it exists"); + }); +});