From b5237048d586d190fa75fe25f2b162da07eaa185 Mon Sep 17 00:00:00 2001 From: e11734937-beep Date: Sun, 12 Jul 2026 19:25:55 +0200 Subject: [PATCH] feat(miner): audit DEPLOYMENT.md env vars, paths, and CLI subcommands against source Mirror the self-host docs-accuracy audit (apps/gittensory-ui/src/lib/selfhost-docs-audit.ts) for packages/gittensory-miner/DEPLOYMENT.md. A new lib/deployment-docs-audit.js module parses the deployment doc and cross-checks every GITTENSORY_MINER_*/MINER_* env var, repo-relative file path, and gittensory-miner it documents against the live source tree, so a rename or move that leaves the doc stale fails CI with a message naming the exact stale claim instead of silently misleading operators. The check runs as a vitest drift guard (test/unit/miner-deployment-docs-audit.test.ts) wired into npm run test:ci, with success and failure fixtures for each claim type, a renamed-var regression, and an invariant that the audit never silently passes on drift. Closes #5180 --- .../lib/deployment-docs-audit.d.ts | 41 +++++ .../lib/deployment-docs-audit.js | 111 ++++++++++++ packages/gittensory-miner/package.json | 2 +- test/unit/miner-deployment-docs-audit.test.ts | 170 ++++++++++++++++++ 4 files changed, 323 insertions(+), 1 deletion(-) create mode 100644 packages/gittensory-miner/lib/deployment-docs-audit.d.ts create mode 100644 packages/gittensory-miner/lib/deployment-docs-audit.js create mode 100644 test/unit/miner-deployment-docs-audit.test.ts diff --git a/packages/gittensory-miner/lib/deployment-docs-audit.d.ts b/packages/gittensory-miner/lib/deployment-docs-audit.d.ts new file mode 100644 index 0000000000..a2d4a3474c --- /dev/null +++ b/packages/gittensory-miner/lib/deployment-docs-audit.d.ts @@ -0,0 +1,41 @@ +/** Parsed claims a DEPLOYMENT.md makes about the miner's runtime surface. */ +export type DeploymentDocsClaims = { + envVars: string[]; + filePaths: string[]; + subcommands: string[]; +}; + +/** Filesystem-independent view of the live source tree the parsed claims are checked against. */ +export type DeploymentDocsReality = { + hasEnvRead: (name: string) => boolean; + pathExists: (relativePath: string) => boolean; + isRegisteredCommand: (name: string) => boolean; +}; + +/** Result of cross-checking claims against reality: `ok` plus a message per stale claim. */ +export type DeploymentDocsAuditResult = { + ok: boolean; + failures: string[]; +}; + +export function scanEnvVarTokens(text: string): Set; + +export function extractEnvVarClaims(markdown: string): string[]; + +export function extractSubcommandClaims(markdown: string): string[]; + +export function isRepoRelativePath(target: string): boolean; + +export function extractFilePathClaims(markdown: string): string[]; + +export function scanRegisteredCommands(binSource: string): Set; + +export function auditDeploymentDocs( + claims: DeploymentDocsClaims, + reality: DeploymentDocsReality, +): DeploymentDocsAuditResult; + +export function assertDeploymentDocsInSync( + claims: DeploymentDocsClaims, + reality: DeploymentDocsReality, +): DeploymentDocsAuditResult; diff --git a/packages/gittensory-miner/lib/deployment-docs-audit.js b/packages/gittensory-miner/lib/deployment-docs-audit.js new file mode 100644 index 0000000000..65897dc750 --- /dev/null +++ b/packages/gittensory-miner/lib/deployment-docs-audit.js @@ -0,0 +1,111 @@ +// Docs-accuracy audit for the miner's DEPLOYMENT.md (#5180). Mirrors the self-host docs audit +// (apps/gittensory-ui/src/lib/selfhost-docs-audit.ts): parse the deployment doc, then assert every +// GITTENSORY_MINER_* / MINER_* env var, repo-relative file path, and `gittensory-miner ` +// it documents still exists under packages/gittensory-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. + +/** The miner's own env-var namespace: GITTENSORY_MINER_* and the shorter MINER_* aliases it reads. */ +const ENV_VAR_PATTERN = /\b(?:GITTENSORY_MINER|MINER)_[A-Z0-9_]+\b/g; + +/** `gittensory-miner ` CLI invocations, excluding the `@jsonbored/gittensory-miner` package spelling. */ +const SUBCOMMAND_PATTERN = /(?"` guards in the miner bin — the CLI's registered top-level command table. */ +const CLI_DISPATCH_PATTERN = /cliArgs\[0\]\s*===\s*"([a-z][a-z0-9-]*)"/g; + +/** Collect every GITTENSORY_MINER_* / MINER_* token that appears in `text` (doc prose/code or source). */ +export function scanEnvVarTokens(text) { + const tokens = new Set(); + for (const match of text.matchAll(ENV_VAR_PATTERN)) { + tokens.add(match[0]); + } + return tokens; +} + +/** Sorted, de-duplicated env-var names DEPLOYMENT.md claims the miner honors. */ +export function extractEnvVarClaims(markdown) { + return [...scanEnvVarTokens(markdown)].sort(); +} + +/** Sorted, de-duplicated `gittensory-miner ` subcommands DEPLOYMENT.md documents. */ +export function extractSubcommandClaims(markdown) { + const commands = new Set(); + for (const match of markdown.matchAll(SUBCOMMAND_PATTERN)) { + commands.add(match[1]); + } + return [...commands].sort(); +} + +/** True when a markdown link target is an on-disk repo path (not a URL, anchor, or runtime path). */ +export function isRepoRelativePath(target) { + return !NON_REPO_LINK_PATTERN.test(target); +} + +/** Sorted, de-duplicated repo-relative file paths DEPLOYMENT.md links to (external issue links excluded). */ +export function extractFilePathClaims(markdown) { + const paths = new Set(); + for (const match of markdown.matchAll(MARKDOWN_LINK_PATTERN)) { + const target = match[1].trim(); + if (isRepoRelativePath(target)) { + paths.add(target); + } + } + return [...paths].sort(); +} + +/** The set of top-level subcommands the miner CLI dispatches, parsed from its bin entry source. */ +export function scanRegisteredCommands(binSource) { + const commands = new Set(); + for (const match of binSource.matchAll(CLI_DISPATCH_PATTERN)) { + commands.add(match[1]); + } + return commands; +} + +/** + * Cross-check parsed DEPLOYMENT.md claims against reality. `reality` supplies three predicates so this + * comparison stays pure and filesystem-independent: `hasEnvRead(name)` (a read of that env var exists + * under packages/gittensory-miner/**), `pathExists(relativePath)` (the doc-relative path is on disk), + * and `isRegisteredCommand(name)` (the subcommand is dispatched by the CLI). Returns the drift findings, + * each failure naming the specific stale claim rather than a generic mismatch. + */ +export function auditDeploymentDocs(claims, reality) { + const failures = []; + for (const name of claims.envVars) { + if (!reality.hasEnvRead(name)) { + failures.push( + `env var "${name}" is documented in DEPLOYMENT.md but no read of it exists under packages/gittensory-miner/**`, + ); + } + } + for (const path of claims.filePaths) { + if (!reality.pathExists(path)) { + failures.push(`file path "${path}" is linked from DEPLOYMENT.md but no longer exists on disk`); + } + } + for (const command of claims.subcommands) { + if (!reality.isRegisteredCommand(command)) { + failures.push( + `CLI subcommand "gittensory-miner ${command}" is documented in DEPLOYMENT.md but is not registered in the CLI command table`, + ); + } + } + return { ok: failures.length === 0, failures }; +} + +/** Run the audit and throw a build-failing error naming every stale claim; returns the result when in sync. */ +export function assertDeploymentDocsInSync(claims, reality) { + const result = auditDeploymentDocs(claims, reality); + if (!result.ok) { + throw new Error( + `DEPLOYMENT.md is out of sync with packages/gittensory-miner/**:\n- ${result.failures.join("\n- ")}`, + ); + } + return result; +} diff --git a/packages/gittensory-miner/package.json b/packages/gittensory-miner/package.json index 9c2f30b32e..bb935d5006 100644 --- a/packages/gittensory-miner/package.json +++ b/packages/gittensory-miner/package.json @@ -33,7 +33,7 @@ "expected-engine.version" ], "scripts": { - "build": "node --check bin/gittensory-miner.js && node --check bin/gittensory-miner-mcp.js && node --check lib/ams-policy.js && node --check lib/attempt-cli.js && node --check lib/attempt-input-builder.js && node --check lib/attempt-log.js && node --check lib/attempt-runner.js && node --check lib/attempt-worktree.js && node --check lib/calibration-types.js && node --check lib/calibration.js && node --check lib/ci-poller.js && node --check lib/claim-adjudication.js && node --check lib/claim-ledger-cli.js && node --check lib/claim-ledger-expiry.js && node --check lib/claim-ledger.js && node --check lib/cli.js && node --check lib/coding-agent-construction.js && node --check lib/coding-agent-house-rules.js && node --check lib/coding-task-spec.js && node --check lib/deny-check.js && node --check lib/deny-hook-synthesis.js && node --check lib/deny-hooks.js && node --check lib/discover-cli.js && node --check lib/event-ledger-cli.js && node --check lib/event-ledger.js && node --check lib/execute-local-write.js && node --check lib/feasibility-cli.js && node --check lib/gate-verdict-poller.js && node --check lib/governor-action-mode.js && node --check lib/governor-chokepoint-persisted.js && node --check lib/governor-chokepoint.js && node --check lib/governor-kill-switch.js && node --check lib/governor-ledger-cli.js && node --check lib/governor-ledger.js && node --check lib/governor-open-pr.js && node --check lib/governor-run-halt.js && node --check lib/governor-state.js && node --check lib/governor-write-rate-limit.js && node --check lib/harness-submission-trigger.js && node --check lib/laptop-init.js && node --check lib/live-issue-snapshot.js && node --check lib/local-store.js && node --check lib/loop-cli.js && node --check lib/loop-closure.js && node --check lib/loop-reentry.js && node --check lib/manage-poll.js && node --check lib/manage-status.js && node --check lib/miner-goal-spec.js && node --check lib/opportunity-fanout.js && node --check lib/opportunity-ranker.js && node --check lib/orb-export.js && node --check lib/plan-store-cli.js && node --check lib/plan-store.js && node --check lib/portfolio-dashboard.js && node --check lib/portfolio-discovery.js && node --check lib/portfolio-queue-cli.js && node --check lib/portfolio-queue-manager.js && node --check lib/portfolio-queue.js && node --check lib/portfolio-queue-expiry.js && node --check lib/pr-disposition-poller.js && node --check lib/pr-outcome.js && node --check lib/prediction-ledger.js && node --check lib/pretooluse-hook.js && node --check lib/rejection-signal.js && node --check lib/rejection-state-machine.js && node --check lib/rejection-templates.js && node --check lib/replay-objective-anchor.js && node --check lib/replay-snapshot.js && node --check lib/replay-task-generation.js && node --check lib/repo-clone.js && node --check lib/run-state-cli.js && node --check lib/run-state.js && node --check lib/self-review-context.js && node --check lib/slop-assessment.js && node --check lib/status.js && node --check lib/submission-freshness-check.js && node --check lib/update-check.js && node --check lib/version.js && node --check lib/worktree-allocator.js" + "build": "node --check bin/gittensory-miner.js && node --check bin/gittensory-miner-mcp.js && node --check lib/ams-policy.js && node --check lib/attempt-cli.js && node --check lib/attempt-input-builder.js && node --check lib/attempt-log.js && node --check lib/attempt-runner.js && node --check lib/attempt-worktree.js && node --check lib/calibration-types.js && node --check lib/calibration.js && node --check lib/ci-poller.js && node --check lib/claim-adjudication.js && node --check lib/claim-ledger-cli.js && node --check lib/claim-ledger-expiry.js && node --check lib/claim-ledger.js && node --check lib/cli.js && node --check lib/coding-agent-construction.js && node --check lib/coding-agent-house-rules.js && node --check lib/coding-task-spec.js && node --check lib/deny-check.js && node --check lib/deny-hook-synthesis.js && node --check lib/deny-hooks.js && node --check lib/deployment-docs-audit.js && node --check lib/discover-cli.js && node --check lib/event-ledger-cli.js && node --check lib/event-ledger.js && node --check lib/execute-local-write.js && node --check lib/feasibility-cli.js && node --check lib/gate-verdict-poller.js && node --check lib/governor-action-mode.js && node --check lib/governor-chokepoint-persisted.js && node --check lib/governor-chokepoint.js && node --check lib/governor-kill-switch.js && node --check lib/governor-ledger-cli.js && node --check lib/governor-ledger.js && node --check lib/governor-open-pr.js && node --check lib/governor-run-halt.js && node --check lib/governor-state.js && node --check lib/governor-write-rate-limit.js && node --check lib/harness-submission-trigger.js && node --check lib/laptop-init.js && node --check lib/live-issue-snapshot.js && node --check lib/local-store.js && node --check lib/loop-cli.js && node --check lib/loop-closure.js && node --check lib/loop-reentry.js && node --check lib/manage-poll.js && node --check lib/manage-status.js && node --check lib/miner-goal-spec.js && node --check lib/opportunity-fanout.js && node --check lib/opportunity-ranker.js && node --check lib/orb-export.js && node --check lib/plan-store-cli.js && node --check lib/plan-store.js && node --check lib/portfolio-dashboard.js && node --check lib/portfolio-discovery.js && node --check lib/portfolio-queue-cli.js && node --check lib/portfolio-queue-manager.js && node --check lib/portfolio-queue.js && node --check lib/portfolio-queue-expiry.js && node --check lib/pr-disposition-poller.js && node --check lib/pr-outcome.js && node --check lib/prediction-ledger.js && node --check lib/pretooluse-hook.js && node --check lib/rejection-signal.js && node --check lib/rejection-state-machine.js && node --check lib/rejection-templates.js && node --check lib/replay-objective-anchor.js && node --check lib/replay-snapshot.js && node --check lib/replay-task-generation.js && node --check lib/repo-clone.js && node --check lib/run-state-cli.js && node --check lib/run-state.js && node --check lib/self-review-context.js && node --check lib/slop-assessment.js && node --check lib/status.js && node --check lib/submission-freshness-check.js && node --check lib/update-check.js && node --check lib/version.js && node --check lib/worktree-allocator.js" }, "dependencies": { "@jsonbored/gittensory-engine": "*", diff --git a/test/unit/miner-deployment-docs-audit.test.ts b/test/unit/miner-deployment-docs-audit.test.ts new file mode 100644 index 0000000000..d3552918d8 --- /dev/null +++ b/test/unit/miner-deployment-docs-audit.test.ts @@ -0,0 +1,170 @@ +import { existsSync, readFileSync, readdirSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { describe, expect, it } from "vitest"; + +import { + assertDeploymentDocsInSync, + auditDeploymentDocs, + extractEnvVarClaims, + extractFilePathClaims, + extractSubcommandClaims, + isRepoRelativePath, + scanEnvVarTokens, + scanRegisteredCommands, +} from "../../packages/gittensory-miner/lib/deployment-docs-audit.js"; +import type { DeploymentDocsReality } from "../../packages/gittensory-miner/lib/deployment-docs-audit.d.ts"; + +const REPO_ROOT = resolve(import.meta.dirname, "../.."); +const MINER_DIR = resolve(REPO_ROOT, "packages/gittensory-miner"); +const DEPLOYMENT_MD = resolve(MINER_DIR, "DEPLOYMENT.md"); +const BIN_DIR = resolve(MINER_DIR, "bin"); +const BIN_ENTRY = resolve(BIN_DIR, "gittensory-miner.js"); +const LIB_DIR = resolve(MINER_DIR, "lib"); + +function readJsFiles(dir: string): string[] { + return readdirSync(dir) + .filter((name) => name.endsWith(".js")) + .map((name) => readFileSync(join(dir, name), "utf8")); +} + +function buildLiveReality(): DeploymentDocsReality { + const envReads = scanEnvVarTokens([...readJsFiles(LIB_DIR), ...readJsFiles(BIN_DIR)].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), + }; +} + +const ALWAYS_IN_SYNC: DeploymentDocsReality = { + hasEnvRead: () => true, + pathExists: () => true, + isRegisteredCommand: () => true, +}; + +describe("gittensory-miner DEPLOYMENT.md docs-accuracy audit (#5180)", () => { + const markdown = readFileSync(DEPLOYMENT_MD, "utf8"); + const claims = { + envVars: extractEnvVarClaims(markdown), + filePaths: extractFilePathClaims(markdown), + subcommands: extractSubcommandClaims(markdown), + }; + + it("passes cleanly against DEPLOYMENT.md's current, accurate state", () => { + const result = assertDeploymentDocsInSync(claims, buildLiveReality()); + expect(result.ok).toBe(true); + expect(result.failures).toEqual([]); + }); + + it("extracts every documented GITTENSORY_MINER_* / MINER_* env var", () => { + expect(claims.envVars).toContain("GITTENSORY_MINER_CONFIG_DIR"); + expect(claims.envVars.every((name) => /^(?:GITTENSORY_MINER|MINER)_/.test(name))).toBe(true); + }); + + it("extracts repo-relative file paths and drops external issue links", () => { + expect(claims.filePaths).toContain("Dockerfile"); + expect(claims.filePaths).toContain("../../docker-compose.yml"); + expect(claims.filePaths).toContain("../../k8s/"); + expect(claims.filePaths.some((path) => path.startsWith("http"))).toBe(false); + }); + + it("extracts documented CLI subcommands, not the npm package spelling", () => { + expect(claims.subcommands).toEqual(expect.arrayContaining(["status", "doctor", "init", "loop"])); + // `@jsonbored/gittensory-miner run build` must not be mistaken for a `run` subcommand. + expect(claims.subcommands).not.toContain("run"); + }); + + it("scanEnvVarTokens keeps the namespaced token whole and finds bare MINER_* aliases", () => { + expect( + [...scanEnvVarTokens("read GITTENSORY_MINER_CONFIG_DIR and MINER_PING_STATUS here")].sort(), + ).toEqual(["GITTENSORY_MINER_CONFIG_DIR", "MINER_PING_STATUS"]); + expect(scanEnvVarTokens("no env vars here").size).toBe(0); + }); + + it("extractSubcommandClaims returns nothing when the CLI is never invoked", () => { + expect(extractSubcommandClaims("plain prose without any commands")).toEqual([]); + }); + + it("extractFilePathClaims returns nothing when there are no markdown links", () => { + expect(extractFilePathClaims("plain prose without links")).toEqual([]); + }); + + it("isRepoRelativePath accepts repo paths and rejects URLs, anchors, and runtime paths", () => { + expect(isRepoRelativePath("Dockerfile")).toBe(true); + expect(isRepoRelativePath("../../k8s/")).toBe(true); + expect(isRepoRelativePath("https://example.com")).toBe(false); + expect(isRepoRelativePath("http://example.com")).toBe(false); + expect(isRepoRelativePath("#anchor")).toBe(false); + expect(isRepoRelativePath("mailto:ops@example.com")).toBe(false); + expect(isRepoRelativePath("~/.config/gittensory-miner")).toBe(false); + expect(isRepoRelativePath("/data/miner")).toBe(false); + }); + + it("scanRegisteredCommands reads the CLI dispatch table from the bin entry", () => { + const registered = scanRegisteredCommands(readFileSync(BIN_ENTRY, "utf8")); + for (const command of ["status", "doctor", "init", "loop"]) { + expect(registered.has(command)).toBe(true); + } + }); + + it("auditDeploymentDocs reports ok when every claim is backed by reality", () => { + const result = auditDeploymentDocs( + { envVars: ["GITTENSORY_MINER_CONFIG_DIR"], filePaths: ["Dockerfile"], subcommands: ["loop"] }, + ALWAYS_IN_SYNC, + ); + expect(result).toEqual({ ok: true, failures: [] }); + }); + + it("flags a documented env var with no corresponding read (renamed-var regression)", () => { + // Regression: an operator renames GITTENSORY_MINER_CONFIG_DIR in code but leaves the doc untouched. + const result = auditDeploymentDocs( + { envVars: ["GITTENSORY_MINER_CONFIG_DIR"], filePaths: [], subcommands: [] }, + { ...ALWAYS_IN_SYNC, hasEnvRead: (name) => name !== "GITTENSORY_MINER_CONFIG_DIR" }, + ); + expect(result.ok).toBe(false); + expect(result.failures).toHaveLength(1); + expect(result.failures[0]).toContain("GITTENSORY_MINER_CONFIG_DIR"); + expect(result.failures[0]).toContain("no read"); + }); + + it("flags a documented file path that no longer exists on disk", () => { + const result = auditDeploymentDocs( + { envVars: [], filePaths: ["docker-compose.moved.yml"], subcommands: [] }, + { ...ALWAYS_IN_SYNC, pathExists: () => false }, + ); + expect(result.ok).toBe(false); + expect(result.failures).toHaveLength(1); + expect(result.failures[0]).toContain("docker-compose.moved.yml"); + expect(result.failures[0]).toContain("no longer exists"); + }); + + it("flags a documented subcommand that is not registered in the CLI", () => { + const result = auditDeploymentDocs( + { envVars: [], filePaths: [], subcommands: ["teleport"] }, + { ...ALWAYS_IN_SYNC, isRegisteredCommand: () => false }, + ); + expect(result.ok).toBe(false); + expect(result.failures).toHaveLength(1); + expect(result.failures[0]).toContain("gittensory-miner teleport"); + expect(result.failures[0]).toContain("not registered"); + }); + + it("assertDeploymentDocsInSync throws and names every stale claim at once", () => { + expect(() => + assertDeploymentDocsInSync( + { envVars: ["GITTENSORY_MINER_GONE"], filePaths: ["gone.yml"], subcommands: ["gone"] }, + { hasEnvRead: () => false, pathExists: () => false, isRegisteredCommand: () => false }, + ), + ).toThrow(/GITTENSORY_MINER_GONE[\s\S]*gone\.yml[\s\S]*gittensory-miner gone/); + }); + + it("assertDeploymentDocsInSync returns the ok result without throwing when in sync", () => { + const result = assertDeploymentDocsInSync( + { envVars: [], filePaths: [], subcommands: [] }, + ALWAYS_IN_SYNC, + ); + expect(result.ok).toBe(true); + expect(result.failures).toEqual([]); + }); +});