From bd21365e609ceed010d9c709a9020606438eaa84 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Sat, 4 Jul 2026 03:49:18 -0700 Subject: [PATCH 1/2] docs(gittensory-ui): fix stale website docs and add a CI docs-drift check A full audit of the 30 docs.*.tsx pages found dead-flag claims, missing GITTENSORY_REVIEW_* flags, missing gate-mode dimensions, an undocumented 9-command maintainer queue-digest family, and schema-drifted API examples. Fixes all of it, plus adds scripts/check-docs-drift.mjs (wired into test:ci) so a new flag/command/gate-mode field shipped without a docs update fails CI instead of silently drifting. Fixes #3047 --- .../src/lib/selfhost-env-reference.ts | 12 +- .../src/routes/docs.ai-summaries.tsx | 48 ++-- .../src/routes/docs.beta-onboarding.tsx | 5 +- .../src/routes/docs.branch-analysis.tsx | 23 +- .../src/routes/docs.github-app.tsx | 41 ++- .../src/routes/docs.how-reviews-work.tsx | 55 ++-- .../routes/docs.maintainer-install-trust.tsx | 9 +- .../routes/docs.maintainer-self-hosting.tsx | 10 + .../src/routes/docs.maintainer-workflow.tsx | 6 +- .../src/routes/docs.mcp-clients.tsx | 44 +++- .../src/routes/docs.privacy-security.tsx | 13 +- .../src/routes/docs.scoreability.tsx | 76 ++++-- .../docs.self-hosting-configuration.tsx | 15 +- .../routes/docs.self-hosting-operations.tsx | 5 + .../routes/docs.self-hosting-quickstart.tsx | 11 +- .../src/routes/docs.self-hosting-releases.tsx | 7 +- .../src/routes/docs.troubleshooting.tsx | 2 +- apps/gittensory-ui/src/routes/docs.tuning.tsx | 66 +++-- package.json | 3 +- scripts/check-docs-drift.d.mts | 17 ++ scripts/check-docs-drift.mjs | 161 ++++++++++++ test/unit/check-docs-drift-script.test.ts | 236 ++++++++++++++++++ 22 files changed, 748 insertions(+), 117 deletions(-) create mode 100644 scripts/check-docs-drift.d.mts create mode 100644 scripts/check-docs-drift.mjs create mode 100644 test/unit/check-docs-drift-script.test.ts diff --git a/apps/gittensory-ui/src/lib/selfhost-env-reference.ts b/apps/gittensory-ui/src/lib/selfhost-env-reference.ts index 4cf067f35d..4a08b8e3b9 100644 --- a/apps/gittensory-ui/src/lib/selfhost-env-reference.ts +++ b/apps/gittensory-ui/src/lib/selfhost-env-reference.ts @@ -291,7 +291,7 @@ export const SELFHOST_ENV_REFERENCE_ROWS: SelfHostEnvReferenceRow[] = [ }, { name: "PGPOOL_MAX", - firstReference: "src/selfhost/queue-common.ts:710", + firstReference: "src/selfhost/queue-common.ts:713", }, { name: "PGVECTOR_ENABLED", @@ -327,11 +327,11 @@ export const SELFHOST_ENV_REFERENCE_ROWS: SelfHostEnvReferenceRow[] = [ }, { name: "QUEUE_DEAD_LETTER_AUTO_RETRY_MAX_EXTRA_ATTEMPTS", - firstReference: "src/selfhost/queue-common.ts:718", + firstReference: "src/selfhost/queue-common.ts:721", }, { name: "QUEUE_STARTUP_JITTER_MIN_JOBS", - firstReference: "src/selfhost/queue-common.ts:699", + firstReference: "src/selfhost/queue-common.ts:702", }, { name: "REDIS_URL", @@ -457,7 +457,7 @@ export const SELFHOST_ENV_REFERENCE_MARKDOWN = [ "| `OTEL_TRACES_EXPORTER` | `src/selfhost/otel.ts:40` |", "| `OTEL_TRACES_SAMPLER` | `src/selfhost/otel.ts:74` |", "| `OTEL_TRACES_SAMPLER_ARG` | `src/selfhost/otel.ts:76` |", - "| `PGPOOL_MAX` | `src/selfhost/queue-common.ts:710` |", + "| `PGPOOL_MAX` | `src/selfhost/queue-common.ts:713` |", "| `PGVECTOR_ENABLED` | `src/server.ts:229` |", "| `PORT` | `src/server.ts:715` |", "| `PUBLIC_API_ORIGIN` | `src/selfhost/preflight.ts:192` |", @@ -466,8 +466,8 @@ export const SELFHOST_ENV_REFERENCE_MARKDOWN = [ "| `QDRANT_URL` | `src/server.ts:527` |", "| `QUEUE_BACKGROUND_CONCURRENCY` | `src/selfhost/queue-common.ts:130` |", "| `QUEUE_CONCURRENCY` | `src/selfhost/pg-queue.ts:285` |", - "| `QUEUE_DEAD_LETTER_AUTO_RETRY_MAX_EXTRA_ATTEMPTS` | `src/selfhost/queue-common.ts:718` |", - "| `QUEUE_STARTUP_JITTER_MIN_JOBS` | `src/selfhost/queue-common.ts:699` |", + "| `QUEUE_DEAD_LETTER_AUTO_RETRY_MAX_EXTRA_ATTEMPTS` | `src/selfhost/queue-common.ts:721` |", + "| `QUEUE_STARTUP_JITTER_MIN_JOBS` | `src/selfhost/queue-common.ts:702` |", "| `REDIS_URL` | `src/selfhost/preflight.ts:144` |", "| `REVIEW_AUDIT_DIR` | `src/server.ts:572` |", "| `SELFHOST_BUNDLE_ALL` | `scripts/build-selfhost.mjs:13` |", diff --git a/apps/gittensory-ui/src/routes/docs.ai-summaries.tsx b/apps/gittensory-ui/src/routes/docs.ai-summaries.tsx index 21b66797fd..696a16cc26 100644 --- a/apps/gittensory-ui/src/routes/docs.ai-summaries.tsx +++ b/apps/gittensory-ui/src/routes/docs.ai-summaries.tsx @@ -1,7 +1,7 @@ import { createFileRoute } from "@tanstack/react-router"; import { DocsPage } from "@/components/site/docs-page"; -import { Callout, CodeBlock } from "@/components/site/primitives"; +import { Callout } from "@/components/site/primitives"; export const Route = createFileRoute("/docs/ai-summaries")({ head: () => ({ @@ -43,39 +43,41 @@ function AiSummariesDoc() {

