diff --git a/src/api/writer-generator/typescript/profile-validation.ts b/src/api/writer-generator/typescript/profile-validation.ts index e9c833b2..2eb3f1bb 100644 --- a/src/api/writer-generator/typescript/profile-validation.ts +++ b/src/api/writer-generator/typescript/profile-validation.ts @@ -120,6 +120,14 @@ export const generateValidateMethod = ( ); } + // Base-resource required fields the profile chain did not re-state. + // Emitted here (not via the regular field loop) because they intentionally + // live outside `fields` to avoid pulling unrelated base metadata into the + // profile's getter/setter surface. + for (const inheritedName of snapshot.inheritedRequiredFields ?? []) { + errors.push(`...validateRequired(res, profileName, ${JSON.stringify(inheritedName)})`); + } + const emitArray = (label: string, exprs: string[]) => { if (exprs.length === 0) { w.line(`${label}: [],`); diff --git a/src/api/writer-generator/typescript/profile.ts b/src/api/writer-generator/typescript/profile.ts index 0c947149..8b7ba46c 100644 --- a/src/api/writer-generator/typescript/profile.ts +++ b/src/api/writer-generator/typescript/profile.ts @@ -250,7 +250,13 @@ const generateProfileHelpersImport = ( imports.push("isExtension", "getExtensionValue", "pushExtension"); if (extensions.some((ext) => ext.url && ext.max === "1")) imports.push("upsertExtension"); } - if (Object.keys(snapshot.fields).length > 0) + // validate() emits calls when the profile has its own fields OR when it + // inherits base-required fields it does not re-state (those produce + // validateRequired() calls with no entry in `fields`). Without the second + // clause, a profile whose only validation is an inherited required field + // (e.g. an Extension profile relying on the base Extension.url) would emit + // validateRequired() without importing it (TS2304). + if (Object.keys(snapshot.fields).length > 0 || (snapshot.inheritedRequiredFields?.length ?? 0) > 0) imports.push( "validateRequired", "validateExcluded", diff --git a/src/typeschema/types.ts b/src/typeschema/types.ts index 62e3c992..05302bd9 100644 --- a/src/typeschema/types.ts +++ b/src/typeschema/types.ts @@ -272,6 +272,14 @@ export interface SnapshotProfileTypeSchema { base: TypeIdentifier; description?: string; fields: Record; + /** Top-level required field names inherited from the base resource that the + * profile chain does not re-state in its differential. Needed so that + * validate() emits validateRequired() for base-FHIR cardinality (e.g. for + * Provenance.target / .recorded) when a profile leaves those fields + * untouched. Profiles that explicitly include the field in their + * differential are NOT listed here — their constraint is already in + * `fields` and the validator handles them via the regular path. */ + inheritedRequiredFields?: string[]; extensions?: ProfileExtension[]; dependencies?: TypeIdentifier[]; nested?: NestedTypeSchema[]; diff --git a/src/typeschema/utils.ts b/src/typeschema/utils.ts index d1741f19..c457163a 100644 --- a/src/typeschema/utils.ts +++ b/src/typeschema/utils.ts @@ -556,14 +556,18 @@ export const mkTypeSchemaIndex = ( if (!schema.fields) continue; for (const [fieldName, fieldConstraints] of Object.entries(schema.fields)) { - if (mergedFields[fieldName]) { - mergedFields[fieldName] = { - ...mergedFields[fieldName], - ...fieldConstraints, - }; - } else { - mergedFields[fieldName] = { ...fieldConstraints }; + const merged: Field = mergedFields[fieldName] + ? { ...mergedFields[fieldName], ...fieldConstraints } + : { ...fieldConstraints }; + // Profile-explicit relaxation: differential lowered min to 0 + // → the field is no longer required even if a base ancestor + // (or earlier profile in the chain) declared it so. Without + // this, validate() would emit validateRequired() for a field + // the leaf profile intentionally relaxed. + if ("min" in fieldConstraints && fieldConstraints.min === 0) { + (merged as { required?: boolean }).required = false; } + mergedFields[fieldName] = merged; } } @@ -600,11 +604,43 @@ export const mkTypeSchemaIndex = ( const buildProfileSnapshot = (schema: ProfileTypeSchema): SnapshotProfileTypeSchema => { const flat = flatProfile(schema); + const flatFields = flat.fields ?? {}; + + // Inherited-required collection: top-level required fields on the base + // resource that the profile chain never re-states. Listed separately + // from `fields` so this fix only adds validateRequired() calls without + // pulling unrelated base metadata (e.g. unused value[x] variants) into + // the snapshot — which would otherwise expand profile getters/setters. + const hierarchySchemas = hierarchy(schema); + const nonConstraintSchema = hierarchySchemas.find((s) => s.identifier.kind !== "profile") as + | SpecializationTypeSchema + | undefined; + const inheritedRequiredFields: string[] = []; + if (nonConstraintSchema?.fields) { + for (const [name, baseField] of Object.entries(nonConstraintSchema.fields)) { + if (!baseField.required) continue; + const flat = flatFields[name] as { required?: boolean; min?: number } | undefined; + // Profile explicitly relaxed the field via differential min:0 → + // skip (regular validate emission also skips it because flatField + // .required was reset to false in flatProfile). + if (flat?.min === 0) continue; + // Profile (or any ancestor in the chain) restated the field with + // required:true → already emitted by the regular field loop. + if (flat?.required) continue; + // Either the profile leaves the field untouched, or the FHIRSchema + // snapshot expansion inlined the inherited element with required + // unset / false despite the base requiring it. Either way the + // emitter needs an explicit validateRequired() call. + inheritedRequiredFields.push(name); + } + } + return { identifier: snapshotIdentifier(flat.identifier), base: flat.base, description: flat.description, - fields: flat.fields ?? {}, + fields: flatFields, + inheritedRequiredFields: inheritedRequiredFields.length > 0 ? inheritedRequiredFields : undefined, extensions: flat.extensions, dependencies: flat.dependencies, nested: flat.nested, diff --git a/test/api/write-generator/multi-package/__snapshots__/local-package.test.ts.snap b/test/api/write-generator/multi-package/__snapshots__/local-package.test.ts.snap index ed2d9111..e65052af 100644 --- a/test/api/write-generator/multi-package/__snapshots__/local-package.test.ts.snap +++ b/test/api/write-generator/multi-package/__snapshots__/local-package.test.ts.snap @@ -1,4 +1,4 @@ -// Bun Snapshot v1, https://bun.sh/docs/test/snapshots +// Bun Snapshot v1, https://goo.gl/fbAQLP exports[`Local Package Folder - Multi-Package Generation TypeScript Generation should generate ExampleNotebook type in custom package folder 1`] = ` "// WARNING: This file is autogenerated by @atomic-ehr/codegen. @@ -182,6 +182,7 @@ export class ExampleTypedBundleProfile { return { errors: [ ...validateSliceCardinality(res, profileName, "entry", {"resource":{"resourceType":"Patient"}}, "PatientEntry", 1, 1), + ...validateRequired(res, profileName, "type"), ], warnings: [], } diff --git a/test/api/write-generator/profile-inherited-required.test.ts b/test/api/write-generator/profile-inherited-required.test.ts new file mode 100644 index 00000000..4f1674e7 --- /dev/null +++ b/test/api/write-generator/profile-inherited-required.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "bun:test"; +import * as Path from "node:path"; +import { APIBuilder } from "@root/api/builder"; +import { mkSilentLogger } from "@typeschema-test/utils"; + +const FIXTURE_PATH = Path.join(__dirname, "../../assets/profile-inherited-required"); + +/** + * Regression for codegen-8iw: a profile whose differential does NOT re-state + * a base-R4 required field (Provenance.target / .recorded) must still emit + * validateRequired() for those fields in the generated validate() method. + * + * The fixture mirrors the real de.cognovis.fhir.praxis PraxisProposalProvenance + * profile: its differential touches only activity, agent.role, agent.type, and + * entity — target and recorded are inherited from base R4 Provenance. + */ +describe("Profile inherited base-required fields (codegen-8iw)", async () => { + const result = await new APIBuilder({ logger: mkSilentLogger() }) + .localStructureDefinitions({ + package: { name: "cognovis.test.praxis", version: "0.0.1" }, + path: FIXTURE_PATH, + dependencies: [{ name: "hl7.fhir.r4.core", version: "4.0.1" }], + }) + .typescript({ inMemoryOnly: true, generateProfile: true, withDebugComment: false }) + .generate(); + + const profileKey = Object.keys(result.filesGenerated.typescript ?? {}).find((k) => + k.includes("Provenance_PraxisProposalProvenance"), + ); + const profileFile = () => (profileKey ? result.filesGenerated.typescript![profileKey] : undefined); + + it("should succeed", () => { + expect(result.success).toBeTrue(); + }); + + it("generates the Provenance profile class", () => { + expect(profileKey).toBeDefined(); + }); + + it("emits validateRequired() for the differential-stated required fields", () => { + const file = profileFile(); + expect(file).toBeDefined(); + expect(file).toContain('validateRequired(res, profileName, "activity")'); + expect(file).toContain('validateRequired(res, profileName, "agent")'); + }); + + it("emits validateRequired() for base-R4 required fields the profile inherits", () => { + const file = profileFile(); + expect(file).toBeDefined(); + // target and recorded are required by base R4 Provenance and are NOT + // re-stated in the profile differential — these are the fields the bug + // silently dropped before the inheritedRequiredFields fix. + expect(file).toContain('validateRequired(res, profileName, "target")'); + expect(file).toContain('validateRequired(res, profileName, "recorded")'); + }); + + // An Extension profile whose differential states no fields still inherits the + // required Extension.url. validate() must emit validateRequired("url") AND the + // generated file must import validateRequired — otherwise tsc fails with TS2304 + // ("Cannot find name 'validateRequired'"). The import guard previously keyed only + // on snapshot.fields being non-empty, which is false for this profile. + const extensionKey = Object.keys(result.filesGenerated.typescript ?? {}).find((k) => + k.includes("MinimalRequiredExtension"), + ); + const extensionFile = () => (extensionKey ? result.filesGenerated.typescript![extensionKey] : undefined); + + it("imports validateRequired when the only validation is an inherited required field", () => { + const file = extensionFile(); + expect(file).toBeDefined(); + expect(file).toContain('validateRequired(res, profileName, "url")'); + // The import must be present for the emitted call to typecheck. + expect(file).toMatch(/import\s*\{[^}]*\bvalidateRequired\b/); + }); +}); diff --git a/test/assets/profile-inherited-required/minimal-extension.json b/test/assets/profile-inherited-required/minimal-extension.json new file mode 100644 index 00000000..4246b863 --- /dev/null +++ b/test/assets/profile-inherited-required/minimal-extension.json @@ -0,0 +1,30 @@ +{ + "resourceType": "StructureDefinition", + "id": "minimal-required-extension", + "url": "https://example.org/StructureDefinition/minimal-required-extension", + "version": "0.0.1", + "name": "MinimalRequiredExtension", + "title": "Minimal Required Extension", + "status": "active", + "fhirVersion": "4.0.1", + "kind": "complex-type", + "abstract": false, + "context": [ + { + "type": "element", + "expression": "Patient" + } + ], + "type": "Extension", + "baseDefinition": "http://hl7.org/fhir/StructureDefinition/Extension", + "derivation": "constraint", + "differential": { + "element": [ + { + "id": "Extension", + "path": "Extension", + "short": "Extension that re-states no fields in its differential" + } + ] + } +} diff --git a/test/assets/profile-inherited-required/provenance-profile.json b/test/assets/profile-inherited-required/provenance-profile.json new file mode 100644 index 00000000..0e1c4585 --- /dev/null +++ b/test/assets/profile-inherited-required/provenance-profile.json @@ -0,0 +1,65 @@ +{ + "resourceType": "StructureDefinition", + "id": "praxis-proposal-provenance", + "url": "https://fhir.cognovis.de/praxis/StructureDefinition/praxis-proposal-provenance", + "version": "0.68.0", + "name": "PraxisProposalProvenance", + "title": "Praxis Proposal Provenance", + "status": "active", + "date": "2026-05-23T19:13:21+02:00", + "publisher": "cognovis GmbH", + "description": "Vendor-neutral Provenance profile for clinical proposal lifecycles: software or LLM suggestions, clinician confirmations, links to existing clinical records, and direct manual entries.", + "fhirVersion": "4.0.1", + "kind": "resource", + "abstract": false, + "type": "Provenance", + "baseDefinition": "http://hl7.org/fhir/StructureDefinition/Provenance", + "derivation": "constraint", + "differential": { + "element": [ + { + "id": "Provenance", + "path": "Provenance", + "constraint": [ + { + "key": "provenance-source-required-for-derived", + "severity": "error", + "human": "If agent.role contains software-suggested, llm-suggested, or linked-existing, then entity must be present.", + "expression": "agent.role.coding.where(system = 'https://fhir.cognovis.de/praxis/CodeSystem/proposal-contribution-role' and (code = 'software-suggested' or code = 'llm-suggested' or code = 'linked-existing')).exists() implies entity.exists()", + "source": "https://fhir.cognovis.de/praxis/StructureDefinition/praxis-proposal-provenance" + } + ] + }, + { + "id": "Provenance.activity", + "path": "Provenance.activity", + "short": "FHIR DataOperation for this proposal provenance event", + "definition": "Use CREATE for proposal creation, UPDATE for confirmation or structured-resource update events, and other FHIR DataOperation codes where appropriate.", + "min": 1 + }, + { + "id": "Provenance.agent.type", + "path": "Provenance.agent.type", + "short": "Standard FHIR provenance participant type", + "min": 1 + }, + { + "id": "Provenance.agent.role", + "path": "Provenance.agent.role", + "short": "Proposal contribution role", + "definition": "Classifies the participant's contribution to the proposal lifecycle, such as LLM suggestion, deterministic software suggestion, clinician confirmation, existing-record linkage, or manual entry.", + "min": 1, + "binding": { + "strength": "required", + "valueSet": "https://fhir.cognovis.de/praxis/ValueSet/proposal-contribution-role-vs" + } + }, + { + "id": "Provenance.entity", + "path": "Provenance.entity", + "short": "Source or revision input for derived proposal events", + "definition": "Source entities are optional for direct manual entry, but required by invariant for software-suggested, LLM-suggested, and linked-existing events." + } + ] + } +} \ No newline at end of file diff --git a/test/unit/typeschema/utils.test.ts b/test/unit/typeschema/utils.test.ts index 8d44ff1a..966f1e88 100644 --- a/test/unit/typeschema/utils.test.ts +++ b/test/unit/typeschema/utils.test.ts @@ -604,4 +604,141 @@ describe("TypeSchema Index", () => { expect(result.identifier).toEqual(constraintSchema.identifier); }); }); + + describe("buildProfileSnapshot — inherited required fields", () => { + // Provenance-like base resource with three top-level required fields, + // mirroring the FHIR R4 Provenance.target/.recorded/.agent constraint. + const provenanceLike = (): SpecializationTypeSchema => ({ + identifier: { + name: "Provenance" as Name, + package: "test", + kind: "resource", + version: "1.0.0", + url: "http://example.org/StructureDefinition/Provenance" as CanonicalUrl, + }, + fields: { + target: { type: stringType, required: true, array: true }, + recorded: { type: stringType, required: true, array: false }, + agent: { type: stringType, required: true, array: true }, + activity: { type: stringType, required: false, array: false }, + }, + }); + + it("AC-2: lists base-required fields that the profile never re-states", () => { + const base = provenanceLike(); + const profile: ProfileTypeSchema = { + identifier: { + name: "ProvenanceProfile" as Name, + package: "test", + kind: "profile", + version: "1.0.0", + url: "http://example.org/StructureDefinition/ProvenanceProfile" as CanonicalUrl, + }, + base: base.identifier, + fields: { + activity: { type: stringType, required: true, array: false, min: 1 }, + agent: { type: stringType, required: true, array: true, min: 1 }, + }, + }; + + const index = mkTypeSchemaIndex([base, profile], {}); + const snap = index.collectSnapshotProfiles().find((s) => s.identifier.name === "ProvenanceProfile"); + if (!snap) throw new Error("snapshot not built"); + + // target + recorded are inherited (profile didn't restate them) + expect(snap.inheritedRequiredFields).toEqual(["target", "recorded"]); + // agent is restated → already in fields, NOT in the inherited list + expect(snap.fields.agent).toBeDefined(); + expect(snap.inheritedRequiredFields).not.toContain("agent"); + }); + + it("AC-3: omits a base-required field when the profile relaxes it via min:0", () => { + const base = provenanceLike(); + const profile: ProfileTypeSchema = { + identifier: { + name: "RelaxedProvenance" as Name, + package: "test", + kind: "profile", + version: "1.0.0", + url: "http://example.org/StructureDefinition/RelaxedProvenance" as CanonicalUrl, + }, + base: base.identifier, + fields: { + // Profile explicitly relaxes target to optional; field-builder + // would have walked genealogy and set required:true here, but + // flatProfile should override that based on the explicit min:0. + target: { type: stringType, required: true, array: true, min: 0 }, + }, + }; + + const index = mkTypeSchemaIndex([base, profile], {}); + const snap = index.collectSnapshotProfiles().find((s) => s.identifier.name === "RelaxedProvenance"); + if (!snap) throw new Error("snapshot not built"); + + // target was relaxed → it lives in fields with required:false, NOT in inheritedRequiredFields + const target = snap.fields.target as RegularField; + expect(target).toBeDefined(); + expect(target.required).toBe(false); + expect(snap.inheritedRequiredFields).not.toContain("target"); + // recorded + agent remain inherited (profile didn't restate them) + expect(snap.inheritedRequiredFields).toEqual(["recorded", "agent"]); + }); + + it("returns undefined when the profile restates (or relaxes) every base-required field", () => { + const base = provenanceLike(); + const profile: ProfileTypeSchema = { + identifier: { + name: "FullyStatedProvenance" as Name, + package: "test", + kind: "profile", + version: "1.0.0", + url: "http://example.org/StructureDefinition/FullyStatedProvenance" as CanonicalUrl, + }, + base: base.identifier, + fields: { + target: { type: stringType, required: true, array: true, min: 1 }, + recorded: { type: stringType, required: true, array: false, min: 1 }, + agent: { type: stringType, required: true, array: true, min: 1 }, + }, + }; + + const index = mkTypeSchemaIndex([base, profile], {}); + const snap = index.collectSnapshotProfiles().find((s) => s.identifier.name === "FullyStatedProvenance"); + if (!snap) throw new Error("snapshot not built"); + + expect(snap.inheritedRequiredFields).toBeUndefined(); + }); + + it("AC-2b: covers snapshot-inlined base fields that lack a restated required flag", () => { + // FHIRSchema snapshot expansion can copy inherited elements into a + // profile's `elements` without re-deriving the `required` array + // (because the differential doesn't restate them). The + // field-builder then emits the field with required:undefined/false + // even though the base requires it. validate() would silently + // skip the field unless inheritedRequiredFields catches it. + const base = provenanceLike(); + const profile: ProfileTypeSchema = { + identifier: { + name: "InlinedProvenance" as Name, + package: "test", + kind: "profile", + version: "1.0.0", + url: "http://example.org/StructureDefinition/InlinedProvenance" as CanonicalUrl, + }, + base: base.identifier, + fields: { + activity: { type: stringType, required: true, array: false, min: 1 }, + // Snapshot-inlined: present but neither restated nor relaxed + target: { type: stringType, required: false, array: true }, + recorded: { type: stringType, array: false }, + }, + }; + + const index = mkTypeSchemaIndex([base, profile], {}); + const snap = index.collectSnapshotProfiles().find((s) => s.identifier.name === "InlinedProvenance"); + if (!snap) throw new Error("snapshot not built"); + + expect(snap.inheritedRequiredFields).toEqual(["target", "recorded", "agent"]); + }); + }); });