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

+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);
}