diff --git a/AGENTS.md b/AGENTS.md
index 6239f8fa..529aa432 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -249,6 +249,58 @@ _No reservations currently active._
---
+## 13. Code Health Rules (CI-Enforced)
+
+Three automated lint checks protect the codebase from common growth problems. These run in CI via `npm run lint` and will block your PR if violated.
+
+### File Size Limits
+
+**Script:** `npm run lint:file-size` (`scripts/check-file-size.mjs`)
+
+- New `.js` files under `js/` must stay **under 1,000 lines**.
+- Existing oversized files are grandfathered with a recorded line count. They must **not grow** beyond their recorded count + 50 lines.
+- If your change would push a grandfathered file over its limit, **extract logic into a new module first**, then make your change.
+
+**Decomposition strategy for agents:**
+1. Identify a cohesive block of logic (e.g., a group of related methods, a subsection of a controller).
+2. Extract it into a new file in the same directory.
+3. Export the extracted functions/class.
+4. Import and re-wire from the original file.
+5. Run `npm run lint:file-size` to confirm the original file shrank.
+6. Update the grandfathered count if needed (the maintainer will handle this when merging).
+
+### innerHTML Baseline
+
+**Script:** `npm run lint:innerhtml` (`scripts/check-innerhtml.mjs`)
+
+- Every file has a **baseline** innerHTML assignment count. Adding new `innerHTML` usage beyond the baseline fails the lint.
+- For new UI code, use **safe DOM APIs** instead:
+ ```javascript
+ // PREFERRED: auto-escaped, no XSS risk
+ element.textContent = userProvidedString;
+ const el = document.createElement("div");
+ el.className = "card";
+ parent.appendChild(el);
+
+ // ACCEPTABLE: innerHTML with escapeHtml for all user data
+ import { escapeHtml } from "../utils/domUtils.js";
+ container.innerHTML = `${escapeHtml(title)}`;
+
+ // BLOCKED: innerHTML with unescaped user data
+ container.innerHTML = `${title}`; // XSS vulnerability
+ ```
+- If you must add innerHTML (e.g., loading a view template), update the baseline: `node scripts/check-innerhtml.mjs --update` and include the updated BASELINE in your PR.
+
+### Constants — No Duplication
+
+- Relay timing constants live in **`js/nostr/relayConstants.js`** — always import from there.
+- Before defining a new numeric constant, search for existing ones with the same value. If a match exists, import it.
+- Feature flags live in **`js/constants.js`**.
+- Cache policies live in **`js/nostr/cachePolicies.js`**.
+- Do not define the same constant in two files. If two modules need the same value, create a shared module and import from it.
+
+---
+
## Next
Please read these documents next.
diff --git a/docs/scheduled-agent-prompts.md b/docs/scheduled-agent-prompts.md
new file mode 100644
index 00000000..ee342e7c
--- /dev/null
+++ b/docs/scheduled-agent-prompts.md
@@ -0,0 +1,115 @@
+# Scheduled Agent Prompts
+
+Copy-paste these prompts to schedule recurring maintenance tasks for AI agents.
+Each prompt is self-contained and targets one specific problem. Run them against
+the `unstable` branch.
+
+---
+
+## 1. File Decomposition (run weekly or biweekly)
+
+**Best agent:** Claude Code (needs full codebase context for safe refactoring)
+
+```
+Target the `unstable` branch. Run `node scripts/check-file-size.mjs --report`
+to see all grandfathered oversized files and their line counts.
+
+Pick the SINGLE largest grandfathered file that has not been decomposed recently.
+Do NOT try to decompose multiple files in one PR.
+
+For the chosen file:
+1. Read the entire file and identify 2-3 cohesive blocks of logic that can be
+ extracted into separate modules (look for: groups of related helper functions,
+ self-contained class methods that could become a separate service, rendering
+ logic that could become its own controller).
+2. Extract each block into a new file in the same directory.
+3. Export the extracted code and import it from the original file.
+4. Run `npm run lint` and `npm run test:unit` to verify nothing broke.
+5. Update the grandfathered line count in `scripts/check-file-size.mjs` to
+ reflect the new (smaller) size of the original file.
+6. Commit with message prefix `[decompose]` and create a PR targeting `unstable`.
+
+Goal: reduce the file by at least 200 lines per session. Do not change any
+behavior — this is a pure extraction refactor.
+```
+
+---
+
+## 2. innerHTML Migration (run weekly or biweekly)
+
+**Best agent:** Claude Code or Codex (scoped to individual files)
+
+```
+Target the `unstable` branch. Run `node scripts/check-innerhtml.mjs --report`
+to see all files with innerHTML usage and their counts.
+
+Pick ONE file from the list (prefer files with the highest count or files
+tagged RISKY in the audit). Do NOT try to migrate multiple files at once.
+
+For the chosen file:
+1. Read the file and find all innerHTML assignments.
+2. For each assignment, determine if it can be replaced with safe DOM APIs:
+ - Static text: use `element.textContent = "..."`.
+ - Creating elements: use `document.createElement()` + `appendChild()`.
+ - Template HTML with user data: ensure ALL interpolated values pass through
+ `escapeHtml()` from `js/utils/domUtils.js`.
+3. Replace as many innerHTML assignments as possible with safe alternatives.
+4. Run `npm run lint` and `npm run test:unit` to verify nothing broke.
+5. Update the baseline: run `node scripts/check-innerhtml.mjs --update` and
+ copy the new BASELINE object into `scripts/check-innerhtml.mjs`.
+6. Commit with message prefix `[security]` and create a PR targeting `unstable`.
+
+Priority order for migration:
+- RISKY files first (user data without escaping)
+- High-count files next (profileModalController, searchView, channelProfile)
+- Low-count static files last (these are low risk)
+```
+
+---
+
+## 3. Constant Deduplication Audit (run monthly)
+
+**Best agent:** Any agent (simple search task)
+
+```
+Target the `unstable` branch.
+
+Search for numeric constants in `js/` that appear in multiple files with
+the same value and similar purpose. Focus on:
+- Timeout values (5000, 10000, 30000, 60000, etc.)
+- Retry counts
+- Cache TTLs
+- Threshold values
+
+For each duplicate found:
+1. Determine the canonical location (check `js/constants.js`,
+ `js/nostr/relayConstants.js`, `js/nostr/cachePolicies.js`).
+2. If a canonical location exists, replace the duplicate with an import.
+3. If no canonical location exists, create one in the most appropriate
+ shared module.
+4. Run `npm run lint` and `npm run test:unit`.
+5. Commit with message prefix `[constants]` and create a PR targeting `unstable`.
+```
+
+---
+
+## 4. Code Health Dashboard (run weekly)
+
+**Best agent:** Any agent (read-only audit)
+
+```
+Target the `unstable` branch. Run all three audit scripts in report mode:
+
+1. `node scripts/check-file-size.mjs --report`
+2. `node scripts/check-innerhtml.mjs --report`
+3. `npm run lint`
+
+Report the following metrics:
+- Total grandfathered oversized files and sum of excess lines
+- Total innerHTML assignments across all files
+- Any lint failures
+
+Compare against the previous week's report (if available in issues/PRs).
+Post the results as a GitHub issue comment or a summary in the PR description
+so the maintainer can track progress.
+```
diff --git a/js/nostr/client.js b/js/nostr/client.js
index 4bcfcdad..276e1912 100644
--- a/js/nostr/client.js
+++ b/js/nostr/client.js
@@ -274,17 +274,19 @@ function shouldRequestExtensionPermissions(signer) {
return signer.type === "extension";
}
-const RELAY_CONNECT_TIMEOUT_MS = 5000;
-const RELAY_RECONNECT_BASE_DELAY_MS = 2000;
-const RELAY_RECONNECT_MAX_DELAY_MS = 60000;
-const RELAY_RECONNECT_MAX_ATTEMPTS = 5;
-const RELAY_BACKOFF_BASE_DELAY_MS = 1000;
-const RELAY_BACKOFF_MAX_DELAY_MS = 8000;
-const RELAY_CIRCUIT_BREAKER_THRESHOLD = 3;
-const RELAY_CIRCUIT_BREAKER_COOLDOWN_MS = 10 * 60 * 1000;
-const RELAY_FAILURE_WINDOW_MS = 5 * 60 * 1000;
-const RELAY_FAILURE_WINDOW_THRESHOLD = 3;
-const RELAY_SUMMARY_LOG_INTERVAL_MS = 30000;
+import {
+ RELAY_CONNECT_TIMEOUT_MS,
+ RELAY_RECONNECT_BASE_DELAY_MS,
+ RELAY_RECONNECT_MAX_DELAY_MS,
+ RELAY_RECONNECT_MAX_ATTEMPTS,
+ RELAY_BACKOFF_BASE_DELAY_MS,
+ RELAY_BACKOFF_MAX_DELAY_MS,
+ RELAY_CIRCUIT_BREAKER_THRESHOLD,
+ RELAY_CIRCUIT_BREAKER_COOLDOWN_MS,
+ RELAY_FAILURE_WINDOW_MS,
+ RELAY_FAILURE_WINDOW_THRESHOLD,
+ RELAY_SUMMARY_LOG_INTERVAL_MS,
+} from "./relayConstants.js";
const EVENTS_CACHE_STORAGE_KEY = "bitvid:eventsCache:v1";
// We use the policy TTL, but currently the storage backend is hardcoded to IDB (with localStorage fallback).
// Future refactors should make EventsCacheStore dynamic based on CACHE_POLICIES[NOTE_TYPES.VIDEO_POST].storage.
diff --git a/js/nostr/managers/ConnectionManager.js b/js/nostr/managers/ConnectionManager.js
index 947e5472..85ce904f 100644
--- a/js/nostr/managers/ConnectionManager.js
+++ b/js/nostr/managers/ConnectionManager.js
@@ -13,18 +13,19 @@ import {
logCountTimeoutCleanupFailure,
logRelayCountFailure,
} from "../countDiagnostics.js";
-
-const RELAY_CONNECT_TIMEOUT_MS = 5000;
-const RELAY_RECONNECT_BASE_DELAY_MS = 2000;
-const RELAY_RECONNECT_MAX_DELAY_MS = 60000;
-const RELAY_RECONNECT_MAX_ATTEMPTS = 5;
-const RELAY_BACKOFF_BASE_DELAY_MS = 1000;
-const RELAY_BACKOFF_MAX_DELAY_MS = 8000;
-const RELAY_CIRCUIT_BREAKER_THRESHOLD = 3;
-const RELAY_CIRCUIT_BREAKER_COOLDOWN_MS = 10 * 60 * 1000;
-const RELAY_FAILURE_WINDOW_MS = 5 * 60 * 1000;
-const RELAY_FAILURE_WINDOW_THRESHOLD = 3;
-const RELAY_SUMMARY_LOG_INTERVAL_MS = 30000;
+import {
+ RELAY_CONNECT_TIMEOUT_MS,
+ RELAY_RECONNECT_BASE_DELAY_MS,
+ RELAY_RECONNECT_MAX_DELAY_MS,
+ RELAY_RECONNECT_MAX_ATTEMPTS,
+ RELAY_BACKOFF_BASE_DELAY_MS,
+ RELAY_BACKOFF_MAX_DELAY_MS,
+ RELAY_CIRCUIT_BREAKER_THRESHOLD,
+ RELAY_CIRCUIT_BREAKER_COOLDOWN_MS,
+ RELAY_FAILURE_WINDOW_MS,
+ RELAY_FAILURE_WINDOW_THRESHOLD,
+ RELAY_SUMMARY_LOG_INTERVAL_MS,
+} from "../relayConstants.js";
function withRequestTimeout(promise, timeoutMs, onTimeout, message = "Request timed out") {
const resolvedTimeout = Number(timeoutMs);
diff --git a/js/nostr/relayConstants.js b/js/nostr/relayConstants.js
new file mode 100644
index 00000000..7701095c
--- /dev/null
+++ b/js/nostr/relayConstants.js
@@ -0,0 +1,15 @@
+// js/nostr/relayConstants.js
+// Canonical relay timing and circuit-breaker constants.
+// Import from here — do NOT duplicate these values in other modules.
+
+export const RELAY_CONNECT_TIMEOUT_MS = 5000;
+export const RELAY_RECONNECT_BASE_DELAY_MS = 2000;
+export const RELAY_RECONNECT_MAX_DELAY_MS = 60000;
+export const RELAY_RECONNECT_MAX_ATTEMPTS = 5;
+export const RELAY_BACKOFF_BASE_DELAY_MS = 1000;
+export const RELAY_BACKOFF_MAX_DELAY_MS = 8000;
+export const RELAY_CIRCUIT_BREAKER_THRESHOLD = 3;
+export const RELAY_CIRCUIT_BREAKER_COOLDOWN_MS = 10 * 60 * 1000;
+export const RELAY_FAILURE_WINDOW_MS = 5 * 60 * 1000;
+export const RELAY_FAILURE_WINDOW_THRESHOLD = 3;
+export const RELAY_SUMMARY_LOG_INTERVAL_MS = 30000;
diff --git a/package.json b/package.json
index c86c1015..3f1c005b 100644
--- a/package.json
+++ b/package.json
@@ -16,7 +16,9 @@
"ci:beacon": "npm run build:beacon",
"format": "prettier --cache --write css/tailwind.source.css css/tokens.css css/docs.css \"docs/**/*.html\" tailwind.config.cjs README.md CONTRIBUTING.md",
"audit": "node scripts/daily-design-system-audit.mjs",
- "lint": "npm run lint:css && npm run lint:hex && npm run lint:inline-styles && npm run lint:tokens && npm run lint:tailwind-brackets && npm run lint:tailwind-colors",
+ "lint": "npm run lint:css && npm run lint:hex && npm run lint:inline-styles && npm run lint:tokens && npm run lint:tailwind-brackets && npm run lint:tailwind-colors && npm run lint:file-size && npm run lint:innerhtml",
+ "lint:file-size": "node scripts/check-file-size.mjs",
+ "lint:innerhtml": "node scripts/check-innerhtml.mjs",
"lint:css": "stylelint css/tailwind.source.css css/tokens.css css/docs.css",
"lint:hex": "node scripts/check-hex.js",
"lint:inline-styles": "node scripts/check-inline-styles.mjs",
diff --git a/scripts/check-file-size.mjs b/scripts/check-file-size.mjs
new file mode 100644
index 00000000..20c3f3dd
--- /dev/null
+++ b/scripts/check-file-size.mjs
@@ -0,0 +1,143 @@
+#!/usr/bin/env node
+
+/**
+ * Lint script: enforce maximum file size for JavaScript source files.
+ *
+ * Files that already exceed the threshold are grandfathered in an allowlist.
+ * The allowlist records each file's line count at the time it was added.
+ * A grandfathered file may NOT grow — if its current line count exceeds the
+ * recorded count, the check fails. New files that exceed the threshold
+ * without an allowlist entry also fail.
+ *
+ * Usage:
+ * node scripts/check-file-size.mjs # enforce (exit 1 on failure)
+ * node scripts/check-file-size.mjs --report # report only (exit 0)
+ */
+
+import { readFileSync, readdirSync, statSync } from "node:fs";
+import { join, relative } from "node:path";
+
+const ROOT = new URL("..", import.meta.url).pathname.replace(/\/$/, "");
+const SOURCE_DIR = join(ROOT, "js");
+const THRESHOLD = 1000; // lines — new files must stay under this
+const GROWTH_MARGIN = 50; // lines — allowlisted files may grow by this much for minor fixes
+
+// Grandfathered files: path relative to repo root → line count when recorded.
+// These files are known to be oversized and are decomposition targets.
+// They must NOT grow beyond (recorded + GROWTH_MARGIN).
+const GRANDFATHERED = {
+ "js/ui/profileModalController.js": 14159,
+ "js/ui/components/VideoModal.js": 6284,
+ "js/app.js": 5602,
+ "js/channelProfile.js": 5529,
+ "js/nostr/client.js": 4356,
+ "js/ui/components/VideoCard.js": 3129,
+ "js/nostrEventSchemas.js": 2906,
+ "js/ui/loginModalController.js": 2552,
+ "js/historyView.js": 2545,
+ "js/services/moderationService.js": 2512,
+ "js/subscriptions.js": 2374,
+ "js/userBlocks.js": 2297,
+ "js/services/nostrService.js": 2227,
+ "js/nostr/watchHistory.js": 2211,
+ "js/nostr/nip46Client.js": 2102,
+ "js/state/cache.js": 1827,
+ "js/app/feedCoordinator.js": 1767,
+ "js/app/authSessionCoordinator.js": 1743,
+ "js/ui/components/RevertModal.js": 1663,
+ "js/payments/nwcClient.js": 1642,
+ "js/ui/moreMenuController.js": 1601,
+ "js/nostr/commentEvents.js": 1571,
+ "js/nostr/nip71.js": 1531,
+ "js/services/hashtagPreferencesService.js": 1434,
+ "js/nostr/publishHelpers.js": 1415,
+ "js/index.js": 1412,
+ "js/watchHistoryService.js": 1395,
+ "js/app/playbackCoordinator.js": 1376,
+ "js/feedEngine/stages.js": 1370,
+ "js/ui/views/VideoListView.js": 1359,
+ "js/ui/components/UploadModal.js": 1319,
+ "js/ui/components/SimilarContentCard.js": 1303,
+ "js/services/playbackService.js": 1289,
+ "js/services/authService.js": 1276,
+ "js/ui/zapController.js": 1206,
+ "js/services/r2Service.js": 1194,
+ "js/ui/applicationBootstrap.js": 1163,
+ "js/ui/components/EditModal.js": 1130,
+ "js/services/commentThreadService.js": 1122,
+ "js/nostr/managers/SignerManager.js": 1106,
+ "js/nostr/nip46Connector.js": 1094,
+ "js/ui/overlay/popoverEngine.js": 1070,
+ "js/adminListStore.js": 1033,
+};
+
+function collectJsFiles(dir) {
+ const results = [];
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
+ const full = join(dir, entry.name);
+ if (entry.isDirectory()) {
+ results.push(...collectJsFiles(full));
+ } else if (entry.isFile() && entry.name.endsWith(".js")) {
+ results.push(full);
+ }
+ }
+ return results;
+}
+
+function countLines(filePath) {
+ const content = readFileSync(filePath, "utf8");
+ return content.split("\n").length;
+}
+
+const reportOnly = process.argv.includes("--report");
+const files = collectJsFiles(SOURCE_DIR);
+const violations = [];
+const warnings = [];
+
+for (const filePath of files) {
+ const relPath = relative(ROOT, filePath);
+ const lines = countLines(filePath);
+
+ if (relPath in GRANDFATHERED) {
+ const recorded = GRANDFATHERED[relPath];
+ const limit = recorded + GROWTH_MARGIN;
+ if (lines > limit) {
+ violations.push(
+ `GREW: ${relPath} (${lines} lines, was ${recorded}, limit ${limit})`,
+ );
+ } else if (lines > THRESHOLD) {
+ warnings.push(`grandfathered: ${relPath} (${lines} lines)`);
+ }
+ } else if (lines > THRESHOLD) {
+ violations.push(`NEW: ${relPath} (${lines} lines, threshold ${THRESHOLD})`);
+ }
+}
+
+if (warnings.length > 0 && (reportOnly || violations.length > 0)) {
+ console.log(`\n${warnings.length} grandfathered oversized file(s):`);
+ for (const w of warnings) {
+ console.log(` ⚠ ${w}`);
+ }
+}
+
+if (violations.length > 0) {
+ console.error(`\n${violations.length} file size violation(s):`);
+ for (const v of violations) {
+ console.error(` ✗ ${v}`);
+ }
+ console.error(
+ `\nNew files must stay under ${THRESHOLD} lines. Grandfathered files must not grow.`,
+ );
+ console.error(
+ "To decompose a large file, extract logic into smaller modules and re-export.",
+ );
+ if (!reportOnly) {
+ process.exit(1);
+ }
+} else {
+ if (reportOnly && warnings.length > 0) {
+ console.log(
+ `\nNo violations. ${warnings.length} grandfathered file(s) remain as decomposition targets.`,
+ );
+ }
+}
diff --git a/scripts/check-innerhtml.mjs b/scripts/check-innerhtml.mjs
new file mode 100644
index 00000000..3996f0e1
--- /dev/null
+++ b/scripts/check-innerhtml.mjs
@@ -0,0 +1,180 @@
+#!/usr/bin/env node
+
+/**
+ * Lint script: audit innerHTML usage in JavaScript source files.
+ *
+ * Maintains a baseline count of innerHTML assignments per file. If a file
+ * introduces NEW innerHTML usage beyond its baseline, the check fails.
+ * This enforces a "no new innerHTML" policy while grandfathering existing usage.
+ *
+ * Usage:
+ * node scripts/check-innerhtml.mjs # enforce (exit 1 on new usage)
+ * node scripts/check-innerhtml.mjs --report # report all usage (exit 0)
+ * node scripts/check-innerhtml.mjs --update # update baseline to current counts
+ */
+
+import { readFileSync, writeFileSync, readdirSync } from "node:fs";
+import { join, relative } from "node:path";
+
+const ROOT = new URL("..", import.meta.url).pathname.replace(/\/$/, "");
+const SOURCE_DIR = join(ROOT, "js");
+
+// Baseline: file path (relative to repo root) → known innerHTML assignment count.
+// Generated with --update flag. Files not listed have a baseline of 0.
+const BASELINE = {
+ "js/app.js": 1,
+ "js/app/feedCoordinator.js": 1,
+ "js/channelProfile.js": 10,
+ "js/docsView.js": 8,
+ "js/exploreView.js": 1,
+ "js/forYouView.js": 1,
+ "js/historyView.js": 4,
+ "js/index.js": 1,
+ "js/kidsView.js": 1,
+ "js/searchView.js": 12,
+ "js/subscriptions.js": 7,
+ "js/ui/components/DeleteModal.js": 3,
+ "js/ui/components/EditModal.js": 1,
+ "js/ui/components/EmbedVideoModal.js": 1,
+ "js/ui/components/EventDetailsModal.js": 1,
+ "js/ui/components/nip71FormManager.js": 1,
+ "js/ui/components/RevertModal.js": 10,
+ "js/ui/components/ShareNostrModal.js": 3,
+ "js/ui/components/UploadModal.js": 1,
+ "js/ui/components/VideoModal.js": 1,
+ "js/ui/dm/AppShell.js": 1,
+ "js/ui/dm/Composer.js": 1,
+ "js/ui/dm/ConversationList.js": 1,
+ "js/ui/dm/DMRelaySettings.js": 1,
+ "js/ui/dm/DMSettingsModalController.js": 3,
+ "js/ui/dm/MessageThread.js": 1,
+ "js/ui/dm/NotificationCenter.js": 1,
+ "js/ui/loginModalController.js": 3,
+ "js/ui/moreMenuController.js": 1,
+ "js/ui/profileModalController.js": 27,
+ "js/ui/subscriptionHistoryController.js": 7,
+ "js/ui/videoListViewController.js": 1,
+ "js/ui/views/VideoListView.js": 3,
+ "js/utils/qrcode.js": 2,
+ "js/viewManager.js": 3,
+};
+
+// Pattern to match innerHTML assignments: .innerHTML = or .innerHTML +=
+const PATTERN = /\.innerHTML\s*[+]?=/g;
+
+function collectJsFiles(dir) {
+ const results = [];
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
+ const full = join(dir, entry.name);
+ if (entry.isDirectory()) {
+ results.push(...collectJsFiles(full));
+ } else if (entry.isFile() && entry.name.endsWith(".js")) {
+ results.push(full);
+ }
+ }
+ return results;
+}
+
+function countInnerHtmlUsage(filePath) {
+ const content = readFileSync(filePath, "utf8");
+ const matches = content.match(PATTERN);
+ return matches ? matches.length : 0;
+}
+
+function findInnerHtmlLines(filePath) {
+ const content = readFileSync(filePath, "utf8");
+ const lines = content.split("\n");
+ const results = [];
+ for (let i = 0; i < lines.length; i++) {
+ if (PATTERN.test(lines[i])) {
+ results.push(i + 1);
+ }
+ // Reset regex lastIndex since we use global flag
+ PATTERN.lastIndex = 0;
+ }
+ return results;
+}
+
+const mode = process.argv.includes("--update")
+ ? "update"
+ : process.argv.includes("--report")
+ ? "report"
+ : "enforce";
+
+const files = collectJsFiles(SOURCE_DIR);
+const violations = [];
+const currentCounts = {};
+
+for (const filePath of files) {
+ const relPath = relative(ROOT, filePath);
+ const count = countInnerHtmlUsage(filePath);
+
+ if (count > 0) {
+ currentCounts[relPath] = count;
+ }
+
+ const baseline = BASELINE[relPath] || 0;
+
+ if (count > baseline) {
+ const lineNums = findInnerHtmlLines(filePath);
+ violations.push({
+ file: relPath,
+ count,
+ baseline,
+ added: count - baseline,
+ lines: lineNums,
+ });
+ }
+}
+
+if (mode === "update") {
+ // Print updated baseline for copy-pasting into this script
+ console.log("// Updated BASELINE — copy this into check-innerhtml.mjs:");
+ console.log("const BASELINE = {");
+ const sorted = Object.entries(currentCounts).sort(([a], [b]) =>
+ a.localeCompare(b),
+ );
+ for (const [file, count] of sorted) {
+ console.log(` "${file}": ${count},`);
+ }
+ console.log("};");
+ process.exit(0);
+}
+
+if (mode === "report") {
+ const total = Object.values(currentCounts).reduce((a, b) => a + b, 0);
+ console.log(`innerHTML usage: ${total} assignments across ${Object.keys(currentCounts).length} files\n`);
+ const sorted = Object.entries(currentCounts).sort(([, a], [, b]) => b - a);
+ for (const [file, count] of sorted) {
+ const base = BASELINE[file] || 0;
+ const status = count > base ? " ← NEW" : "";
+ console.log(` ${file}: ${count}${status}`);
+ }
+ process.exit(0);
+}
+
+// Enforce mode
+if (violations.length > 0) {
+ console.error(`${violations.length} file(s) introduced new innerHTML usage:\n`);
+ for (const v of violations) {
+ console.error(
+ ` ✗ ${v.file}: ${v.count} total (baseline ${v.baseline}, +${v.added} new) at line(s) ${v.lines.join(", ")}`,
+ );
+ }
+ console.error(
+ "\nUse safe DOM APIs instead of innerHTML for new code:",
+ );
+ console.error(
+ " element.textContent = text; // plain text (auto-escaped) ",
+ );
+ console.error(
+ " element.appendChild(doc.createElement()); // structured DOM building",
+ );
+ console.error(
+ "\nIf innerHTML is truly needed, use escapeHtml() for all interpolated values.",
+ );
+ console.error(
+ "To update the baseline after intentional changes: node scripts/check-innerhtml.mjs --update",
+ );
+ process.exit(1);
+}