From 1159ee32d88477648457a41f164794e7cdee5893 Mon Sep 17 00:00:00 2001 From: Denis Angell Date: Thu, 14 May 2026 08:44:53 +0200 Subject: [PATCH] fix workflow --- .github/doc-coverage-thresholds.json | 19 +- .github/scripts/doc-agent/package.json | 1 + .../scripts/doc-agent/prompts/audit-file.md | 105 +++++++ .../doc-agent/prompts/document-file.md | 58 +++- .github/scripts/doc-agent/src/audit.ts | 295 ++++++++++++++++++ .github/scripts/doc-agent/src/document.ts | 30 +- .github/scripts/doc-agent/src/index.ts | 11 + .github/scripts/doc-agent/src/pairing.ts | 47 +++ .github/workflows/doc-coverage.yml | 109 ------- .github/workflows/doc-review.yml | 1 + .github/workflows/publish-docs.yml | 113 ++++++- 11 files changed, 647 insertions(+), 142 deletions(-) create mode 100644 .github/scripts/doc-agent/prompts/audit-file.md create mode 100644 .github/scripts/doc-agent/src/audit.ts create mode 100644 .github/scripts/doc-agent/src/pairing.ts delete mode 100644 .github/workflows/doc-coverage.yml diff --git a/.github/doc-coverage-thresholds.json b/.github/doc-coverage-thresholds.json index d9e2ae4b47..e246c081a7 100644 --- a/.github/doc-coverage-thresholds.json +++ b/.github/doc-coverage-thresholds.json @@ -12,18 +12,11 @@ "include/xrpl/nodestore/": 0, "include/xrpl/shamap/": 0, "include/xrpl/resource/": 0, - "src/xrpld/rpc/": 0, - "src/xrpld/overlay/": 0, - "src/xrpld/peerfinder/": 0, - "src/xrpld/consensus/": 0, - "src/xrpld/app/": 0, - "src/libxrpl/": 0 - }, - "schedule": { - "2026-Q3": { "global_minimum": 30 }, - "2026-Q4": { "global_minimum": 40 }, - "2027-Q1": { "global_minimum": 50 }, - "2027-Q2": { "global_minimum": 60 }, - "2027-Q3": { "global_minimum": 70 } + "xrpld/rpc/": 0, + "xrpld/overlay/": 0, + "xrpld/peerfinder/": 0, + "xrpld/consensus/": 0, + "xrpld/app/": 0, + "libxrpl/": 0 } } diff --git a/.github/scripts/doc-agent/package.json b/.github/scripts/doc-agent/package.json index 4eb459b9e3..8bc94e5937 100644 --- a/.github/scripts/doc-agent/package.json +++ b/.github/scripts/doc-agent/package.json @@ -13,6 +13,7 @@ "dev": "tsx --env-file-if-exists=.env src/index.ts", "document": "tsx --env-file-if-exists=.env src/index.ts document", "review": "tsx --env-file-if-exists=.env src/index.ts review", + "audit": "tsx --env-file-if-exists=.env src/index.ts audit", "regen-skills": "tsx --env-file-if-exists=.env src/index.ts regen-skills", "typecheck": "tsc --noEmit", "lint": "biome lint src", diff --git a/.github/scripts/doc-agent/prompts/audit-file.md b/.github/scripts/doc-agent/prompts/audit-file.md new file mode 100644 index 0000000000..647fa98072 --- /dev/null +++ b/.github/scripts/doc-agent/prompts/audit-file.md @@ -0,0 +1,105 @@ +You are auditing a C++ source file in the xrpld (XRP Ledger daemon) +codebase to determine how completely the file's existing Doxygen +documentation reflects the authoritative design intent captured in its +sibling `.ai.md` file. + +This is a read-only audit. Do NOT modify the file. + +## Input + +You receive up to four pieces of context: +- A **primary** C++ file (.h, .hpp, or .cpp) — the file this audit is + scoped to. +- The **primary's `.ai.md`** — authoritative prose about the primary file's + purpose, design, invariants, failure modes, and non-obvious behavior. +- A **partner** file — the header/source counterpart of the primary + (e.g., the `.h` partner of a `.cpp` primary), if one exists. +- The **partner's `.ai.md`** — authoritative prose about the partner + file, if one exists. + +The **primary's `.ai.md`** is the source of truth for what concepts must +be documented for the primary file. The partner's `.ai.md` is context: +it tells you which concepts the project considers a *partner-file* +responsibility (e.g., a "this class is the public contract for X" theme +that naturally lives in the header). Use it to avoid flagging concepts +that the project's own intent assigns to the partner. + +Documentation that satisfies a primary-file concept may live in **either** +the primary file or the partner file — both count as "reflected." Header +docs (the contract) and source docs (the implementation) together form +the full documentation surface, so a concept covered on the header is +not "missed" on the source even if the primary is the source. + +## Task + +For every distinct concept, invariant, design decision, state transition, +ordering constraint, or failure mode in the `.ai.md`, decide: + +1. **Where it belongs.** Each concept has a *correct home* in the + documentation: + - `"header"` — the public *contract*: what the function/class promises + to its caller. Examples: parameter meanings, return-value semantics, + thread-safety guarantees, when an exception is thrown, "this class + represents X". These belong on the declaration in the header. + - `"source"` — the *implementation*: algorithm, ordering of checks, + state transitions, internal invariants, failure modes, the **why** + behind non-obvious choices. These belong on the definition in the + `.cpp` file. + - `"either"` — concepts that are equally at home in either place + (e.g., a file-level `@file` block describing overall role). +2. **Whether it is reflected** in the correct home. A concept is + reflected if a reader of that file's docstrings can understand the + same point without reading the `.ai.md`. Verbatim wording is not + required; equivalent meaning is enough. A concept whose correct home + is the source but only appears on the header is **not** correctly + placed — it should also (or instead) be on the `.cpp` definition. + +A concept is **missed** if it is silent, paraphrased so thinly the +reader cannot rely on the docstring, or documented only in the wrong +home (e.g., implementation depth on the header instead of the source). + +Do **not** flag implementation details the `.ai.md` does not call out as +design-significant. Do **not** invent concepts not in the `.ai.md`. + +## Output + +Respond with **only** a JSON object — no prose, no markdown fences: + +``` +{ + "file": "", + "ai_md_concepts": , + "translated": , + "missed": [ + { + "function": "", + "topic": "", + "home": "header" | "source" | "either", + "current_state": "absent" | "wrong-home" | "thin", + "ai_md_quote": "" + } + ], + "verdict": "rerun" | "leave" +} +``` + +`current_state` values: +- `"absent"`: not mentioned anywhere. +- `"wrong-home"`: present in the partner file but not in the correct home + (e.g., implementation invariant lives on the header but not the source). +- `"thin"`: mentioned in the correct home but too briefly to convey the + point. + +## Verdict rules + +The bar is 100% correctly placed coverage. + +- `"leave"` if and only if `missed` is empty — every `.ai.md` concept is + reflected in its correct home with adequate depth. +- `"rerun"` otherwise. Any missed concept (absent, wrong-home, or thin) + produces a `"rerun"` verdict. + +Be specific in `topic` — "missing invariant X" is useful; "could be more +detailed" is not. Quote the `.ai.md` directly in `ai_md_quote` so a +human can verify the call. Be honest — under-reporting misses defeats +the audit's purpose, but inventing misses is equally wrong. diff --git a/.github/scripts/doc-agent/prompts/document-file.md b/.github/scripts/doc-agent/prompts/document-file.md index 630bd55d90..c1f08e9e17 100644 --- a/.github/scripts/doc-agent/prompts/document-file.md +++ b/.github/scripts/doc-agent/prompts/document-file.md @@ -32,7 +32,24 @@ Read `docs/DOCUMENTATION_STANDARDS.md` for the full specification. Key rules: - Document every public class, struct, function, and enum - Document public methods with `@param`, `@return`, `@throw`/`@throws`, `@note` - Continuation lines for `@param` descriptions indent 4 spaces from the `*` -- Document `.cpp` files only where the algorithm or invariant is non-obvious +- **Documentation layers: contract on the header, implementation on the + `.cpp`.** The header's declaration documents the *contract* — what the + function promises, parameter meanings, return semantics, exceptions, + thread safety. The `.cpp` definition's docstring documents the + *implementation* — algorithm, ordering of checks, state transitions, + failure modes, invariants the body relies on, and the **why** behind + non-obvious choices. These layers are complementary, never duplicative. +- **Whether a `.cpp` function definition gets its own docstring is + decided by the `.ai.md`, not by style.** If the `.ai.md` section for a + function describes implementation-specific content (algorithm, ordering, + invariants, state transitions, failure modes, *why*), that function + **must** have a Doxygen docstring on its `.cpp` definition translating + that prose. Target 5–15 lines for substantive implementation. If the + `.ai.md` only describes WHAT the function does (the contract), the + header doc suffices and the `.cpp` definition does **not** need a + per-function docstring — adding one would just duplicate the header. + Use the `.ai.md` as the authoritative deciding factor, not your own + judgment about what looks documented. - `JAVADOC_AUTOBRIEF = YES` — the first sentence is automatically the brief, so `@brief` is optional @@ -46,10 +63,24 @@ Read `docs/DOCUMENTATION_STANDARDS.md` for the full specification. Key rules: function does — read it. - **Cross-reference test files** to find edge cases worth documenting in `@note` tags. -- **Be terse.** Target 2-5 lines for classes, 1-3 for functions, plus tag - lines. If you need a multi-paragraph essay, the code probably needs help. -- **Wrong docs are worse than no docs.** If you're not sure what the code - does, say so — don't invent. +- **Length matches the layer.** + - **Header declarations** (the contract): be terse. 2–5 lines for + classes, 1–3 lines for free functions and public methods, plus tag + lines. The contract should fit on one screen. + - **`.cpp` function definitions** (the implementation): be thorough. + 5–15 lines for non-trivial functions is normal. Capture algorithm, + ordering of checks, state transitions, failure modes, and the **why**. + The `.ai.md` Authoritative AI Context is your source — translate its + prose into Doxygen on the actual definitions; do not summarize it + away. A function whose `.ai.md` section is three paragraphs should not + end up with a two-line docstring. +- **When you are not sure what the code does, the `.ai.md` is + authoritative.** Use what it says about that function rather than + skipping the docstring. Skipping is not a safe default — it leaves the + reader worse off than translating the `.ai.md`'s explanation onto the + declaration. Inventing facts not in the code, the `.ai.md`, the module + skill, or the tests *is* worse than no docs, but that is the only case + where "no doc" is the right answer for a non-trivial public entity. ## Module Context @@ -273,11 +304,18 @@ explaining something the reader cannot derive from the line. - Do NOT document entities that don't need it (private members with obvious purpose, trivial defaulted constructors, getters whose name is self-explanatory). -- Do NOT read the `.ai.md` file yourself — it is already in your prompt - if one exists for this file. -- If "Authoritative AI Context" is provided in the user prompt, treat it - as the source of truth for the file's intent and behavior. Your task - is to translate that prose into Doxygen on the actual declarations. +- Do NOT read the primary's `.ai.md` file yourself — it is already in + your prompt as "Primary's Authoritative AI Context." +- The partner's `.ai.md` (if any) is also already in your prompt as + "Partner's Authoritative AI Context." Use it to understand what + concepts the project assigns to the partner file, so you don't + duplicate them on the primary. +- The "Primary's Authoritative AI Context" is the source of truth for + this file's intent. Your task is to translate that prose into Doxygen + on the actual declarations in the primary file, in the layer + (header vs. source) where each concept correctly belongs. +- **Only modify the primary file.** Use Read (not Edit) on the partner + file — it is reference context, not an editing target. When you finish, summarize: - How many entities you documented diff --git a/.github/scripts/doc-agent/src/audit.ts b/.github/scripts/doc-agent/src/audit.ts new file mode 100644 index 0000000000..fe1eee5aa1 --- /dev/null +++ b/.github/scripts/doc-agent/src/audit.ts @@ -0,0 +1,295 @@ +/** + * Audit mode: measure how completely each file's Doxygen documentation + * reflects the authoritative design intent in its sibling .ai.md. + * + * For each C++ file under the target that has a .ai.md sibling: + * - Locate its header/source partner (if any) and the partner's .ai.md. + * - Send primary + partner files and both .ai.md files to the agent. + * - Parse a structured JSON verdict per file. + * + * Writes: + * - doc-audit-report.json Aggregated per-file results. + * - doc-audit-report.md Human-readable summary. + */ + +import { existsSync, readdirSync, statSync } from 'node:fs'; +import { readFile, writeFile } from 'node:fs/promises'; +import { join, relative, resolve } from 'node:path'; +import { query } from '@anthropic-ai/claude-agent-sdk'; +import { MODEL, XRPLD_ROOT } from './config.js'; +import { findPartner } from './pairing.js'; +import { loadSystemPrompt } from './prompt-loader.js'; + +const SOURCE_EXTS: ReadonlySet = new Set(['.h', '.hpp', '.cpp']); +const MAX_FILE_CHARS = 24_000; +const MAX_AI_MD_CHARS = 16_000; +const DEFAULT_CONCURRENCY = 5; + +interface AuditMissed { + function: string; + topic: string; + home: 'header' | 'source' | 'either'; + current_state: 'absent' | 'wrong-home' | 'thin'; + ai_md_quote: string; +} + +interface AuditResult { + file: string; + ai_md_concepts: number; + translated: number; + missed: AuditMissed[]; + verdict: 'rerun' | 'leave'; +} + +/** + * Recursively find C++ source files under a target path that have a + * sibling .ai.md. + */ +function findAuditTargets(target: string): string[] { + const absTarget = resolve(XRPLD_ROOT, target); + if (!existsSync(absTarget)) { + throw new Error(`Target does not exist: ${absTarget}`); + } + + const out: string[] = []; + const consider = (file: string): void => { + const dotIdx = file.lastIndexOf('.'); + if (dotIdx === -1) return; + const ext = file.slice(dotIdx); + if (!SOURCE_EXTS.has(ext)) return; + if (!existsSync(`${file}.ai.md`)) return; + out.push(file); + }; + + const stat = statSync(absTarget); + if (stat.isFile()) { + consider(absTarget); + return out; + } + + const walk = (dir: string): void => { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name); + if (entry.isDirectory()) walk(full); + else if (entry.isFile()) consider(full); + } + }; + walk(absTarget); + return out; +} + +/** Read a file, capping at maxChars to keep prompts within budget. */ +async function readCapped(absPath: string, maxChars: number): Promise { + const text = await readFile(absPath, 'utf8'); + if (text.length <= maxChars) return text; + return `${text.slice(0, maxChars)}\n\n... [truncated, ${text.length - maxChars} bytes elided] ...`; +} + +/** Extract a JSON object from a possibly-fenced model response. */ +function extractJson(response: string): AuditResult | null { + const fenced = response.match(/```json\s*([\s\S]*?)```/); + const raw = fenced?.[1] ?? response.match(/(\{[\s\S]*\})/)?.[1]; + if (raw === undefined) return null; + try { + return JSON.parse(raw) as AuditResult; + } catch { + return null; + } +} + +/** Audit a single primary file against its .ai.md and partner context. */ +async function auditFile(absPrimary: string): Promise { + const relPrimary = relative(XRPLD_ROOT, absPrimary); + console.log(`\n=== Auditing: ${relPrimary} ===`); + + const primary = await readCapped(absPrimary, MAX_FILE_CHARS); + const primaryAiMd = await readCapped(`${absPrimary}.ai.md`, MAX_AI_MD_CHARS); + + const absPartner = findPartner(absPrimary); + const relPartner = absPartner === null ? null : relative(XRPLD_ROOT, absPartner); + const partner = absPartner === null ? null : await readCapped(absPartner, MAX_FILE_CHARS); + const partnerAiMdPath = absPartner === null ? null : `${absPartner}.ai.md`; + const partnerAiMd = + partnerAiMdPath !== null && existsSync(partnerAiMdPath) + ? await readCapped(partnerAiMdPath, MAX_AI_MD_CHARS) + : null; + + const partnerBlock = + relPartner === null || partner === null + ? '' + : ` + +## Partner File (${relPartner}) +\`\`\` +${partner} +\`\`\`${ + partnerAiMd === null + ? '' + : ` + +## Partner's .ai.md (${relPartner}.ai.md) +${partnerAiMd}` + }`; + + const userPrompt = `Audit the documentation coverage of this file against its authoritative .ai.md. + +## Primary File (${relPrimary}) +\`\`\` +${primary} +\`\`\` + +## Primary's .ai.md (${relPrimary}.ai.md) +${primaryAiMd}${partnerBlock} + +Output JSON per the schema in the system prompt. The "file" field MUST be +"${relPrimary}".`; + + const systemPrompt = await loadSystemPrompt('audit-file', relPrimary); + + let response = ''; + const result = query({ + prompt: userPrompt, + options: { + model: MODEL, + systemPrompt, + cwd: XRPLD_ROOT, + allowedTools: ['Read', 'Glob', 'Grep'], + permissionMode: 'acceptEdits', + }, + }); + + for await (const message of result) { + if (message.type === 'assistant') { + const content = message.message?.content; + if (Array.isArray(content)) { + for (const block of content) { + if (block.type === 'text') response += block.text; + } + } + } + if (message.type === 'result') { + const cost = message.total_cost_usd?.toFixed(4) ?? '?'; + const inTok = message.usage?.['input_tokens'] ?? 0; + const outTok = message.usage?.['output_tokens'] ?? 0; + console.log(` [Cost: $${cost}, Tokens: ${inTok}/${outTok}]`); + } + } + + const parsed = extractJson(response); + if (parsed === null) { + console.warn(` No JSON output for ${relPrimary}, skipping`); + return null; + } + parsed.file = relPrimary; + return parsed; +} + +/** Render the aggregated markdown report. */ +function buildReport(results: readonly AuditResult[]): string { + const total = results.length; + const reruns = results.filter((r) => r.verdict === 'rerun'); + const totalConcepts = results.reduce((s, r) => s + r.ai_md_concepts, 0); + const totalTranslated = results.reduce((s, r) => s + r.translated, 0); + const overallRate = totalConcepts === 0 ? 0 : Math.round((totalTranslated / totalConcepts) * 100); + + const lines: string[] = [ + '# Documentation Audit Report', + '', + `**Files audited:** ${total}`, + `**Overall translation rate:** ${overallRate}% (${totalTranslated} of ${totalConcepts} .ai.md concepts reflected in docstrings)`, + `**Files flagged for re-run:** ${reruns.length}`, + '', + '## Files flagged for re-run', + '', + ]; + + if (reruns.length === 0) { + lines.push('_None — all audited files passed._', ''); + } else { + lines.push('| File | Translated | Missed | Rate |', '|------|-----------:|-------:|-----:|'); + for (const r of reruns.sort( + (a, b) => + a.translated / Math.max(a.ai_md_concepts, 1) - b.translated / Math.max(b.ai_md_concepts, 1), + )) { + const rate = r.ai_md_concepts === 0 ? 0 : Math.round((r.translated / r.ai_md_concepts) * 100); + lines.push(`| \`${r.file}\` | ${r.translated} | ${r.missed.length} | ${rate}% |`); + } + lines.push('', '## Top missed concepts (sampled)', ''); + for (const r of reruns.slice(0, 10)) { + if (r.missed.length === 0) continue; + lines.push(`### \`${r.file}\``, ''); + for (const m of r.missed.slice(0, 5)) { + lines.push(`- **${m.function}** — ${m.topic}`); + lines.push(` > ${m.ai_md_quote.replace(/\n/g, ' ').slice(0, 200)}`); + } + lines.push(''); + } + } + + return lines.join('\n'); +} + +/** + * Run async work over a list of items with bounded concurrency. Mirrors the + * minimal slice of p-limit we actually need; collects results in input order. + */ +async function mapWithConcurrency( + items: readonly T[], + limit: number, + worker: (item: T, index: number) => Promise, +): Promise { + const results = new Array(items.length); + let next = 0; + + async function pump(): Promise { + while (true) { + const index = next++; + if (index >= items.length) return; + // biome-ignore lint/style/noNonNullAssertion: index < items.length + results[index] = await worker(items[index]!, index); + } + } + + const workers = Array.from({ length: Math.min(limit, items.length) }, pump); + await Promise.all(workers); + return results; +} + +/** + * Audit every C++ file with a .ai.md sibling under the target path. + * + * Concurrency is read from the AUDIT_CONCURRENCY env var (default 5). + */ +export async function auditTarget(target: string): Promise { + const files = findAuditTargets(target); + const concurrency = Number(process.env['AUDIT_CONCURRENCY']) || DEFAULT_CONCURRENCY; + console.log( + `Found ${files.length} file(s) with .ai.md siblings to audit (concurrency=${concurrency}).`, + ); + + let completed = 0; + const raw = await mapWithConcurrency(files, concurrency, async (file) => { + try { + const result = await auditFile(file); + completed++; + console.log(` Progress: ${completed}/${files.length}`); + return result; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.warn(` Audit failed for ${file}: ${message}`); + completed++; + console.log(` Progress: ${completed}/${files.length}`); + return null; + } + }); + const results = raw.filter((r): r is AuditResult => r !== null); + + const report = buildReport(results); + await writeFile('doc-audit-report.md', report); + await writeFile('doc-audit-report.json', JSON.stringify(results, null, 2)); + + const reruns = results.filter((r) => r.verdict === 'rerun').length; + console.log(`\nAudited: ${results.length}/${files.length}`); + console.log(`Flagged for re-run: ${reruns}`); + console.log('Reports: doc-audit-report.md, doc-audit-report.json'); +} diff --git a/.github/scripts/doc-agent/src/document.ts b/.github/scripts/doc-agent/src/document.ts index 760e35527b..e8ac998873 100644 --- a/.github/scripts/doc-agent/src/document.ts +++ b/.github/scripts/doc-agent/src/document.ts @@ -7,6 +7,7 @@ import { readFile } from 'node:fs/promises'; import { join, relative, resolve } from 'node:path'; import { query } from '@anthropic-ai/claude-agent-sdk'; import { MODEL, XRPLD_ROOT } from './config.js'; +import { findPartner } from './pairing.js'; import { loadSystemPrompt } from './prompt-loader.js'; const CPP_EXTENSIONS: ReadonlySet = new Set(['.h', '.hpp', '.cpp']); @@ -65,6 +66,11 @@ async function readAiContext(absPath: string): Promise { /** * Document a single file by running the documentation agent against it. + * + * Inject the partner file's path + its `.ai.md` (if any) into the prompt + * so the agent can apply the "contract on header, implementation on + * source" policy with full visibility into the other half. The agent + * Reads the partner only as reference; only the primary file is edited. */ async function documentFile(absPath: string): Promise { const relPath = relative(XRPLD_ROOT, absPath); @@ -75,15 +81,33 @@ async function documentFile(absPath: string): Promise { const aiContextBlock = aiContext === null ? '' - : `\n\n## Authoritative AI Context (${relPath}.ai.md)\n\nThe following is high-signal prose describing this file's purpose, design,\nand non-obvious behavior. Treat it as the source of truth for intent and\nbehavior. Your job is to translate this into structured Doxygen \`/** */\`\ncomments on the actual declarations.\n\n---\n\n${aiContext}\n---`; + : `\n\n## Primary's Authoritative AI Context (${relPath}.ai.md)\n\nThe following is high-signal prose describing this file's purpose, design,\nand non-obvious behavior. Treat it as the source of truth for intent and\nbehavior. Your job is to translate this into structured Doxygen \`/** */\`\ncomments on the actual declarations.\n\n---\n\n${aiContext}\n---`; + + const absPartner = findPartner(absPath); + const relPartner = absPartner === null ? null : relative(XRPLD_ROOT, absPartner); + const partnerAiContext = absPartner === null ? null : await readAiContext(absPartner); + const partnerBlock = + relPartner === null + ? '' + : `\n\n## Partner File\n\nThis file's partner is **${relPartner}**. Use the Read tool to see its\ncurrent docstrings before deciding what belongs on the primary. A concept\nalready documented on the partner does not need to be duplicated here.\nConversely, an implementation-depth concept currently on the partner that\nbelongs on the source (or vice versa) should be moved.${ + partnerAiContext === null + ? '' + : `\n\n### Partner's Authoritative AI Context (${relPartner}.ai.md)\n\n---\n\n${partnerAiContext}\n---` + }`; const userPrompt = `Add Doxygen documentation to: ${relPath} The file is rooted at ${XRPLD_ROOT}. Use the Read tool to read it, the Edit tool to add documentation, and Glob/Grep to find related tests or callers -when needed. +when needed.${ + relPartner === null + ? '' + : ` Use Read on the partner file (${relPartner}) to see what's already +documented there.` + } -Do not modify any code logic — only add documentation comments.${aiContextBlock}`; +Do not modify any code logic — only add documentation comments to the +primary file (${relPath}). Do NOT edit the partner file.${aiContextBlock}${partnerBlock}`; const result = query({ prompt: userPrompt, diff --git a/.github/scripts/doc-agent/src/index.ts b/.github/scripts/doc-agent/src/index.ts index 962aba599a..6734bc6d55 100644 --- a/.github/scripts/doc-agent/src/index.ts +++ b/.github/scripts/doc-agent/src/index.ts @@ -10,6 +10,7 @@ * doc-agent regen-skills protocol */ +import { auditTarget } from './audit.js'; import { documentTarget } from './document.js'; import { regenSkills } from './regen-skills.js'; import { reviewDiff } from './review.js'; @@ -21,6 +22,9 @@ Usage: doc-agent document Add Doxygen documentation doc-agent review .. Detect doc drift in range doc-agent review --pr Detect doc drift for a PR + doc-agent audit Measure how completely each file's + docstrings reflect its .ai.md intent; + outputs doc-audit-report.{md,json} doc-agent regen-skills Regenerate docs/skills/soul/.md from sibling .ai.md files @@ -62,6 +66,13 @@ async function main(): Promise { return; } + if (mode === 'audit') { + const target = args[0]; + if (target === undefined) printUsageAndExit(1); + await auditTarget(target); + return; + } + if (mode === 'regen-skills') { const moduleName = args[0]; if (moduleName === undefined) printUsageAndExit(1); diff --git a/.github/scripts/doc-agent/src/pairing.ts b/.github/scripts/doc-agent/src/pairing.ts new file mode 100644 index 0000000000..0048799577 --- /dev/null +++ b/.github/scripts/doc-agent/src/pairing.ts @@ -0,0 +1,47 @@ +/** + * Header/source pairing for C++ files in the xrpld layout. + * + * libxrpl: src/libxrpl/.cpp <-> include/xrpl/.h + * xrpld: src/xrpld/.cpp <-> src/xrpld/.h (same directory) + * + * Inline-only headers may have no .cpp partner; standalone .cpp may have + * no .h partner. + */ + +import { existsSync } from 'node:fs'; +import { relative, resolve } from 'node:path'; +import { XRPLD_ROOT } from './config.js'; + +/** + * Compute the partner file path for a given primary, by swapping the + * extension between header/source. Returns null if no candidate exists + * on disk. + */ +export function findPartner(absPrimary: string): string | null { + const rel = relative(XRPLD_ROOT, absPrimary); + const dotIdx = rel.lastIndexOf('.'); + if (dotIdx === -1) return null; + const stem = rel.slice(0, dotIdx); + const ext = rel.slice(dotIdx); + + const candidates: string[] = []; + + if (ext === '.cpp') { + if (stem.startsWith('src/libxrpl/')) { + const tail = stem.slice('src/libxrpl/'.length); + candidates.push(`include/xrpl/${tail}.h`, `include/xrpl/${tail}.hpp`); + } + candidates.push(`${stem}.h`, `${stem}.hpp`); + } else if (ext === '.h' || ext === '.hpp') { + if (stem.startsWith('include/xrpl/')) { + candidates.push(`src/libxrpl/${stem.slice('include/xrpl/'.length)}.cpp`); + } + candidates.push(`${stem}.cpp`); + } + + for (const candidate of candidates) { + const abs = resolve(XRPLD_ROOT, candidate); + if (existsSync(abs) && abs !== absPrimary) return abs; + } + return null; +} diff --git a/.github/workflows/doc-coverage.yml b/.github/workflows/doc-coverage.yml deleted file mode 100644 index 6482ff9259..0000000000 --- a/.github/workflows/doc-coverage.yml +++ /dev/null @@ -1,109 +0,0 @@ -name: Documentation Coverage - -on: - pull_request: - types: [opened, synchronize, reopened] - paths: - - 'include/**' - - 'src/libxrpl/**' - - 'src/xrpld/**' - - 'docs/Doxyfile' - - '.github/doc-coverage-thresholds.json' - - '.github/workflows/doc-coverage.yml' - -concurrency: - group: doc-coverage-${{ github.ref }} - cancel-in-progress: true - -defaults: - run: - shell: bash - -jobs: - coverage: - runs-on: ubuntu-latest - container: ghcr.io/xrplf/ci/tools-rippled-documentation:sha-a8c7be1 - steps: - - name: Checkout PR branch - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - fetch-depth: 0 - - - name: Install coverxygen - run: pip install coverxygen - - - name: Determine new C++ files - id: new-files - uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6 - with: - files: | - include/**/*.h - src/**/*.h - src/**/*.cpp - since_last_remote_commit: false - - - name: Build Doxygen XML (PR branch) - env: - BUILD_DIR: build-pr - run: | - mkdir -p "${BUILD_DIR}" - cd "${BUILD_DIR}" - cmake -Donly_docs=ON .. - cmake --build . --target docs - - - name: Generate coverage report (PR branch) - run: | - python3 -m coverxygen \ - --xml-dir build-pr/docs/xml \ - --src-dir . \ - --output doc-coverage.info \ - --kind class,struct,function,enum,typedef,variable \ - --scope public - - - name: Build Doxygen XML (base branch) - env: - BUILD_DIR: build-base - run: | - git checkout ${{ github.event.pull_request.base.sha }} - mkdir -p "${BUILD_DIR}" - cd "${BUILD_DIR}" - cmake -Donly_docs=ON .. - cmake --build . --target docs || true - git checkout ${{ github.event.pull_request.head.sha }} - - - name: Generate coverage report (base branch) - run: | - if [ -d "build-base/docs/xml" ]; then - python3 -m coverxygen \ - --xml-dir build-base/docs/xml \ - --src-dir . \ - --output base-doc-coverage.info \ - --kind class,struct,function,enum,typedef,variable \ - --scope public || true - fi - - - name: Check coverage thresholds - run: | - BASE_FLAG="" - if [ -f "base-doc-coverage.info" ]; then - BASE_FLAG="--base-lcov-file base-doc-coverage.info" - fi - - NEW_FILES="" - if [ -n "${{ steps.new-files.outputs.added_files }}" ]; then - NEW_FILES="--new-files ${{ steps.new-files.outputs.added_files }}" - fi - - python3 .github/scripts/doc-coverage-check.py \ - --lcov-file doc-coverage.info \ - --threshold-file .github/doc-coverage-thresholds.json \ - --output doc-coverage-report.md \ - ${BASE_FLAG} \ - ${NEW_FILES} || true - - - name: Post coverage report to PR - if: always() - uses: marocchino/sticky-pull-request-comment@67d0dec7b07ed060a405f9b2a64b8ab319fdd7db # v2.9.2 - with: - header: doc-coverage - path: doc-coverage-report.md diff --git a/.github/workflows/doc-review.yml b/.github/workflows/doc-review.yml index 0bc146c91e..3cf62207a3 100644 --- a/.github/workflows/doc-review.yml +++ b/.github/workflows/doc-review.yml @@ -20,6 +20,7 @@ defaults: jobs: review: + if: github.head_ref != 'dangell7/docs' runs-on: ubuntu-latest permissions: pull-requests: write diff --git a/.github/workflows/publish-docs.yml b/.github/workflows/publish-docs.yml index d619be5543..f8ac0ea2d2 100644 --- a/.github/workflows/publish-docs.yml +++ b/.github/workflows/publish-docs.yml @@ -1,6 +1,7 @@ -# This workflow builds the documentation for the repository, and publishes it to -# GitHub Pages when changes are merged into the default branch. -name: Build and publish documentation +# Builds Doxygen XML + HTML in a single pass, runs documentation coverage +# checks on pull requests, and publishes the HTML to GitHub Pages when changes +# land on `develop`. +name: Documentation (build, coverage, publish) on: push: @@ -8,6 +9,8 @@ on: - "develop" paths: - ".github/workflows/publish-docs.yml" + - ".github/doc-coverage-thresholds.json" + - ".github/scripts/doc-coverage-check.py" - "*.md" - "**/*.md" - "docs/**" @@ -17,6 +20,8 @@ on: pull_request: paths: - ".github/workflows/publish-docs.yml" + - ".github/doc-coverage-thresholds.json" + - ".github/scripts/doc-coverage-check.py" - "*.md" - "**/*.md" - "docs/**" @@ -42,9 +47,14 @@ jobs: build: runs-on: ubuntu-latest container: ghcr.io/xrplf/ci/tools-rippled-documentation:sha-a8c7be1 + permissions: + pull-requests: write + contents: read steps: - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 - name: Prepare runner uses: XRPLF/actions/prepare-runner@90f11ee655d1687824fb8793db770477d52afbab @@ -57,21 +67,25 @@ jobs: with: subtract: ${{ env.NPROC_SUBTRACT }} + - name: Install coverxygen + # TODO: drop pin once upstream fixes the 1.8.x regression. + # 1.8.2 crashes on enums when no --exclude is configured: + # AttributeError: 'str' object has no attribute 'iter' + # at coverxygen/__init__.py extract_enum_qualified_name + run: pip install 'coverxygen<1.8' + - name: Check configuration run: | echo 'Checking path.' echo ${PATH} | tr ':' '\n' - echo 'Checking environment variables.' - env | sort - echo 'Checking CMake version.' cmake --version echo 'Checking Doxygen version.' doxygen --version - - name: Build documentation + - name: Build documentation (PR/HEAD) env: BUILD_NPROC: ${{ steps.nproc.outputs.nproc }} run: | @@ -80,6 +94,91 @@ jobs: cmake -Donly_docs=ON .. cmake --build . --target docs --parallel ${BUILD_NPROC} + - name: Determine changed C++ files + if: github.event_name == 'pull_request' + id: changed + uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6 + with: + files: | + include/**/*.h + src/**/*.h + src/**/*.cpp + + - name: Cache base-branch Doxygen XML + if: github.event_name == 'pull_request' + id: base-cache + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + with: + path: build-base/docs/xml + key: doxygen-xml-${{ github.event.pull_request.base.sha }}-${{ hashFiles('docs/Doxyfile') }} + + - name: Build base-branch Doxygen XML (cache miss) + if: github.event_name == 'pull_request' && steps.base-cache.outputs.cache-hit != 'true' + env: + BUILD_NPROC: ${{ steps.nproc.outputs.nproc }} + run: | + git checkout ${{ github.event.pull_request.base.sha }} + mkdir -p build-base + cd build-base + if ! cmake -Donly_docs=ON .. > cmake.log 2>&1; then + echo "::warning::Base-branch cmake configure failed; ratchet disabled for this PR" + cat cmake.log + elif ! cmake --build . --target docs --parallel ${BUILD_NPROC} > build.log 2>&1; then + echo "::warning::Base-branch Doxygen build failed; ratchet disabled for this PR" + tail -50 build.log + fi + cd .. + git checkout ${{ github.event.pull_request.head.sha }} + + - name: Generate coverage report (PR) + if: github.event_name == 'pull_request' + run: | + python3 -m coverxygen \ + --xml-dir ${BUILD_DIR}/docs/xml \ + --src-dir . \ + --output doc-coverage.info \ + --kind class,struct,function,enum,typedef,variable \ + --scope public + + - name: Generate coverage report (base) + if: github.event_name == 'pull_request' + run: | + if [ -d "build-base/docs/xml" ]; then + python3 -m coverxygen \ + --xml-dir build-base/docs/xml \ + --src-dir . \ + --output base-doc-coverage.info \ + --kind class,struct,function,enum,typedef,variable \ + --scope public || true + fi + + - name: Check coverage thresholds + if: github.event_name == 'pull_request' + run: | + BASE_FLAG="" + if [ -f "base-doc-coverage.info" ]; then + BASE_FLAG="--base-lcov-file base-doc-coverage.info" + fi + + NEW_FILES="" + if [ -n "${{ steps.changed.outputs.added_files }}" ]; then + NEW_FILES="--new-files ${{ steps.changed.outputs.added_files }}" + fi + + python3 .github/scripts/doc-coverage-check.py \ + --lcov-file doc-coverage.info \ + --threshold-file .github/doc-coverage-thresholds.json \ + --output doc-coverage-report.md \ + ${BASE_FLAG} \ + ${NEW_FILES} || true + + - name: Post coverage report to PR + if: github.event_name == 'pull_request' && always() + uses: marocchino/sticky-pull-request-comment@67d0dec7b07ed060a405f9b2a64b8ab319fdd7db # v2.9.2 + with: + header: doc-coverage + path: doc-coverage-report.md + - name: Create documentation artifact if: ${{ github.event.repository.visibility == 'public' && github.event_name == 'push' }} uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0