pediatric-ai-scribe-v3/scripts/lint-references.js
Daniel 67b7667e04 feat: ED encounters + notes model selector; remove AI corrections; fix notes framing
Four changes batched:

1. ED Encounters tab (new) — multi-stage emergency note with don't-miss
   tooltips and 2023 E/M MDM finalize. New route /api/ed-encounters
   (generate per-stage + finalize MDM), new ed-encounters.js owning all
   client logic, new ed-encounter.html component, new template_ed memory
   category. Persists draft to localStorage every keystroke and to
   saved_encounters on stage advance. encounters.js touched only to
   register the new tab in sessionStorage restore + tabMap (save and
   idempotency code untouched).

2. Notes model selector — /notes/from-voice now accepts a client-supplied
   model (validated by the existing callAI allow-list); falls back to the
   admin default. Added <select class="tab-model-select"> to notes.html
   so the existing app.js populator handles options + default.

3. Remove AI-learning-from-corrections — deleted correctionTracker.js,
   POST /memories/correction, the corrections branch in
   /memories/context, the settings UI section, the FAQ entry, and all
   dead trackAIOutput/saveCorrection guards in callers. Legacy
   correction_* DB rows are filtered (NOT LIKE) rather than dropped, so
   no destructive migration.

4. Fix notes AI framing — /notes/from-voice prompt no longer assumes
   "physician dictation". Plain notes (shopping lists, reminders,
   ideas) now match the dictation tone instead of being forced into
   clinical structure.

All 46 tests pass.
2026-04-28 03:09:38 +02:00

178 lines
7.3 KiB
JavaScript
Executable file

