@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.
- package/README.md +128 -0
- package/package.json +57 -0
- package/src/_linter/catalog.js +638 -0
- package/src/_linter/examples.js +270 -0
- package/src/_linter/i18n.js +403 -0
- package/src/_linter/resources.js +173 -0
- package/src/_linter/rules.d.ts +105 -0
- package/src/_linter/rules.js +1196 -0
- package/src/_linter/terminology.js +265 -0
- package/src/errors.js +73 -0
- package/src/http.js +127 -0
- package/src/index.d.ts +125 -0
- package/src/index.js +194 -0
- package/src/index.mjs +16 -0
|
@@ -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
|
+
};
|