diff --git a/apps/gittensory-ui/src/lib/command-reference.ts b/apps/gittensory-ui/src/lib/command-reference.ts new file mode 100644 index 0000000000..3b366525c9 --- /dev/null +++ b/apps/gittensory-ui/src/lib/command-reference.ts @@ -0,0 +1,7 @@ +// Generated by scripts/gen-command-reference.mjs. Do not edit manually. +// Regenerate via `npm run command-reference`. +export const PUBLIC_COMMAND_LIST = + "@gittensory help\n@gittensory ask\n@gittensory preflight\n@gittensory blockers\n@gittensory duplicate-check\n@gittensory miner-context\n@gittensory next-action\n@gittensory reviewability\n@gittensory repo-fit\n@gittensory packet"; + +export const MAINTAINER_COMMAND_LIST = + "@gittensory queue-summary\n@gittensory confirmed-miners\n@gittensory review-now\n@gittensory needs-author\n@gittensory duplicate-clusters\n@gittensory burden-forecast\n@gittensory intake-health\n@gittensory outcome-patterns\n@gittensory noise-report"; 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 2cafe48e86..a818d7b75f 100644 --- a/apps/gittensory-ui/src/routes/docs.maintainer-install-trust.tsx +++ b/apps/gittensory-ui/src/routes/docs.maintainer-install-trust.tsx @@ -2,6 +2,7 @@ import { createFileRoute, Link } from "@tanstack/react-router"; import { DocsPage } from "@/components/site/docs-page"; import { Callout, CodeBlock, FeatureRow } from "@/components/site/primitives"; +import { MAINTAINER_COMMAND_LIST, PUBLIC_COMMAND_LIST } from "@/lib/command-reference"; export const Route = createFileRoute("/docs/maintainer-install-trust")({ head: () => ({ @@ -137,14 +138,12 @@ POST /v1/repos/:owner/:repo/settings-preview`} Maintainer commands should be treated like privileged review actions. Use them to fetch context on demand, not to create always-on public scoring.

- + +

+ A separate maintainer-only queue-digest command family defaults to maintainers and + collaborators only: +

+

