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..b865c55e97 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 scripts/check-miner-deployment-docs.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", @@ -91,7 +92,7 @@ "test:smoke:observability:metrics": "node scripts/smoke-observability-metrics.mjs", "test:smoke:browser:install": "playwright install chromium", "test:smoke:browser": "node scripts/smoke-ui-browser.mjs", - "test:ci": "git diff --check && npm run actionlint && npm run db:migrations:check && npm run db:schema-drift:check && npm run selfhost:env-reference:check && npm run miner:env-reference:check && npm run selfhost:validate-observability && npm run cf-typegen:check && npm run build --workspace @loopover/engine && npm run typecheck && npm run test:coverage && npm run test:engine-parity && npm run test:live-gate-parity && npm run test:driver-parity && npm run test --workspace @loopover/engine && npm run test:workers && npm run build:mcp && npm run test:mcp-pack && npm run build:miner && npm run test:miner-pack && npm run rees:test && npm run ui:openapi:check && npm run ui:openapi:settings-parity && npm run ui:version-audit && npm run docs:drift-check && npm run manifest:drift-check && npm run engine-parity:drift-check && npm run command-reference:check && npm run ui:lint && npm run ui:typecheck && npm run ui:test && npm run ui:build", + "test:ci": "git diff --check && npm run actionlint && npm run db:migrations:check && npm run db:schema-drift:check && npm run selfhost:env-reference:check && npm run miner:env-reference:check && npm run selfhost:validate-observability && npm run cf-typegen:check && npm run build --workspace @loopover/engine && npm run typecheck && npm run test:coverage && npm run test:engine-parity && npm run test:live-gate-parity && npm run test:driver-parity && npm run test --workspace @loopover/engine && npm run test:workers && npm run build:mcp && npm run test:mcp-pack && npm run build:miner && npm run test:miner-pack && npm run test:miner-deployment-docs-audit && npm run rees:test && npm run ui:openapi:check && npm run ui:openapi:settings-parity && npm run ui:version-audit && npm run docs:drift-check && npm run manifest:drift-check && npm run engine-parity:drift-check && npm run command-reference:check && npm run ui:lint && npm run ui:typecheck && npm run ui:test && npm run ui:build", "test:release": "npm run test:ci && npm run changelog:check", "test:release:mcp": "npm run test:ci", "test:watch": "vitest", diff --git a/packages/loopover-miner/lib/deployment-docs-audit.js b/packages/loopover-miner/lib/deployment-docs-audit.js index be3552d032..49d5b0686e 100644 --- a/packages/loopover-miner/lib/deployment-docs-audit.js +++ b/packages/loopover-miner/lib/deployment-docs-audit.js @@ -3,6 +3,8 @@ // LOOPOVER_MINER_* / MINER_* env var, repo-relative file path, and `loopover-miner ` // it documents still exists under packages/loopover-miner/**. A rename or move that leaves the doc // stale then fails CI with a message naming the exact stale claim, instead of misleading operators. +// Wired into CI via `npm run test:miner-deployment-docs-audit` (scripts/check-miner-deployment-docs.mjs) +// and the live unit suite in test/unit/miner-deployment-docs-audit.test.ts (#6158). /** The miner's own env-var namespace: LOOPOVER_MINER_* and the shorter MINER_* aliases it reads. */ const ENV_VAR_PATTERN = /\b(?:LOOPOVER_MINER|MINER)_[A-Z0-9_]+\b/g; diff --git a/scripts/check-miner-deployment-docs.d.mts b/scripts/check-miner-deployment-docs.d.mts new file mode 100644 index 0000000000..a6af3a3fa8 --- /dev/null +++ b/scripts/check-miner-deployment-docs.d.mts @@ -0,0 +1,29 @@ +export type MinerDeploymentReality = { + hasEnvRead: (name: string) => boolean; + pathExists: (relativePath: string) => boolean; + isRegisteredCommand: (name: string) => boolean; +}; + +export type MinerDeploymentAuditResult = { + ok: boolean; + failures: string[]; + claimCounts: { + envVars: number; + filePaths: number; + subcommands: number; + }; +}; + +export function buildLiveMinerDeploymentReality(): MinerDeploymentReality; +export function runMinerDeploymentDocsAudit(opts?: { + testMode?: string | null; + reality?: MinerDeploymentReality; +}): MinerDeploymentAuditResult; + +export type MinerDeploymentAuditIo = { + log: (...args: unknown[]) => void; + error: (...args: unknown[]) => void; + exit: (code: number) => void; +}; + +export function main(env?: Record, io?: MinerDeploymentAuditIo): number; diff --git a/scripts/check-miner-deployment-docs.mjs b/scripts/check-miner-deployment-docs.mjs new file mode 100644 index 0000000000..7941e7da2c --- /dev/null +++ b/scripts/check-miner-deployment-docs.mjs @@ -0,0 +1,99 @@ +// CI entrypoint for the miner DEPLOYMENT.md accuracy audit (#6158). The pure checker lives in +// packages/loopover-miner/lib/deployment-docs-audit.js; this script builds live reality from the +// miner + engine trees and fails non-zero on drift so validate-code / test:ci catch renames. +import { existsSync, readFileSync, readdirSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { + assertDeploymentDocsInSync, + extractEnvVarClaims, + extractFilePathClaims, + extractSubcommandClaims, + scanEnvVarTokens, + scanRegisteredCommands, +} from "../packages/loopover-miner/lib/deployment-docs-audit.js"; + +const REPO_ROOT = resolve(fileURLToPath(new URL(".", import.meta.url)), ".."); +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")); +} + +/** Build the live reality predicates used by the audit (exported for unit tests). */ +export function buildLiveMinerDeploymentReality() { + 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), + }; +} + +/** + * Run the live DEPLOYMENT.md audit. + * @param {{ testMode?: string | null, reality?: ReturnType }} [opts] + */ +export function runMinerDeploymentDocsAudit(opts = {}) { + const markdown = readFileSync(DEPLOYMENT_MD, "utf8"); + const claims = { + envVars: extractEnvVarClaims(markdown), + filePaths: extractFilePathClaims(markdown), + subcommands: extractSubcommandClaims(markdown), + }; + let reality = opts.reality ?? buildLiveMinerDeploymentReality(); + if (opts.testMode === "missing-env") { + const inner = reality; + reality = { + ...inner, + hasEnvRead: () => false, + }; + } + const result = assertDeploymentDocsInSync(claims, reality); + return { + ok: result.ok, + failures: result.failures, + claimCounts: { + envVars: claims.envVars.length, + filePaths: claims.filePaths.length, + subcommands: claims.subcommands.length, + }, + }; +} + +export function main(env = process.env, io = { + log: console.log.bind(console), + error: console.error.bind(console), + exit: (code) => process.exit(code), +}) { + try { + const result = runMinerDeploymentDocsAudit({ + testMode: env.CHECK_MINER_DEPLOYMENT_DOCS_AUDIT_TEST_MODE ?? null, + }); + io.log( + `Miner deployment docs audit ok: ${result.claimCounts.envVars} env vars, ${result.claimCounts.filePaths} paths, ${result.claimCounts.subcommands} subcommands.`, + ); + return 0; + } catch (error) { + io.error(error instanceof Error ? error.message : String(error)); + io.exit(1); + return 1; + } +} + +const invokedDirectly = process.argv[1] != null && import.meta.url === pathToFileURL(process.argv[1]).href; +if (invokedDirectly) main(); diff --git a/test/unit/check-miner-deployment-docs.test.ts b/test/unit/check-miner-deployment-docs.test.ts new file mode 100644 index 0000000000..31fb99953d --- /dev/null +++ b/test/unit/check-miner-deployment-docs.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it, vi } from "vitest"; +import { + main, + runMinerDeploymentDocsAudit, +} from "../../scripts/check-miner-deployment-docs.mjs"; + +describe("check-miner-deployment-docs (#6158)", () => { + it("passes against the live miner DEPLOYMENT.md and source tree", () => { + const result = runMinerDeploymentDocsAudit(); + expect(result.ok).toBe(true); + expect(result.failures).toEqual([]); + expect(result.claimCounts.envVars).toBeGreaterThan(0); + expect(result.claimCounts.filePaths).toBeGreaterThan(0); + expect(result.claimCounts.subcommands).toBeGreaterThan(0); + }); + + it("fails when env-var backing reads are forced missing (drift fixture)", () => { + expect(() => runMinerDeploymentDocsAudit({ testMode: "missing-env" })).toThrow(/DEPLOYMENT\.md is out of sync/i); + }); + + it("main exits non-zero on the forced-missing-env drift fixture", () => { + const exit = vi.fn(); + const error = vi.fn(); + const log = vi.fn(); + const code = main( + { CHECK_MINER_DEPLOYMENT_DOCS_AUDIT_TEST_MODE: "missing-env" }, + { log, error, exit }, + ); + expect(code).toBe(1); + expect(exit).toHaveBeenCalledWith(1); + expect(error).toHaveBeenCalledWith(expect.stringMatching(/DEPLOYMENT\.md is out of sync/i)); + expect(log).not.toHaveBeenCalled(); + }); + + it("main prints ok and returns 0 on the live tree", () => { + const exit = vi.fn(); + const error = vi.fn(); + const log = vi.fn(); + const code = main({}, { log, error, exit }); + expect(code).toBe(0); + expect(exit).not.toHaveBeenCalled(); + expect(error).not.toHaveBeenCalled(); + expect(log).toHaveBeenCalledWith(expect.stringMatching(/^Miner deployment docs audit ok:/)); + }); +});