@fhiron/sdk 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1196 @@
1
+ // Fhiron linter — reglas locales contra CL Core v1.9.4.
2
+ //
3
+ // Diseño: módulo plano en CommonJS, sin dependencias. Corre en tres lugares:
4
+ // - Web Worker del dashboard (import nativo vía ES module interop de turbopack)
5
+ // - MCP connector (require directo)
6
+ // - Servidor (/api/validate enriquece la respuesta HAPI con estas reglas)
7
+ //
8
+ // División de trabajo con HAPI:
9
+ // - Local: "ortografía" — formato de RUN, presencia de campos obligatorios,
10
+ // system de identifier correcto, puntos en RUT, etc. Barato, instantáneo.
11
+ // - HAPI: "gramática profunda" — slicing, discriminators, binding a
12
+ // ValueSets, expresiones FHIRPath. Caro, una ronda al servidor.
13
+ //
14
+ // Cada regla devuelve { code, severity, path, message, quickFix? }. Los
15
+ // códigos siguen el patrón cl-<recurso>-NN para que la IA los reconozca sin
16
+ // tener que parsear prosa en español.
17
+
18
+ "use strict";
19
+
20
+ const { isKnownResourceType } = require("./resources.js");
21
+ const { lookupCode, listCodes } = require("./catalog.js");
22
+
23
+ // ── Enriquecimiento pedagógico ───────────────────────────────────────────────
24
+ //
25
+ // Cada issue emitido por las reglas internas trae al menos {code, severity,
26
+ // path, message}. enrichIssue() consulta el catálogo y agrega los campos
27
+ // pedagógicos opcionales: why, profileUrl, docsUrl, suggestion, example.
28
+ //
29
+ // Si el código no está catalogado, devuelve el issue intacto — esto preserva
30
+ // la retrocompatibilidad para integraciones que añaden códigos custom.
31
+
32
+ function enrichIssue(issue) {
33
+ if (!issue || !issue.code) return issue;
34
+ const meta = lookupCode(issue.code);
35
+ if (!meta) return issue;
36
+ // No sobreescribir campos ya presentes (las reglas pueden customizar).
37
+ if (meta.why && !issue.why) issue.why = meta.why;
38
+ if (meta.profileUrl && !issue.profileUrl) issue.profileUrl = meta.profileUrl;
39
+ if (meta.docsUrl && !issue.docsUrl) issue.docsUrl = meta.docsUrl;
40
+ if (meta.suggestion && !issue.suggestion) issue.suggestion = meta.suggestion;
41
+ if (meta.example !== undefined && issue.example === undefined) issue.example = meta.example;
42
+ return issue;
43
+ }
44
+
45
+ // ── Validación de RUN (módulo 11) ────────────────────────────────────────────
46
+ //
47
+ // Cómo se identifica el slot RUN en un Patient.identifier según CL Core 1.9.4:
48
+ //
49
+ // 1. identifier.system = "http://www.registrocivil.cl/run"
50
+ // (NamingSystem oficial del Registro Civil — uso primario en CL Core)
51
+ // 2. identifier.type.coding[].system = CSTipoIdentificador
52
+ // identifier.type.coding[].code = "01" (RUN)
53
+ // (Tipificación canónica del IG)
54
+ // 3. URLs legacy aún en uso por algunas integraciones:
55
+ // - "https://hl7chile.cl/identificador/run" (CL Core <1.7)
56
+ // - "https://hl7chile.cl/fhir/ig/clcore/CodeSystem/CSIdentificadores"
57
+ // (URL inventada por Fhiron en sesión 3-May-2026, ya purgada — se
58
+ // deja en el detector solo como fallback para clientes que aún la
59
+ // emiten antes de actualizarse)
60
+ //
61
+ // Cualquiera de estas tres formas cuenta como "este identifier es un RUN".
62
+
63
+ const RUN_SYSTEM_REGCIVIL = "http://www.registrocivil.cl/run";
64
+ const RUN_SYSTEM_CL_CORE_TIPO = "https://hl7chile.cl/fhir/ig/clcore/CodeSystem/CSTipoIdentificador";
65
+ const RUN_SYSTEM_LEGACY_PRE17 = "https://hl7chile.cl/identificador/run";
66
+ const RUN_SYSTEM_LEGACY_FHIRON_INVENT = "https://hl7chile.cl/fhir/ig/clcore/CodeSystem/CSIdentificadores";
67
+ const RUN_SYSTEMS = [
68
+ RUN_SYSTEM_REGCIVIL,
69
+ RUN_SYSTEM_LEGACY_PRE17,
70
+ RUN_SYSTEM_LEGACY_FHIRON_INVENT,
71
+ ];
72
+ const RUN_TYPE_CODE = "01"; // CSTipoIdentificador#01
73
+
74
+ function stripRunDots(value) {
75
+ return typeof value === "string" ? value.replace(/\./g, "") : value;
76
+ }
77
+
78
+ /** Reconoce el system del identifier directamente. */
79
+ function isRunSystem(system) {
80
+ if (!system) return false;
81
+ return RUN_SYSTEMS.some((s) => system === s || system === s.replace("https:", "http:") || system === s.replace("http:", "https:"));
82
+ }
83
+
84
+ /**
85
+ * Reconoce un identifier como RUN considerando cualquiera de las formas
86
+ * canónicas: system del identifier, type.coding con CSTipoIdentificador#01,
87
+ * o URLs legacy aún en uso.
88
+ */
89
+ function isRunIdentifier(id) {
90
+ if (!id || typeof id !== "object") return false;
91
+ if (isRunSystem(id.system)) return true;
92
+ // type.coding con CSTipoIdentificador + code "01"
93
+ const codings = (id.type && Array.isArray(id.type.coding)) ? id.type.coding : [];
94
+ for (const c of codings) {
95
+ if (!c) continue;
96
+ const sys = c.system || "";
97
+ const code = c.code;
98
+ if (sys === RUN_SYSTEM_CL_CORE_TIPO && (code === RUN_TYPE_CODE || code === "RUN")) return true;
99
+ // legacy: code === "RUN" sin system específico
100
+ if (code === "RUN") return true;
101
+ }
102
+ return false;
103
+ }
104
+
105
+ function hasRunDots(value) {
106
+ return typeof value === "string" && value.includes(".");
107
+ }
108
+
109
+ // ── Mensajes de error de JSON en español ─────────────────────────────────────
110
+
111
+ const V8_ERROR_TRANSLATIONS = [
112
+ [/Expected property name or '}' in JSON at position (\d+)/i,
113
+ (m) => `Se esperaba un nombre de propiedad o '}' en la posición ${m[1]}`],
114
+ [/Unexpected token '(.+)', "(.+)" is not valid JSON/i,
115
+ (m) => `Token inesperado '${m[1]}' — el texto "${m[2]}" no es JSON válido`],
116
+ [/Unexpected end of JSON input/i,
117
+ () => "El JSON está incompleto (fin inesperado)"],
118
+ [/Unexpected token (\S+) in JSON at position (\d+)/i,
119
+ (m) => `Token inesperado ${m[1]} en la posición ${m[2]}`],
120
+ [/Expected ',' or '\}' after property value in JSON at position (\d+)/i,
121
+ (m) => `Se esperaba ',' o '}' tras el valor de la propiedad en la posición ${m[1]}`],
122
+ [/circular structure/i,
123
+ () => "El objeto tiene referencias circulares y no puede serializarse como JSON"],
124
+ ];
125
+
126
+ function translateJsonParseError(err) {
127
+ const msg = err && err.message ? err.message : "no se pudo parsear";
128
+ for (const [pattern, builder] of V8_ERROR_TRANSLATIONS) {
129
+ const m = msg.match(pattern);
130
+ if (m) return builder(m);
131
+ }
132
+ return msg;
133
+ }
134
+
135
+ // ── Clasificación de formato de RUN ─────────────────────────────────────────
136
+ // Devuelve { valid, message, cleaned } antes del cálculo del DV.
137
+ // Permite emitir mensajes específicos según la causa del formato inválido.
138
+
139
+ function classifyRunFormat(raw) {
140
+ if (typeof raw !== "string" || !raw.trim()) {
141
+ return { valid: false, message: "El RUN está vacío.", cleaned: null };
142
+ }
143
+ const noSpaces = raw.replace(/\s/g, "").toUpperCase();
144
+ const noDots = noSpaces.replace(/\./g, "");
145
+
146
+ if (!noDots.includes("-")) {
147
+ return {
148
+ valid: false,
149
+ message: "Falta el guión entre el cuerpo y el dígito verificador. Formato esperado: 12345678-5.",
150
+ cleaned: null,
151
+ };
152
+ }
153
+
154
+ const dashIdx = noDots.lastIndexOf("-");
155
+ const body = noDots.slice(0, dashIdx);
156
+ const dv = noDots.slice(dashIdx + 1);
157
+
158
+ if (!/^\d+$/.test(body)) {
159
+ return {
160
+ valid: false,
161
+ message: "El cuerpo del RUN solo admite dígitos 0-9. Formato esperado: 12345678-5.",
162
+ cleaned: null,
163
+ };
164
+ }
165
+ if (body.length > 8) {
166
+ return {
167
+ valid: false,
168
+ message: `El cuerpo del RUN excede los 8 dígitos permitidos por CL Core CorePacienteCl (recibido: ${body.length} dígitos).`,
169
+ cleaned: null,
170
+ };
171
+ }
172
+ if (!/^[\dK]$/.test(dv)) {
173
+ return {
174
+ valid: false,
175
+ message: "El dígito verificador debe ser un número del 0-9 o la letra K. CL Core CorePacienteCl.",
176
+ cleaned: null,
177
+ };
178
+ }
179
+
180
+ return { valid: true, message: null, cleaned: `${body}-${dv}` };
181
+ }
182
+
183
+ function validateRunDv(raw) {
184
+ const fmt = classifyRunFormat(raw);
185
+ if (!fmt.valid) return { valid: false, message: fmt.message };
186
+
187
+ const [body, dv] = fmt.cleaned.split("-");
188
+ let sum = 0;
189
+ let mul = 2;
190
+ for (let i = body.length - 1; i >= 0; i--) {
191
+ sum += parseInt(body[i], 10) * mul;
192
+ mul = mul === 7 ? 2 : mul + 1;
193
+ }
194
+ const rest = 11 - (sum % 11);
195
+ const expected = rest === 11 ? "0" : rest === 10 ? "K" : String(rest);
196
+
197
+ if (expected !== dv) {
198
+ return { valid: false, expected, given: dv, cleaned: fmt.cleaned };
199
+ }
200
+ return { valid: true, expected, given: dv, normalized: fmt.cleaned };
201
+ }
202
+
203
+ // ── Rule runner ──────────────────────────────────────────────────────────────
204
+
205
+ /**
206
+ * Inspecciona el recurso FHIR y retorna un arreglo de issues.
207
+ * El input puede ser un objeto ya parseado o un string JSON — si es string
208
+ * que no parsea, devolvemos un único issue cl-json-01.
209
+ *
210
+ * @param {object|string} input
211
+ * @param {{enrich?:boolean}} [options] — si enrich:false, se omiten los campos
212
+ * pedagógicos (why, profileUrl, docsUrl, suggestion, example). Default true.
213
+ * @returns {Array<{code:string, severity:'error'|'warning'|'info', path:string, message:string, quickFix?:{title:string, jsonPointer:string, replacement:any}, why?:string, profileUrl?:string, docsUrl?:string, suggestion?:string, example?:any}>}
214
+ */
215
+ function lintResource(input, options) {
216
+ const enrich = !options || options.enrich !== false;
217
+ const issues = [];
218
+
219
+ let resource = input;
220
+ if (typeof input === "string") {
221
+ try {
222
+ resource = JSON.parse(input);
223
+ } catch (err) {
224
+ issues.push({
225
+ code: "cl-json-01",
226
+ severity: "error",
227
+ path: "",
228
+ message: `JSON inválido: ${translateJsonParseError(err)}.`,
229
+ });
230
+ return enrich ? issues.map(enrichIssue) : issues;
231
+ }
232
+ }
233
+ if (!resource || typeof resource !== "object" || Array.isArray(resource)) {
234
+ issues.push({
235
+ code: "cl-json-02",
236
+ severity: "error",
237
+ path: "",
238
+ message: "El cuerpo debe ser un objeto FHIR con la propiedad resourceType.",
239
+ });
240
+ return enrich ? issues.map(enrichIssue) : issues;
241
+ }
242
+
243
+ const resourceType = resource.resourceType;
244
+ if (!resourceType || typeof resourceType !== "string") {
245
+ issues.push({
246
+ code: "cl-json-03",
247
+ severity: "error",
248
+ path: "resourceType",
249
+ message: "Falta resourceType — todo recurso FHIR debe declararlo (por ejemplo Patient).",
250
+ });
251
+ return enrich ? issues.map(enrichIssue) : issues;
252
+ }
253
+
254
+ // cl-json-04: resourceType no reconocido en FHIR R4.
255
+ if (!isKnownResourceType(resourceType)) {
256
+ issues.push({
257
+ code: "cl-json-04",
258
+ severity: "warning",
259
+ path: "resourceType",
260
+ message: `resourceType '${resourceType}' no es un tipo oficial de FHIR R4. El servidor lo rechazará.`,
261
+ });
262
+ }
263
+
264
+ // ── Identifier: RUN chileno — reglas cl-run-NN ────────────────────────────
265
+ const identifiers = Array.isArray(resource.identifier) ? resource.identifier : [];
266
+ identifiers.forEach((id, i) => {
267
+ const systemTop = id && id.system;
268
+ const pointsToRun = isRunIdentifier(id);
269
+ if (!pointsToRun) return;
270
+
271
+ const valuePath = `${resourceType}.identifier[${i}].value`;
272
+
273
+ if (!id.value || typeof id.value !== "string") {
274
+ issues.push({
275
+ code: "cl-run-02",
276
+ severity: "error",
277
+ path: valuePath,
278
+ message: "El identifier referencia el sistema RUN pero no trae value. CL Core CorePacienteCl v1.9.4.",
279
+ });
280
+ return;
281
+ }
282
+
283
+ if (hasRunDots(id.value)) {
284
+ issues.push({
285
+ code: "cl-run-01",
286
+ severity: "error",
287
+ path: valuePath,
288
+ message: "El RUN no debe incluir puntos. Formato esperado: 12345678-5 (CL Core CorePacienteCl · v1.9.4).",
289
+ quickFix: {
290
+ title: "Quitar puntos automáticamente",
291
+ jsonPointer: `/identifier/${i}/value`,
292
+ replacement: stripRunDots(id.value),
293
+ },
294
+ });
295
+ }
296
+
297
+ const dv = validateRunDv(id.value);
298
+ if (!dv.valid) {
299
+ issues.push({
300
+ code: "cl-run-03",
301
+ severity: "error",
302
+ path: valuePath,
303
+ message: dv.expected
304
+ ? `Dígito verificador incorrecto: esperado ${dv.expected}, recibido ${dv.given}. CL Core CorePacienteCl.`
305
+ : dv.message || "Formato de RUN inválido — usa 12345678-5. CL Core CorePacienteCl v1.9.4.",
306
+ });
307
+ }
308
+
309
+ // Preferir formato canónico: identifier.system = http://www.registrocivil.cl/run
310
+ // + identifier.type.coding[0] = CSTipoIdentificador#01
311
+ if (systemTop === RUN_SYSTEM_LEGACY_PRE17) {
312
+ issues.push({
313
+ code: "cl-run-04",
314
+ severity: "warning",
315
+ path: `${resourceType}.identifier[${i}].system`,
316
+ message:
317
+ "El system legacy https://hl7chile.cl/identificador/run está obsoleto en CL Core 1.7+. Usa identifier.system='http://www.registrocivil.cl/run' + type.coding[0] CSTipoIdentificador con code='01' (RUN).",
318
+ quickFix: {
319
+ title: "Actualizar al system canónico del Registro Civil",
320
+ jsonPointer: `/identifier/${i}/system`,
321
+ replacement: RUN_SYSTEM_REGCIVIL,
322
+ },
323
+ });
324
+ }
325
+ if (systemTop === RUN_SYSTEM_LEGACY_FHIRON_INVENT) {
326
+ issues.push({
327
+ code: "cl-run-04",
328
+ severity: "warning",
329
+ path: `${resourceType}.identifier[${i}].system`,
330
+ message:
331
+ "El system 'CSIdentificadores' fue una invención de Fhiron y NO existe en CL Core 1.9.4 oficial. Usa identifier.system='http://www.registrocivil.cl/run' + type.coding[0] CSTipoIdentificador con code='01' (RUN).",
332
+ quickFix: {
333
+ title: "Actualizar al system canónico del Registro Civil",
334
+ jsonPointer: `/identifier/${i}/system`,
335
+ replacement: RUN_SYSTEM_REGCIVIL,
336
+ },
337
+ });
338
+ }
339
+ });
340
+
341
+ // ── Dispatch por resourceType ────────────────────────────────────────────
342
+ const LINTERS = {
343
+ Patient: lintPatient,
344
+ Practitioner: lintPractitioner,
345
+ Observation: lintObservation,
346
+ Medication: lintMedication,
347
+ MedicationRequest: lintMedicationRequest,
348
+ Encounter: lintEncounter,
349
+ Condition: lintCondition,
350
+ AllergyIntolerance: lintAllergyIntolerance,
351
+ Procedure: lintProcedure,
352
+ Coverage: lintCoverage,
353
+ Organization: lintOrganization,
354
+ Immunization: lintImmunization,
355
+ DiagnosticReport: lintDiagnosticReport,
356
+ Bundle: lintBundle,
357
+ };
358
+
359
+ const lintFn = LINTERS[resourceType];
360
+ if (lintFn) lintFn(resource, issues);
361
+
362
+ // Linter genérico FHIR — corre en TODOS los recursos. Reglas que no
363
+ // dependen del resourceType: meta.profile bien formado, fechas ISO,
364
+ // references válidas. Esto da valor incluso cuando el motor server-side
365
+ // está caído.
366
+ lintGeneric(resource, resourceType, issues);
367
+
368
+ // Linter EIS — reconoce uso de terminología MINSAL y valida formato.
369
+ // Aditivo: no rompe ni modifica nada que ya validaba CL Core.
370
+ lintEisTerminology(resource, resourceType, issues);
371
+
372
+ return enrich ? issues.map(enrichIssue) : issues;
373
+ }
374
+
375
+ // ── MINSAL EIS — reconocimiento de terminología paralela a CL Core ───────────
376
+ //
377
+ // EIS (https://interoperabilidad.minsal.cl/fhir/ig/eis/) es un IG paralelo
378
+ // que publica 49 CodeSystems oficiales del MINSAL bajo un namespace distinto
379
+ // al de CL Core. Sin esta tabla, el linter marcaría como "system desconocido"
380
+ // algo que en realidad es estándar oficial publicado.
381
+ const EIS_CS_PREFIX = "https://interoperabilidad.minsal.cl/fhir/ig/eis/CodeSystem/";
382
+ const EIS_KNOWN_CODESYSTEMS = new Set([
383
+ "CSAnamnesis", "CSAreaUrbanoCensal", "CSComunas", "CSCondiciondelaActividad",
384
+ "CSConsultaMedExclu", "CSConsultaMedIncluyente", "CSConsultaMedRendida",
385
+ "CSDiagnostico", "CSEspecialidadBioquimica", "CSEspecialidadFarma",
386
+ "CSEspecialidadMedica", "CSEspecialidadOdontologica", "CSEstadoCivil",
387
+ "CSIdentidadGenero", "CSInstitucionEmiteEspecialidad", "CSInstitucionEmiteTitulo",
388
+ "CSLeyPrevisionales", "CSModalidadAtencionFonasa", "CSNivelAtencion",
389
+ "CSNivelComplejidad", "CSNivelInstruccion", "CSOcupaciones",
390
+ "CSOcupacionesDetalladas", "CSOtroTipoEstabAsistenciales", "CSPaises",
391
+ "CSPrevision", "CSProvincia", "CSPueblosOriginarios", "CSRegion",
392
+ "CSReligion",
393
+ // Hay 49 totales — los menos comunes se aceptan via heurística de prefix.
394
+ ]);
395
+
396
+ function lintEisTerminology(r, resourceType, issues) {
397
+ if (!r || typeof r !== "object") return;
398
+ walkForCodings(r, "", (coding, path) => {
399
+ const sys = coding && coding.system;
400
+ if (typeof sys !== "string" || !sys.startsWith(EIS_CS_PREFIX)) return;
401
+
402
+ const csName = sys.slice(EIS_CS_PREFIX.length);
403
+
404
+ // cl-eis-system-01 — system bajo namespace EIS pero el CodeSystem no está
405
+ // en el catálogo conocido. Heurística simple: nombre debe ser PascalCase
406
+ // tipo "CSXxxxxx". Marca warning para typos sin alarmar a quien usa los
407
+ // 19 CodeSystems menos comunes.
408
+ const looksLikeCS = /^CS[A-Z][A-Za-z0-9]+$/.test(csName);
409
+ if (!EIS_KNOWN_CODESYSTEMS.has(csName) && !looksLikeCS) {
410
+ issues.push({
411
+ code: "cl-eis-system-01",
412
+ severity: "warning",
413
+ path,
414
+ message: `El system '${sys}' parece de MINSAL EIS pero el CodeSystem '${csName}' no es uno de los 49 publicados oficialmente. Verifica el nombre.`,
415
+ });
416
+ return;
417
+ }
418
+
419
+ // Reglas pedagógicas por CodeSystem específico (info-level).
420
+ if (csName === "CSComunas") {
421
+ issues.push({
422
+ code: "cl-eis-comuna-01",
423
+ severity: "information",
424
+ path,
425
+ message:
426
+ "Usando CSComunas oficial MINSAL (347 comunas DEIS). Equivalente válido al CSCodComunasCL de CL Core.",
427
+ });
428
+ } else if (csName === "CSPrevision") {
429
+ issues.push({
430
+ code: "cl-eis-prevision-01",
431
+ severity: "information",
432
+ path,
433
+ message:
434
+ "Usando CSPrevision oficial MINSAL — única fuente FHIR para Fonasa/ISAPRE/PRAIS publicada en Chile. Sin equivalente en CL Core.",
435
+ });
436
+ }
437
+ });
438
+ }
439
+
440
+ // Recorre el recurso buscando coding[] anidados (ej. code.coding,
441
+ // vaccineCode.coding, address.city.coding) y llama cb(coding, fullPath).
442
+ function walkForCodings(node, path, cb) {
443
+ if (!node || typeof node !== "object") return;
444
+ if (Array.isArray(node)) {
445
+ for (let i = 0; i < node.length; i++) walkForCodings(node[i], `${path}[${i}]`, cb);
446
+ return;
447
+ }
448
+ if (Array.isArray(node.coding)) {
449
+ for (let i = 0; i < node.coding.length; i++) {
450
+ cb(node.coding[i], `${path}.coding[${i}]`);
451
+ }
452
+ }
453
+ for (const key of Object.keys(node)) {
454
+ if (key === "coding") continue;
455
+ const child = node[key];
456
+ if (child && typeof child === "object") {
457
+ walkForCodings(child, path ? `${path}.${key}` : key, cb);
458
+ }
459
+ }
460
+ }
461
+
462
+ /**
463
+ * Reglas FHIR generales que aplican a todos los recursos.
464
+ */
465
+ function lintGeneric(r, resourceType, issues) {
466
+ // meta.profile: array de URIs canónicos
467
+ if (r.meta && Array.isArray(r.meta.profile)) {
468
+ r.meta.profile.forEach((url, i) => {
469
+ if (typeof url !== "string" || !/^https?:\/\//.test(url)) {
470
+ issues.push({
471
+ code: "cl-meta-01",
472
+ severity: "error",
473
+ path: `${resourceType}.meta.profile[${i}]`,
474
+ message: `meta.profile[${i}] debería ser una URL canónica HTTPS de un StructureDefinition (ej: https://hl7chile.cl/fhir/ig/clcore/StructureDefinition/CorePacienteCl). Valor recibido: '${url}'.`,
475
+ });
476
+ }
477
+ });
478
+ }
479
+
480
+ // ── text.div narrativa ────────────────────────────────────────────────────
481
+ // FHIR R4 dom-6 recomienda incluir text.div en cada DomainResource. HAPI
482
+ // emite la advertencia "A resource should have narrative for robust
483
+ // management" cuando falta. El linter local replica la regla con
484
+ // cl-narrative-02 + quickFix (inserta text.status='generated' + div mínimo).
485
+ // Excluye Bundle (no es DomainResource — su narrativa va en cada entry).
486
+ const TEXT_EXCLUDED_TYPES = new Set([
487
+ "Bundle", "Parameters", "Binary", "OperationOutcome",
488
+ ]);
489
+ if (!TEXT_EXCLUDED_TYPES.has(resourceType)) {
490
+ const hasNarrative = r.text && typeof r.text === "object" && r.text.status && typeof r.text.div === "string" && r.text.div.length > 0;
491
+ if (!hasNarrative) {
492
+ issues.push({
493
+ code: "cl-narrative-02",
494
+ severity: "warning",
495
+ path: `${resourceType}.text`,
496
+ message: `${resourceType}.text.div recomendado por FHIR R4 (dom-6): incluye una narrativa HTML legible del recurso. Mejora la lectura humana cuando un sistema no entiende el JSON estructurado.`,
497
+ quickFix: {
498
+ title: "Agregar narrativa text.div mínima",
499
+ jsonPointer: `/text`,
500
+ replacement: {
501
+ status: "generated",
502
+ div: `<div xmlns="http://www.w3.org/1999/xhtml">Recurso ${resourceType} generado por Fhiron.</div>`,
503
+ },
504
+ },
505
+ });
506
+ }
507
+ }
508
+
509
+ // text.div — si está, debería tener un status válido
510
+ if (r.text && typeof r.text === "object") {
511
+ const VALID_NARRATIVE = ["generated", "extensions", "additional", "empty"];
512
+ if (r.text.status && !VALID_NARRATIVE.includes(r.text.status)) {
513
+ issues.push({
514
+ code: "cl-narrative-01",
515
+ severity: "error",
516
+ path: `${resourceType}.text.status`,
517
+ message: `${resourceType}.text.status='${r.text.status}' no es válido. Valores permitidos: ${VALID_NARRATIVE.join(", ")}. FHIR R4 NarrativeStatus.`,
518
+ quickFix: {
519
+ title: "Reemplazar por 'generated' (status canónico)",
520
+ jsonPointer: `/text/status`,
521
+ replacement: "generated",
522
+ },
523
+ });
524
+ }
525
+ }
526
+
527
+ // Identifier system — los identifier deberían tener system URI absoluto
528
+ if (Array.isArray(r.identifier)) {
529
+ r.identifier.forEach((id, i) => {
530
+ if (id && id.system && typeof id.system === "string") {
531
+ if (!/^https?:\/\/|^urn:/.test(id.system)) {
532
+ issues.push({
533
+ code: "cl-system-02",
534
+ severity: "warning",
535
+ path: `${resourceType}.identifier[${i}].system`,
536
+ message: `identifier.system debería ser una URI absoluta (HTTPS o urn:). Valor recibido: '${id.system}'. FHIR R4 Identifier.`,
537
+ });
538
+ }
539
+ }
540
+ });
541
+ }
542
+ }
543
+
544
+ // ── Linters por recurso ──────────────────────────────────────────────────────
545
+
546
+ function lintPatient(r, issues) {
547
+ if (!Array.isArray(r.identifier) || r.identifier.length === 0) {
548
+ issues.push({
549
+ code: "cl-patient-01",
550
+ severity: "error",
551
+ path: "Patient.identifier",
552
+ message:
553
+ "Patient debe incluir al menos un identifier (usualmente el RUN con system http://www.registrocivil.cl/run + type.coding CSTipoIdentificador#01). CL Core CorePacienteCl.",
554
+ });
555
+ }
556
+ if (!Array.isArray(r.name) || r.name.length === 0) {
557
+ issues.push({
558
+ code: "cl-patient-02",
559
+ severity: "error",
560
+ path: "Patient.name",
561
+ message:
562
+ "Patient.name es obligatorio (mínimo family + given). CL Core CorePacienteCl.",
563
+ });
564
+ } else {
565
+ r.name.forEach((n, i) => {
566
+ if (!n || (!n.family && !(Array.isArray(n.given) && n.given.length))) {
567
+ issues.push({
568
+ code: "cl-patient-03",
569
+ severity: "error",
570
+ path: `Patient.name[${i}]`,
571
+ message: "Cada name debe tener family y/o given — CL Core CorePacienteCl.",
572
+ });
573
+ }
574
+ });
575
+ }
576
+ if (!r.gender) {
577
+ issues.push({
578
+ code: "cl-patient-04",
579
+ severity: "warning",
580
+ path: "Patient.gender",
581
+ message:
582
+ "Patient.gender es recomendado por CL Core CorePacienteCl (male · female · other · unknown).",
583
+ quickFix: {
584
+ title: "Agregar gender='unknown' (valor neutro)",
585
+ jsonPointer: "/gender",
586
+ replacement: "unknown",
587
+ },
588
+ });
589
+ }
590
+ }
591
+
592
+ function lintPractitioner(r, issues) {
593
+ if (!Array.isArray(r.identifier) || r.identifier.length === 0) {
594
+ issues.push({
595
+ code: "cl-practitioner-01",
596
+ severity: "error",
597
+ path: "Practitioner.identifier",
598
+ message:
599
+ "Practitioner debe incluir un identifier — usualmente RUN o código DEIS. CL Core CorePrestadorCl.",
600
+ });
601
+ }
602
+ if (!Array.isArray(r.name) || r.name.length === 0) {
603
+ issues.push({
604
+ code: "cl-practitioner-02",
605
+ severity: "error",
606
+ path: "Practitioner.name",
607
+ message: "Practitioner.name es obligatorio. CL Core CorePrestadorCl.",
608
+ });
609
+ }
610
+ }
611
+
612
+ function lintObservation(r, issues) {
613
+ if (!r.status) {
614
+ issues.push({
615
+ code: "cl-obs-01",
616
+ severity: "error",
617
+ path: "Observation.status",
618
+ message:
619
+ "Observation.status es obligatorio (registered · preliminary · final · amended · corrected · cancelled · entered-in-error · unknown).",
620
+ });
621
+ }
622
+ if (!r.code) {
623
+ issues.push({
624
+ code: "cl-obs-02",
625
+ severity: "error",
626
+ path: "Observation.code",
627
+ message: "Observation.code es obligatorio (LOINC o similar).",
628
+ });
629
+ }
630
+ if (!r.subject) {
631
+ issues.push({
632
+ code: "cl-obs-03",
633
+ severity: "error",
634
+ path: "Observation.subject",
635
+ message: "Observation.subject es obligatorio (referencia al Patient).",
636
+ });
637
+ }
638
+ }
639
+
640
+ function lintMedication(r, issues) {
641
+ if (!r.code) {
642
+ issues.push({
643
+ code: "cl-med-01",
644
+ severity: "error",
645
+ path: "Medication.code",
646
+ message:
647
+ "Medication.code es obligatorio. Usa ATC (http://www.whocc.no/atc), SNOMED CT o RxNorm. CL Core CoreMedicamentoCl.",
648
+ });
649
+ } else {
650
+ const codings = Array.isArray(r.code.coding) ? r.code.coding : [];
651
+ const KNOWN_MED_SYSTEMS = [
652
+ "http://www.whocc.no/atc",
653
+ "http://snomed.info/sct",
654
+ "http://www.nlm.nih.gov/research/umls/rxnorm",
655
+ ];
656
+ const hasKnownSystem = codings.some(
657
+ (c) => c && KNOWN_MED_SYSTEMS.includes(c.system),
658
+ );
659
+ if (codings.length > 0 && !hasKnownSystem) {
660
+ issues.push({
661
+ code: "cl-med-02",
662
+ severity: "warning",
663
+ path: "Medication.code.coding",
664
+ message:
665
+ "Se recomienda usar ATC (http://www.whocc.no/atc), SNOMED CT o RxNorm para Medication.code.coding. CL Core CoreMedicamentoCl.",
666
+ });
667
+ }
668
+ }
669
+ }
670
+
671
+ function lintMedicationRequest(r, issues) {
672
+ if (!r.status) {
673
+ issues.push({
674
+ code: "cl-medreq-01",
675
+ severity: "error",
676
+ path: "MedicationRequest.status",
677
+ message:
678
+ "MedicationRequest.status es obligatorio (active · on-hold · cancelled · completed · entered-in-error · stopped · draft · unknown). CL Core.",
679
+ });
680
+ }
681
+ if (!r.intent) {
682
+ issues.push({
683
+ code: "cl-medreq-02",
684
+ severity: "error",
685
+ path: "MedicationRequest.intent",
686
+ message:
687
+ "MedicationRequest.intent es obligatorio (proposal · plan · order · original-order · reflex-order · filler-order · instance-order · option). CL Core.",
688
+ });
689
+ }
690
+ if (!r.subject) {
691
+ issues.push({
692
+ code: "cl-medreq-03",
693
+ severity: "error",
694
+ path: "MedicationRequest.subject",
695
+ message:
696
+ "MedicationRequest.subject es obligatorio (referencia al Patient). CL Core.",
697
+ });
698
+ }
699
+ const hasMed =
700
+ r.medicationCodeableConcept ||
701
+ r.medicationReference;
702
+ if (!hasMed) {
703
+ issues.push({
704
+ code: "cl-medreq-04",
705
+ severity: "error",
706
+ path: "MedicationRequest.medication[x]",
707
+ message:
708
+ "MedicationRequest debe incluir medicationCodeableConcept o medicationReference. CL Core CoreMedicamentoCl.",
709
+ });
710
+ }
711
+ }
712
+
713
+ function lintEncounter(r, issues) {
714
+ // status — campo obligatorio + value-set cerrado
715
+ const ENC_STATUS = [
716
+ "planned", "arrived", "triaged", "in-progress", "onleave",
717
+ "finished", "cancelled", "entered-in-error", "unknown",
718
+ ];
719
+ if (!r.status) {
720
+ issues.push({
721
+ code: "cl-enc-01",
722
+ severity: "error",
723
+ path: "Encounter.status",
724
+ message:
725
+ "Encounter.status es obligatorio. Valores permitidos: planned, arrived, triaged, in-progress, onleave, finished, cancelled, entered-in-error, unknown. CL Core EncounterCL.",
726
+ });
727
+ } else if (!ENC_STATUS.includes(r.status)) {
728
+ issues.push({
729
+ code: "cl-enc-04",
730
+ severity: "error",
731
+ path: "Encounter.status",
732
+ message: `Encounter.status='${r.status}' no es válido. Valor debe ser uno de: ${ENC_STATUS.join(", ")}. Especificación FHIR R4 EncounterStatus.`,
733
+ });
734
+ }
735
+
736
+ // class — CodeableConcept con system canónico v3-ActCode
737
+ if (!r.class) {
738
+ issues.push({
739
+ code: "cl-enc-02",
740
+ severity: "error",
741
+ path: "Encounter.class",
742
+ message:
743
+ "Encounter.class es obligatorio. Usa v3-ActCode (AMB · IMP · EMER, etc.) según el tipo de atención. CL Core EncounterCL.",
744
+ });
745
+ } else {
746
+ const cls = r.class;
747
+ if (typeof cls === "object" && cls !== null) {
748
+ if (cls.system && cls.system !== "http://terminology.hl7.org/CodeSystem/v3-ActCode") {
749
+ issues.push({
750
+ code: "cl-enc-05",
751
+ severity: "warning",
752
+ path: "Encounter.class.system",
753
+ message: `Encounter.class.system debería ser 'http://terminology.hl7.org/CodeSystem/v3-ActCode' (canónico). Valor recibido: '${cls.system}'.`,
754
+ quickFix: {
755
+ title: "Reemplazar por v3-ActCode canónico",
756
+ jsonPointer: "/class/system",
757
+ replacement: "http://terminology.hl7.org/CodeSystem/v3-ActCode",
758
+ },
759
+ });
760
+ }
761
+ const VALID_CLASS_CODES = ["AMB", "IMP", "EMER", "HH", "FLD", "VR", "ACUTE", "NONAC", "OBSENC", "PRENC", "SS"];
762
+ if (cls.code && !VALID_CLASS_CODES.includes(cls.code)) {
763
+ issues.push({
764
+ code: "cl-enc-06",
765
+ severity: "warning",
766
+ path: "Encounter.class.code",
767
+ message: `Encounter.class.code='${cls.code}' no es un valor común de v3-ActCode. Lista típica: ${VALID_CLASS_CODES.slice(0, 6).join(", ")}.`,
768
+ });
769
+ }
770
+ }
771
+ }
772
+
773
+ // subject — referencia obligatoria al Patient
774
+ if (!r.subject) {
775
+ issues.push({
776
+ code: "cl-enc-03",
777
+ severity: "error",
778
+ path: "Encounter.subject",
779
+ message:
780
+ "Encounter.subject es obligatorio (referencia al Patient). CL Core EncounterCL.",
781
+ });
782
+ } else if (typeof r.subject === "object" && r.subject.reference) {
783
+ if (!REFERENCE_RE.test(r.subject.reference)) {
784
+ issues.push({
785
+ code: "cl-ref-01",
786
+ severity: "error",
787
+ path: "Encounter.subject.reference",
788
+ message: `La referencia '${r.subject.reference}' no tiene formato FHIR válido. Esperado: '<ResourceType>/<id>' (ej: 'Patient/123'). FHIR R4.`,
789
+ });
790
+ } else if (!r.subject.reference.startsWith("Patient/")) {
791
+ issues.push({
792
+ code: "cl-enc-07",
793
+ severity: "warning",
794
+ path: "Encounter.subject.reference",
795
+ message: `Encounter.subject debería referenciar un Patient, no '${r.subject.reference.split("/")[0]}'. CL Core EncounterCL.`,
796
+ });
797
+ }
798
+ }
799
+
800
+ // period — si presente, validar fechas
801
+ if (r.period && typeof r.period === "object") {
802
+ if (r.period.start && !ISO_DATETIME_RE.test(r.period.start)) {
803
+ issues.push({
804
+ code: "cl-date-01",
805
+ severity: "error",
806
+ path: "Encounter.period.start",
807
+ message: `Encounter.period.start='${r.period.start}' no es una fecha/hora ISO-8601 válida. Formato esperado: YYYY-MM-DDTHH:MM:SS+TZ. FHIR R4 dateTime.`,
808
+ });
809
+ }
810
+ if (r.period.end && !ISO_DATETIME_RE.test(r.period.end)) {
811
+ issues.push({
812
+ code: "cl-date-01",
813
+ severity: "error",
814
+ path: "Encounter.period.end",
815
+ message: `Encounter.period.end='${r.period.end}' no es una fecha/hora ISO-8601 válida. Formato esperado: YYYY-MM-DDTHH:MM:SS+TZ. FHIR R4 dateTime.`,
816
+ });
817
+ }
818
+ }
819
+
820
+ // participant — si hay, cada uno debe tener individual con reference válida
821
+ if (Array.isArray(r.participant)) {
822
+ r.participant.forEach((p, i) => {
823
+ if (p && p.individual && p.individual.reference && !REFERENCE_RE.test(p.individual.reference)) {
824
+ issues.push({
825
+ code: "cl-ref-01",
826
+ severity: "error",
827
+ path: `Encounter.participant[${i}].individual.reference`,
828
+ message: `La referencia '${p.individual.reference}' no tiene formato FHIR válido. Esperado: '<ResourceType>/<id>'.`,
829
+ });
830
+ }
831
+ });
832
+ }
833
+ }
834
+
835
+ // Regex compartidos para reglas FHIR generales
836
+ const REFERENCE_RE = /^[A-Z][A-Za-z]+\/[A-Za-z0-9\-.]{1,64}(\|[^\s]+)?$/;
837
+ const ISO_DATE_RE = /^\d{4}(-\d{2}(-\d{2})?)?$/;
838
+ const ISO_DATETIME_RE = /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})?)?$/;
839
+
840
+ function lintCondition(r, issues) {
841
+ if (!r.subject) {
842
+ issues.push({
843
+ code: "cl-cond-01",
844
+ severity: "error",
845
+ path: "Condition.subject",
846
+ message:
847
+ "Condition.subject es obligatorio (referencia al Patient). CL Core CoreDiagnosticoCl.",
848
+ });
849
+ }
850
+ if (!r.code) {
851
+ issues.push({
852
+ code: "cl-cond-02",
853
+ severity: "error",
854
+ path: "Condition.code",
855
+ message:
856
+ "Condition.code es obligatorio. Usa codificación CIE-10 (system http://hl7.org/fhir/sid/icd-10) o SNOMED CT. CL Core CoreDiagnosticoCl.",
857
+ });
858
+ }
859
+ }
860
+
861
+ function lintAllergyIntolerance(r, issues) {
862
+ // cl-allergy-04 — warning si falta clinicalStatus
863
+ if (!r.clinicalStatus) {
864
+ issues.push({
865
+ code: "cl-allergy-04",
866
+ severity: "warning",
867
+ path: "AllergyIntolerance.clinicalStatus",
868
+ message:
869
+ "Recomendado: declarar clinicalStatus (active · inactive · resolved). Sin él la alergia queda ambigua para el clínico que la lee.",
870
+ });
871
+ }
872
+ if (!r.patient) {
873
+ issues.push({
874
+ code: "cl-allergy-01",
875
+ severity: "error",
876
+ path: "AllergyIntolerance.patient",
877
+ message:
878
+ "AllergyIntolerance.patient es obligatorio (referencia al Patient). CL Core CoreAlergiaIntCl.",
879
+ });
880
+ }
881
+ if (!r.code) {
882
+ issues.push({
883
+ code: "cl-allergy-02",
884
+ severity: "error",
885
+ path: "AllergyIntolerance.code",
886
+ message:
887
+ "AllergyIntolerance.code es obligatorio. Usa SNOMED CT o el catálogo de alergias DEIS. CL Core CoreAlergiaIntCl.",
888
+ });
889
+ }
890
+ }
891
+
892
+ function lintProcedure(r, issues) {
893
+ if (!r.status) {
894
+ issues.push({
895
+ code: "cl-proc-01",
896
+ severity: "error",
897
+ path: "Procedure.status",
898
+ message:
899
+ "Procedure.status es obligatorio (preparation · in-progress · not-done · on-hold · stopped · completed · entered-in-error · unknown). CL Core.",
900
+ });
901
+ }
902
+ if (!r.subject) {
903
+ issues.push({
904
+ code: "cl-proc-02",
905
+ severity: "error",
906
+ path: "Procedure.subject",
907
+ message:
908
+ "Procedure.subject es obligatorio (referencia al Patient). CL Core.",
909
+ });
910
+ }
911
+ if (!r.code) {
912
+ issues.push({
913
+ code: "cl-proc-03",
914
+ severity: "error",
915
+ path: "Procedure.code",
916
+ message:
917
+ "Procedure.code es obligatorio. Usa SNOMED CT o la codificación de prestaciones FONASA/DEIS. CL Core.",
918
+ });
919
+ }
920
+ // cl-proc-04 — warning si falta fecha del procedimiento
921
+ if (!r.performedDateTime && !r.performedPeriod && !r.performedString) {
922
+ issues.push({
923
+ code: "cl-proc-04",
924
+ severity: "warning",
925
+ path: "Procedure.performed[x]",
926
+ message:
927
+ "Recomendado: declarar cuándo ocurrió el procedimiento (performedDateTime o performedPeriod). Sin fecha el procedimiento no es analizable epidemiológicamente.",
928
+ });
929
+ }
930
+ }
931
+
932
+ function lintCoverage(r, issues) {
933
+ if (!r.status) {
934
+ issues.push({
935
+ code: "cl-cov-01",
936
+ severity: "error",
937
+ path: "Coverage.status",
938
+ message:
939
+ "Coverage.status es obligatorio (active · cancelled · draft · entered-in-error). CL Core CoreCoberturaCl.",
940
+ });
941
+ }
942
+ if (!r.beneficiary) {
943
+ issues.push({
944
+ code: "cl-cov-02",
945
+ severity: "error",
946
+ path: "Coverage.beneficiary",
947
+ message:
948
+ "Coverage.beneficiary es obligatorio (referencia al Patient asegurado). CL Core CoreCoberturaCl.",
949
+ });
950
+ }
951
+ if (!Array.isArray(r.payor) || r.payor.length === 0) {
952
+ issues.push({
953
+ code: "cl-cov-03",
954
+ severity: "error",
955
+ path: "Coverage.payor",
956
+ message:
957
+ "Coverage.payor es obligatorio (Isapre, FONASA u otra entidad pagadora). CL Core CoreCoberturaCl.",
958
+ });
959
+ }
960
+ // cl-cov-04 — warning: subscriberId requerido para liquidación Fonasa/ISAPRE
961
+ if (!r.subscriberId && !r.subscriber) {
962
+ issues.push({
963
+ code: "cl-cov-04",
964
+ severity: "warning",
965
+ path: "Coverage.subscriberId",
966
+ message:
967
+ "Recomendado: incluir subscriberId (RUN del cotizante en Fonasa, número de contrato en ISAPRE) para que el dispensador pueda liquidar.",
968
+ });
969
+ }
970
+ }
971
+
972
+ function lintOrganization(r, issues) {
973
+ const hasIdentifier = Array.isArray(r.identifier) && r.identifier.length > 0;
974
+ const hasName = typeof r.name === "string" && r.name.trim().length > 0;
975
+ if (!hasIdentifier && !hasName) {
976
+ issues.push({
977
+ code: "cl-org-01",
978
+ severity: "error",
979
+ path: "Organization",
980
+ message:
981
+ "Organization debe incluir al menos un identifier (código DEIS) o un name. CL Core CoreOrganizacionCl.",
982
+ });
983
+ }
984
+ // cl-org-02: MINSAL aún no publica un CodeSystem oficial para
985
+ // establecimientos DEIS. Mientras no exista URI canónica publicada,
986
+ // este chequeo queda como no-op para no exigir un system inventado.
987
+ // Cuando MINSAL publique el CodeSystem oficial, restaurar la advertencia
988
+ // contra esa URI.
989
+ }
990
+
991
+ function lintImmunization(r, issues) {
992
+ if (!r.status) {
993
+ issues.push({
994
+ code: "cl-imm-01",
995
+ severity: "error",
996
+ path: "Immunization.status",
997
+ message:
998
+ "Immunization.status es obligatorio (completed · entered-in-error · not-done). CL Core ImmunizationCL.",
999
+ });
1000
+ }
1001
+ if (!r.vaccineCode) {
1002
+ issues.push({
1003
+ code: "cl-imm-02",
1004
+ severity: "error",
1005
+ path: "Immunization.vaccineCode",
1006
+ message:
1007
+ "Immunization.vaccineCode es obligatorio. Usa CVX (http://hl7.org/fhir/sid/cvx). El catálogo PNI chileno (CSNombreCampana) está pendiente. CL Core ImmunizationCL.",
1008
+ });
1009
+ }
1010
+ if (!r.patient) {
1011
+ issues.push({
1012
+ code: "cl-imm-03",
1013
+ severity: "error",
1014
+ path: "Immunization.patient",
1015
+ message:
1016
+ "Immunization.patient es obligatorio (referencia al Patient). CL Core ImmunizationCL.",
1017
+ });
1018
+ }
1019
+ const hasOccurrence = r.occurrenceDateTime || r.occurrenceString;
1020
+ if (!hasOccurrence) {
1021
+ issues.push({
1022
+ code: "cl-imm-04",
1023
+ severity: "error",
1024
+ path: "Immunization.occurrence[x]",
1025
+ message:
1026
+ "Immunization.occurrenceDateTime u occurrenceString es obligatorio (fecha/hora de la vacunación). CL Core ImmunizationCL.",
1027
+ });
1028
+ }
1029
+ // cl-imm-05 — warning: lotNumber recomendado para trazabilidad PNI
1030
+ if (!r.lotNumber) {
1031
+ issues.push({
1032
+ code: "cl-imm-05",
1033
+ severity: "warning",
1034
+ path: "Immunization.lotNumber",
1035
+ message:
1036
+ "Recomendado: incluir lotNumber para trazabilidad PNI. Sin lote la dosis no es auditable ante eventos adversos.",
1037
+ });
1038
+ }
1039
+ }
1040
+
1041
+ function lintDiagnosticReport(r, issues) {
1042
+ if (!r.status) {
1043
+ issues.push({
1044
+ code: "cl-dr-01",
1045
+ severity: "error",
1046
+ path: "DiagnosticReport.status",
1047
+ message:
1048
+ "DiagnosticReport.status es obligatorio (registered · partial · preliminary · final · amended · corrected · appended · cancelled · entered-in-error · unknown). CL Core.",
1049
+ });
1050
+ }
1051
+ if (!r.code) {
1052
+ issues.push({
1053
+ code: "cl-dr-02",
1054
+ severity: "error",
1055
+ path: "DiagnosticReport.code",
1056
+ message:
1057
+ "DiagnosticReport.code es obligatorio. Usa LOINC (system http://loinc.org) para el tipo de informe. CL Core.",
1058
+ });
1059
+ }
1060
+ if (!r.subject) {
1061
+ issues.push({
1062
+ code: "cl-dr-03",
1063
+ severity: "error",
1064
+ path: "DiagnosticReport.subject",
1065
+ message:
1066
+ "DiagnosticReport.subject es obligatorio (referencia al Patient). CL Core.",
1067
+ });
1068
+ }
1069
+ // cl-dr-04 — warning si falta effectiveDateTime
1070
+ if (!r.effectiveDateTime && !r.effectivePeriod) {
1071
+ issues.push({
1072
+ code: "cl-dr-04",
1073
+ severity: "warning",
1074
+ path: "DiagnosticReport.effective[x]",
1075
+ message:
1076
+ "Recomendado: declarar effectiveDateTime (fecha/hora del estudio o toma de muestra). Útil para trazabilidad clínica.",
1077
+ });
1078
+ }
1079
+ }
1080
+
1081
+ function lintBundle(r, issues) {
1082
+ if (!r.type) {
1083
+ issues.push({
1084
+ code: "cl-bundle-01",
1085
+ severity: "error",
1086
+ path: "Bundle.type",
1087
+ message:
1088
+ "Bundle.type es obligatorio (document · message · transaction · transaction-response · batch · batch-response · history · searchset · collection).",
1089
+ });
1090
+ }
1091
+
1092
+ const entries = Array.isArray(r.entry) ? r.entry : [];
1093
+ entries.forEach((entry, i) => {
1094
+ const res = entry && entry.resource;
1095
+ if (!res) return;
1096
+ // Recursión shallow: lintResource sobre el recurso interno.
1097
+ // Pasamos enrich:false porque el outer call enriquece al final — evita doble pasada.
1098
+ const innerIssues = lintResource(res, { enrich: false });
1099
+ innerIssues.forEach((issue) => {
1100
+ const wrapped = {
1101
+ ...issue,
1102
+ entryIndex: i,
1103
+ path: `Bundle.entry[${i}].resource${issue.path ? `.${issue.path}` : ""}`,
1104
+ };
1105
+ // Re-anchorar quickFix.jsonPointer para que sea aplicable al Bundle completo:
1106
+ // /identifier/0/value (relativo al recurso interno) → /entry/0/resource/identifier/0/value
1107
+ if (issue.quickFix && typeof issue.quickFix.jsonPointer === "string") {
1108
+ wrapped.quickFix = {
1109
+ ...issue.quickFix,
1110
+ jsonPointer: `/entry/${i}/resource${issue.quickFix.jsonPointer}`,
1111
+ };
1112
+ }
1113
+ issues.push(wrapped);
1114
+ });
1115
+ });
1116
+ }
1117
+
1118
+ // ── Quick-fix applier ────────────────────────────────────────────────────────
1119
+
1120
+ /**
1121
+ * Aplica un quick-fix sobre el recurso (o string JSON). Retorna el resultado
1122
+ * serializado con el mismo indent que se infirió del input. El jsonPointer
1123
+ * sigue RFC 6901 (ej: /identifier/0/value).
1124
+ */
1125
+ // FHI-72 H6: prototype pollution guard. Un atacante puede plantar un
1126
+ // jsonPointer `/__proto__/x` o `/constructor/prototype/y` para mutar
1127
+ // Object.prototype durante toda la sesión MCP. Bloqueamos *después* de
1128
+ // decodePointerSegment porque `~2` no es una codificación válida pero
1129
+ // `~01` decodifica a `~1` y un atacante creativo podría intentar variantes.
1130
+ const FORBIDDEN_POINTER_SEGMENTS = new Set(["__proto__", "constructor", "prototype"]);
1131
+
1132
+ function applyQuickFix(input, fix) {
1133
+ if (!fix || !fix.jsonPointer) return typeof input === "string" ? input : JSON.stringify(input, null, 2);
1134
+ let resource = input;
1135
+ let indent = 2;
1136
+ if (typeof input === "string") {
1137
+ const m = input.match(/\n(\s+)"/);
1138
+ if (m) indent = m[1].length;
1139
+ try {
1140
+ resource = JSON.parse(input);
1141
+ } catch {
1142
+ return input;
1143
+ }
1144
+ }
1145
+ const parts = fix.jsonPointer.split("/").filter(Boolean).map(decodePointerSegment);
1146
+ // FHI-72 H6: rechazar cualquier segmento peligroso → no-op.
1147
+ if (parts.some((p) => FORBIDDEN_POINTER_SEGMENTS.has(p))) {
1148
+ return typeof input === "string" ? input : JSON.stringify(resource, null, indent);
1149
+ }
1150
+ let node = resource;
1151
+ for (let i = 0; i < parts.length - 1; i++) {
1152
+ const key = parts[i];
1153
+ if (node == null) return typeof input === "string" ? input : JSON.stringify(resource, null, indent);
1154
+ node = node[key];
1155
+ }
1156
+ if (node && parts.length) {
1157
+ const lastKey = parts[parts.length - 1];
1158
+ node[lastKey] = fix.replacement;
1159
+ }
1160
+ return JSON.stringify(resource, null, indent);
1161
+ }
1162
+
1163
+ function decodePointerSegment(seg) {
1164
+ return seg.replace(/~1/g, "/").replace(/~0/g, "~");
1165
+ }
1166
+
1167
+ // Re-exports — para que consumidores hagan un único require("@fhiron/linter")
1168
+ // y obtengan todo el surface (rules + catalog + examples + terminology + i18n).
1169
+ const examples = require("./examples.js");
1170
+ const terminology = require("./terminology.js");
1171
+ const i18n = require("./i18n.js");
1172
+
1173
+ module.exports = {
1174
+ lintResource,
1175
+ applyQuickFix,
1176
+ stripRunDots,
1177
+ validateRunDv,
1178
+ classifyRunFormat,
1179
+ translateJsonParseError,
1180
+ enrichIssue,
1181
+ lookupCode,
1182
+ listCodes,
1183
+ RUN_SYSTEMS,
1184
+ // examples
1185
+ getExample: examples.getExample,
1186
+ listExamples: examples.listExamples,
1187
+ EXAMPLES: examples.EXAMPLES,
1188
+ // terminology
1189
+ searchTerminology: terminology.search,
1190
+ listTerminologySystems: terminology.listSystems,
1191
+ TERMINOLOGY_SYSTEMS: terminology.SYSTEMS,
1192
+ // i18n
1193
+ t: i18n.t,
1194
+ normalizeLang: i18n.normalizeLang,
1195
+ SUPPORTED_LANGS: i18n.SUPPORTED_LANGS,
1196
+ };