← 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}`);