/** * Module Types Generator * * Pure codegen: given a validated ModuleManifest, produces the content of * `/celilo/types.d.ts` — a TypeScript declaration file exposing * a typed `Config` interface derived from the manifest's * `variables.owns` and `variables.imports` arrays. * * This file is intentionally I/O-free. The CLI command in * `cli/commands/module-types.ts` handles reading manifests and writing * output files; this module is responsible for the translation only, so * unit tests can exercise every branch without touching the filesystem. * * See `openspec/changes/hook-api-v2/proposal.md` D2 for the design rationale. */ import type { ModuleManifest, VariableDeclare, VariableImport } from '../manifest/schema'; /** * Convert a kebab-case module ID to a PascalCase type name. * * "lunacycle" → "Lunacycle" * "dns-external" → "DnsExternal" * "my-fancy-app" → "MyFancyApp" */ export function moduleIdToPascalCase(id: string): string { return id .split('-') .map((part) => (part.length === 0 ? '' : part[0].toUpperCase() + part.slice(1))) .join(''); } /** * Translate a variable's declared `type:` field into a TypeScript type * expression. Per HOOK_API_V2 D2, arrays and objects get generic * unknown-element types for now; richer item-level typing can be added * later without breaking any committed `types.d.ts`. */ export function variableTypeToTs(type: VariableDeclare['type']): string { switch (type) { case 'string': return 'string'; case 'integer': case 'number': return 'number'; case 'boolean': return 'boolean'; case 'array': return 'unknown[]'; case 'object': return 'Record'; } } /** * Determine whether a declared variable is guaranteed to have a value at * hook execution time. A variable is guaranteed if: * - It's marked `required: true`, OR * - It has a `default:` value (the default guarantees presence). * * `infrastructure` sources are treated as guaranteed when the variable is * marked required; Celilo's infrastructure-variable-resolver populates * them before hooks run and fails fast if it can't. */ function isVariableGuaranteed(variable: VariableDeclare): boolean { if (variable.required) return true; if (variable.default !== undefined) return true; return false; } function isImportGuaranteed(_variable: VariableImport): boolean { // Imports are sourced from another module's capability. The capability // must be declared in requires.capabilities (enforced by // validateVariableSources), and at hook invocation time Celilo's // resolver populates the value. We treat imports as guaranteed — // Phase 3 will add an explicit runtime pre-flight check. return true; } /** * Escape a string so it can appear safely inside a JSDoc block comment. * Converts sequences that would terminate the comment. */ function escapeJsDoc(text: string): string { return text.replace(/\*\//g, '* /'); } function formatJsDocLine(description: string | undefined): string[] { if (!description) return []; return [` /** ${escapeJsDoc(description)} */`]; } /** * Render a single interface field: optional JSDoc + name + optionality + type. */ function renderField( name: string, type: string, optional: boolean, description: string | undefined, ): string[] { const doc = formatJsDocLine(description); const optionalMark = optional ? '?' : ''; return [...doc, ` ${name}${optionalMark}: ${type};`]; } /** * Sort variables deterministically so regenerating against the same * manifest always produces byte-identical output. Manifest author order * is the canonical order — stable across runs, meaningful to readers. * `variables.owns` is already ordered by the YAML parser; we just * preserve it. */ /** * Generate the full `/celilo/types.d.ts` content for a manifest. * * Deterministic: calling this twice with the same input produces * byte-identical output, so the drift check can compare against the * committed file. */ export function generateModuleTypes(manifest: ModuleManifest): string { const typeName = `${moduleIdToPascalCase(manifest.id)}Config`; const lines: string[] = []; lines.push('// Generated from manifest.yml by `celilo module types generate`.'); lines.push('// Do not edit by hand. Run the command again after changing `variables.owns`.'); lines.push('// CI enforces this file stays in sync via `celilo module types check`.'); lines.push(''); lines.push('/**'); lines.push(` * Configuration shape for the ${manifest.name} module.`); if (manifest.description) { lines.push(' *'); for (const descLine of manifest.description.split('\n')) { lines.push(` * ${descLine}`); } } lines.push(' *'); lines.push(' * Fields are derived from `variables.owns` and `variables.imports` in the'); lines.push(' * module manifest. Optional fields (marked with `?`) correspond to variables'); lines.push(' * that are neither `required: true` nor have a `default:` value.'); lines.push(' *'); lines.push(' * Emitted as a `type` alias rather than an `interface` so it satisfies the'); lines.push(' * `Record` constraint on `defineHook`: TypeScript'); lines.push(' * gives type aliases an implicit index signature but withholds one from'); lines.push(' * interfaces (which can be declaration-merged). See v2/issues.'); lines.push(' */'); const ownsFields: string[] = []; const importsFields: string[] = []; for (const variable of manifest.variables.owns) { const tsType = variableTypeToTs(variable.type); const optional = !isVariableGuaranteed(variable); const rendered = renderField(variable.name, tsType, optional, variable.description); ownsFields.push(...rendered); } for (const imp of manifest.variables.imports) { // Imports don't carry type info in the manifest schema — they're // raw references to capability data. Treat as `unknown` so consumers // cast as needed. A future enhancement could look up the capability's // `data_schema` to infer a more precise type. const optional = !isImportGuaranteed(imp); const rendered = renderField( imp.name, 'unknown', optional, `Imported from ${imp.source}: ${imp.from}`, ); importsFields.push(...rendered); } if (ownsFields.length === 0 && importsFields.length === 0) { // `= {}` is the banned "any non-nullish value" type, not "no keys" — and an // empty config surface means exactly no keys. lines.push('// (No variables declared — module has no typed config surface)'); lines.push(`export type ${typeName} = Record;`); lines.push(''); return lines.map((line) => line.trimEnd()).join('\n'); } lines.push(`export type ${typeName} = {`); if (ownsFields.length > 0) { lines.push(' // Module-owned variables (from variables.owns)'); lines.push(...ownsFields); } if (importsFields.length > 0) { if (ownsFields.length > 0) lines.push(''); lines.push(' // Imported capability variables (from variables.imports)'); lines.push(...importsFields); } lines.push('};'); lines.push(''); return lines.map((line) => line.trimEnd()).join('\n'); }