- In the /app/playground tool runs, behind an opt-in toggle, above the JSON.
+ In the /app/playground tool runs, behind an opt-in "Include AI summary"
+ toggle, above the JSON.
- Optionally inside the MCP CLI with --summary, printed above the structured
- output.
-
-
- Never in public GitHub comments. Never in maintainer packets without explicit maintainer
- opt-in.
+ As an optional AI-clarified rewrite of the public PR intelligence comment, gated
+ server-side by AI_PUBLIC_COMMENTS_ENABLED and always falling back to the
+ deterministic comment body on any error, quota limit, or unsafe output.
+
Never in maintainer packets without explicit maintainer opt-in.
+
+ The playground's toggle currently renders a local, deterministic preview of the structured
+ response — it does not call the backend AI summary service described below yet. Treat it as
+ a stand-in for what a wired-up summary would look like.
+
What is sent to the model
-
- Only the response Gittensory already showed you, plus the boundary and ruleset snapshot, are
- sent. No source code, no PAT, no GitHub identity, no per-user history.
+ A compacted signal bundle — the run's objective, actor login, surface, status, and data
+ quality, plus up to five ranked actions (kind, recommendation, why, blockers) and up to
+ eight freshness warnings. For a public rewrite, scoreability/risk fields are stripped before
+ the bundle is built, not filtered out of the model's response after the fact.
+
+
+ No source code, no PAT, no GitHub identity beyond the acting login, and no per-user history
+ beyond the current run are sent.
Model choice
- You pick the provider per-session: GPT, Claude, or a local model. Defaults to off. The
- selection lives in your browser only and is cleared on sign-out.
+ There is no per-user or per-session model picker. The self-hosted or hosted operator
+ configures one fixed Cloudflare Workers AI model (WORKERS_AI_SUMMARY_MODEL,
+ default a small Llama instruct model) for the whole instance. Summaries are off by default (
+ AI_SUMMARIES_ENABLED); public-comment rewriting is a separate,
+ also-off-by-default switch (AI_PUBLIC_COMMENTS_ENABLED).
- Repo owners care about registration readiness and sensible .gittensor.yml{" "}
+ Repo owners care about registration readiness and sensible .gittensory.yml{" "}
configuration before promoting labels or maintainer-cut policy.
+ The request body is a strict schema — every field must match a known key exactly (
+ repoFullName not a nested repo object, changedFiles with{" "}
+ additions/deletions, linkedIssues not{" "}
+ linked_issues) and unknown keys are rejected rather than ignored.
+
File metadata is allowed (path, line counts). File contents are not
diff --git a/apps/gittensory-ui/src/routes/docs.github-app.tsx b/apps/gittensory-ui/src/routes/docs.github-app.tsx
index e60efb6c06..9f9ffc5d76 100644
--- a/apps/gittensory-ui/src/routes/docs.github-app.tsx
+++ b/apps/gittensory-ui/src/routes/docs.github-app.tsx
@@ -169,9 +169,10 @@ GET /v1/installations/:id/repair`}
gateCheckMode (off / enabled); each dimension then
refines an already-enabled gate with a tri-state mode — off (not evaluated),{" "}
advisory (surfaced, never blocks), or block (can become a hard{" "}
- Gittensory Orb Review Agent blocker). Blocking is always
- confirmed-contributor-gated: the mode chooses which deterministic checks are active, never{" "}
- who can be blocked.
+ Gittensory Orb Review Agent blocker). A configured blocker fails the gate
+ for any author identically — confirmed-Gittensor-contributor status does not change{" "}
+ who can be blocked; it's carried through only for on-chain scoring, a separate
+ concern from the gate's own merge/close decision.
@@ -203,15 +204,37 @@ GET /v1/installations/:id/repair`}
aiReviewMode — AI review. Default off; advisory{" "}
posts AI review notes only, block lets a dual-model high-confidence consensus
- defect become a blocker (confirmed contributors only).
+ defect become a blocker.
+
+
+ sizeGateMode — PR-size hold. Default off; a PR at or above the
+ configured file/line thresholds is held for manual review rather than auto-merged, never a
+ hard failure.
+
+
+ lockfileIntegrityGateMode — lockfile-tamper-risk check (a resolved/integrity
+ change with no matching version bump, or a dependency pointed off the npm registry).
+ Default off.
+
+ selfAuthoredLinkedIssueGateMode — flags or blocks a PR whose author also
+ opened the linked issue. Default advisory.
+
+
+ moderationGateMode — whether the moderation-rules engine (contributor cap,
+ blacklist, review-nag feeding a shared cross-repo violation tally) runs on this repo.
+ Default inherit (defers to the instance-wide default); off/
+ enabled force it per repo.
The policy pack (gatePack) selects which rule set runs: gittensor{" "}
- (confirmed-contributor-gated, registry-aware) or oss-anti-slop (the
- deterministic rules against any author on any repo). Enable{" "}
- firstTimeContributorGrace to soften a would-be block to advisory for a genuine
- newcomer.
+ (registry-aware, tracks confirmed-Gittensor-contributor status for scoring) or{" "}
+ oss-anti-slop (the deterministic rules against any author on any repo, with no
+ confirmed-contributor tracking at all).
@@ -221,7 +244,7 @@ GET /v1/installations/:id/repair`}
Every setting can be committed to .gittensory.yml at the repo root instead of,
or layered over, the dashboard. Precedence is .gittensory.yml > repository
settings > safe defaults; an unset field falls back to the next layer. It only chooses{" "}
- what Gittensory does — only confirmed Gittensor contributors are ever hard-blocked,
+ what Gittensory does — a configured blocker gates every author identically,
regardless of config.
- A block outcome is always confirmed-contributor-gated: the
- mode chooses which checks are active, never who can be blocked. A genuine
- newcomer can be softened from a block to an advisory when{" "}
- firstTimeContributorGrace is on.
+ A block outcome fails the gate for any author identically — confirmed-
+ Gittensor-contributor status doesn't change who can be blocked, only the mode
+ chooses which checks are active. Confirmed status is carried through for on-chain
+ scoring, a separate concern from the gate's own merge/close decision.
The gate dimensions
@@ -119,11 +119,37 @@ function HowReviewsWork() {
issue and test expectations) becomes enforceable. Manual-review path holds are controlled
separately by settings.hardGuardrailGlobs.
+
+ PR-size hold (sizeGateMode, default off) — a PR
+ at or above the configured file/line thresholds is held for manual review, never a hard
+ failure.
+
+
+ Lockfile-integrity gate (lockfileIntegrityGateMode, default{" "}
+ off) — flags a lockfile-tamper-risk finding (a resolved/integrity change with
+ no matching version bump, or a dependency pointed off the npm registry).
+
+ Self-authored-linked-issue gate (
+ selfAuthoredLinkedIssueGateMode, default advisory) — flags or
+ blocks a PR whose author also opened the linked issue.
+
+
+ Moderation-rules engine (moderationGateMode, default{" "}
+ inherit) — whether the contributor-cap / blacklist / review-nag mechanisms
+ feed a shared, cross-repo violation tally on this repo; inherit defers to the
+ instance-wide default, off/enabled force it per repo.
+
Which deterministic rules even apply is set by the policy pack (
- gatePack): gittensor (confirmed-contributor-gated, registry-aware)
- or oss-anti-slop (runs the rules against any author on any repo).
+ gatePack): gittensor (registry-aware, tracks confirmed-Gittensor-
+ contributor status for scoring) or oss-anti-slop (runs the rules against any
+ author on any repo, with no confirmed-contributor tracking at all).
2. The dual-AI review and consensus
@@ -155,15 +180,17 @@ function HowReviewsWork() {
block — a dual-model high-confidence consensus defect is
- allowed to become a blocker (confirmed contributors only).
+ allowed to become a blocker.
- The blocking decision always runs on a pair of free models and only blocks
- when both models independently agree, with high confidence, on a real defect. Two
- agreeing models is the bar — there is no single-model block and no tie-breaker third model.
- That consensus requirement is what keeps a confident-but-wrong single model from blocking a
- good PR.
+ By default, the blocking decision runs on a pair of free models and only
+ blocks when both models independently agree, with high confidence, on a real defect
+ — no single-model block and no tie-breaker third model, so a confident-but-wrong single
+ model can't block a good PR on its own. An operator can override this per repo (
+ aiReviewCombine: single / consensus /{" "}
+ synthesis); in single mode, one reviewer's verdict is the
+ decision.
Bring your own model (advisory only)
diff --git a/apps/gittensory-ui/src/routes/docs.maintainer-install-trust.tsx b/apps/gittensory-ui/src/routes/docs.maintainer-install-trust.tsx
index a818d7b75f..cae6061680 100644
--- a/apps/gittensory-ui/src/routes/docs.maintainer-install-trust.tsx
+++ b/apps/gittensory-ui/src/routes/docs.maintainer-install-trust.tsx
@@ -128,15 +128,18 @@ POST /v1/repos/:owner/:repo/settings-preview`}
{
title: "Command access",
description:
- "PR-thread commands are maintainer-authorized. Untrusted contributors should not be able to trigger private maintainer packets.",
+ "PR-thread commands default to maintainers, collaborators, and confirmed miners. Untrusted contributors should not be able to trigger private maintainer packets.",
},
]}
/>
Command authorization
- Maintainer commands should be treated like privileged review actions. Use them to fetch
- context on demand, not to create always-on public scoring.
+ Commands should be treated like privileged review actions. Use them to fetch context on
+ demand, not to create always-on public scoring. The default authorized roles are{" "}
+ maintainer, collaborator, and{" "}
+ confirmed miner — a repo can narrow (or further restrict) this per command
+ via commandAuthorization in its settings.
diff --git a/apps/gittensory-ui/src/routes/docs.maintainer-self-hosting.tsx b/apps/gittensory-ui/src/routes/docs.maintainer-self-hosting.tsx
index 4cdb9dcc25..47fb4dd239 100644
--- a/apps/gittensory-ui/src/routes/docs.maintainer-self-hosting.tsx
+++ b/apps/gittensory-ui/src/routes/docs.maintainer-self-hosting.tsx
@@ -82,6 +82,12 @@ const SECTION_LINKS = [
description: "Official images, tags, source maps, upgrade cadence, and local custom builds.",
to: "/docs/self-hosting-releases",
},
+ {
+ title: "Beta release checklist",
+ description:
+ "The smoke matrix to run against a candidate image before tagging an orb-vX.Y.Z release.",
+ to: "/docs/self-hosting-release-checklist",
+ },
{
title: "Security",
description:
@@ -167,6 +173,10 @@ function MaintainerSelfHosting() {
Security before exposing the service to
production traffic.
+
+ Run the beta release checklist{" "}
+ before tagging or promoting a candidate image.
+
Pages
diff --git a/apps/gittensory-ui/src/routes/docs.maintainer-workflow.tsx b/apps/gittensory-ui/src/routes/docs.maintainer-workflow.tsx
index 0b78c2931d..2e0eeedbf6 100644
--- a/apps/gittensory-ui/src/routes/docs.maintainer-workflow.tsx
+++ b/apps/gittensory-ui/src/routes/docs.maintainer-workflow.tsx
@@ -147,8 +147,10 @@ GET /v1/repos/:owner/:repo/registration-readiness`}
On-demand commands
- Maintainers (and only maintainers) can trigger context with a comment. Output is scoped to
- maintainer-visible packets when appropriate.
+ By default, the public commands below are authorized for maintainers, collaborators, and
+ confirmed miners (not the general public) — output stays scoped to maintainer-visible
+ packets when appropriate. A repo can narrow this per command via{" "}
+ commandAuthorization in its settings.
--print mcp uses the same JSON snippet as Claude Desktop and Cursor for other
- stdio MCP hosts that expect the mcpServers shape.
+ stdio MCP hosts that expect the mcpServers shape. Every generated snippet
+ assumes gittensory-mcp is on your PATH (install it globally first,
+ per Quickstart) — pass{" "}
+ --command /absolute/path/to/gittensory-mcp if your client doesn't inherit your
+ shell PATH.
+ VS Code's native MCP support uses a servers map with an explicit transport type
+ instead of the mcpServers shape the other JSON hosts use:
+
+
The internal-only controls never surface publicly. Submitter reputation, for example, can
diff --git a/apps/gittensory-ui/src/routes/docs.scoreability.tsx b/apps/gittensory-ui/src/routes/docs.scoreability.tsx
index 84de55bbe6..54003bf072 100644
--- a/apps/gittensory-ui/src/routes/docs.scoreability.tsx
+++ b/apps/gittensory-ui/src/routes/docs.scoreability.tsx
@@ -32,55 +32,89 @@ function Scoreability() {
title="Scoreability"
description="Gittensory projects how scoreable your branch is under several scenarios. These are estimates, never guarantees."
>
-
The six scenarios
+
The seven scenarios
+
+ Every preview computes scenarioPreviews, an array of exactly seven named
+ scenarios, alongside a top-level effectiveEstimatedScore and{" "}
+ underlyingPotentialScore for the current state:
+
- Current gated — what's scoreable right now, given all current gates.
+ current — what's scoreable right now, given all current gates and
+ observed data.
- Underlying potential — the upper bound implied by the work itself,
- ignoring gates.
+ cleanGates — projection assuming every currently-failing gate (open-PR
+ threshold, credibility floor, review penalty, etc.) clears.
- Clean-gate — what becomes scoreable if branch hygiene issues are
- resolved.
+ afterPendingMerges — projection assuming your other open PRs on this repo
+ merge, relieving open-PR collateral.
- After-pending-merges — projection assuming pending related PRs merge.
+ afterApprovedPrsMerge — projection assuming only your already-approved
+ open PRs merge.
- Linked-issue-fixed — projection assuming the linked issue is closed
- cleanly.
+ afterStalePrsClose — projection assuming your stale open PRs close
+ instead of merging.
- Best reasonable case — the realistic upper bound across known cleanups.
+ linkedIssueFixed — projection assuming the linked issue is validated and
+ the standard issue multiplier applies.
+
+
+ bestReasonableCase — the best of the above scenarios; the realistic upper
+ bound across known cleanups.
+
+ Each scenario carries its own scoreEstimate, gates,{" "}
+ effectiveEstimatedScore, underlyingPotentialScore,{" "}
+ blockedBy, and a human-readable deltaExplanation.
+
Language rules
Use scoreability, estimated score,{" "}
- underlying potential, and risk-adjusted priority. Never say{" "}
+ underlying potential, and scoreability status. Never say{" "}
guaranteed payout, guaranteed reward, or anything implying outcome
guarantees.
Example shape
+
+ Trimmed for readability — the real response also includes laneMath,{" "}
+ gates, gateDeltas, and per-scenario detail:
+
+
+ scoreabilityStatus is one of blocked,{" "}
+ conditionally_scoreable, scoreable, or hold (the repo
+ itself isn't actively scoreable — unregistered or inactive allocation).
+
Scoreability numbers and risk language are private. They appear only in
diff --git a/apps/gittensory-ui/src/routes/docs.self-hosting-configuration.tsx b/apps/gittensory-ui/src/routes/docs.self-hosting-configuration.tsx
index 4f73446716..86c5dd4358 100644
--- a/apps/gittensory-ui/src/routes/docs.self-hosting-configuration.tsx
+++ b/apps/gittensory-ui/src/routes/docs.self-hosting-configuration.tsx
@@ -101,6 +101,13 @@ MCP_ACTUATION_REPO_ALLOWLIST=owner/repo-one, owner/repo-two
# Restore pre-upgrade any-repo behavior:
# MCP_ACTUATION_REPO_ALLOWLIST=*`}
/>
+
+ MCP_READ_REPO_ALLOWLIST is the same fail-closed/wildcard model, kept as a{" "}
+ separate allowlist so read-only MCP tools (repo context, issue quality,
+ watch subscriptions) can be granted independently of actuation trust. The full{" "}
+ */all wildcard additionally unlocks the non-repo-scoped
+ contributor/operator tools.
+
- Mount a gitignored directory and point GITTENSORY_REPO_CONFIG_DIR at it. The
- first matching file wins and replaces the public repo config for that review.
+ Mount a gitignored directory and point GITTENSORY_REPO_CONFIG_DIR at it. If
+ either a per-repo file or the dir-root global default (.gittensory.yml at the
+ mount root) exists, the public repo .gittensory.yml is never fetched for that
+ review. With only one of the two present, its contents are used as-is; with both present,
+ they are deep-merged — the per-repo file overlaid onto the global default, nested mappings
+ merging key by key and arrays replacing wholesale.
diff --git a/apps/gittensory-ui/src/routes/docs.self-hosting-quickstart.tsx b/apps/gittensory-ui/src/routes/docs.self-hosting-quickstart.tsx
index f500ece297..96991ed242 100644
--- a/apps/gittensory-ui/src/routes/docs.self-hosting-quickstart.tsx
+++ b/apps/gittensory-ui/src/routes/docs.self-hosting-quickstart.tsx
@@ -117,7 +117,11 @@ GITTENSORY_IMAGE=ghcr.io/jsonbored/gittensory-selfhost@sha256:... ./scripts/depl
Contributors and anyone customizing the Dockerfile can still build locally —{" "}
docker compose up -d --build builds the gittensory service from
the checkout instead of pulling a published image. Everything else in this quickstart (env,
- health checks, GitHub App) is identical either way.
+ health checks, GitHub App) is identical either way. Two build-args trim the image:{" "}
+ --build-arg INSTALL_AI_CLIS=false skips the Claude Code/Codex CLIs (default{" "}
+ true), and --build-arg INSTALL_VISUAL_REVIEW=true adds{" "}
+ puppeteer-core for visual capture (default false — needs a{" "}
+ BROWSER_WS_ENDPOINT at runtime).
GitHub App and Orb.
+
+ Set ADMIN_GITHUB_LOGINS to a comma/whitespace-separated list of GitHub logins
+ before signing in to the control panel — it's the only allowlist for the operator role
+ (operator dashboard, drift status). No login is authorized as operator without it.
+
5. Watch the first review
Look for these logs during boot and the first webhook:
- Custom builds are useful for testing local changes or including subscription CLIs. They
- should not contain secrets.
+ Custom builds are useful for testing local changes, including subscription CLIs, or trimming
+ the image. They should not contain secrets. INSTALL_AI_CLIS (default{" "}
+ true) installs the Claude Code and Codex CLIs; a sibling build-arg,{" "}
+ INSTALL_VISUAL_REVIEW (default false), adds{" "}
+ puppeteer-core for visual capture.
API status & offline mode
The site continuously monitors the Gittensory API and surfaces problems through a banner
- under the header and a single deduped toast with a Retry button.
+ under the header and a single deduped toast with a Recheck button.
You're offline
diff --git a/apps/gittensory-ui/src/routes/docs.tuning.tsx b/apps/gittensory-ui/src/routes/docs.tuning.tsx
index 354fbf12a5..758980cc56 100644
--- a/apps/gittensory-ui/src/routes/docs.tuning.tsx
+++ b/apps/gittensory-ui/src/routes/docs.tuning.tsx
@@ -143,6 +143,23 @@ function Tuning() {
in-place unified comment instead of the legacy multi-panel comment. Per-PR. With the flag
off, the legacy comment is byte-identical.
+
+ GITTENSORY_REVIEW_ENRICHMENT — runs the review-enrichment analyzer registry
+ (duplication, churn hotspots, blame links, approval integrity, undocumented exports, and
+ more) and folds their findings into the review context. Per-PR.
+
+
+ GITTENSORY_REVIEW_INLINE_COMMENTS — posts AI-review findings as inline
+ diff-anchored PR review comments instead of (or alongside) the summary comment. Per-PR.
+
+
+ GITTENSORY_REVIEW_PLANNER — enables @gittensory plan, an
+ on-demand structured implementation plan posted to the PR thread. Per-PR.
+
+
+ GITTENSORY_REVIEW_SCREENSHOTS — visual capture: renders and attaches
+ before/after screenshots for PRs that change UI. Per-PR.
+
GITTENSORY_REVIEW_OPS — observability, read-only. On the cron tick an anomaly
scan over the gate-block ledger and calibration data emits a structured{" "}
@@ -205,9 +222,11 @@ function Tuning() {
block — the finding can become a hard{" "}
- Gittensory Orb Review Agent blocker. Blocking is always
- confirmed-contributor-gated: the mode chooses which deterministic checks are
- active, never who can be blocked.
+ Gittensory Orb Review Agent blocker. A block outcome fails the gate for any
+ author identically — confirmed-Gittensor-contributor status doesn't change{" "}
+ who can be blocked, only the mode chooses which deterministic checks are
+ active. Confirmed status is carried through for on-chain scoring, a separate concern from
+ the gate's own merge/close decision.
@@ -216,9 +235,10 @@ function Tuning() {
- gate.pack — the policy pack: gittensor (default;
- confirmed-contributor-gated, registry-aware) or oss-anti-slop (runs the
- deterministic rules against any author on any repo).
+ gate.pack — the policy pack: gittensor (default; registry-aware,
+ tracks confirmed-Gittensor-contributor status for scoring) or oss-anti-slop{" "}
+ (runs the deterministic rules against any author on any repo, with no
+ confirmed-contributor tracking at all).
- gate.firstTimeContributorGrace — when true, softens a would-be
- block to advisory for a genuine newcomer (0 merged PRs, fewer than 3 closed-unmerged PRs).
- Repeat offenders and authors with merge history are gated normally. Default{" "}
- false.
+ gate.size — PR-size hold: flags an oversized diff. Default off.
+
+
+ gate.lockfileIntegrity — flags lockfile-tamper risk (a lockfile changed
+ without its matching manifest, or vice versa). Default off.
+
+ gate.selfAuthoredLinkedIssue — whether a PR may link an issue opened by the
+ same author. Default advisory.
+
+
+ settings.moderationGateMode — whether the moderation-rules engine
+ (contributor cap, blacklist, review-nag feeding a shared cross-repo violation tally) runs
+ on this repo at all. inherit (default) defers to the instance-wide{" "}
+ global_moderation_config.enabled; off/enabled force
+ this repo regardless of the global default.
gate.aiReview.mode — AI review. Default off.{" "}
advisory posts AI review notes only; block lets a dual-model
- high-confidence consensus defect become a blocker (confirmed contributors only).
+ high-confidence consensus defect become a blocker.
Bring your own model (AI review)
- The AI-review write-up can optionally use your own frontier model. The consensus blocker
- always uses the free built-in model pair, so BYOK never changes who can be blocked.
+ The AI-review write-up can optionally use your own frontier model. By default the blocking
+ decision runs on a pair of free built-in models and requires agreement; an operator can
+ override this per repo with aiReviewCombine (single /{" "}
+ consensus / synthesis) — in single mode, one
+ reviewer's verdict is the decision. BYOK changes which model writes the advisory text, not
+ this combine behavior.
@@ -417,7 +456,6 @@ gate:
aiAdvisory: true
mergeReadiness: advisory
manifestPolicy: block
- firstTimeContributorGrace: true
aiReview:
mode: advisory
byok: true
diff --git a/package.json b/package.json
index 6ff8363098..97f990e22e 100644
--- a/package.json
+++ b/package.json
@@ -48,6 +48,7 @@
"ui:openapi:check": "tsx scripts/write-ui-openapi.ts --check",
"ui:openapi:settings-parity": "tsx scripts/check-openapi-settings-parity.mjs",
"ui:version-audit": "node scripts/check-ui-mcp-version-copy.mjs",
+ "docs:drift-check": "node scripts/check-docs-drift.mjs",
"ui:deploy": "npm run ui:build && npm run ui:deploy:built",
"ui:deploy:built": "wrangler deploy --config apps/gittensory-ui/dist/server/wrangler.json",
"ui:version:built": "wrangler versions upload --config apps/gittensory-ui/dist/server/wrangler.json",
@@ -71,7 +72,7 @@
"test:smoke:observability": "node scripts/smoke-observability-traces.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 selfhost:validate-observability && npm run cf-typegen:check && npm run typecheck && npm run test:coverage && npm run test:workers && npm run build:mcp && npm run test:mcp-pack && npm run build:miner && npm run rees:test && npm run ui:openapi:check && npm run ui:openapi:settings-parity && npm run ui:version-audit && 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 selfhost:validate-observability && npm run cf-typegen:check && npm run typecheck && npm run test:coverage && npm run test:workers && npm run build:mcp && npm run test:mcp-pack && npm run build:miner && 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 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 && npm run changelog:check:mcp",
"test:watch": "vitest",
diff --git a/scripts/check-docs-drift.d.mts b/scripts/check-docs-drift.d.mts
new file mode 100644
index 0000000000..0e6074b5ea
--- /dev/null
+++ b/scripts/check-docs-drift.d.mts
@@ -0,0 +1,17 @@
+export function extractGittensoryReviewFlags(envDtsText: string): string[];
+
+export function extractCatalogIds(sourceText: string, catalogConstName: string): string[];
+
+export function extractGateModeFields(typesText: string): string[];
+
+export type GateModeManifestRow = { field: string; aliases: string[]; pages: string[] };
+
+export const GATE_MODE_MANIFEST: GateModeManifestRow[];
+
+export function checkDocsDrift(options: {
+ root: string;
+ readFile?: (root: string, relativePath: string) => string;
+}): {
+ failures: string[];
+ counts: { flags: number; commands: number; gateModes: number };
+};
diff --git a/scripts/check-docs-drift.mjs b/scripts/check-docs-drift.mjs
new file mode 100644
index 0000000000..8705f2d4c9
--- /dev/null
+++ b/scripts/check-docs-drift.mjs
@@ -0,0 +1,161 @@
+#!/usr/bin/env node
+// Cross-checks three enumerable "surfaces" that each have a single code source of truth but are also
+// meant to be documented EXHAUSTIVELY on specific docs pages: feature flags (src/env.d.ts's
+// GITTENSORY_REVIEW_* family), @gittensory commands (src/github/commands.ts's two command catalogs), and
+// gate-mode dimensions (src/types.ts's *GateMode fields on RepositorySettings). Nothing else in CI catches a
+// docs page silently falling behind when a new flag/command/gate-mode field is added to source but the docs
+// page enumerating that surface is never updated -- a reviewer has to notice by eye, and often doesn't.
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
+import { fileURLToPath } from "node:url";
+
+/** Extract every unique GITTENSORY_REVIEW_ flag DECLARED as a TS interface field (e.g.
+ * `GITTENSORY_REVIEW_SAFETY?: string;`) from src/env.d.ts's text. Deliberately anchored on the declaration
+ * shape (optional `?`, then `:`, then whitespace, then `string`) rather than a bare name match, so a comment
+ * that merely MENTIONS a flag name (common in this file's prose-heavy JSDoc) is never mistaken for a real
+ * declaration. */
+export function extractGittensoryReviewFlags(envDtsText) {
+ const matches = envDtsText.matchAll(/GITTENSORY_REVIEW_[A-Z0-9_]+(?=\??:\s*string)/g);
+ return [...new Set([...matches].map((match) => match[0]))];
+}
+
+/** Find the array literal assigned to `const = [ ... ] as const;` (non-greedy up to the
+ * FIRST `] as const;` after the const name -- catalogs in commands.ts never nest another `] as const;`
+ * inside themselves, so the first close is always the right one) and extract every `id: ""` string
+ * from within that slice. Scoped to the named catalog's own slice so two catalogs in the same file never
+ * bleed into each other's id list. */
+export function extractCatalogIds(sourceText, catalogConstName) {
+ const catalogPattern = new RegExp(`const\\s+${catalogConstName}\\s*=\\s*\\[([\\s\\S]*?)\\]\\s*as\\s*const;`);
+ const catalogMatch = catalogPattern.exec(sourceText);
+ if (!catalogMatch) return [];
+ const idMatches = catalogMatch[1].matchAll(/id:\s*"([^"]+)"/g);
+ return [...new Set([...idMatches].map((match) => match[1]))];
+}
+
+/** Extract every unique identifier matching `[a-zA-Z]+GateMode` DECLARED as a field (optional `?` then `:`)
+ * from src/types.ts's text -- e.g. `slopGateMode?: GateRuleMode;` or `linkedIssueGateMode: GateRuleMode;`.
+ * Anchored on the field-declaration shape so a comment mentioning a GateMode name in prose (this file's
+ * JSDoc references sibling gate modes constantly, e.g. "mirrors sizeGateMode") is never mistaken for a real
+ * field. */
+export function extractGateModeFields(typesText) {
+ const matches = typesText.matchAll(/[a-zA-Z]+GateMode(?=\??:)/g);
+ return [...new Set([...matches].map((match) => match[0]))];
+}
+
+// The real current *GateMode fields on RepositorySettings in src/types.ts. Each row maps the field to its
+// .gittensory.yml alias(es) (the field's own DB/settings name, plus any config-as-code YAML path it is also
+// known by) and the docs route filenames (relative to apps/gittensory-ui/src/routes/) that must document it.
+// Adding a new *GateMode field to src/types.ts without adding a row here is a docs-drift failure by design
+// (see checkDocsDrift step 3) -- the manifest is the single place that maps "a gate dimension exists" to
+// "here is where a maintainer can read about it".
+export const GATE_MODE_MANIFEST = [
+ { field: "linkedIssueGateMode", aliases: ["linkedIssueGateMode", "gate.linkedIssue"], pages: ["docs.how-reviews-work.tsx", "docs.tuning.tsx"] },
+ { field: "duplicatePrGateMode", aliases: ["duplicatePrGateMode", "gate.duplicates"], pages: ["docs.how-reviews-work.tsx", "docs.tuning.tsx"] },
+ { field: "qualityGateMode", aliases: ["qualityGateMode", "gate.readiness.mode"], pages: ["docs.how-reviews-work.tsx", "docs.tuning.tsx"] },
+ { field: "slopGateMode", aliases: ["slopGateMode", "gate.slop.mode"], pages: ["docs.how-reviews-work.tsx", "docs.tuning.tsx"] },
+ { field: "sizeGateMode", aliases: ["sizeGateMode", "gate.size"], pages: ["docs.how-reviews-work.tsx", "docs.tuning.tsx", "docs.github-app.tsx"] },
+ { field: "lockfileIntegrityGateMode", aliases: ["lockfileIntegrityGateMode", "gate.lockfileIntegrity"], pages: ["docs.how-reviews-work.tsx", "docs.tuning.tsx", "docs.github-app.tsx"] },
+ { field: "claGateMode", aliases: ["claGateMode", "gate.claMode"], pages: ["docs.how-reviews-work.tsx", "docs.tuning.tsx", "docs.github-app.tsx"] },
+ { field: "mergeReadinessGateMode", aliases: ["mergeReadinessGateMode", "gate.mergeReadiness"], pages: ["docs.how-reviews-work.tsx", "docs.tuning.tsx"] },
+ { field: "manifestPolicyGateMode", aliases: ["manifestPolicyGateMode", "gate.manifestPolicy"], pages: ["docs.how-reviews-work.tsx", "docs.tuning.tsx"] },
+ { field: "selfAuthoredLinkedIssueGateMode", aliases: ["selfAuthoredLinkedIssueGateMode", "gate.selfAuthoredLinkedIssue"], pages: ["docs.how-reviews-work.tsx", "docs.tuning.tsx", "docs.github-app.tsx"] },
+ { field: "moderationGateMode", aliases: ["moderationGateMode", "settings.moderationGateMode"], pages: ["docs.how-reviews-work.tsx", "docs.tuning.tsx", "docs.github-app.tsx"] },
+];
+
+const DOCS_ROUTES_DIR = "apps/gittensory-ui/src/routes";
+
+function defaultReadFile(root, relativePath) {
+ return readFileSync(join(root, relativePath), "utf8");
+}
+
+/**
+ * Cross-check feature flags, @gittensory commands, and gate-mode dimensions between their code source of
+ * truth and the docs pages meant to document them exhaustively. `readFile(root, relativePath)` is injectable
+ * so tests can simulate a broken/incomplete docs page or source file without touching the real filesystem.
+ * Returns `{ failures, counts }` -- pure given its inputs, no process.exit/console side effects of its own
+ * (those live in main()).
+ */
+export function checkDocsDrift({ root, readFile = defaultReadFile }) {
+ const failures = [];
+ const read = (relativePath) => readFile(root, relativePath);
+
+ // 1. Feature flags: src/env.d.ts vs docs.tuning.tsx + docs.privacy-security.tsx.
+ const envDtsText = read("src/env.d.ts");
+ const flags = extractGittensoryReviewFlags(envDtsText);
+ if (flags.length < 10) {
+ failures.push(`src/env.d.ts: extraction found only ${flags.length} GITTENSORY_REVIEW_* flags -- expected 10+; the extraction regex may be broken`);
+ } else {
+ const flagDocsPages = ["docs.tuning.tsx", "docs.privacy-security.tsx"];
+ for (const flag of flags) {
+ for (const page of flagDocsPages) {
+ const pageText = read(`${DOCS_ROUTES_DIR}/${page}`);
+ if (!pageText.includes(flag)) {
+ failures.push(`${page}: missing documentation for feature flag ${flag}`);
+ }
+ }
+ }
+ }
+
+ // 2. @gittensory commands: src/github/commands.ts vs docs.maintainer-workflow.tsx + docs.maintainer-install-trust.tsx.
+ const commandsSourceText = read("src/github/commands.ts");
+ const publicCommandIds = extractCatalogIds(commandsSourceText, "PUBLIC_MENTION_COMMAND_CATALOG");
+ const maintainerCommandIds = extractCatalogIds(commandsSourceText, "MAINTAINER_QUEUE_DIGEST_COMMAND_CATALOG");
+ const allCommandIds = [...new Set([...publicCommandIds, ...maintainerCommandIds])];
+ if (allCommandIds.length < 15) {
+ failures.push(`src/github/commands.ts: extraction found only ${allCommandIds.length} unique @gittensory command ids -- expected 15+; the extraction regex may be broken`);
+ } else {
+ const commandDocsPages = ["docs.maintainer-workflow.tsx", "docs.maintainer-install-trust.tsx"];
+ for (const id of allCommandIds) {
+ for (const page of commandDocsPages) {
+ const pageText = read(`${DOCS_ROUTES_DIR}/${page}`);
+ if (!pageText.includes(`@gittensory ${id}`)) {
+ failures.push(`${page}: missing documentation for command @gittensory ${id}`);
+ }
+ }
+ }
+ }
+
+ // 3. Gate-mode dimensions: src/types.ts vs GATE_MODE_MANIFEST vs each row's docs pages.
+ const typesText = read("src/types.ts");
+ const gateModeFields = extractGateModeFields(typesText);
+ if (gateModeFields.length < 5) {
+ failures.push(`src/types.ts: extraction found only ${gateModeFields.length} *GateMode fields -- expected 5+; the extraction regex may be broken`);
+ } else {
+ const manifestFields = new Set(GATE_MODE_MANIFEST.map((row) => row.field));
+ for (const field of gateModeFields) {
+ if (!manifestFields.has(field)) {
+ failures.push(`src/types.ts declares ${field} but GATE_MODE_MANIFEST in scripts/check-docs-drift.mjs has no entry for it -- add a row mapping it to its .gittensory.yml alias(es) and the docs pages that must document it`);
+ }
+ }
+
+ for (const row of GATE_MODE_MANIFEST) {
+ for (const page of row.pages) {
+ const pageText = read(`${DOCS_ROUTES_DIR}/${page}`);
+ const hasAlias = row.aliases.some((alias) => pageText.includes(alias));
+ if (!hasAlias) {
+ failures.push(`${page}: missing documentation for gate mode ${row.field} (expected one of: ${row.aliases.join(", ")})`);
+ }
+ }
+ }
+ }
+
+ return {
+ failures,
+ counts: { flags: flags.length, commands: allCommandIds.length, gateModes: gateModeFields.length },
+ };
+}
+
+function main() {
+ const { failures, counts } = checkDocsDrift({ root: process.cwd() });
+
+ if (failures.length > 0) {
+ console.error(`Docs-drift check found ${failures.length} issue(s):`);
+ for (const failure of failures) console.error(failure);
+ process.exit(1);
+ }
+
+ console.log(`Docs-drift check ok: ${counts.flags} feature flags, ${counts.commands} commands, ${counts.gateModes} gate-mode fields all documented.`);
+}
+
+// Guard so importing this module for its pure exports (tests) never triggers the file-read/exit side effects.
+if (process.argv[1] === fileURLToPath(import.meta.url)) main();
diff --git a/test/unit/check-docs-drift-script.test.ts b/test/unit/check-docs-drift-script.test.ts
new file mode 100644
index 0000000000..82f6829483
--- /dev/null
+++ b/test/unit/check-docs-drift-script.test.ts
@@ -0,0 +1,236 @@
+import { execFileSync } from "node:child_process";
+import { describe, expect, it } from "vitest";
+import {
+ checkDocsDrift,
+ extractCatalogIds,
+ extractGateModeFields,
+ extractGittensoryReviewFlags,
+ GATE_MODE_MANIFEST,
+} from "../../scripts/check-docs-drift.mjs";
+
+describe("check-docs-drift script", () => {
+ describe("extractGittensoryReviewFlags", () => {
+ it("extracts only real field declarations, not a comment mentioning a flag name", () => {
+ const fixture = `
+ interface Env {
+ /** See GITTENSORY_REVIEW_SAFETY for context on why this one is separate. */
+ GITTENSORY_REVIEW_FOO?: string;
+ GITTENSORY_REVIEW_BAR: string;
+ GITTENSORY_REVIEW_BAZ?: string;
+ }
+ `;
+
+ const flags = extractGittensoryReviewFlags(fixture);
+
+ expect(flags.sort()).toEqual(["GITTENSORY_REVIEW_BAR", "GITTENSORY_REVIEW_BAZ", "GITTENSORY_REVIEW_FOO"]);
+ expect(flags).not.toContain("GITTENSORY_REVIEW_SAFETY");
+ });
+
+ it("returns unique values only", () => {
+ const fixture = `
+ GITTENSORY_REVIEW_FOO?: string;
+ GITTENSORY_REVIEW_FOO?: string;
+ `;
+
+ expect(extractGittensoryReviewFlags(fixture)).toEqual(["GITTENSORY_REVIEW_FOO"]);
+ });
+ });
+
+ describe("extractCatalogIds", () => {
+ const fixture = `
+ const FIRST_CATALOG = [
+ { id: "alpha", title: "Alpha" },
+ { id: "beta", title: "Beta" },
+ ] as const;
+
+ const SECOND_CATALOG = [
+ { id: "gamma", title: "Gamma" },
+ ] as const;
+ `;
+
+ it("extracts only the ids from the named catalog, not the other one", () => {
+ expect(extractCatalogIds(fixture, "FIRST_CATALOG").sort()).toEqual(["alpha", "beta"]);
+ expect(extractCatalogIds(fixture, "SECOND_CATALOG")).toEqual(["gamma"]);
+ });
+
+ it("returns an empty array when the named catalog does not exist", () => {
+ expect(extractCatalogIds(fixture, "MISSING_CATALOG")).toEqual([]);
+ });
+ });
+
+ describe("extractGateModeFields", () => {
+ it("extracts only real field declarations, not a comment mentioning a GateMode name without a colon", () => {
+ const fixture = `
+ type RepositorySettings = {
+ // mirrors sizeGateMode in spirit, but this comment has no colon after it
+ fooGateMode: GateRuleMode;
+ barGateMode?: GateRuleMode | undefined;
+ bazGateMode: GateRuleMode;
+ };
+ `;
+
+ const fields = extractGateModeFields(fixture);
+
+ expect(fields.sort()).toEqual(["barGateMode", "bazGateMode", "fooGateMode"]);
+ expect(fields).not.toContain("sizeGateMode");
+ });
+
+ it("returns unique values only", () => {
+ const fixture = `fooGateMode: GateRuleMode; fooGateMode: GateRuleMode;`;
+
+ expect(extractGateModeFields(fixture)).toEqual(["fooGateMode"]);
+ });
+ });
+
+ describe("checkDocsDrift", () => {
+ // A minimal set of fixtures that satisfies every check EXCEPT the one under test in each case below.
+ const baseFlags = Array.from({ length: 10 }, (_, i) => `GITTENSORY_REVIEW_FLAG_${i}?: string;`).join("\n");
+ const baseCommandsSource = `
+ const PUBLIC_MENTION_COMMAND_CATALOG = [
+ ${Array.from({ length: 10 }, (_, i) => `{ id: "public-${i}", title: "Public ${i}" },`).join("\n")}
+ ] as const;
+ const MAINTAINER_QUEUE_DIGEST_COMMAND_CATALOG = [
+ ${Array.from({ length: 9 }, (_, i) => `{ id: "maint-${i}", title: "Maint ${i}" },`).join("\n")}
+ ] as const;
+ `;
+ const allBaseCommandIds = [
+ ...Array.from({ length: 10 }, (_, i) => `public-${i}`),
+ ...Array.from({ length: 9 }, (_, i) => `maint-${i}`),
+ ];
+ const baseFlagNames = Array.from({ length: 10 }, (_, i) => `GITTENSORY_REVIEW_FLAG_${i}`);
+
+ function buildDocsPageText(commandIds: string[]) {
+ return commandIds.map((id) => `@gittensory ${id}`).join("\n");
+ }
+
+ function buildFlagsPageText(flagNames: string[]) {
+ return flagNames.join("\n");
+ }
+
+ function buildGateModePageText() {
+ return GATE_MODE_MANIFEST.flatMap((row) => row.aliases).join("\n");
+ }
+
+ function baseFixtures(): Record {
+ const files: Record = {
+ "src/env.d.ts": baseFlags,
+ "src/github/commands.ts": baseCommandsSource,
+ "src/types.ts": GATE_MODE_MANIFEST.map((row) => `${row.field}: GateRuleMode;`).join("\n"),
+ "apps/gittensory-ui/src/routes/docs.tuning.tsx": [buildFlagsPageText(baseFlagNames), buildGateModePageText()].join("\n"),
+ "apps/gittensory-ui/src/routes/docs.privacy-security.tsx": buildFlagsPageText(baseFlagNames),
+ "apps/gittensory-ui/src/routes/docs.maintainer-workflow.tsx": buildDocsPageText(allBaseCommandIds),
+ "apps/gittensory-ui/src/routes/docs.maintainer-install-trust.tsx": buildDocsPageText(allBaseCommandIds),
+ "apps/gittensory-ui/src/routes/docs.how-reviews-work.tsx": buildGateModePageText(),
+ "apps/gittensory-ui/src/routes/docs.github-app.tsx": buildGateModePageText(),
+ };
+ return files;
+ }
+
+ function makeReadFile(files: Record) {
+ return (_root: string, relativePath: string): string => {
+ const contents = files[relativePath];
+ if (contents === undefined) throw new Error(`unexpected read: ${relativePath}`);
+ return contents;
+ };
+ }
+
+ it("passes cleanly against a fully-consistent synthetic fixture set", () => {
+ const files = baseFixtures();
+ const result = checkDocsDrift({ root: "/fake", readFile: makeReadFile(files) });
+
+ expect(result.failures).toEqual([]);
+ expect(result.counts).toEqual({ flags: 10, commands: 19, gateModes: 11 });
+ });
+
+ it("catches an unmapped *GateMode field missing from GATE_MODE_MANIFEST", () => {
+ const files = baseFixtures();
+ files["src/types.ts"] += "\nnewThingGateMode?: GateRuleMode;";
+ const result = checkDocsDrift({ root: "/fake", readFile: makeReadFile(files) });
+
+ const hit = result.failures.find((failure) => failure.includes("newThingGateMode") && failure.includes("GATE_MODE_MANIFEST"));
+ expect(hit).toBeDefined();
+ });
+
+ it("catches a docs page missing a known feature flag", () => {
+ const files = baseFixtures();
+ // Drop one known flag from docs.tuning.tsx.
+ files["apps/gittensory-ui/src/routes/docs.tuning.tsx"] = [
+ buildFlagsPageText(baseFlagNames.filter((flag) => flag !== "GITTENSORY_REVIEW_FLAG_3")),
+ buildGateModePageText(),
+ ].join("\n");
+ const result = checkDocsDrift({ root: "/fake", readFile: makeReadFile(files) });
+
+ const hit = result.failures.find((failure) => failure.includes("docs.tuning.tsx") && failure.includes("GITTENSORY_REVIEW_FLAG_3"));
+ expect(hit).toBeDefined();
+ });
+
+ it("catches a docs page missing a known @gittensory command", () => {
+ const files = baseFixtures();
+ files["apps/gittensory-ui/src/routes/docs.maintainer-workflow.tsx"] = buildDocsPageText(
+ allBaseCommandIds.filter((id) => id !== "public-5"),
+ );
+ const result = checkDocsDrift({ root: "/fake", readFile: makeReadFile(files) });
+
+ const hit = result.failures.find((failure) => failure.includes("docs.maintainer-workflow.tsx") && failure.includes("public-5"));
+ expect(hit).toBeDefined();
+ });
+
+ it("catches a docs page missing a gate-mode alias", () => {
+ const files = baseFixtures();
+ const withoutSlop = GATE_MODE_MANIFEST.filter((row) => row.field !== "slopGateMode")
+ .flatMap((row) => row.aliases)
+ .join("\n");
+ files["apps/gittensory-ui/src/routes/docs.tuning.tsx"] = [buildFlagsPageText(baseFlagNames), withoutSlop].join("\n");
+ const result = checkDocsDrift({ root: "/fake", readFile: makeReadFile(files) });
+
+ const hit = result.failures.find((failure) => failure.includes("docs.tuning.tsx") && failure.includes("slopGateMode"));
+ expect(hit).toBeDefined();
+ });
+
+ it("self-defends against a broken flag-extraction regex (fewer than 10 flags found)", () => {
+ const files = baseFixtures();
+ files["src/env.d.ts"] = "GITTENSORY_REVIEW_ONLY_ONE?: string;";
+ const result = checkDocsDrift({ root: "/fake", readFile: makeReadFile(files) });
+
+ const hit = result.failures.find((failure) => failure.includes("src/env.d.ts") && failure.includes("extraction regex may be broken"));
+ expect(hit).toBeDefined();
+ });
+
+ it("self-defends against a broken command-extraction regex (fewer than 15 commands found)", () => {
+ const files = baseFixtures();
+ files["src/github/commands.ts"] = `
+ const PUBLIC_MENTION_COMMAND_CATALOG = [{ id: "only-one", title: "Only" }] as const;
+ const MAINTAINER_QUEUE_DIGEST_COMMAND_CATALOG = [] as const;
+ `;
+ const result = checkDocsDrift({ root: "/fake", readFile: makeReadFile(files) });
+
+ const hit = result.failures.find((failure) => failure.includes("src/github/commands.ts") && failure.includes("extraction regex may be broken"));
+ expect(hit).toBeDefined();
+ });
+
+ it("self-defends against a broken gate-mode-extraction regex (fewer than 5 fields found)", () => {
+ const files = baseFixtures();
+ files["src/types.ts"] = "onlyOneGateMode: GateRuleMode;";
+ const result = checkDocsDrift({ root: "/fake", readFile: makeReadFile(files) });
+
+ const hit = result.failures.find((failure) => failure.includes("src/types.ts") && failure.includes("extraction regex may be broken"));
+ expect(hit).toBeDefined();
+ });
+
+ // Most important regression test in this file: proves the REAL current repo state (source files +
+ // docs pages) passes cleanly, using the real filesystem reader against the real repo root. If this
+ // fails, either a real doc gap exists or the extraction logic is broken -- either way, the check must
+ // not be weakened to make this test pass.
+ it("the real repo's surfaces and docs pages agree (regression guard)", () => {
+ const result = checkDocsDrift({ root: process.cwd() });
+
+ expect(result.failures).toEqual([]);
+ });
+
+ it("prints a clean summary and exits 0 for the real repo state when run as a subprocess", () => {
+ const output = execFileSync("node", ["scripts/check-docs-drift.mjs"], { encoding: "utf8" });
+
+ expect(output).toMatch(/Docs-drift check ok: \d+ feature flags, \d+ commands, \d+ gate-mode fields all documented\./);
+ });
+ });
+});
From 56ba276bece52c83dd84c6c1cdfee8635779d9ef Mon Sep 17 00:00:00 2001
From: JSONbored <49853598+JSONbored@users.noreply.github.com>
Date: Sat, 4 Jul 2026 04:31:07 -0700
Subject: [PATCH 2/2] fix(docs): let a page delegate command docs to the
generated reference
check-docs-drift.mjs's per-command substring check false-flagged
docs.maintainer-workflow.tsx/docs.maintainer-install-trust.tsx once
they switched to importing PUBLIC_COMMAND_LIST/MAINTAINER_COMMAND_LIST
from the generated apps/gittensory-ui/src/lib/command-reference.ts
(#3046) instead of hand-listing @gittensory commands inline. A page
that imports the generated constants is already covered by
command-reference:check, so skip its per-id checks.
---
scripts/check-docs-drift.mjs | 12 +++++++++---
test/unit/check-docs-drift-script.test.ts | 23 +++++++++++++++++++++++
2 files changed, 32 insertions(+), 3 deletions(-)
diff --git a/scripts/check-docs-drift.mjs b/scripts/check-docs-drift.mjs
index 8705f2d4c9..df60673f22 100644
--- a/scripts/check-docs-drift.mjs
+++ b/scripts/check-docs-drift.mjs
@@ -97,6 +97,11 @@ export function checkDocsDrift({ root, readFile = defaultReadFile }) {
}
// 2. @gittensory commands: src/github/commands.ts vs docs.maintainer-workflow.tsx + docs.maintainer-install-trust.tsx.
+ // A page can satisfy this either by literally mentioning "@gittensory " in its own source, or by
+ // importing the generated command-reference constants (apps/gittensory-ui/src/lib/command-reference.ts,
+ // regenerated from the same catalogs via `npm run command-reference:check`) -- once a page delegates to the
+ // generator, per-id substring checks against its own source would always false-fail, since the literal
+ // "@gittensory " text now lives in the generated file, not the page.
const commandsSourceText = read("src/github/commands.ts");
const publicCommandIds = extractCatalogIds(commandsSourceText, "PUBLIC_MENTION_COMMAND_CATALOG");
const maintainerCommandIds = extractCatalogIds(commandsSourceText, "MAINTAINER_QUEUE_DIGEST_COMMAND_CATALOG");
@@ -105,9 +110,10 @@ export function checkDocsDrift({ root, readFile = defaultReadFile }) {
failures.push(`src/github/commands.ts: extraction found only ${allCommandIds.length} unique @gittensory command ids -- expected 15+; the extraction regex may be broken`);
} else {
const commandDocsPages = ["docs.maintainer-workflow.tsx", "docs.maintainer-install-trust.tsx"];
- for (const id of allCommandIds) {
- for (const page of commandDocsPages) {
- const pageText = read(`${DOCS_ROUTES_DIR}/${page}`);
+ for (const page of commandDocsPages) {
+ const pageText = read(`${DOCS_ROUTES_DIR}/${page}`);
+ if (pageText.includes("@/lib/command-reference")) continue;
+ for (const id of allCommandIds) {
if (!pageText.includes(`@gittensory ${id}`)) {
failures.push(`${page}: missing documentation for command @gittensory ${id}`);
}
diff --git a/test/unit/check-docs-drift-script.test.ts b/test/unit/check-docs-drift-script.test.ts
index 82f6829483..bad6e4d296 100644
--- a/test/unit/check-docs-drift-script.test.ts
+++ b/test/unit/check-docs-drift-script.test.ts
@@ -175,6 +175,29 @@ describe("check-docs-drift script", () => {
expect(hit).toBeDefined();
});
+ it("skips per-command checks for a page that delegates to the generated command-reference instead of listing commands itself", () => {
+ const files = baseFixtures();
+ // Replace the page's literal @gittensory lines with an import marker only -- none of the individual
+ // command ids appear in the page's own source anymore, mirroring docs.maintainer-workflow.tsx after
+ // it switched to `import { PUBLIC_COMMAND_LIST, MAINTAINER_COMMAND_LIST } from "@/lib/command-reference"`.
+ files["apps/gittensory-ui/src/routes/docs.maintainer-workflow.tsx"] =
+ 'import { PUBLIC_COMMAND_LIST } from "@/lib/command-reference";';
+ const result = checkDocsDrift({ root: "/fake", readFile: makeReadFile(files) });
+
+ expect(result.failures).toEqual([]);
+ });
+
+ it("still checks a page for missing commands when it does NOT delegate to the generated command-reference", () => {
+ const files = baseFixtures();
+ files["apps/gittensory-ui/src/routes/docs.maintainer-install-trust.tsx"] = buildDocsPageText(
+ allBaseCommandIds.filter((id) => id !== "maint-2"),
+ );
+ const result = checkDocsDrift({ root: "/fake", readFile: makeReadFile(files) });
+
+ const hit = result.failures.find((failure) => failure.includes("docs.maintainer-install-trust.tsx") && failure.includes("maint-2"));
+ expect(hit).toBeDefined();
+ });
+
it("catches a docs page missing a gate-mode alias", () => {
const files = baseFixtures();
const withoutSlop = GATE_MODE_MANIFEST.filter((row) => row.field !== "slopGateMode")