diff --git a/CLAUDE.md b/CLAUDE.md index 5bfce572f..96409a5e1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -241,7 +241,7 @@ assets/api/writer-generator/ 1. Use `builder.introspection({ typeSchemas: "./debug-schemas" })` to inspect intermediate output 2. Check `src/typeschema/types.ts` for TypeSchema structure 3. Review `src/typeschema/core/transformer.ts` for transformation logic -4. Enable verbose logging with `builder.setLogLevel("DEBUG")` +4. Enable verbose logging by passing `mkCodegenLogger({ level: "DEBUG" })` to the builder ### Testing Generated Code 1. Use `builder.build()` instead of `generate()` to avoid file I/O diff --git a/docs/guides/testing-generators.md b/docs/guides/testing-generators.md index 4f34fb059..6087ed868 100644 --- a/docs/guides/testing-generators.md +++ b/docs/guides/testing-generators.md @@ -56,11 +56,10 @@ All generator tests follow this basic structure: ```typescript import { describe, expect, it } from "bun:test"; import { APIBuilder } from "@root/api/builder"; -import { r4Manager } from "@typeschema-test/utils"; +import { silentLogger, r4Manager } from "@typeschema-test/utils"; describe("TypeScript Writer Generator", async () => { - const result = await new APIBuilder({ manager: r4Manager }) - .setLogLevel("SILENT") + const result = await new APIBuilder({ manager: r4Manager, logger: silentLogger }) .typescript({ inMemoryOnly: true, }) @@ -79,8 +78,7 @@ describe("TypeScript Writer Generator", async () => { ### Key Components **APIBuilder Setup:** -- Initialize with test manager: `new APIBuilder({ manager: r4Manager })` -- Set log level to SILENT to reduce noise: `.setLogLevel("SILENT")` +- Initialize with test manager and silent logger: `new APIBuilder({ manager: r4Manager, logger: silentLogger })` - Choose generator method: `.typescript()`, `.python()`, `.csharp()`, `.mustache()` - Enable in-memory mode: `inMemoryOnly: true` (no file I/O) diff --git a/docs/guides/writer-generator.md b/docs/guides/writer-generator.md index ed46e0c12..82e89d5ae 100644 --- a/docs/guides/writer-generator.md +++ b/docs/guides/writer-generator.md @@ -77,7 +77,7 @@ The `FileSystemWriter` class handles file I/O and directory management. It requi export type FileSystemWriterOptions = { outputDir: string; // Where to write files inMemoryOnly?: boolean; // Don't write to disk (for testing) - logger?: CodegenLogger; // Optional logging + logger?: CodegenLogManager; // Created automatically if not provided resolveAssets?: (fn: string) => string; // Asset resolution function }; ``` diff --git a/examples/local-package-folder/generate.ts b/examples/local-package-folder/generate.ts index fb24b17ec..7b1ca4212 100644 --- a/examples/local-package-folder/generate.ts +++ b/examples/local-package-folder/generate.ts @@ -1,12 +1,12 @@ import * as Path from "node:path"; import { fileURLToPath } from "node:url"; -import { APIBuilder, LogLevel, prettyReport } from "../../src/api"; +import { APIBuilder, prettyReport } from "../../src/api"; const __dirname = Path.dirname(fileURLToPath(import.meta.url)); async function generateFromLocalPackageFolder() { const builder = new APIBuilder({ - logLevel: LogLevel.INFO, + logLevel: "INFO", }); const report = await builder diff --git a/examples/mustache/mustache-java-r4-gen.ts b/examples/mustache/mustache-java-r4-gen.ts index 942bcac3f..4484436c7 100644 --- a/examples/mustache/mustache-java-r4-gen.ts +++ b/examples/mustache/mustache-java-r4-gen.ts @@ -1,10 +1,9 @@ -import { APIBuilder, prettyReport } from "../../src/api/builder"; +import { APIBuilder, mkCodegenLogger, prettyReport } from "../../src"; if (require.main === module) { console.log("📦 Generating FHIR R4 Core Types..."); - const builder = new APIBuilder() - .setLogLevel("DEBUG") + const builder = new APIBuilder({ logger: mkCodegenLogger({ level: "DEBUG" }) }) .throwException() .fromPackage("hl7.fhir.r4.core", "4.0.1") .outputTo("./examples/mustache/mustache-java-r4-output") diff --git a/examples/python/generate.ts b/examples/python/generate.ts index 3fb292500..e12f7782f 100644 --- a/examples/python/generate.ts +++ b/examples/python/generate.ts @@ -1,8 +1,13 @@ -import { APIBuilder, prettyReport } from "../../src"; +import { APIBuilder, mkCodegenLogger, prettyReport } from "../../src"; console.log("📦 Generating FHIR R4 Core Types..."); -const builder = new APIBuilder() +const logger = mkCodegenLogger({ + prefix: "API", + suppressTags: ["#fieldTypeNotFound", "#largeValueSet"], +}); + +const builder = new APIBuilder({ logger }) .throwException() .fromPackage("hl7.fhir.r4.core", "4.0.1") .python({ diff --git a/examples/typescript-r4/generate.ts b/examples/typescript-r4/generate.ts index 173407301..81b31c6d8 100644 --- a/examples/typescript-r4/generate.ts +++ b/examples/typescript-r4/generate.ts @@ -1,12 +1,12 @@ // Run this script using Bun CLI with: // bun run scripts/generate-fhir-types.ts -import { APIBuilder, prettyReport } from "../../src/api/builder"; +import { APIBuilder, mkCodegenLogger, prettyReport } from "../../src"; if (require.main === module) { console.log("📦 Generating FHIR R4 Core Types..."); - const builder = new APIBuilder() + const builder = new APIBuilder({ logger: mkCodegenLogger({ suppressTags: ["#fieldTypeNotFound"] }) }) .throwException() .fromPackage("hl7.fhir.r4.core", "4.0.1") .typescript({ diff --git a/src/api/builder.ts b/src/api/builder.ts index 146333d84..5ae84a4d5 100644 --- a/src/api/builder.ts +++ b/src/api/builder.ts @@ -23,13 +23,8 @@ import type { IrConf, LogicalPromotionConf, TreeShakeConf } from "@root/typesche import { type Register, registerFromManager } from "@root/typeschema/register"; import { type PackageMeta, packageMetaToNpm } from "@root/typeschema/types"; import { mkTypeSchemaIndex, type TypeSchemaIndex } from "@root/typeschema/utils"; -import { - type CodegenLogger, - createLogger, - type LogLevel, - type LogLevelString, - parseLogLevel, -} from "@root/utils/codegen-logger"; +import type { CodegenLogManager } from "@root/utils/log"; +import { mkLogger } from "@root/utils/log"; import { IntrospectionWriter, type IntrospectionWriterOptions } from "./writer-generator/introspection"; import { IrReportWriterWriter, type IrReportWriterWriterOptions } from "./writer-generator/ir-report"; import type { FileBasedMustacheGeneratorOptions } from "./writer-generator/mustache"; @@ -47,8 +42,6 @@ export interface APIBuilderOptions { treeShake: TreeShakeConf | undefined; promoteLogical: LogicalPromotionConf | undefined; - /** Log level for the logger. Default: INFO */ - logLevel: LogLevel; /** Custom FHIR package registry URL (default: https://fs.get-ig.org/pkgs/) */ registry: string | undefined; /** Drop the canonical manager cache */ @@ -100,7 +93,7 @@ export interface LocalStructureDefinitionConfig { dependencies?: PackageMeta[]; } -const cleanup = async (opts: APIBuilderOptions, logger: CodegenLogger): Promise => { +const cleanup = async (opts: APIBuilderOptions, logger: CodegenLogManager): Promise => { logger.info(`Cleaning outputs...`); try { logger.info(`Clean ${opts.outputDir}`); @@ -125,7 +118,7 @@ export class APIBuilder { localSDs: LocalPackageConfig[]; localTgzPackages: TgzPackageConfig[]; }; - private logger: CodegenLogger; + private logger: CodegenLogManager; private generators: { name: string; writer: FileSystemWriter }[] = []; constructor( @@ -133,7 +126,7 @@ export class APIBuilder { manager?: ReturnType; register?: Register; preprocessPackage?: (context: PreprocessContext) => PreprocessContext; - logger?: CodegenLogger; + logger?: CodegenLogManager; } = {}, ) { const defaultOpts: APIBuilderOptions = { @@ -143,7 +136,6 @@ export class APIBuilder { treeShake: undefined, promoteLogical: undefined, registry: undefined, - logLevel: parseLogLevel("INFO"), dropCanonicalManagerCache: false, }; const opts: APIBuilderOptions = { @@ -179,7 +171,7 @@ export class APIBuilder { dropCache: userOpts.dropCanonicalManagerCache, preprocessPackage: userOpts.preprocessPackage, }); - this.logger = userOpts.logger ?? createLogger({ prefix: "API", level: opts.logLevel }); + this.logger = userOpts.logger ?? mkLogger({ prefix: "api" }); this.options = opts; } @@ -340,11 +332,6 @@ export class APIBuilder { return this; } - setLogLevel(level: LogLevel | LogLevelString): APIBuilder { - this.logger?.setLevel(typeof level === "string" ? parseLogLevel(level) : level); - return this; - } - throwException(enabled = true): APIBuilder { this.options.throwException = enabled; return this; @@ -423,22 +410,26 @@ export class APIBuilder { const packageMetas = Object.values(ref2meta); register = await registerFromManager(this.manager, { - logger: this.logger, + logger: this.logger.fork("reg"), focusedPackages: packageMetas, }); } - const { schemas: typeSchemas, collisions } = await generateTypeSchemas(register, this.logger); + const tsLogger = this.logger.fork("ts"); + + const { schemas: typeSchemas, collisions } = await generateTypeSchemas(register, tsLogger); const tsIndexOpts = { register, - logger: this.logger, + logger: tsLogger, irReport: Object.keys(collisions).length > 0 ? { collisions } : {}, }; let tsIndex = mkTypeSchemaIndex(typeSchemas, tsIndexOpts); if (this.options.treeShake) tsIndex = treeShake(tsIndex, this.options.treeShake); if (this.options.promoteLogical) tsIndex = promoteLogical(tsIndex, this.options.promoteLogical); + tsLogger.printTagSummary(); + this.logger.debug(`Executing ${this.generators.length} generators`); await this.executeGenerators(result, tsIndex); @@ -449,7 +440,7 @@ export class APIBuilder { this.logger.debug(`Generation completed: ${result.filesGenerated.length} files`); } catch (error) { - this.logger.error("Code generation failed", error instanceof Error ? error : new Error(String(error))); + this.logger.error(`Code generation failed: ${error instanceof Error ? error.message : String(error)}`); result.errors.push(error instanceof Error ? error.message : String(error)); if (this.options.throwException) throw error; } diff --git a/src/api/index.ts b/src/api/index.ts index 2dbd32b35..d507869e2 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -8,7 +8,8 @@ */ export type { IrConf, LogicalPromotionConf, TreeShakeConf } from "../typeschema/ir/types"; -export { LogLevel } from "../utils/codegen-logger"; +export type { CodegenLog, CodegenLogManager, CodegenTag, LogLevel } from "../utils/log"; +export { mkCodegenLogger } from "../utils/log"; export type { APIBuilderOptions, LocalStructureDefinitionConfig } from "./builder"; export { APIBuilder, prettyReport } from "./builder"; export type { CSharpGeneratorOptions } from "./writer-generator/csharp/csharp"; diff --git a/src/api/writer-generator/mustache.ts b/src/api/writer-generator/mustache.ts index cf270e680..81b26744a 100644 --- a/src/api/writer-generator/mustache.ts +++ b/src/api/writer-generator/mustache.ts @@ -22,7 +22,7 @@ import type { ViewModel, } from "@mustache/types"; import type { TypeSchemaIndex } from "@root/typeschema/utils"; -import type { CodegenLogger } from "@root/utils/codegen-logger"; +import type { CodegenLog } from "@root/utils/log"; import { default as Mustache } from "mustache"; import { FileSystemWriter, type FileSystemWriterOptions } from "./writer"; @@ -57,7 +57,7 @@ export type MustacheGeneratorOptions = FileSystemWriterOptions & export function loadMustacheGeneratorConfig( templatePath: string, - logger?: CodegenLogger, + logger?: CodegenLog, ): Partial { const filePath = Path.resolve(templatePath, "config.json"); try { diff --git a/src/api/writer-generator/writer.ts b/src/api/writer-generator/writer.ts index 70938815b..81c946d1b 100644 --- a/src/api/writer-generator/writer.ts +++ b/src/api/writer-generator/writer.ts @@ -2,12 +2,12 @@ import * as fs from "node:fs"; import * as fsPromises from "node:fs/promises"; import * as Path from "node:path"; import type { TypeSchemaIndex } from "@root/typeschema/utils"; -import type { CodegenLogger } from "@root/utils/codegen-logger"; +import type { CodegenLog } from "@root/utils/log"; export type FileSystemWriterOptions = { outputDir: string; inMemoryOnly?: boolean; - logger?: CodegenLogger; + logger?: CodegenLog; resolveAssets?: (fn: string) => string; }; @@ -36,7 +36,7 @@ export abstract class FileSystemWriter = { - debug: LogLevel.DEBUG, - info: LogLevel.INFO, - warn: LogLevel.WARN, - error: LogLevel.ERROR, - silent: LogLevel.SILENT, - }; - return levelMap[level.toLowerCase()]; -} +let cliLogger = mkLogger({ prefix: "cli" }); -/** - * Middleware to setup logging - */ async function setupLoggingMiddleware(argv: any) { - // Determine log level: explicit --log-level takes precedence over --verbose/--debug - let level = parseLogLevel(argv.logLevel); - - // If no explicit log level, use --verbose or --debug as shortcuts - if (level === undefined) { - if (argv.debug || argv.verbose) { - level = LogLevel.DEBUG; - } else { - level = LogLevel.INFO; - } - } - - // Configure the CliLogger with user preferences - configure({ - timestamp: argv.debug, - level, - }); + const level: LogLevel = argv.logLevel ?? (argv.debug || argv.verbose ? "DEBUG" : "INFO"); + cliLogger = mkLogger({ prefix: "cli", level }); } /** @@ -84,8 +55,8 @@ export function createCLI() { .option("log-level", { alias: "l", type: "string", - choices: ["debug", "info", "warn", "error", "silent"] as const, - description: "Set the log level (default: info)", + choices: ["DEBUG", "INFO", "WARN", "ERROR", "SILENT"] as const, + description: "Set the log level (default: INFO)", global: true, }) .demandCommand(0) // Allow 0 commands so we can handle it ourselves @@ -110,13 +81,8 @@ export function createCLI() { "Generate TypeSchemas from FHIR package", ) .fail((msg, err, _yargs) => { - if (err) { - error(err.message, err); - } else { - error(msg); - } - - error("\nUse --help for usage information"); + cliLogger.error(err ? err.message : msg); + cliLogger.error("Use --help for usage information"); process.exit(1); }) .wrap(Math.min(120, process.stdout.columns || 80)); @@ -132,8 +98,8 @@ export async function runCLI() { // Run CLI if this file is executed directly if (import.meta.main) { - runCLI().catch((error) => { - error("Unexpected error:", error); + runCLI().catch((err) => { + cliLogger.error(String(err)); process.exit(1); }); } diff --git a/src/cli/commands/typeschema.ts b/src/cli/commands/typeschema.ts index 37863c16e..b4431a5f7 100644 --- a/src/cli/commands/typeschema.ts +++ b/src/cli/commands/typeschema.ts @@ -4,13 +4,16 @@ * Commands for validating and managing TypeSchema files */ -import { error, info, list } from "@root/utils/codegen-logger"; +import { list } from "@root/utils/cli-fmt"; +import { mkLogger } from "@root/utils/log"; import type { CommandModule } from "yargs"; import { generateTypeschemaCommand } from "./typeschema/generate"; /** * TypeSchema command group */ +const logger = mkLogger({ prefix: "typeschema" }); + export const typeschemaCommand: CommandModule = { command: "typeschema [subcommand]", describe: "TypeSchema operations - generate, validate and merge schemas", @@ -21,9 +24,8 @@ export const typeschemaCommand: CommandModule = { .example("$0 typeschema generate hl7.fhir.r4.core@4.0.1", "Generate TypeSchema from FHIR R4 core package"); }, handler: (argv: any) => { - // If no subcommand provided, show available subcommands if (!argv.subcommand && argv._.length === 1) { - info("Available typeschema subcommands:"); + logger.info("Available typeschema subcommands:"); list(["generate Generate TypeSchema files from FHIR packages"]); console.log( "\nUse 'atomic-codegen typeschema --help' for more information about a subcommand.", @@ -37,10 +39,9 @@ export const typeschemaCommand: CommandModule = { return; } - // If unknown subcommand provided, show error and available commands if (argv.subcommand && !["generate", "validate", "merge"].includes(argv.subcommand)) { - error(`Unknown typeschema subcommand: ${argv.subcommand}\n`); - info("Available typeschema subcommands:"); + logger.error(`Unknown typeschema subcommand: ${argv.subcommand}`); + logger.info("Available typeschema subcommands:"); list([ "generate Generate TypeSchema files from FHIR packages", "validate Validate TypeSchema files for correctness and consistency", diff --git a/src/cli/commands/typeschema/generate.ts b/src/cli/commands/typeschema/generate.ts index e75b6d167..93b12b3c9 100644 --- a/src/cli/commands/typeschema/generate.ts +++ b/src/cli/commands/typeschema/generate.ts @@ -6,7 +6,8 @@ import { mkdir, writeFile } from "node:fs/promises"; import { dirname } from "node:path"; -import { complete, createLogger, list } from "@root/utils/codegen-logger"; +import { complete, list } from "@root/utils/cli-fmt"; +import { mkLogger } from "@root/utils/log"; import { generateTypeSchemas } from "@typeschema/index"; import { registerFromPackageMetas } from "@typeschema/register"; import type { PackageMeta } from "@typeschema/types"; @@ -73,12 +74,12 @@ export const generateTypeschemaCommand: CommandModule, G }, }, handler: async (argv) => { - const logger = createLogger({ + const logger = mkLogger({ prefix: "TypeSchema", }); try { - logger.step("Generating TypeSchema from FHIR packages"); + logger.info("Generating TypeSchema from FHIR packages"); logger.info(`Packages: ${argv.packages.join(", ")}`); logger.info(`Output: ${argv.output}`); @@ -113,7 +114,7 @@ export const generateTypeschemaCommand: CommandModule, G return { name: packageSpec, version: "latest" }; }); - logger.progress(`Processing packages: ${packageMetas.map((p) => `${p.name}@${p.version}`).join(", ")}`); + logger.info(`Processing packages: ${packageMetas.map((p) => `${p.name}@${p.version}`).join(", ")}`); // Create register from packages const register = await registerFromPackageMetas(packageMetas, { @@ -149,7 +150,7 @@ export const generateTypeschemaCommand: CommandModule, G const duration = Date.now() - startTime; complete(`Generated ${allSchemas.length} TypeSchema definitions`, duration, { schemas: allSchemas.length }); - logger.dim(`Output: ${outputPath}`); + logger.info(`Output: ${outputPath}`); if (argv.verbose) { logger.debug("Generated schemas:"); @@ -160,7 +161,7 @@ export const generateTypeschemaCommand: CommandModule, G list(schemaNames); } } catch (error) { - logger.error("Failed to generate TypeSchema", error instanceof Error ? error : new Error(String(error))); + logger.error(`Failed to generate TypeSchema: ${error instanceof Error ? error.message : String(error)}`); process.exit(1); } }, diff --git a/src/typeschema/core/binding.ts b/src/typeschema/core/binding.ts index e8bbb1396..2e120779d 100644 --- a/src/typeschema/core/binding.ts +++ b/src/typeschema/core/binding.ts @@ -7,7 +7,7 @@ import assert from "node:assert"; import type { FHIRSchemaElement } from "@atomic-ehr/fhirschema"; import type { CodeSystem, CodeSystemConcept } from "@root/fhir-types/hl7-fhir-r4-core"; -import type { CodegenLogger } from "@root/utils/codegen-logger"; +import type { CodegenLog } from "@root/utils/log"; import type { Register } from "@typeschema/register"; import type { BindingTypeSchema, @@ -24,7 +24,7 @@ export function extractValueSetConceptsByUrl( register: Register, pkg: PackageMeta, valueSetUrl: CanonicalUrl, - logger?: CodegenLogger, + logger?: CodegenLog, ): Concept[] | undefined { const cleanUrl = dropVersionFromUrl(valueSetUrl) || valueSetUrl; const valueSet = register.resolveVs(pkg, cleanUrl as CanonicalUrl); @@ -35,7 +35,7 @@ export function extractValueSetConceptsByUrl( function extractValueSetConcepts( register: Register, valueSet: RichValueSet, - _logger?: CodegenLogger, + _logger?: CodegenLog, ): Concept[] | undefined { if (valueSet.expansion?.contains) { return valueSet.expansion.contains @@ -106,7 +106,7 @@ export function buildEnum( register: Register, fhirSchema: RichFHIRSchema, element: FHIRSchemaElement, - logger?: CodegenLogger, + logger?: CodegenLog, ): EnumDefinition | undefined { if (!element.binding) return undefined; @@ -115,7 +115,10 @@ export function buildEnum( if (!valueSetUrl) return undefined; if (!BINDABLE_TYPES.has(element.type ?? "")) { - logger?.dryWarn(`eld-11: Binding on non-bindable type '${element.type}' (valueSet: ${valueSetUrl})`); + logger?.dryWarn( + "#binding", + `eld-11: Binding on non-bindable type '${element.type}' (valueSet: ${valueSetUrl})`, + ); return undefined; } @@ -132,6 +135,7 @@ export function buildEnum( if (codes.length > MAX_ENUM_LENGTH) { logger?.dryWarn( + "#largeValueSet", `Value set ${valueSetUrl} has ${codes.length} which is more than ${MAX_ENUM_LENGTH} codes, which may cause issues with code generation.`, ); return undefined; @@ -146,7 +150,7 @@ function generateBindingSchema( fhirSchema: RichFHIRSchema, path: string[], element: FHIRSchemaElement, - logger?: CodegenLogger, + logger?: CodegenLog, ): BindingTypeSchema | undefined { if (!element.binding?.valueSet) return undefined; @@ -171,7 +175,7 @@ function generateBindingSchema( export function collectBindingSchemas( register: Register, fhirSchema: RichFHIRSchema, - logger?: CodegenLogger, + logger?: CodegenLog, ): BindingTypeSchema[] { const processedPaths = new Set(); if (!fhirSchema.elements) return []; diff --git a/src/typeschema/core/field-builder.ts b/src/typeschema/core/field-builder.ts index 3676adce0..3b8acbf4e 100644 --- a/src/typeschema/core/field-builder.ts +++ b/src/typeschema/core/field-builder.ts @@ -6,7 +6,7 @@ import type { FHIRSchemaElement } from "@atomic-ehr/fhirschema"; import type { Register } from "@root/typeschema/register"; -import type { CodegenLogger } from "@root/utils/codegen-logger"; +import type { CodegenLog } from "@root/utils/log"; import { packageMetaToFhir } from "@typeschema/types"; import type { BindingIdentifier, @@ -224,7 +224,7 @@ export function buildFieldType( fhirSchema: RichFHIRSchema, path: string[], element: FHIRSchemaElement, - logger?: CodegenLogger, + logger?: CodegenLog, ): Identifier | undefined { if (element.elementReference) { const refPath = element.elementReference @@ -247,6 +247,7 @@ export function buildFieldType( // Some packages (e.g., simplifier.core.r4.*) have incomplete element definitions // Log a warning but continue processing instead of throwing logger?.dryWarn( + "#fieldTypeNotFound", `Can't recognize element type: <${fhirSchema.url}>.${path.join(".")} (pkg: '${packageMetaToFhir(fhirSchema.package_meta)}'): missing type info`, ); return undefined; @@ -258,7 +259,7 @@ export const mkField = ( fhirSchema: RichFHIRSchema, path: string[], element: FHIRSchemaElement, - logger?: CodegenLogger, + logger?: CodegenLog, rawElement?: FHIRSchemaElement, ): Field => { let binding: BindingIdentifier | undefined; @@ -274,7 +275,10 @@ export const mkField = ( const fieldType = buildFieldType(register, fhirSchema, path, element, logger); // TODO: should be an exception if (!fieldType) - logger?.dryWarn(`Field type not found for '${fhirSchema.url}#${path.join(".")}' (${fhirSchema.derivation})`); + logger?.dryWarn( + "#fieldTypeNotFound", + `Field type not found for '${fhirSchema.url}#${path.join(".")}' (${fhirSchema.derivation})`, + ); let valueConstraint: ValueConstraint | undefined; if (element.pattern) { diff --git a/src/typeschema/core/nested-types.ts b/src/typeschema/core/nested-types.ts index b2da3ad54..957c4dee8 100644 --- a/src/typeschema/core/nested-types.ts +++ b/src/typeschema/core/nested-types.ts @@ -6,7 +6,7 @@ import type { FHIRSchema, FHIRSchemaElement } from "@atomic-ehr/fhirschema"; import { mergeFsElementProps, type Register, resolveFsElementGenealogy } from "@root/typeschema/register"; -import type { CodegenLogger } from "@root/utils/codegen-logger"; +import type { CodegenLog } from "@root/utils/log"; import type { CanonicalUrl, Field, Identifier, Name, NestedIdentifier, NestedType, RichFHIRSchema } from "../types"; import { mkField, mkNestedField } from "./field-builder"; @@ -114,7 +114,7 @@ function transformNestedElements( fhirSchema: RichFHIRSchema, parentPath: string[], elements: Record, - logger?: CodegenLogger, + logger?: CodegenLog, ): Record { const fields: Record = {}; @@ -148,7 +148,7 @@ function transformNestedElements( export function mkNestedTypes( register: Register, fhirSchema: RichFHIRSchema, - logger?: CodegenLogger, + logger?: CodegenLog, ): NestedType[] | undefined { if (!fhirSchema.elements) return undefined; diff --git a/src/typeschema/core/profile-extensions.ts b/src/typeschema/core/profile-extensions.ts index c72105850..484effae2 100644 --- a/src/typeschema/core/profile-extensions.ts +++ b/src/typeschema/core/profile-extensions.ts @@ -7,7 +7,7 @@ import type { FHIRSchemaElement } from "@atomic-ehr/fhirschema"; import type { Register } from "@root/typeschema/register"; -import type { CodegenLogger } from "@root/utils/codegen-logger"; +import type { CodegenLog } from "@root/utils/log"; import { type CanonicalUrl, concatIdentifiers, @@ -24,7 +24,7 @@ const extractExtensionValueTypes = ( register: Register, fhirSchema: RichFHIRSchema, extensionUrl: CanonicalUrl, - logger?: CodegenLogger, + logger?: CodegenLog, ): Identifier[] | undefined => { const extensionSchema = register.resolveFs(fhirSchema.package_meta, extensionUrl); if (!extensionSchema?.elements) return undefined; @@ -42,7 +42,7 @@ const extractExtensionValueTypes = ( const extractLegacySubExtensions = ( register: Register, extensionSchema: RichFHIRSchema, - logger?: CodegenLogger, + logger?: CodegenLog, ): ExtensionSubField[] => { const subExtensions: ExtensionSubField[] = []; if (!extensionSchema.elements) return subExtensions; @@ -114,7 +114,7 @@ const extractSubExtensions = ( register: Register, fhirSchema: RichFHIRSchema, extensionUrl: CanonicalUrl, - logger?: CodegenLogger, + logger?: CodegenLog, ): ExtensionSubField[] | undefined => { const extensionSchema = register.resolveFs(fhirSchema.package_meta, extensionUrl); if (!extensionSchema?.elements) return undefined; @@ -129,7 +129,7 @@ const extractSubExtensions = ( export const extractProfileExtensions = ( register: Register, fhirSchema: RichFHIRSchema, - logger?: CodegenLogger, + logger?: CodegenLog, ): ProfileExtension[] | undefined => { const extensions: ProfileExtension[] = []; diff --git a/src/typeschema/core/transformer.ts b/src/typeschema/core/transformer.ts index 316618b31..6e759bfa0 100644 --- a/src/typeschema/core/transformer.ts +++ b/src/typeschema/core/transformer.ts @@ -6,7 +6,7 @@ import type { FHIRSchemaElement } from "@atomic-ehr/fhirschema"; import { shouldSkipCanonical } from "@root/typeschema/skip-hack"; -import type { CodegenLogger } from "@root/utils/codegen-logger"; +import type { CodegenLog } from "@root/utils/log"; import type { Register } from "@typeschema/register"; import { concatIdentifiers, @@ -33,7 +33,7 @@ export function mkFields( fhirSchema: RichFHIRSchema, parentPath: string[], elements: Record | undefined, - logger?: CodegenLogger, + logger?: CodegenLog, ): Record | undefined { if (!elements) return undefined; @@ -44,6 +44,7 @@ export function mkFields( const fcurl = elemSnapshot.type ? register.ensureSpecializationCanonicalUrl(elemSnapshot.type) : undefined; if (fcurl && shouldSkipCanonical(fhirSchema.package_meta, fcurl).shouldSkip) { logger?.warn( + "#skipCanonical", `Skipping field ${path} for ${fcurl} due to skip hack ${shouldSkipCanonical(fhirSchema.package_meta, fcurl).reason}`, ); continue; @@ -76,7 +77,7 @@ function extractFieldDependencies(fields: Record): Identifier[] { export async function transformValueSet( register: Register, valueSet: RichValueSet, - logger?: CodegenLogger, + logger?: CodegenLog, ): Promise { if (!valueSet.url) throw new Error("ValueSet URL is required"); @@ -116,7 +117,7 @@ export function extractDependencies( function transformFhirSchemaResource( register: Register, fhirSchema: RichFHIRSchema, - logger?: CodegenLogger, + logger?: CodegenLog, ): TypeSchema[] { const identifier = mkIdentifier(fhirSchema); @@ -158,7 +159,7 @@ function transformFhirSchemaResource( export async function transformFhirSchema( register: Register, fhirSchema: RichFHIRSchema, - logger?: CodegenLogger, + logger?: CodegenLog, ): Promise { return transformFhirSchemaResource(register, fhirSchema, logger); } diff --git a/src/typeschema/index.ts b/src/typeschema/index.ts index 9aa625a60..3f4d78161 100644 --- a/src/typeschema/index.ts +++ b/src/typeschema/index.ts @@ -10,7 +10,7 @@ * - Validating TypeSchema documents */ -import type { CodegenLogger } from "@root/utils/codegen-logger"; +import type { CodegenLog } from "@root/utils/log"; import { transformFhirSchema, transformValueSet } from "./core/transformer"; import type { TypeSchemaCollisions } from "./ir/types"; import type { Register } from "./register"; @@ -33,10 +33,7 @@ type SchemaWithSource = { sourceCanonical: CanonicalUrl; }; -const deduplicateSchemas = ( - schemasWithSources: SchemaWithSource[], - logger?: CodegenLogger, -): GenerateTypeSchemasResult => { +const deduplicateSchemas = (schemasWithSources: SchemaWithSource[], logger?: CodegenLog): GenerateTypeSchemasResult => { // key -> hash const groups: Record> = {}; @@ -62,7 +59,7 @@ const deduplicateSchemas = ( if (sorted.length > 1) { const pkg = best.typeSchema.identifier.package; const url = best.typeSchema.identifier.url; - logger?.dryWarn(`'${url}' from '${pkg}'' has ${sorted.length} versions`); + logger?.dryWarn("#duplicateSchema", `'${url}' from '${pkg}' has ${sorted.length} versions`); collisions[pkg] ??= {}; collisions[pkg][url] = sorted.flatMap((v) => v.sources.map((s) => ({ @@ -79,7 +76,7 @@ const deduplicateSchemas = ( export const generateTypeSchemas = async ( register: Register, - logger?: CodegenLogger, + logger?: CodegenLog, ): Promise => { const schemasWithSources: { schema: TypeSchema; sourcePackage: PkgName; sourceCanonical: CanonicalUrl }[] = []; @@ -88,7 +85,7 @@ export const generateTypeSchemas = async ( const skipCheck = shouldSkipCanonical(fhirSchema.package_meta, fhirSchema.url); if (skipCheck.shouldSkip) { - logger?.dryWarn(`Skip ${fhirSchema.url} from ${pkgId}. Reason: ${skipCheck.reason}`); + logger?.dryWarn("#skipCanonical", `Skip ${fhirSchema.url} from ${pkgId}. Reason: ${skipCheck.reason}`); continue; } diff --git a/src/typeschema/ir/tree-shake.ts b/src/typeschema/ir/tree-shake.ts index fad2f2d51..cc5a0bc70 100644 --- a/src/typeschema/ir/tree-shake.ts +++ b/src/typeschema/ir/tree-shake.ts @@ -1,5 +1,5 @@ import assert from "node:assert"; -import type { CodegenLogger } from "@root/utils/codegen-logger"; +import type { CodegenLog } from "@root/utils/log"; import { extractDependencies } from "../core/transformer"; import { type CanonicalUrl, @@ -174,7 +174,7 @@ const mutableFillReport = (report: TreeShakeReport, tsIndex: TypeSchemaIndex, sh } }; -export const treeShakeTypeSchema = (schema: TypeSchema, rule: TreeShakeRule, _logger?: CodegenLogger): TypeSchema => { +export const treeShakeTypeSchema = (schema: TypeSchema, rule: TreeShakeRule, _logger?: CodegenLog): TypeSchema => { schema = JSON.parse(JSON.stringify(schema)); if (isPrimitiveTypeSchema(schema) || isValueSetTypeSchema(schema) || isBindingSchema(schema)) return schema; diff --git a/src/typeschema/register.ts b/src/typeschema/register.ts index 59abcde91..141dfccac 100644 --- a/src/typeschema/register.ts +++ b/src/typeschema/register.ts @@ -7,7 +7,7 @@ import { type StructureDefinition, } from "@atomic-ehr/fhirschema"; import { type CodeSystem, isCodeSystem, isValueSet, type ValueSet } from "@root/fhir-types/hl7-fhir-r4-core"; -import type { CodegenLogger } from "@root/utils/codegen-logger"; +import type { CodegenLog } from "@root/utils/log"; import type { CanonicalUrl, Name, @@ -86,7 +86,7 @@ const mkPackageAwareResolver = async ( pkg: PackageMeta, deep: number, acc: PackageAwareResolver, - logger?: CodegenLogger, + logger?: CodegenLog, ): Promise => { const pkgId = packageMetaToFhir(pkg); logger?.info(`${" ".repeat(deep * 2)}+ ${pkgId}`); @@ -98,7 +98,8 @@ const mkPackageAwareResolver = async ( if (!rawUrl) continue; if (!(isStructureDefinition(resource) || isValueSet(resource) || isCodeSystem(resource))) continue; const url = rawUrl as CanonicalUrl; - if (index.canonicalResolution[url]) logger?.dryWarn(`Duplicate canonical URL: ${url} at ${pkgId}.`); + if (index.canonicalResolution[url]) + logger?.dryWarn("#duplicateCanonical", `Duplicate canonical URL: ${url} at ${pkgId}.`); index.canonicalResolution[url] = [{ deep, pkg: pkg, pkgId, resource: resource as FocusedResource }]; } @@ -118,7 +119,7 @@ const mkPackageAwareResolver = async ( return index; }; -const enrichResolver = (resolver: PackageAwareResolver, logger?: CodegenLogger) => { +const enrichResolver = (resolver: PackageAwareResolver, logger?: CodegenLog) => { for (const { pkg, canonicalResolution } of Object.values(resolver)) { const pkgId = packageMetaToFhir(pkg); if (!resolver[pkgId]) throw new Error(`Package ${pkgId} not found`); @@ -144,11 +145,7 @@ const enrichResolver = (resolver: PackageAwareResolver, logger?: CodegenLogger) } }; -const packageAgnosticResolveCanonical = ( - resolver: PackageAwareResolver, - url: CanonicalUrl, - _logger?: CodegenLogger, -) => { +const packageAgnosticResolveCanonical = (resolver: PackageAwareResolver, url: CanonicalUrl, _logger?: CodegenLog) => { const options = Object.values(resolver).flatMap((pkg) => pkg.canonicalResolution[url]); if (!options) throw new Error(`No canonical resolution found for ${url} in any package`); // if (options.length > 1) @@ -163,7 +160,7 @@ const packageAgnosticResolveCanonical = ( }; export type RegisterConfig = { - logger?: CodegenLogger; + logger?: CodegenLog; focusedPackages?: PackageMeta[]; /** Custom FHIR package registry URL */ registry?: string; @@ -178,7 +175,7 @@ export const registerFromManager = async ( for (const pkg of packages) { await mkPackageAwareResolver(manager, pkg, 0, resolver, logger); } - enrichResolver(resolver); + enrichResolver(resolver, logger); const resolveFs = (pkg: PackageMeta, canonicalUrl: CanonicalUrl) => { const pkgIndex = resolver[packageMetaToFhir(pkg)]; @@ -343,7 +340,7 @@ export const registerFromPackageMetas = async ( conf: RegisterConfig, ): Promise => { const packageNames = packageMetas.map(packageMetaToNpm); - conf?.logger?.step(`Loading FHIR packages: ${packageNames.join(", ")}`); + conf?.logger?.info(`Loading FHIR packages: ${packageNames.join(", ")}`); const manager = CanonicalManager({ packages: packageNames, workingDir: ".codegen-cache/canonical-manager-cache", diff --git a/src/typeschema/types.ts b/src/typeschema/types.ts index 35b214c07..b452817b7 100644 --- a/src/typeschema/types.ts +++ b/src/typeschema/types.ts @@ -7,6 +7,7 @@ import { createHash } from "node:crypto"; import type { CanonicalManager } from "@atomic-ehr/fhir-canonical-manager"; import type * as FS from "@atomic-ehr/fhirschema"; import type { StructureDefinition, ValueSet, ValueSetCompose } from "@root/fhir-types/hl7-fhir-r4-core"; +import type { CodegenLog } from "@root/utils/log"; export type Name = string & { readonly __brand: unique symbol }; export type CanonicalUrl = string & { readonly __brand: unique symbol }; @@ -362,7 +363,7 @@ export const enrichValueSet = (vs: ValueSet, packageMeta: PackageMeta): RichValu /////////////////////////////////////////////////////////// export interface TypeschemaGeneratorOptions { - logger?: import("../utils/codegen-logger").CodegenLogger; + logger?: CodegenLog; treeshake?: string[]; manager: ReturnType; /** Custom FHIR package registry URL */ diff --git a/src/typeschema/utils.ts b/src/typeschema/utils.ts index 340ec39ed..f2dda9007 100644 --- a/src/typeschema/utils.ts +++ b/src/typeschema/utils.ts @@ -1,6 +1,6 @@ import * as afs from "node:fs/promises"; import * as Path from "node:path"; -import type { CodegenLogger } from "@root/utils/codegen-logger"; +import type { CodegenLog } from "@root/utils/log"; import * as YAML from "yaml"; import type { IrReport } from "./ir/types"; import type { Register } from "./register"; @@ -199,7 +199,7 @@ export const mkTypeSchemaIndex = ( irReport = {}, }: { register?: Register; - logger?: CodegenLogger; + logger?: CodegenLog; irReport?: IrReport; }, ): TypeSchemaIndex => { @@ -274,6 +274,7 @@ export const mkTypeSchemaIndex = ( const resolved = resolve(base); if (!resolved) { logger?.warn( + "#resolveBase", `Failed to resolve base type: ${res.map((e) => `${e.identifier.url} (${e.identifier.kind})`).join(", ")}`, ); return undefined; diff --git a/src/utils/cli-fmt.ts b/src/utils/cli-fmt.ts new file mode 100644 index 000000000..dc0b08ba4 --- /dev/null +++ b/src/utils/cli-fmt.ts @@ -0,0 +1,23 @@ +import pc from "picocolors"; + +export const header = (title: string): void => { + console.log(); + console.log(pc.cyan(pc.bold(`━━━ ${title} ━━━`))); +}; + +export const complete = (message: string, duration?: number, stats?: Record): void => { + let msg = message; + if (duration) msg += ` ${pc.gray(`(${duration}ms)`)}`; + console.log(`${pc.green("")} ${msg}`); + if (stats) { + for (const [key, value] of Object.entries(stats)) { + console.log(pc.gray(` ${key}: ${value}`)); + } + } +}; + +export const list = (items: string[], bullet = "•"): void => { + for (const item of items) { + console.log(pc.gray(` ${bullet} ${item}`)); + } +}; diff --git a/src/utils/codegen-logger.ts b/src/utils/codegen-logger.ts deleted file mode 100644 index f13c3a96a..000000000 --- a/src/utils/codegen-logger.ts +++ /dev/null @@ -1,316 +0,0 @@ -/** - * CodeGen Logger - * - * Clean, colorful logging designed for code generation tools - */ - -import pc from "picocolors"; - -export enum LogLevel { - DEBUG = 0, - INFO = 1, - WARN = 2, - ERROR = 3, - SILENT = 4, -} - -export type LogLevelString = keyof typeof LogLevel; - -export const parseLogLevel = (level: LogLevelString): LogLevel => { - switch (level.toUpperCase()) { - case "DEBUG": - return LogLevel.DEBUG; - case "INFO": - return LogLevel.INFO; - case "WARN": - return LogLevel.WARN; - case "ERROR": - return LogLevel.ERROR; - case "SILENT": - return LogLevel.SILENT; - default: - throw new Error(`Invalid log level: ${level}`); - } -}; - -export interface LogOptions { - prefix?: string; - timestamp?: boolean; - suppressLoggingLevel?: LogLevel[] | "all"; - /** Minimum log level to display. Messages below this level are suppressed. Default: INFO */ - level?: LogLevel; -} - -/** - * Simple code generation logger with pretty colors and clean formatting - */ -export class CodegenLogger { - private options: LogOptions; - private dryWarnSet: Set = new Set(); - - constructor(options: LogOptions = {}) { - this.options = { - timestamp: false, - level: LogLevel.INFO, - ...options, - }; - } - - /** - * Check if a message at the given level should be logged - */ - private shouldLog(messageLevel: LogLevel): boolean { - const currentLevel = this.options.level ?? LogLevel.INFO; - return messageLevel >= currentLevel; - } - - private static consoleLevelsMap: Record void> = { - [LogLevel.INFO]: console.log, - [LogLevel.WARN]: console.warn, - [LogLevel.ERROR]: console.error, - [LogLevel.DEBUG]: console.log, - [LogLevel.SILENT]: () => {}, - }; - - private formatMessage(level: string, message: string, color: (str: string) => string): string { - const timestamp = this.options.timestamp ? `${pc.gray(new Date().toLocaleTimeString())} ` : ""; - const prefix = this.options.prefix ? `${pc.cyan(`[${this.options.prefix}]`)} ` : ""; - return `${timestamp}${color(level)} ${prefix}${message}`; - } - - private isSuppressed(level: LogLevel): boolean { - return ( - this.options.suppressLoggingLevel === "all" || this.options.suppressLoggingLevel?.includes(level) || false - ); - } - - private tryWriteToConsole(level: LogLevel, formattedMessage: string): void { - if (this.isSuppressed(level)) return; - if (!this.shouldLog(level)) return; - const logFn = CodegenLogger.consoleLevelsMap[level] || console.log; - logFn(formattedMessage); - } - - /** - * Success message with checkmark - */ - success(message: string): void { - this.tryWriteToConsole(LogLevel.INFO, this.formatMessage("", message, pc.green)); - } - - /** - * Error message with X mark - */ - error(message: string, error?: Error): void { - if (this.isSuppressed(LogLevel.ERROR)) return; - if (!this.shouldLog(LogLevel.ERROR)) return; - console.error(this.formatMessage("X", message, pc.red)); - // Show error details if verbose or log level is DEBUG - const showDetails = this.options.level === LogLevel.DEBUG; - if (error && showDetails) { - console.error(pc.red(` ${error.message}`)); - if (error.stack) { - console.error(pc.gray(error.stack)); - } - } - } - - /** - * Warning message with warning sign - */ - warn(message: string): void { - this.tryWriteToConsole(LogLevel.WARN, this.formatMessage("!", message, pc.yellow)); - } - - dryWarn(message: string): void { - if (!this.dryWarnSet.has(message)) { - this.warn(message); - this.dryWarnSet.add(message); - } - } - - /** - * Info message with info icon - */ - info(message: string): void { - this.tryWriteToConsole(LogLevel.INFO, this.formatMessage("i", message, pc.blue)); - } - - /** - * Debug message (only shows when log level is DEBUG or verbose is true) - */ - debug(message: string): void { - // Debug shows if verbose is true OR log level allows DEBUG - if (this.shouldLog(LogLevel.DEBUG)) { - this.tryWriteToConsole(LogLevel.DEBUG, this.formatMessage("🐛", message, pc.magenta)); - } - } - - /** - * Step message with rocket - */ - step(message: string): void { - this.tryWriteToConsole(LogLevel.INFO, this.formatMessage("🚀", message, pc.cyan)); - } - - /** - * Progress message with clock - */ - progress(message: string): void { - this.tryWriteToConsole(LogLevel.INFO, this.formatMessage("⏳", message, pc.blue)); - } - - /** - * Plain message (no icon, just colored text) - */ - plain(message: string, color: (str: string) => string = (s) => s): void { - const timestamp = this.options.timestamp ? `${pc.gray(new Date().toLocaleTimeString())} ` : ""; - const prefix = this.options.prefix ? `${pc.cyan(`[${this.options.prefix}]`)} ` : ""; - this.tryWriteToConsole(LogLevel.INFO, `${timestamp}${prefix}${color(message)}`); - } - - /** - * Dimmed/gray text for less important info - */ - dim(message: string): void { - this.plain(message, pc.gray); - } - - /** - * Create a child logger with a prefix - */ - child(prefix: string): CodegenLogger { - return new CodegenLogger({ - ...this.options, - prefix: this.options.prefix ? `${this.options.prefix}:${prefix}` : prefix, - }); - } - - /** - * Update options - */ - configure(options: Partial): void { - this.options = { ...this.options, ...options }; - } - - getLevel(): LogLevel { - return this.options.level ?? LogLevel.INFO; - } - - setLevel(level: LogLevel): void { - this.options.level = level; - } -} - -/** - * Quick logging functions for simple usage - */ - -const defaultLogger = new CodegenLogger(); - -export function success(message: string): void { - defaultLogger.success(message); -} - -export function error(message: string, err?: Error): void { - defaultLogger.error(message, err); -} - -export function warn(message: string): void { - defaultLogger.warn(message); -} - -export function info(message: string): void { - defaultLogger.info(message); -} - -function _debug(message: string): void { - defaultLogger.debug(message); -} - -export function step(message: string): void { - defaultLogger.step(message); -} - -function _progress(message: string): void { - defaultLogger.progress(message); -} - -function _plain(message: string, color?: (str: string) => string): void { - defaultLogger.plain(message, color); -} - -export function dim(message: string): void { - defaultLogger.dim(message); -} - -/** - * Configure the default logger - */ -export function configure(options: Partial): void { - defaultLogger.configure(options); -} - -/** - * Create a new logger instance - */ -export function createLogger(options: LogOptions = {}): CodegenLogger { - return new CodegenLogger(options); -} - -/** - * Convenience functions for common CLI patterns - */ - -/** - * Show a command header with separator - */ -export function header(title: string): void { - console.log(); - console.log(pc.cyan(pc.bold(`━━━ ${title} ━━━`))); -} - -/** - * Show a section break - */ -function _section(title: string): void { - console.log(); - console.log(pc.bold(title)); -} - -/** - * Show completion message with stats - */ -export function complete(message: string, duration?: number, stats?: Record): void { - let msg = message; - if (duration) { - msg += ` ${pc.gray(`(${duration}ms)`)}`; - } - success(msg); - - if (stats) { - Object.entries(stats).forEach(([key, value]) => { - dim(` ${key}: ${value}`); - }); - } -} - -/** - * Show a list of items - */ -export function list(items: string[], bullet = "•"): void { - items.forEach((item) => { - console.log(pc.gray(` ${bullet} ${item}`)); - }); -} - -/** - * Show key-value pairs - */ -function _table(data: Record): void { - const maxKeyLength = Math.max(...Object.keys(data).map((k) => k.length)); - Object.entries(data).forEach(([key, value]) => { - const paddedKey = key.padEnd(maxKeyLength); - console.log(` ${pc.blue(paddedKey)} ${pc.gray("─")} ${value}`); - }); -} diff --git a/src/utils/common-log.ts b/src/utils/common-log.ts new file mode 100644 index 000000000..9cd24581d --- /dev/null +++ b/src/utils/common-log.ts @@ -0,0 +1,151 @@ +import pc from "picocolors"; + +export type LogLevel = "DEBUG" | "INFO" | "WARN" | "ERROR" | "SILENT"; + +export type LogEntry = { + level: LogLevel; + tag?: T; + message: string; + suppressed: boolean; + prefix: string; + timestamp: number; +}; + +export type Log = { + warn: TaggedLogFn; + dryWarn: TaggedLogFn; + info: TaggedLogFn; + error: TaggedLogFn; + debug: TaggedLogFn; +}; + +export type LogManager = Log & { + fork(prefix: string, opts?: Partial>): LogManager; + as(): LogManager; + + tagCounts(): Readonly>; + printTagSummary(): void; + + buffer(): readonly LogEntry[]; + bufferClear(): void; +}; + +type TagsOf = L extends LogManager ? T : never; + +export type ExtendLogManager> = LogManager | Extra>; + +type TaggedLogFn = (...args: [string] | [T, string]) => void; + +export type LoggerOptions = { + prefix?: string; + suppressTags?: T[]; + level?: LogLevel; +}; + +const LEVEL_PRIORITY: Record = { DEBUG: 0, INFO: 1, WARN: 2, ERROR: 3, SILENT: 4 }; + +export function mkLogger(opts: LoggerOptions = {}): LogManager { + const prefix = opts.prefix ?? ""; + const suppressedSet = new Set(opts.suppressTags ?? []); + const tagCounts: Record = {}; + const entries: LogEntry[] = []; + const drySet = new Set(); + const currentLevel: LogLevel = opts.level ?? "INFO"; + + const shouldLog = (level: LogLevel): boolean => LEVEL_PRIORITY[level] >= LEVEL_PRIORITY[currentLevel]; + + const colorize: Record string> = { + DEBUG: (s) => s, + INFO: (s) => s, + WARN: pc.yellow, + ERROR: pc.red, + SILENT: (s) => s, + }; + + const fmt = (level: LogLevel, icon: string, msg: string, tag?: string) => { + const pfx = prefix ? `${prefix}: ` : ""; + const tagSuffix = tag ? ` ${pc.dim(`(${tag})`)}` : ""; + return colorize[level](`${icon} ${pfx}${msg}`) + tagSuffix; + }; + + const pushEntry = (level: LogLevel, msg: string, tag?: T, suppressed = false) => { + entries.push({ level, tag, message: msg, suppressed, prefix, timestamp: Date.now() }); + }; + + const mkLogFn = ( + level: LogLevel, + icon: string, + consoleFn: (...args: any[]) => void, + dedupe = false, + ): TaggedLogFn => { + return (...args: [string] | [T, string]) => { + const tag = args.length === 2 ? args[0] : undefined; + const msg = args.length === 2 ? args[1] : args[0]; + if (tag) tagCounts[tag] = (tagCounts[tag] ?? 0) + 1; + const isSuppressed = tag !== undefined && suppressedSet.has(tag); + pushEntry(level, msg, tag, isSuppressed); + if (isSuppressed) return; + if (!shouldLog(level)) return; + if (dedupe) { + const key = `${level}::${tag ?? ""}::${msg}`; + if (drySet.has(key)) return; + drySet.add(key); + } + consoleFn(fmt(level, icon, msg, tag)); + }; + }; + + const logger: LogManager = { + warn: mkLogFn("WARN", "!", console.warn), + dryWarn: mkLogFn("WARN", "!", console.warn, true), + info: mkLogFn("INFO", "i", console.log), + error: mkLogFn("ERROR", "X", console.error), + debug: mkLogFn("DEBUG", "D", console.log), + + fork(childPrefix: string, childOpts?: Partial>): LogManager { + const fullPrefix = prefix ? `${prefix}/${childPrefix}` : childPrefix; + const merged = [...suppressedSet, ...(childOpts?.suppressTags ?? [])] as T[]; + return mkLogger({ + prefix: fullPrefix, + suppressTags: merged, + level: childOpts?.level ?? currentLevel, + }); + }, + + as(): LogManager { + return logger as unknown as LogManager; + }, + + tagCounts(): Readonly> { + return tagCounts; + }, + + printTagSummary() { + const allTags = Object.entries(tagCounts); + if (allTags.length === 0) return; + const pfx = prefix ? `${prefix}: ` : ""; + const emitted = allTags.filter(([tag]) => !suppressedSet.has(tag)); + const suppressed = allTags.filter(([tag]) => suppressedSet.has(tag)); + if (emitted.length > 0) { + const total = emitted.reduce((sum, [, c]) => sum + c, 0); + const detail = emitted.map(([tag, c]) => `${tag}: ${c}`).join(", "); + console.warn(pc.yellow(`! ${pfx}${total} warnings (${detail})`)); + } + if (suppressed.length > 0) { + const total = suppressed.reduce((sum, [, c]) => sum + c, 0); + const detail = suppressed.map(([tag, c]) => `${tag}: ${c}`).join(", "); + console.log(pc.dim(`i ${pfx}${total} suppressed (${detail})`)); + } + }, + + buffer(): readonly LogEntry[] { + return entries; + }, + + bufferClear() { + entries.length = 0; + }, + }; + + return logger; +} diff --git a/src/utils/log.ts b/src/utils/log.ts new file mode 100644 index 000000000..f9b1407bd --- /dev/null +++ b/src/utils/log.ts @@ -0,0 +1,25 @@ +import { type Log, type LoggerOptions, type LogManager, mkLogger } from "./common-log"; + +export type { + ExtendLogManager, + Log, + LogEntry, + LoggerOptions, + LogLevel, + LogManager, +} from "./common-log"; +export { mkLogger } from "./common-log"; + +export type CodegenTag = + | "#binding" + | "#largeValueSet" + | "#fieldTypeNotFound" + | "#skipCanonical" + | "#duplicateSchema" + | "#duplicateCanonical" + | "#resolveBase"; + +export type CodegenLog = Log; +export type CodegenLogManager = LogManager; + +export const mkCodegenLogger = (opts: LoggerOptions = {}) => mkLogger(opts); diff --git a/test/api/mustache.test.ts b/test/api/mustache.test.ts index 02a42c5cb..7e99626d1 100644 --- a/test/api/mustache.test.ts +++ b/test/api/mustache.test.ts @@ -1,10 +1,9 @@ import { describe, expect, it } from "bun:test"; import { APIBuilder } from "@root/api/builder"; -import { r4Manager } from "@typeschema-test/utils"; +import { mkErrorLogger, r4Manager } from "@typeschema-test/utils"; describe("Mustache Template Based Generation", async () => { - const report = await new APIBuilder({ register: r4Manager }) - .setLogLevel("SILENT") + const report = await new APIBuilder({ register: r4Manager, logger: mkErrorLogger() }) .mustache("./examples/mustache/java", { debug: "COMPACT", inMemoryOnly: true, diff --git a/test/api/write-generator/__snapshots__/typescript.test.ts.snap b/test/api/write-generator/__snapshots__/typescript.test.ts.snap index 4def252bc..15cb76e8c 100644 --- a/test/api/write-generator/__snapshots__/typescript.test.ts.snap +++ b/test/api/write-generator/__snapshots__/typescript.test.ts.snap @@ -276,7 +276,7 @@ export const isMaterial = (resource: unknown): resource is Material => { " `; -exports[`TypeScript R4 Example (with generateProfile) file rewrite warnings match expected collisions 1`] = ` +exports[`TypeScript R4 Example (with generateProfile) file rewrite warnings 1`] = ` [ "File will be rewritten 'generated/types/hl7-fhir-r4-core/profiles/Extension_assertedDate.ts'", "File will be rewritten 'generated/types/hl7-fhir-r4-core/profiles/Extension_author.ts'", diff --git a/test/api/write-generator/csharp.test.ts b/test/api/write-generator/csharp.test.ts index c4031e3a6..43f6c5236 100644 --- a/test/api/write-generator/csharp.test.ts +++ b/test/api/write-generator/csharp.test.ts @@ -1,10 +1,9 @@ import { describe, expect, it } from "bun:test"; import { APIBuilder } from "@root/api/builder"; -import { r4Manager } from "@typeschema-test/utils"; +import { mkErrorLogger, r4Manager } from "@typeschema-test/utils"; describe("C# Writer Generator", async () => { - const result = await new APIBuilder({ register: r4Manager }) - .setLogLevel("SILENT") + const result = await new APIBuilder({ register: r4Manager, logger: mkErrorLogger() }) .csharp({ inMemoryOnly: true, }) diff --git a/test/api/write-generator/introspection.test.ts b/test/api/write-generator/introspection.test.ts index 70779a7e0..5d180afd5 100644 --- a/test/api/write-generator/introspection.test.ts +++ b/test/api/write-generator/introspection.test.ts @@ -1,10 +1,9 @@ import { describe, expect, it } from "bun:test"; import { APIBuilder } from "@root/api/builder"; -import { r4Manager } from "@typeschema-test/utils"; +import { mkErrorLogger, r4Manager } from "@typeschema-test/utils"; describe("IntrospectionWriter - Fhir Schema Output", async () => { - const result = await new APIBuilder({ register: r4Manager }) - .setLogLevel("SILENT") + const result = await new APIBuilder({ register: r4Manager, logger: mkErrorLogger() }) .introspection({ fhirSchemas: "introspection" }) .introspection({ fhirSchemas: "introspection.ndjson" }) .generate(); @@ -27,8 +26,7 @@ describe("IntrospectionWriter - Fhir Schema Output", async () => { }); describe("IntrospectionWriter - TypeSchema output", async () => { - const result = await new APIBuilder({ register: r4Manager }) - .setLogLevel("SILENT") + const result = await new APIBuilder({ register: r4Manager, logger: mkErrorLogger() }) .typeSchema({ treeShake: { "hl7.fhir.r4.core": { @@ -67,8 +65,7 @@ describe("IntrospectionWriter - TypeSchema output", async () => { }); describe("IntrospectionWriter - typeTree", async () => { - const result = await new APIBuilder({ register: r4Manager }) - .setLogLevel("SILENT") + const result = await new APIBuilder({ register: r4Manager, logger: mkErrorLogger() }) .typeSchema({ treeShake: { "hl7.fhir.r4.core": { @@ -93,8 +90,7 @@ describe("IntrospectionWriter - typeTree", async () => { }); describe("IntrospectionWriter - StructureDefinition output", async () => { - const result = await new APIBuilder({ register: r4Manager }) - .setLogLevel("SILENT") + const result = await new APIBuilder({ register: r4Manager, logger: mkErrorLogger() }) .typeSchema({ treeShake: { "hl7.fhir.r4.core": { diff --git a/test/api/write-generator/multi-package/cda.test.ts b/test/api/write-generator/multi-package/cda.test.ts index f1daf1a27..7f8bb01ce 100644 --- a/test/api/write-generator/multi-package/cda.test.ts +++ b/test/api/write-generator/multi-package/cda.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "bun:test"; import { APIBuilder } from "@root/api/builder"; import type { CanonicalUrl } from "@root/typeschema/types"; +import { mkSilentLogger } from "@typeschema-test/utils"; /** * Tests for CDA package generation. @@ -18,8 +19,7 @@ describe("CDA", async () => { }; describe("TypeScript Generation", async () => { - const result = await new APIBuilder() - .setLogLevel("SILENT") + const result = await new APIBuilder({ logger: mkSilentLogger() }) .fromPackage("hl7.cda.uv.core", "2.0.1-sd") .typeSchema({ treeShake: treeShakeConfig }) .typescript({ inMemoryOnly: true }) @@ -45,8 +45,7 @@ describe("CDA", async () => { }); describe("Python Generation", async () => { - const result = await new APIBuilder() - .setLogLevel("SILENT") + const result = await new APIBuilder({ logger: mkSilentLogger() }) .fromPackage("hl7.cda.uv.core", "2.0.1-sd") .typeSchema({ treeShake: treeShakeConfig, promoteLogical: promoteLogicalConfig }) .python({ inMemoryOnly: true }) @@ -69,8 +68,7 @@ describe("CDA", async () => { }); describe("C# Generation", async () => { - const result = await new APIBuilder() - .setLogLevel("SILENT") + const result = await new APIBuilder({ logger: mkSilentLogger() }) .fromPackage("hl7.cda.uv.core", "2.0.1-sd") .typeSchema({ treeShake: treeShakeConfig, promoteLogical: promoteLogicalConfig }) .csharp({ inMemoryOnly: true }) diff --git a/test/api/write-generator/multi-package/local-package.test.ts b/test/api/write-generator/multi-package/local-package.test.ts index e3a8e9594..24e168245 100644 --- a/test/api/write-generator/multi-package/local-package.test.ts +++ b/test/api/write-generator/multi-package/local-package.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "bun:test"; import * as Path from "node:path"; import { APIBuilder } from "@root/api/builder"; import type { CanonicalUrl } from "@root/typeschema/types"; +import { mkSilentLogger } from "@typeschema-test/utils"; const LOCAL_PACKAGE_PATH = Path.join(__dirname, "../../../assets/local-package/structure-definitions"); @@ -29,8 +30,7 @@ describe("Local Package Folder - Multi-Package Generation", async () => { }; describe("TypeScript Generation", async () => { - const result = await new APIBuilder() - .setLogLevel("SILENT") + const result = await new APIBuilder({ logger: mkSilentLogger() }) .localStructureDefinitions(localPackageConfig) .typeSchema({ treeShake: treeShakeConfig }) .typescript({ inMemoryOnly: true }) @@ -61,8 +61,7 @@ describe("Local Package Folder - Multi-Package Generation", async () => { }); describe("Python Generation", async () => { - const result = await new APIBuilder() - .setLogLevel("SILENT") + const result = await new APIBuilder({ logger: mkSilentLogger() }) .localStructureDefinitions(localPackageConfig) .typeSchema({ treeShake: treeShakeConfig, promoteLogical: promoteLogicalConfig }) .python({ inMemoryOnly: true }) @@ -98,8 +97,7 @@ describe("Local Package Folder - Multi-Package Generation", async () => { }); describe("C# Generation", async () => { - const result = await new APIBuilder() - .setLogLevel("SILENT") + const result = await new APIBuilder({ logger: mkSilentLogger() }) .localStructureDefinitions(localPackageConfig) .typeSchema({ treeShake: treeShakeConfig, promoteLogical: promoteLogicalConfig }) .csharp({ inMemoryOnly: true }) diff --git a/test/api/write-generator/multi-package/sql-on-fhir.test.ts b/test/api/write-generator/multi-package/sql-on-fhir.test.ts index 29228e42d..e447a661c 100644 --- a/test/api/write-generator/multi-package/sql-on-fhir.test.ts +++ b/test/api/write-generator/multi-package/sql-on-fhir.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "bun:test"; import { APIBuilder } from "@root/api/builder"; import type { CanonicalUrl } from "@root/typeschema/types"; +import { mkSilentLogger } from "@typeschema-test/utils"; /** * Tests for SQL-on-FHIR package. @@ -20,8 +21,7 @@ describe("SQL-on-FHIR", async () => { }; describe("TypeScript Generation", async () => { - const result = await new APIBuilder() - .setLogLevel("SILENT") + const result = await new APIBuilder({ logger: mkSilentLogger() }) .fromPackageRef(packageUrl) .typeSchema({ treeShake: treeShakeConfig }) .typescript({ inMemoryOnly: true }) @@ -54,8 +54,7 @@ describe("SQL-on-FHIR", async () => { }); describe("Python Generation", async () => { - const result = await new APIBuilder() - .setLogLevel("SILENT") + const result = await new APIBuilder({ logger: mkSilentLogger() }) .fromPackageRef(packageUrl) .typeSchema({ treeShake: treeShakeConfig, promoteLogical: promoteLogicalConfig }) .python({ inMemoryOnly: true }) @@ -85,8 +84,7 @@ describe("SQL-on-FHIR", async () => { }); describe("C# Generation", async () => { - const result = await new APIBuilder() - .setLogLevel("SILENT") + const result = await new APIBuilder({ logger: mkSilentLogger() }) .fromPackageRef(packageUrl) .typeSchema({ treeShake: treeShakeConfig, promoteLogical: promoteLogicalConfig }) .csharp({ inMemoryOnly: true }) diff --git a/test/api/write-generator/python.test.ts b/test/api/write-generator/python.test.ts index 04e99814d..5aba924d7 100644 --- a/test/api/write-generator/python.test.ts +++ b/test/api/write-generator/python.test.ts @@ -1,10 +1,9 @@ import { describe, expect, it } from "bun:test"; import { APIBuilder } from "@root/api/builder"; -import { r4Manager } from "@typeschema-test/utils"; +import { mkErrorLogger, r4Manager } from "@typeschema-test/utils"; describe("Python Writer Generator", async () => { - const result = await new APIBuilder({ register: r4Manager }) - .setLogLevel("SILENT") + const result = await new APIBuilder({ register: r4Manager, logger: mkErrorLogger() }) .python({ inMemoryOnly: true, }) diff --git a/test/api/write-generator/typescript.test.ts b/test/api/write-generator/typescript.test.ts index f270955aa..1975d23b8 100644 --- a/test/api/write-generator/typescript.test.ts +++ b/test/api/write-generator/typescript.test.ts @@ -1,24 +1,10 @@ import { describe, expect, it } from "bun:test"; import { APIBuilder } from "@root/api/builder"; import type { CanonicalUrl } from "@root/typeschema/types"; -import { CodegenLogger, LogLevel } from "@root/utils/codegen-logger"; -import { ccdaManager, r4Manager } from "@typeschema-test/utils"; - -/** Creates a logger that captures all warnings for testing */ -const createCapturingLogger = () => { - const warnings: string[] = []; - const logger = new CodegenLogger({ level: LogLevel.WARN }); - const originalWarn = logger.warn.bind(logger); - logger.warn = (message: string) => { - warnings.push(message); - originalWarn(message); - }; - return { logger, warnings }; -}; +import { ccdaManager, mkErrorLogger, r4Manager } from "@typeschema-test/utils"; describe("TypeScript Writer Generator", async () => { - const result = await new APIBuilder({ register: r4Manager }) - .setLogLevel("SILENT") + const result = await new APIBuilder({ register: r4Manager, logger: mkErrorLogger() }) .typescript({ inMemoryOnly: true, }) @@ -41,8 +27,7 @@ describe("TypeScript Writer Generator", async () => { }); describe("TypeScript CDA with Logical Model Promotion to Resource", async () => { - const result = await new APIBuilder({ register: ccdaManager }) - .setLogLevel("SILENT") + const result = await new APIBuilder({ register: ccdaManager, logger: mkErrorLogger() }) .typeSchema({ promoteLogical: { "hl7.cda.uv.core": ["http://hl7.org/cda/stds/core/StructureDefinition/Material" as CanonicalUrl], @@ -64,10 +49,8 @@ describe("TypeScript CDA with Logical Model Promotion to Resource", async () => }); describe("TypeScript R4 Example (with generateProfile)", async () => { - const { logger, warnings } = createCapturingLogger(); - + const logger = mkErrorLogger(); const result = await new APIBuilder({ register: r4Manager, logger }) - .setLogLevel("SILENT") .typescript({ inMemoryOnly: true, withDebugComment: false, @@ -80,8 +63,11 @@ describe("TypeScript R4 Example (with generateProfile)", async () => { expect(result.success).toBeTrue(); }); - it("file rewrite warnings match expected collisions", () => { - const rewriteWarnings = warnings.filter((w) => w.includes("File will be rewritten")); + it("file rewrite warnings", () => { + const rewriteWarnings = logger + .buffer() + .filter((e) => e.level === "WARN" && e.message.includes("File will be rewritten")) + .map((e) => e.message); expect(rewriteWarnings).toMatchSnapshot(); }); diff --git a/test/unit/typeschema/ir/tree-shake.test.ts b/test/unit/typeschema/ir/tree-shake.test.ts index 940d3ea0b..60c6b8b64 100644 --- a/test/unit/typeschema/ir/tree-shake.test.ts +++ b/test/unit/typeschema/ir/tree-shake.test.ts @@ -8,7 +8,7 @@ import { } from "@root/typeschema/ir/tree-shake"; import { registerFromPackageMetas } from "@root/typeschema/register"; import type { CanonicalUrl, RegularTypeSchema } from "@root/typeschema/types"; -import { mkIndex, mkR4Register, r4Package, r5Package, resolveTs } from "@typeschema-test/utils"; +import { mkIndex, mkR4Register, mkTestLogger, r4Package, r5Package, resolveTs } from "@typeschema-test/utils"; describe("treeShake specific TypeSchema", async () => { const manager = await registerFromPackageMetas([r4Package, r5Package], {}); @@ -55,10 +55,12 @@ describe("treeShake specific TypeSchema", async () => { describe("treeShake specific TypeSchema", async () => { const r4 = await mkR4Register(); + const logger = mkTestLogger(); const patientTss = await resolveTs( r4, r4Package, "http://hl7.org/fhir/StructureDefinition/Patient" as CanonicalUrl, + logger, ); const patientOrigin = patientTss[0] as RegularTypeSchema; assert(patientOrigin !== undefined); diff --git a/test/unit/typeschema/r4.test.ts b/test/unit/typeschema/r4.test.ts index ccedd05bd..fd7c8f133 100644 --- a/test/unit/typeschema/r4.test.ts +++ b/test/unit/typeschema/r4.test.ts @@ -1,17 +1,18 @@ import { describe, expect, it } from "bun:test"; import { generateTypeSchemas } from "@root/typeschema"; import type { CanonicalUrl, Name, RegularTypeSchema } from "@root/typeschema/types"; -import { mkR4Register, r4Package, registerFsAndMkTs } from "@typeschema-test/utils"; +import { mkR4Register, mkTestLogger, r4Package, registerFsAndMkTs } from "@typeschema-test/utils"; describe("TypeSchema R4 generation", async () => { const r4 = await mkR4Register(); + const logger = mkTestLogger(); it("Bundle and elementReference", async () => { const profile = r4.resolveFs(r4Package, "http://hl7.org/fhir/StructureDefinition/Bundle" as CanonicalUrl); if (!profile) { throw new Error("Bundle profile not found"); } - const ts = (await registerFsAndMkTs(r4, profile))[0] as RegularTypeSchema; + const ts = (await registerFsAndMkTs(r4, profile, logger))[0] as RegularTypeSchema; expect(ts?.nested).toHaveLength(5); expect(ts).toMatchObject({ identifier: { kind: "resource", url: "http://hl7.org/fhir/StructureDefinition/Bundle" }, @@ -54,7 +55,7 @@ describe("TypeSchema R4 generation", async () => { if (!md) { throw new Error("markdown type not found"); } - const ts = (await registerFsAndMkTs(r4, md))[0] as RegularTypeSchema; + const ts = (await registerFsAndMkTs(r4, md, logger))[0] as RegularTypeSchema; expect(ts).toMatchObject({ identifier: { kind: "primitive-type", @@ -77,7 +78,7 @@ describe("TypeSchema R4 generation", async () => { if (!parameters) { throw new Error("Parameters resource not found"); } - const ts = (await registerFsAndMkTs(r4, parameters))[0] as RegularTypeSchema; + const ts = (await registerFsAndMkTs(r4, parameters, logger))[0] as RegularTypeSchema; expect(ts.dependencies).toBeDefined(); expect(ts.dependencies).toContainEqual({ kind: "primitive-type", diff --git a/test/unit/typeschema/snapshot.test.ts b/test/unit/typeschema/snapshot.test.ts index 4e3909af9..fdd8c5d4c 100644 --- a/test/unit/typeschema/snapshot.test.ts +++ b/test/unit/typeschema/snapshot.test.ts @@ -1,8 +1,9 @@ import { describe, expect, it } from "bun:test"; import type { PFS, PVS } from "@typeschema-test/utils"; -import { mkR4Register, r4Package, registerFsAndMkTs, transformVS } from "@typeschema-test/utils"; +import { mkR4Register, mkTestLogger, r4Package, registerFsAndMkTs, transformVS } from "@typeschema-test/utils"; const r4 = await mkR4Register(); +const logger = mkTestLogger(); describe("ValueSet to Type Schema (snapshot)", async () => { it("administrative-gender", async () => { @@ -27,56 +28,56 @@ describe("ValueSet to Type Schema (snapshot)", async () => { describe("FHIR Schema to Type Schema (snapshot)", async () => { it("with cardinality", async () => { const fs: PFS = await Bun.file("test/assets/fhir-schemas/with-cardinality.fs.json").json(); - const ts = await registerFsAndMkTs(r4, fs); + const ts = await registerFsAndMkTs(r4, fs, logger); expect(JSON.stringify(ts, null, 2)).toMatchSnapshot(); }); it("with resource with string", async () => { const fs: PFS = await Bun.file("test/assets/fhir-schemas/resource-with-string.fs.json").json(); - const ts = await registerFsAndMkTs(r4, fs); + const ts = await registerFsAndMkTs(r4, fs, logger); expect(JSON.stringify(ts, null, 2)).toMatchSnapshot(); }); it("with resource with code", async () => { const fs: PFS = await Bun.file("test/assets/fhir-schemas/resource-with-code.fs.json").json(); - const ts = await registerFsAndMkTs(r4, fs); + const ts = await registerFsAndMkTs(r4, fs, logger); expect(JSON.stringify(ts, null, 2)).toMatchSnapshot(); }); it("with resource with codable concept", async () => { const fs: PFS = await Bun.file("test/assets/fhir-schemas/resource-with-codable-concept.fs.json").json(); - const ts = await registerFsAndMkTs(r4, fs); + const ts = await registerFsAndMkTs(r4, fs, logger); expect(JSON.stringify(ts, null, 2)).toMatchSnapshot(); }); it("with resource with choice", async () => { const fs: PFS = await Bun.file("test/assets/fhir-schemas/resource-with-choice.fs.json").json(); - const ts = await registerFsAndMkTs(r4, fs); + const ts = await registerFsAndMkTs(r4, fs, logger); expect(JSON.stringify(ts, null, 2)).toMatchSnapshot(); }); it("with resource with nested type", async () => { const fs: PFS = await Bun.file("test/assets/fhir-schemas/resource-with-nested-type.fs.json").json(); - const ts = await registerFsAndMkTs(r4, fs); + const ts = await registerFsAndMkTs(r4, fs, logger); expect(JSON.stringify(ts, null, 2)).toMatchSnapshot(); }); it("with resource with nested type 2", async () => { const fs: PFS = await Bun.file("test/assets/fhir-schemas/resource-with-nested-type-2.fs.json").json(); - const ts = await registerFsAndMkTs(r4, fs); + const ts = await registerFsAndMkTs(r4, fs, logger); expect(JSON.stringify(ts, null, 2)).toMatchSnapshot(); }); describe("Real world examples", async () => { it("coding primitive type", async () => { const fs: PFS = await Bun.file("test/assets/fhir-schemas/coding.fs.json").json(); - const ts = await registerFsAndMkTs(r4, fs); + const ts = await registerFsAndMkTs(r4, fs, logger); expect(JSON.stringify(ts, null, 2)).toMatchSnapshot(); }); it("string primitive type", async () => { const fs: PFS = await Bun.file("test/assets/fhir-schemas/string.fs.json").json(); - const ts = await registerFsAndMkTs(r4, fs); + const ts = await registerFsAndMkTs(r4, fs, logger); expect(JSON.stringify(ts, null, 2)).toMatchSnapshot(); }); }); @@ -84,12 +85,12 @@ describe("FHIR Schema to Type Schema (snapshot)", async () => { describe("Custom resource", async () => { it("TutorNotificationTemplate", async () => { const fs: PFS = await Bun.file("test/assets/fhir-schemas/TutorNotificationTemplate.fs.json").json(); - const ts = await registerFsAndMkTs(r4, fs); + const ts = await registerFsAndMkTs(r4, fs, logger); expect(JSON.stringify(ts, null, 2)).toMatchSnapshot(); }); it("TutorNotification", async () => { const fs: PFS = await Bun.file("test/assets/fhir-schemas/TutorNotification.fs.json").json(); - const ts = await registerFsAndMkTs(r4, fs); + const ts = await registerFsAndMkTs(r4, fs, logger); expect(JSON.stringify(ts, null, 2)).toMatchSnapshot(); }); }); diff --git a/test/unit/typeschema/transformer.test.ts b/test/unit/typeschema/transformer.test.ts index 956c846a8..8551200a4 100644 --- a/test/unit/typeschema/transformer.test.ts +++ b/test/unit/typeschema/transformer.test.ts @@ -1,10 +1,11 @@ import { describe, expect, it } from "bun:test"; import type { Name, PackageMeta, RegularField, RegularTypeSchema } from "@typeschema/types"; import type { PFS } from "@typeschema-test/utils"; -import { mkR4Register, registerFsAndMkTs } from "@typeschema-test/utils"; +import { mkR4Register, mkTestLogger, registerFsAndMkTs } from "@typeschema-test/utils"; describe("TypeSchema Transformer Core Logic", async () => { const r4 = await mkR4Register(); + const logger = mkTestLogger(); const _basePackageInfo: PackageMeta = { name: "test.package", @@ -26,7 +27,7 @@ describe("TypeSchema Transformer Core Logic", async () => { class: "", }; - const result = await registerFsAndMkTs(r4, fhirSchema); + const result = await registerFsAndMkTs(r4, fhirSchema, logger); expect(result).toHaveLength(1); expect(result[0]?.identifier.name).toBe("TestResource" as Name); @@ -44,7 +45,7 @@ describe("TypeSchema Transformer Core Logic", async () => { class: "", }; - const result = await registerFsAndMkTs(r4, fhirSchema); + const result = await registerFsAndMkTs(r4, fhirSchema, logger); const schema = result[0] as RegularTypeSchema; expect(result).toHaveLength(1); @@ -61,7 +62,7 @@ describe("TypeSchema Transformer Core Logic", async () => { class: "", }; - const result = await registerFsAndMkTs(r4, fhirSchema); + const result = await registerFsAndMkTs(r4, fhirSchema, logger); expect(result).toHaveLength(1); expect(result[0]?.identifier.kind).toBe("primitive-type"); @@ -81,7 +82,7 @@ describe("TypeSchema Transformer Core Logic", async () => { }, }; - const result = await registerFsAndMkTs(r4, fhirSchema); + const result = await registerFsAndMkTs(r4, fhirSchema, logger); const schema = result[0] as RegularTypeSchema; expect(result).toHaveLength(1); @@ -102,7 +103,7 @@ describe("TypeSchema Transformer Core Logic", async () => { }, }; - const result = await registerFsAndMkTs(r4, fhirSchema); + const result = await registerFsAndMkTs(r4, fhirSchema, logger); const schema = result[0] as RegularTypeSchema; expect(result).toHaveLength(1); @@ -127,7 +128,7 @@ describe("TypeSchema Transformer Core Logic", async () => { class: "", }; - const result = await registerFsAndMkTs(r4, fhirSchema); + const result = await registerFsAndMkTs(r4, fhirSchema, logger); const schema = result[0] as RegularTypeSchema; expect(result).toHaveLength(1); @@ -147,7 +148,7 @@ describe("TypeSchema Transformer Core Logic", async () => { }, }; - const result = await registerFsAndMkTs(r4, fhirSchema); + const result = await registerFsAndMkTs(r4, fhirSchema, logger); const schema = result[0] as RegularTypeSchema; expect(result).toHaveLength(1); @@ -169,7 +170,7 @@ describe("TypeSchema Transformer Core Logic", async () => { elements: {}, }; - const result = await registerFsAndMkTs(r4, fhirSchema); + const result = await registerFsAndMkTs(r4, fhirSchema, logger); expect(result).toHaveLength(1); expect(result[0]?.identifier.kind).toBe("profile"); @@ -187,7 +188,7 @@ describe("TypeSchema Transformer Core Logic", async () => { }, }; - const result = await registerFsAndMkTs(r4, fhirSchema); + const result = await registerFsAndMkTs(r4, fhirSchema, logger); const schema = result[0] as RegularTypeSchema; expect(result).toHaveLength(1); @@ -208,7 +209,7 @@ describe("TypeSchema Transformer Core Logic", async () => { }, }; - const result = await registerFsAndMkTs(r4, fhirSchema); + const result = await registerFsAndMkTs(r4, fhirSchema, logger); const schema = result[0] as RegularTypeSchema; expect(result).toHaveLength(1); @@ -230,7 +231,7 @@ describe("TypeSchema Transformer Core Logic", async () => { }, }; - const result = await registerFsAndMkTs(r4, fhirSchema); + const result = await registerFsAndMkTs(r4, fhirSchema, logger); const schema = result[0] as RegularTypeSchema; expect(result).toHaveLength(1); @@ -254,7 +255,7 @@ describe("TypeSchema Transformer Core Logic", async () => { }, }; - const result = await registerFsAndMkTs(r4, fhirSchema); + const result = await registerFsAndMkTs(r4, fhirSchema, logger); const schema = result[0] as RegularTypeSchema; expect(result.length).toBeGreaterThanOrEqual(1); @@ -275,7 +276,7 @@ describe("TypeSchema Transformer Core Logic", async () => { }, }; - const result = await registerFsAndMkTs(r4, fhirSchema); + const result = await registerFsAndMkTs(r4, fhirSchema, logger); const schema = result[0] as RegularTypeSchema; expect(result).toHaveLength(1); @@ -290,7 +291,7 @@ describe("TypeSchema Transformer Core Logic", async () => { url: "http://example.org/EmptyResource", }; - const result = await registerFsAndMkTs(r4, fhirSchema); + const result = await registerFsAndMkTs(r4, fhirSchema, logger); const schema = result[0] as RegularTypeSchema; expect(result).toHaveLength(1); @@ -312,7 +313,7 @@ describe("TypeSchema Transformer Core Logic", async () => { package_meta: customPackageInfo, }; - const result = await registerFsAndMkTs(r4, fhirSchema); + const result = await registerFsAndMkTs(r4, fhirSchema, logger); expect(result).toHaveLength(1); expect(result[0]?.identifier.package).toBe("custom.package"); @@ -331,7 +332,7 @@ describe("TypeSchema Transformer Core Logic", async () => { }, }; - const result = await registerFsAndMkTs(r4, fhirSchema); + const result = await registerFsAndMkTs(r4, fhirSchema, logger); const schema = result[0] as RegularTypeSchema; expect(result).toHaveLength(1); @@ -356,7 +357,7 @@ describe("TypeSchema Transformer Core Logic", async () => { }, }; - const result = await registerFsAndMkTs(r4, fhirSchema); + const result = await registerFsAndMkTs(r4, fhirSchema, logger); const schema = result[0] as RegularTypeSchema; // Binding schemas are also generated @@ -375,7 +376,7 @@ describe("TypeSchema Transformer Core Logic", async () => { elements: {}, }; - const result = await registerFsAndMkTs(r4, fhirSchema); + const result = await registerFsAndMkTs(r4, fhirSchema, logger); expect(result).toHaveLength(1); // Extension detection may vary based on URL pattern @@ -403,7 +404,7 @@ describe("TypeSchema Transformer Core Logic", async () => { }, }; - const result = await registerFsAndMkTs(r4, fhirSchema); + const result = await registerFsAndMkTs(r4, fhirSchema, logger); const schema = result[0] as RegularTypeSchema; expect(result).toHaveLength(1); diff --git a/test/unit/typeschema/transformer/binding.test.ts b/test/unit/typeschema/transformer/binding.test.ts index 0fa9700dc..aa779448c 100644 --- a/test/unit/typeschema/transformer/binding.test.ts +++ b/test/unit/typeschema/transformer/binding.test.ts @@ -1,9 +1,10 @@ import { describe, expect, it } from "bun:test"; import type { PFS } from "@typeschema-test/utils"; -import { mkR4Register, registerFsAndMkTs } from "@typeschema-test/utils"; +import { mkR4Register, mkTestLogger, registerFsAndMkTs } from "@typeschema-test/utils"; describe("Type Schema generator > Binding", async () => { const r4 = await mkR4Register(); + const logger = mkTestLogger(); const A: PFS = { description: "description", derivation: "specialization", @@ -29,7 +30,7 @@ describe("Type Schema generator > Binding", async () => { version: "4.0.1", }; it("Generate nested type for resource", async () => { - expect(await registerFsAndMkTs(r4, A)).toMatchObject([ + expect(await registerFsAndMkTs(r4, A, logger)).toMatchObject([ { identifier: { package: "mypackage", url: "http://example.io/fhir/WithCode" }, base: { package: "hl7.fhir.r4.core", url: "http://hl7.org/fhir/StructureDefinition/DomainResource" }, diff --git a/test/unit/typeschema/transformer/ccda.test.ts b/test/unit/typeschema/transformer/ccda.test.ts index 5a240e5db..dd2f4f65c 100644 --- a/test/unit/typeschema/transformer/ccda.test.ts +++ b/test/unit/typeschema/transformer/ccda.test.ts @@ -1,11 +1,12 @@ import { describe, expect, it } from "bun:test"; import type { CanonicalUrl, RegularTypeSchema } from "@root/typeschema/types"; -import { ccdaPackage, mkCCDARegister, registerFsAndMkTs } from "@typeschema-test/utils"; +import { ccdaPackage, mkCCDARegister, mkTestLogger, registerFsAndMkTs } from "@typeschema-test/utils"; const skipMe = false; describe("TypeSchema CCDA generation", async () => { const ccda = await mkCCDARegister(); + const logger = mkTestLogger(); it.skipIf(skipMe)("http://hl7.org/fhir/StructureDefinition/workflow-protectiveFactor", async () => { const resource = ccda.resolveFs( @@ -15,7 +16,7 @@ describe("TypeSchema CCDA generation", async () => { if (!resource) { throw new Error("workflow-protectiveFactor not found"); } - const ts = (await registerFsAndMkTs(ccda, resource))[0] as RegularTypeSchema; + const ts = (await registerFsAndMkTs(ccda, resource, logger))[0] as RegularTypeSchema; expect(ts).toMatchObject({ identifier: { kind: "profile", @@ -74,7 +75,7 @@ describe("TypeSchema CCDA generation", async () => { if (!resource) { throw new Error("ON StructureDefinition not found"); } - const ts = (await registerFsAndMkTs(ccda, resource))[0] as RegularTypeSchema; + const ts = (await registerFsAndMkTs(ccda, resource, logger))[0] as RegularTypeSchema; expect(ts).toMatchObject({ identifier: { kind: "logical", @@ -166,7 +167,7 @@ describe("TypeSchema CCDA generation", async () => { if (!resource) { throw new Error("ehrsrle-auditevent not found"); } - const ts = (await registerFsAndMkTs(ccda, resource))[0] as RegularTypeSchema; + const ts = (await registerFsAndMkTs(ccda, resource, logger))[0] as RegularTypeSchema; // console.log(JSON.stringify(ts, null, 2)); // NOTE: problem: canonical manager recomend us to use R5, but we failing on R4 AuditEvent. expect(ts).toMatchObject({ diff --git a/test/unit/typeschema/transformer/choice-types.test.ts b/test/unit/typeschema/transformer/choice-types.test.ts index af7539bf7..a984c4d17 100644 --- a/test/unit/typeschema/transformer/choice-types.test.ts +++ b/test/unit/typeschema/transformer/choice-types.test.ts @@ -1,9 +1,10 @@ import { describe, expect, it } from "bun:test"; import type { PFS } from "@typeschema-test/utils"; -import { mkR4Register, registerFsAndMkTs } from "@typeschema-test/utils"; +import { mkR4Register, mkTestLogger, registerFsAndMkTs } from "@typeschema-test/utils"; describe("TypeSchema choice type generation", async () => { const r4 = await mkR4Register(); + const logger = mkTestLogger(); it("Simple choice type generation (optional)", async () => { const fs: PFS = { @@ -15,7 +16,7 @@ describe("TypeSchema choice type generation", async () => { deceasedBoolean: { type: "boolean" }, }, }; - expect(await registerFsAndMkTs(r4, fs)).toMatchObject([ + expect(await registerFsAndMkTs(r4, fs, logger)).toMatchObject([ { identifier: { kind: "resource", url: "uri::OptionalChoice" }, fields: { @@ -54,7 +55,7 @@ describe("TypeSchema choice type generation", async () => { deceasedBoolean: { choiceOf: "deceased", type: "boolean" }, }, }; - expect(await registerFsAndMkTs(r4, fs)).toMatchObject([ + expect(await registerFsAndMkTs(r4, fs, logger)).toMatchObject([ { identifier: { url: "uri::RequiredChoice" }, fields: { @@ -97,7 +98,7 @@ describe("TypeSchema choice type generation", async () => { }, }; - expect(await registerFsAndMkTs(r4, fs)).toMatchObject([ + expect(await registerFsAndMkTs(r4, fs, logger)).toMatchObject([ { identifier: { kind: "resource", url: "uri::RequiredChoiceLimited" }, base: { url: "uri::RequiredChoice" }, @@ -132,7 +133,7 @@ describe("TypeSchema choice type generation", async () => { }, }; - expect(await registerFsAndMkTs(r4, fs)).toMatchObject([ + expect(await registerFsAndMkTs(r4, fs, logger)).toMatchObject([ { identifier: { kind: "resource", url: "uri::RequiredChoiceLimited" }, base: { url: "uri::RequiredChoice" }, diff --git a/test/unit/typeschema/transformer/constraint.test.ts b/test/unit/typeschema/transformer/constraint.test.ts index 79584c7d5..72bb6f37a 100644 --- a/test/unit/typeschema/transformer/constraint.test.ts +++ b/test/unit/typeschema/transformer/constraint.test.ts @@ -1,9 +1,10 @@ import { describe, expect, it } from "bun:test"; import type { CanonicalUrl } from "@typeschema/types"; -import { mkR4Register, type PFS, r4Package, registerFsAndMkTs } from "@typeschema-test/utils"; +import { mkR4Register, mkTestLogger, type PFS, r4Package, registerFsAndMkTs } from "@typeschema-test/utils"; describe("TypeSchema Processing constraint generation", async () => { const r4 = await mkR4Register(); + const logger = mkTestLogger(); const A: PFS = { url: "uri::A", derivation: "specialization", @@ -18,7 +19,7 @@ describe("TypeSchema Processing constraint generation", async () => { }, }; it("Generate nested type for resource", async () => { - expect(await registerFsAndMkTs(r4, A)).toMatchObject([ + expect(await registerFsAndMkTs(r4, A, logger)).toMatchObject([ { identifier: { kind: "resource", name: "a", url: "uri::A" }, fields: { @@ -47,7 +48,7 @@ describe("TypeSchema Processing constraint generation", async () => { elements: { foo: { min: 1 } }, }; it("Constraint nested type for resource in profile", async () => { - expect(await registerFsAndMkTs(r4, B)).toMatchObject([ + expect(await registerFsAndMkTs(r4, B, logger)).toMatchObject([ { identifier: { kind: "profile", name: "b", url: "uri::B" }, base: { kind: "resource", name: "a", url: "uri::A" }, @@ -72,7 +73,7 @@ describe("TypeSchema Processing constraint generation", async () => { }; it("Constraint nested type for resource in profile", async () => { - expect(await registerFsAndMkTs(r4, C)).toMatchObject([ + expect(await registerFsAndMkTs(r4, C, logger)).toMatchObject([ { identifier: { kind: "profile", name: "c", url: "uri::C" }, base: { kind: "profile", name: "b", url: "uri::B" }, @@ -117,8 +118,8 @@ describe("TypeSchema Processing constraint generation", async () => { }, }; it("Constraint profile nested type includes all inherited sub-elements", async () => { - await registerFsAndMkTs(r4, D); - expect(await registerFsAndMkTs(r4, E)).toMatchObject([ + await registerFsAndMkTs(r4, D, logger); + expect(await registerFsAndMkTs(r4, E, logger)).toMatchObject([ { identifier: { kind: "profile", name: "e", url: "uri::E" }, base: { kind: "resource", name: "d", url: "uri::D" }, @@ -151,7 +152,7 @@ describe("TypeSchema Processing constraint generation", async () => { if (!profile) { throw new Error("shareablecodesystem profile not found"); } - expect(await registerFsAndMkTs(r4, profile)).toMatchObject([ + expect(await registerFsAndMkTs(r4, profile, logger)).toMatchObject([ { base: { kind: "resource", url: "http://hl7.org/fhir/StructureDefinition/CodeSystem" }, identifier: { kind: "profile", url: "http://hl7.org/fhir/StructureDefinition/shareablecodesystem" }, diff --git a/test/unit/typeschema/transformer/hierarchy.test.ts b/test/unit/typeschema/transformer/hierarchy.test.ts index 1d6fd112c..10283ba24 100644 --- a/test/unit/typeschema/transformer/hierarchy.test.ts +++ b/test/unit/typeschema/transformer/hierarchy.test.ts @@ -1,9 +1,10 @@ import { describe, expect, it } from "bun:test"; import type { PFS } from "@typeschema-test/utils"; -import { mkR4Register, mkR5Register, registerFsAndMkTs } from "@typeschema-test/utils"; +import { mkR4Register, mkR5Register, mkTestLogger, registerFsAndMkTs } from "@typeschema-test/utils"; describe("TypeSchema: Nested types", async () => { const r4 = await mkR4Register(); + const logger = mkTestLogger(); describe("A with array field", () => { const A: PFS = { url: "uri::A", @@ -13,7 +14,7 @@ describe("TypeSchema: Nested types", async () => { }, }; it("Base", async () => { - expect(await registerFsAndMkTs(r4, A)).toMatchObject([ + expect(await registerFsAndMkTs(r4, A, logger)).toMatchObject([ { identifier: { url: "uri::A" }, fields: { @@ -42,7 +43,7 @@ describe("TypeSchema: Nested types", async () => { }, }; - expect(await registerFsAndMkTs(r4, B)).toMatchObject([ + expect(await registerFsAndMkTs(r4, B, logger)).toMatchObject([ { identifier: { url: "uri::B" }, base: { url: "uri::A" }, @@ -89,8 +90,8 @@ describe("TypeSchema: Nested types", async () => { }; it("Check optional choice fields", async () => { // Register B first since C depends on it - await registerFsAndMkTs(r4, B); - expect(await registerFsAndMkTs(r4, C)).toMatchObject([ + await registerFsAndMkTs(r4, B, logger); + expect(await registerFsAndMkTs(r4, C, logger)).toMatchObject([ { identifier: { url: "uri::C" }, base: { url: "uri::B" }, @@ -160,8 +161,9 @@ const viewDefinitionSD = { describe("TypeSchema: Nested types", async () => { const r5 = await mkR5Register(); + const logger = mkTestLogger(); it("Check recursive nested types", async () => { - const tss = await registerFsAndMkTs(r5, viewDefinitionSD); + const tss = await registerFsAndMkTs(r5, viewDefinitionSD, logger); expect(tss).toMatchObject([ { nested: [ diff --git a/test/unit/typeschema/transformer/r4.test.ts b/test/unit/typeschema/transformer/r4.test.ts index cff2c5051..32fde5e1d 100644 --- a/test/unit/typeschema/transformer/r4.test.ts +++ b/test/unit/typeschema/transformer/r4.test.ts @@ -1,17 +1,18 @@ import { describe, expect, it } from "bun:test"; import { generateTypeSchemas } from "@root/typeschema"; import type { CanonicalUrl, Name, RegularTypeSchema } from "@typeschema/types"; -import { mkR4Register, r4Package, registerFsAndMkTs } from "@typeschema-test/utils"; +import { mkR4Register, mkTestLogger, r4Package, registerFsAndMkTs } from "@typeschema-test/utils"; describe("TypeSchema R4 generation", async () => { const r4 = await mkR4Register(); + const logger = mkTestLogger(); it("Bundle and elementReference", async () => { const profile = r4.resolveFs(r4Package, "http://hl7.org/fhir/StructureDefinition/Bundle" as CanonicalUrl); if (!profile) { throw new Error("Bundle profile not found"); } - const ts = (await registerFsAndMkTs(r4, profile))[0] as RegularTypeSchema; + const ts = (await registerFsAndMkTs(r4, profile, logger))[0] as RegularTypeSchema; expect(ts?.nested).toHaveLength(5); expect(ts).toMatchObject({ identifier: { kind: "resource", url: "http://hl7.org/fhir/StructureDefinition/Bundle" }, @@ -54,7 +55,7 @@ describe("TypeSchema R4 generation", async () => { if (!md) { throw new Error("markdown type not found"); } - const ts = (await registerFsAndMkTs(r4, md))[0] as RegularTypeSchema; + const ts = (await registerFsAndMkTs(r4, md, logger))[0] as RegularTypeSchema; expect(ts).toMatchObject({ identifier: { kind: "primitive-type", @@ -77,7 +78,7 @@ describe("TypeSchema R4 generation", async () => { if (!parameters) { throw new Error("Parameters resource not found"); } - const ts = (await registerFsAndMkTs(r4, parameters))[0] as RegularTypeSchema; + const ts = (await registerFsAndMkTs(r4, parameters, logger))[0] as RegularTypeSchema; expect(ts.dependencies).toBeDefined(); expect(ts.dependencies).toContainEqual({ kind: "primitive-type", diff --git a/test/unit/typeschema/transformer/r5.test.ts b/test/unit/typeschema/transformer/r5.test.ts index 53236fb2d..211fa6eb0 100644 --- a/test/unit/typeschema/transformer/r5.test.ts +++ b/test/unit/typeschema/transformer/r5.test.ts @@ -1,9 +1,10 @@ import { describe, expect, it } from "bun:test"; import type { CanonicalUrl, RegularTypeSchema } from "@root/typeschema/types"; -import { mkR5Register, r5Package, registerFsAndMkTs } from "@typeschema-test/utils"; +import { mkR5Register, mkTestLogger, r5Package, registerFsAndMkTs } from "@typeschema-test/utils"; describe("TypeSchema R5 generation", async () => { const r5 = await mkR5Register(); + const logger = mkTestLogger(); it("http://hl7.org/fhir/StructureDefinition/shareablecodesystem", async () => { const fs = r5.resolveFs( @@ -12,7 +13,7 @@ describe("TypeSchema R5 generation", async () => { ); expect(fs).toBeDefined(); if (!fs) throw new Error("fs is undefined"); - const ts = (await registerFsAndMkTs(r5, fs))[0] as RegularTypeSchema; + const ts = (await registerFsAndMkTs(r5, fs, logger))[0] as RegularTypeSchema; expect(ts).toMatchObject({ identifier: { @@ -76,7 +77,7 @@ describe("TypeSchema R5 generation", async () => { const fs = r5.resolveFs(r5Package, "http://hl7.org/fhir/StructureDefinition/Extension" as CanonicalUrl); expect(fs).toBeDefined(); if (!fs) throw new Error("Failed to resolve fs"); - const ts = (await registerFsAndMkTs(r5, fs))[0] as RegularTypeSchema; + const ts = (await registerFsAndMkTs(r5, fs, logger))[0] as RegularTypeSchema; expect(ts).toMatchObject({ identifier: { kind: "complex-type", diff --git a/test/unit/typeschema/utils.ts b/test/unit/typeschema/utils.ts index ba4bcc086..f6f00ea2a 100644 --- a/test/unit/typeschema/utils.ts +++ b/test/unit/typeschema/utils.ts @@ -2,7 +2,8 @@ import type { FHIRSchema } from "@atomic-ehr/fhirschema"; import type { ValueSet } from "@root/fhir-types/hl7-fhir-r4-core"; import { generateTypeSchemas } from "@root/typeschema"; import { mkTypeSchemaIndex } from "@root/typeschema/utils"; -import { type CodegenLogger, createLogger } from "@root/utils/codegen-logger"; +import type { CodegenLog } from "@root/utils/log"; +import { mkCodegenLogger } from "@root/utils/log"; import { transformFhirSchema, transformValueSet } from "@typeschema/core/transformer"; import { type Register, registerFromPackageMetas } from "@typeschema/register"; import { type CanonicalUrl, enrichFHIRSchema, enrichValueSet, type PackageMeta } from "@typeschema/types"; @@ -10,9 +11,11 @@ import { type CanonicalUrl, enrichFHIRSchema, enrichValueSet, type PackageMeta } export type PFS = Partial; export type PVS = Partial; -const logger = createLogger({ prefix: "TEST" }); +export const mkTestLogger = () => mkCodegenLogger({ prefix: "TEST" }); +export const mkErrorLogger = () => mkCodegenLogger({ level: "ERROR" }); +export const mkSilentLogger = () => mkCodegenLogger({ level: "SILENT" }); -export const mkIndex = async (register: Register, logger?: CodegenLogger) => { +export const mkIndex = async (register: Register, logger?: CodegenLog) => { const { schemas } = await generateTypeSchemas(register, logger); return mkTypeSchemaIndex(schemas, { register, logger }); }; @@ -49,13 +52,18 @@ export const registerFs = (register: Register, fs: PFS) => { return rfs; }; -export const resolveTs = async (register: Register, pkgMeta: PackageMeta, url: string | CanonicalUrl) => { +export const resolveTs = async ( + register: Register, + pkgMeta: PackageMeta, + url: string | CanonicalUrl, + logger: CodegenLog, +) => { const rfs = register.resolveFs(pkgMeta, url as CanonicalUrl); if (!rfs) throw new Error("Failed to resolve registered FHIR schema"); return await transformFhirSchema(register, rfs, logger); }; -export const registerFsAndMkTs = async (register: Register, fs: PFS) => { +export const registerFsAndMkTs = async (register: Register, fs: PFS, logger: CodegenLog) => { const rfs = registerFs(register, fs); return await transformFhirSchema(register, rfs, logger); }; diff --git a/test/unit/utils/log.test.ts b/test/unit/utils/log.test.ts new file mode 100644 index 000000000..66fe7044a --- /dev/null +++ b/test/unit/utils/log.test.ts @@ -0,0 +1,413 @@ +import { beforeEach, describe, expect, it, mock } from "bun:test"; +import { type ExtendLogManager, type LogEntry, type LogManager, mkLogger } from "@root/utils/log"; + +type BufferFilter = { level?: string; tag?: T; suppressed?: boolean }; + +const bufferFilter = (logger: LogManager, filter: BufferFilter): LogEntry[] => + logger.buffer().filter((e) => { + if (filter.level !== undefined && e.level !== filter.level) return false; + if (filter.tag !== undefined && e.tag !== filter.tag) return false; + if (filter.suppressed !== undefined && e.suppressed !== filter.suppressed) return false; + return true; + }); + +type TestTags = "TAG_A" | "TAG_B" | "TAG_C"; + +describe("mkLogger", () => { + let logger: LogManager; + + beforeEach(() => { + logger = mkLogger({ prefix: "test" }); + mock.module("console", () => ({})); // silence console in tests + }); + + describe("untagged logging", () => { + it("buffers info messages", () => { + logger.info("hello"); + const entry = logger.buffer()[0]; + expect(entry).toBeDefined(); + expect(entry?.level).toBe("INFO"); + expect(entry?.message).toBe("hello"); + expect(entry?.tag).toBeUndefined(); + expect(entry?.suppressed).toBe(false); + expect(entry?.prefix).toBe("test"); + }); + + it("untagged messages are never suppressed", () => { + const l = mkLogger({ suppressTags: ["TAG_A", "TAG_B", "TAG_C"] }); + l.info("still visible"); + l.warn("still visible"); + l.error("still visible"); + l.debug("still visible"); + expect(bufferFilter(l, { suppressed: true })).toHaveLength(0); + expect(l.buffer()).toHaveLength(4); + }); + }); + + describe("tagged logging", () => { + it("buffers tagged messages with tag field set", () => { + logger.info("TAG_A", "tagged info"); + const entry = logger.buffer()[0]; + expect(entry).toBeDefined(); + expect(entry?.tag).toBe("TAG_A"); + expect(entry?.message).toBe("tagged info"); + expect(entry?.level).toBe("INFO"); + }); + + it("works for all log levels", () => { + logger.info("TAG_A", "i"); + logger.warn("TAG_B", "w"); + logger.error("TAG_C", "e"); + logger.debug("TAG_A", "d"); + expect(logger.buffer().map((e) => e.level)).toEqual(["INFO", "WARN", "ERROR", "DEBUG"]); + expect(logger.buffer().every((e) => e.tag !== undefined)).toBe(true); + }); + + it("increments tag counts", () => { + logger.warn("TAG_A", "one"); + logger.warn("TAG_A", "two"); + logger.info("TAG_B", "three"); + expect(logger.tagCounts().TAG_A).toBe(2); + expect(logger.tagCounts().TAG_B).toBe(1); + expect(logger.tagCounts().TAG_C).toBeUndefined(); + }); + + it("does not increment tag counts for untagged messages", () => { + logger.info("no tag"); + expect(Object.keys(logger.tagCounts())).toHaveLength(0); + }); + }); + + describe("suppression", () => { + it("suppresses tagged messages matching suppressTags", () => { + const l = mkLogger({ suppressTags: ["TAG_A"] }); + l.warn("TAG_A", "suppressed"); + l.warn("TAG_B", "visible"); + + expect(l.buffer()).toHaveLength(2); + expect(l.buffer()[0]?.suppressed).toBe(true); + expect(l.buffer()[1]?.suppressed).toBe(false); + }); + + it("still counts suppressed tags", () => { + const l = mkLogger({ suppressTags: ["TAG_A"] }); + l.warn("TAG_A", "one"); + l.warn("TAG_A", "two"); + expect(l.tagCounts().TAG_A).toBe(2); + }); + }); + + describe("dryWarn deduplication", () => { + it("deduplicates identical tag+message pairs", () => { + logger.dryWarn("TAG_A", "same"); + logger.dryWarn("TAG_A", "same"); + logger.dryWarn("TAG_A", "same"); + // all 3 buffered + expect(logger.buffer()).toHaveLength(3); + // but only 1 was not suppressed (the first), the rest are deduped at console level + // all are marked suppressed=false since TAG_A is not in suppressTags + expect(bufferFilter(logger, { suppressed: false })).toHaveLength(3); + expect(logger.tagCounts().TAG_A).toBe(3); + }); + + it("different messages are not deduped", () => { + logger.dryWarn("TAG_A", "msg1"); + logger.dryWarn("TAG_A", "msg2"); + expect(logger.buffer()).toHaveLength(2); + }); + + it("same message with different tags are not deduped", () => { + logger.dryWarn("TAG_A", "same"); + logger.dryWarn("TAG_B", "same"); + expect(logger.buffer()).toHaveLength(2); + }); + + it("untagged dryWarn deduplicates by message", () => { + logger.dryWarn("same msg"); + logger.dryWarn("same msg"); + logger.dryWarn("different msg"); + expect(logger.buffer()).toHaveLength(3); + }); + }); + + describe("fork", () => { + it("creates child with combined prefix", () => { + const child = logger.fork("child"); + child.info("hello"); + expect(child.buffer()[0]?.prefix).toBe("test/child"); + }); + + it("creates child from root without parent prefix", () => { + const root = mkLogger({}); + const child = root.fork("child"); + child.info("hello"); + expect(child.buffer()[0]?.prefix).toBe("child"); + }); + + it("inherits parent suppressTags", () => { + const parent = mkLogger({ suppressTags: ["TAG_A"] }); + const child = parent.fork("child"); + child.warn("TAG_A", "inherited suppression"); + expect(child.buffer()[0]?.suppressed).toBe(true); + }); + + it("adds child-specific suppressTags", () => { + const parent = mkLogger({ suppressTags: ["TAG_A"] }); + const child = parent.fork("child", { suppressTags: ["TAG_B"] }); + child.warn("TAG_A", "from parent"); + child.warn("TAG_B", "from child"); + child.warn("TAG_C", "not suppressed"); + expect(bufferFilter(child, { suppressed: true })).toHaveLength(2); + expect(child.buffer()[2]?.suppressed).toBe(false); + }); + + it("child has independent buffer", () => { + const child = logger.fork("child"); + logger.info("parent"); + child.info("child"); + expect(logger.buffer()).toHaveLength(1); + expect(child.buffer()).toHaveLength(1); + expect(logger.buffer()[0]?.message).toBe("parent"); + expect(child.buffer()[0]?.message).toBe("child"); + }); + + it("child has independent tag counts", () => { + const child = logger.fork("child"); + logger.warn("TAG_A", "parent"); + child.warn("TAG_A", "child"); + child.warn("TAG_A", "child2"); + expect(logger.tagCounts().TAG_A).toBe(1); + expect(child.tagCounts().TAG_A).toBe(2); + }); + + it("narrows tag set via as()", () => { + type Narrow = "TAG_A"; + const child = logger.fork("narrow").as(); + child.warn("TAG_A", "valid"); + expect(child.buffer()[0]?.tag).toBe("TAG_A"); + }); + }); + + describe("as (narrowing)", () => { + it("returns the same logger instance with narrowed type", () => { + type Narrow = "TAG_A" | "TAG_B"; + const narrow = logger.as(); + narrow.warn("TAG_A", "works"); + expect(logger.buffer()).toHaveLength(1); + expect(narrow.buffer()).toHaveLength(1); + }); + + it("narrowed logger inherits suppression from original", () => { + const parent = mkLogger({ suppressTags: ["TAG_A"] }); + type Narrow = "TAG_A"; + const narrow = parent.as(); + narrow.warn("TAG_A", "suppressed via parent"); + expect(narrow.buffer()[0]?.suppressed).toBe(true); + }); + }); + + describe("ExtendLogManager (extending)", () => { + type BaseTags = "BASE_A" | "BASE_B"; + type ExtraTags = "EXTRA_X" | "EXTRA_Y"; + type Combined = ExtendLogManager>; + + it("extended logger accepts both base and extra tags", () => { + const l: Combined = mkLogger({}); + l.warn("BASE_A", "base tag"); + l.warn("EXTRA_X", "extra tag"); + expect(l.buffer()).toHaveLength(2); + expect(l.buffer()[0]?.tag).toBe("BASE_A"); + expect(l.buffer()[1]?.tag).toBe("EXTRA_X"); + }); + + it("extended logger suppresses both base and extra tags", () => { + const l: Combined = mkLogger({ + suppressTags: ["BASE_A", "EXTRA_X"], + }); + l.warn("BASE_A", "suppressed base"); + l.warn("BASE_B", "visible base"); + l.warn("EXTRA_X", "suppressed extra"); + l.warn("EXTRA_Y", "visible extra"); + expect(bufferFilter(l, { suppressed: true })).toHaveLength(2); + expect(bufferFilter(l, { suppressed: false })).toHaveLength(2); + }); + + it("base logger can be passed where extended is expected via as()", () => { + const base = mkLogger({}); + const extended = base.as(); + extended.warn("EXTRA_X", "works at runtime"); + expect(base.buffer()).toHaveLength(1); + expect(extended.buffer()[0]?.tag).toBe("EXTRA_X"); + }); + + it("fork from extended logger can narrow to base tags", () => { + const extended: Combined = mkLogger({ + prefix: "root", + suppressTags: ["BASE_A"], + }); + const child = extended.fork("child").as(); + child.warn("BASE_A", "suppressed from parent"); + child.warn("BASE_B", "visible"); + expect(bufferFilter(child, { suppressed: true })).toHaveLength(1); + expect(child.buffer()[0]?.tag).toBe("BASE_A"); + expect(child.buffer()[1]?.prefix).toBe("root/child"); + }); + }); + + describe("buffer", () => { + it("returns entries in insertion order", () => { + logger.info("first"); + logger.warn("second"); + logger.error("third"); + expect(logger.buffer().map((e) => e.message)).toEqual(["first", "second", "third"]); + }); + + it("includes timestamp", () => { + const before = Date.now(); + logger.info("timed"); + const after = Date.now(); + const ts = logger.buffer()[0]?.timestamp; + expect(ts).toBeGreaterThanOrEqual(before); + expect(ts).toBeLessThanOrEqual(after); + }); + }); + + describe("bufferFilter", () => { + beforeEach(() => { + const l = mkLogger({ prefix: "f", suppressTags: ["TAG_C"] }); + l.info("untagged info"); + l.warn("TAG_A", "tagged warn"); + l.error("TAG_B", "tagged error"); + l.debug("untagged debug"); + l.info("TAG_C", "suppressed info"); + logger = l; + }); + + it("filters by level", () => { + expect(bufferFilter(logger, { level: "INFO" })).toHaveLength(2); + expect(bufferFilter(logger, { level: "WARN" })).toHaveLength(1); + expect(bufferFilter(logger, { level: "ERROR" })).toHaveLength(1); + expect(bufferFilter(logger, { level: "DEBUG" })).toHaveLength(1); + }); + + it("filters by tag", () => { + expect(bufferFilter(logger, { tag: "TAG_A" })).toHaveLength(1); + expect(bufferFilter(logger, { tag: "TAG_B" })).toHaveLength(1); + expect(bufferFilter(logger, { tag: "TAG_C" })).toHaveLength(1); + }); + + it("filters by suppressed", () => { + expect(bufferFilter(logger, { suppressed: true })).toHaveLength(1); + expect(bufferFilter(logger, { suppressed: false })).toHaveLength(4); + }); + + it("combines filters", () => { + expect(bufferFilter(logger, { level: "INFO", suppressed: true })).toHaveLength(1); + expect(bufferFilter(logger, { level: "INFO", suppressed: false })).toHaveLength(1); + expect(bufferFilter(logger, { level: "WARN", tag: "TAG_A" })).toHaveLength(1); + expect(bufferFilter(logger, { level: "WARN", tag: "TAG_B" })).toHaveLength(0); + }); + }); + + describe("bufferClear", () => { + it("empties the buffer", () => { + logger.info("a"); + logger.warn("b"); + expect(logger.buffer()).toHaveLength(2); + logger.bufferClear(); + expect(logger.buffer()).toHaveLength(0); + }); + + it("does not reset tag counts", () => { + logger.warn("TAG_A", "msg"); + logger.bufferClear(); + expect(logger.tagCounts().TAG_A).toBe(1); + }); + }); + + describe("printTagSummary", () => { + it("prints warning summary with all tag counts", () => { + const l = mkLogger({ prefix: "test", suppressTags: ["TAG_A"] }); + l.warn("TAG_A", "a1"); + l.warn("TAG_A", "a2"); + l.warn("TAG_B", "b1"); + l.printTagSummary(); + // printTagSummary writes directly to console, doesn't buffer + expect(l.tagCounts().TAG_A).toBe(2); + expect(l.tagCounts().TAG_B).toBe(1); + }); + + it("does nothing when no tags exist", () => { + logger.info("no tags"); + logger.printTagSummary(); + // no crash, no extra output + }); + }); + + describe("prefix", () => { + it("uses empty prefix by default", () => { + const l = mkLogger({}); + l.info("msg"); + expect(l.buffer()[0]?.prefix).toBe(""); + }); + + it("nests prefixes through multiple forks", () => { + const child = logger.fork("a").fork("b"); + child.info("deep"); + expect(child.buffer()[0]?.prefix).toBe("test/a/b"); + }); + }); + + describe("log level filtering", () => { + it("defaults to info level (debug messages not printed but buffered)", () => { + const l = mkLogger({}); + l.debug("hidden"); + l.info("visible"); + expect(l.buffer()).toHaveLength(2); + }); + + it("filters messages below configured level", () => { + const l = mkLogger({ level: "WARN" }); + l.debug("d"); + l.info("i"); + l.warn("w"); + l.error("e"); + // all 4 buffered + expect(l.buffer()).toHaveLength(4); + }); + + it("fork inherits parent level", () => { + const parent = mkLogger({ level: "WARN" }); + const child = parent.fork("child"); + child.debug("d"); + child.info("i"); + child.warn("w"); + expect(child.buffer()).toHaveLength(3); + }); + + it("fork can override parent level", () => { + const parent = mkLogger({ level: "WARN" }); + const child = parent.fork("child", { level: "DEBUG" }); + child.debug("d"); + expect(child.buffer()).toHaveLength(1); + }); + + it("level filtering works alongside tag suppression", () => { + const l = mkLogger({ level: "WARN", suppressTags: ["TAG_A"] }); + l.info("TAG_A", "suppressed + below level"); + l.warn("TAG_A", "suppressed at level"); + l.warn("TAG_B", "visible"); + expect(l.buffer()).toHaveLength(3); + expect(bufferFilter(l, { suppressed: true })).toHaveLength(2); + }); + + it("silent level suppresses all console output but still buffers", () => { + const l = mkLogger({ level: "SILENT" }); + l.debug("d"); + l.info("i"); + l.warn("w"); + l.error("e"); + expect(l.buffer()).toHaveLength(4); + }); + }); +});