import { LEGACY_HASH_ALGORITHM, sameHashIdentity } from '../index-builder/content-hash.js' import { readFileSync } from 'node:fs' import { splitFrontmatter } from '@agentskit/cross-platform' import type { DocBridgeConfigV1 } from '../config/schema.js' import type { IndexPublication } from '../schemas/doc-bridge-index.js' import { runDocumentationStandardV1, type DocumentationConformanceReportV1, } from '../conformance/documentation-standard-v1.js' import { buildDocBridgeIndex } from '../index-builder/build-index.js' import { scanAgentCorpus } from '../index-builder/scan-corpus.js' import { scanHumanDocRecords } from '../index-builder/human-adapters/index.js' import { IndexNotFoundError, loadDocBridgeIndex } from '../query/load-index.js' import { checkIndexReproducibility } from '../discovery/reproducibility.js' import { checkReferenceFindings, formatPendingReferenceFindings, type PendingReferenceFinding, type ReferenceFindingsCheck, type ReferenceFindingsReason, } from './reference-findings.js' export type GateId = | 'index-freshness' | 'index-reproducible' | 'human-guide-links' | 'okf-type' | 'docs-style' | 'documentation-standard-v1' | 'reference-findings' const RESERVED_GATE_IDS = new Set(['link-rot', 'routing-currency', 'bootstrap-size']) export type GateResult = { readonly id: GateId readonly ok: boolean readonly message: string readonly expected?: string readonly actual?: string readonly details?: DocumentationConformanceReportV1 /** `reference-findings` only: the advisory `pendingCount` and every pending finding, complete. */ readonly pendingCount?: number /** `reference-findings` only: why it failed, as a stable code (`pending` when findings are pending). */ readonly reasonCode?: ReferenceFindingsReason readonly baseRevision?: string readonly findings?: readonly PendingReferenceFinding[] } export type GateRunResult = { readonly ok: boolean readonly results: GateResult[] } export const runGate = ( root: string, config: DocBridgeConfigV1, id: GateId, ): GateResult => { if (id === 'documentation-standard-v1') { const details = runDocumentationStandardV1(root, config) return { id, ok: details.ok, message: details.ok ? 'Documentation Standard v1 required rules pass' : `${details.summary.required.failed} Documentation Standard v1 required rule(s) failed`, expected: 'all required rules pass or have approved exceptions', actual: `${details.summary.required.passed} passed, ${details.summary.required.failed} failed, ${details.summary.required.excepted} excepted`, details, } } if (id === 'reference-findings') return runReferenceFindingsGate(root, config) if (id === 'human-guide-links') return runHumanGuideLinksGate(root, config) if (id === 'okf-type') return runOkfTypeGate(root, config) if (id === 'docs-style') return runDocsStyleGate(root, config) if (id === 'index-reproducible') { /* * Opt-in, and in no preset: a repository that regenerates the index on every run has nothing * to enforce, and turning this on for everyone would fail gates that were passing for good * reasons. `gates.include: ['index-reproducible']` is how a repository that commits the index * says it wants the guarantee enforced rather than merely reported. */ let index try { index = loadDocBridgeIndex(root, config) } catch (error) { return { id, ok: false, message: error instanceof Error ? error.message : String(error) } } if (index.contentHashAlgo === LEGACY_HASH_ALGORITHM) return { id, ok: false, message: 'Legacy index is migration-only. Run: ak-docs index to regenerate with sha256-semantic-v1.' } const result = checkIndexReproducibility( root, config.index?.outFile ?? '.doc-bridge/index.json', index.knowledge.map((entry) => entry.path), ) if (!result.checked) { return { id, ok: true, message: result.skipped === 'index-untracked' ? 'Index is not committed, so nothing has to reproduce it' : 'Not a Git checkout; reproducibility was not checked', } } if (result.ignored.length === 0) { return { id, ok: true, message: 'Every indexed path is committed' } } const sample = result.ignored.slice(0, 5).map((entry) => `${entry.path} (${entry.rule})`) return { id, ok: false, message: `${result.ignored.length} indexed path(s) are ignored by Git, so a clean checkout builds a different index. Rebuild the index (scans skip ignored paths) or add them to safety.exclude.`, expected: 'every indexed path is committed', actual: sample.join(', '), } } if (id !== 'index-freshness') throw new Error(`Unsupported gate "${id}"`) let current: import('../schemas/doc-bridge-index.js').DocBridgeIndexV1 try { current = loadDocBridgeIndex(root, config) } catch (error) { if (error instanceof IndexNotFoundError) { return { id, ok: false, message: error.message } } return { id, ok: false, message: error instanceof Error ? error.message : String(error) } } if (current.contentHashAlgo === LEGACY_HASH_ALGORITHM) return { id, ok: false, message: 'Legacy index is migration-only. Run: ak-docs index to regenerate with sha256-semantic-v1.' } const next = buildDocBridgeIndex({ root, config, write: false, hashAlgorithm: current.contentHashAlgo }).index if (!sameHashIdentity(current, next)) { return { id, ok: false, message: `Index is stale under ${current.contentHashAlgo}. Run: ak-docs index`, expected: next.contentHash, actual: current.contentHash, } } return { id, ok: true, message: 'Index is fresh', expected: next.contentHash, actual: current.contentHash } } /* * Opt-in, and in no preset (ADR 0025): the advisory stays advisory unless a maintainer names this * gate in `gates.include`. Once named it fails closed, so a missing base never reads as a pass. */ const runReferenceFindingsGate = (root: string, config: DocBridgeConfigV1): GateResult => referenceFindingsGateResult(checkReferenceFindings(root, config)) /** * The gate result for a completed check. The underlying error text goes to stderr only: it can * name local paths, so it never reaches the JSON result, an Action report or the index. */ export const referenceFindingsGateResult = (check: ReferenceFindingsCheck): GateResult => { const expected = 'no pending reference findings since the base snapshot' if (check.ok) { return { id: 'reference-findings', ok: true, message: `No pending reference findings since base ${check.baseRevision}`, expected, actual: '0 pending', pendingCount: 0, baseRevision: check.baseRevision, findings: [] } } if (check.detail) process.stderr.write(`[ak-docs] reference-findings (${check.reasonCode}): ${check.detail}\n`) const findings = check.findings ?? [] return { id: 'reference-findings', ok: false, message: [ `Publication blocked: ${check.reason}`, ...formatPendingReferenceFindings(findings), ...(findings.length ? ['Fix the cited documentation, or publish anyway with ak-docs index --allow-pending-findings.'] : []), ].join('\n'), expected, actual: check.findings ? `${findings.length} pending` : 'not analyzed', reasonCode: check.reasonCode, ...(check.baseRevision ? { baseRevision: check.baseRevision } : {}), ...(check.findings ? { pendingCount: findings.length, findings } : {}), } } /** True when the configuration selects the `reference-findings` gate. */ export const referenceFindingsGateEnabled = (config: DocBridgeConfigV1): boolean => Boolean(config.gates?.include?.includes('reference-findings')) && !config.gates?.exclude?.includes('reference-findings') export type PublicationDecision = | { readonly allowed: true; readonly gate?: GateResult; readonly publication?: IndexPublication } | { readonly allowed: false; readonly gate: GateResult } /** * Whether `ak-docs index` may publish. Without the gate configured nothing is checked. With it, a * failing gate blocks unless the operator passed `--allow-pending-findings`, and then the index * records that it was published past the gate. */ export const publicationDecision = ( root: string, config: DocBridgeConfigV1, options: { readonly allowPendingFindings?: boolean } = {}, ): PublicationDecision => (referenceFindingsGateEnabled(config) ? decidePublication(runGate(root, config, 'reference-findings'), options) : { allowed: true }) /** Applies the override policy to a `reference-findings` gate result that is already computed. */ export const decidePublication = (gate: GateResult, options: { readonly allowPendingFindings?: boolean } = {}): PublicationDecision => { if (gate.ok) return { allowed: true, gate } if (!options.allowPendingFindings) return { allowed: false, gate } return { allowed: true, gate, publication: { pendingReferenceFindings: { override: 'allow-pending-findings', // A code, never the message: the message can be anything a check reported. reason: gate.reasonCode ?? 'diff-unavailable', ...(gate.pendingCount === undefined ? {} : { pendingCount: gate.pendingCount }), ...(gate.baseRevision ? { baseRevision: gate.baseRevision.slice(0, 128) } : {}), }, }, } } const runHumanGuideLinksGate = (root: string, config: DocBridgeConfigV1): GateResult => { const urls = new Set(scanHumanDocRecords(root, config).map((doc) => doc.url)) const index = buildDocBridgeIndex({ root, config, write: false }).index const localHumanDocs = Object.values(index.handoffs ?? {}) .map((handoff) => handoff.humanDoc) .filter((url): url is string => typeof url === 'string' && url.length > 0) .filter((url) => !/^https?:\/\//.test(url)) const missing = localHumanDocs.filter((url, i, all) => !urls.has(url) && all.indexOf(url) === i) if (missing.length) { return { id: 'human-guide-links', ok: false, message: `Broken humanDoc links: ${missing.join(', ')}`, expected: 'all local humanDoc links resolve', actual: `${missing.length} broken`, } } return { id: 'human-guide-links', ok: true, message: `Resolved ${localHumanDocs.length} humanDoc link(s)`, } } const frontmatterType = (markdown: string): string | undefined => { return splitFrontmatter(markdown).frontmatter?.match(/^type:\s*['"]?([^'"\n#]+)['"]?/m)?.[1]?.trim() } const frontmatterField = (markdown: string, field: string): string | undefined => { return splitFrontmatter(markdown).frontmatter?.match(new RegExp(`^${field}:\\s*['"]?([^'\"\\n#]+)['"]?`, 'm'))?.[1]?.trim() } const runOkfTypeGate = (root: string, config: DocBridgeConfigV1): GateResult => { const required = config.corpus.agent.okf?.requireType ?? config.gates?.preset === 'strict' if (!required) return { id: 'okf-type', ok: true, message: 'OKF type frontmatter not required' } const allowed = config.corpus.agent.okf?.allowedTypes const bad = scanAgentCorpus(root, config) .filter((doc) => doc.path !== config.corpus.agent.index) .map((doc) => ({ path: doc.path, type: frontmatterType(readFileSync(doc.absPath, 'utf8')) })) .filter((doc) => !doc.type || (allowed && !allowed.includes(doc.type))) if (bad.length) { return { id: 'okf-type', ok: false, message: `Missing or invalid OKF type frontmatter: ${bad.map((doc) => doc.path).join(', ')}`, expected: allowed?.length ? `type: ${allowed.join(' | ')}` : 'type frontmatter', actual: `${bad.length} invalid`, } } return { id: 'okf-type', ok: true, message: 'All agent docs have OKF type frontmatter' } } type DocsStyleRule = | 'title' | 'purpose' | 'audience' | 'task-orientation' | 'examples' | 'owner-source' | 'no-stale-wording' type DocsStyleProfile = | 'google-dev-docs' | 'playbook-okf' | 'playbook-okf-soft' | 'title-only' | 'custom' const DOCS_STYLE_RULES: Record, DocsStyleRule[]> = { 'google-dev-docs': [ 'title', 'purpose', 'audience', 'task-orientation', 'examples', 'no-stale-wording', ], /** Full playbook OKF style (strict). */ 'playbook-okf': ['title', 'purpose', 'owner-source', 'no-stale-wording'], /** Soft profile for large existing OKF corpora (title + no stale placeholders). */ 'playbook-okf-soft': ['title', 'no-stale-wording'], 'title-only': ['title'], } const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value) const docsStyleOptions = ( config: DocBridgeConfigV1, ): { profile: DocsStyleProfile; required: DocsStyleRule[] } => { const raw = config.gates?.options?.['docs-style'] if (!isRecord(raw)) { if (config.gates?.preset === 'playbook') { return { profile: 'playbook-okf-soft', required: DOCS_STYLE_RULES['playbook-okf-soft'] } } return { profile: 'playbook-okf', required: DOCS_STYLE_RULES['playbook-okf'] } } const profile = raw.profile === 'google-dev-docs' || raw.profile === 'playbook-okf' || raw.profile === 'playbook-okf-soft' || raw.profile === 'title-only' || raw.profile === 'custom' ? raw.profile : 'playbook-okf' const customRequired = Array.isArray(raw.required) ? raw.required.filter((rule): rule is DocsStyleRule => [ 'title', 'purpose', 'audience', 'task-orientation', 'examples', 'owner-source', 'no-stale-wording', ].includes(String(rule)), ) : [] return { profile, required: profile === 'custom' ? customRequired : DOCS_STYLE_RULES[profile], } } const missingStyleRules = (markdown: string, rules: readonly DocsStyleRule[]): DocsStyleRule[] => { const checks: Record = { title: /^#\s+\S+/m.test(markdown), purpose: Boolean(frontmatterField(markdown, 'purpose') || /^##\s+(Purpose|Goal)\b/im.test(markdown)), audience: Boolean(frontmatterField(markdown, 'audience') || /^##\s+Audience\b/im.test(markdown)), 'task-orientation': /^##\s+(How to|Usage|Workflow|Tasks?)\b/im.test(markdown), examples: /^##\s+Examples?\b/im.test(markdown) || /```[\s\S]*?```/.test(markdown), 'owner-source': Boolean( frontmatterField(markdown, 'owner') || frontmatterField(markdown, 'source') || /^(Owner|Source):\s+\S+/im.test(markdown), ), 'no-stale-wording': !/\b(TODO|TBD|coming soon|eventually|placeholder)\b/i.test(markdown), } return rules.filter((rule) => !checks[rule]) } const runDocsStyleGate = (root: string, config: DocBridgeConfigV1): GateResult => { const { profile, required } = docsStyleOptions(config) if (!required.length) { return { id: 'docs-style', ok: true, message: 'No docs-style rules configured' } } const bad = scanAgentCorpus(root, config) .filter((doc) => doc.path !== config.corpus.agent.index) .map((doc) => ({ path: doc.path, missing: missingStyleRules(readFileSync(doc.absPath, 'utf8'), required), })) .filter((doc) => doc.missing.length > 0) if (bad.length) { return { id: 'docs-style', ok: false, message: `Docs style issues: ${bad .map((doc) => `${doc.path} (${doc.missing.join(', ')})`) .join('; ')}`, expected: `${profile}: ${required.join(', ')}`, actual: `${bad.length} file(s) with style issues`, } } return { id: 'docs-style', ok: true, message: `All agent docs pass ${profile} docs-style rules`, } } export const runGates = ( root: string, config: DocBridgeConfigV1, ids?: readonly GateId[], ): GateRunResult => { const results = (ids ?? resolveGateIds(config)).map((id) => runGate(root, config, id)) return { ok: results.every((result) => result.ok), results } } export const resolveGateIds = (config: DocBridgeConfigV1): GateId[] => { const preset = config.gates?.preset ?? 'minimal' const ids = new Set( preset === 'minimal' ? ['index-freshness'] : preset === 'strict' ? ['index-freshness', 'human-guide-links', 'okf-type', 'docs-style'] : preset === 'playbook' ? // okf-type only — docs-style soft is opt-in via include (large corpora vary) ['index-freshness', 'okf-type'] : ['index-freshness', 'human-guide-links'], ) for (const id of config.gates?.include ?? []) { if (RESERVED_GATE_IDS.has(id)) { process.emitWarning(`Gate "${id}" is reserved and has no runtime implementation; it was not executed.`, { code: 'AK_DOCS_RESERVED_GATE', }) continue } ids.add(id as GateId) } for (const id of config.gates?.exclude ?? []) { if (!RESERVED_GATE_IDS.has(id)) ids.delete(id as GateId) } return [...ids] }