Add code health lint guards, consolidate relay constants, document agent prompts

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
This commit is contained in:
Claude committed 2026-02-10 03:25:18 +00:00
1 parent 451e983a7e
commit 804b3323c4
8 files changed
+534 -24

No files matched your search

+52
View File
@@ -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 = `<span>${escapeHtml(title)}</span>`;
// BLOCKED: innerHTML with unescaped user data
container.innerHTML = `<span>${title}</span>`; // 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.
+115
View File
@@ -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.
```
+13 -11
View File
@@ -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.
+13 -12
View File
@@ -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);
+15
View File
@@ -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;
+3 -1
View File
@@ -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",
+143
View File
@@ -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.`,
);
}
}
+180
View File
@@ -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);
}