test(lint): static reference linter — catches dead-code + orphan refs
You were right that Playwright has been catching the easy bugs while
the high-signal bugs (lightbox stranded after the Bedside reorg, PVC
clinically wrong, prod not rebuilt) all had to be caught by you as
the user. Adding a static linter so the class of bug that produced
the lightbox regression fails CI next time instead of the app.
scripts/lint-references.js walks public/js and validates every
getElementById('X') and querySelector('#X') resolves to an id that
is defined SOMEWHERE in the repo — either a static HTML attribute,
a .id = 'X' assignment, or an id="X" substring inside a JS template
string. It also walks HTML for asset references (data-img-src,
<img src>, <audio src>, <link href>) and verifies each root-absolute
path maps to a real file on disk.
Running it on the current tree surfaced two real problems:
1. shadess.js:917 reached for #wv-note-transcript when collecting
refine source context. The actual id is #wv-transcript (no -note-
infix — the transcript element is shared across well-visit sub-
panels). The bug meant a generated well-visit note's Refine call
silently missed the original transcript as source context; the AI
still "worked" but with less signal and no error. Fixed.
2. public/js/adminMilestones.js (221 lines) referenced 17 ids that
were removed a year+ ago in commit 3173ce6 ("Remove milestone
admin UI, add CMS content refresh button"). That commit dropped
the HTML but forgot the JS, which has been dead-loaded on every
page view since. All its addEventListener calls are guarded with
optional chaining, so nothing errored — just silent cruft.
Deleted the file and dropped the <script defer> tag from
index.html.
scripts/e2e.sh runs the linter as a preflight before the Playwright
container starts; a broken reference now fails the suite before any
test even boots.
Allowlist is kept small and prefix-based for the handful of id families
that are built dynamically by JS (bedside em-* sections, BP chart
elements, etc.). When a new component is added, its ids get picked up
automatically by the repo-wide collect() pass; the allowlist rarely
needs to grow.
Suite: 294 passed / 0 failed.
This commit is contained in:
parent
e676c7b748
commit
bd0016a225
5 changed files with 190 additions and 328 deletions
|
|
@ -468,7 +468,6 @@
|
||||||
<script type="module" src="/js/bedside/index.js"></script>
|
<script type="module" src="/js/bedside/index.js"></script>
|
||||||
<script defer src="/js/learningHub.js"></script>
|
<script defer src="/js/learningHub.js"></script>
|
||||||
<script defer src="/js/admin.js"></script>
|
<script defer src="/js/admin.js"></script>
|
||||||
<script defer src="/js/adminMilestones.js"></script>
|
|
||||||
|
|
||||||
<!-- ═══════════ IMAGE LIGHTBOX (global overlay — triggered by any
|
<!-- ═══════════ IMAGE LIGHTBOX (global overlay — triggered by any
|
||||||
[data-img-src] button in any tab; lives here so it exists in the
|
[data-img-src] button in any tab; lives here so it exists in the
|
||||||
|
|
|
||||||
|
|
@ -1,326 +0,0 @@
|
||||||
// ============================================================
|
|
||||||
// ADMIN: DEVELOPMENTAL MILESTONES MANAGEMENT
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
(function() {
|
|
||||||
var _inited = false;
|
|
||||||
|
|
||||||
document.addEventListener('tabChanged', function(e) {
|
|
||||||
if (e.detail.tab !== 'admin' || _inited) return;
|
|
||||||
_inited = true;
|
|
||||||
initMilestonesAdmin();
|
|
||||||
});
|
|
||||||
|
|
||||||
var milestones = [];
|
|
||||||
var ageGroups = [];
|
|
||||||
var domains = [];
|
|
||||||
var editingId = null;
|
|
||||||
|
|
||||||
function initMilestonesAdmin() {
|
|
||||||
console.log('[AdminMilestones] Initializing...');
|
|
||||||
|
|
||||||
// Buttons
|
|
||||||
document.getElementById('btn-refresh-milestones')?.addEventListener('click', loadMilestones);
|
|
||||||
document.getElementById('btn-add-milestone')?.addEventListener('click', function() {
|
|
||||||
editingId = null;
|
|
||||||
openMilestoneModal();
|
|
||||||
});
|
|
||||||
document.getElementById('btn-close-milestone-modal')?.addEventListener('click', closeMilestoneModal);
|
|
||||||
document.getElementById('btn-cancel-milestone-modal')?.addEventListener('click', closeMilestoneModal);
|
|
||||||
document.getElementById('btn-save-milestone')?.addEventListener('click', saveMilestone);
|
|
||||||
document.getElementById('btn-bulk-import-milestones')?.addEventListener('click', bulkImportMilestones);
|
|
||||||
document.getElementById('btn-reimport-milestones')?.addEventListener('click', function() {
|
|
||||||
showConfirm('This will DELETE all existing milestones and re-import from static data. Continue?', function() {
|
|
||||||
bulkImportMilestones(true);
|
|
||||||
}, { danger: true, confirmText: 'Re-import' });
|
|
||||||
});
|
|
||||||
|
|
||||||
// Filter
|
|
||||||
document.getElementById('ms-filter-age')?.addEventListener('change', renderMilestones);
|
|
||||||
|
|
||||||
// Load data
|
|
||||||
loadMetadata();
|
|
||||||
loadMilestones();
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadMetadata() {
|
|
||||||
fetch('/api/admin/milestones/meta', {
|
|
||||||
headers: getAuthHeaders()
|
|
||||||
})
|
|
||||||
.then(r => r.json())
|
|
||||||
.then(data => {
|
|
||||||
if (!data.success) return;
|
|
||||||
ageGroups = data.age_groups || [];
|
|
||||||
domains = data.domains || [];
|
|
||||||
|
|
||||||
// Populate filter dropdown
|
|
||||||
var filterSelect = document.getElementById('ms-filter-age');
|
|
||||||
if (filterSelect) {
|
|
||||||
filterSelect.innerHTML = '<option value="">All Age Groups</option>';
|
|
||||||
ageGroups.forEach(function(age) {
|
|
||||||
var opt = document.createElement('option');
|
|
||||||
opt.value = age;
|
|
||||||
opt.textContent = age;
|
|
||||||
filterSelect.appendChild(opt);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Populate datalists for modal
|
|
||||||
var ageDatalist = document.getElementById('ms-age-list');
|
|
||||||
if (ageDatalist) {
|
|
||||||
ageDatalist.innerHTML = '';
|
|
||||||
ageGroups.forEach(function(age) {
|
|
||||||
var opt = document.createElement('option');
|
|
||||||
opt.value = age;
|
|
||||||
ageDatalist.appendChild(opt);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
var domainDatalist = document.getElementById('ms-domain-list');
|
|
||||||
if (domainDatalist) {
|
|
||||||
domainDatalist.innerHTML = '';
|
|
||||||
domains.forEach(function(domain) {
|
|
||||||
var opt = document.createElement('option');
|
|
||||||
opt.value = domain;
|
|
||||||
domainDatalist.appendChild(opt);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(err => console.error('[AdminMilestones] Failed to load metadata:', err));
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadMilestones() {
|
|
||||||
var filterAge = document.getElementById('ms-filter-age')?.value;
|
|
||||||
var url = '/api/admin/milestones' + (filterAge ? '?age_group=' + encodeURIComponent(filterAge) : '');
|
|
||||||
|
|
||||||
fetch(url, {
|
|
||||||
headers: getAuthHeaders()
|
|
||||||
})
|
|
||||||
.then(r => r.json())
|
|
||||||
.then(data => {
|
|
||||||
if (!data.success) {
|
|
||||||
showToast(data.error || 'Failed to load milestones', 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
milestones = data.milestones || [];
|
|
||||||
|
|
||||||
// Show/hide empty notice
|
|
||||||
var emptyNotice = document.getElementById('ms-empty-notice');
|
|
||||||
if (emptyNotice) {
|
|
||||||
emptyNotice.style.display = (milestones.length === 0 && !filterAge) ? 'block' : 'none';
|
|
||||||
}
|
|
||||||
|
|
||||||
renderMilestones();
|
|
||||||
})
|
|
||||||
.catch(err => {
|
|
||||||
console.error('[AdminMilestones] Failed to load:', err);
|
|
||||||
showToast('Failed to load milestones: ' + err.message, 'error');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderMilestones() {
|
|
||||||
var list = document.getElementById('milestones-list');
|
|
||||||
if (!list) return;
|
|
||||||
|
|
||||||
var filterAge = document.getElementById('ms-filter-age')?.value;
|
|
||||||
var filtered = filterAge ? milestones.filter(m => m.age_group === filterAge) : milestones;
|
|
||||||
|
|
||||||
if (filtered.length === 0) {
|
|
||||||
list.innerHTML = '<p style="text-align:center;color:var(--g400);padding:40px;">No milestones found. Click "Add Milestone" to create one.</p>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Group by age_group and domain
|
|
||||||
var grouped = {};
|
|
||||||
filtered.forEach(function(m) {
|
|
||||||
if (!grouped[m.age_group]) grouped[m.age_group] = {};
|
|
||||||
if (!grouped[m.age_group][m.domain]) grouped[m.age_group][m.domain] = [];
|
|
||||||
grouped[m.age_group][m.domain].push(m);
|
|
||||||
});
|
|
||||||
|
|
||||||
var html = '';
|
|
||||||
Object.keys(grouped).forEach(function(age) {
|
|
||||||
html += '<div style="border:1px solid var(--g200);border-radius:8px;padding:12px;background:var(--g50);">';
|
|
||||||
html += '<h4 style="margin:0 0 12px 0;font-size:15px;color:var(--g700);">' + age + '</h4>';
|
|
||||||
|
|
||||||
Object.keys(grouped[age]).forEach(function(domain) {
|
|
||||||
html += '<div style="margin-bottom:10px;">';
|
|
||||||
html += '<div style="font-size:13px;font-weight:600;color:var(--g600);margin-bottom:6px;">' + domain + '</div>';
|
|
||||||
|
|
||||||
grouped[age][domain].forEach(function(m) {
|
|
||||||
html += '<div style="display:flex;align-items:center;gap:8px;padding:6px;border:1px solid var(--g200);border-radius:6px;background:white;margin-bottom:4px;">';
|
|
||||||
html += '<span style="flex:1;font-size:13px;color:var(--g700);">' + m.milestone_text + '</span>';
|
|
||||||
html += '<button class="btn-sm btn-ghost" style="padding:4px 8px;font-size:11px;" onclick="editMilestone(' + m.id + ')"><i class="fas fa-edit"></i></button>';
|
|
||||||
html += '<button class="btn-sm" style="padding:4px 8px;font-size:11px;background:var(--red);color:white;border:none;" onclick="deleteMilestone(' + m.id + ')"><i class="fas fa-trash"></i></button>';
|
|
||||||
html += '</div>';
|
|
||||||
});
|
|
||||||
|
|
||||||
html += '</div>';
|
|
||||||
});
|
|
||||||
|
|
||||||
html += '</div>';
|
|
||||||
});
|
|
||||||
|
|
||||||
list.innerHTML = html;
|
|
||||||
}
|
|
||||||
|
|
||||||
function openMilestoneModal(milestone) {
|
|
||||||
var modal = document.getElementById('milestone-modal');
|
|
||||||
var title = document.getElementById('milestone-modal-title');
|
|
||||||
|
|
||||||
if (!modal) return;
|
|
||||||
|
|
||||||
if (milestone) {
|
|
||||||
title.innerHTML = '<i class="fas fa-edit"></i> Edit Milestone';
|
|
||||||
document.getElementById('ms-edit-id').value = milestone.id;
|
|
||||||
document.getElementById('ms-edit-age').value = milestone.age_group;
|
|
||||||
document.getElementById('ms-edit-domain').value = milestone.domain;
|
|
||||||
document.getElementById('ms-edit-text').value = milestone.milestone_text;
|
|
||||||
document.getElementById('ms-edit-sort').value = milestone.sort_order;
|
|
||||||
} else {
|
|
||||||
title.innerHTML = '<i class="fas fa-plus"></i> Add Milestone';
|
|
||||||
document.getElementById('ms-edit-id').value = '';
|
|
||||||
document.getElementById('ms-edit-age').value = '';
|
|
||||||
document.getElementById('ms-edit-domain').value = '';
|
|
||||||
document.getElementById('ms-edit-text').value = '';
|
|
||||||
document.getElementById('ms-edit-sort').value = '0';
|
|
||||||
}
|
|
||||||
|
|
||||||
modal.style.display = 'flex';
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeMilestoneModal() {
|
|
||||||
var modal = document.getElementById('milestone-modal');
|
|
||||||
if (modal) modal.style.display = 'none';
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveMilestone() {
|
|
||||||
var id = document.getElementById('ms-edit-id').value;
|
|
||||||
var age_group = document.getElementById('ms-edit-age').value.trim();
|
|
||||||
var domain = document.getElementById('ms-edit-domain').value.trim();
|
|
||||||
var milestone_text = document.getElementById('ms-edit-text').value.trim();
|
|
||||||
var sort_order = parseInt(document.getElementById('ms-edit-sort').value) || 0;
|
|
||||||
|
|
||||||
if (!age_group || !domain || !milestone_text) {
|
|
||||||
showToast('Please fill in all fields', 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var method = id ? 'PUT' : 'POST';
|
|
||||||
var url = id ? '/api/admin/milestones/' + id : '/api/admin/milestones';
|
|
||||||
|
|
||||||
fetch(url, {
|
|
||||||
method: method,
|
|
||||||
headers: getAuthHeaders(),
|
|
||||||
body: JSON.stringify({ age_group, domain, milestone_text, sort_order })
|
|
||||||
})
|
|
||||||
.then(r => r.json())
|
|
||||||
.then(data => {
|
|
||||||
if (!data.success) {
|
|
||||||
showToast(data.error || 'Failed to save', 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
showToast(id ? 'Milestone updated' : 'Milestone added', 'success');
|
|
||||||
closeMilestoneModal();
|
|
||||||
loadMetadata();
|
|
||||||
loadMilestones();
|
|
||||||
})
|
|
||||||
.catch(err => {
|
|
||||||
console.error('[AdminMilestones] Save error:', err);
|
|
||||||
showToast('Failed to save: ' + err.message, 'error');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
window.editMilestone = function(id) {
|
|
||||||
var milestone = milestones.find(m => m.id === id);
|
|
||||||
if (!milestone) return;
|
|
||||||
editingId = id;
|
|
||||||
openMilestoneModal(milestone);
|
|
||||||
};
|
|
||||||
|
|
||||||
window.deleteMilestone = function(id) {
|
|
||||||
showConfirm('Delete this milestone? This action cannot be undone.', function() {
|
|
||||||
fetch('/api/admin/milestones/' + id, {
|
|
||||||
method: 'DELETE',
|
|
||||||
headers: getAuthHeaders()
|
|
||||||
})
|
|
||||||
.then(r => r.json())
|
|
||||||
.then(data => {
|
|
||||||
if (!data.success) {
|
|
||||||
showToast(data.error || 'Failed to delete', 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
showToast('Milestone deleted', 'success');
|
|
||||||
loadMetadata();
|
|
||||||
loadMilestones();
|
|
||||||
})
|
|
||||||
.catch(err => {
|
|
||||||
console.error('[AdminMilestones] Delete error:', err);
|
|
||||||
showToast('Failed to delete: ' + err.message, 'error');
|
|
||||||
});
|
|
||||||
}, { danger: true, confirmText: 'Delete' });
|
|
||||||
};
|
|
||||||
|
|
||||||
function bulkImportMilestones(clearExisting) {
|
|
||||||
if (!window.MILESTONES_DATA_STATIC) {
|
|
||||||
showToast('Static milestones data not available', 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var btn = clearExisting ? document.getElementById('btn-reimport-milestones') : document.getElementById('btn-bulk-import-milestones');
|
|
||||||
if (btn) {
|
|
||||||
btn.disabled = true;
|
|
||||||
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Importing...';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert static data to array format for bulk import
|
|
||||||
var milestonesToImport = [];
|
|
||||||
var sortOrder = 0;
|
|
||||||
|
|
||||||
Object.keys(window.MILESTONES_DATA_STATIC).forEach(function(ageGroup) {
|
|
||||||
Object.keys(window.MILESTONES_DATA_STATIC[ageGroup]).forEach(function(domain) {
|
|
||||||
var items = window.MILESTONES_DATA_STATIC[ageGroup][domain];
|
|
||||||
items.forEach(function(text) {
|
|
||||||
milestonesToImport.push({
|
|
||||||
age_group: ageGroup,
|
|
||||||
domain: domain,
|
|
||||||
milestone_text: text,
|
|
||||||
sort_order: sortOrder++
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Import directly (backend will handle existing data)
|
|
||||||
fetch('/api/admin/milestones/bulk-import', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: getAuthHeaders(),
|
|
||||||
body: JSON.stringify({ milestones: milestonesToImport, clearExisting: clearExisting })
|
|
||||||
})
|
|
||||||
.then(function(r) { return r.json(); })
|
|
||||||
.then(function(data) {
|
|
||||||
if (btn) {
|
|
||||||
btn.disabled = false;
|
|
||||||
btn.innerHTML = clearExisting ? '<i class="fas fa-sync"></i> Re-import All' : '<i class="fas fa-download"></i> Import Default Milestones Data';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!data.success) {
|
|
||||||
showToast(data.error || 'Import failed', 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
showToast('Successfully imported ' + data.imported + ' milestones', 'success');
|
|
||||||
loadMetadata();
|
|
||||||
loadMilestones();
|
|
||||||
})
|
|
||||||
.catch(function(err) {
|
|
||||||
if (btn) {
|
|
||||||
btn.disabled = false;
|
|
||||||
btn.innerHTML = clearExisting ? '<i class="fas fa-sync"></i> Re-import All' : '<i class="fas fa-download"></i> Import Default Milestones Data';
|
|
||||||
}
|
|
||||||
console.error('[AdminMilestones] Import error:', err);
|
|
||||||
showToast('Import failed: ' + err.message, 'error');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
})();
|
|
||||||
|
|
@ -914,7 +914,7 @@
|
||||||
if (typeof trackAIOutput === 'function') trackAIOutput('wv-note-text', data.note);
|
if (typeof trackAIOutput === 'function') trackAIOutput('wv-note-text', data.note);
|
||||||
// Store source for refine — collect all well visit inputs
|
// Store source for refine — collect all well visit inputs
|
||||||
var wvSource = [];
|
var wvSource = [];
|
||||||
var wvTranscript = document.getElementById('wv-note-transcript');
|
var wvTranscript = document.getElementById('wv-transcript');
|
||||||
if (wvTranscript && wvTranscript.innerText.trim()) wvSource.push('TRANSCRIPT: ' + wvTranscript.innerText.trim());
|
if (wvTranscript && wvTranscript.innerText.trim()) wvSource.push('TRANSCRIPT: ' + wvTranscript.innerText.trim());
|
||||||
var wvAge = document.getElementById('wv-note-age');
|
var wvAge = document.getElementById('wv-note-age');
|
||||||
if (wvAge && wvAge.value) wvSource.push('Age: ' + wvAge.value);
|
if (wvAge && wvAge.value) wvSource.push('Age: ' + wvAge.value);
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,16 @@ cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
IMAGE="mcr.microsoft.com/playwright:v1.50.0-noble"
|
IMAGE="mcr.microsoft.com/playwright:v1.50.0-noble"
|
||||||
|
|
||||||
|
# --- PREFLIGHT: static reference linter ---
|
||||||
|
# Catches the class of bug where a JS file reaches for an id that no
|
||||||
|
# HTML element (or dynamic id assignment anywhere in the repo) ever
|
||||||
|
# produces — the lightbox + adminMilestones dead-code bugs were both
|
||||||
|
# this shape and both went undetected until someone tripped over them
|
||||||
|
# in the real app. Fails the build before tests even start.
|
||||||
|
echo "==> Static reference lint"
|
||||||
|
docker run --rm -v "$PWD:/work" -w /work node:20-alpine \
|
||||||
|
node scripts/lint-references.js
|
||||||
|
|
||||||
# Attach the Playwright container to the same Docker network as the app so it
|
# Attach the Playwright container to the same Docker network as the app so it
|
||||||
# can resolve pediatric-ai-scribe by service name. Internal container port is 3000.
|
# can resolve pediatric-ai-scribe by service name. Internal container port is 3000.
|
||||||
NETWORK="${E2E_NETWORK:-ped-ai_default}"
|
NETWORK="${E2E_NETWORK:-ped-ai_default}"
|
||||||
|
|
|
||||||
179
scripts/lint-references.js
Executable file
179
scripts/lint-references.js
Executable file
|
|
@ -0,0 +1,179 @@
|
||||||
|
#!/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
|
||||||
|
'corrections-', // corrections list
|
||||||
|
'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);
|
||||||
Loading…
Reference in a new issue