From d9fd7e3284ab60eb225c6db59ef9a57d2e2a3340 Mon Sep 17 00:00:00 2001 From: Jarvis Prime Date: Mon, 9 Mar 2026 14:42:15 +0000 Subject: [PATCH] Phase 7B, 7E, 7D: Contracts, Diagrams, Sysdoc --- contracts.js | 247 +++++++++++++++++++++++++++++++++++++++++ diagrams.js | 144 ++++++++++++++++++++++++ sysdoc.js | 146 ++++++++++++++++++++++++ test/test-contracts.js | 104 +++++++++++++++++ test/test-diagrams.js | 66 +++++++++++ test/test-sysdoc.js | 74 ++++++++++++ 6 files changed, 781 insertions(+) create mode 100644 contracts.js create mode 100644 diagrams.js create mode 100644 sysdoc.js create mode 100644 test/test-contracts.js create mode 100644 test/test-diagrams.js create mode 100644 test/test-sysdoc.js diff --git a/contracts.js b/contracts.js new file mode 100644 index 0000000..ffabe85 --- /dev/null +++ b/contracts.js @@ -0,0 +1,247 @@ +const fs = require('fs'); +const path = require('path'); +const Parser = require('tree-sitter'); +const tsGrammar = require('tree-sitter-typescript').typescript; + +/** + * Phase 7B: Contract Extractor + * Extracts interfaces, type aliases, and enums from TypeScript source files. + * Uses tree-sitter AST parsing — zero inference. + */ + +const parser = new Parser(); +parser.setLanguage(tsGrammar); + +/** Extract contracts from a single TypeScript source file */ +function extractContracts(filePath, relFile) { + const source = fs.readFileSync(filePath, 'utf8'); + const tree = parser.parse(source); + const contracts = []; + + for (const node of tree.rootNode.children) { + // Handle both exported and non-exported declarations + let decl = node; + let isExported = false; + + if (node.type === 'export_statement') { + isExported = true; + // The actual declaration is a child + decl = node.namedChildren.find(c => + c.type === 'interface_declaration' || + c.type === 'type_alias_declaration' || + c.type === 'enum_declaration' + ); + if (!decl) continue; + } + + if (decl.type === 'interface_declaration') { + const name = decl.childForFieldName('name')?.text; + if (!name) continue; + + const contract = { + id: `${relFile}:${name}`, + type: 'Interface', + name, + visibility: isExported ? 'public' : 'private', + }; + + // Extract extends clause + const heritage = decl.children.find(c => c.type === 'extends_type_clause'); + if (heritage) { + contract.extends = []; + for (const child of heritage.namedChildren) { + const typeName = child.type === 'type_identifier' ? child.text : child.text; + if (typeName && typeName !== 'extends') contract.extends.push(typeName); + } + } + + // Extract fields from interface body + const body = decl.childForFieldName('body'); + if (body) { + contract.fields = []; + for (const member of body.namedChildren) { + if (member.type === 'property_signature' || member.type === 'public_field_definition') { + const fieldName = member.childForFieldName('name')?.text; + const typeAnnotation = member.childForFieldName('type') || member.children.find(c => c.type === 'type_annotation'); + let fieldType = 'unknown'; + if (typeAnnotation) { + // type_annotation wraps the actual type; strip the ': ' + fieldType = typeAnnotation.text.replace(/^:\s*/, ''); + } + if (fieldName) { + contract.fields.push({ name: fieldName, type: fieldType }); + } + } else if (member.type === 'method_signature') { + const methodName = member.childForFieldName('name')?.text; + if (methodName) { + contract.fields.push({ name: methodName, type: 'method' }); + } + } + } + } + + contracts.push(contract); + + } else if (decl.type === 'type_alias_declaration') { + const name = decl.childForFieldName('name')?.text; + if (!name) continue; + + contracts.push({ + id: `${relFile}:${name}`, + type: 'TypeAlias', + name, + visibility: isExported ? 'public' : 'private', + }); + + } else if (decl.type === 'enum_declaration') { + const name = decl.childForFieldName('name')?.text; + if (!name) continue; + + const contract = { + id: `${relFile}:${name}`, + type: 'Enum', + name, + visibility: isExported ? 'public' : 'private', + members: [], + }; + + const body = decl.childForFieldName('body'); + if (body) { + for (const member of body.namedChildren) { + if (member.type === 'enum_assignment' || member.type === 'enum_member') { + // enum_assignment: "Debug = 'debug'" — first named child is the identifier + const nameNode = member.namedChildren[0]; + const memberName = nameNode?.text || member.childForFieldName('name')?.text; + if (memberName) contract.members.push(memberName); + } else if (member.type === 'property_identifier') { + if (member.text) contract.members.push(member.text); + } + } + } + + contracts.push(contract); + } + } + + return contracts; +} + +/** + * Extract contracts from all TypeScript files in a subsystem map. + * @param {object} subsystemMap - Result from buildSubsystems + * @param {string} srcRoot - Absolute path to source root + * @returns {object} { contracts, bySubsystem } + */ +function extractAllContracts(subsystemMap, srcRoot) { + const allContracts = []; + const bySubsystem = {}; + + for (const sub of subsystemMap.subsystems) { + bySubsystem[sub.name] = []; + + for (const relFile of sub.files) { + if (!relFile.endsWith('.ts') && !relFile.endsWith('.tsx')) continue; + + const absPath = path.join(srcRoot, relFile); + if (!fs.existsSync(absPath)) continue; + + try { + const contracts = extractContracts(absPath, relFile); + allContracts.push(...contracts); + bySubsystem[sub.name].push(...contracts); + } catch (err) { + // Skip files that fail to parse + } + } + } + + return { contracts: allContracts, bySubsystem }; +} + +/** + * Build a cross-reference map: which contracts are used by which subsystems. + * @param {Array} contracts - All extracted contracts + * @param {object} graph - Graph from GraphStore + * @param {function} relPathFn - relPath function from subsystem.js + * @returns {object} Map of contract name → { definedIn, usedBy[] } + */ +function buildContractXref(contracts, graph, relPathFn) { + const xref = {}; + + // Index contract names → definition location + for (const c of contracts) { + xref[c.name] = { + id: c.id, + type: c.type, + definedIn: c.id.split(':')[0].split('/')[0], + usedBy: new Set(), + }; + } + + // Scan IMPORTS edges for contract references + for (const e of graph.edges) { + if (e.type !== 'IMPORTS') continue; + const dep = e.target.replace('dep:', ''); + // Check if any contract is defined in the imported file + for (const c of contracts) { + const contractFile = c.id.split(':')[0]; + const contractFileNoExt = contractFile.replace(/\.\w+$/, ''); + if (dep === contractFile || dep === contractFileNoExt || dep.endsWith('/' + contractFile) || dep.endsWith('/' + contractFileNoExt)) { + const sourceFile = relPathFn(e.source.split(':')[0]); + const sourceSub = sourceFile.split('/')[0]; + if (sourceSub !== xref[c.name].definedIn) { + xref[c.name].usedBy.add(sourceSub); + } + } + } + } + + // Convert Sets to Arrays + for (const key of Object.keys(xref)) { + xref[key].usedBy = Array.from(xref[key].usedBy); + } + + return xref; +} + +if (require.main === module) { + const srcRoot = process.argv[2]; + if (!srcRoot) { + console.error('Usage: node contracts.js '); + process.exit(1); + } + + // Quick standalone mode: scan all .ts files under srcRoot + const { buildSubsystems, relPath } = require('./subsystem.js'); + const GraphStore = require('./graph.js'); + + // Need a snapshot for subsystem map + const snapshotPath = process.argv[3]; + if (snapshotPath) { + const graph = GraphStore.loadSnapshot(snapshotPath); + const subs = buildSubsystems(graph); + const result = extractAllContracts(subs, srcRoot); + console.log(JSON.stringify(result, null, 2)); + } else { + // Just scan the directory + const files = []; + function walk(dir, rel) { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + const r = rel ? `${rel}/${entry.name}` : entry.name; + if (entry.isDirectory()) walk(full, r); + else if (entry.name.endsWith('.ts') || entry.name.endsWith('.tsx')) { + files.push({ abs: full, rel: r }); + } + } + } + walk(srcRoot, ''); + const all = []; + for (const f of files) { + all.push(...extractContracts(f.abs, f.rel)); + } + console.log(JSON.stringify({ contracts: all }, null, 2)); + } +} + +module.exports = { extractContracts, extractAllContracts, buildContractXref }; diff --git a/diagrams.js b/diagrams.js new file mode 100644 index 0000000..2e9667d --- /dev/null +++ b/diagrams.js @@ -0,0 +1,144 @@ +const fs = require('fs'); +const path = require('path'); + +/** + * Phase 7E: Diagram Generator + * Auto-generates Mermaid diagrams from graph analysis outputs. + */ + +/** + * Generate a subsystem dependency diagram from the dependency matrix. + * @param {object} subsystemMap - Result from buildSubsystems + * @returns {string} Mermaid graph TD source + */ +function generateDependencyDiagram(subsystemMap) { + const lines = ['graph TD']; + const { subsystems, crossCutting, dependencyMatrix } = subsystemMap; + + // Define nodes + for (const sub of subsystems) { + const label = sub.name.charAt(0).toUpperCase() + sub.name.slice(1); + if (sub.kind === 'cross-cutting') { + lines.push(` ${sub.name}["${label} (shared)"]:::shared`); + } else { + lines.push(` ${sub.name}["${label}"]`); + } + } + + // Define edges (skip cross-cutting → cross-cutting to reduce noise) + for (const [key, val] of Object.entries(dependencyMatrix)) { + const [from, to] = key.split('→'); + const weight = val.calls + val.imports; + if (weight === 0) continue; + + // Skip edges between two cross-cutting subsystems + if (crossCutting.includes(from) && crossCutting.includes(to)) continue; + + const label = weight > 5 ? `|${weight}|` : ''; + lines.push(` ${from} -->${label} ${to}`); + } + + // Style + lines.push(' classDef shared fill:#f9f,stroke:#333,stroke-dasharray: 5 5'); + + return lines.join('\n'); +} + +/** + * Generate a sequence diagram from a flow trace. + * @param {object} flowResult - Result from traceFlow + * @returns {string} Mermaid sequenceDiagram source + */ +function generateFlowDiagram(flowResult) { + const lines = ['sequenceDiagram']; + + // Participants in order + for (const sub of flowResult.subsystemSequence) { + const label = sub.charAt(0).toUpperCase() + sub.slice(1); + lines.push(` participant ${sub} as ${label}`); + } + + // Messages from flow steps + let prevSub = null; + for (const step of flowResult.flow) { + if (step.crossedVia && prevSub && prevSub !== step.subsystem) { + const funcName = step.entity.includes(':') ? step.entity.split(':')[1] : step.entity.split('/').pop(); + lines.push(` ${prevSub}->>${step.subsystem}: ${funcName}()`); + } + prevSub = step.subsystem; + } + + // Note cycles + for (const cycle of flowResult.cyclesDetected) { + const sub = null; // We'd need subsystem lookup, just note it + const atFunc = cycle.at.includes(':') ? cycle.at.split(':')[1] : cycle.at; + const backFunc = cycle.backEdgeTo.includes(':') ? cycle.backEdgeTo.split(':')[1] : cycle.backEdgeTo; + lines.push(` Note right of ${flowResult.subsystemSequence[flowResult.subsystemSequence.length - 1]}: Cycle: ${atFunc} ↔ ${backFunc}`); + } + + return lines.join('\n'); +} + +/** + * Generate a contract/class diagram from extracted contracts. + * @param {Array} contracts - Array of contract objects with fields + * @returns {string} Mermaid classDiagram source + */ +function generateContractDiagram(contracts) { + const lines = ['classDiagram']; + + for (const c of contracts) { + if (c.type === 'Interface' || c.type === 'TypeAlias') { + lines.push(` class ${c.name} {`); + if (c.fields) { + for (const f of c.fields) { + lines.push(` +${f.name}: ${f.type}`); + } + } + lines.push(' }'); + lines.push(` <<${c.type}>> ${c.name}`); + + if (c.extends) { + for (const ext of c.extends) { + lines.push(` ${ext} <|-- ${c.name}`); + } + } + } else if (c.type === 'Enum') { + lines.push(` class ${c.name} {`); + lines.push(' <>'); + if (c.members) { + for (const m of c.members) { + lines.push(` ${m}`); + } + } + lines.push(' }'); + } + } + + return lines.join('\n'); +} + +if (require.main === module) { + const cmd = process.argv[2]; + const inputPath = process.argv[3]; + + if (!cmd || !inputPath) { + console.error('Usage: node diagrams.js '); + process.exit(1); + } + + const data = JSON.parse(fs.readFileSync(inputPath, 'utf8')); + + if (cmd === 'deps') { + console.log(generateDependencyDiagram(data)); + } else if (cmd === 'flow') { + console.log(generateFlowDiagram(data)); + } else if (cmd === 'contracts') { + console.log(generateContractDiagram(data.contracts || data)); + } else { + console.error('Unknown command:', cmd); + process.exit(1); + } +} + +module.exports = { generateDependencyDiagram, generateFlowDiagram, generateContractDiagram }; diff --git a/sysdoc.js b/sysdoc.js new file mode 100644 index 0000000..87a3e97 --- /dev/null +++ b/sysdoc.js @@ -0,0 +1,146 @@ +const fs = require('fs'); +const path = require('path'); +const GraphStore = require('./graph.js'); +const { buildSubsystems } = require('./subsystem.js'); +const { extractAllContracts } = require('./contracts.js'); +const { buildFlowIndex, traceFlow } = require('./flow.js'); +const { generateDependencyDiagram, generateFlowDiagram, generateContractDiagram } = require('./diagrams.js'); + +/** + * Phase 7D: Hierarchical Doc Generator + * Orchestrates 7A, 7B, 7C, and 7E to generate a Divio-structured documentation site. + */ + +function generateDocs(graph, srcRoot, outDir, opts = {}) { + const entryPoints = opts.entryPoints || []; + + // 1. Build Subsystems (7A) + const subs = buildSubsystems(graph, { + srcDir: opts.srcDir || '/src/', + minTraffic: opts.minTraffic || 3, + crossCuttingThreshold: opts.crossCuttingThreshold || 0.6 + }); + + // 2. Extract Contracts (7B) + const contractsResult = extractAllContracts(subs, srcRoot); + + // 3. Trace Flows (7C) + const flowIndex = buildFlowIndex(graph, subs); + const flowResults = entryPoints.map(ep => traceFlow(ep, flowIndex)); + + // Initialize output directory structure (Divio) + const dirs = [ + 'reference/subsystems', + 'reference/contracts', + 'reference/modules', + 'explanation', + 'tutorials', + 'how-to', + 'diagrams' + ]; + + for (const d of dirs) { + fs.mkdirSync(path.join(outDir, d), { recursive: true }); + } + + // Generate Reference: System Architecture + const sysArchPath = path.join(outDir, 'reference/system-architecture.md'); + const depDiag = generateDependencyDiagram(subs); + const depDiagPath = 'diagrams/system-deps.mmd'; + fs.writeFileSync(path.join(outDir, depDiagPath), depDiag); + + const sysArchContent = `# System Architecture + +## Subsystems +${subs.subsystems.map(s => `- **${s.name}** (${s.kind}): ${s.entities.modules} modules, ${s.entities.functions} functions`).join('\n')} + +## Cross-Cutting Concerns +${subs.crossCutting.map(c => `- **${c}**`).join('\n')} + +## Dependency Map +\`\`\`mermaid +${depDiag} +\`\`\` +`; + fs.writeFileSync(sysArchPath, sysArchContent); + + // Generate Reference: Subsystem Docs + for (const sub of subs.subsystems) { + const subDocPath = path.join(outDir, `reference/subsystems/${sub.name}.md`); + const subContracts = contractsResult.bySubsystem[sub.name] || []; + + let contractSection = ''; + if (subContracts.length > 0) { + const contractDiag = generateContractDiagram(subContracts); + fs.writeFileSync(path.join(outDir, `diagrams/${sub.name}-contracts.mmd`), contractDiag); + contractSection = `\n## Contracts\n\`\`\`mermaid\n${contractDiag}\n\`\`\`\n`; + } + + const subContent = `# Subsystem: ${sub.name} + +**Kind:** ${sub.kind} +**Files:** ${sub.files.length} + +## Public Exports +${sub.publicExports.length > 0 ? sub.publicExports.map(e => `- \`${e}\``).join('\n') : '*None*'} +${contractSection} +## Files +${sub.files.map(f => `- \`${f}\``).join('\n')} +`; + fs.writeFileSync(subDocPath, subContent); + } + + // Generate Reference: Contracts + const contractDocPath = path.join(outDir, 'reference/contracts/index.md'); + const allContractsDiag = generateContractDiagram(contractsResult.contracts); + fs.writeFileSync(path.join(outDir, 'diagrams/all-contracts.mmd'), allContractsDiag); + fs.writeFileSync(contractDocPath, `# System Contracts\n\n\`\`\`mermaid\n${allContractsDiag}\n\`\`\`\n`); + + // Generate Explanation: Data Flows + const flowsPath = path.join(outDir, 'explanation/data-flows.md'); + let flowsContent = '# Data Flows\n\n'; + + for (let i = 0; i < flowResults.length; i++) { + const fr = flowResults[i]; + if (fr.error) { + flowsContent += `## Flow: ${fr.entryPoint}\n**Error:** ${fr.error}\n\n`; + continue; + } + const flowDiag = generateFlowDiagram(fr); + const diagName = `flow-${i}.mmd`; + fs.writeFileSync(path.join(outDir, `diagrams/${diagName}`), flowDiag); + + flowsContent += `## Flow: ${fr.entryPoint}\n`; + flowsContent += `**Subsystem Sequence:** ${fr.subsystemSequence.join(' → ')}\n\n`; + flowsContent += `\`\`\`mermaid\n${flowDiag}\n\`\`\`\n\n`; + } + fs.writeFileSync(flowsPath, flowsContent); + + return { + subsystems: subs.subsystems.length, + contracts: contractsResult.contracts.length, + flows: flowResults.length, + outDir + }; +} + +if (require.main === module) { + const snapshotPath = process.argv[2]; + const srcRoot = process.argv[3]; + const outDir = process.argv[4]; + const entryPoints = process.argv.slice(5); + + if (!snapshotPath || !srcRoot || !outDir) { + console.error('Usage: node sysdoc.js [entryPoint1] [entryPoint2] ...'); + process.exit(1); + } + + const graph = GraphStore.loadSnapshot(snapshotPath); + const result = generateDocs(graph, srcRoot, outDir, { entryPoints }); + console.log(`Generated docs in ${result.outDir}`); + console.log(`- ${result.subsystems} subsystems`); + console.log(`- ${result.contracts} contracts`); + console.log(`- ${result.flows} flows`); +} + +module.exports = { generateDocs }; diff --git a/test/test-contracts.js b/test/test-contracts.js new file mode 100644 index 0000000..a91c2a2 --- /dev/null +++ b/test/test-contracts.js @@ -0,0 +1,104 @@ +const fs = require('fs'); +const path = require('path'); +const { extractContracts, extractAllContracts, buildContractXref } = require('../contracts.js'); +const GraphStore = require('../graph.js'); +const { buildSubsystems, relPath } = require('../subsystem.js'); + +const FIXTURE_DIR = path.join(__dirname, 'fixtures/system-docs'); +const FIXTURE_SRC = path.join(FIXTURE_DIR, 'src'); +const SNAPSHOT = path.join(FIXTURE_DIR, 'snapshot.json'); +const EXPECTED = JSON.parse(fs.readFileSync(path.join(FIXTURE_DIR, 'expected-contracts.json'), 'utf8')); + +let passed = 0; +let failed = 0; + +function assert(condition, name) { + if (condition) { passed++; console.log(` ✓ ${name}`); } + else { failed++; console.log(` ✗ ${name}`); } +} + +console.log('=== 7B: Contract Extractor Tests ===\n'); + +// Test 1: Interface extraction with fields +console.log('Test 1: Interface extraction'); +const gwContracts = extractContracts(path.join(FIXTURE_SRC, 'gateway/types.ts'), 'gateway/types.ts'); +const gwConfig = gwContracts.find(c => c.name === 'GatewayConfig'); +assert(gwConfig !== undefined, 'Found GatewayConfig'); +assert(gwConfig.type === 'Interface', 'GatewayConfig is Interface'); +assert(gwConfig.fields.length === 2, 'GatewayConfig has 2 fields'); +assert(gwConfig.fields.some(f => f.name === 'sessionKey' && f.type === 'string'), 'Has sessionKey: string'); +assert(gwConfig.fields.some(f => f.name === 'timeout' && f.type === 'number'), 'Has timeout: number'); + +// Test 2: Extends clause +console.log('\nTest 2: Extends clause'); +assert(gwConfig.extends && gwConfig.extends.includes('BaseConfig'), 'GatewayConfig extends BaseConfig'); + +const sessionEntry = gwContracts.find(c => c.name === 'SessionEntry'); +assert(sessionEntry !== undefined, 'Found SessionEntry'); +assert(!sessionEntry.extends, 'SessionEntry has no extends'); + +// Test 3: Type alias +console.log('\nTest 3: Type alias'); +const agentContracts = extractContracts(path.join(FIXTURE_SRC, 'agents/types.ts'), 'agents/types.ts'); +const agentStatus = agentContracts.find(c => c.name === 'AgentStatus'); +assert(agentStatus !== undefined, 'Found AgentStatus'); +assert(agentStatus.type === 'TypeAlias', 'AgentStatus is TypeAlias'); + +// Test 4: Enum with members +console.log('\nTest 4: Enum extraction'); +const configContracts = extractContracts(path.join(FIXTURE_SRC, 'config/types.ts'), 'config/types.ts'); +const logLevel = configContracts.find(c => c.name === 'LogLevel'); +assert(logLevel !== undefined, 'Found LogLevel'); +assert(logLevel.type === 'Enum', 'LogLevel is Enum'); +assert(logLevel.members.length === 4, 'LogLevel has 4 members'); +assert(logLevel.members.includes('Debug'), 'Has Debug member'); +assert(logLevel.members.includes('Error'), 'Has Error member'); + +// Test 5: Visibility +console.log('\nTest 5: Visibility'); +assert(gwConfig.visibility === 'public', 'Exported interface is public'); + +// Test 6: Match expected contracts count +console.log('\nTest 6: Total contract count'); +const graph = GraphStore.loadSnapshot(SNAPSHOT); +const subs = buildSubsystems(graph, { minTraffic: 3, crossCuttingThreshold: 0.6 }); +const allResult = extractAllContracts(subs, FIXTURE_SRC); +assert(allResult.contracts.length === EXPECTED.contracts.length, + `Total contracts: ${allResult.contracts.length} (expected ${EXPECTED.contracts.length})`); + +// Test 7: By-subsystem grouping +console.log('\nTest 7: By-subsystem grouping'); +assert(allResult.bySubsystem['gateway'].length === 2, 'gateway has 2 contracts'); +assert(allResult.bySubsystem['agents'].length === 4, 'agents has 4 contracts'); +assert(allResult.bySubsystem['config'].length === 5, 'config has 5 contracts'); + +// Test 8: Contract cross-reference +console.log('\nTest 8: Contract cross-reference'); +const xref = buildContractXref(allResult.contracts, graph, relPath); +assert(xref['BaseConfig'] !== undefined, 'BaseConfig in xref'); +assert(xref['BaseConfig'].definedIn === 'config', 'BaseConfig defined in config'); +assert(xref['BaseConfig'].usedBy.includes('gateway'), 'BaseConfig used by gateway'); + +// Test 9: Orphan file contracts still extracted +console.log('\nTest 9: Orphan file contracts'); +const schemaContracts = allResult.bySubsystem['config'].filter(c => c.id.startsWith('config/schema.ts')); +assert(schemaContracts.length === 2, 'Orphan schema.ts contracts extracted'); + +// Test 10: OpenClaw scale test +console.log('\nTest 10: OpenClaw scale'); +const fullSnap = path.join(__dirname, '..', 'snapshots', 'openclaw-full.json'); +if (fs.existsSync(fullSnap)) { + const fullGraph = GraphStore.loadSnapshot(fullSnap); + const fullSubs = buildSubsystems(fullGraph); + const start = Date.now(); + const fullResult = extractAllContracts(fullSubs, '/app/src'); + const elapsed = Date.now() - start; + console.log(` OpenClaw: ${fullResult.contracts.length} contracts in ${elapsed}ms`); + assert(fullResult.contracts.length > 50, `Found >50 contracts (${fullResult.contracts.length})`); + assert(elapsed < 30000, `Completed in <30s (${elapsed}ms)`); +} else { + console.log(' (skipped — openclaw-full.json not found)'); +} + +console.log(`\n=== Results: ${passed} passed, ${failed} failed ===`); +process.exit(failed > 0 ? 1 : 0); diff --git a/test/test-diagrams.js b/test/test-diagrams.js new file mode 100644 index 0000000..bbf2199 --- /dev/null +++ b/test/test-diagrams.js @@ -0,0 +1,66 @@ +const fs = require('fs'); +const path = require('path'); +const GraphStore = require('../graph.js'); +const { buildSubsystems } = require('../subsystem.js'); +const { buildFlowIndex, traceFlow } = require('../flow.js'); +const { generateDependencyDiagram, generateFlowDiagram, generateContractDiagram } = require('../diagrams.js'); + +const FIXTURE_DIR = path.join(__dirname, 'fixtures/system-docs'); +const SNAPSHOT = path.join(FIXTURE_DIR, 'snapshot.json'); + +let passed = 0; +let failed = 0; + +function assert(condition, name) { + if (condition) { passed++; console.log(` ✓ ${name}`); } + else { failed++; console.log(` ✗ ${name}`); } +} + +const graph = GraphStore.loadSnapshot(SNAPSHOT); +const subs = buildSubsystems(graph, { minTraffic: 3, crossCuttingThreshold: 0.6 }); +const index = buildFlowIndex(graph, subs); + +console.log('=== 7E: Diagram Generator Tests ===\n'); + +// Test 1: Dependency diagram valid Mermaid +console.log('Test 1: Dependency diagram'); +const depDiag = generateDependencyDiagram(subs); +assert(depDiag.startsWith('graph TD'), 'Starts with graph TD'); +assert(depDiag.includes('gateway'), 'Contains gateway node'); +assert(depDiag.includes('-->'), 'Contains edges'); +assert(depDiag.includes('shared'), 'Contains shared class for cross-cutting'); +assert(!depDiag.includes('undefined'), 'No undefined values'); + +// Test 2: Flow sequence diagram +console.log('\nTest 2: Flow sequence diagram'); +const flow = traceFlow('channels/telegram.ts:onTelegramMessage', index); +const flowDiag = generateFlowDiagram(flow); +assert(flowDiag.startsWith('sequenceDiagram'), 'Starts with sequenceDiagram'); +assert(flowDiag.includes('participant channels'), 'Has channels participant'); +assert(flowDiag.includes('->>'), 'Has message arrows'); +assert(!flowDiag.includes('undefined'), 'No undefined values'); + +// Test 3: Contract class diagram +console.log('\nTest 3: Contract class diagram'); +const contracts = JSON.parse(fs.readFileSync(path.join(FIXTURE_DIR, 'expected-contracts.json'), 'utf8')); +const contractDiag = generateContractDiagram(contracts.contracts); +assert(contractDiag.startsWith('classDiagram'), 'Starts with classDiagram'); +assert(contractDiag.includes('GatewayConfig'), 'Contains GatewayConfig'); +assert(contractDiag.includes('BaseConfig <|-- GatewayConfig'), 'Contains extends relationship'); +assert(contractDiag.includes('<>'), 'Contains enum'); +assert(contractDiag.includes('LogLevel'), 'Contains LogLevel enum'); +assert(!contractDiag.includes('undefined'), 'No undefined values'); + +// Test 4: Empty inputs +console.log('\nTest 4: Edge cases'); +const emptyDeps = generateDependencyDiagram({ subsystems: [], crossCutting: [], dependencyMatrix: {} }); +assert(emptyDeps === 'graph TD\n classDef shared fill:#f9f,stroke:#333,stroke-dasharray: 5 5', 'Empty dep diagram is valid'); + +const emptyFlow = generateFlowDiagram({ flow: [], subsystemSequence: [], cyclesDetected: [] }); +assert(emptyFlow === 'sequenceDiagram', 'Empty flow diagram is valid'); + +const emptyContracts = generateContractDiagram([]); +assert(emptyContracts === 'classDiagram', 'Empty contract diagram is valid'); + +console.log(`\n=== Results: ${passed} passed, ${failed} failed ===`); +process.exit(failed > 0 ? 1 : 0); diff --git a/test/test-sysdoc.js b/test/test-sysdoc.js new file mode 100644 index 0000000..c078707 --- /dev/null +++ b/test/test-sysdoc.js @@ -0,0 +1,74 @@ +const fs = require('fs'); +const path = require('path'); +const { generateDocs } = require('../sysdoc.js'); +const GraphStore = require('../graph.js'); + +const FIXTURE_DIR = path.join(__dirname, 'fixtures/system-docs'); +const FIXTURE_SRC = path.join(FIXTURE_DIR, 'src'); +const SNAPSHOT = path.join(FIXTURE_DIR, 'snapshot.json'); +const OUT_DIR = path.join(__dirname, 'tmp-docs'); + +let passed = 0; +let failed = 0; + +function assert(condition, name) { + if (condition) { passed++; console.log(` ✓ ${name}`); } + else { failed++; console.log(` ✗ ${name}`); } +} + +console.log('=== 7D: Hierarchical Doc Generator Tests ===\n'); + +// Clean and run +if (fs.existsSync(OUT_DIR)) { + fs.rmSync(OUT_DIR, { recursive: true, force: true }); +} + +const graph = GraphStore.loadSnapshot(SNAPSHOT); +const result = generateDocs(graph, FIXTURE_SRC, OUT_DIR, { + entryPoints: ['channels/telegram.ts:onTelegramMessage', 'gateway/session.ts:refreshSession'], + srcDir: '/src/', + minTraffic: 3, + crossCuttingThreshold: 0.6 +}); + +// Test 1: Output directory structure (Divio) +console.log('Test 1: Output directory structure'); +assert(fs.existsSync(path.join(OUT_DIR, 'reference/subsystems')), 'reference/subsystems exists'); +assert(fs.existsSync(path.join(OUT_DIR, 'reference/contracts')), 'reference/contracts exists'); +assert(fs.existsSync(path.join(OUT_DIR, 'explanation')), 'explanation exists'); +assert(fs.existsSync(path.join(OUT_DIR, 'diagrams')), 'diagrams exists'); + +// Test 2: System Architecture overview +console.log('\nTest 2: System Architecture document'); +const sysArch = fs.readFileSync(path.join(OUT_DIR, 'reference/system-architecture.md'), 'utf8'); +assert(sysArch.includes('# System Architecture'), 'Has title'); +assert(sysArch.includes('- **gateway**'), 'Lists gateway subsystem'); +assert(sysArch.includes('- **config**'), 'Lists config cross-cutting concern'); +assert(sysArch.includes('graph TD'), 'Embeds Mermaid dependency diagram'); + +// Test 3: Subsystem Docs +console.log('\nTest 3: Per-Subsystem documents'); +const gatewayDoc = fs.readFileSync(path.join(OUT_DIR, 'reference/subsystems/gateway.md'), 'utf8'); +assert(gatewayDoc.includes('# Subsystem: gateway'), 'Gateway doc has title'); +assert(gatewayDoc.includes('- `handleRequest`'), 'Gateway doc lists public exports'); +assert(gatewayDoc.includes('classDiagram'), 'Gateway doc embeds contract diagram'); +assert(gatewayDoc.includes('GatewayConfig'), 'Gateway doc includes its contracts'); + +// Test 4: Data Flows +console.log('\nTest 4: Data Flows document'); +const flowsDoc = fs.readFileSync(path.join(OUT_DIR, 'explanation/data-flows.md'), 'utf8'); +assert(flowsDoc.includes('# Data Flows'), 'Has title'); +assert(flowsDoc.includes('channels/telegram.ts:onTelegramMessage'), 'Has first flow entry point'); +assert(flowsDoc.includes('gateway/session.ts:refreshSession'), 'Has second flow entry point'); +assert(flowsDoc.includes('sequenceDiagram'), 'Embeds Mermaid sequence diagrams'); +assert(flowsDoc.includes('channels → utils → gateway → config'), 'Shows subsystem sequence'); + +// Test 5: Mermaid Artifacts +console.log('\nTest 5: Diagram files generated'); +assert(fs.existsSync(path.join(OUT_DIR, 'diagrams/system-deps.mmd')), 'system-deps.mmd generated'); +assert(fs.existsSync(path.join(OUT_DIR, 'diagrams/gateway-contracts.mmd')), 'gateway-contracts.mmd generated'); +assert(fs.existsSync(path.join(OUT_DIR, 'diagrams/all-contracts.mmd')), 'all-contracts.mmd generated'); +assert(fs.existsSync(path.join(OUT_DIR, 'diagrams/flow-0.mmd')), 'flow-0.mmd generated'); + +console.log(`\n=== Results: ${passed} passed, ${failed} failed ===`); +process.exit(failed > 0 ? 1 : 0);