#!/usr/bin/env node
// ============================================================
// STATIC REFERENCE LINTER
// ============================================================
// Scans public/js/ for DOM id references and verifies each target
// exists in some public/*.html or public/components/*.html. Catches
// the class of bug where a component is moved/renamed/deleted and a
// JS file keeps calling getElementById('orphan') — the handler then
// silently no-ops at runtime. The lightbox that broke for Bedside
// pathway images was exactly this: the markup lived in
// calculators.html, but after the reorg the bedside tab never loaded
// it, so getElementById('img-lightbox') returned null.
//
// Also checks:
// - <button data-img-src="/path"> resolves to a file in public/
// - <audio|img src="/path"> resolves to a file in public/
//
// Prints a list of unresolved references and exits non-zero. An
// optional allowlist below handles IDs that are legitimately created
// at runtime (e.g. dynamically injected components).
// ============================================================
'use strict';
const fs = require('fs');
const path = require('path');
const ROOT = path.resolve(__dirname, '..');
const PUB = path.join(ROOT, 'public');
// ── Files to scan for id references ──────────────────────────
function walk(dir, ext, acc = []) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const p = path.join(dir, entry.name);
if (entry.isDirectory()) walk(p, ext, acc);
else if (ext.test(entry.name)) acc.push(p);
}
return acc;
}
const jsFiles = walk(path.join(PUB, 'js'), /\.(js|mjs)$/);
const htmlFiles = walk(PUB, /\.html$/);
// ── Collect every id defined anywhere ────────────────────────
// Both static HTML attributes and dynamic JS (innerHTML strings,
// createElement().id = 'X'). If an id is "defined" by any file in
// the repo, a reference to it is considered resolved — whether it
// appears in the DOM at any given moment depends on control flow,
// which this linter doesn't model. We only catch the dead-reference
// class of bug: a JS module reaches for an id that literally no
// source in the tree ever produces.
const definedIds = new Set();
function collect(src) {
for (const m of src.matchAll(/\bid\s*=\s*["']([a-zA-Z_][\w-]*)["']/g)) definedIds.add(m[1]);
for (const m of src.matchAll(/\.id\s*=\s*["']([a-zA-Z_][\w-]*)["']/g)) definedIds.add(m[1]);
}
for (const f of htmlFiles) collect(fs.readFileSync(f, 'utf8'));
for (const f of jsFiles) collect(fs.readFileSync(f, 'utf8'));
// Dynamic-id allowlist: created by JS via innerHTML or createElement.
// Keep this list short and specific; expand only when sure the id is
// genuinely runtime-constructed. Use prefixes ending in '-' to match
// an entire family.
const DYNAMIC_ID_PREFIXES = [
'em-', // bedside section containers built by sub-nav.js
'shadess-', // wellvisit SSHADESS domain injection
'wv-panel-', // wellvisit subpanel ids (these exist in HTML but also built in JS)
'milestone-', // milestones checklist
'quiz-', // learning hub quiz questions
'lh-quiz-', // learning hub quiz answers
'pe-', // PE guide steps are rendered dynamically
'ros-', // ROS dynamic rows
'nrp-', // NRP pathway dynamic widgets
'seizure-', // seizure pathway dynamic widgets
'sepsis-', // sepsis pathway dynamic widgets
'airway-', // airway pathway dynamic widgets
'cardiac-', // cardiac-arrest pathway dynamic widgets
'anaph-', // anaphylaxis
'burn-', // burns
'vent-', // ventilation
'tox-', // toxicology
'trauma-', // trauma
'neo-', // neonatal
'resp-', // respiratory
'sed-', // sedation
'agit-', // agitation
'antiemetic-', // antiemetics
'antibio-', // antimicrobials
'bili-', // bilirubin dynamic columns
'bmi-', // BMI chart elements
'bp-', // BP percentile chart elements
'growth-', // growth chart elements
'dose-', // dosing widgets
'bsa-', // BSA calc
'equip-', // equipment sizing
'resus-', // resus meds
'gcs-', // GCS components
'vitals-', // vitals reference
'catchup-', // catch-up schedule
'totp-', // 2FA elements
'sessions-', // active sessions list
'nc-', // nextcloud settings
'mem-', // memories
'doc-', // documents
'audio-', // audio backups
'saved-', // saved encounters
'ext-', // extensions CRUD
'cms-', // content manager
'fpa-', // forgot-password accept screen
'rsp-', // reset password
'browser-whisper-', // whisper settings
'admin-', // admin panel
'adminms-', // admin milestones
'2fa-', // 2FA UI
'calc-', // calculator panels (some may be missing from HTML)
];
function isDynamicId(id) {
return DYNAMIC_ID_PREFIXES.some(p => id.startsWith(p));
}
// ── Scan JS for getElementById / querySelector('#id') / '#id' ─
const unresolved = [];
const idRef = /getElementById\s*\(\s*['"]([^'"]+)['"]\s*\)|querySelector(?:All)?\s*\(\s*['"]#([a-zA-Z_][\w-]*)\s*['"]|querySelector(?:All)?\s*\(\s*['"][^'"]*?#([a-zA-Z_][\w-]+)/g;
for (const f of jsFiles) {
const src = fs.readFileSync(f, 'utf8');
const seen = new Set();
for (const m of src.matchAll(idRef)) {
const id = m[1] || m[2] || m[3];
if (!id || seen.has(id)) continue;
seen.add(id);
if (definedIds.has(id) || isDynamicId(id)) continue;
unresolved.push({ file: path.relative(ROOT, f), id });
}
}
// ── Scan HTML for asset references that must resolve on disk ──
const brokenAssets = [];
function checkAsset(file, p) {
if (!p || !p.startsWith('/')) return; // only root-absolute
if (p.startsWith('//') || p.startsWith('/api/') || p.startsWith('/components/')) return;
if (p.startsWith('/#')) return; // hash-only
const onDisk = path.join(PUB, p.split('?')[0].split('#')[0]);
if (!fs.existsSync(onDisk)) brokenAssets.push({ file: path.relative(ROOT, file), path: p });
}
const ASSET_PATTERNS = [
/data-img-src\s*=\s*["']([^"']+)["']/g,
/<(?:img|audio|video|source|link)[^>]*\bsrc\s*=\s*["']([^"']+)["']/g,
/<(?:link)[^>]*\bhref\s*=\s*["']([^"']+)["']/g,
];
for (const f of htmlFiles) {
const html = fs.readFileSync(f, 'utf8');
for (const pat of ASSET_PATTERNS) {
for (const m of html.matchAll(pat)) checkAsset(f, m[1]);
}
}
// ── Report ────────────────────────────────────────────────────
let failed = false;
if (unresolved.length) {
console.error('\n❌ Unresolved DOM id references:');
for (const { file, id } of unresolved) console.error(` ${file} -> #${id}`);
failed = true;
} else {
console.log('✓ All JS id references resolve to an HTML id.');
}
if (brokenAssets.length) {
console.error('\n❌ Broken asset paths:');
for (const { file, path: p } of brokenAssets) console.error(` ${file} -> ${p}`);
failed = true;
} else {
console.log('✓ All asset paths resolve to a file on disk.');
}
process.exit(failed ? 1 : 0);