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; classesFor: (key: string, context: { name: string; topical?: boolean }) => string[]; PLAUSIBLE_RESULTS: Record; } 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 & { 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)[key] !== undefined) .map((key) => `${JSON.stringify(key)}:${stableJson((value as Record)[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 = {}) => { let name = ''; const options: Record = {}; 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 = {}) => ({ 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 = {}; if (section === 'interactions') { const aOtc = rule.allowStop === 'a'; const bOtc = rule.allowStop === 'b' || rule.allowStop === 'either'; const aExtra: Record = aOtc ? { origin: 'over_the_counter' } : {}; if (rule.aOverMgPerDay) aExtra.strength = `${rule.aOverMgPerDay * 2} mg`; const bExtra: Record = 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, }; }