If a command would include private reviewability, private scoreability, duplicate-risk, or contributor-history context, the result must stay in maintainer-visible surfaces. Public diff --git a/apps/gittensory-ui/src/routes/docs.maintainer-workflow.tsx b/apps/gittensory-ui/src/routes/docs.maintainer-workflow.tsx index 7e9b85d3b6..0b78c2931d 100644 --- a/apps/gittensory-ui/src/routes/docs.maintainer-workflow.tsx +++ b/apps/gittensory-ui/src/routes/docs.maintainer-workflow.tsx @@ -3,6 +3,7 @@ import { createFileRoute, Link } from "@tanstack/react-router"; import { DocsPage } from "@/components/site/docs-page"; import { CodeBlock, Callout } from "@/components/site/primitives"; import { WorkflowMirror, type MirroredStep } from "@/components/site/workflow-mirror"; +import { MAINTAINER_COMMAND_LIST, PUBLIC_COMMAND_LIST } from "@/lib/command-reference"; export const Route = createFileRoute("/docs/maintainer-workflow")({ head: () => ({ @@ -149,14 +150,12 @@ GET /v1/repos/:owner/:repo/registration-readiness`} Maintainers (and only maintainers) can trigger context with a comment. Output is scoped to maintainer-visible packets when appropriate.

- + +

+ A separate maintainer-only queue-digest family defaults to maintainers and collaborators + only: +

+ Public-facing comments are sanitized before they leave the Worker. Private scoring, reward, diff --git a/package.json b/package.json index 6e84b405ad..6ff8363098 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,8 @@ "selfhost:postgres:migrate": "tsx scripts/migrate-selfhost-sqlite-to-postgres.ts", "selfhost:env-reference": "node scripts/gen-selfhost-env-reference.mjs", "selfhost:env-reference:check": "node scripts/gen-selfhost-env-reference.mjs --check", + "command-reference": "node scripts/gen-command-reference.mjs", + "command-reference:check": "node scripts/gen-command-reference.mjs --check", "selfhost:validate-observability": "node scripts/validate-observability-configs.mjs", "selfhost:config-lint": "tsx scripts/gittensory-config-lint.ts", "cf-typegen": "wrangler types && perl -pi -e 's/[[:blank:]]+$//' worker-configuration.d.ts", @@ -69,7 +71,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 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 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/gen-command-reference.d.mts b/scripts/gen-command-reference.d.mts new file mode 100644 index 0000000000..d7984b4969 --- /dev/null +++ b/scripts/gen-command-reference.d.mts @@ -0,0 +1,39 @@ +export type CommandCatalogEntry = { + id: string; + title: string; + description: string; +}; + +export type CommandCatalogOptions = { + rootDir?: string; + sourcePath?: string; +}; + +export type WriteCommandReferenceOptions = CommandCatalogOptions & { + outputPath?: string; + check?: boolean; +}; + +export declare const DEFAULT_SOURCE_PATH: string; +export declare const DEFAULT_OUTPUT_PATH: string; + +export declare function extractCatalogEntries(sourceText: string, catalogConstName: string): CommandCatalogEntry[]; + +export declare function renderCommandList(entries: CommandCatalogEntry[]): string; + +export declare function collectCommandCatalogs(options?: CommandCatalogOptions): { + publicCommands: CommandCatalogEntry[]; + maintainerCommands: CommandCatalogEntry[]; +}; + +export declare function renderCommandReferenceModule(catalogs: { + publicCommands: CommandCatalogEntry[]; + maintainerCommands: CommandCatalogEntry[]; +}): string; + +export declare function writeCommandReference(options?: WriteCommandReferenceOptions): { + changed: boolean; + outputPath: string; + publicCommands: CommandCatalogEntry[]; + maintainerCommands: CommandCatalogEntry[]; +}; diff --git a/scripts/gen-command-reference.mjs b/scripts/gen-command-reference.mjs new file mode 100644 index 0000000000..23604f34df --- /dev/null +++ b/scripts/gen-command-reference.mjs @@ -0,0 +1,102 @@ +#!/usr/bin/env node +// Generates the @gittensory command reference from its single source of truth -- +// src/github/commands.ts's PUBLIC_MENTION_COMMAND_CATALOG and MAINTAINER_QUEUE_DIGEST_COMMAND_CATALOG +// -- so docs pages render the real command list instead of hand-copying it (a hand-copy is exactly how +// the 9-command maintainer-only queue-digest family went completely undocumented; see #3046). Mirrors +// scripts/gen-selfhost-env-reference.mjs's generate/--check dual-mode convention. +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; + +export const DEFAULT_SOURCE_PATH = "src/github/commands.ts"; +export const DEFAULT_OUTPUT_PATH = "apps/gittensory-ui/src/lib/command-reference.ts"; + +const PUBLIC_CATALOG_NAME = "PUBLIC_MENTION_COMMAND_CATALOG"; +const MAINTAINER_CATALOG_NAME = "MAINTAINER_QUEUE_DIGEST_COMMAND_CATALOG"; + +// Self-defense floor: 10 public + 9 maintainer-only commands exist today. If extraction ever finds fewer +// than 15 total entries across both catalogs, the extraction regex is almost certainly broken -- fail +// loudly instead of silently writing an empty/tiny reference (mirrors check-docs-drift.mjs's own floor). +const MIN_TOTAL_COMMANDS = 15; + +/** 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: "...", title: "...", description: "..." }` entry from within that slice, in source order. + * Scoped to the named catalog's own slice so two catalogs in the same file never bleed into each + * other's entries. */ +export function extractCatalogEntries(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 entryPattern = /id:\s*"([^"]+)"\s*,\s*title:\s*"([^"]+)"\s*,\s*description:\s*"([^"]+)"/g; + return [...catalogMatch[1].matchAll(entryPattern)].map((match) => ({ + id: match[1], + title: match[2], + description: match[3], + })); +} + +/** Renders a catalog's entries as the same `@gittensory ` line format the docs pages already + * hand-write, newline-joined, in catalog order. */ +export function renderCommandList(entries) { + return entries.map((entry) => `@gittensory ${entry.id}`).join("\n"); +} + +export function collectCommandCatalogs({ rootDir = process.cwd(), sourcePath = DEFAULT_SOURCE_PATH } = {}) { + const sourceText = readFileSync(resolve(rootDir, sourcePath), "utf8"); + const publicCommands = extractCatalogEntries(sourceText, PUBLIC_CATALOG_NAME); + const maintainerCommands = extractCatalogEntries(sourceText, MAINTAINER_CATALOG_NAME); + const total = publicCommands.length + maintainerCommands.length; + if (total < MIN_TOTAL_COMMANDS) { + throw new Error( + `gen-command-reference: extraction found only ${total} total @gittensory command(s) across both catalogs in ${sourcePath} -- expected ${MIN_TOTAL_COMMANDS}+; the extraction regex may be broken`, + ); + } + return { publicCommands, maintainerCommands }; +} + +export function renderCommandReferenceModule({ publicCommands, maintainerCommands }) { + return `// Generated by scripts/gen-command-reference.mjs. Do not edit manually. +// Regenerate via \`npm run command-reference\`. +export const PUBLIC_COMMAND_LIST = + ${JSON.stringify(renderCommandList(publicCommands))}; + +export const MAINTAINER_COMMAND_LIST = + ${JSON.stringify(renderCommandList(maintainerCommands))}; +`; +} + +export function writeCommandReference({ + rootDir = process.cwd(), + sourcePath = DEFAULT_SOURCE_PATH, + outputPath = DEFAULT_OUTPUT_PATH, + check = false, +} = {}) { + const { publicCommands, maintainerCommands } = collectCommandCatalogs({ rootDir, sourcePath }); + const output = renderCommandReferenceModule({ publicCommands, maintainerCommands }); + const absOutput = resolve(rootDir, outputPath); + const current = existsSync(absOutput) ? readFileSync(absOutput, "utf8") : null; + const changed = current !== output; + if (!check && changed) { + mkdirSync(dirname(absOutput), { recursive: true }); + writeFileSync(absOutput, output); + } + return { changed, outputPath, publicCommands, maintainerCommands }; +} + +function main(argv) { + const check = argv.includes("--check"); + const result = writeCommandReference({ check }); + if (check && result.changed) { + process.stderr.write(`gen-command-reference: ${result.outputPath} is stale; run npm run command-reference.\n`); + process.exit(1); + } + process.stdout.write( + `gen-command-reference: ${check ? "checked" : "wrote"} ${result.publicCommands.length} public + ${result.maintainerCommands.length} maintainer-only command references in ${result.outputPath}\n`, + ); +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { + main(process.argv.slice(2)); +} diff --git a/test/unit/gen-command-reference-script.test.ts b/test/unit/gen-command-reference-script.test.ts new file mode 100644 index 0000000000..af8b1717bb --- /dev/null +++ b/test/unit/gen-command-reference-script.test.ts @@ -0,0 +1,157 @@ +import { execFileSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { describe, expect, it } from "vitest"; +import { + type CommandCatalogEntry, + collectCommandCatalogs, + DEFAULT_OUTPUT_PATH, + extractCatalogEntries, + renderCommandList, + renderCommandReferenceModule, +} from "../../scripts/gen-command-reference.mjs"; + +function fixtureRoot(commandsSource: string): string { + const root = mkdtempSync(join(tmpdir(), "gt-command-reference-")); + mkdirSync(join(root, "src", "github"), { recursive: true }); + writeFileSync(join(root, "src", "github", "commands.ts"), commandsSource); + return root; +} + +describe("gen-command-reference script (#3046)", () => { + describe("extractCatalogEntries", () => { + const fixture = ` + const PUBLIC_MENTION_COMMAND_CATALOG = [ + { id: "help", title: "Help", description: "Show help." }, + { id: "ask", title: "Ask", description: "Answer a question." }, + ] as const; + + const MAINTAINER_QUEUE_DIGEST_COMMAND_CATALOG = [ + { id: "queue-summary", title: "Queue summary", description: "Post a queue digest." }, + ] as const; + `; + + it("extracts only the entries from the named catalog, not the other one, in source order", () => { + expect(extractCatalogEntries(fixture, "PUBLIC_MENTION_COMMAND_CATALOG")).toEqual([ + { id: "help", title: "Help", description: "Show help." }, + { id: "ask", title: "Ask", description: "Answer a question." }, + ]); + expect(extractCatalogEntries(fixture, "MAINTAINER_QUEUE_DIGEST_COMMAND_CATALOG")).toEqual([ + { id: "queue-summary", title: "Queue summary", description: "Post a queue digest." }, + ]); + }); + + it("returns an empty array when the named catalog does not exist", () => { + expect(extractCatalogEntries(fixture, "MISSING_CATALOG")).toEqual([]); + }); + }); + + describe("renderCommandList", () => { + it("renders one @gittensory line per entry, newline-joined, in order", () => { + expect( + renderCommandList([ + { id: "help", title: "Help", description: "Show help." }, + { id: "ask", title: "Ask", description: "Answer a question." }, + ]), + ).toBe("@gittensory help\n@gittensory ask"); + }); + + it("renders an empty string for an empty catalog", () => { + expect(renderCommandList([])).toBe(""); + }); + }); + + describe("collectCommandCatalogs", () => { + it("self-defends against a broken extraction regex (fewer than 15 total commands)", () => { + const root = fixtureRoot(` + const PUBLIC_MENTION_COMMAND_CATALOG = [{ id: "only-one", title: "Only", description: "One." }] as const; + const MAINTAINER_QUEUE_DIGEST_COMMAND_CATALOG = [] as const; + `); + + expect(() => collectCommandCatalogs({ rootDir: root })).toThrow(/extraction regex may be broken/); + }); + + it("extracts a fixture with exactly 15 total commands without throwing", () => { + const publicIds = Array.from({ length: 10 }, (_, i) => `{ id: "public-${i}", title: "Public ${i}", description: "Desc ${i}." },`).join("\n"); + const maintainerIds = Array.from({ length: 5 }, (_, i) => `{ id: "maint-${i}", title: "Maint ${i}", description: "Desc ${i}." },`).join("\n"); + const root = fixtureRoot(` + const PUBLIC_MENTION_COMMAND_CATALOG = [ + ${publicIds} + ] as const; + const MAINTAINER_QUEUE_DIGEST_COMMAND_CATALOG = [ + ${maintainerIds} + ] as const; + `); + + const { publicCommands, maintainerCommands } = collectCommandCatalogs({ rootDir: root }); + expect(publicCommands).toHaveLength(10); + expect(maintainerCommands).toHaveLength(5); + }); + + it("extracts the real 10 public + 9 maintainer-only commands from the real repo source", () => { + const { publicCommands, maintainerCommands } = collectCommandCatalogs({ rootDir: process.cwd() }); + + expect(publicCommands).toHaveLength(10); + expect(maintainerCommands).toHaveLength(9); + expect(publicCommands.map((c: CommandCatalogEntry) => c.id)).toEqual([ + "help", + "ask", + "preflight", + "blockers", + "duplicate-check", + "miner-context", + "next-action", + "reviewability", + "repo-fit", + "packet", + ]); + expect(maintainerCommands.map((c: CommandCatalogEntry) => c.id)).toEqual([ + "queue-summary", + "confirmed-miners", + "review-now", + "needs-author", + "duplicate-clusters", + "burden-forecast", + "intake-health", + "outcome-patterns", + "noise-report", + ]); + }); + }); + + describe("renderCommandReferenceModule", () => { + it("renders a generated-file header plus both exported string constants", () => { + const output = renderCommandReferenceModule({ + publicCommands: [{ id: "help", title: "Help", description: "Show help." }], + maintainerCommands: [{ id: "queue-summary", title: "Queue summary", description: "Post a digest." }], + }); + + expect(output).toContain("// Generated by scripts/gen-command-reference.mjs. Do not edit manually."); + expect(output).toContain("npm run command-reference"); + expect(output).toContain('export const PUBLIC_COMMAND_LIST =\n "@gittensory help";'); + expect(output).toContain('export const MAINTAINER_COMMAND_LIST =\n "@gittensory queue-summary";'); + }); + }); + + // Most important regression test in this file: proves the committed generated file on disk is + // byte-identical to what the generator's pure function would produce right now from the real + // src/github/commands.ts. If this fails, either the generated file is stale (run + // `npm run command-reference` and commit it) or the extraction logic changed -- either way, the + // generated file must not be hand-edited to make this test pass. + it("the committed generated file matches what the generator would produce right now (regression guard)", () => { + const rootDir = process.cwd(); + const { publicCommands, maintainerCommands } = collectCommandCatalogs({ rootDir }); + const expected = renderCommandReferenceModule({ publicCommands, maintainerCommands }); + + const actual = readFileSync(resolve(rootDir, DEFAULT_OUTPUT_PATH), "utf8"); + + expect(actual).toBe(expected); + }); + + it("prints a clean summary and exits 0 for the real repo state when run as a subprocess with --check", () => { + const output = execFileSync("node", ["scripts/gen-command-reference.mjs", "--check"], { encoding: "utf8" }); + + expect(output).toMatch(/gen-command-reference: checked \d+ public \+ \d+ maintainer-only command references in/); + }); +});