/** * Reading a capability's SHAPE: its interface, and the tables it declares. * * Extracted from `capability-shape-drift.test.ts` so the gate and the tool that * regenerates its fixture compute the same hash from the same code. Two * implementations of "what is this capability's shape" would drift, and the one * in the GENERATOR drifting is indistinguishable from the gate passing. * * Same split as `module-script-scan.ts` / `no-hand-built-ssh.test.ts` in this * directory, for the same reason. */ import { createHash } from 'node:crypto'; import { existsSync, readFileSync, readdirSync } from 'node:fs'; import { join, resolve } from 'node:path'; import type { CapabilityTableDeclaration, CapabilityTables } from '@celilo/capabilities'; import ts from 'typescript'; /** Walk up to the repo root (the dir holding both modules/ and apps/). */ export function repoRoot(): string { let dir = import.meta.dir; for (let i = 0; i < 8; i++) { if (existsSync(join(dir, 'modules')) && existsSync(join(dir, 'apps'))) return dir; dir = resolve(dir, '..'); } throw new Error('could not locate repo root (no ancestor with modules/ + apps/)'); } const CAPABILITIES_SRC = join(repoRoot(), 'packages', 'capabilities', 'src'); function parse(file: string): ts.SourceFile { return ts.createSourceFile(file, readFileSync(file, 'utf8'), ts.ScriptTarget.Latest, true); } /** * capability name → { interface type name, source file }, read out of * `capability-registry.ts` itself. */ export function capabilitySubjects(): Map { const registryFile = join(CAPABILITIES_SRC, 'capability-registry.ts'); const source = parse(registryFile); // `import type { XCapability } from './x'` → XCapability lives in ./x.ts const fileOfType = new Map(); for (const statement of source.statements) { if (!ts.isImportDeclaration(statement)) continue; const bindings = statement.importClause?.namedBindings; if (!bindings || !ts.isNamedImports(bindings)) continue; const specifier = (statement.moduleSpecifier as ts.StringLiteral).text; for (const element of bindings.elements) { fileOfType.set( element.name.text, join(CAPABILITIES_SRC, `${specifier.replace('./', '')}.ts`), ); } } const subjects = new Map(); for (const statement of source.statements) { if (!ts.isInterfaceDeclaration(statement) || statement.name.text !== 'CapabilityRegistry') { continue; } for (const member of statement.members) { if (!ts.isPropertySignature(member) || !member.name || !member.type) continue; const name = member.name.getText(source); const typeName = member.type.getText(source); const file = fileOfType.get(typeName); if (!file) { // LOUD, not skipped. A member whose type is not a bare imported // identifier — an intersection, a generic, a locally-declared type — // would otherwise drop out of coverage silently, and the gate would go // green having checked one capability fewer. That is the same shape as // the bugs this whole change kept finding: an absence that reads as a // pass. If a registry member legitimately needs a composite type, teach // this resolver about it rather than letting it vanish. throw new Error( `capability-shape: '${name}' has type '${typeName}', which is not a bare identifier imported into capability-registry.ts, so its shape cannot be hashed. Teach capabilitySubjects() how to resolve it.`, ); } subjects.set(name, { typeName, file }); } } return subjects; } /** The interface, re-printed from its AST with comments dropped. */ function printedInterface(file: string, typeName: string): string { const source = parse(file); const printer = ts.createPrinter({ removeComments: true }); for (const statement of source.statements) { if (ts.isInterfaceDeclaration(statement) && statement.name.text === typeName) { return printer.printNode(ts.EmitHint.Unspecified, statement, source); } } throw new Error(`${typeName} not found in ${file}`); } // --- REQUEST / RESULT TYPES ------------------------------------------------- // // The interface hash above references its request and result types by NAME // only, so an edit to `PublishStaticSiteRequest` never moved a hash. That is // the exact hole celilo#1361 fell through: `sourceDir` was removed from three // request types, no contract version moved, and every new consumer build died // against the deployed providers. Everything below resolves those types and // hashes their members, so a request-contract change trips the gate like any // interface change — and the recorded member text lets the regenerate script // tell a breaking member diff (bump the MAJOR) from an additive one (minor). interface DeclaredType { file: string; decl: ts.InterfaceDeclaration | ts.TypeAliasDeclaration; source: ts.SourceFile; } /** All interface and type-alias declarations in one capabilities src file. */ function declaredTypes(file: string): Map { const out = new Map(); if (!existsSync(file)) return out; const source = parse(file); for (const statement of source.statements) { if (ts.isInterfaceDeclaration(statement) || ts.isTypeAliasDeclaration(statement)) { out.set(statement.name.text, { file, decl: statement, source }); } } return out; } /** Named relative imports of one file: local name → the file it came from. */ function importsOf(file: string): Map { const out = new Map(); if (!existsSync(file)) return out; const source = parse(file); for (const statement of source.statements) { if (!ts.isImportDeclaration(statement)) continue; const specifier = (statement.moduleSpecifier as ts.StringLiteral).text; if (!specifier.startsWith('./')) continue; const target = join(CAPABILITIES_SRC, `${specifier.replace('./', '')}.ts`); const bindings = statement.importClause?.namedBindings; if (!bindings || !ts.isNamedImports(bindings)) continue; for (const element of bindings.elements) { out.set(element.name.text, target); } } return out; } /** Type-reference identifiers reachable from one declaration. */ function referencedTypeNames(decl: ts.Declaration): Set { const out = new Set(); const walk = (node: ts.Node): void => { if (ts.isTypeReferenceNode(node)) { if (ts.isIdentifier(node.typeName)) out.add(node.typeName.text); } else if (ts.isExpressionWithTypeArguments(node)) { if (ts.isIdentifier(node.expression)) out.add(node.expression.text); } ts.forEachChild(node, (child) => walk(child)); }; walk(decl); return out; } const shapePrinter = ts.createPrinter({ removeComments: true }); /** One member of a request type, in the form the baseline records and diffs. */ export interface RequestMemberShape { name: string; optional: boolean; /** Printed member type, comments stripped. */ type: string; } /** One request or result type a capability's interface can reach. */ export interface RequestTypeShape { name: string; /** 'alias' types hash as one synthetic `_self` member. */ kind: 'interface' | 'alias'; members: RequestMemberShape[]; } function membersOfDecl(decl: DeclaredType): RequestMemberShape[] { if (ts.isTypeAliasDeclaration(decl.decl)) { return [ { name: '_self', optional: false, type: shapePrinter.printNode(ts.EmitHint.Unspecified, decl.decl.type, decl.source), }, ]; } const members: RequestMemberShape[] = []; for (const member of decl.decl.members) { if (!member.name) continue; const typeNode = ts.isPropertySignature(member) || ts.isMethodSignature(member) ? member.type : undefined; members.push({ name: member.name.getText(decl.source), optional: Boolean(member.questionToken), type: typeNode ? shapePrinter.printNode(ts.EmitHint.Unspecified, typeNode, decl.source) : shapePrinter.printNode(ts.EmitHint.Unspecified, member, decl.source), }); } return members.sort((a, b) => a.name.localeCompare(b.name)); } const declsCache = new Map>(); function declsOf(file: string): Map { let cached = declsCache.get(file); if (!cached) { cached = declaredTypes(file); declsCache.set(file, cached); } return cached; } function allSrcFiles(): string[] { return readdirSync(CAPABILITIES_SRC) .filter((f) => f.endsWith('.ts') && !f.endsWith('.test.ts')) .map((name) => join(CAPABILITIES_SRC, name)); } /** * Resolve a referenced type name from the file that references it: the file's * own declarations first, then its named imports, then a unique export * anywhere in packages/capabilities/src. An unresolved name is not contract * surface this repo owns (primitives, DOM types) and is ignored; an ambiguous * one would hash the wrong declaration, so it refuses rather than guessing. */ function resolveName(name: string, fromFile: string): DeclaredType | undefined { const local = declsOf(fromFile).get(name); if (local) return local; const imported = importsOf(fromFile).get(name); if (imported) { const found = declsOf(imported).get(name); if (found) return found; } const hits: DeclaredType[] = []; for (const file of allSrcFiles()) { const found = declsOf(file).get(name); const exported = found?.decl.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword); if (found && exported) hits.push(found); } if (hits.length === 1) return hits[0]; if (hits.length > 1) { throw new Error( `capability-shape: type name '${name}' is declared in ${hits.length} files (${hits.map((h) => h.file).join(', ')}), so its request shape cannot be resolved unambiguously. Rename one of them.`, ); } return undefined; } /** * Every request/result type reachable from a capability's interface. Names * that resolve to nothing in packages/capabilities/src (primitives, DOM * types) are ignored — they are not contract surface this repo owns. */ export function requestTypes(capability: string): RequestTypeShape[] { const subject = capabilitySubjects().get(capability); if (!subject) throw new Error(`${capability} is not in CapabilityRegistry`); const start = parse(subject.file); let iface: ts.InterfaceDeclaration | undefined; for (const statement of start.statements) { if (ts.isInterfaceDeclaration(statement) && statement.name.text === subject.typeName) { iface = statement; } } if (!iface) throw new Error(`${subject.typeName} not found in ${subject.file}`); const visited = new Set(); const shapes: RequestTypeShape[] = []; const queue: Array<{ name: string; fromFile: string }> = [ { name: subject.typeName, fromFile: subject.file }, ]; while (queue.length > 0) { const { name, fromFile } = queue.pop() as { name: string; fromFile: string }; if (visited.has(name)) continue; visited.add(name); const decl = resolveName(name, fromFile); if (!decl) continue; const members = membersOfDecl(decl); shapes.push({ name, kind: ts.isTypeAliasDeclaration(decl.decl) ? 'alias' : 'interface', members, }); const refs = referencedTypeNames(decl.decl); for (const ref of refs) { if (visited.has(ref)) continue; const resolved = resolveName(ref, decl.file); if (resolved) queue.push({ name: ref, fromFile: resolved.file }); else visited.add(ref); } } return shapes.sort((a, b) => a.name.localeCompare(b.name)); } /** * The canonical request-shape text: the baseline's `requests` field, the diff * input for the regenerate guard, and part of the shape hash. Sorted so member * or type reordering is not a shape change. */ export function requestsKey(capability: string): string { return canonical(requestTypes(capability)); } /** Every `* satisfies CapabilityTables` const exported from a capability's module. */ export async function tablesOf(file: string): Promise { const loaded = (await import(file)) as Record; const out: Record = {}; for (const [exportName, value] of Object.entries(loaded)) { if (!exportName.endsWith('_TABLES') || typeof value !== 'object' || value === null) continue; for (const [key, decl] of Object.entries(value as Record)) { out[key] = decl as CapabilityTableDeclaration; } } return out; } /** Order-independent so a reordered literal is not a shape change. */ function canonical(value: unknown): string { if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`; if (value && typeof value === 'object') { const entries = Object.entries(value as Record) .map(([k, v]) => `${k}:${canonical(v)}`) .sort(); return `{${entries.join(',')}}`; } return JSON.stringify(value); } export async function shapeHash(capability: string): Promise { const subject = capabilitySubjects().get(capability); if (!subject) throw new Error(`${capability} is not in CapabilityRegistry`); const iface = printedInterface(subject.file, subject.typeName); const tables = canonical(await tablesOf(subject.file)); // Requests fold into the SAME hash as the interface: a request-contract // change must move the version exactly like an interface change, and the // regenerate script records `requests` beside the hash so it can tell the // breaking member diff from an additive one (see classifyRequestChange). const requests = requestsKey(capability); return createHash('sha256') .update(`${iface}\n--tables--\n${tables}\n--requests--\n${requests}`) .digest('hex'); } /** How a capability's request-shape text changed from one recording to the next. */ export type RequestChangeClassification = | { kind: 'unchanged' } | { kind: 'additive'; note: string } | { kind: 'breaking'; reasons: string[] }; /** * Classify a request-shape change between a baseline recording and the * current code. ONLY these are breaking (the bump rule in * capability-contract.ts): * - a member was removed (old providers reject the request; old consumers * stop compiling) * - a member went optional → required (consumers that omitted it break) * - a member's type changed (cannot tell widened from narrowed here, so * treated as breaking; pass --non-breaking to the regenerate script * when review agrees the widening is safe) * Everything else — adding an optional member, relaxing required → optional — * is additive and takes a minor per the bump rules. */ export function classifyRequestChange( before: RequestTypeShape[], after: RequestTypeShape[], ): RequestChangeClassification { const beforeByName = new Map(before.map((t) => [t.name, t])); const afterByName = new Map(after.map((t) => [t.name, t])); const reasons: string[] = []; const additions: string[] = []; for (const [name, oldType] of beforeByName) { const newType = afterByName.get(name); if (!newType) { // A type leaving the reachable set is an interface change, which the // interface hash and its own bump cover. Not a request-level break. continue; } const oldMembers = new Map(oldType.members.map((m) => [m.name, m])); const newMembers = new Map(newType.members.map((m) => [m.name, m])); for (const [memberName, oldMember] of oldMembers) { const newMember = newMembers.get(memberName); if (!newMember) { reasons.push(`${name}.${memberName} was removed`); } else { if (!oldMember.optional && newMember.optional) { additions.push(`${name}.${memberName} became optional`); } else if (oldMember.optional && !newMember.optional) { reasons.push(`${name}.${memberName} went optional → required`); } else if (oldMember.type !== newMember.type) { reasons.push( `${name}.${memberName} changed type (${oldMember.type} → ${newMember.type})`, ); } } } for (const newMember of newMembers.values()) { if (!oldMembers.has(newMember.name)) { if (newMember.optional) additions.push(`${name}.${newMember.name} added (optional)`); else reasons.push(`${name}.${newMember.name} added as REQUIRED`); } } } if (reasons.length > 0) return { kind: 'breaking', reasons }; if (additions.length > 0) return { kind: 'additive', note: additions.join('; ') }; return { kind: 'unchanged' }; }