@nugehs/bouncer 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,169 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { buildContext, evalRule } from "./engine.js";
5
+
6
+ const here = path.dirname(fileURLToPath(import.meta.url));
7
+ const BUILTIN_DIR = path.resolve(here, "../packs");
8
+
9
+ /** Read a single pack JSON file and validate its shape minimally. */
10
+ function readPack(file) {
11
+ const pack = JSON.parse(fs.readFileSync(file, "utf8"));
12
+ if (!pack.id) throw new Error(`Pack ${file} is missing "id".`);
13
+ if (!Array.isArray(pack.rules)) throw new Error(`Pack ${pack.id} is missing a "rules" array.`);
14
+ pack._file = file;
15
+ return pack;
16
+ }
17
+
18
+ /** List every pack available to bouncer (built-in + any from extraDirs), as metadata. */
19
+ export function availablePacks(extraDirs = []) {
20
+ const dirs = [BUILTIN_DIR, ...extraDirs];
21
+ const out = [];
22
+ const seen = new Set();
23
+ for (const dir of dirs) {
24
+ let entries = [];
25
+ try {
26
+ entries = fs.readdirSync(dir).filter((f) => f.endsWith(".json"));
27
+ } catch {
28
+ continue;
29
+ }
30
+ for (const entry of entries) {
31
+ const pack = readPack(path.join(dir, entry));
32
+ if (seen.has(pack.id)) continue;
33
+ seen.add(pack.id);
34
+ out.push({
35
+ id: pack.id,
36
+ title: pack.title,
37
+ authority: pack.authority,
38
+ url: pack.url,
39
+ rules: pack.rules.length,
40
+ builtin: dir === BUILTIN_DIR,
41
+ });
42
+ }
43
+ }
44
+ return out;
45
+ }
46
+
47
+ /** Resolve the configured pack ids to loaded pack objects. */
48
+ export function loadPacks(cfg) {
49
+ const dirs = [BUILTIN_DIR, ...(cfg.packDirs || [])];
50
+ const wanted = cfg.packs && cfg.packs.length ? cfg.packs : null;
51
+ const byId = new Map();
52
+
53
+ for (const dir of dirs) {
54
+ let entries = [];
55
+ try {
56
+ entries = fs.readdirSync(dir).filter((f) => f.endsWith(".json"));
57
+ } catch {
58
+ continue;
59
+ }
60
+ for (const entry of entries) {
61
+ const pack = readPack(path.join(dir, entry));
62
+ if (!byId.has(pack.id)) byId.set(pack.id, pack);
63
+ }
64
+ }
65
+
66
+ if (!wanted) return [...byId.values()];
67
+
68
+ const resolved = [];
69
+ for (const id of wanted) {
70
+ const pack = byId.get(id);
71
+ if (!pack) throw new Error(`Pack not found: ${id}. Run \`bouncer packs\` to list available packs.`);
72
+ resolved.push(pack);
73
+ }
74
+ return resolved;
75
+ }
76
+
77
+ /** Run every rule in the configured packs against the target repo. */
78
+ export function runCheck(cfg) {
79
+ const ctx = buildContext(cfg);
80
+ const packs = loadPacks(cfg);
81
+ const ignore = new Set(cfg.ignore || []);
82
+
83
+ const findings = [];
84
+ for (const pack of packs) {
85
+ const meta = { id: pack.id, title: pack.title, authority: pack.authority };
86
+ for (const rule of pack.rules) {
87
+ if (ignore.has(rule.id)) continue;
88
+ findings.push(evalRule(rule, ctx, meta));
89
+ }
90
+ }
91
+
92
+ const totals = { pass: 0, fail: 0, unknown: 0 };
93
+ for (const f of findings) totals[f.status] += 1;
94
+
95
+ const checked = findings.length;
96
+ const score = checked ? Math.round((totals.pass / checked) * 100) : 100;
97
+
98
+ return {
99
+ findings,
100
+ totals,
101
+ score,
102
+ meta: {
103
+ adapter: cfg.target.adapter,
104
+ repo: ctx.root,
105
+ filesScanned: ctx.files.length,
106
+ packs: packs.map((p) => ({ id: p.id, title: p.title, authority: p.authority })),
107
+ },
108
+ };
109
+ }
110
+
111
+ /** Flat list of every rule the configured packs would apply (no scanning). */
112
+ export function listRules(cfg) {
113
+ const packs = loadPacks(cfg);
114
+ const rules = [];
115
+ for (const pack of packs) {
116
+ for (const rule of pack.rules) {
117
+ rules.push({
118
+ packId: pack.id,
119
+ ruleId: rule.id,
120
+ standard: rule.standard,
121
+ severity: rule.severity || "medium",
122
+ surface: typeof rule.surface === "string" ? rule.surface : undefined,
123
+ intent: rule.intent,
124
+ });
125
+ }
126
+ }
127
+ return rules;
128
+ }
129
+
130
+ /** Find a single rule across the configured packs and render how it is checked. */
131
+ export function explainRule(cfg, ruleId) {
132
+ const packs = loadPacks(cfg);
133
+ for (const pack of packs) {
134
+ const rule = pack.rules.find((r) => r.id === ruleId);
135
+ if (rule) {
136
+ return {
137
+ packId: pack.id,
138
+ packTitle: pack.title,
139
+ authority: pack.authority,
140
+ url: pack.url,
141
+ ...rule,
142
+ checks: renderAssert(rule.assert),
143
+ };
144
+ }
145
+ }
146
+ throw new Error(`Rule not found: ${ruleId}. Run \`bouncer list\` to see available rules.`);
147
+ }
148
+
149
+ /** Human-readable description of an assertion tree. */
150
+ function renderAssert(node, depth = 0) {
151
+ const pad = " ".repeat(depth);
152
+ if (node.allOf) return [`${pad}ALL of:`, ...node.allOf.flatMap((n) => renderAssert(n, depth + 1))];
153
+ if (node.anyOf) return [`${pad}ANY of:`, ...node.anyOf.flatMap((n) => renderAssert(n, depth + 1))];
154
+ if (node.not) return [`${pad}NOT:`, ...renderAssert(node.not, depth + 1)];
155
+ if (node.find) {
156
+ const where = Array.isArray(node.in) ? node.in.join(", ") : node.in || "any";
157
+ const expect = node.expect === "absent" ? "must NOT appear" : "must appear";
158
+ return [`${pad}- /${node.find}/ ${expect} in surface [${where}]`];
159
+ }
160
+ if (node.allInFile) {
161
+ const where = Array.isArray(node.in) ? node.in.join(", ") : node.in || "any";
162
+ const expect = node.expect === "absent" ? "must NOT all co-occur" : "must all co-occur";
163
+ return [
164
+ `${pad}- all of these patterns ${expect} in a single file in surface [${where}]:`,
165
+ ...node.allInFile.map((p) => `${pad} /${p}/`),
166
+ ];
167
+ }
168
+ return [`${pad}- (empty)`];
169
+ }
@@ -0,0 +1,156 @@
1
+ // Self-contained HTML compliance report (inline CSS + a little vanilla JS, no deps).
2
+ // Modelled on tieline's report: a health ring, summary cards, and per-pack control
3
+ // tables with each rule's verdict, the legal standard, and file-level evidence.
4
+
5
+ const esc = (s) =>
6
+ String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
7
+
8
+ const shortFile = (f) => {
9
+ const p = String(f).split("/");
10
+ return p.length > 2 ? p.slice(-2).join("/") : String(f);
11
+ };
12
+ const locCell = (file, line) =>
13
+ `<td class="loc" title="${esc(file)}${line ? ":" + line : ""}">${esc(shortFile(file))}${line ? ":" + line : ""}</td>`;
14
+
15
+ export function reportHtml(result, meta = {}) {
16
+ const { findings = [], totals = {}, score = 100, meta: rmeta = {} } = result;
17
+
18
+ const byPack = new Map();
19
+ for (const f of findings) {
20
+ if (!byPack.has(f.packId)) byPack.set(f.packId, []);
21
+ byPack.get(f.packId).push(f);
22
+ }
23
+
24
+ const packSections = [...byPack.values()].map((rows) => packTable(rows)).join("\n");
25
+
26
+ return `<!doctype html>
27
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
28
+ <title>bouncer · compliance report</title>
29
+ <style>${CSS}</style></head>
30
+ <body>
31
+ <header class="hero">
32
+ <div class="hero-inner">
33
+ <div class="brand"><span class="logo">⛨</span> bouncer<span class="sub">compliance report</span></div>
34
+ <div class="meta">
35
+ <span>${esc(rmeta.adapter || "adapter")} · ${esc(String(rmeta.filesScanned ?? 0))} files</span>
36
+ ${meta.generatedAt ? `<span class="dim">${esc(meta.generatedAt)}</span>` : ""}
37
+ </div>
38
+ </div>
39
+ <div class="ring" style="--p:${score}">
40
+ <svg viewBox="0 0 120 120"><circle class="track" cx="60" cy="60" r="52"/><circle class="value ${score >= 90 ? "ok" : score >= 70 ? "warn" : "bad"}" cx="60" cy="60" r="52"/></svg>
41
+ <div class="ring-label"><strong>${score}%</strong><span>controls present</span></div>
42
+ </div>
43
+ </header>
44
+
45
+ <main>
46
+ <section class="cards">
47
+ ${card("pass", totals.pass || 0, "ok", "required control found")}
48
+ ${card("fail", totals.fail || 0, "bad", "surface exists, control missing")}
49
+ ${card("unknown", totals.unknown || 0, "warn", "surface not located — can't determine")}
50
+ </section>
51
+
52
+ <p class="disclaimer">bouncer checks that the controls a regulation requires are present in code. It is an engineering aid, <strong>not legal advice</strong> and not a substitute for a compliance/DPO review.</p>
53
+
54
+ ${packSections}
55
+ </main>
56
+
57
+ <footer>Generated by <strong>bouncer</strong> · static compliance-controls checker</footer>
58
+ <script>${JS}</script>
59
+ </body></html>`;
60
+ }
61
+
62
+ function card(label, value, kind, sub) {
63
+ return `<div class="cardx ${kind}"><div class="num">${value}</div><div class="lab">${esc(label)}</div><div class="csub">${esc(sub)}</div></div>`;
64
+ }
65
+
66
+ function packTable(rows) {
67
+ const head = rows[0];
68
+ const ordered = [...rows].sort((a, b) => order(a.status) - order(b.status));
69
+ const body = ordered
70
+ .map((f) => {
71
+ const evidence =
72
+ f.status === "pass" && f.hits[0]
73
+ ? locCell(f.hits[0].file, f.hits[0].line)
74
+ : f.status === "fail" && f.hits[0]
75
+ ? locCell(f.hits[0].file, f.hits[0].line)
76
+ : `<td class="loc dim">${f.status === "unknown" ? "not located" : "—"}</td>`;
77
+ const detail =
78
+ f.status === "fail"
79
+ ? `<div class="sub">${esc(f.intent || "")}</div><div class="fix">Fix: ${esc(f.fix || "")}</div>`
80
+ : f.status === "unknown"
81
+ ? `<div class="sub dim">surface not found in this repo — can't determine</div>`
82
+ : "";
83
+ return `<tr data-status="${f.status}" data-text="${esc(f.ruleId + " " + (f.standard || ""))}">
84
+ <td class="verdict"><span class="pill ${f.status}">${esc(f.status)}</span></td>
85
+ <td class="sev ${esc(f.severity)}">${esc(f.severity)}</td>
86
+ <td class="rule"><span class="rid">${esc(f.ruleId)}</span><div class="std">${esc(f.standard || "")}</div>${detail}</td>
87
+ ${evidence}
88
+ </tr>`;
89
+ })
90
+ .join("\n");
91
+
92
+ return `<section class="panel">
93
+ <h2>${esc(head.packTitle)}</h2>
94
+ <p class="hint">${esc(head.authority || "")} &nbsp;·&nbsp; <span class="k ok">●</span> pass · <span class="k bad">●</span> fail · <span class="k warn">●</span> unknown</p>
95
+ <table class="grid">
96
+ <thead><tr><th>Verdict</th><th>Severity</th><th>Control / standard</th><th>Evidence</th></tr></thead>
97
+ <tbody>${body}</tbody>
98
+ </table>
99
+ </section>`;
100
+ }
101
+
102
+ function order(status) {
103
+ return status === "fail" ? 0 : status === "unknown" ? 1 : 2;
104
+ }
105
+
106
+ const CSS = `
107
+ :root{--bg:#0d1117;--panel:#161b22;--line:#21262d;--ink:#e6edf3;--dim:#8b949e;--ok:#2ea043;--bad:#f85149;--warn:#d29922;--mute:#6e7681}
108
+ *{box-sizing:border-box}
109
+ body{margin:0;background:var(--bg);color:var(--ink);font:14px/1.5 ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif}
110
+ .hero{display:flex;align-items:center;justify-content:space-between;gap:24px;padding:28px 32px;background:linear-gradient(135deg,#161b22,#0d1117);border-bottom:1px solid var(--line)}
111
+ .brand{font-size:22px;font-weight:700;letter-spacing:.3px}
112
+ .brand .logo{color:#58a6ff;margin-right:6px}
113
+ .brand .sub{font-size:13px;font-weight:500;color:var(--dim);margin-left:10px}
114
+ .meta{margin-top:6px;color:var(--dim);font-size:13px;display:flex;gap:14px}
115
+ .meta .dim{color:var(--mute)}
116
+ .ring{position:relative;width:120px;height:120px;flex:none}
117
+ .ring svg{transform:rotate(-90deg)}
118
+ .ring .track{fill:none;stroke:var(--line);stroke-width:10}
119
+ .ring .value{fill:none;stroke-width:10;stroke-linecap:round;stroke-dasharray:calc(var(--p)*3.27) 327}
120
+ .ring .value.ok{stroke:var(--ok)}.ring .value.warn{stroke:var(--warn)}.ring .value.bad{stroke:var(--bad)}
121
+ .ring-label{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center}
122
+ .ring-label strong{font-size:24px}.ring-label span{font-size:11px;color:var(--dim)}
123
+ main{max-width:1040px;margin:0 auto;padding:28px 32px}
124
+ .cards{display:grid;grid-template-columns:repeat(3,1fr);gap:14px;margin-bottom:18px}
125
+ .cardx{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:16px 18px}
126
+ .cardx .num{font-size:30px;font-weight:700}
127
+ .cardx .lab{text-transform:uppercase;letter-spacing:.6px;font-size:12px;color:var(--dim)}
128
+ .cardx .csub{font-size:12px;color:var(--mute);margin-top:4px}
129
+ .cardx.ok .num{color:var(--ok)}.cardx.bad .num{color:var(--bad)}.cardx.warn .num{color:var(--warn)}
130
+ .disclaimer{background:#1c2128;border:1px solid var(--line);border-radius:8px;padding:10px 14px;color:var(--dim);font-size:12.5px}
131
+ .panel{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:18px 20px;margin-top:18px}
132
+ .panel h2{margin:0 0 2px;font-size:16px}
133
+ .hint{color:var(--dim);font-size:12.5px;margin:0 0 12px}
134
+ .k{font-size:10px;vertical-align:middle}.k.ok{color:var(--ok)}.k.bad{color:var(--bad)}.k.warn{color:var(--warn)}
135
+ table.grid{width:100%;border-collapse:collapse;font-size:13px}
136
+ .grid th{text-align:left;color:var(--dim);font-weight:600;font-size:11px;text-transform:uppercase;letter-spacing:.5px;padding:6px 10px;border-bottom:1px solid var(--line)}
137
+ .grid td{padding:10px;border-bottom:1px solid var(--line);vertical-align:top}
138
+ .pill{display:inline-block;padding:2px 9px;border-radius:999px;font-size:11px;font-weight:700;text-transform:uppercase}
139
+ .pill.pass{background:rgba(46,160,67,.15);color:var(--ok)}
140
+ .pill.fail{background:rgba(248,81,73,.15);color:var(--bad)}
141
+ .pill.unknown{background:rgba(210,153,34,.15);color:var(--warn)}
142
+ .sev{font-size:12px;text-transform:capitalize;color:var(--dim)}
143
+ .sev.high{color:var(--bad)}.sev.medium{color:var(--warn)}
144
+ .rule .rid{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12.5px}
145
+ .rule .std{color:var(--dim);font-size:12px;margin-top:2px}
146
+ .rule .sub{margin-top:6px;font-size:12.5px}
147
+ .rule .fix{margin-top:3px;font-size:12px;color:#7ee787}
148
+ .loc{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;color:#79c0ff;white-space:nowrap}
149
+ .dim{color:var(--mute)}
150
+ footer{max-width:1040px;margin:0 auto;padding:20px 32px 40px;color:var(--mute);font-size:12px}
151
+ `;
152
+
153
+ const JS = `
154
+ // no-op placeholder for future filtering; report is fully static.
155
+ document.querySelectorAll('.pill').forEach(function(p){});
156
+ `;
@@ -0,0 +1,72 @@
1
+ import { printText } from "../output.js";
2
+ import { GLYPH } from "../brand.js";
3
+
4
+ const SEV_ORDER = { high: 0, medium: 1, low: 2 };
5
+
6
+ /** Terminal report grouped by pack, ordered fail -> unknown -> pass within each pack. */
7
+ export function reportHuman(result, { statusFilter = "all" } = {}) {
8
+ const { findings, totals, score, meta } = result;
9
+
10
+ printText("");
11
+ printText(` bouncer · compliance-controls report`);
12
+ printText(` ${meta.repo}`);
13
+ printText(` adapter: ${meta.adapter} · files scanned: ${meta.filesScanned}`);
14
+ printText("");
15
+
16
+ const byPack = new Map();
17
+ for (const f of findings) {
18
+ if (!byPack.has(f.packId)) byPack.set(f.packId, []);
19
+ byPack.get(f.packId).push(f);
20
+ }
21
+
22
+ for (const [, rows] of byPack) {
23
+ const head = rows[0];
24
+ printText(` ${head.packTitle}`);
25
+ printText(` ${dim(head.authority || "")}`);
26
+ const ordered = [...rows].sort(byStatusThenSeverity);
27
+ for (const f of ordered) {
28
+ if (statusFilter !== "all" && !statusMatches(f.status, statusFilter)) continue;
29
+ printText(` ${mark(f.status)} ${pad(f.severity, 6)} ${f.ruleId}`);
30
+ printText(` ${dim(f.standard || "")}`);
31
+ if (f.status === "pass" && f.hits[0]) {
32
+ printText(` evidence: ${f.hits[0].file}:${f.hits[0].line}`);
33
+ } else if (f.status === "fail") {
34
+ printText(` ${f.intent || ""}`);
35
+ printText(` fix: ${f.fix || ""}`);
36
+ for (const h of f.hits.slice(0, 3)) {
37
+ printText(` offending: ${h.file}:${h.line}`);
38
+ }
39
+ } else if (f.status === "unknown") {
40
+ printText(` surface not located in this repo — can't determine`);
41
+ }
42
+ }
43
+ printText("");
44
+ }
45
+
46
+ printText(` ${GLYPH.pass} pass ${totals.pass} ${GLYPH.fail} fail ${totals.fail} ${GLYPH.unknown} unknown ${totals.unknown} · score ${score}%`);
47
+ printText("");
48
+ }
49
+
50
+ function byStatusThenSeverity(a, b) {
51
+ const order = { fail: 0, unknown: 1, pass: 2 };
52
+ if (order[a.status] !== order[b.status]) return order[a.status] - order[b.status];
53
+ return (SEV_ORDER[a.severity] ?? 1) - (SEV_ORDER[b.severity] ?? 1);
54
+ }
55
+
56
+ function statusMatches(status, filter) {
57
+ if (filter === "fail") return status === "fail";
58
+ if (filter === "unknown") return status === "unknown" || status === "fail";
59
+ return true;
60
+ }
61
+
62
+ function mark(status) {
63
+ return status === "pass" ? GLYPH.pass : status === "fail" ? GLYPH.fail : GLYPH.unknown;
64
+ }
65
+
66
+ function pad(s, n) {
67
+ return String(s || "").padEnd(n);
68
+ }
69
+
70
+ function dim(s) {
71
+ return s;
72
+ }
@@ -0,0 +1,5 @@
1
+ import { printJson } from "../output.js";
2
+
3
+ export function reportJson(result) {
4
+ printJson(result);
5
+ }
@@ -0,0 +1,109 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ const SKIP = new Set([
5
+ "node_modules",
6
+ "dist",
7
+ ".git",
8
+ ".next",
9
+ "coverage",
10
+ ".worktrees",
11
+ ".yarn",
12
+ "build",
13
+ "out",
14
+ ]);
15
+
16
+ /**
17
+ * Recursively collect files under `dir` whose basename passes `filter`.
18
+ * Returns absolute paths. Missing directories are skipped silently.
19
+ */
20
+ export function walk(dir, filter = () => true) {
21
+ const out = [];
22
+ let entries;
23
+ try {
24
+ entries = fs.readdirSync(dir, { withFileTypes: true });
25
+ } catch {
26
+ return out;
27
+ }
28
+ for (const entry of entries) {
29
+ if (entry.name.startsWith(".") && entry.name !== ".") continue;
30
+ const full = path.join(dir, entry.name);
31
+ if (entry.isDirectory()) {
32
+ if (SKIP.has(entry.name)) continue;
33
+ out.push(...walk(full, filter));
34
+ } else if (entry.isFile() && filter(entry.name)) {
35
+ out.push(full);
36
+ }
37
+ }
38
+ return out;
39
+ }
40
+
41
+ /**
42
+ * Expand a single level of brace alternation: "a.{ts,tsx}" -> ["a.ts", "a.tsx"].
43
+ * Recurses so multiple brace groups in one pattern all expand.
44
+ */
45
+ export function expandBraces(pattern) {
46
+ const open = pattern.indexOf("{");
47
+ if (open === -1) return [pattern];
48
+ const close = pattern.indexOf("}", open);
49
+ if (close === -1) return [pattern];
50
+ const head = pattern.slice(0, open);
51
+ const tail = pattern.slice(close + 1);
52
+ const options = pattern.slice(open + 1, close).split(",");
53
+ const out = [];
54
+ for (const option of options) {
55
+ for (const expanded of expandBraces(head + option + tail)) {
56
+ out.push(expanded);
57
+ }
58
+ }
59
+ return out;
60
+ }
61
+
62
+ /** Translate a glob (supports **, *, ?, and brace groups) into a RegExp anchored to the whole path. */
63
+ export function globToRegExp(glob) {
64
+ let re = "^";
65
+ for (let i = 0; i < glob.length; ) {
66
+ const c = glob[i];
67
+ if (c === "*" && glob[i + 1] === "*") {
68
+ if (glob[i + 2] === "/") {
69
+ re += "(?:.*/)?";
70
+ i += 3;
71
+ } else {
72
+ re += ".*";
73
+ i += 2;
74
+ }
75
+ } else if (c === "*") {
76
+ re += "[^/]*";
77
+ i += 1;
78
+ } else if (c === "?") {
79
+ re += "[^/]";
80
+ i += 1;
81
+ } else if ("\\^$.|+()[]{}".includes(c)) {
82
+ re += "\\" + c;
83
+ i += 1;
84
+ } else {
85
+ re += c;
86
+ i += 1;
87
+ }
88
+ }
89
+ return new RegExp(re + "$");
90
+ }
91
+
92
+ /** True when `relPath` matches any of the provided globs (brace groups expanded first). */
93
+ export function matchesAnyGlob(relPath, globs) {
94
+ for (const glob of globs) {
95
+ for (const expanded of expandBraces(glob)) {
96
+ if (globToRegExp(expanded).test(relPath)) return true;
97
+ }
98
+ }
99
+ return false;
100
+ }
101
+
102
+ /** Line number (1-based) of a character offset within text. */
103
+ export function lineAt(text, offset) {
104
+ let line = 1;
105
+ for (let i = 0; i < offset && i < text.length; i++) {
106
+ if (text[i] === "\n") line++;
107
+ }
108
+ return line;
109
+ }
@@ -0,0 +1,109 @@
1
+ {
2
+ "id": "uk-aadc",
3
+ "title": "UK Age Appropriate Design Code (ICO Children's Code)",
4
+ "authority": "ICO — UK GDPR / Data Protection Act 2018",
5
+ "url": "https://ico.org.uk/for-organisations/uk-gdpr-guidance-and-resources/childrens-information/childrens-code-guidance-and-resources/age-appropriate-design-a-code-of-practice-for-online-services/",
6
+ "rules": [
7
+ {
8
+ "id": "aadc.age-assurance-present",
9
+ "standard": "Standard 3 — Age-appropriate application",
10
+ "severity": "high",
11
+ "surface": "signup",
12
+ "intent": "The service must establish age with a level of certainty appropriate to the risks — a real age-assurance step at sign-up, not nothing.",
13
+ "fix": "Capture date of birth or run age assurance at registration so age-appropriate treatment can be applied.",
14
+ "assert": {
15
+ "find": "date[_-]?of[_-]?birth|dateOfBirth|\\bdob\\b|birth[_-]?date|age[_-]?(gate|check|verif|assur|estimat)",
16
+ "in": ["signup", "auth"],
17
+ "expect": "present"
18
+ }
19
+ },
20
+ {
21
+ "id": "aadc.self-declared-age-insufficient",
22
+ "standard": "Standard 3 — Age-appropriate application",
23
+ "severity": "medium",
24
+ "surface": "signup",
25
+ "intent": "A bare self-declaration checkbox ('I am over 18') is not sufficient age assurance under current ICO/Ofcom expectations.",
26
+ "fix": "Replace or back the self-declared age checkbox with real age assurance (DOB + verification, estimation, or ID).",
27
+ "assert": {
28
+ "find": "type=[\"']checkbox[\"'][^>]*(age|over\\s*18|18\\s*\\+)|(over\\s*18|18\\s*\\+|i am over)[^<]{0,40}checkbox",
29
+ "in": ["signup", "auth"],
30
+ "expect": "absent"
31
+ }
32
+ },
33
+ {
34
+ "id": "aadc.high-privacy-default",
35
+ "standard": "Standard 7 — Default settings",
36
+ "severity": "high",
37
+ "surface": "profile",
38
+ "intent": "Settings must be 'high privacy' by default for children — profiles private unless the child changes it.",
39
+ "fix": "Default profile visibility to private/followers-only for accounts that may belong to a child.",
40
+ "assert": {
41
+ "allInFile": [
42
+ "visibility|profileVisibility|isPrivate|accountPrivacy",
43
+ "default|initial|defaultValue|initialState",
44
+ "private|PRIVATE|followers|true"
45
+ ],
46
+ "within": 4,
47
+ "in": ["profile"],
48
+ "expect": "present"
49
+ }
50
+ },
51
+ {
52
+ "id": "aadc.geolocation-default-off",
53
+ "standard": "Standard 10 — Geolocation",
54
+ "severity": "high",
55
+ "surface": "profile",
56
+ "intent": "Geolocation must default to off for children, and any setting that makes a child's location visible to others must default off.",
57
+ "fix": "Default any location-sharing / geolocation setting to off; switch it back off at the end of each session.",
58
+ "assert": {
59
+ "allInFile": [
60
+ "geo|location|geolocation|shareLocation|locationSharing",
61
+ "default|initial|defaultValue|initialState",
62
+ "false|off|disabled|private"
63
+ ],
64
+ "within": 4,
65
+ "in": ["profile", "any"],
66
+ "expect": "present"
67
+ }
68
+ },
69
+ {
70
+ "id": "aadc.no-nudge-techniques",
71
+ "standard": "Standard 13 — Nudge techniques",
72
+ "severity": "medium",
73
+ "surface": "any",
74
+ "intent": "Do not use nudge techniques to lead or encourage children to provide unnecessary personal data or weaken their privacy.",
75
+ "fix": "Remove copy/flows that push users to make profiles public, share location, or disable privacy to unlock features.",
76
+ "assert": {
77
+ "find": "make[^\\n<]{0,20}(profile|account)[^\\n<]{0,20}public|share[^\\n<]{0,20}location[^\\n<]{0,20}(unlock|get|earn)|turn off[^\\n<]{0,20}privacy",
78
+ "in": ["any"],
79
+ "expect": "absent"
80
+ }
81
+ },
82
+ {
83
+ "id": "aadc.parental-consent-under13",
84
+ "standard": "Standard 9 — Data minimisation / lawful basis (under 13)",
85
+ "severity": "high",
86
+ "surface": "signup",
87
+ "intent": "Where the service relies on consent for a child under 13, parental/guardian consent must be obtained.",
88
+ "fix": "Add a parental/guardian consent flow for under-13 sign-ups before processing their data.",
89
+ "assert": {
90
+ "find": "parental[_-]?consent|guardian[_-]?consent|parentEmail|guardianEmail|under[_-]?13",
91
+ "in": ["signup", "auth"],
92
+ "expect": "present"
93
+ }
94
+ },
95
+ {
96
+ "id": "aadc.dpia-exists",
97
+ "standard": "Standard 2 — Data Protection Impact Assessments",
98
+ "severity": "high",
99
+ "surface": "governance",
100
+ "intent": "A DPIA must be completed and maintained for services likely to be accessed by children.",
101
+ "fix": "Add and keep current a Data Protection Impact Assessment (DPIA) artifact in the repo or linked from it.",
102
+ "assert": {
103
+ "find": "data protection impact assessment|\\bDPIA\\b",
104
+ "in": ["governance", "any"],
105
+ "expect": "present"
106
+ }
107
+ }
108
+ ]
109
+ }