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 = {}; const parseErrors = []; 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) { parseErrors.push({ file: relFile, error: err.message }); } } } return { contracts: allContracts, bySubsystem, parseErrors }; } /** * 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; } /** * Extract contracts from Helm chart data (from extract-helm.js). * Produces contracts in the same shape as TypeScript ones for diagram/xref compatibility. * @param {Array} helmCharts - From discoverCharts() * @returns {object} { contracts, bySubsystem } */ function extractHelmContracts(helmCharts) { const allContracts = []; const bySubsystem = {}; for (const c of helmCharts) { const sub = c.dir.split('/')[0] || 'root'; if (!bySubsystem[sub]) bySubsystem[sub] = []; // Chart itself as a contract (its values.yaml is the API surface) if (c.values.keys.length > 0) { const contract = { id: `${c.dir}/values.yaml:${c.chart.name}`, type: 'HelmValues', name: `${c.chart.name} (values)`, visibility: 'public', fields: c.values.keys.map(k => ({ name: k.name, type: k.type || 'unknown', })), }; allContracts.push(contract); bySubsystem[sub].push(contract); } // Services produced by this chart const services = c.templates.resources.filter(r => r.kind === 'Service'); if (services.length > 0) { const contract = { id: `${c.dir}/templates:${c.chart.name}-services`, type: 'HelmServices', name: `${c.chart.name} (services)`, visibility: 'public', fields: services.map(s => ({ name: s.name === '(templated)' ? `${c.chart.name}-svc` : s.name, type: 'Service', })), }; // Add port info from interactions const ports = c.interactions.filter(i => i.type === 'port' && i.target !== '0'); for (const p of ports) { contract.fields.push({ name: `port:${p.target}`, type: 'port' }); } allContracts.push(contract); bySubsystem[sub].push(contract); } // Deployments/StatefulSets/DaemonSets as workload contracts const workloads = c.templates.resources.filter(r => ['Deployment', 'StatefulSet', 'DaemonSet', 'Rollout', 'CronJob', 'Job'].includes(r.kind) ); if (workloads.length > 0) { const contract = { id: `${c.dir}/templates:${c.chart.name}-workloads`, type: 'HelmWorkloads', name: `${c.chart.name} (workloads)`, visibility: 'public', fields: workloads.map(w => ({ name: w.name === '(templated)' ? `${c.chart.name}-${w.kind.toLowerCase()}` : w.name, type: w.kind, })), }; allContracts.push(contract); bySubsystem[sub].push(contract); } // External interactions as dependency contracts const extInteractions = c.interactions.filter(i => i.type === 'k8s-service' || i.type === 'config-ref'); if (extInteractions.length > 0) { const contract = { id: `${c.dir}/templates:${c.chart.name}-deps`, type: 'HelmDependencies', name: `${c.chart.name} (external deps)`, visibility: 'public', fields: extInteractions.map(i => ({ name: i.target, type: i.type, })), }; allContracts.push(contract); bySubsystem[sub].push(contract); } } return { contracts: allContracts, bySubsystem }; } 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, extractHelmContracts };