Where they appear

+ + 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).

diff --git a/apps/gittensory-ui/src/routes/docs.beta-onboarding.tsx b/apps/gittensory-ui/src/routes/docs.beta-onboarding.tsx index ec5e699469..f55110d058 100644 --- a/apps/gittensory-ui/src/routes/docs.beta-onboarding.tsx +++ b/apps/gittensory-ui/src/routes/docs.beta-onboarding.tsx @@ -149,7 +149,8 @@ gittensory-mcp preflight --login your-login --json`} @gittensory blockers @gittensory duplicate-check @gittensory miner-context -@gittensory next-action`} +@gittensory next-action +@gittensory reviewability`} /> @@ -160,7 +161,7 @@ gittensory-mcp preflight --login your-login --json`}

Repo owner journey

- 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.

    diff --git a/apps/gittensory-ui/src/routes/docs.branch-analysis.tsx b/apps/gittensory-ui/src/routes/docs.branch-analysis.tsx index 43fd1f821b..27d95b0046 100644 --- a/apps/gittensory-ui/src/routes/docs.branch-analysis.tsx +++ b/apps/gittensory-ui/src/routes/docs.branch-analysis.tsx @@ -67,14 +67,27 @@ Authorization: Bearer •••••••• Content-Type: application/json { - "repo": { "owner": "entrius", "repo": "gittensor" }, - "refs": { "base": "main", "head": "feat/scorer-cleanup" }, - "files": [ { "path": "src/scorer.ts", "added": 42, "removed": 8 } ], + "login": "your-github-login", + "repoFullName": "entrius/gittensor", + "baseRef": "main", + "headRef": "feat/scorer-cleanup", + "changedFiles": [ + { "path": "src/scorer.ts", "additions": 42, "deletions": 8, "status": "modified" } + ], "labels": ["scorer", "ready-for-review"], - "linked_issues": [421], - "commits": ["refactor scorer gating", "fix linked-issue projection"] + "linkedIssues": [421], + "commitMessages": ["refactor scorer gating", "fix linked-issue projection"], + "validation": [ + { "command": "npm run typecheck", "status": "passed" } + ] }`} /> +

    + 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. +
    • +
    • + claGateMode — CLA / license-compatibility gate. 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. +
  1. + 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. +
  2. +
  3. + 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). +
  4. +
  5. + CLA / license gate (claGateMode, default off) — + CLA / license-compatibility check. +
  6. +
  7. + Self-authored-linked-issue gate ( + selfAuthoredLinkedIssueGateMode, default advisory) — flags or + blocks a PR whose author also opened the linked issue. +
  8. +
  9. + 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. +
  10. 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() {
  11. block — a dual-model high-confidence consensus defect is - allowed to become a blocker (confirmed contributors only). + allowed to become a blocker.
  12. - 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. +

  13. + 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.

diff --git a/apps/gittensory-ui/src/routes/docs.mcp-clients.tsx b/apps/gittensory-ui/src/routes/docs.mcp-clients.tsx index 10bd2612db..587873c7a4 100644 --- a/apps/gittensory-ui/src/routes/docs.mcp-clients.tsx +++ b/apps/gittensory-ui/src/routes/docs.mcp-clients.tsx @@ -39,11 +39,16 @@ function McpClients() { code={`gittensory-mcp init-client --print codex gittensory-mcp init-client --print claude gittensory-mcp init-client --print cursor -gittensory-mcp init-client --print mcp`} +gittensory-mcp init-client --print mcp +gittensory-mcp init-client --print vscode`} />

