From 804b3323c466c5bb2bf6eaa5a42fdd4079775295 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Feb 2026 03:25:18 +0000 Subject: [PATCH] Add code health lint guards, consolidate relay constants, document agent prompts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three CI-enforced lint scripts to prevent common codebase growth problems: - lint:file-size — enforces 1000-line max for new JS files, grandfathers existing oversized files and prevents them from growing further - lint:innerhtml — tracks innerHTML usage per-file with a baseline count, blocks new innerHTML additions (guides agents toward safe DOM APIs) - Relay constants consolidated from duplicate definitions in client.js and ConnectionManager.js into shared js/nostr/relayConstants.js - AGENTS.md Section 13: documents all three rules with examples - docs/scheduled-agent-prompts.md: copy-paste prompts for recurring file decomposition, innerHTML migration, and constant dedup tasks https://claude.ai/code/session_01LzErpH9FNw4hAPsjoYb1v9 --- AGENTS.md | 52 +++++++ docs/scheduled-agent-prompts.md | 115 ++++++++++++++++ js/nostr/client.js | 24 ++-- js/nostr/managers/ConnectionManager.js | 25 ++-- js/nostr/relayConstants.js | 15 +++ package.json | 4 +- scripts/check-file-size.mjs | 143 ++++++++++++++++++++ scripts/check-innerhtml.mjs | 180 +++++++++++++++++++++++++ 8 files changed, 534 insertions(+), 24 deletions(-) create mode 100644 docs/scheduled-agent-prompts.md create mode 100644 js/nostr/relayConstants.js create mode 100644 scripts/check-file-size.mjs create mode 100644 scripts/check-innerhtml.mjs 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); +}