import { createHash } from 'node:crypto';
import type { CheckInput, CheckResult } from './engine.js';
import type { Ingredient } from './catalog.js';
import type { Matcher } from './rules.js';
/**
* A copy of the sign-off hashing recipe in tests/support/rule-content.ts, so the signed-off-only mode (CHK-7) can
* work out each rule's current hash at run time. Never edit one without the other: tests/engine-signed-off-mode.test.ts
* fails unless both give every rule the same hash.
*
* What a clinical sign-off approves of a rule, for its content hash (tests/clinical-signoff.test.ts): every field of the
* rule but `review`, the medicines its matchers cover in the catalogue, its sources as cited, and the wording the engine
* gives it in a few fixed cases. The first case of each rule is the review pack's own example (the pack of 13 October
* 2026 was built the same way), the others other voices, origins, ages and results.
*
* Pure: the check's modules are passed in, so the same hash can be worked out for another checkout of the rules.
*/
export interface CheckModules {
runCheck: (input: CheckInput) => CheckResult;
INGREDIENTS: Ingredient[];
PRODUCTS: Array<{ names: string[]; ingredients: string[] }>;
CHECK_SOURCES: Record<string, { name: string; url: string | null; kind?: string }>;
classesFor: (key: string, context: { name: string; topical?: boolean }) => string[];
PLAUSIBLE_RESULTS: Record<string, { min: number; max: number } | undefined>;
}
export type RuleSection = 'interactions' | 'allergies' | 'older' | 'children' | 'monitoring' | 'labs';
/** A rule of any of the six tables: each section reads the fields its table has. */
type AnyRule = Record<string, any> & { id: string; review: string };
const titleCase = (text: string) => text.replace(/\b[a-z]/g, (letter) => letter.toUpperCase());
/** Stable JSON: keys sorted at every level, so the hash never depends on the order fields are written in. */
export function stableJson(value: unknown): string {
if (Array.isArray(value)) return `[${value.map(stableJson).join(',')}]`;
if (value && typeof value === 'object') {
return `{${Object.keys(value).sort().filter((key) => (value as Record<string, unknown>)[key] !== undefined)
.map((key) => `${JSON.stringify(key)}:${stableJson((value as Record<string, unknown>)[key])}`).join(',')}}`;
}
return JSON.stringify(value);
}
export function ruleContentHash(section: RuleSection, rule: AnyRule, modules: CheckModules): string {
return createHash('sha256').update(stableJson(ruleContent(section, rule, modules))).digest('hex');
}
export function ruleContent(section: RuleSection, rule: AnyRule, modules: CheckModules) {
const { INGREDIENTS, PRODUCTS, CHECK_SOURCES, classesFor, runCheck } = modules;
const ingName = (key: string) => INGREDIENTS.find((ingredient) => ingredient.key === key)?.name ?? titleCase(key);
/** The catalogue's medicines a matcher covers, with the classes the check gives them (dose unknown, or on the skin). */
const covered = (matcher: Matcher) => {
if (matcher.product) return { product: matcher.product, ingredients: PRODUCTS.find((product) => product.names.includes(matcher.product!))?.ingredients ?? [] };
if (matcher.ingredient) return { ingredients: [matcher.ingredient] };
const hit = (classes: string[]) => classes.includes(matcher.class!) && !(matcher.except && classes.includes(matcher.except));
return { ingredients: INGREDIENTS.filter((ingredient) => hit(classesFor(ingredient.key, { name: ingredient.name }))
|| hit(classesFor(ingredient.key, { name: ingredient.name, topical: true }))).map((ingredient) => ingredient.key) };
};
// The review pack's example medicines (review-pack-src/extract.mts, 13 October 2026).
const members = (matcher: Matcher): string[] => {
if (matcher.ingredient) return [ingName(matcher.ingredient)];
if (matcher.product) return [titleCase(matcher.product)];
if (matcher.class === 'aspirin_dose_unknown') return ['Aspirin (dose not known)'];
if (matcher.class === 'topical_nsaid') return ['NSAID gels, creams and patches'];
return INGREDIENTS.filter((ingredient) => (ingredient.classes as string[]).includes(matcher.class!)
&& !(matcher.except && (ingredient.classes as string[]).includes(matcher.except))).map((ingredient) => ingredient.name);
};
const exampleMed = (matcher: Matcher, extra: Record<string, unknown> = {}) => {
let name = '';
const options: Record<string, unknown> = {};
if (matcher.ingredient) name = ingName(matcher.ingredient);
else if (matcher.product) name = titleCase(matcher.product);
else if (matcher.class === 'aspirin_dose_unknown') name = 'Aspirin';
else if (matcher.class === 'topical_nsaid') { name = 'Ibuprofen gel'; options.form = 'gel'; }
else { const all = members(matcher); name = all.find((each) => each !== 'Aspirin') ?? all[0] ?? matcher.class ?? 'Medicine'; }
return { id: name.toLowerCase().replace(/\W+/g, '-'), name, strength: null, form: 'tablet', timesPerDay: 1, unitsPerDose: 1,
prnMaxPerDay: null, origin: 'prescribed', contentsKnown: true, ...options, ...extra };
};
const amina = { kind: 'person', name: 'Amina', sex: 'female', age: 45 };
const input = (medicines: unknown[], extra: Record<string, unknown> = {}) => ({
subject: amina, medicines, allergies: [], conditions: [], labs: [], labTargets: {}, symptoms: null,
emergency: { number: '112', extra: 'or go to the nearest hospital emergency unit' }, language: 'en', today: '2026-10-13', ...extra,
}) as unknown as CheckInput;
const self = { kind: 'self', name: null, sex: 'female', age: 45 };
const him = { kind: 'person', name: 'Musa', sex: 'male', age: 50 };
const uk = { number: '999', extra: null };
const findings = (result: CheckResult) => result.findings.filter((finding) => finding.ruleId === rule.id);
const wording: Record<string, unknown> = {};
if (section === 'interactions') {
const aOtc = rule.allowStop === 'a';
const bOtc = rule.allowStop === 'b' || rule.allowStop === 'either';
const aExtra: Record<string, unknown> = aOtc ? { origin: 'over_the_counter' } : {};
if (rule.aOverMgPerDay) aExtra.strength = `${rule.aOverMgPerDay * 2} mg`;
const bExtra: Record<string, unknown> = bOtc ? { origin: 'over_the_counter' } : rule.unprescribedAspirinToday ? { origin: null } : {};
wording.pack = findings(runCheck(input([exampleMed(rule.a, aExtra), exampleMed(rule.b, bExtra)])));
wording.prescribedSelf = findings(runCheck(input([exampleMed(rule.a, { ...aExtra, origin: 'prescribed' }), exampleMed(rule.b, { origin: 'prescribed' })], { subject: self })));
wording.unknownOriginHimUk = findings(runCheck(input([exampleMed(rule.a, { ...aExtra, origin: null }), exampleMed(rule.b, { origin: null })], { subject: him, emergency: uk })));
} else if (section === 'allergies') {
const term = rule.allergyTerms[0];
wording.pack = runCheck(input([exampleMed(rule.matches[0])], { allergies: [term] })).findings.filter((finding) => finding.kind === 'allergy');
wording.self = runCheck(input([exampleMed(rule.matches[0])], { allergies: [term], subject: self })).findings.filter((finding) => finding.kind === 'allergy');
if (rule.crossReactive?.length) {
wording.crossReactive = runCheck(input([exampleMed(rule.crossReactive[0])], { allergies: [term] })).findings.filter((finding) => finding.kind === 'allergy');
}
} else if (section === 'older') {
wording.pack = findings(runCheck(input([exampleMed(rule.match)], { subject: { ...amina, age: 70 } })));
wording.selfAt80 = findings(runCheck(input([exampleMed(rule.match)], { subject: { ...self, age: 80 } })));
} else if (section === 'children') {
const named = rule.nameWords ? { name: `${exampleMed(rule.match).name} ${rule.nameWords[0]} syrup` } : {};
const age = Math.max(0, rule.under - 1);
wording.pack = findings(runCheck(input([exampleMed(rule.match, named)], { subject: { kind: 'person', name: 'Tobi', sex: 'male', age } })));
wording.girlAgeRange = findings(runCheck(input([exampleMed(rule.match, named)], {
subject: { kind: 'person', name: 'Ada', sex: 'female', age: rule.under, ageRange: { min: rule.under - 1, max: rule.under } } })));
} else if (section === 'monitoring') {
const tests = (result: CheckResult) => result.tests_to_ask_about.filter((test) => test.test === rule.test);
wording.noResult = tests(runCheck(input([exampleMed(rule.match)])));
if (rule.labCode) wording.oldResult = tests(runCheck(input([exampleMed(rule.match)], { labs: [{ code: rule.labCode, value: 1, observedAt: '2025-01-01' }] })));
} else {
const bound = rule.bound;
const threshold = typeof bound.threshold === 'number' ? bound.threshold : 3;
const value = bound.direction === 'above' ? +(threshold + (threshold >= 10 ? 1 : 0.2)).toFixed(1) : +(threshold - (threshold >= 10 ? 1 : 0.2)).toFixed(1);
const meds = [exampleMed(rule.match[0]), ...(rule.alsoNeeds ? [exampleMed(rule.alsoNeeds)] : [])];
const targets = rule.labCode === 'inr' ? { inr: { low: 2, high: 3 } } : {};
wording.pack = findings(runCheck(input(meds, { labs: [{ code: rule.labCode, value, observedAt: '2026-10-13' }], labTargets: targets })));
wording.datedOlderUk = findings(runCheck(input(meds, { labs: [{ code: rule.labCode, value, observedAt: '2026-09-13' }], labTargets: targets,
subject: { ...amina, age: 80 }, emergency: uk })));
const range = modules.PLAUSIBLE_RESULTS[rule.labCode];
if (range) {
const implausible = bound.direction === 'above' ? range.max * 2 : range.min / 2;
wording.implausible = findings(runCheck(input(meds, { labs: [{ code: rule.labCode, value: implausible, observedAt: '2026-10-13' }], labTargets: targets })));
}
}
const { review, ...fields } = rule;
void review;
const matchers: Matcher[] = section === 'interactions' ? [rule.a, rule.b]
: section === 'allergies' ? [...rule.matches, ...(rule.crossReactive ?? [])]
: section === 'labs' ? [...rule.match, ...(rule.alsoNeeds ? [rule.alsoNeeds] : [])] : [rule.match];
return {
section,
fields,
covers: matchers.map(covered),
sources: (rule.sources as string[]).map((id) => ({ id, name: CHECK_SOURCES[id]?.name ?? null, url: CHECK_SOURCES[id]?.url ?? null })),
wording,
};
}