Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions packages/gittensory-miner/lib/deployment-docs-audit.d.ts
Original file line number Diff line number Diff line change
@@ -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<string>;

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<string>;

export function auditDeploymentDocs(
claims: DeploymentDocsClaims,
reality: DeploymentDocsReality,
): DeploymentDocsAuditResult;

export function assertDeploymentDocsInSync(
claims: DeploymentDocsClaims,
reality: DeploymentDocsReality,
): DeploymentDocsAuditResult;
111 changes: 111 additions & 0 deletions packages/gittensory-miner/lib/deployment-docs-audit.js
Original file line number Diff line number Diff line change
@@ -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 <subcommand>`
// 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 <subcommand>` CLI invocations, excluding the `@jsonbored/gittensory-miner` package spelling. */
const SUBCOMMAND_PATTERN = /(?<![\w./@-])gittensory-miner\s+([a-z][a-z0-9-]*)/g;

/** Markdown inline-link targets: the `target` in `](target)`. */
const MARKDOWN_LINK_PATTERN = /\]\(([^)]+)\)/g;

/** Link targets the audit ignores: URLs, in-page anchors, and runtime-generated (~ or absolute) paths. */
const NON_REPO_LINK_PATTERN = /^(?:https?:\/\/|mailto:|#|~|\/)/;

/** `cliArgs[0] === "<name>"` 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 <subcommand>` 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;
}
2 changes: 1 addition & 1 deletion packages/gittensory-miner/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "*",
Expand Down
170 changes: 170 additions & 0 deletions test/unit/miner-deployment-docs-audit.test.ts
Original file line number Diff line number Diff line change
@@ -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([]);
});
});