From 2077822a871a8473500e3cd3586c5e2cabc2d885 Mon Sep 17 00:00:00 2001 From: thatcodebabe Date: Sat, 27 Jun 2026 16:28:09 +0100 Subject: [PATCH] feat(docs): Soroban rule documentation generator (#483) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rule documentation drifts from the rules over time. This generates Markdown docs directly from the canonical rule definitions (the knowledge-base `RULES: KBRule[]`), so the docs stay in sync by construction. src/docs/rules/stellar/: - rule-metadata-parser.ts: parse rule metadata — validate required fields and severity/category enums, slugify ids, de-dup by id, sort by category+name; invalid rules are skipped and reported as issues (never throws) - rule-doc-generator.ts: render a Markdown page per rule (id, severity/category badges, summary, rationale, fenced remediation, tags, references) plus a category-grouped index with links - types.ts: ParsedRule / GeneratedDoc / options (re-uses KBRule) - index.ts: barrel + generateStellarRuleDocs() wired to the built-in catalogue - __tests__/rule-doc-generator.spec.ts: 9 tests (parse validate/dedup/sort, page + index rendering, generateDocs paths, real-catalogue generation) Verified: jest 9/9 passing; tsc reports no errors in the new module. --- .../__tests__/rule-doc-generator.spec.ts | 132 ++++++++++++++++ src/docs/rules/stellar/index.ts | 35 +++++ src/docs/rules/stellar/rule-doc-generator.ts | 143 ++++++++++++++++++ .../rules/stellar/rule-metadata-parser.ts | 92 +++++++++++ src/docs/rules/stellar/types.ts | 48 ++++++ 5 files changed, 450 insertions(+) create mode 100644 src/docs/rules/stellar/__tests__/rule-doc-generator.spec.ts create mode 100644 src/docs/rules/stellar/index.ts create mode 100644 src/docs/rules/stellar/rule-doc-generator.ts create mode 100644 src/docs/rules/stellar/rule-metadata-parser.ts create mode 100644 src/docs/rules/stellar/types.ts diff --git a/src/docs/rules/stellar/__tests__/rule-doc-generator.spec.ts b/src/docs/rules/stellar/__tests__/rule-doc-generator.spec.ts new file mode 100644 index 0000000..1646617 --- /dev/null +++ b/src/docs/rules/stellar/__tests__/rule-doc-generator.spec.ts @@ -0,0 +1,132 @@ +/** + * Soroban Rule Documentation Generator — Tests (Issue #483) + */ + +import { + KBRule, + generateDocs, + generateStellarRuleDocs, + parseRules, + renderIndex, + renderRulePage, + slugify, +} from '../index'; + +function rule(overrides: Partial = {}): KBRule { + return { + id: 'soroban-storage-read', + name: 'Soroban Storage Read Optimization', + description: 'Identifies inefficient storage read patterns.', + explanation: 'Repeated reads of the same key waste CPU and gas.', + severity: 'medium', + category: 'Optimization', + remediation: 'let value = env.storage().instance().get(&key);', + documentationUrl: 'docs/rules/general.md', + tags: ['storage', 'gas'], + ...overrides, + }; +} + +describe('slugify', () => { + it('produces a file/URL-safe slug', () => { + expect(slugify('Stellar Network Validation')).toBe('stellar-network-validation'); + expect(slugify('soroban_map.iteration!')).toBe('soroban-map-iteration'); + }); +}); + +describe('parseRules', () => { + it('validates, slugs, dedups, and sorts by category then name', () => { + const { rules, issues } = parseRules([ + rule({ id: 'b-rule', name: 'Beta', category: 'Security' }), + rule({ id: 'a-rule', name: 'Alpha', category: 'Security' }), + rule({ id: 'b-rule', name: 'Beta dup', category: 'Security' }), + ]); + expect(rules.map((r) => r.name)).toEqual(['Alpha', 'Beta']); // sorted, dup dropped + expect(rules[0].slug).toBe('a-rule'); + expect(issues.some((i) => i.code === 'DUPLICATE_ID')).toBe(true); + }); + + it('skips and reports invalid rules without throwing', () => { + const { rules, issues } = parseRules([ + rule(), + { ...rule({ id: 'bad' }), severity: 'extreme' as unknown as KBRule['severity'] }, + { ...rule({ id: 'noname' }), name: '' }, + ]); + expect(rules).toHaveLength(1); + const codes = issues.map((i) => i.code); + expect(codes).toContain('SEVERITY_INVALID'); + expect(codes).toContain('NAME_REQUIRED'); + }); +}); + +describe('renderRulePage', () => { + it('includes the key metadata fields', () => { + const [parsed] = parseRules([rule()]).rules; + const md = renderRulePage(parsed); + expect(md).toContain('# Soroban Storage Read Optimization'); + expect(md).toContain('`soroban-storage-read`'); + expect(md).toContain('Medium'); + expect(md).toContain('Optimization'); + expect(md).toContain('Identifies inefficient storage read patterns.'); + expect(md).toContain('## Remediation'); + expect(md).toContain('docs/rules/general.md'); + }); + + it('fences multi-line / code remediation as a rust block', () => { + const [parsed] = parseRules([ + rule({ remediation: 'let a = 1;\nlet b = 2;' }), + ]).rules; + const md = renderRulePage(parsed); + expect(md).toContain('```rust'); + }); +}); + +describe('renderIndex', () => { + it('groups rules by category with links to their pages', () => { + const { rules } = parseRules([ + rule({ id: 'sec-1', name: 'Sec One', category: 'Security' }), + rule({ id: 'opt-1', name: 'Opt One', category: 'Optimization' }), + ]); + const md = renderIndex(rules); + expect(md).toContain('## Security'); + expect(md).toContain('## Optimization'); + expect(md).toContain('[Sec One](rules/sec-1.md)'); + expect(md).toContain('2 rules'); + }); +}); + +describe('generateDocs', () => { + it('emits an index plus one page per valid rule', () => { + const docs = generateDocs([ + rule({ id: 'r1', name: 'R1' }), + rule({ id: 'r2', name: 'R2' }), + ]); + const paths = docs.map((d) => d.path); + expect(paths).toContain('index.md'); + expect(paths).toContain('rules/r1.md'); + expect(paths).toContain('rules/r2.md'); + expect(docs).toHaveLength(3); + }); + + it('can omit the index and honour a custom pages dir', () => { + const docs = generateDocs([rule({ id: 'r1' })], { + includeIndex: false, + pagesDir: 'pages', + }); + expect(docs).toHaveLength(1); + expect(docs[0].path).toBe('pages/r1.md'); + }); +}); + +describe('generateStellarRuleDocs (built-in catalogue)', () => { + it('generates docs from the knowledge-base RULES', () => { + const docs = generateStellarRuleDocs(); + expect(docs.length).toBeGreaterThan(1); + expect(docs[0].path).toBe('index.md'); + // Every non-index page lives under rules/ and is Markdown. + for (const doc of docs.slice(1)) { + expect(doc.path).toMatch(/^rules\/[a-z0-9-]+\.md$/); + expect(doc.content.startsWith('# ')).toBe(true); + } + }); +}); diff --git a/src/docs/rules/stellar/index.ts b/src/docs/rules/stellar/index.ts new file mode 100644 index 0000000..eeebd94 --- /dev/null +++ b/src/docs/rules/stellar/index.ts @@ -0,0 +1,35 @@ +/** + * Soroban Rule Documentation Generator (Issue #483) + * + * Generates Markdown documentation directly from Soroban rule definitions so + * the docs never drift from the rules. + * + * @example + * ```ts + * import { generateStellarRuleDocs } from 'src/docs/rules/stellar'; + * + * for (const doc of generateStellarRuleDocs()) { + * // doc.path -> 'index.md' | 'rules/.md' + * // doc.content -> Markdown + * writeFileSync(doc.path, doc.content); + * } + * ``` + */ + +import { RULES } from '../../../knowledge-base/stellar/rules-db'; +import { generateDocs } from './rule-doc-generator'; +import { DocGeneratorOptions, GeneratedDoc } from './types'; + +export * from './types'; +export { slugify, parseRules } from './rule-metadata-parser'; +export { renderRulePage, renderIndex, generateDocs } from './rule-doc-generator'; + +/** + * Generate documentation for the project's built-in Soroban rule catalogue + * (the knowledge-base `RULES`). + */ +export function generateStellarRuleDocs( + options: DocGeneratorOptions = {}, +): GeneratedDoc[] { + return generateDocs(RULES, options); +} diff --git a/src/docs/rules/stellar/rule-doc-generator.ts b/src/docs/rules/stellar/rule-doc-generator.ts new file mode 100644 index 0000000..56e56fd --- /dev/null +++ b/src/docs/rules/stellar/rule-doc-generator.ts @@ -0,0 +1,143 @@ +/** + * Soroban Rule Documentation Generator — Markdown renderer (Issue #483) + * + * Turns parsed rule metadata into Markdown documentation pages: one page per + * rule plus a grouped index. Because the content is derived directly from the + * rule definitions, the docs stay in sync with the rules by construction. + */ + +import { parseRules } from './rule-metadata-parser'; +import { + DocGeneratorOptions, + GeneratedDoc, + KBRule, + ParsedRule, +} from './types'; + +const SEVERITY_BADGE: Record = { + critical: '🔴 Critical', + high: '🟠 High', + medium: '🟡 Medium', + low: '🔵 Low', + info: '⚪ Info', +}; + +const DEFAULT_TITLE = 'Soroban Analysis Rules'; +const DEFAULT_PAGES_DIR = 'rules'; + +function severityLabel(severity: KBRule['severity']): string { + return SEVERITY_BADGE[severity] ?? severity; +} + +/** Render the Markdown page for a single rule. */ +export function renderRulePage(rule: ParsedRule): string { + const lines: string[] = []; + + lines.push(`# ${rule.name}`); + lines.push(''); + lines.push(`> **Rule ID:** \`${rule.id}\``); + lines.push('>'); + lines.push(`> **Severity:** ${severityLabel(rule.severity)}  •  **Category:** ${rule.category}`); + lines.push(''); + lines.push('## Summary'); + lines.push(''); + lines.push(rule.description); + lines.push(''); + lines.push('## Why it matters'); + lines.push(''); + lines.push(rule.explanation); + lines.push(''); + lines.push('## Remediation'); + lines.push(''); + // Multi-line remediation is almost always a code snippet; fence it as Rust. + if (rule.remediation.includes('\n') || /[;{}()]/.test(rule.remediation)) { + lines.push('```rust'); + lines.push(rule.remediation); + lines.push('```'); + } else { + lines.push(rule.remediation); + } + lines.push(''); + + if (rule.tags.length > 0) { + lines.push('## Tags'); + lines.push(''); + lines.push(rule.tags.map((t) => `\`${t}\``).join(' ')); + lines.push(''); + } + + if (rule.documentationUrl) { + lines.push('## References'); + lines.push(''); + lines.push(`- [Additional documentation](${rule.documentationUrl})`); + lines.push(''); + } + + lines.push('---'); + lines.push(''); + lines.push('_This page is generated from the rule definition. Do not edit by hand._'); + lines.push(''); + + return lines.join('\n'); +} + +/** Render the index page listing all rules grouped by category. */ +export function renderIndex( + rules: ParsedRule[], + options: DocGeneratorOptions = {}, +): string { + const title = options.title ?? DEFAULT_TITLE; + const pagesDir = options.pagesDir ?? DEFAULT_PAGES_DIR; + const lines: string[] = []; + + lines.push(`# ${title}`); + lines.push(''); + lines.push(`${rules.length} rule${rules.length === 1 ? '' : 's'}, generated from rule definitions.`); + lines.push(''); + + const categories = [...new Set(rules.map((r) => r.category))].sort(); + for (const category of categories) { + const inCategory = rules.filter((r) => r.category === category); + lines.push(`## ${category}`); + lines.push(''); + lines.push('| Rule | Severity | Description |'); + lines.push('| --- | --- | --- |'); + for (const rule of inCategory) { + const link = `[${rule.name}](${pagesDir}/${rule.slug}.md)`; + lines.push(`| ${link} | ${severityLabel(rule.severity)} | ${rule.description} |`); + } + lines.push(''); + } + + lines.push('---'); + lines.push(''); + lines.push('_This index is generated from the rule definitions. Do not edit by hand._'); + lines.push(''); + + return lines.join('\n'); +} + +/** + * Generate all documentation pages from raw rule metadata. + * + * Returns the index page (unless disabled) followed by one page per valid + * rule. Invalid rules are skipped; inspect `parseRules(...).issues` separately + * if you need to surface them. + */ +export function generateDocs( + rawRules: readonly KBRule[], + options: DocGeneratorOptions = {}, +): GeneratedDoc[] { + const pagesDir = options.pagesDir ?? DEFAULT_PAGES_DIR; + const includeIndex = options.includeIndex ?? true; + const { rules } = parseRules(rawRules); + + const docs: GeneratedDoc[] = []; + if (includeIndex) { + docs.push({ path: 'index.md', content: renderIndex(rules, options) }); + } + for (const rule of rules) { + docs.push({ path: `${pagesDir}/${rule.slug}.md`, content: renderRulePage(rule) }); + } + return docs; +} diff --git a/src/docs/rules/stellar/rule-metadata-parser.ts b/src/docs/rules/stellar/rule-metadata-parser.ts new file mode 100644 index 0000000..6ce1448 --- /dev/null +++ b/src/docs/rules/stellar/rule-metadata-parser.ts @@ -0,0 +1,92 @@ +/** + * Soroban Rule Documentation Generator — Metadata Parser (Issue #483) + * + * Validates and normalises raw rule metadata before documentation is rendered. + * Invalid rules are skipped and reported as issues (rather than throwing) so a + * single malformed entry never blocks the whole doc build. + */ + +import { KBRule, ParseIssue, ParseResult, ParsedRule } from './types'; + +const VALID_SEVERITIES: ReadonlyArray = [ + 'critical', + 'high', + 'medium', + 'low', + 'info', +]; + +const VALID_CATEGORIES: ReadonlyArray = [ + 'Security', + 'Optimization', + 'Upgradeability', + 'Quality', +]; + +/** Derive a file/URL-safe slug from a rule id. */ +export function slugify(id: string): string { + return id + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); +} + +function isNonEmptyString(v: unknown): v is string { + return typeof v === 'string' && v.trim().length > 0; +} + +/** + * Parse a raw rule list into validated, slugged rules plus a list of issues. + * + * Rules are de-duplicated by id (first occurrence wins) and the output is + * sorted by category then name for stable, reviewable documentation. + */ +export function parseRules(input: readonly KBRule[]): ParseResult { + const rules: ParsedRule[] = []; + const issues: ParseIssue[] = []; + const seen = new Set(); + + input.forEach((rule, index) => { + const ref = isNonEmptyString(rule?.id) ? rule.id : `#${index}`; + const problems: ParseIssue[] = []; + + if (!isNonEmptyString(rule?.id)) { + problems.push({ ruleRef: ref, message: 'Rule id is required', code: 'ID_REQUIRED' }); + } + if (!isNonEmptyString(rule?.name)) { + problems.push({ ruleRef: ref, message: 'Rule name is required', code: 'NAME_REQUIRED' }); + } + if (!isNonEmptyString(rule?.description)) { + problems.push({ ruleRef: ref, message: 'Rule description is required', code: 'DESCRIPTION_REQUIRED' }); + } + if (!VALID_SEVERITIES.includes(rule?.severity)) { + problems.push({ ruleRef: ref, message: `Invalid severity '${String(rule?.severity)}'`, code: 'SEVERITY_INVALID' }); + } + if (!VALID_CATEGORIES.includes(rule?.category)) { + problems.push({ ruleRef: ref, message: `Invalid category '${String(rule?.category)}'`, code: 'CATEGORY_INVALID' }); + } + + if (problems.length > 0) { + issues.push(...problems); + return; + } + if (seen.has(rule.id)) { + issues.push({ ruleRef: ref, message: 'Duplicate rule id; later definition skipped', code: 'DUPLICATE_ID' }); + return; + } + seen.add(rule.id); + + rules.push({ + ...rule, + tags: Array.isArray(rule.tags) ? rule.tags : [], + slug: slugify(rule.id), + }); + }); + + rules.sort((a, b) => + a.category === b.category ? a.name.localeCompare(b.name) : a.category.localeCompare(b.category), + ); + + return { rules, issues }; +} diff --git a/src/docs/rules/stellar/types.ts b/src/docs/rules/stellar/types.ts new file mode 100644 index 0000000..65053d3 --- /dev/null +++ b/src/docs/rules/stellar/types.ts @@ -0,0 +1,48 @@ +/** + * Soroban Rule Documentation Generator — Types (Issue #483) + * + * Generates documentation directly from rule definitions so the docs cannot + * drift from the rules themselves. Input is the canonical knowledge-base rule + * metadata (`KBRule`); output is a set of Markdown pages. + */ + +import { KBRule } from '../../../knowledge-base/stellar/types'; + +export { KBRule }; + +/** A rule after parsing/validation, with a stable doc slug attached. */ +export interface ParsedRule extends KBRule { + /** URL/file-safe identifier derived from the rule id (used as the page name). */ + slug: string; +} + +/** A problem found while parsing rule metadata. The offending rule is skipped. */ +export interface ParseIssue { + /** Rule id when known, else a positional marker like `#3`. */ + ruleRef: string; + message: string; + code: string; +} + +/** Result of parsing a raw rule list. */ +export interface ParseResult { + rules: ParsedRule[]; + issues: ParseIssue[]; +} + +/** A generated documentation file. */ +export interface GeneratedDoc { + /** Relative path, e.g. `index.md` or `rules/stellar-network-validation.md`. */ + path: string; + content: string; +} + +/** Options controlling documentation generation. */ +export interface DocGeneratorOptions { + /** Title for the index page. Defaults to "Soroban Analysis Rules". */ + title?: string; + /** Directory (relative) the per-rule pages are written under. Default: `rules`. */ + pagesDir?: string; + /** Whether to include the index/landing page. Default: true. */ + includeIndex?: boolean; +}