Phase 7B, 7E, 7D: Contracts, Diagrams, Sysdoc
This commit is contained in:
247
contracts.js
Normal file
247
contracts.js
Normal file
@@ -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 <srcRoot>');
|
||||
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 };
|
||||
144
diagrams.js
Normal file
144
diagrams.js
Normal file
@@ -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(' <<enumeration>>');
|
||||
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 <deps|flow|contracts> <input.json>');
|
||||
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 };
|
||||
146
sysdoc.js
Normal file
146
sysdoc.js
Normal file
@@ -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 <snapshot.json> <srcRoot> <outDir> [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 };
|
||||
104
test/test-contracts.js
Normal file
104
test/test-contracts.js
Normal file
@@ -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);
|
||||
66
test/test-diagrams.js
Normal file
66
test/test-diagrams.js
Normal file
@@ -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('<<enumeration>>'), '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);
|
||||
74
test/test-sysdoc.js
Normal file
74
test/test-sysdoc.js
Normal file
@@ -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);
|
||||
Reference in New Issue
Block a user