← back to Abrams
scripts/mag-20-audit.js
169 lines
#!/usr/bin/env node
// mag-20-audit.js — runs each of the 20 standing-rule patterns against every project.
// Emits data/mag-20-results.json with pass/fail/n-a per (project × pattern).
// Read-only.
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
const ROOT = path.resolve(__dirname, '..');
const HOME = process.env.HOME;
const PROJECTS_DIR = path.join(HOME, 'Projects');
const PATTERNS = JSON.parse(fs.readFileSync(path.join(ROOT, 'data', 'mag-20.json'), 'utf8')).patterns;
const PROJECTS = JSON.parse(fs.readFileSync(path.join(ROOT, 'data', 'projects.json'), 'utf8')).projects;
const OUT = path.join(ROOT, 'data', 'mag-20-results.json');
const LOG = path.join(ROOT, 'data', 'build-log.jsonl');
const log = (type, payload) => fs.appendFileSync(LOG, JSON.stringify({ ts: new Date().toISOString(), type, ...payload }) + '\n');
const tryExec = (cmd) => {
try { return execSync(cmd, { encoding: 'utf8', stdio: ['pipe','pipe','ignore'] }).trim(); } catch { return null; }
};
const tryRead = (p) => { try { return fs.readFileSync(p, 'utf8'); } catch { return null; } };
const htmlFiles = (dir) => {
try { return fs.readdirSync(path.join(dir, 'public')).filter(f => f.endsWith('.html')).map(f => path.join(dir, 'public', f)); }
catch { return []; }
};
function audit(project, pattern) {
const dir = project.path;
if (!dir || !fs.existsSync(dir)) return { status: 'n/a', evidence: 'no local path' };
const htmls = htmlFiles(dir);
switch (pattern.id) {
case 1: { // logo UL + hamburger UR
if (!htmls.length) return { status: 'n/a', evidence: 'no html files' };
const failing = htmls.filter(f => { const c = tryRead(f) || ''; return !/\.chrome[\s\S]*\.logo|class="logo"/.test(c) || !/nav-burger|\.hamburger/.test(c); });
return { status: failing.length === 0 ? 'pass' : 'fail', evidence: failing.length ? `${failing.length}/${htmls.length} missing chrome` : `${htmls.length} pages compliant` };
}
case 2: { // Gucci ≡ MENU
if (!htmls.length) return { status: 'n/a', evidence: 'no html files' };
const failing = htmls.filter(f => !/\bMENU\b/.test(tryRead(f) || ''));
return { status: failing.length === 0 ? 'pass' : 'fail', evidence: failing.length ? `${failing.length}/${htmls.length} missing MENU label` : 'all pages have MENU label' };
}
case 3: { // hero CTA single white-pill ENTER bottom
const idx = tryRead(path.join(dir, 'public/index.html'));
if (!idx) return { status: 'n/a', evidence: 'no index.html' };
const hasEnter = /class="cta"[^>]*>\s*ENTER/.test(idx);
const hasBottom = /\.cta\s*\{[\s\S]{0,400}bottom\s*:/.test(tryRead(path.join(dir, 'public/css/panel.css')) || '');
return { status: hasEnter && hasBottom ? 'pass' : 'fail', evidence: `enter:${hasEnter} bottom-anchored:${hasBottom}` };
}
case 4: return { status: 'n/a', evidence: 'rotating hero rule is for DW microsites, not panels' };
case 5: { // sort + density on every grid
const gridPages = htmls.filter(f => /class="grid"|id="grid"|class="ideas-grid"/.test(tryRead(f) || ''));
if (!gridPages.length) return { status: 'n/a', evidence: 'no grid pages' };
const failing = gridPages.filter(f => { const c = tryRead(f) || ''; return !/id="sort"/.test(c) || !/id="density"/.test(c); });
return { status: failing.length === 0 ? 'pass' : 'fail', evidence: failing.length ? `${failing.length}/${gridPages.length} grid pages missing sort/density` : `${gridPages.length} grid pages compliant` };
}
case 6: { // corner-nav.js
if (!htmls.length) return { status: 'n/a', evidence: 'no html files' };
const failing = htmls.filter(f => !/corner-nav\.js/.test(tryRead(f) || ''));
return { status: failing.length === 0 ? 'pass' : 'fail', evidence: failing.length ? `${failing.length}/${htmls.length} missing corner-nav.js` : 'all pages have corner-nav' };
}
case 7: { // ns-modal CSS when nsContactOpen used
const usesModal = htmls.filter(f => /nsContactOpen\(\)/.test(tryRead(f) || ''));
if (!usesModal.length) return { status: 'n/a', evidence: 'no contact modal trigger' };
const css = tryRead(path.join(dir, 'public/css/panel.css')) || '';
return { status: /\.ns-modal/.test(css) ? 'pass' : 'fail', evidence: /\.ns-modal/.test(css) ? 'CSS present' : 'modal trigger without CSS — fanout fix needed' };
}
case 8: return { status: 'n/a', evidence: 'customer-chat rule (lower-LEFT woman avatar) is DW-customer-facing only' };
case 9: return { status: 'n/a', evidence: 'chip rule applies to DW catalog grids' };
case 10: { // viewport meta tag
if (!htmls.length) return { status: 'n/a', evidence: 'no html files' };
const failing = htmls.filter(f => !/<meta\s+name=["']viewport["']/i.test(tryRead(f) || ''));
return { status: failing.length === 0 ? 'pass' : 'fail', evidence: failing.length ? `${failing.length}/${htmls.length} missing viewport meta` : 'all pages have viewport' };
}
case 11: { // gitified
return { status: fs.existsSync(path.join(dir, '.git')) ? 'pass' : 'fail', evidence: fs.existsSync(path.join(dir, '.git')) ? '.git/ present' : 'no git — gitify rule violation' };
}
case 12: { // commits per success
const count = parseInt(tryExec(`cd "${dir}" && git rev-list --count HEAD 2>/dev/null`) || '0', 10);
const days = parseInt(tryExec(`cd "${dir}" && git log --format="%ci" 2>/dev/null | head -1 | head -c 10 && echo`) ? Math.max(1, Math.floor((Date.now() - new Date(tryExec(`cd "${dir}" && git log --reverse --format="%ci" 2>/dev/null | head -1`)).getTime()) / 86400000)) : 1, 10);
const ratio = days > 0 ? count / days : count;
return { status: count >= 3 ? 'pass' : (count >= 1 ? 'fail' : 'n/a'), evidence: `${count} commits over ${days} days (${ratio.toFixed(2)}/day)` };
}
case 13: { // .gitignore essentials
const gi = tryRead(path.join(dir, '.gitignore')) || '';
const required = ['node_modules', '.env', 'tmp', '*.log', '.DS_Store'];
const missing = required.filter(r => !gi.includes(r));
return { status: missing.length === 0 ? 'pass' : 'fail', evidence: missing.length ? `missing: ${missing.join(', ')}` : 'all 5 essentials present' };
}
case 14: { // no AI vendor names exposed
if (!htmls.length) return { status: 'n/a', evidence: 'no html files' };
const leaks = [];
for (const f of htmls) {
const c = tryRead(f) || '';
const m = c.match(/\b(replicate|sdxl|gemini|claude-3|gpt-4|ollama|moonshot|kimi|deepseek|qwen3)\b/i);
if (m) leaks.push(`${path.basename(f)}: ${m[0]}`);
}
return { status: leaks.length === 0 ? 'pass' : 'fail', evidence: leaks.length ? `LEAKS: ${leaks.join('; ')}` : 'no vendor leaks' };
}
case 15: { // CAN-SPAM in email
const e = tryRead(path.join(dir, 'scripts/email-hourly.sh'));
if (!e) return { status: 'n/a', evidence: 'no mailer in this project' };
const hasAddr = /\b\d{4,5}\b.{0,80}(Blvd|St|Ave|Rd|Dr|Ln|Way|Ct)/i.test(e);
const hasUnsub = /unsub|STOP|disable/i.test(e);
return { status: (hasAddr && hasUnsub) ? 'pass' : 'fail', evidence: `addr:${hasAddr} unsub:${hasUnsub}` };
}
case 16: { // mojibake guard on email subject
const e = tryRead(path.join(dir, 'scripts/email-hourly.sh'));
if (!e) return { status: 'n/a', evidence: 'no mailer in this project' };
const subjectLine = (e.match(/"subject"\s*:\s*"[^"]+"/g) || []).join('|');
return { status: !/—|–/.test(subjectLine) ? 'pass' : 'fail', evidence: !/—|–/.test(subjectLine) ? 'ASCII-safe subject' : 'em-dash or en-dash in subject' };
}
case 17: { // bind 127.0.0.1
const s = tryRead(path.join(dir, 'server.js'));
if (!s) return { status: 'n/a', evidence: 'no server.js' };
const bindsLocal = /127\.0\.0\.1|BIND.*127/.test(s);
const bindsAll = /'0\.0\.0\.0'\s*\)/.test(s);
return { status: bindsLocal && !bindsAll ? 'pass' : 'fail', evidence: bindsLocal ? '127.0.0.1 default' : (bindsAll ? '0.0.0.0 hardcoded' : 'no listen() found') };
}
case 18: { // rate-limit POST routes
const s = tryRead(path.join(dir, 'server.js'));
if (!s) return { status: 'n/a', evidence: 'no server.js' };
const posts = (s.match(/app\.post\(/g) || []).length;
const limiters = (s.match(/rateLimit|Limiter/g) || []).length;
if (posts === 0) return { status: 'n/a', evidence: 'no POST routes' };
return { status: limiters >= 1 ? 'pass' : 'fail', evidence: `${limiters} limiters / ${posts} POSTs` };
}
case 19: { // after-hours email gate
const e = tryRead(path.join(dir, 'scripts/email-hourly.sh'));
if (!e) return { status: 'n/a', evidence: 'no hourly mailer in this project' };
const hasHour = /HOUR/.test(e) && /DOW/.test(e);
const hasGate = /-gt 17|-lt 9|-gt 5/.test(e);
return { status: (hasHour && hasGate) ? 'pass' : 'fail', evidence: `HOUR-gate:${hasHour} hour-bounds:${hasGate}` };
}
case 20: { // what-landed-video for each artifact
const videos = tryExec(`ls ~/Videos/what-landed/ 2>/dev/null | wc -l | tr -d ' '`);
return { status: parseInt(videos || '0', 10) > 0 ? 'pass' : 'fail', evidence: `${videos || 0} what-landed videos exist; per-project mapping TBD` };
}
default: return { status: 'n/a', evidence: 'no auditor for this rule' };
}
}
const results = { generated_at: new Date().toISOString(), projects: [], summary: {} };
for (const proj of PROJECTS) {
const row = { slug: proj.slug, name: proj.name, status: proj.status, checks: {} };
for (const pat of PATTERNS) {
row.checks[pat.id] = audit(proj, pat);
}
row.score = Object.values(row.checks).filter(c => c.status === 'pass').length;
row.fail = Object.values(row.checks).filter(c => c.status === 'fail').length;
row.na = Object.values(row.checks).filter(c => c.status === 'n/a').length;
results.projects.push(row);
}
// summary by pattern
for (const pat of PATTERNS) {
const cells = results.projects.map(p => p.checks[pat.id].status);
results.summary[pat.id] = {
pass: cells.filter(s => s === 'pass').length,
fail: cells.filter(s => s === 'fail').length,
na: cells.filter(s => s === 'n/a').length,
};
}
fs.writeFileSync(OUT, JSON.stringify(results, null, 2));
log('mag-20-audit', { projects: results.projects.length, total_pass: results.projects.reduce((a,p)=>a+p.score,0), total_fail: results.projects.reduce((a,p)=>a+p.fail,0) });
console.log(`mag-20: ${results.projects.length} projects × 20 rules → ${OUT}`);