diff --git a/bun.lock b/bun.lock index b75b43d0..df0d34ce 100644 --- a/bun.lock +++ b/bun.lock @@ -7,7 +7,7 @@ "dependencies": { "@atomic-ehr/fhir-canonical-manager": "0.0.24", "@atomic-ehr/fhirschema": "0.0.14", - "brace-expansion": "^5.0.8", + "brace-expansion": "^5.0.9", "mustache": "^4.2.0", "picocolors": "^1.1.1", "yaml": "^2.9.0", @@ -26,7 +26,7 @@ }, }, "overrides": { - "brace-expansion": "^5.0.8", + "brace-expansion": "^5.0.9", "esbuild": ">=0.28.1", "minimatch": ">=10.2.3", "picomatch": ">=4.0.4", @@ -282,7 +282,7 @@ "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], - "brace-expansion": ["brace-expansion@5.0.8", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg=="], + "brace-expansion": ["brace-expansion@5.0.9", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg=="], "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], diff --git a/package.json b/package.json index 73459a3b..cb13661f 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,7 @@ "dependencies": { "@atomic-ehr/fhir-canonical-manager": "0.0.24", "@atomic-ehr/fhirschema": "0.0.14", - "brace-expansion": "^5.0.8", + "brace-expansion": "^5.0.9", "mustache": "^4.2.0", "picocolors": "^1.1.1", "yaml": "^2.9.0", @@ -69,7 +69,7 @@ "minimatch": ">=10.2.3", "rollup": ">=4.59.0", "smol-toml": ">=1.6.1", - "brace-expansion": "^5.0.8", + "brace-expansion": "^5.0.9", "picomatch": ">=4.0.4", "esbuild": ">=0.28.1" } diff --git a/src/api/generate-config.ts b/src/api/generate-config.ts new file mode 100644 index 00000000..c6642e98 --- /dev/null +++ b/src/api/generate-config.ts @@ -0,0 +1,679 @@ +/** + * Declarative generation config + * + * Everything `APIBuilder` exposes as a fluent JS call is expressible here as plain data, so a + * generation pipeline can be described by a JSON file instead of a hand-written script. This is + * what the `atomic-codegen generate --config ` command consumes. + * + * Two rules make the format safe to hand to an automated caller: + * + * - Unknown keys are rejected. A misspelled key that was silently ignored would produce an empty + * or half-generated output tree that looks like a successful run. + * - Path-valued fields are resolved against the config file's own directory, never the process + * working directory, so a generated config in a temporary directory behaves the same wherever + * the command is invoked from. + */ + +import * as Path from "node:path"; +import type { PreprocessContext } from "@atomic-ehr/fhir-canonical-manager"; +import type { CSharpGeneratorOptions } from "@root/api/writer-generator/csharp/csharp"; +import type { PythonGeneratorOptions } from "@root/api/writer-generator/python/writer"; +import type { IrConf, TreeShakeRule } from "@root/typeschema/ir/types"; +import type { CodegenLogManager } from "@root/utils/log"; +import { APIBuilder, type GenerationReport, type LocalStructureDefinitionConfig } from "./builder"; +import type { IntrospectionWriterOptions } from "./writer-generator/introspection"; +import type { TypeScriptOptions } from "./writer-generator/typescript/writer"; +import type { WriterOptions } from "./writer-generator/writer"; + +/** The only config schema version this build understands. */ +export const GENERATE_CONFIG_VERSION = 1; + +export type GenerateConfigPackage = { + name: string; + version: string; +}; + +export type GenerateConfigLocalStructureDefinitions = { + package: GenerateConfigPackage; + /** Directory of StructureDefinition files, resolved against the config file's directory. */ + path: string; + dependencies?: GenerateConfigPackage[]; +}; + +export type GenerateConfigBuilder = { + /** Identifies the builder in reports and error messages. Must be unique within one config. */ + name: string; + fromPackages?: GenerateConfigPackage[]; + fromPackageRefs?: string[]; + /** Local `.tgz` archives, resolved against the config file's directory. */ + localTgzPackages?: string[]; + localStructureDefinitions?: GenerateConfigLocalStructureDefinitions[]; + typeSchema?: IrConf; + introspection?: Partial; + typescript?: Partial; + python?: Partial; + csharp?: Partial; + /** Output directory, resolved against the config file's directory. */ + outputTo: string; + cleanOutput?: boolean; + throwException?: boolean; +}; + +export type GenerateConfigOptions = { + /** Custom FHIR package registry URL. */ + registry?: string; + ignorePackageIndex?: boolean; + dropCanonicalManagerCache?: boolean; + /** Default for every builder that does not set its own `throwException`. */ + throwException?: boolean; + /** + * Pin a dependency to one version across the whole closure. + * + * Every package whose `package.json` declares one of these dependencies has the declared + * version rewritten to the given one before the canonical manager resolves it, so two + * packages asking for different versions of the same dependency cannot pull both in. This is + * the declarative form of the `preprocessPackage` callback. + */ + forceDependencies?: Record; +}; + +export type GenerateConfig = { + version: typeof GENERATE_CONFIG_VERSION; + options?: GenerateConfigOptions; + builders: GenerateConfigBuilder[]; +}; + +export type GenerateConfigIssue = { + /** JSON path of the offending value, for example `builders[1].outputTo`. */ + path: string; + message: string; +}; + +export class GenerateConfigError extends Error { + readonly issues: readonly GenerateConfigIssue[]; + + constructor(issues: readonly GenerateConfigIssue[]) { + super( + [`Invalid generate config (${issues.length} problem${issues.length === 1 ? "" : "s"}):`] + .concat(issues.map((issue) => ` ${issue.path || ""}: ${issue.message}`)) + .join("\n"), + ); + this.name = "GenerateConfigError"; + this.issues = issues; + } +} + +const ROOT_KEYS = ["version", "options", "builders"] as const satisfies readonly (keyof GenerateConfig)[]; + +const OPTIONS_KEYS = [ + "registry", + "ignorePackageIndex", + "dropCanonicalManagerCache", + "throwException", + "forceDependencies", +] as const satisfies readonly (keyof GenerateConfigOptions)[]; + +const BUILDER_KEYS = [ + "name", + "fromPackages", + "fromPackageRefs", + "localTgzPackages", + "localStructureDefinitions", + "typeSchema", + "introspection", + "typescript", + "python", + "csharp", + "outputTo", + "cleanOutput", + "throwException", +] as const satisfies readonly (keyof GenerateConfigBuilder)[]; + +const PACKAGE_KEYS = ["name", "version"] as const satisfies readonly (keyof GenerateConfigPackage)[]; + +const LOCAL_SD_KEYS = [ + "package", + "path", + "dependencies", +] as const satisfies readonly (keyof GenerateConfigLocalStructureDefinitions)[]; + +const IR_CONF_KEYS = [ + "treeShake", + "treeShakeDefaults", + "promoteLogical", + "resolveCollisions", +] as const satisfies readonly (keyof IrConf)[]; + +const TREE_SHAKE_RULE_KEYS = [ + "ignoreFields", + "selectFields", + "ignoreExtensions", + "followReferences", +] as const satisfies readonly (keyof TreeShakeRule)[]; + +/** + * Generator option keys settable from config. + * + * `outputDir` is excluded on purpose: it is derived from the builder's `outputTo`. `logger` and + * `resolveAssets` are excluded because they are runtime objects with no JSON representation. + */ +const FILE_SYSTEM_WRITER_KEYS = ["inMemoryOnly"] as const; + +const WRITER_KEYS = [ + ...FILE_SYSTEM_WRITER_KEYS, + "tabSize", + "withDebugComment", + "commentLinePrefix", + "generateProfile", +] as const satisfies readonly (keyof WriterOptions)[]; + +const TYPESCRIPT_KEYS = [ + ...WRITER_KEYS, + "lineWidth", + "openResourceTypeSet", + "primitiveTypeExtension", + "extensionGetterDefault", + "sliceGetterDefault", +] as const satisfies readonly (keyof TypeScriptOptions)[]; + +const PYTHON_KEYS = [ + ...WRITER_KEYS, + "allowExtraFields", + "primitiveTypeExtension", + "rootPackageName", + "fieldFormat", + "client", + "fhirpyClient", +] as const satisfies readonly (keyof PythonGeneratorOptions)[]; + +const CSHARP_KEYS = [ + ...WRITER_KEYS, + "staticSourceDir", + "rootNamespace", +] as const satisfies readonly (keyof CSharpGeneratorOptions)[]; + +const INTROSPECTION_KEYS = [ + ...FILE_SYSTEM_WRITER_KEYS, + "typeSchemas", + "typeTree", + "fhirSchemas", + "structureDefinitions", +] as const satisfies readonly (keyof IntrospectionWriterOptions)[]; + +const INPUT_KEYS = ["fromPackages", "fromPackageRefs", "localTgzPackages", "localStructureDefinitions"] as const; + +const GENERATOR_KEYS = ["introspection", "typescript", "python", "csharp"] as const; + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +const describeType = (value: unknown): string => { + if (value === null) return "null"; + if (Array.isArray(value)) return "an array"; + if (typeof value === "string") return `a string (${JSON.stringify(value)})`; + return `a ${typeof value}`; +}; + +const childPath = (parent: string, key: string): string => (parent === "" ? key : `${parent}.${key}`); + +type Ctx = { + issues: GenerateConfigIssue[]; + /** Directory of the config file; every relative path in the config is resolved against it. */ + configDir: string; +}; + +const report = (ctx: Ctx, path: string, message: string): undefined => { + ctx.issues.push({ path, message }); + return undefined; +}; + +const checkKnownKeys = (ctx: Ctx, value: Record, allowed: readonly string[], path: string): void => { + for (const key of Object.keys(value)) { + if (allowed.includes(key)) continue; + report(ctx, childPath(path, key), `unknown key "${key}"; allowed keys: ${allowed.join(", ")}`); + } +}; + +const readRecord = (ctx: Ctx, value: unknown, path: string): Record | undefined => + isRecord(value) ? value : report(ctx, path, `expected an object, got ${describeType(value)}`); + +const readString = (ctx: Ctx, value: unknown, path: string): string | undefined => { + if (typeof value !== "string") return report(ctx, path, `expected a string, got ${describeType(value)}`); + if (value.trim() === "") return report(ctx, path, "must not be empty"); + return value; +}; + +const readBoolean = (ctx: Ctx, value: unknown, path: string): boolean | undefined => + typeof value === "boolean" ? value : report(ctx, path, `expected a boolean, got ${describeType(value)}`); + +const readArray = (ctx: Ctx, value: unknown, path: string): unknown[] | undefined => + Array.isArray(value) ? value : report(ctx, path, `expected an array, got ${describeType(value)}`); + +const readStringArray = (ctx: Ctx, value: unknown, path: string): string[] | undefined => { + const items = readArray(ctx, value, path); + if (!items) return undefined; + const result: string[] = []; + items.forEach((item, index) => { + const entry = readString(ctx, item, `${path}[${index}]`); + if (entry !== undefined) result.push(entry); + }); + return result; +}; + +const readStringMap = (ctx: Ctx, value: unknown, path: string): Record | undefined => { + const record = readRecord(ctx, value, path); + if (!record) return undefined; + const result: Record = {}; + for (const [key, entry] of Object.entries(record)) { + const version = readString(ctx, entry, childPath(path, key)); + if (version !== undefined) result[key] = version; + } + return result; +}; + +const resolvePath = (ctx: Ctx, value: string): string => Path.resolve(ctx.configDir, value); + +const readPackage = (ctx: Ctx, value: unknown, path: string): GenerateConfigPackage | undefined => { + const record = readRecord(ctx, value, path); + if (!record) return undefined; + checkKnownKeys(ctx, record, PACKAGE_KEYS, path); + const name = readString(ctx, record.name, childPath(path, "name")); + const version = readString(ctx, record.version, childPath(path, "version")); + if (name === undefined || version === undefined) return undefined; + return { name, version }; +}; + +const readPackages = (ctx: Ctx, value: unknown, path: string): GenerateConfigPackage[] | undefined => { + const items = readArray(ctx, value, path); + if (!items) return undefined; + const result: GenerateConfigPackage[] = []; + items.forEach((item, index) => { + const entry = readPackage(ctx, item, `${path}[${index}]`); + if (entry) result.push(entry); + }); + return result; +}; + +const readLocalStructureDefinitions = ( + ctx: Ctx, + value: unknown, + path: string, +): GenerateConfigLocalStructureDefinitions[] | undefined => { + const items = readArray(ctx, value, path); + if (!items) return undefined; + const result: GenerateConfigLocalStructureDefinitions[] = []; + items.forEach((item, index) => { + const itemPath = `${path}[${index}]`; + const record = readRecord(ctx, item, itemPath); + if (!record) return; + checkKnownKeys(ctx, record, LOCAL_SD_KEYS, itemPath); + const pkg = readPackage(ctx, record.package, childPath(itemPath, "package")); + const sdPath = readString(ctx, record.path, childPath(itemPath, "path")); + const dependencies = + record.dependencies === undefined + ? undefined + : readPackages(ctx, record.dependencies, childPath(itemPath, "dependencies")); + if (!pkg || sdPath === undefined) return; + result.push({ package: pkg, path: resolvePath(ctx, sdPath), dependencies }); + }); + return result; +}; + +const readTreeShake = (ctx: Ctx, value: unknown, path: string): void => { + const packages = readRecord(ctx, value, path); + if (!packages) return; + for (const [packageName, canonicals] of Object.entries(packages)) { + const packagePath = childPath(path, packageName); + const rules = readRecord(ctx, canonicals, packagePath); + if (!rules) continue; + for (const [canonical, rule] of Object.entries(rules)) { + const rulePath = childPath(packagePath, canonical); + const ruleRecord = readRecord(ctx, rule, rulePath); + if (!ruleRecord) continue; + checkKnownKeys(ctx, ruleRecord, TREE_SHAKE_RULE_KEYS, rulePath); + } + } +}; + +const readTypeSchema = (ctx: Ctx, value: unknown, path: string): IrConf | undefined => { + const record = readRecord(ctx, value, path); + if (!record) return undefined; + checkKnownKeys(ctx, record, IR_CONF_KEYS, path); + if (record.treeShake !== undefined) readTreeShake(ctx, record.treeShake, childPath(path, "treeShake")); + return record as IrConf; +}; + +const readGeneratorOptions = (ctx: Ctx, value: unknown, path: string, allowed: readonly string[]): T | undefined => { + const record = readRecord(ctx, value, path); + if (!record) return undefined; + checkKnownKeys(ctx, record, allowed, path); + return record as T; +}; + +const readBuilder = (ctx: Ctx, value: unknown, path: string): GenerateConfigBuilder | undefined => { + const record = readRecord(ctx, value, path); + if (!record) return undefined; + checkKnownKeys(ctx, record, BUILDER_KEYS, path); + + const name = readString(ctx, record.name, childPath(path, "name")); + const outputTo = + record.outputTo === undefined + ? report(ctx, childPath(path, "outputTo"), "outputTo is required; a builder must name its output directory") + : readString(ctx, record.outputTo, childPath(path, "outputTo")); + + const builder: Partial = {}; + if (record.fromPackages !== undefined) + builder.fromPackages = readPackages(ctx, record.fromPackages, childPath(path, "fromPackages")); + if (record.fromPackageRefs !== undefined) + builder.fromPackageRefs = readStringArray(ctx, record.fromPackageRefs, childPath(path, "fromPackageRefs")); + if (record.localTgzPackages !== undefined) { + const archives = readStringArray(ctx, record.localTgzPackages, childPath(path, "localTgzPackages")); + builder.localTgzPackages = archives?.map((archive) => resolvePath(ctx, archive)); + } + if (record.localStructureDefinitions !== undefined) + builder.localStructureDefinitions = readLocalStructureDefinitions( + ctx, + record.localStructureDefinitions, + childPath(path, "localStructureDefinitions"), + ); + if (record.typeSchema !== undefined) + builder.typeSchema = readTypeSchema(ctx, record.typeSchema, childPath(path, "typeSchema")); + if (record.introspection !== undefined) + builder.introspection = readGeneratorOptions( + ctx, + record.introspection, + childPath(path, "introspection"), + INTROSPECTION_KEYS, + ); + if (record.typescript !== undefined) + builder.typescript = readGeneratorOptions( + ctx, + record.typescript, + childPath(path, "typescript"), + TYPESCRIPT_KEYS, + ); + if (record.python !== undefined) + builder.python = readGeneratorOptions(ctx, record.python, childPath(path, "python"), PYTHON_KEYS); + if (record.csharp !== undefined) + builder.csharp = readGeneratorOptions(ctx, record.csharp, childPath(path, "csharp"), CSHARP_KEYS); + if (record.cleanOutput !== undefined) + builder.cleanOutput = readBoolean(ctx, record.cleanOutput, childPath(path, "cleanOutput")); + if (record.throwException !== undefined) + builder.throwException = readBoolean(ctx, record.throwException, childPath(path, "throwException")); + + const hasInput = INPUT_KEYS.some((key) => record[key] !== undefined); + if (!hasInput) + report( + ctx, + path, + `no input configured; set at least one of ${INPUT_KEYS.join(", ")} or the builder generates nothing`, + ); + + const hasGenerator = GENERATOR_KEYS.some((key) => record[key] !== undefined); + if (!hasGenerator) + report( + ctx, + path, + `no output generator configured; set at least one of ${GENERATOR_KEYS.join(", ")} or the builder writes nothing`, + ); + + if (name === undefined || outputTo === undefined) return undefined; + return { ...builder, name, outputTo: resolvePath(ctx, outputTo) }; +}; + +const readOptions = (ctx: Ctx, value: unknown, path: string): GenerateConfigOptions | undefined => { + const record = readRecord(ctx, value, path); + if (!record) return undefined; + checkKnownKeys(ctx, record, OPTIONS_KEYS, path); + const options: GenerateConfigOptions = {}; + if (record.registry !== undefined) options.registry = readString(ctx, record.registry, childPath(path, "registry")); + if (record.ignorePackageIndex !== undefined) + options.ignorePackageIndex = readBoolean(ctx, record.ignorePackageIndex, childPath(path, "ignorePackageIndex")); + if (record.dropCanonicalManagerCache !== undefined) + options.dropCanonicalManagerCache = readBoolean( + ctx, + record.dropCanonicalManagerCache, + childPath(path, "dropCanonicalManagerCache"), + ); + if (record.throwException !== undefined) + options.throwException = readBoolean(ctx, record.throwException, childPath(path, "throwException")); + if (record.forceDependencies !== undefined) + options.forceDependencies = readStringMap(ctx, record.forceDependencies, childPath(path, "forceDependencies")); + return options; +}; + +/** + * Validate raw config data and resolve its relative paths. + * + * `configPath` is the file the data came from; every relative `outputTo`, `localTgzPackages` + * entry, and `localStructureDefinitions[].path` is resolved against its directory rather than + * against the process working directory. + * + * Every problem found is reported, not just the first, so one run tells the caller everything + * that has to be fixed. + * + * @throws {GenerateConfigError} when the config is not usable. + */ +export const parseGenerateConfig = (raw: unknown, configPath: string): GenerateConfig => { + const ctx: Ctx = { issues: [], configDir: Path.dirname(Path.resolve(configPath)) }; + + const root = readRecord(ctx, raw, ""); + if (!root) throw new GenerateConfigError(ctx.issues); + checkKnownKeys(ctx, root, ROOT_KEYS, ""); + + if (root.version === undefined) report(ctx, "version", `version is required; expected ${GENERATE_CONFIG_VERSION}`); + else if (typeof root.version !== "number") + report(ctx, "version", `expected the number ${GENERATE_CONFIG_VERSION}, got ${describeType(root.version)}`); + else if (root.version !== GENERATE_CONFIG_VERSION) + report( + ctx, + "version", + `unsupported config version ${root.version}; this build understands ${GENERATE_CONFIG_VERSION}`, + ); + + const options = root.options === undefined ? undefined : readOptions(ctx, root.options, "options"); + + const builders: GenerateConfigBuilder[] = []; + if (root.builders === undefined) { + report(ctx, "builders", "builders is required and must list at least one builder"); + } else { + const items = readArray(ctx, root.builders, "builders"); + if (items && items.length === 0) report(ctx, "builders", "must list at least one builder"); + items?.forEach((item, index) => { + const builder = readBuilder(ctx, item, `builders[${index}]`); + if (builder) builders.push(builder); + }); + } + + const seen = new Set(); + builders.forEach((builder, index) => { + if (seen.has(builder.name)) + report(ctx, `builders[${index}].name`, `duplicate builder name "${builder.name}"; names must be unique`); + seen.add(builder.name); + }); + + if (ctx.issues.length > 0) throw new GenerateConfigError(ctx.issues); + return { version: GENERATE_CONFIG_VERSION, options, builders }; +}; + +/** + * Build the `preprocessPackage` callback that `forceDependencies` stands for. + * + * Only dependencies a package already declares are rewritten; the map never adds a dependency to + * a package that does not ask for it, so the closure keeps its shape and only its versions are + * pinned. + */ +export const mkForceDependenciesPreprocessor = + (forced: Record) => + (context: PreprocessContext): PreprocessContext => { + if (context.kind !== "package") return context; + const declared = context.packageJson.dependencies; + if (!isRecord(declared)) return context; + + let changed = false; + const dependencies: Record = { ...declared }; + for (const [name, version] of Object.entries(forced)) { + if (!(name in dependencies)) continue; + if (dependencies[name] === version) continue; + dependencies[name] = version; + changed = true; + } + if (!changed) return context; + return { ...context, packageJson: { ...context.packageJson, dependencies } }; + }; + +/** The subset of `APIBuilder` a config-driven run uses. Lets callers and tests substitute it. */ +export type GenerationBuilder = { + fromPackage(packageName: string, version?: string): GenerationBuilder; + fromPackageRef(packageRef: string): GenerationBuilder; + localTgzPackage(archivePath: string): GenerationBuilder; + localStructureDefinitions(config: LocalStructureDefinitionConfig): GenerationBuilder; + typeSchema(config: IrConf): GenerationBuilder; + introspection(options: Partial): GenerationBuilder; + typescript(options: Partial): GenerationBuilder; + python(options: Partial): GenerationBuilder; + csharp(options: Partial): GenerationBuilder; + outputTo(directory: string): GenerationBuilder; + cleanOutput(enabled: boolean): GenerationBuilder; + throwException(enabled: boolean): GenerationBuilder; + generate(): Promise; +}; + +export type BuilderFactoryOptions = { + registry?: string; + dropCanonicalManagerCache?: boolean; + ignorePackageIndex?: boolean; + preprocessPackage?: (context: PreprocessContext) => PreprocessContext; + logger?: CodegenLogManager; +}; + +export type BuilderFactory = (options: BuilderFactoryOptions) => GenerationBuilder; + +export type BuilderRunResult = { + name: string; + success: boolean; + outputDir: string; + errors: string[]; + report?: GenerationReport; +}; + +export type GenerateRunResult = { + success: boolean; + builders: BuilderRunResult[]; +}; + +const defaultBuilderFactory: BuilderFactory = (options) => new APIBuilder(options); + +const runBuilder = async ( + config: GenerateConfigBuilder, + options: GenerateConfigOptions, + createBuilder: BuilderFactory, + logger: CodegenLogManager | undefined, +): Promise => { + const preprocessPackage = options.forceDependencies + ? mkForceDependenciesPreprocessor(options.forceDependencies) + : undefined; + + try { + const builder = createBuilder({ + registry: options.registry, + dropCanonicalManagerCache: options.dropCanonicalManagerCache, + ignorePackageIndex: options.ignorePackageIndex, + preprocessPackage, + logger, + }); + + for (const pkg of config.fromPackages ?? []) builder.fromPackage(pkg.name, pkg.version); + for (const ref of config.fromPackageRefs ?? []) builder.fromPackageRef(ref); + for (const archive of config.localTgzPackages ?? []) builder.localTgzPackage(archive); + for (const local of config.localStructureDefinitions ?? []) builder.localStructureDefinitions(local); + + if (config.typeSchema) builder.typeSchema(config.typeSchema); + if (config.introspection) builder.introspection(config.introspection); + if (config.typescript) builder.typescript(config.typescript); + if (config.python) builder.python(config.python); + if (config.csharp) builder.csharp(config.csharp); + + // Applied after the generators on purpose: `outputTo` rewrites the output directory of + // every generator already configured, so each one writes straight into it instead of a + // generator-specific subdirectory. This is the order the repository's examples use. + builder.outputTo(config.outputTo); + + if (config.cleanOutput !== undefined) builder.cleanOutput(config.cleanOutput); + const throwException = config.throwException ?? options.throwException; + if (throwException !== undefined) builder.throwException(throwException); + + const generationReport = await builder.generate(); + return { + name: config.name, + success: generationReport.success, + outputDir: config.outputTo, + errors: generationReport.errors, + report: generationReport, + }; + } catch (error) { + return { + name: config.name, + success: false, + outputDir: config.outputTo, + errors: [error instanceof Error ? error.message : String(error)], + }; + } +}; + +/** + * Run every builder in the config, in order. + * + * A failing builder does not stop the ones after it: the caller gets one report covering all of + * them, so a broken pipeline is fixed in one pass instead of one builder per run. + */ +export const runGenerateConfig = async ( + config: GenerateConfig, + deps: { createBuilder?: BuilderFactory; logger?: CodegenLogManager } = {}, +): Promise => { + const createBuilder = deps.createBuilder ?? defaultBuilderFactory; + const options = config.options ?? {}; + + const builders: BuilderRunResult[] = []; + for (const builderConfig of config.builders) { + builders.push(await runBuilder(builderConfig, options, createBuilder, deps.logger)); + } + return { success: builders.every((builder) => builder.success), builders }; +}; + +const describeInputs = (builder: GenerateConfigBuilder): string[] => { + const lines: string[] = []; + for (const pkg of builder.fromPackages ?? []) lines.push(`package ${pkg.name}@${pkg.version}`); + for (const ref of builder.fromPackageRefs ?? []) lines.push(`package ref ${ref}`); + for (const archive of builder.localTgzPackages ?? []) lines.push(`local tgz ${archive}`); + for (const local of builder.localStructureDefinitions ?? []) + lines.push(`local StructureDefinitions ${local.package.name}@${local.package.version} from ${local.path}`); + return lines; +}; + +/** Render the validated config as a human-readable plan, with every path already resolved. */ +export const describeGenerateConfig = (config: GenerateConfig): string => { + const options = config.options ?? {}; + const lines: string[] = [`Generate plan (${config.builders.length} builder(s)):`]; + + const optionLines: string[] = []; + if (options.registry) optionLines.push(`registry: ${options.registry}`); + if (options.ignorePackageIndex !== undefined) optionLines.push(`ignorePackageIndex: ${options.ignorePackageIndex}`); + if (options.dropCanonicalManagerCache !== undefined) + optionLines.push(`dropCanonicalManagerCache: ${options.dropCanonicalManagerCache}`); + if (options.throwException !== undefined) optionLines.push(`throwException: ${options.throwException}`); + for (const [name, version] of Object.entries(options.forceDependencies ?? {})) + optionLines.push(`forceDependencies: ${name} -> ${version}`); + if (optionLines.length > 0) lines.push(" options:", ...optionLines.map((line) => ` ${line}`)); + + config.builders.forEach((builder, index) => { + const generators = GENERATOR_KEYS.filter((key) => builder[key] !== undefined); + lines.push(` ${index + 1}. ${builder.name}`); + for (const input of describeInputs(builder)) lines.push(` input: ${input}`); + lines.push(` generators: ${generators.length > 0 ? generators.join(", ") : "none"}`); + if (builder.typeSchema) lines.push(` typeSchema: ${Object.keys(builder.typeSchema).join(", ")}`); + lines.push(` outputTo: ${builder.outputTo}`); + if (builder.cleanOutput !== undefined) lines.push(` cleanOutput: ${builder.cleanOutput}`); + }); + return lines.join("\n"); +}; diff --git a/src/api/writer-generator/assets.ts b/src/api/writer-generator/assets.ts new file mode 100644 index 00000000..1a821848 --- /dev/null +++ b/src/api/writer-generator/assets.ts @@ -0,0 +1,54 @@ +/** + * Static asset resolution for the code generators. + * + * Generators copy files verbatim out of `assets/api/writer-generator//`. + * That directory sits at the package root and ships alongside `dist`, but the module + * asking for it can be at very different depths: `src/api/writer-generator// + * writer.ts` when running from source, `dist/index.js` when importing the bundled + * library, and `dist/cli/index.js` when running the bundled CLI. + * + * Rather than encode those depths, walk up from the calling module until the asset + * tree is found. Any future entry point is then handled without a change here, and a + * missing asset reports every path that was tried instead of failing later with an + * ENOENT on a path nobody recognises. + */ + +import { existsSync } from "node:fs"; +import * as Path from "node:path"; +import { fileURLToPath } from "node:url"; + +const ASSET_ROOT_SEGMENTS = ["assets", "api", "writer-generator"] as const; + +/** Directories walked upwards before giving up. Generous; the real depth is at most 4. */ +const MAX_LOOKUP_DEPTH = 12; + +/** + * Resolve one static asset shipped with the package. + * + * @param moduleUrl - `import.meta.url` of the calling generator module. + * @param language - Asset subdirectory, e.g. `"typescript"`. + * @param fn - File name inside that subdirectory. + * @returns Absolute path to the asset. + * @throws When no ancestor directory carries the asset tree. + * + * @example + * ```typescript + * const helpers = resolveGeneratorAsset(import.meta.url, "typescript", "profile-helpers.ts"); + * ``` + */ +export const resolveGeneratorAsset = (moduleUrl: string, language: string, fn: string): string => { + const searched: string[] = []; + let directory = Path.dirname(fileURLToPath(moduleUrl)); + + for (let depth = 0; depth < MAX_LOOKUP_DEPTH; depth++) { + const candidate = Path.resolve(directory, ...ASSET_ROOT_SEGMENTS, language, fn); + searched.push(candidate); + if (existsSync(candidate)) return candidate; + + const parent = Path.dirname(directory); + if (parent === directory) break; + directory = parent; + } + + throw new Error(`Cannot locate generator asset ${language}/${fn}. Looked in:\n ${searched.join("\n ")}`); +}; diff --git a/src/api/writer-generator/csharp/csharp.ts b/src/api/writer-generator/csharp/csharp.ts index 7440f05a..86b2c198 100644 --- a/src/api/writer-generator/csharp/csharp.ts +++ b/src/api/writer-generator/csharp/csharp.ts @@ -1,6 +1,5 @@ import fs from "node:fs"; import Path from "node:path"; -import { fileURLToPath } from "node:url"; import { pascalCase, uppercaseFirstLetter, uppercaseFirstLetterOfEach } from "@root/api/writer-generator/utils.ts"; import { Writer, type WriterOptions } from "@root/api/writer-generator/writer.ts"; import type { PartialBy } from "@root/utils/types.ts"; @@ -12,16 +11,10 @@ import { type SpecializationTypeSchema, } from "@typeschema/types.ts"; import type { TypeSchemaIndex } from "@typeschema/utils.ts"; +import { resolveGeneratorAsset } from "../assets"; import { formatEnumEntry, formatName } from "./formatHelper.ts"; -const resolveCSharpAssets = (fn: string) => { - const __filename = fileURLToPath(import.meta.url); - const __dirname = Path.dirname(__filename); - if (__filename.endsWith("dist/index.js")) { - return Path.resolve(__dirname, "..", "assets", "api", "writer-generator", "csharp", fn); - } - return Path.resolve(__dirname, "../../../..", "assets", "api", "writer-generator", "csharp", fn); -}; +const resolveCSharpAssets = (fn: string) => resolveGeneratorAsset(import.meta.url, "csharp", fn); const PRIMITIVE_TYPE_MAP: Record = { boolean: "bool", diff --git a/src/api/writer-generator/python/writer.ts b/src/api/writer-generator/python/writer.ts index 3ed991cb..74d39c35 100644 --- a/src/api/writer-generator/python/writer.ts +++ b/src/api/writer-generator/python/writer.ts @@ -1,6 +1,4 @@ import assert from "node:assert"; -import * as Path from "node:path"; -import { fileURLToPath } from "node:url"; import { canonicalToName, deriveResourceName, @@ -24,17 +22,11 @@ import { type SpecializationTypeSchema, type TypeIdentifier, } from "@typeschema/types.ts"; +import { resolveGeneratorAsset } from "../assets"; import { pyReferenceTypeParam } from "./naming-utils"; import { generateNewProfiles } from "./profile"; -export const resolvePyAssets = (fn: string) => { - const __dirname = Path.dirname(fileURLToPath(import.meta.url)); - const __filename = fileURLToPath(import.meta.url); - if (__filename.endsWith("dist/index.js")) { - return Path.resolve(__dirname, "..", "assets", "api", "writer-generator", "python", fn); - } - return Path.resolve(__dirname, "../../../..", "assets", "api", "writer-generator", "python", fn); -}; +export const resolvePyAssets = (fn: string) => resolveGeneratorAsset(import.meta.url, "python", fn); type StringFormatKey = "snake_case" | "PascalCase" | "camelCase"; diff --git a/src/api/writer-generator/typescript/writer.ts b/src/api/writer-generator/typescript/writer.ts index 6ebee66a..346af9bc 100644 --- a/src/api/writer-generator/typescript/writer.ts +++ b/src/api/writer-generator/typescript/writer.ts @@ -1,5 +1,3 @@ -import * as Path from "node:path"; -import { fileURLToPath } from "node:url"; import { Writer, type WriterOptions } from "@root/api/writer-generator/writer"; import { type CanonicalUrl, @@ -19,6 +17,7 @@ import { type TypeSchema, } from "@root/typeschema/types"; import { groupByPackages, type TypeSchemaIndex } from "@root/typeschema/utils"; +import { resolveGeneratorAsset } from "../assets"; import { tsFieldName, tsModuleFileName, @@ -32,14 +31,7 @@ import { import { generateProfileClass, generateProfileImports, generateProfileIndexFile, mkIsFamilyType } from "./profile"; import { resolveFieldTsType } from "./utils"; -export const resolveTsAssets = (fn: string) => { - const __dirname = Path.dirname(fileURLToPath(import.meta.url)); - const __filename = fileURLToPath(import.meta.url); - if (__filename.endsWith("dist/index.js")) { - return Path.resolve(__dirname, "..", "assets", "api", "writer-generator", "typescript", fn); - } - return Path.resolve(__dirname, "../../../..", "assets", "api", "writer-generator", "typescript", fn); -}; +export const resolveTsAssets = (fn: string) => resolveGeneratorAsset(import.meta.url, "typescript", fn); const leafOf = (path: string[]): string => path[path.length - 1] ?? ""; diff --git a/src/cli/commands/generate.ts b/src/cli/commands/generate.ts new file mode 100644 index 00000000..d5cc6daa --- /dev/null +++ b/src/cli/commands/generate.ts @@ -0,0 +1,110 @@ +/** + * Generate Command + * + * Runs one or more `APIBuilder` pipelines described by a JSON configuration file, so a generation + * setup can live in data instead of a hand-written script. + */ + +import { readFile } from "node:fs/promises"; +import * as Path from "node:path"; +import { + describeGenerateConfig, + GenerateConfigError, + type GenerateRunResult, + parseGenerateConfig, + runGenerateConfig, +} from "@root/api/generate-config"; +import { complete, list } from "@root/utils/cli-fmt"; +import { mkCodegenLogger } from "@root/utils/log"; +import type { CommandModule } from "yargs"; + +type GenerateArgs = { + config: string; + dryRun?: boolean; +}; + +export type GenerateCommandDeps = Parameters[1]; + +const readConfigFile = async (configPath: string): Promise => { + let content: string; + try { + content = await readFile(configPath, "utf-8"); + } catch (error) { + throw new Error(`Cannot read config file ${configPath}: ${error instanceof Error ? error.message : error}`); + } + try { + return JSON.parse(content); + } catch (error) { + throw new Error( + `Config file ${configPath} is not valid JSON: ${error instanceof Error ? error.message : error}`, + ); + } +}; + +const reportRun = (result: GenerateRunResult): void => { + for (const builder of result.builders) { + if (builder.success) { + complete(`${builder.name}: generated into ${builder.outputDir}`); + continue; + } + console.error(`${builder.name}: FAILED`); + list(builder.errors.length > 0 ? builder.errors : ["generation reported no details"]); + } +}; + +/** + * Execute the command and return the process exit code. + * + * Kept separate from the yargs handler so the behaviour is callable and testable without a + * process boundary. + */ +export const runGenerateCommand = async (args: GenerateArgs, deps: GenerateCommandDeps = {}): Promise => { + const logger = mkCodegenLogger({ prefix: "generate" }); + const configPath = Path.resolve(args.config); + + let result: GenerateRunResult; + try { + const config = parseGenerateConfig(await readConfigFile(configPath), configPath); + + if (args.dryRun) { + console.log(describeGenerateConfig(config)); + logger.info("Dry run: nothing was generated"); + return 0; + } + + result = await runGenerateConfig(config, { logger, ...deps }); + } catch (error) { + if (error instanceof GenerateConfigError) logger.error(error.message); + else logger.error(error instanceof Error ? error.message : String(error)); + return 1; + } + + reportRun(result); + if (result.success) return 0; + + const failed = result.builders.filter((builder) => !builder.success).map((builder) => builder.name); + logger.error(`${failed.length} of ${result.builders.length} builder(s) failed: ${failed.join(", ")}`); + return 1; +}; + +export const generateCommand: CommandModule, GenerateArgs> = { + command: "generate", + describe: "Generate code from a JSON configuration file", + builder: { + config: { + alias: "c", + type: "string", + demandOption: true, + describe: "Path to the JSON generation config. Relative paths inside it resolve against its directory", + }, + "dry-run": { + type: "boolean", + default: false, + describe: "Validate the config and print the resolved plan without generating anything", + }, + }, + handler: async (argv) => { + const code = await runGenerateCommand({ config: argv.config, dryRun: argv.dryRun }); + if (code !== 0) process.exit(code); + }, +}; diff --git a/src/cli/commands/index.ts b/src/cli/commands/index.ts index 03eee30c..cb710105 100644 --- a/src/cli/commands/index.ts +++ b/src/cli/commands/index.ts @@ -11,6 +11,8 @@ import type { LogLevel } from "@root/utils/log"; import { mkLogger } from "@root/utils/log"; import yargs from "yargs"; import { hideBin } from "yargs/helpers"; +import packageJson from "../../../package.json" with { type: "json" }; +import { generateCommand } from "./generate"; import { typeschemaCommand } from "./typeschema"; let cliLogger = mkLogger({ prefix: "cli" }); @@ -29,6 +31,7 @@ export function createCLI() { .usage("$0 [options]") .middleware(setupLoggingMiddleware) .command(typeschemaCommand) + .command(generateCommand) .option("verbose", { alias: "v", type: "boolean", @@ -58,19 +61,22 @@ export function createCLI() { header("Welcome to Atomic Codegen!"); console.log("Available commands:"); console.log(" typeschema Generate, validate and merge TypeSchema files"); + console.log(" generate Generate code from a JSON configuration file"); console.log("\nUse 'atomic-codegen --help' for more information about a command."); console.log("\nQuick examples:"); console.log(" atomic-codegen typeschema generate hl7.fhir.r4.core@4.0.1 -o schemas.ndjson"); + console.log(" atomic-codegen generate --config ./codegen.json"); console.log("\nUse 'atomic-codegen --help' to see all options."); process.exit(0); } }) .help() - .version("0.1.0") + .version(packageJson.version) .example( "$0 typeschema generate hl7.fhir.r4.core@4.0.1 -o schemas.ndjson", "Generate TypeSchemas from FHIR package", ) + .example("$0 generate --config ./codegen.json", "Run the generation pipelines described by a config file") .fail((msg, err, _yargs) => { cliLogger.error(err ? err.message : msg); cliLogger.error("Use --help for usage information"); diff --git a/test/unit/api/generate-config.test.ts b/test/unit/api/generate-config.test.ts new file mode 100644 index 00000000..4169c6f7 --- /dev/null +++ b/test/unit/api/generate-config.test.ts @@ -0,0 +1,574 @@ +import { describe, expect, it } from "bun:test"; +import * as Path from "node:path"; +import type { PreprocessContext } from "@atomic-ehr/fhir-canonical-manager"; +import type { GenerationReport } from "@root/api/builder"; +import { + type BuilderFactoryOptions, + describeGenerateConfig, + GenerateConfigError, + type GenerationBuilder, + mkForceDependenciesPreprocessor, + parseGenerateConfig, + runGenerateConfig, +} from "@root/api/generate-config"; + +const CONFIG_PATH = "/tmp/atomic-codegen-fixture/codegen.json"; +const CONFIG_DIR = Path.dirname(CONFIG_PATH); + +type Call = { method: string; args: unknown[] }; + +type Recording = { + builder: GenerationBuilder; + calls: Call[]; +}; + +const mkReport = (success: boolean, errors: string[]): GenerationReport => ({ + success, + outputDir: "", + filesGenerated: {}, + errors, + warnings: [], + duration: 0, +}); + +/** A GenerationBuilder that records every call instead of touching FHIR packages or the disk. */ +const mkRecordingBuilder = (outcome: { success?: boolean; errors?: string[]; throws?: string } = {}): Recording => { + const calls: Call[] = []; + const record = + (method: string) => + (...args: unknown[]): GenerationBuilder => { + calls.push({ method, args }); + return builder; + }; + const builder: GenerationBuilder = { + fromPackage: record("fromPackage") as GenerationBuilder["fromPackage"], + fromPackageRef: record("fromPackageRef") as GenerationBuilder["fromPackageRef"], + localTgzPackage: record("localTgzPackage") as GenerationBuilder["localTgzPackage"], + localStructureDefinitions: record( + "localStructureDefinitions", + ) as GenerationBuilder["localStructureDefinitions"], + typeSchema: record("typeSchema") as GenerationBuilder["typeSchema"], + introspection: record("introspection") as GenerationBuilder["introspection"], + typescript: record("typescript") as GenerationBuilder["typescript"], + python: record("python") as GenerationBuilder["python"], + csharp: record("csharp") as GenerationBuilder["csharp"], + outputTo: record("outputTo") as GenerationBuilder["outputTo"], + cleanOutput: record("cleanOutput") as GenerationBuilder["cleanOutput"], + throwException: record("throwException") as GenerationBuilder["throwException"], + generate: async () => { + calls.push({ method: "generate", args: [] }); + if (outcome.throws) throw new Error(outcome.throws); + return mkReport(outcome.success ?? true, outcome.errors ?? []); + }, + }; + return { builder, calls }; +}; + +const mkFactory = ( + outcomes: Record = {}, +): { + createBuilder: (options: BuilderFactoryOptions) => GenerationBuilder; + recordings: Recording[]; + seen: BuilderFactoryOptions[]; +} => { + const recordings: Recording[] = []; + const seen: BuilderFactoryOptions[] = []; + let index = 0; + const createBuilder = (options: BuilderFactoryOptions): GenerationBuilder => { + seen.push(options); + const outcome = outcomes[String(index)] ?? {}; + index += 1; + const recording = mkRecordingBuilder(outcome); + recordings.push(recording); + return recording.builder; + }; + return { createBuilder, recordings, seen }; +}; + +const validConfig = () => ({ + version: 1, + builders: [ + { + name: "core", + fromPackages: [{ name: "hl7.fhir.r4.core", version: "4.0.1" }], + typescript: {}, + outputTo: "./out/core", + }, + ], +}); + +const methodsOf = (recording: Recording): string[] => recording.calls.map((call) => call.method); + +describe("parseGenerateConfig", () => { + it("accepts a minimal valid config", () => { + const config = parseGenerateConfig(validConfig(), CONFIG_PATH); + + expect(config.version).toBe(1); + expect(config.builders).toHaveLength(1); + expect(config.builders[0]!.name).toBe("core"); + }); + + it("rejects an unknown key in a builder and names it", () => { + const raw = validConfig(); + (raw.builders[0] as Record).typscript = {}; + + expect(() => parseGenerateConfig(raw, CONFIG_PATH)).toThrow(GenerateConfigError); + try { + parseGenerateConfig(raw, CONFIG_PATH); + } catch (error) { + const issues = (error as GenerateConfigError).issues; + expect(issues).toHaveLength(1); + expect(issues[0]!.path).toBe("builders[0].typscript"); + expect(issues[0]!.message).toContain('unknown key "typscript"'); + expect(issues[0]!.message).toContain("typescript"); + } + }); + + it("rejects an unknown key at the root and inside options", () => { + const raw = { ...validConfig(), buidlers: [], options: { registryy: "https://example.org" } }; + + try { + parseGenerateConfig(raw, CONFIG_PATH); + throw new Error("expected a GenerateConfigError"); + } catch (error) { + const paths = (error as GenerateConfigError).issues.map((issue) => issue.path); + expect(paths).toContain("buidlers"); + expect(paths).toContain("options.registryy"); + } + }); + + it("rejects an unknown tree shake rule key", () => { + const raw = validConfig(); + (raw.builders[0] as Record).typeSchema = { + treeShake: { + "hl7.fhir.r4.core": { "http://hl7.org/fhir/StructureDefinition/Patient": { ignoreField: [] } }, + }, + }; + + try { + parseGenerateConfig(raw, CONFIG_PATH); + throw new Error("expected a GenerateConfigError"); + } catch (error) { + const issues = (error as GenerateConfigError).issues; + expect(issues[0]!.path).toBe( + "builders[0].typeSchema.treeShake.hl7.fhir.r4.core.http://hl7.org/fhir/StructureDefinition/Patient.ignoreField", + ); + expect(issues[0]!.message).toContain('unknown key "ignoreField"'); + } + }); + + it("reports a missing outputTo", () => { + const raw = validConfig(); + delete (raw.builders[0] as Record).outputTo; + + try { + parseGenerateConfig(raw, CONFIG_PATH); + throw new Error("expected a GenerateConfigError"); + } catch (error) { + const issues = (error as GenerateConfigError).issues; + expect(issues).toHaveLength(1); + expect(issues[0]!.path).toBe("builders[0].outputTo"); + expect(issues[0]!.message).toContain("outputTo is required"); + } + }); + + it("reports an empty builders array", () => { + try { + parseGenerateConfig({ version: 1, builders: [] }, CONFIG_PATH); + throw new Error("expected a GenerateConfigError"); + } catch (error) { + const issues = (error as GenerateConfigError).issues; + expect(issues).toHaveLength(1); + expect(issues[0]!.path).toBe("builders"); + expect(issues[0]!.message).toContain("at least one builder"); + } + }); + + it("reports a version of the wrong type", () => { + const raw = { ...validConfig(), version: "1" }; + + try { + parseGenerateConfig(raw, CONFIG_PATH); + throw new Error("expected a GenerateConfigError"); + } catch (error) { + const issues = (error as GenerateConfigError).issues; + expect(issues).toHaveLength(1); + expect(issues[0]!.path).toBe("version"); + expect(issues[0]!.message).toContain("expected the number 1"); + expect(issues[0]!.message).toContain('a string ("1")'); + } + }); + + it("reports an unsupported version number distinctly from a wrong type", () => { + const raw = { ...validConfig(), version: 2 }; + + try { + parseGenerateConfig(raw, CONFIG_PATH); + throw new Error("expected a GenerateConfigError"); + } catch (error) { + expect((error as GenerateConfigError).issues[0]!.message).toContain("unsupported config version 2"); + } + }); + + it("reports a builder without any input", () => { + const raw = { version: 1, builders: [{ name: "core", typescript: {}, outputTo: "./out" }] }; + + try { + parseGenerateConfig(raw, CONFIG_PATH); + throw new Error("expected a GenerateConfigError"); + } catch (error) { + expect((error as GenerateConfigError).issues[0]!.message).toContain("no input configured"); + } + }); + + it("reports a builder without any output generator", () => { + const raw = { + version: 1, + builders: [ + { name: "core", fromPackages: [{ name: "hl7.fhir.r4.core", version: "4.0.1" }], outputTo: "./out" }, + ], + }; + + try { + parseGenerateConfig(raw, CONFIG_PATH); + throw new Error("expected a GenerateConfigError"); + } catch (error) { + expect((error as GenerateConfigError).issues[0]!.message).toContain("no output generator configured"); + } + }); + + it("reports duplicate builder names", () => { + const raw = validConfig(); + raw.builders.push({ ...raw.builders[0]!, outputTo: "./out/other" }); + + try { + parseGenerateConfig(raw, CONFIG_PATH); + throw new Error("expected a GenerateConfigError"); + } catch (error) { + expect((error as GenerateConfigError).issues[0]!.message).toContain('duplicate builder name "core"'); + } + }); + + it("collects every problem instead of stopping at the first", () => { + const raw = { + version: "1", + builders: [ + { name: "a", fromPackages: [{ name: "x", version: "1.0.0" }], typescript: {} }, + { + name: "b", + fromPackages: [{ name: "x", version: "1.0.0" }], + typescript: {}, + nope: true, + outputTo: "./b", + }, + ], + }; + + try { + parseGenerateConfig(raw, CONFIG_PATH); + throw new Error("expected a GenerateConfigError"); + } catch (error) { + const paths = (error as GenerateConfigError).issues.map((issue) => issue.path); + expect(paths).toEqual(["version", "builders[0].outputTo", "builders[1].nope"]); + } + }); + + it("resolves relative paths against the config file directory, not the process cwd", () => { + const raw = { + version: 1, + builders: [ + { + name: "core", + localTgzPackages: ["./archives/local.tgz"], + localStructureDefinitions: [ + { package: { name: "x", version: "1.0.0" }, path: "../shared/structure-definitions" }, + ], + typescript: {}, + outputTo: "./out/core", + }, + ], + }; + + const config = parseGenerateConfig(raw, CONFIG_PATH); + const builder = config.builders[0]!; + + expect(builder.outputTo).toBe(Path.join(CONFIG_DIR, "out/core")); + expect(builder.localTgzPackages).toEqual([Path.join(CONFIG_DIR, "archives/local.tgz")]); + expect(builder.localStructureDefinitions![0]!.path).toBe( + Path.resolve(CONFIG_DIR, "../shared/structure-definitions"), + ); + expect(builder.outputTo.startsWith(process.cwd())).toBe(false); + }); + + it("keeps absolute paths untouched", () => { + const raw = validConfig(); + raw.builders[0]!.outputTo = "/srv/generated/core"; + + expect(parseGenerateConfig(raw, CONFIG_PATH).builders[0]!.outputTo).toBe("/srv/generated/core"); + }); +}); + +describe("runGenerateConfig", () => { + it("applies one builder's configuration in a fixed order", async () => { + const config = parseGenerateConfig( + { + version: 1, + builders: [ + { + name: "core", + fromPackages: [{ name: "hl7.fhir.r4.core", version: "4.0.1" }], + fromPackageRefs: ["https://example.org/package.tgz"], + localTgzPackages: ["./local.tgz"], + localStructureDefinitions: [{ package: { name: "x", version: "1.0.0" }, path: "./sds" }], + typeSchema: { treeShake: {} }, + introspection: { typeTree: "tree.json" }, + typescript: { generateProfile: true }, + outputTo: "./out/core", + cleanOutput: true, + throwException: true, + }, + ], + }, + CONFIG_PATH, + ); + const factory = mkFactory(); + + const result = await runGenerateConfig(config, { createBuilder: factory.createBuilder }); + + expect(result.success).toBe(true); + expect(methodsOf(factory.recordings[0]!)).toEqual([ + "fromPackage", + "fromPackageRef", + "localTgzPackage", + "localStructureDefinitions", + "typeSchema", + "introspection", + "typescript", + "outputTo", + "cleanOutput", + "throwException", + "generate", + ]); + }); + + it("passes resolved values through to the builder", async () => { + const config = parseGenerateConfig(validConfig(), CONFIG_PATH); + const factory = mkFactory(); + + await runGenerateConfig(config, { createBuilder: factory.createBuilder }); + const calls = factory.recordings[0]!.calls; + + expect(calls.find((call) => call.method === "fromPackage")!.args).toEqual(["hl7.fhir.r4.core", "4.0.1"]); + expect(calls.find((call) => call.method === "outputTo")!.args).toEqual([Path.join(CONFIG_DIR, "out/core")]); + }); + + it("runs multiple builders in config order", async () => { + const raw = validConfig(); + raw.builders.push({ + name: "dental", + fromPackages: [{ name: "example.dental", version: "1.0.0" }], + typescript: {}, + outputTo: "./out/dental", + }); + const config = parseGenerateConfig(raw, CONFIG_PATH); + const factory = mkFactory(); + + const result = await runGenerateConfig(config, { createBuilder: factory.createBuilder }); + + expect(result.builders.map((builder) => builder.name)).toEqual(["core", "dental"]); + expect(factory.recordings).toHaveLength(2); + expect(factory.recordings[1]!.calls.find((call) => call.method === "fromPackage")!.args).toEqual([ + "example.dental", + "1.0.0", + ]); + }); + + it("attempts every builder even after one fails and reports all failures", async () => { + const raw = validConfig(); + raw.builders.push({ + name: "broken", + fromPackages: [{ name: "example.broken", version: "1.0.0" }], + typescript: {}, + outputTo: "./out/broken", + }); + raw.builders.push({ + name: "tail", + fromPackages: [{ name: "example.tail", version: "1.0.0" }], + typescript: {}, + outputTo: "./out/tail", + }); + const config = parseGenerateConfig(raw, CONFIG_PATH); + const factory = mkFactory({ + "0": { success: false, errors: ["typescript generator failed: boom"] }, + "1": { throws: "canonical manager exploded" }, + }); + + const result = await runGenerateConfig(config, { createBuilder: factory.createBuilder }); + + expect(result.success).toBe(false); + expect(result.builders.map((builder) => builder.success)).toEqual([false, false, true]); + expect(result.builders[0]!.errors).toEqual(["typescript generator failed: boom"]); + expect(result.builders[1]!.errors).toEqual(["canonical manager exploded"]); + expect(factory.recordings).toHaveLength(3); + expect(methodsOf(factory.recordings[2]!)).toContain("generate"); + }); + + it("omits cleanOutput and throwException when the config does not set them", async () => { + const config = parseGenerateConfig(validConfig(), CONFIG_PATH); + const factory = mkFactory(); + + await runGenerateConfig(config, { createBuilder: factory.createBuilder }); + + expect(methodsOf(factory.recordings[0]!)).not.toContain("cleanOutput"); + expect(methodsOf(factory.recordings[0]!)).not.toContain("throwException"); + }); + + it("lets a builder override the shared throwException default", async () => { + const raw = { ...validConfig(), options: { throwException: true } }; + raw.builders.push({ + name: "lenient", + fromPackages: [{ name: "example.lenient", version: "1.0.0" }], + typescript: {}, + outputTo: "./out/lenient", + throwException: false, + } as (typeof raw.builders)[number]); + const config = parseGenerateConfig(raw, CONFIG_PATH); + const factory = mkFactory(); + + await runGenerateConfig(config, { createBuilder: factory.createBuilder }); + + const inherited = factory.recordings[0]!.calls.find((call) => call.method === "throwException"); + const overridden = factory.recordings[1]!.calls.find((call) => call.method === "throwException"); + expect(inherited!.args).toEqual([true]); + expect(overridden!.args).toEqual([false]); + }); + + it("forwards shared options to the builder factory", async () => { + const raw = { + ...validConfig(), + options: { + registry: "https://example.org/pkgs/", + ignorePackageIndex: true, + dropCanonicalManagerCache: true, + }, + }; + const config = parseGenerateConfig(raw, CONFIG_PATH); + const factory = mkFactory(); + + await runGenerateConfig(config, { createBuilder: factory.createBuilder }); + + expect(factory.seen[0]!.registry).toBe("https://example.org/pkgs/"); + expect(factory.seen[0]!.ignorePackageIndex).toBe(true); + expect(factory.seen[0]!.dropCanonicalManagerCache).toBe(true); + expect(factory.seen[0]!.preprocessPackage).toBeUndefined(); + }); +}); + +describe("forceDependencies", () => { + const packageContext = (dependencies: Record): PreprocessContext => ({ + kind: "package", + package: { name: "example.package", version: "1.0.0" }, + packageJson: { name: "example.package", version: "1.0.0", dependencies }, + }); + + it("rewrites a declared dependency version", () => { + const preprocess = mkForceDependenciesPreprocessor({ "de.basisprofil.r4": "1.6.0-ballot2" }); + + const result = preprocess(packageContext({ "de.basisprofil.r4": "1.5.4", "hl7.fhir.r4.core": "4.0.1" })); + + expect(result.kind).toBe("package"); + expect(result.kind === "package" && result.packageJson.dependencies).toEqual({ + "de.basisprofil.r4": "1.6.0-ballot2", + "hl7.fhir.r4.core": "4.0.1", + }); + }); + + it("does not add a dependency the package never declared", () => { + const preprocess = mkForceDependenciesPreprocessor({ "de.basisprofil.r4": "1.6.0-ballot2" }); + + const result = preprocess(packageContext({ "hl7.fhir.r4.core": "4.0.1" })); + + expect(result.kind === "package" && result.packageJson.dependencies).toEqual({ "hl7.fhir.r4.core": "4.0.1" }); + }); + + it("leaves resource contexts untouched", () => { + const preprocess = mkForceDependenciesPreprocessor({ "de.basisprofil.r4": "1.6.0-ballot2" }); + const context: PreprocessContext = { + kind: "resource", + package: { name: "example.package", version: "1.0.0" }, + resource: { resourceType: "StructureDefinition", url: "http://example.org/sd" } as never, + }; + + expect(preprocess(context)).toBe(context); + }); + + it("is equivalent to the hand-written preprocessPackage callback", () => { + const handWritten = (context: PreprocessContext): PreprocessContext => { + if (context.kind !== "package") return context; + const dependencies = context.packageJson.dependencies as Record | undefined; + if (!dependencies?.["de.basisprofil.r4"]) return context; + return { + ...context, + packageJson: { + ...context.packageJson, + dependencies: { ...dependencies, "de.basisprofil.r4": "1.6.0-ballot2" }, + }, + }; + }; + const fromConfig = mkForceDependenciesPreprocessor({ "de.basisprofil.r4": "1.6.0-ballot2" }); + + const cases: Record[] = [ + { "de.basisprofil.r4": "1.5.4", "hl7.fhir.r4.core": "4.0.1" }, + { "hl7.fhir.r4.core": "4.0.1" }, + { "de.basisprofil.r4": "1.6.0-ballot2" }, + ]; + for (const dependencies of cases) { + const context = packageContext(dependencies); + expect(fromConfig(context)).toEqual(handWritten(context)); + } + }); + + it("is handed to the builder factory when the config declares it", async () => { + const raw = { ...validConfig(), options: { forceDependencies: { "de.basisprofil.r4": "1.6.0-ballot2" } } }; + const config = parseGenerateConfig(raw, CONFIG_PATH); + const factory = mkFactory(); + + await runGenerateConfig(config, { createBuilder: factory.createBuilder }); + const preprocess = factory.seen[0]!.preprocessPackage; + + expect(preprocess).toBeDefined(); + const rewritten = preprocess!(packageContext({ "de.basisprofil.r4": "1.5.4" })); + expect(rewritten.kind === "package" && rewritten.packageJson.dependencies).toEqual({ + "de.basisprofil.r4": "1.6.0-ballot2", + }); + }); + + it("rejects a non-string forced version", () => { + const raw = { ...validConfig(), options: { forceDependencies: { "de.basisprofil.r4": 1.6 } } }; + + try { + parseGenerateConfig(raw, CONFIG_PATH); + throw new Error("expected a GenerateConfigError"); + } catch (error) { + const issues = (error as GenerateConfigError).issues; + expect(issues[0]!.path).toBe("options.forceDependencies.de.basisprofil.r4"); + expect(issues[0]!.message).toContain("expected a string"); + } + }); +}); + +describe("describeGenerateConfig", () => { + it("renders every builder with resolved paths", () => { + const raw = { + ...validConfig(), + options: { forceDependencies: { "de.basisprofil.r4": "1.6.0-ballot2" } }, + }; + const plan = describeGenerateConfig(parseGenerateConfig(raw, CONFIG_PATH)); + + expect(plan).toContain("Generate plan (1 builder(s)):"); + expect(plan).toContain("forceDependencies: de.basisprofil.r4 -> 1.6.0-ballot2"); + expect(plan).toContain("input: package hl7.fhir.r4.core@4.0.1"); + expect(plan).toContain("generators: typescript"); + expect(plan).toContain(`outputTo: ${Path.join(CONFIG_DIR, "out/core")}`); + }); +}); diff --git a/test/unit/api/writer-generator/assets.test.ts b/test/unit/api/writer-generator/assets.test.ts new file mode 100644 index 00000000..29db1857 --- /dev/null +++ b/test/unit/api/writer-generator/assets.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "bun:test"; +import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import * as Path from "node:path"; +import { pathToFileURL } from "node:url"; +import { resolveGeneratorAsset } from "@root/api/writer-generator/assets"; +import { resolvePyAssets } from "@root/api/writer-generator/python/writer"; +import { resolveTsAssets } from "@root/api/writer-generator/typescript/writer"; + +/** + * Build a package layout carrying the asset tree, and return its root. + * + * `entryPoints` are created as files so a resolver can be pointed at any of them, + * standing in for the depths a real install produces: the bundled library entry, + * the bundled CLI entry, and a source module. + */ +const mkPackage = (entryPoints: string[]): { root: string; asset: string } => { + const root = mkdtempSync(Path.join(tmpdir(), "codegen-assets-")); + const assetDir = Path.join(root, "assets", "api", "writer-generator", "typescript"); + mkdirSync(assetDir, { recursive: true }); + const asset = Path.join(assetDir, "profile-helpers.ts"); + writeFileSync(asset, "export const helper = 1;\n"); + + for (const entry of entryPoints) { + const entryPath = Path.join(root, entry); + mkdirSync(Path.dirname(entryPath), { recursive: true }); + writeFileSync(entryPath, ""); + } + return { root, asset }; +}; + +describe("resolveGeneratorAsset", () => { + it("resolves assets from the bundled library entry point", () => { + const { root, asset } = mkPackage(["dist/index.js"]); + const moduleUrl = pathToFileURL(Path.join(root, "dist/index.js")).href; + + expect(resolveGeneratorAsset(moduleUrl, "typescript", "profile-helpers.ts")).toBe(asset); + }); + + it("resolves assets from the bundled CLI entry point", () => { + // Regression: the CLI lives at dist/cli/index.js, one level deeper than the + // library entry. Matching the library filename sent this case up past the + // package root and it died with ENOENT on a directory outside the package. + const { root, asset } = mkPackage(["dist/cli/index.js"]); + const moduleUrl = pathToFileURL(Path.join(root, "dist/cli/index.js")).href; + + expect(resolveGeneratorAsset(moduleUrl, "typescript", "profile-helpers.ts")).toBe(asset); + }); + + it("resolves assets from a source module", () => { + const { root, asset } = mkPackage(["src/api/writer-generator/typescript/writer.ts"]); + const moduleUrl = pathToFileURL(Path.join(root, "src/api/writer-generator/typescript/writer.ts")).href; + + expect(resolveGeneratorAsset(moduleUrl, "typescript", "profile-helpers.ts")).toBe(asset); + }); + + it("names every path it tried when the asset is absent", () => { + const { root } = mkPackage(["dist/cli/index.js"]); + const moduleUrl = pathToFileURL(Path.join(root, "dist/cli/index.js")).href; + + expect(() => resolveGeneratorAsset(moduleUrl, "typescript", "missing.ts")).toThrow( + /Cannot locate generator asset typescript\/missing\.ts/, + ); + }); + + it("resolves the real assets this package ships", () => { + // Whatever layout the suite itself runs in, the shipped assets must resolve. + const helpers = resolveTsAssets("profile-helpers.ts"); + const requirements = resolvePyAssets("requirements.txt"); + + expect(existsSync(helpers)).toBe(true); + expect(existsSync(requirements)).toBe(true); + }); +}); diff --git a/test/unit/cli/generate.test.ts b/test/unit/cli/generate.test.ts new file mode 100644 index 00000000..5f879514 --- /dev/null +++ b/test/unit/cli/generate.test.ts @@ -0,0 +1,233 @@ +import { afterEach, describe, expect, it, spyOn } from "bun:test"; +import { existsSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import * as Path from "node:path"; +import type { GenerationReport } from "@root/api/builder"; +import type { GenerationBuilder } from "@root/api/generate-config"; +import { generateCommand, runGenerateCommand } from "@root/cli/commands/generate"; + +const mkReport = (success: boolean, errors: string[]): GenerationReport => ({ + success, + outputDir: "", + filesGenerated: {}, + errors, + warnings: [], + duration: 0, +}); + +/** A builder that accepts every call and reports the configured outcome. */ +const mkStubBuilder = (outcome: { success: boolean; errors?: string[] }): GenerationBuilder => { + const builder = { + generate: async () => mkReport(outcome.success, outcome.errors ?? []), + } as GenerationBuilder; + const methods = [ + "fromPackage", + "fromPackageRef", + "localTgzPackage", + "localStructureDefinitions", + "typeSchema", + "introspection", + "typescript", + "python", + "csharp", + "outputTo", + "cleanOutput", + "throwException", + ] as const; + for (const method of methods) { + (builder as unknown as Record GenerationBuilder>)[method] = () => builder; + } + return builder; +}; + +const writeConfig = (content: unknown | string): string => { + const dir = mkdtempSync(Path.join(tmpdir(), "atomic-codegen-cli-")); + const configPath = Path.join(dir, "codegen.json"); + writeFileSync(configPath, typeof content === "string" ? content : JSON.stringify(content, null, 2), "utf-8"); + return configPath; +}; + +const twoBuilderConfig = { + version: 1, + builders: [ + { + name: "core", + fromPackages: [{ name: "hl7.fhir.r4.core", version: "4.0.1" }], + typescript: {}, + outputTo: "./out/core", + }, + { + name: "extras", + fromPackages: [{ name: "example.extras", version: "1.0.0" }], + typescript: {}, + outputTo: "./out/extras", + }, + ], +}; + +type Captured = { lines: string[]; restore: () => void }; + +const captureConsole = (): Captured => { + const lines: string[] = []; + const push = + () => + (...args: unknown[]): void => { + lines.push(args.map(String).join(" ")); + }; + const log = spyOn(console, "log").mockImplementation(push()); + const error = spyOn(console, "error").mockImplementation(push()); + const warn = spyOn(console, "warn").mockImplementation(push()); + return { + lines, + restore: () => { + log.mockRestore(); + error.mockRestore(); + warn.mockRestore(); + }, + }; +}; + +let captured: Captured | undefined; + +afterEach(() => { + captured?.restore(); + captured = undefined; +}); + +describe("generate command module", () => { + it("exposes an accurate command surface", () => { + expect(generateCommand.command).toBe("generate"); + expect(generateCommand.describe).toBe("Generate code from a JSON configuration file"); + + const options = generateCommand.builder as Record>; + expect(options.config!.demandOption).toBe(true); + expect(options.config!.alias).toBe("c"); + expect(options.config!.type).toBe("string"); + expect(String(options.config!.describe)).toContain("resolve against its directory"); + expect(options["dry-run"]!.type).toBe("boolean"); + expect(options["dry-run"]!.default).toBe(false); + }); +}); + +describe("runGenerateCommand", () => { + it("returns 0 and reports each builder on success", async () => { + const configPath = writeConfig(twoBuilderConfig); + captured = captureConsole(); + + const code = await runGenerateCommand( + { config: configPath }, + { createBuilder: () => mkStubBuilder({ success: true }) }, + ); + + expect(code).toBe(0); + const output = captured.lines.join("\n"); + expect(output).toContain("core: generated into"); + expect(output).toContain("extras: generated into"); + }); + + it("dry run prints the plan and generates nothing", async () => { + const configPath = writeConfig(twoBuilderConfig); + const outputDir = Path.join(Path.dirname(configPath), "out"); + captured = captureConsole(); + + const code = await runGenerateCommand( + { config: configPath, dryRun: true }, + { + createBuilder: () => { + throw new Error("dry run must not construct a builder"); + }, + }, + ); + + expect(code).toBe(0); + expect(existsSync(outputDir)).toBe(false); + const output = captured.lines.join("\n"); + expect(output).toContain("Generate plan (2 builder(s)):"); + expect(output).toContain(Path.join(outputDir, "core")); + expect(output).toContain("Dry run: nothing was generated"); + }); + + it("returns 1 and lists every failing builder", async () => { + const configPath = writeConfig(twoBuilderConfig); + captured = captureConsole(); + const outcomes = [ + { success: false, errors: ["typescript generator failed: boom"] }, + { success: false, errors: ["python generator failed: kaboom"] }, + ]; + let index = 0; + + const code = await runGenerateCommand( + { config: configPath }, + { + createBuilder: () => { + const outcome = outcomes[index] ?? { success: true }; + index += 1; + return mkStubBuilder(outcome); + }, + }, + ); + + expect(code).toBe(1); + const output = captured.lines.join("\n"); + expect(output).toContain("core: FAILED"); + expect(output).toContain("typescript generator failed: boom"); + expect(output).toContain("extras: FAILED"); + expect(output).toContain("python generator failed: kaboom"); + expect(output).toContain("2 of 2 builder(s) failed: core, extras"); + }); + + it("keeps going after one builder fails", async () => { + const configPath = writeConfig(twoBuilderConfig); + captured = captureConsole(); + let constructed = 0; + + const code = await runGenerateCommand( + { config: configPath }, + { + createBuilder: () => { + constructed += 1; + return mkStubBuilder(constructed === 1 ? { success: false, errors: ["boom"] } : { success: true }); + }, + }, + ); + + expect(code).toBe(1); + expect(constructed).toBe(2); + const output = captured.lines.join("\n"); + expect(output).toContain("extras: generated into"); + expect(output).toContain("1 of 2 builder(s) failed: core"); + }); + + it("returns 1 with a config error naming the offending path", async () => { + const configPath = writeConfig({ + version: 1, + builders: [{ name: "core", fromPackages: [{ name: "x", version: "1.0.0" }], typescript: {} }], + }); + captured = captureConsole(); + + const code = await runGenerateCommand({ config: configPath }); + + expect(code).toBe(1); + expect(captured.lines.join("\n")).toContain("builders[0].outputTo: outputTo is required"); + }); + + it("returns 1 when the config file is not valid JSON", async () => { + const configPath = writeConfig("{ not json"); + captured = captureConsole(); + + const code = await runGenerateCommand({ config: configPath }); + + expect(code).toBe(1); + expect(captured.lines.join("\n")).toContain("is not valid JSON"); + }); + + it("returns 1 when the config file does not exist", async () => { + const configPath = Path.join(mkdtempSync(Path.join(tmpdir(), "atomic-codegen-cli-")), "missing.json"); + captured = captureConsole(); + + const code = await runGenerateCommand({ config: configPath }); + + expect(code).toBe(1); + expect(captured.lines.join("\n")).toContain("Cannot read config file"); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index d4b06550..9f98fa23 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,6 +9,7 @@ "allowJs": true, // Bundler mode "moduleResolution": "bundler", + "resolveJsonModule": true, "allowImportingTsExtensions": true, "verbatimModuleSyntax": true, "noEmit": true,