Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 132 additions & 0 deletions src/docs/rules/stellar/__tests__/rule-doc-generator.spec.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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);
}
});
});
35 changes: 35 additions & 0 deletions src/docs/rules/stellar/index.ts
Original file line number Diff line number Diff line change
@@ -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/<slug>.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);
}
143 changes: 143 additions & 0 deletions src/docs/rules/stellar/rule-doc-generator.ts
Original file line number Diff line number Diff line change
@@ -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<KBRule['severity'], string> = {
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)} &nbsp;•&nbsp; **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;
}
Loading
Loading