diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bd311f7f..32db9e32 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,11 +10,6 @@ permissions: jobs: test: runs-on: ubuntu-latest - - strategy: - matrix: - bun-version: [latest] - steps: - name: Checkout code uses: actions/checkout@v4 @@ -22,7 +17,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: ${{ matrix.bun-version }} + bun-version: latest - name: Install dependencies run: bun install --frozen-lockfile diff --git a/examples/python/generate.ts b/examples/python/generate.ts index 3fb29250..17c97d00 100644 --- a/examples/python/generate.ts +++ b/examples/python/generate.ts @@ -1,8 +1,14 @@ import { APIBuilder, prettyReport } from "../../src"; +import { type LogManager, mkLogger } from "../../src/utils/log"; console.log("📦 Generating FHIR R4 Core Types..."); -const builder = new APIBuilder() +const logger: LogManager = mkLogger({ + prefix: "API", + suppressTags: ["FIELD_TYPE_NOT_FOUND", "LARGE_VALUESET"], +}); + +const builder = new APIBuilder({ logger }) .throwException() .fromPackage("hl7.fhir.r4.core", "4.0.1") .python({ diff --git a/src/api/builder.ts b/src/api/builder.ts index 146333d8..23c107ea 100644 --- a/src/api/builder.ts +++ b/src/api/builder.ts @@ -23,13 +23,7 @@ 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 LogManager, type LogLevel, 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"; @@ -100,7 +94,7 @@ export interface LocalStructureDefinitionConfig { dependencies?: PackageMeta[]; } -const cleanup = async (opts: APIBuilderOptions, logger: CodegenLogger): Promise => { +const cleanup = async (opts: APIBuilderOptions, logger: LogManager): Promise => { logger.info(`Cleaning outputs...`); try { logger.info(`Clean ${opts.outputDir}`); @@ -125,7 +119,7 @@ export class APIBuilder { localSDs: LocalPackageConfig[]; localTgzPackages: TgzPackageConfig[]; }; - private logger: CodegenLogger; + private logger: LogManager; private generators: { name: string; writer: FileSystemWriter }[] = []; constructor( @@ -133,7 +127,7 @@ export class APIBuilder { manager?: ReturnType; register?: Register; preprocessPackage?: (context: PreprocessContext) => PreprocessContext; - logger?: CodegenLogger; + logger?: LogManager; } = {}, ) { const defaultOpts: APIBuilderOptions = { @@ -143,7 +137,7 @@ export class APIBuilder { treeShake: undefined, promoteLogical: undefined, registry: undefined, - logLevel: parseLogLevel("INFO"), + logLevel: "INFO", dropCanonicalManagerCache: false, }; const opts: APIBuilderOptions = { @@ -179,7 +173,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", level: opts.logLevel }); this.options = opts; } @@ -340,8 +334,8 @@ export class APIBuilder { return this; } - setLogLevel(level: LogLevel | LogLevelString): APIBuilder { - this.logger?.setLevel(typeof level === "string" ? parseLogLevel(level) : level); + setLogLevel(level: LogLevel): APIBuilder { + this.logger?.setLevel(level); return this; } @@ -449,7 +443,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 2dbd32b3..dc3e11a5 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -8,7 +8,7 @@ */ export type { IrConf, LogicalPromotionConf, TreeShakeConf } from "../typeschema/ir/types"; -export { LogLevel } from "../utils/codegen-logger"; +export type { LogLevel } 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 cf270e68..3a2d794a 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 { Log } 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?: Log, ): 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 70938815..d704aa46 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 { Log } from "@root/utils/log"; export type FileSystemWriterOptions = { outputDir: string; inMemoryOnly?: boolean; - logger?: CodegenLogger; + logger?: Log; 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()]; -} +const 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.setLevel(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 37863c16..b4431a5f 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 e75b6d16..93b12b3c 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 e8bbb139..47d2e534 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 { Log } 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?: Log, ): Concept[] | undefined { const cleanUrl = dropVersionFromUrl(valueSetUrl) || valueSetUrl; const valueSet = register.resolveVs(pkg, cleanUrl as CanonicalUrl); @@ -32,11 +32,7 @@ export function extractValueSetConceptsByUrl( return extractValueSetConcepts(register, valueSet, logger); } -function extractValueSetConcepts( - register: Register, - valueSet: RichValueSet, - _logger?: CodegenLogger, -): Concept[] | undefined { +function extractValueSetConcepts(register: Register, valueSet: RichValueSet, _logger?: Log): Concept[] | undefined { if (valueSet.expansion?.contains) { return valueSet.expansion.contains .filter((item) => item.code !== undefined) @@ -106,7 +102,7 @@ export function buildEnum( register: Register, fhirSchema: RichFHIRSchema, element: FHIRSchemaElement, - logger?: CodegenLogger, + logger?: Log, ): EnumDefinition | undefined { if (!element.binding) return undefined; @@ -115,7 +111,7 @@ 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 +128,7 @@ export function buildEnum( if (codes.length > MAX_ENUM_LENGTH) { logger?.dryWarn( + "LARGE_VALUESET", `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 +143,7 @@ function generateBindingSchema( fhirSchema: RichFHIRSchema, path: string[], element: FHIRSchemaElement, - logger?: CodegenLogger, + logger?: Log, ): BindingTypeSchema | undefined { if (!element.binding?.valueSet) return undefined; @@ -171,7 +168,7 @@ function generateBindingSchema( export function collectBindingSchemas( register: Register, fhirSchema: RichFHIRSchema, - logger?: CodegenLogger, + logger?: Log, ): 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 3676adce..de673e4c 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 { Log } 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?: Log, ): 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( + "FIELD_TYPE_NOT_FOUND", `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?: Log, 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( + "FIELD_TYPE_NOT_FOUND", + `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 b2da3ad5..e8bef31b 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 { Log } 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?: Log, ): Record { const fields: Record = {}; @@ -148,7 +148,7 @@ function transformNestedElements( export function mkNestedTypes( register: Register, fhirSchema: RichFHIRSchema, - logger?: CodegenLogger, + logger?: Log, ): NestedType[] | undefined { if (!fhirSchema.elements) return undefined; diff --git a/src/typeschema/core/profile-extensions.ts b/src/typeschema/core/profile-extensions.ts index c7210585..d835aa11 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 { Log } from "@root/utils/log"; import { type CanonicalUrl, concatIdentifiers, @@ -24,7 +24,7 @@ const extractExtensionValueTypes = ( register: Register, fhirSchema: RichFHIRSchema, extensionUrl: CanonicalUrl, - logger?: CodegenLogger, + logger?: Log, ): 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?: Log, ): ExtensionSubField[] => { const subExtensions: ExtensionSubField[] = []; if (!extensionSchema.elements) return subExtensions; @@ -114,7 +114,7 @@ const extractSubExtensions = ( register: Register, fhirSchema: RichFHIRSchema, extensionUrl: CanonicalUrl, - logger?: CodegenLogger, + logger?: Log, ): 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?: Log, ): ProfileExtension[] | undefined => { const extensions: ProfileExtension[] = []; diff --git a/src/typeschema/core/transformer.ts b/src/typeschema/core/transformer.ts index 316618b3..45ae672f 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 { Log } 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?: Log, ): 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( + "SKIP_CANONICAL", `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?: Log, ): Promise { if (!valueSet.url) throw new Error("ValueSet URL is required"); @@ -113,11 +114,7 @@ export function extractDependencies( return concatIdentifiers(filtered); } -function transformFhirSchemaResource( - register: Register, - fhirSchema: RichFHIRSchema, - logger?: CodegenLogger, -): TypeSchema[] { +function transformFhirSchemaResource(register: Register, fhirSchema: RichFHIRSchema, logger?: Log): TypeSchema[] { const identifier = mkIdentifier(fhirSchema); let base: Identifier | undefined; @@ -158,7 +155,7 @@ function transformFhirSchemaResource( export async function transformFhirSchema( register: Register, fhirSchema: RichFHIRSchema, - logger?: CodegenLogger, + logger?: Log, ): Promise { return transformFhirSchemaResource(register, fhirSchema, logger); } diff --git a/src/typeschema/index.ts b/src/typeschema/index.ts index 9aa625a6..3bd4f729 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 { Log } 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?: Log): 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("DUPLICATE_SCHEMA", `'${url}' from '${pkg}'' has ${sorted.length} versions`); collisions[pkg] ??= {}; collisions[pkg][url] = sorted.flatMap((v) => v.sources.map((s) => ({ @@ -77,10 +74,7 @@ const deduplicateSchemas = ( return { schemas, collisions }; }; -export const generateTypeSchemas = async ( - register: Register, - logger?: CodegenLogger, -): Promise => { +export const generateTypeSchemas = async (register: Register, logger?: Log): Promise => { const schemasWithSources: { schema: TypeSchema; sourcePackage: PkgName; sourceCanonical: CanonicalUrl }[] = []; for (const fhirSchema of register.allFs()) { @@ -88,7 +82,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("SKIP_CANONICAL", `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 fad2f2d5..661f423e 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 { Log } 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?: Log): 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 82bed46c..796bdd12 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 { Log } from "@root/utils/log"; import type { CanonicalUrl, Name, @@ -86,7 +86,7 @@ const mkPackageAwareResolver = async ( pkg: PackageMeta, deep: number, acc: PackageAwareResolver, - logger?: CodegenLogger, + logger?: Log, ): 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("DUPLICATE_CANONICAL", `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?: Log) => { 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?: Log) => { 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?: Log; focusedPackages?: PackageMeta[]; /** Custom FHIR package registry URL */ registry?: string; @@ -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: "tmp/fhir", diff --git a/src/typeschema/types.ts b/src/typeschema/types.ts index 35b214c0..72475077 100644 --- a/src/typeschema/types.ts +++ b/src/typeschema/types.ts @@ -362,7 +362,7 @@ export const enrichValueSet = (vs: ValueSet, packageMeta: PackageMeta): RichValu /////////////////////////////////////////////////////////// export interface TypeschemaGeneratorOptions { - logger?: import("../utils/codegen-logger").CodegenLogger; + logger?: import("../utils/log").Log; treeshake?: string[]; manager: ReturnType; /** Custom FHIR package registry URL */ diff --git a/src/typeschema/utils.ts b/src/typeschema/utils.ts index 340ec39e..43915c3c 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 { Log } 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?: Log; irReport?: IrReport; }, ): TypeSchemaIndex => { @@ -274,6 +274,7 @@ export const mkTypeSchemaIndex = ( const resolved = resolve(base); if (!resolved) { logger?.warn( + "RESOLVE_BASE", `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 00000000..dc0b08ba --- /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 f13c3a96..00000000 --- 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/log.ts b/src/utils/log.ts new file mode 100644 index 00000000..59c456a9 --- /dev/null +++ b/src/utils/log.ts @@ -0,0 +1,154 @@ +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; + + suppress(...tags: T[]): void; + setLevel(level: LogLevel): void; + tagCounts(): Readonly>; + printSuppressedSummary(): void; + + buffer(): readonly LogEntry[]; + bufferClear(): void; +}; + +type TagsOf = L extends LogManager ? T : never; + +export type ExtendLogManager> = LogManager< + TagsOf | Extra +>; + +type TaggedLogFn = (...args: [string] | [T, string]) => void; + +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(); + let 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 tagStr = tag ? `[${tag}] ` : ""; + return colorize[level](`${icon} ${pfx}${tagStr}${msg}`); + }; + + 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 C[]; + return mkLogger({ + prefix: fullPrefix, + suppressTags: merged, + level: childOpts?.level ?? currentLevel, + }); + }, + + as(): LogManager { + return logger as unknown as LogManager; + }, + + suppress(...tags: T[]) { + for (const tag of tags) suppressedSet.add(tag); + }, + + setLevel(level: LogLevel) { + currentLevel = level; + }, + + tagCounts(): Readonly> { + return tagCounts; + }, + + printSuppressedSummary() { + const suppressed = Object.entries(tagCounts) + .filter(([tag]) => suppressedSet.has(tag)) + .map(([tag, count]) => `${tag}: ${count}`); + if (suppressed.length > 0) { + logger.info(`Suppressed: ${suppressed.join(", ")}`); + } + }, + + buffer(): readonly LogEntry[] { + return entries; + }, + + bufferClear() { + entries.length = 0; + }, + }; + + return logger; +} diff --git a/test/api/mustache.test.ts b/test/api/mustache.test.ts index 02a42c5c..d0585139 100644 --- a/test/api/mustache.test.ts +++ b/test/api/mustache.test.ts @@ -4,7 +4,7 @@ import { r4Manager } from "@typeschema-test/utils"; describe("Mustache Template Based Generation", async () => { const report = await new APIBuilder({ register: r4Manager }) - .setLogLevel("SILENT") + .setLogLevel("ERROR") .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 59f84629..1a78fcef 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 c4031e3a..11138868 100644 --- a/test/api/write-generator/csharp.test.ts +++ b/test/api/write-generator/csharp.test.ts @@ -4,7 +4,7 @@ import { r4Manager } from "@typeschema-test/utils"; describe("C# Writer Generator", async () => { const result = await new APIBuilder({ register: r4Manager }) - .setLogLevel("SILENT") + .setLogLevel("ERROR") .csharp({ inMemoryOnly: true, }) diff --git a/test/api/write-generator/introspection.test.ts b/test/api/write-generator/introspection.test.ts index 70779a7e..af1116f6 100644 --- a/test/api/write-generator/introspection.test.ts +++ b/test/api/write-generator/introspection.test.ts @@ -4,7 +4,7 @@ import { r4Manager } from "@typeschema-test/utils"; describe("IntrospectionWriter - Fhir Schema Output", async () => { const result = await new APIBuilder({ register: r4Manager }) - .setLogLevel("SILENT") + .setLogLevel("ERROR") .introspection({ fhirSchemas: "introspection" }) .introspection({ fhirSchemas: "introspection.ndjson" }) .generate(); @@ -28,7 +28,7 @@ describe("IntrospectionWriter - Fhir Schema Output", async () => { describe("IntrospectionWriter - TypeSchema output", async () => { const result = await new APIBuilder({ register: r4Manager }) - .setLogLevel("SILENT") + .setLogLevel("ERROR") .typeSchema({ treeShake: { "hl7.fhir.r4.core": { @@ -68,7 +68,7 @@ describe("IntrospectionWriter - TypeSchema output", async () => { describe("IntrospectionWriter - typeTree", async () => { const result = await new APIBuilder({ register: r4Manager }) - .setLogLevel("SILENT") + .setLogLevel("ERROR") .typeSchema({ treeShake: { "hl7.fhir.r4.core": { @@ -94,7 +94,7 @@ describe("IntrospectionWriter - typeTree", async () => { describe("IntrospectionWriter - StructureDefinition output", async () => { const result = await new APIBuilder({ register: r4Manager }) - .setLogLevel("SILENT") + .setLogLevel("ERROR") .typeSchema({ treeShake: { "hl7.fhir.r4.core": { diff --git a/test/api/write-generator/python.test.ts b/test/api/write-generator/python.test.ts index 265c9530..d8bdb21e 100644 --- a/test/api/write-generator/python.test.ts +++ b/test/api/write-generator/python.test.ts @@ -4,7 +4,7 @@ import { r4Manager } from "@typeschema-test/utils"; describe("Python Writer Generator", async () => { const result = await new APIBuilder({ register: r4Manager }) - .setLogLevel("SILENT") + .setLogLevel("ERROR") .python({ inMemoryOnly: true, }) diff --git a/test/api/write-generator/typescript.test.ts b/test/api/write-generator/typescript.test.ts index f270955a..2c7198c3 100644 --- a/test/api/write-generator/typescript.test.ts +++ b/test/api/write-generator/typescript.test.ts @@ -1,24 +1,12 @@ 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 { mkLogger } from "@root/utils/log"; 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 }; -}; - describe("TypeScript Writer Generator", async () => { const result = await new APIBuilder({ register: r4Manager }) - .setLogLevel("SILENT") + .setLogLevel("ERROR") .typescript({ inMemoryOnly: true, }) @@ -42,7 +30,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") + .setLogLevel("ERROR") .typeSchema({ promoteLogical: { "hl7.cda.uv.core": ["http://hl7.org/cda/stds/core/StructureDefinition/Material" as CanonicalUrl], @@ -64,10 +52,9 @@ describe("TypeScript CDA with Logical Model Promotion to Resource", async () => }); describe("TypeScript R4 Example (with generateProfile)", async () => { - const { logger, warnings } = createCapturingLogger(); + const logger = mkLogger({ level: "ERROR" }); const result = await new APIBuilder({ register: r4Manager, logger }) - .setLogLevel("SILENT") .typescript({ inMemoryOnly: true, withDebugComment: false, @@ -80,8 +67,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/utils.ts b/test/unit/typeschema/utils.ts index ba4bcc08..764d5b6f 100644 --- a/test/unit/typeschema/utils.ts +++ b/test/unit/typeschema/utils.ts @@ -2,7 +2,7 @@ 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 Log, mkLogger } 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 +10,9 @@ import { type CanonicalUrl, enrichFHIRSchema, enrichValueSet, type PackageMeta } export type PFS = Partial; export type PVS = Partial; -const logger = createLogger({ prefix: "TEST" }); +const logger = mkLogger({ prefix: "TEST" }); -export const mkIndex = async (register: Register, logger?: CodegenLogger) => { +export const mkIndex = async (register: Register, logger?: Log) => { const { schemas } = await generateTypeSchemas(register, logger); return mkTypeSchemaIndex(schemas, { register, logger }); }; diff --git a/test/unit/utils/log.test.ts b/test/unit/utils/log.test.ts new file mode 100644 index 00000000..faf93d5a --- /dev/null +++ b/test/unit/utils/log.test.ts @@ -0,0 +1,453 @@ +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); + }); + + it("suppress() adds tags at runtime", () => { + logger.warn("TAG_B", "before"); + expect(logger.buffer()[0]?.suppressed).toBe(false); + + logger.suppress("TAG_B"); + logger.warn("TAG_B", "after"); + expect(logger.buffer()[1]?.suppressed).toBe(true); + }); + }); + + 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 on fork", () => { + type Narrow = "TAG_A"; + const child = logger.fork("narrow"); + child.warn("TAG_A", "valid"); + expect(child.buffer()[0]?.tag).toBe("TAG_A"); + }); + + it("inherits runtime suppress() calls", () => { + const parent = mkLogger({ suppressTags: ["TAG_A"] }); + parent.suppress("TAG_B"); + const child = parent.fork("child"); + child.warn("TAG_A", "from init"); + child.warn("TAG_B", "from runtime suppress"); + child.warn("TAG_C", "not suppressed"); + expect(bufferFilter(child, { suppressed: true })).toHaveLength(2); + expect(child.buffer()[2]?.suppressed).toBe(false); + }); + }); + + 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); + }); + + it("suppress on narrowed logger affects original", () => { + type Narrow = "TAG_A" | "TAG_B"; + const narrow = logger.as(); + narrow.suppress("TAG_A"); + logger.warn("TAG_A", "should be suppressed"); + expect(logger.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"); + 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("printSuppressedSummary", () => { + it("emits an info entry with suppressed counts", () => { + const l = mkLogger({ suppressTags: ["TAG_A", "TAG_B"] }); + l.warn("TAG_A", "a1"); + l.warn("TAG_A", "a2"); + l.warn("TAG_B", "b1"); + l.printSuppressedSummary(); + + const summaryEntries = bufferFilter(l, { level: "INFO" }); + expect(summaryEntries).toHaveLength(1); + expect(summaryEntries[0]?.message).toContain("TAG_A: 2"); + expect(summaryEntries[0]?.message).toContain("TAG_B: 1"); + }); + + it("does nothing when no tags are suppressed", () => { + logger.warn("TAG_A", "visible"); + const countBefore = logger.buffer().length; + logger.printSuppressedSummary(); + expect(logger.buffer()).toHaveLength(countBefore); + }); + }); + + 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("setLevel changes level at runtime", () => { + const l = mkLogger({ level: "INFO" }); + l.debug("before"); + l.setLevel("DEBUG"); + l.debug("after"); + // both buffered regardless + expect(l.buffer()).toHaveLength(2); + }); + + 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); + }); + }); +});