- extractHelmContracts() in contracts.js: values, services, workloads, deps - Merged Helm contracts into main pipeline (124 contracts on Foxtrot) - diagrams.js: generateContractDiagram now handles Helm types - Sanitized Mermaid class names for Helm contracts - 1601-line contracts index with full classDiagram
341 lines
11 KiB
JavaScript
341 lines
11 KiB
JavaScript
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 <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, extractHelmContracts };
|