Skip to content

Commit ec866d3

Browse files
committed
Add flat-directory detection and extended skill test coverage
- detect-fhir-project.mjs: classify a flat directory of FHIR resources (resources placed directly in the root, no conventional input/fsh, input/resources, examples, or fixtures layout) as fhir-resources, and include the root in the resource inventory. Conventional projects are unchanged, so the detector snapshot is unaffected. - smoke-test.mjs: broaden coverage to exercise validator edge cases (unknown elements, choice[x] exclusivity, code enums, nulls, empty arrays, Bundle recursion, unschemaed types, invalid JSON exit code), all 13 issue codes plus the unknown fallback, analyzer snapshot/discriminator branches, flat vs empty directory detection, Bundle redaction, high-confidence quality-rule derivation, and CI generation modes.
1 parent 7d51730 commit ec866d3

2 files changed

Lines changed: 111 additions & 2 deletions

File tree

plugins/records/scripts/smoke-test.mjs

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,21 @@ function runValidator(args, input = null) {
266266
return { status: result.status, parsed };
267267
}
268268

269+
// runJson in this file passes env, not stdin; this variant feeds JSON on stdin.
270+
function runJsonInput(script, input) {
271+
const result = spawnSync(process.execPath, [script], { cwd: repo, input, encoding: "utf8" });
272+
if (result.status !== 0) {
273+
errors.push(`${rel(script)} failed on stdin input: ${result.stderr || result.stdout}`);
274+
return null;
275+
}
276+
try {
277+
return JSON.parse(result.stdout);
278+
} catch (error) {
279+
errors.push(`${rel(script)} did not output JSON: ${error.message}`);
280+
return null;
281+
}
282+
}
283+
269284
const invalidObs = runValidator([path.join(plugin, "fixtures/invalid-observation.json")]);
270285
if (invalidObs.parsed) {
271286
if (invalidObs.status !== 1) errors.push("Structural validator should exit 1 for the invalid Observation.");
@@ -312,6 +327,82 @@ if (resolvedDetection && resolvedDetection.packageResolution?.resolvedCount !==
312327
errors.push("Detector should resolve hl7.fhir.r4.core when present in the cache.");
313328
}
314329

330+
// --- Extended structural validator coverage ---
331+
function validatorCodes(args, input = null) {
332+
const run = runValidator(args, input);
333+
return { status: run.status, codes: run.parsed ? run.parsed.operationOutcome.issue.map((entry) => `${entry.code}:${entry.expression[0]}`) : null };
334+
}
335+
const unknownElement = validatorCodes([], '{"resourceType":"Patient","id":"x","bogusElement":1}');
336+
if (!unknownElement.codes?.some((entry) => entry.startsWith("structure:") && entry.includes("bogusElement"))) errors.push("Validator should flag unknown elements as structure.");
337+
const doubleChoice = validatorCodes([], '{"resourceType":"Observation","status":"final","code":{},"valueString":"a","valueInteger":2}');
338+
if (!doubleChoice.codes?.includes("structure:Observation.value[x]")) errors.push("Validator should flag choice[x] exclusivity.");
339+
const badEnum = validatorCodes([], '{"resourceType":"Observation","status":"bogus","code":{}}');
340+
if (!badEnum.codes?.includes("code-invalid:Observation.status")) errors.push("Validator should flag invalid required code enums.");
341+
const nullValue = validatorCodes([], '{"resourceType":"Patient","id":"x","active":null}');
342+
if (!nullValue.codes?.some((entry) => entry.startsWith("value:"))) errors.push("Validator should flag null values.");
343+
const emptyArray = validatorCodes([], '{"resourceType":"Patient","id":"x","name":[]}');
344+
if (!emptyArray.codes?.some((entry) => entry.startsWith("value:"))) errors.push("Validator should flag empty arrays.");
345+
const unknownType = runValidator([], '{"resourceType":"Goober","id":"x"}');
346+
if (unknownType.status !== 0 || !unknownType.parsed?.operationOutcome.issue.some((entry) => entry.code === "incomplete")) errors.push("Validator should return incomplete info and exit 0 for unschemaed resource types.");
347+
const bundleRecursion = validatorCodes([], '{"resourceType":"Bundle","type":"collection","entry":[{"resource":{"resourceType":"Observation","status":12}}]}');
348+
if (!bundleRecursion.codes?.some((entry) => entry.includes("entry[0].resource"))) errors.push("Validator should recurse into Bundle entries.");
349+
const validatorBadJson = spawnSync(process.execPath, [path.join(plugin, "skills/fhir-validation/scripts/validate-structural.mjs")], { cwd: repo, input: "{not json", encoding: "utf8" });
350+
if (validatorBadJson.status !== 2) errors.push("Validator should exit 2 on invalid JSON.");
351+
352+
// --- Extended expression mapper coverage ---
353+
const functionExpr = runJson(expressionMapper, ["Bundle.entry.resource.ofType(Patient).name.where(use='official')"]);
354+
if (!functionExpr?.functions?.some((entry) => entry.name === "ofType") || !functionExpr?.functions?.some((entry) => entry.name === "where")) errors.push("Expression mapper should capture FHIRPath functions.");
355+
const noArgExpr = spawnSync(process.execPath, [expressionMapper], { cwd: repo, encoding: "utf8" });
356+
if (noArgExpr.status !== 2) errors.push("Expression mapper should exit 2 with no argument.");
357+
358+
// --- Extended explainer coverage ---
359+
const allCodes = ["required", "value", "code-invalid", "structure", "invariant", "processing", "not-found", "duplicate", "forbidden", "incomplete", "business-rule", "profile-unknown", "slicing"];
360+
const allExplained = runJsonInput(explainer, JSON.stringify({ resourceType: "OperationOutcome", issue: allCodes.map((code) => ({ severity: "error", code })) }));
361+
if (!allExplained?.issues?.every((entry) => entry.meaning && !/Unknown/.test(entry.meaning))) errors.push("Explainer should map every known issue code.");
362+
const unknownCode = runJsonInput(explainer, JSON.stringify({ resourceType: "OperationOutcome", issue: [{ severity: "error", code: "made-up" }] }));
363+
if (!/Unknown/.test(unknownCode?.issues?.[0]?.meaning || "")) errors.push("Explainer should fall back for unknown codes.");
364+
if (spawnSync(process.execPath, [explainer], { cwd: repo, input: '{"resourceType":"Patient"}', encoding: "utf8" }).status !== 2) errors.push("Explainer should exit 2 for non-OperationOutcome input.");
365+
366+
// --- Extended analyzer coverage ---
367+
const withSnapshot = runJsonInput(analyzer, JSON.stringify({ resourceType: "StructureDefinition", derivation: "constraint", snapshot: { element: [{ path: "Observation" }] } }));
368+
if (withSnapshot?.needsSnapshot !== false) errors.push("Analyzer should not flag profiles that already have a snapshot.");
369+
const noDiscriminator = runJsonInput(analyzer, JSON.stringify({ resourceType: "StructureDefinition", derivation: "constraint", snapshot: { element: [{ path: "X" }] }, differential: { element: [{ path: "Observation.category", slicing: { rules: "open" } }] } }));
370+
if (!noDiscriminator?.caveats?.some((entry) => /no discriminator/.test(entry))) errors.push("Analyzer should caveat slicing without a discriminator.");
371+
if (spawnSync(process.execPath, [analyzer], { cwd: repo, input: '{"resourceType":"Patient"}', encoding: "utf8" }).status !== 2) errors.push("Analyzer should exit 2 for non-StructureDefinition input.");
372+
373+
// --- Flat-directory detection ---
374+
const flatDir = await mkdtemp(path.join(os.tmpdir(), "records-flat-"));
375+
await writeFile(path.join(flatDir, "obs.json"), JSON.stringify({ resourceType: "Observation", id: "a", status: "final", code: {} }), "utf8");
376+
const flatDetection = runJson(detector, [flatDir], { ...process.env, FHIR_PACKAGE_CACHE: emptyCache });
377+
if (flatDetection?.projectType !== "fhir-resources" || flatDetection?.resourceInventory.byResourceType.Observation !== 1) {
378+
errors.push("Detector should classify a flat directory of resources as fhir-resources.");
379+
}
380+
const emptyDir = await mkdtemp(path.join(os.tmpdir(), "records-emptydir-"));
381+
const emptyDetection = runJson(detector, [emptyDir], { ...process.env, FHIR_PACKAGE_CACHE: emptyCache });
382+
if (emptyDetection?.projectType !== "unknown") errors.push("Detector should classify an empty directory as unknown.");
383+
384+
// --- Redaction and quality-rule derivation ---
385+
const redactor = path.join(plugin, "skills/fhir-validation/scripts/redact-fhir-summary.mjs");
386+
const bundleSummary = runJsonInput(redactor, JSON.stringify({ resourceType: "Bundle", entry: [{ resource: { resourceType: "Patient", id: "p1" } }] }));
387+
if (bundleSummary?.entryCount !== 1 || bundleSummary?.privacyRiskLevel !== "high") errors.push("Redactor should summarize Bundles and raise risk for Patient entries.");
388+
const qualityDir = await mkdtemp(path.join(os.tmpdir(), "records-quality-"));
389+
for (const id of ["a", "b", "c"]) {
390+
await writeFile(path.join(qualityDir, `${id}.json`), JSON.stringify({ resourceType: "Observation", id, status: "final", code: {}, meta: { profile: ["https://example.org/StructureDefinition/p"] } }), "utf8");
391+
}
392+
const qualityRules = runJson(path.join(plugin, "skills/fhir-validation/scripts/derive-quality-rules.mjs"), [qualityDir]);
393+
if (!qualityRules?.proposedRules?.some((rule) => rule.id.startsWith("profile-") && rule.confidence === "high")) {
394+
errors.push("Quality-rule derivation should propose a high-confidence profile rule when all resources share a profile.");
395+
}
396+
397+
// --- CI generation modes ---
398+
const ciGen = path.join(plugin, "skills/fhir-validation/scripts/generate-ci.mjs");
399+
const apiCi = spawnSync(process.execPath, [ciGen, "--api"], { cwd: repo, encoding: "utf8" }).stdout;
400+
if (!apiCi.includes("RECORDS_API_URL")) errors.push("CI generator --api should reference RECORDS_API_URL.");
401+
const sushiCi = spawnSync(process.execPath, [ciGen, "--sushi"], { cwd: repo, encoding: "utf8" }).stdout;
402+
if (!sushiCi.includes("sushi .")) errors.push("CI generator --sushi should include a SUSHI build step.");
403+
const uploadCi = spawnSync(process.execPath, [ciGen, "--upload-artifact"], { cwd: repo, encoding: "utf8" }).stdout;
404+
if (!uploadCi.includes("upload-artifact")) errors.push("CI generator --upload-artifact should add an artifact upload step.");
405+
315406
if (errors.length) {
316407
console.error(errors.map((error) => `- ${error}`).join("\n"));
317408
process.exit(1);

plugins/records/skills/fhir-validation/scripts/detect-fhir-project.mjs

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -295,15 +295,33 @@ const availableRuntimes = {
295295
};
296296

297297
let projectType = "unknown";
298+
// Flat-directory fallback: FHIR resources placed directly in the root, with no
299+
// conventional source/generated layout. Only checked when no conventional
300+
// resource directory matched, so it does not double-scan known projects.
301+
const rootResourceDirs = [];
302+
if (!sourceDirs.length && !generatedDirs.length) {
303+
for (const file of (await listFiles(".", 1)).filter((entry) => entry.endsWith(".json") && !entry.includes(path.sep))) {
304+
try {
305+
const resource = JSON.parse(await readText(file));
306+
if (resource && typeof resource.resourceType === "string") {
307+
rootResourceDirs.push(".");
308+
break;
309+
}
310+
} catch {
311+
// Ignore non-resource JSON in the root.
312+
}
313+
}
314+
}
315+
298316
if (workflowFiles.some((file) => file.startsWith("sushi-config")) || sourceDirs.includes("input/fsh")) {
299317
projectType = "fsh-ig";
300318
} else if (workflowFiles.includes("ig.ini")) {
301319
projectType = "ig";
302-
} else if (sourceDirs.some((dir) => ["input/resources", "examples", "fixtures"].includes(dir))) {
320+
} else if (sourceDirs.some((dir) => ["input/resources", "examples", "fixtures"].includes(dir)) || rootResourceDirs.length) {
303321
projectType = "fhir-resources";
304322
}
305323

306-
const resourceDirs = [...new Set([...sourceDirs, ...generatedDirs].filter((dir) => !dir.endsWith("/fsh") && dir !== "fsh-generated"))];
324+
const resourceDirs = [...new Set([...sourceDirs, ...generatedDirs, ...rootResourceDirs].filter((dir) => !dir.endsWith("/fsh") && dir !== "fsh-generated"))];
307325
const resourceInventory = await inventoryResources(resourceDirs);
308326
const mixedFhirVersionWarning = new Set(fhirVersions.map((value) => String(value).toLowerCase().replace(/\s+/g, ""))).size > 1;
309327

0 commit comments

Comments
 (0)