--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.

Codex (OpenAI)

@@ -51,8 +56,8 @@ gittensory-mcp init-client --print mcp`} filename="~/.codex/config.toml" lang="toml" code={`[mcp_servers.gittensory] -command = "npx" -args = ["-y", "@jsonbored/gittensory-mcp@latest", "--stdio"]`} +command = "gittensory-mcp" +args = ["--stdio"]`} />

Claude Desktop

@@ -60,10 +65,10 @@ args = ["-y", "@jsonbored/gittensory-mcp@latest", "--stdio"]`} filename="claude_desktop_config.json" lang="json" code={`{ - "mcpServers": { + "mcpServers": { "gittensory": { - "command": "npx", - "args": ["-y", "@jsonbored/gittensory-mcp@latest", "--stdio"] + "command": "gittensory-mcp", + "args": ["--stdio"] } } }`} @@ -74,10 +79,29 @@ args = ["-y", "@jsonbored/gittensory-mcp@latest", "--stdio"]`} filename=".cursor/mcp.json" lang="json" code={`{ - "mcpServers": { + "mcpServers": { "gittensory": { - "command": "npx", - "args": ["-y", "@jsonbored/gittensory-mcp@latest", "--stdio"] + "command": "gittensory-mcp", + "args": ["--stdio"] + } + } +}`} + /> + +

VS Code

+

+ 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. +

GitHub API cache

@@ -157,8 +164,12 @@ GITTENSORY_REVIEW_REPUTATION=false`}

Private per-repo config

- 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:

diff --git a/apps/gittensory-ui/src/routes/docs.self-hosting-releases.tsx b/apps/gittensory-ui/src/routes/docs.self-hosting-releases.tsx index ca02a0f1f3..fa667cbbba 100644 --- a/apps/gittensory-ui/src/routes/docs.self-hosting-releases.tsx +++ b/apps/gittensory-ui/src/routes/docs.self-hosting-releases.tsx @@ -97,8 +97,11 @@ curl http://localhost:8787/ready`}

Custom images

- 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.duplicates — duplicate / superseding-PR detection. Default{" "} @@ -258,22 +278,41 @@ function Tuning() { off.
    • - 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.claMode — CLA / license-acknowledgment gate. 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")