arkgate 2.5.0 → 2.6.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,1301 @@
1
+ /**
2
+ * Enforcement detection + HTML architecture reports (roadmap #11).
3
+ */
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import {
7
+ arkCommand,
8
+ detectPackageManager,
9
+ enrichViolationWithFixClass,
10
+ patternSpecificity,
11
+ resolveOperatingMode,
12
+ } from '../ark-shared.mjs';
13
+ import { collectAdoptionGaps, arkCheckCommand } from './agent-gates.mjs';
14
+ import { FIX_HINTS } from './violations.mjs';
15
+
16
+ export function detectEnforcement(root) {
17
+ const has = (rel) => fs.existsSync(path.join(root, rel));
18
+ const fileIncludes = (rel, needle) => {
19
+ try {
20
+ return fs.readFileSync(path.join(root, rel), 'utf8').includes(needle);
21
+ } catch {
22
+ return false;
23
+ }
24
+ };
25
+ const workflowsMentionArk = () => {
26
+ const dir = path.join(root, '.github', 'workflows');
27
+ if (!fs.existsSync(dir)) return null;
28
+ const hit = fs
29
+ .readdirSync(dir)
30
+ .filter((f) => /\.ya?ml$/.test(f))
31
+ .find((f) => fileIncludes(path.join('.github', 'workflows', f), 'ark-check'));
32
+ return hit ? `.github/workflows/${hit}` : null;
33
+ };
34
+ const eslintFile = ['eslint.config.mjs', 'eslint.config.js', 'eslint.config.cjs', '.eslintrc.json', '.eslintrc.cjs'].find(
35
+ (f) => has(f) && (fileIncludes(f, 'arkgate') || fileIncludes(f, 'ark-runtime-kernel'))
36
+ );
37
+ const writeGateFile =
38
+ ((fileIncludes('.claude/settings.json', 'arkgate-mcp') ||
39
+ fileIncludes('.claude/settings.json', 'ark-mcp')) &&
40
+ '.claude/settings.json') ||
41
+ (has('.cursor/mcp.json') && '.cursor/mcp.json') ||
42
+ (fileIncludes('.grok/hooks/ark-write-gate.json', 'arkgate-mcp') &&
43
+ '.grok/hooks/ark-write-gate.json') ||
44
+ null;
45
+ return [
46
+ { name: 'Write gate', where: writeGateFile, what: 'blocks a bad edit as you type (PreToolUse hook / MCP)' },
47
+ { name: 'ESLint', where: eslintFile || null, what: 'flags violations in your editor' },
48
+ { name: 'CI check', where: workflowsMentionArk(), what: 'blocks the merge if the architecture breaks' },
49
+ { name: 'Baseline', where: has('.ark-baseline.json') ? '.ark-baseline.json' : null, what: 'old violations frozen; new ones fail' },
50
+ ].map((e) => ({ ...e, on: !!e.where }));
51
+ }
52
+
53
+ export function htmlEscape(value) {
54
+ return String(value)
55
+ .replace(/&/g, '&')
56
+ .replace(/</g, '&lt;')
57
+ .replace(/>/g, '&gt;')
58
+ .replace(/"/g, '&quot;');
59
+ }
60
+
61
+ /** Directory for origin / latest / history architecture report snapshots. */
62
+ const ARK_REPORTS_DIR = path.join('.ark', 'reports');
63
+ const ARK_REPORT_HISTORY_MAX = 20;
64
+
65
+ export function reportsDir(root) {
66
+ return path.join(root, ARK_REPORTS_DIR);
67
+ }
68
+
69
+ /**
70
+ * Compact metrics snapshot — machine-readable so future reports can diff against origin.
71
+ * Intentionally small (not the full HTML). Layer file counts included for evolution.
72
+ */
73
+ export function buildReportSnapshot({
74
+ root,
75
+ config,
76
+ coverage,
77
+ violations,
78
+ ok,
79
+ suppressed,
80
+ version,
81
+ fileCountByLayer,
82
+ enforcement,
83
+ score,
84
+ mode,
85
+ }) {
86
+ const layers = Array.isArray(config?.layers) ? config.layers : [];
87
+ const rules = Array.isArray(config?.rules) ? config.rules : [];
88
+ const counts = {};
89
+ if (fileCountByLayer instanceof Map) {
90
+ for (const [name, n] of fileCountByLayer) counts[name] = n;
91
+ }
92
+ const gatesOn = (enforcement || []).filter((e) => e.on).length;
93
+ return {
94
+ version: 1,
95
+ kind: 'ark-architecture-snapshot',
96
+ generatedAt: new Date().toISOString(),
97
+ arkVersion: version ?? null,
98
+ project: (() => {
99
+ try {
100
+ return readJsonSafe(path.join(root, 'package.json'))?.name || path.basename(root);
101
+ } catch {
102
+ return path.basename(root);
103
+ }
104
+ })(),
105
+ ok: Boolean(ok),
106
+ mode: mode ?? null,
107
+ score: score ?? null,
108
+ governedPercent: coverage?.governed?.percent ?? null,
109
+ classifiedFiles: coverage?.governed?.classifiedFiles ?? 0,
110
+ totalFiles: coverage?.governed?.totalFiles ?? 0,
111
+ unclassifiedFiles: coverage?.unclassified?.count ?? 0,
112
+ layerCount: layers.length,
113
+ denyRules: rules.filter((r) => r.allowed === false).length,
114
+ allowRules: rules.filter((r) => r.allowed === true).length,
115
+ activeViolations: Array.isArray(violations) ? violations.length : 0,
116
+ typeOnlyViolations: Array.isArray(violations)
117
+ ? violations.filter((v) => v.typeOnly).length
118
+ : 0,
119
+ valueViolations: Array.isArray(violations)
120
+ ? violations.filter((v) => !v.typeOnly).length
121
+ : 0,
122
+ suppressed: suppressed ?? 0,
123
+ gatesOn,
124
+ gatesTotal: (enforcement || []).length,
125
+ layerFiles: counts,
126
+ };
127
+ }
128
+
129
+ export function readJsonSafe(file) {
130
+ try {
131
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
132
+ } catch {
133
+ return null;
134
+ }
135
+ }
136
+
137
+ export function deltaField(current, origin, key) {
138
+ const a = current?.[key];
139
+ const b = origin?.[key];
140
+ if (typeof a !== 'number' || typeof b !== 'number') return null;
141
+ return a - b;
142
+ }
143
+
144
+ /**
145
+ * Persist origin (once), latest, optional history; return { origin, createdOrigin }.
146
+ */
147
+ /** Shared fitness numbers for HTML report + machine-readable snapshots. */
148
+ export function computeReportFitness({ coverage, violations, ok, enforcement, config }) {
149
+ const layers = Array.isArray(config?.layers) ? config.layers : [];
150
+ const rules = Array.isArray(config?.rules) ? config.rules : [];
151
+ const deniedCount = rules.filter((r) => r.allowed === false).length;
152
+ const gatesOn = (enforcement || []).filter((e) => e.on).length;
153
+ const governedPercent = coverage?.governed?.percent ?? null;
154
+ const totalFiles = coverage?.governed?.totalFiles ?? 0;
155
+ const classifiedFiles = coverage?.governed?.classifiedFiles ?? 0;
156
+ const mode = resolveOperatingMode({
157
+ governedPercent: totalFiles === 0 ? 0 : governedPercent,
158
+ planMet:
159
+ ok &&
160
+ (violations?.length ?? 0) === 0 &&
161
+ totalFiles > 0 &&
162
+ (governedPercent == null || governedPercent >= 50),
163
+ mature: totalFiles >= 150,
164
+ totalFiles,
165
+ });
166
+ const modeLabel = { suggest: 'SUGGEST', adapt: 'ADAPT', enforce: 'ENFORCE' }[mode] || String(mode).toUpperCase();
167
+ const modeBlurb = {
168
+ suggest: 'Starter shape — expand layers as the codebase grows.',
169
+ adapt: 'Contract is live; raise governed coverage or match real folders.',
170
+ enforce: 'Contract governs the tree. Gates can honestly hold the line.',
171
+ }[mode];
172
+ const scoreCoverage = governedPercent == null ? 50 : governedPercent;
173
+ const scoreClean =
174
+ (violations?.length ?? 0) === 0
175
+ ? 100
176
+ : Math.max(0, 100 - Math.min(100, violations.length * 4));
177
+ const scoreGates = enforcement?.length
178
+ ? Math.round((gatesOn / enforcement.length) * 100)
179
+ : 40;
180
+ const scoreRules = layers.length
181
+ ? Math.min(
182
+ 100,
183
+ Math.round((deniedCount / Math.max(1, layers.length * (layers.length - 1))) * 120)
184
+ )
185
+ : 0;
186
+ const score = Math.round(
187
+ scoreCoverage * 0.4 + scoreClean * 0.3 + scoreGates * 0.2 + scoreRules * 0.1
188
+ );
189
+ const scoreTone = score >= 90 ? 'elite' : score >= 70 ? 'strong' : score >= 50 ? 'ok' : 'weak';
190
+ const scoreCaption =
191
+ score >= 90
192
+ ? 'World-class architecture fitness'
193
+ : score >= 70
194
+ ? 'Solid architecture discipline'
195
+ : score >= 50
196
+ ? 'Useful guardrails — room to grow'
197
+ : 'Early stage — keep adopting layers';
198
+ return {
199
+ governedPercent,
200
+ totalFiles,
201
+ classifiedFiles,
202
+ mode,
203
+ modeLabel,
204
+ modeBlurb,
205
+ score,
206
+ scoreCoverage,
207
+ scoreClean,
208
+ scoreGates,
209
+ scoreRules,
210
+ scoreTone,
211
+ scoreCaption,
212
+ gatesOn,
213
+ deniedCount,
214
+ };
215
+ }
216
+
217
+ export function formatDelta(n, opts = {}) {
218
+ if (n == null || Number.isNaN(n)) return '—';
219
+ if (n === 0) return '0';
220
+ const sign = n > 0 ? '+' : '';
221
+ const suffix = opts.suffix ?? '';
222
+ return `${sign}${n}${suffix}`;
223
+ }
224
+
225
+ export function archiveReportSnapshots(root, { html, snapshot, resetOrigin = false, noArchive = false }) {
226
+ const dir = reportsDir(root);
227
+ const historyDir = path.join(dir, 'history');
228
+ fs.mkdirSync(historyDir, { recursive: true });
229
+
230
+ const originJson = path.join(dir, 'origin.json');
231
+ const originHtml = path.join(dir, 'origin.html');
232
+ const latestJson = path.join(dir, 'latest.json');
233
+ const latestHtml = path.join(dir, 'latest.html');
234
+
235
+ let origin = readJsonSafe(originJson);
236
+ let createdOrigin = false;
237
+ if (!origin || resetOrigin) {
238
+ fs.writeFileSync(originJson, `${JSON.stringify(snapshot, null, 2)}\n`);
239
+ fs.writeFileSync(originHtml, html);
240
+ origin = snapshot;
241
+ createdOrigin = true;
242
+ }
243
+
244
+ fs.writeFileSync(latestJson, `${JSON.stringify(snapshot, null, 2)}\n`);
245
+ fs.writeFileSync(latestHtml, html);
246
+
247
+ if (!noArchive) {
248
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
249
+ fs.writeFileSync(path.join(historyDir, `${stamp}.json`), `${JSON.stringify(snapshot, null, 2)}\n`);
250
+ // Cap history: keep newest ARK_REPORT_HISTORY_MAX JSON files.
251
+ try {
252
+ const files = fs
253
+ .readdirSync(historyDir)
254
+ .filter((f) => f.endsWith('.json'))
255
+ .map((f) => ({ f, t: fs.statSync(path.join(historyDir, f)).mtimeMs }))
256
+ .sort((a, b) => b.t - a.t);
257
+ for (const old of files.slice(ARK_REPORT_HISTORY_MAX)) {
258
+ fs.unlinkSync(path.join(historyDir, old.f));
259
+ }
260
+ } catch {
261
+ /* ignore prune errors */
262
+ }
263
+ }
264
+
265
+ // Ensure .ark/ is gitignored when a .gitignore exists.
266
+ const gitignore = path.join(root, '.gitignore');
267
+ if (fs.existsSync(gitignore)) {
268
+ const text = fs.readFileSync(gitignore, 'utf8');
269
+ const hasArk =
270
+ text.split('\n').some((line) => {
271
+ const t = line.trim();
272
+ return t === '.ark/' || t === '.ark' || t === '/.ark/' || t === '**/.ark/';
273
+ });
274
+ if (!hasArk) {
275
+ const suffix = text.endsWith('\n') || text.length === 0 ? '' : '\n';
276
+ fs.writeFileSync(
277
+ gitignore,
278
+ `${text}${suffix}\n# Ark generated reports / local state\n.ark/\n`
279
+ );
280
+ }
281
+ }
282
+
283
+ return { origin, createdOrigin, dir, originJson, latestHtml };
284
+ }
285
+
286
+ // Simplified onboarding report: compact diagram, placement table, short violation list.
287
+ export function renderBeginnerHtmlReport({ root, config, violations, ok, version, configPath, generatedAt }) {
288
+ const layers = Array.isArray(config.layers) ? config.layers : [];
289
+ const esc = htmlEscape;
290
+ const project = (() => {
291
+ try {
292
+ return readJsonSafe(path.join(root, 'package.json'))?.name || path.basename(root);
293
+ } catch {
294
+ return path.basename(root);
295
+ }
296
+ })();
297
+ const status = ok ? 'PASS' : 'FAIL';
298
+ const phase1 = layers.slice(0, 4);
299
+ const diagram = phase1
300
+ .map((layer, index) => `${index + 1}. ${layer.name}`)
301
+ .join(' → ') || 'Add layers in ark.config.json';
302
+
303
+ const placementRows = layers
304
+ .map((layer) => {
305
+ const purpose = layer.description || 'See ark.config.json';
306
+ const folders = (layer.patterns || []).join(', ') || '—';
307
+ return `<tr><td><strong>${esc(layer.name)}</strong></td><td>${esc(purpose)}</td><td><code>${esc(folders)}</code></td></tr>`;
308
+ })
309
+ .join('\n');
310
+
311
+ const violationRows = violations.length
312
+ ? violations
313
+ .slice(0, 12)
314
+ .map((v) => {
315
+ const enriched = enrichViolationWithFixClass(v);
316
+ return `<li><code>${esc(v.file)}:${v.line}</code> — ${esc(enriched.enthusiastHint ?? v.message)}</li>`;
317
+ })
318
+ .join('\n')
319
+ : '<li class="dim">No active violations — architecture matches the contract.</li>';
320
+
321
+ const meta = [version ? `ark-check v${esc(version)}` : '', generatedAt ? esc(generatedAt) : '']
322
+ .filter(Boolean)
323
+ .join(' · ');
324
+
325
+ return `<!doctype html>
326
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
327
+ <title>Ark beginner guide — ${esc(project)}</title>
328
+ <style>
329
+ body { font-family: system-ui, sans-serif; margin: 2rem; line-height: 1.5; max-width: 720px; }
330
+ h1 { font-size: 1.4rem; }
331
+ .badge { padding: .2em .6em; border-radius: 999px; font-weight: 700; font-size: .85rem; }
332
+ .PASS { background: #dcfce7; color: #166534; }
333
+ .FAIL { background: #fee2e2; color: #991b1b; }
334
+ .diagram { background: #f4f4f5; padding: 1rem; border-radius: 8px; font-family: monospace; margin: 1rem 0; }
335
+ table { width: 100%; border-collapse: collapse; margin: 1rem 0; }
336
+ th, td { text-align: left; padding: .5rem; border-bottom: 1px solid #e4e4e7; vertical-align: top; }
337
+ th { font-size: .75rem; text-transform: uppercase; color: #71717a; }
338
+ ul { padding-left: 1.2rem; }
339
+ .dim { color: #71717a; }
340
+ footer { margin-top: 2rem; font-size: .85rem; color: #71717a; }
341
+ </style></head>
342
+ <body>
343
+ <h1>${esc(project)} <span class="badge ${status}">${status}</span></h1>
344
+ <p class="dim">Beginner architecture guide · ${meta}</p>
345
+ <h2>How layers flow (inner → outer)</h2>
346
+ <div class="diagram">${esc(diagram)}</div>
347
+ <p>Business rules live in inner layers; UI and databases live in outer adapter layers. Inner code must not import outer code.</p>
348
+ <h2>Where code goes</h2>
349
+ <table>
350
+ <tr><th>Layer</th><th>Purpose</th><th>Typical folders</th></tr>
351
+ ${placementRows || '<tr><td colspan="3">No layers configured.</td></tr>'}
352
+ </table>
353
+ <h2>What to fix first</h2>
354
+ <ul>${violationRows}</ul>
355
+ <h2>Next steps</h2>
356
+ <p><code>${arkCheckCommand(root)}</code></p>
357
+ <p><code>${arkCommand(root, 'ark-check', '--recommend')}</code></p>
358
+ <footer>Generated by ark-check --report --beginner. Config: ${esc(configPath)}</footer>
359
+ </body></html>`;
360
+ }
361
+
362
+ /**
363
+ * Showcase HTML architecture report — the visual product of `/ark-explain` + ark-check.
364
+ * Self-contained (no CDN), print-friendly, works offline. Designed to look great on a
365
+ * fully governed repo (100% coverage, clean gates) and still be useful when debt remains.
366
+ */
367
+ export function renderHtmlReport({
368
+ root,
369
+ config,
370
+ exampleByLayer,
371
+ fileCountByLayer,
372
+ coverage,
373
+ violations,
374
+ ok,
375
+ suppressed,
376
+ version,
377
+ configPath,
378
+ generatedAt,
379
+ skillGaps = [],
380
+ originSnapshot = null,
381
+ currentSnapshot = null,
382
+ originJustCreated = false,
383
+ adoption = null,
384
+ }) {
385
+ const layers = Array.isArray(config.layers) ? config.layers : [];
386
+ const rules = Array.isArray(config.rules) ? config.rules : [];
387
+ const esc = htmlEscape;
388
+ const project = (() => {
389
+ try {
390
+ return readJsonSafe(path.join(root, 'package.json'))?.name || path.basename(root);
391
+ } catch {
392
+ return path.basename(root);
393
+ }
394
+ })();
395
+
396
+ const findRule = (from, to) => rules.find((r) => r.from === from && r.to === to);
397
+ const deniedOut = (name) => rules.filter((r) => r.from === name && r.allowed === false).length;
398
+ // Innermost first: more outbound denies → deeper (pure core).
399
+ const ordered = [...layers].sort(
400
+ (a, b) => deniedOut(b.name) - deniedOut(a.name) || a.name.localeCompare(b.name)
401
+ );
402
+
403
+ const deniedCount = rules.filter((r) => r.allowed === false).length;
404
+ const allowedCount = rules.filter((r) => r.allowed === true).length;
405
+ const guarded = layers.filter(
406
+ (l) => Array.isArray(l.forbiddenGlobals) && l.forbiddenGlobals.length
407
+ ).length;
408
+ const enforcement = detectEnforcement(root);
409
+ const gatesOn = enforcement.filter((e) => e.on).length;
410
+ const status = ok ? 'PASS' : 'FAIL';
411
+
412
+ const fitness = computeReportFitness({
413
+ coverage,
414
+ violations,
415
+ ok,
416
+ enforcement,
417
+ config,
418
+ });
419
+ const {
420
+ governedPercent,
421
+ totalFiles,
422
+ classifiedFiles,
423
+ mode,
424
+ modeLabel,
425
+ modeBlurb,
426
+ score,
427
+ scoreCoverage,
428
+ scoreClean,
429
+ scoreGates,
430
+ scoreRules,
431
+ scoreTone,
432
+ scoreCaption,
433
+ } = fitness;
434
+
435
+ const adoptionView = adoption || collectAdoptionGaps(root, config, coverage);
436
+
437
+ // ── Senior diagnostics (coupling, purity, contract density) ──────────────
438
+ const layerNames = ordered.map((l) => l.name);
439
+ const pairCount = Math.max(1, layers.length * Math.max(0, layers.length - 1));
440
+ const denyRatio = Math.round((deniedCount / pairCount) * 1000) / 10;
441
+ const fanOut = new Map(layerNames.map((n) => [n, 0]));
442
+ const fanIn = new Map(layerNames.map((n) => [n, 0]));
443
+ for (const from of layerNames) {
444
+ for (const to of layerNames) {
445
+ if (from === to) continue;
446
+ const rule = findRule(from, to);
447
+ const denied = rule && rule.allowed === false;
448
+ if (!denied) {
449
+ fanOut.set(from, (fanOut.get(from) || 0) + 1);
450
+ fanIn.set(to, (fanIn.get(to) || 0) + 1);
451
+ }
452
+ }
453
+ }
454
+ const couplingRows = ordered
455
+ .map((layer) => {
456
+ const fo = fanOut.get(layer.name) || 0;
457
+ const fi = fanIn.get(layer.name) || 0;
458
+ const files = (fileCountByLayer instanceof Map ? fileCountByLayer.get(layer.name) : 0) || 0;
459
+ const density = files > 0 ? Math.round((fo / files) * 100) / 100 : fo;
460
+ return { name: layer.name, fo, fi, files, density, denyOut: deniedOut(layer.name) };
461
+ })
462
+ .sort((a, b) => b.fo - a.fo || b.fi - a.fi);
463
+
464
+ const purityLayers = ordered.filter(
465
+ (l) => Array.isArray(l.forbiddenGlobals) && l.forbiddenGlobals.length
466
+ );
467
+ const infraLayers = ordered.filter((l) => l.mayImportInfrastructure);
468
+ const excludeLayers = ordered.filter((l) => Array.isArray(l.exclude) && l.exclude.length);
469
+ const intentMap = ordered
470
+ .filter((l) => Array.isArray(l.intentPrefixes) && l.intentPrefixes.length)
471
+ .map((l) => ({ name: l.name, prefixes: l.intentPrefixes }));
472
+
473
+ const emptyLayers = coverage?.emptyLayers ?? [];
474
+ const layersWithoutRules = coverage?.layersWithoutRules ?? [];
475
+ const unclassifiedCount = coverage?.unclassified?.count ?? 0;
476
+ const includeRoots = Array.isArray(config.include) ? config.include : [];
477
+
478
+ const typeOnlyN = violations.filter((v) => v.typeOnly).length;
479
+ const valueN = violations.length - typeOnlyN;
480
+ const byEdge = new Map();
481
+ for (const v of violations) {
482
+ if (!v.fromLayer || !v.toLayer) continue;
483
+ const key = `${v.fromLayer} → ${v.toLayer}`;
484
+ byEdge.set(key, (byEdge.get(key) || 0) + 1);
485
+ }
486
+ const topEdges = [...byEdge.entries()].sort((a, b) => b[1] - a[1]).slice(0, 8);
487
+
488
+ let packageManagerLabel = 'npm';
489
+ try {
490
+ packageManagerLabel = detectPackageManager(root);
491
+ } catch {
492
+ /* ignore */
493
+ }
494
+
495
+ const baselinePath = path.join(root, '.ark-baseline.json');
496
+ let baselineKeys = 0;
497
+ if (fs.existsSync(baselinePath)) {
498
+ try {
499
+ const raw = JSON.parse(fs.readFileSync(baselinePath, 'utf8'));
500
+ baselineKeys = Array.isArray(raw?.violations)
501
+ ? raw.violations.length
502
+ : Array.isArray(raw)
503
+ ? raw.length
504
+ : typeof raw === 'object' && raw
505
+ ? Object.keys(raw).length
506
+ : 0;
507
+ } catch {
508
+ baselineKeys = suppressed || 0;
509
+ }
510
+ }
511
+
512
+ // Pattern specificity hotspots: very broad globs (**/ or bare *) vs file-precise.
513
+ const broadPatterns = [];
514
+ const precisePatterns = [];
515
+ for (const layer of ordered) {
516
+ for (const pattern of layer.patterns || []) {
517
+ const p = String(pattern);
518
+ const scoreP = patternSpecificity(p);
519
+ if (p.includes('**') && p.split('/').filter(Boolean).length <= 2) {
520
+ broadPatterns.push({ layer: layer.name, pattern: p, score: scoreP });
521
+ }
522
+ if (!p.includes('*') || /\.[a-zA-Z0-9]+$/.test(p.replace(/\*$/, ''))) {
523
+ if (p.includes('.') && !p.endsWith('/**')) {
524
+ precisePatterns.push({ layer: layer.name, pattern: p, score: scoreP });
525
+ }
526
+ }
527
+ }
528
+ }
529
+ broadPatterns.sort((a, b) => a.score - b.score);
530
+ precisePatterns.sort((a, b) => b.score - a.score);
531
+
532
+ const counts = fileCountByLayer instanceof Map ? fileCountByLayer : new Map();
533
+ const maxFiles = Math.max(1, ...ordered.map((l) => counts.get(l.name) || 0));
534
+
535
+ // Concentric “onion” SVG — outer entrypoints, pure core in the center.
536
+ const palette = [
537
+ '#38bdf8',
538
+ '#818cf8',
539
+ '#a78bfa',
540
+ '#e879f9',
541
+ '#fb7185',
542
+ '#fb923c',
543
+ '#fbbf24',
544
+ '#a3e635',
545
+ '#34d399',
546
+ '#2dd4bf',
547
+ '#22d3ee',
548
+ '#60a5fa',
549
+ ];
550
+ // ordered is inner→outer; reverse for drawing outer rings first
551
+ const outerFirst = [...ordered].reverse();
552
+ const n = outerFirst.length || 1;
553
+ const cx = 200;
554
+ const cy = 200;
555
+ const rMax = 185;
556
+ const rMin = 28;
557
+ const rings = outerFirst
558
+ .map((layer, i) => {
559
+ const t0 = i / n;
560
+ const t1 = (i + 1) / n;
561
+ const rOuter = rMax - t0 * (rMax - rMin);
562
+ const rInner = rMax - t1 * (rMax - rMin);
563
+ const color = palette[i % palette.length];
564
+ const files = counts.get(layer.name) || 0;
565
+ // Donut sector as full ring (annulus) via two arcs
566
+ const ringPath = (() => {
567
+ if (rInner <= 0.5) {
568
+ return `<circle cx="${cx}" cy="${cy}" r="${rOuter}" fill="${color}" fill-opacity="0.22" stroke="${color}" stroke-width="1.2"/>`;
569
+ }
570
+ return `<circle cx="${cx}" cy="${cy}" r="${(rOuter + rInner) / 2}" fill="none" stroke="${color}" stroke-width="${Math.max(6, rOuter - rInner - 2)}" stroke-opacity="0.85"/>`;
571
+ })();
572
+ const labelR = (rOuter + rInner) / 2;
573
+ const labelY = cy - labelR + (i === n - 1 ? 0 : 0);
574
+ // Labels stacked on the right of the diagram for readability
575
+ return { layer, color, files, ringPath, labelR, i };
576
+ })
577
+ .map((item, idx, arr) => {
578
+ const legendY = 28 + idx * 22;
579
+ return `${item.ringPath}
580
+ <circle cx="430" cy="${legendY}" r="5" fill="${item.color}"/>
581
+ <text x="442" y="${legendY + 4}" class="svg-lbl">${esc(item.layer.name)} · ${item.files}</text>`;
582
+ })
583
+ .join('\n');
584
+ const coreLabel =
585
+ ordered.length > 0
586
+ ? `<text x="${cx}" y="${cy + 4}" text-anchor="middle" class="svg-core">${esc(ordered[0].name)}</text>`
587
+ : '';
588
+ const onionSvg = `<svg viewBox="0 0 560 400" class="onion" role="img" aria-label="Architecture layers from outer adapters to inner core">
589
+ <rect x="0" y="0" width="560" height="400" fill="transparent"/>
590
+ ${rings}
591
+ ${coreLabel}
592
+ <text x="${cx}" y="388" text-anchor="middle" class="svg-cap">outer adapters → pure core</text>
593
+ </svg>`;
594
+
595
+ // Coverage bars
596
+ const barRows = ordered
597
+ .map((layer) => {
598
+ const files = counts.get(layer.name) || 0;
599
+ const pct = Math.round((files / maxFiles) * 100);
600
+ const example = exampleByLayer?.get?.(layer.name);
601
+ return `<div class="bar-row">
602
+ <div class="bar-name">${esc(layer.name)}</div>
603
+ <div class="bar-track"><div class="bar-fill" style="width:${pct}%"></div></div>
604
+ <div class="bar-n">${files}</div>
605
+ <div class="bar-ex">${example ? `<code>${esc(example)}</code>` : '<span class="dim">—</span>'}</div>
606
+ </div>`;
607
+ })
608
+ .join('\n');
609
+
610
+ const layerRows = ordered
611
+ .map((layer) => {
612
+ const tags = [
613
+ Array.isArray(layer.forbiddenGlobals) && layer.forbiddenGlobals.length
614
+ ? `<span class="tag warn">no ${layer.forbiddenGlobals.map(esc).join(', ')}</span>`
615
+ : '',
616
+ layer.mayImportInfrastructure ? '<span class="tag">may import infra</span>' : '',
617
+ Array.isArray(layer.intentPrefixes) && layer.intentPrefixes.length
618
+ ? `<span class="tag">${layer.intentPrefixes.map(esc).join(' ')}</span>`
619
+ : '',
620
+ layer.optional ? '<span class="tag dim-tag">optional</span>' : '',
621
+ ].join(' ');
622
+ const example = exampleByLayer?.get?.(layer.name);
623
+ const files = counts.get(layer.name) || 0;
624
+ return `<tr>
625
+ <td class="ln">${esc(layer.name)}<div class="tags">${tags}</div></td>
626
+ <td>${layer.description ? esc(layer.description) : '<span class="dim">—</span>'}</td>
627
+ <td class="num">${files}</td>
628
+ <td><code class="pat">${(layer.patterns || []).map(esc).join('<br>') || '—'}</code></td>
629
+ <td>${example ? `<code>${esc(example)}</code>` : '<span class="dim">no files yet</span>'}</td>
630
+ </tr>`;
631
+ })
632
+ .join('\n');
633
+
634
+ const flowRows = ordered
635
+ .map((layer) => {
636
+ const targets = ordered
637
+ .filter((other) => other.name !== layer.name)
638
+ .filter((other) => {
639
+ const rule = findRule(layer.name, other.name);
640
+ return !(rule && rule.allowed === false);
641
+ })
642
+ .map((other) => `<span class="chip ok">${esc(other.name)}</span>`)
643
+ .join('');
644
+ return `<div class="flow"><span class="flow-name">${esc(layer.name)}</span>
645
+ <span class="flow-arrow">may import →</span>
646
+ <span class="flow-targets">${targets || '<span class="dim">nothing (pure core)</span>'}</span></div>`;
647
+ })
648
+ .join('\n');
649
+
650
+ const matrixHead = ordered.map((l) => `<th class="rot"><span>${esc(l.name)}</span></th>`).join('');
651
+ const matrixBody = ordered
652
+ .map((from) => {
653
+ const cells = ordered
654
+ .map((to) => {
655
+ if (from.name === to.name) return '<td class="self">·</td>';
656
+ const rule = findRule(from.name, to.name);
657
+ if (!rule) return '<td class="implicit" title="no rule (implicitly allowed)">·</td>';
658
+ return rule.allowed
659
+ ? '<td class="allow" title="allowed">✓</td>'
660
+ : `<td class="deny" title="${esc(rule.message || 'denied')}">✕</td>`;
661
+ })
662
+ .join('');
663
+ return `<tr><th class="rowlbl">${esc(from.name)}</th>${cells}</tr>`;
664
+ })
665
+ .join('\n');
666
+
667
+ const byRule = new Map();
668
+ for (const v of violations) {
669
+ if (!byRule.has(v.ruleId)) byRule.set(v.ruleId, []);
670
+ byRule.get(v.ruleId).push(v);
671
+ }
672
+ const violationBlocks = violations.length
673
+ ? [...byRule.entries()]
674
+ .map(([ruleId, items]) => {
675
+ const hint = FIX_HINTS[ruleId];
676
+ const rows = items
677
+ .map((v) => {
678
+ const edge =
679
+ v.fromLayer && v.toLayer ? `${esc(v.fromLayer)} → ${esc(v.toLayer)}` : '';
680
+ const enriched = enrichViolationWithFixClass(v);
681
+ return `<li>
682
+ <code>${esc(v.file)}:${v.line}</code>
683
+ ${edge ? `<span class="edge">${edge}${v.target ? ` <span class="dim">(${esc(v.target)})</span>` : ''}</span>` : ''}
684
+ <div class="msg">${esc(enriched.enthusiastHint || v.message)}</div>
685
+ </li>`;
686
+ })
687
+ .join('\n');
688
+ return `<div class="vgroup">
689
+ <div class="vghead"><span class="rule">${esc(ruleId)}</span> <span class="dim">${items.length}</span></div>
690
+ <ul class="vitems">${rows}</ul>
691
+ ${hint ? `<div class="fix">fix: ${esc(hint)}</div>` : ''}
692
+ </div>`;
693
+ })
694
+ .join('\n')
695
+ : `<div class="clean hero-clean">
696
+ <div class="clean-title">Architecture matches the contract</div>
697
+ <div class="clean-body">No active violations${suppressed ? ` · ${suppressed} frozen by baseline` : ''}. This is what “honest green” looks like when coverage is real.</div>
698
+ </div>`;
699
+
700
+ const enforcementRows = enforcement
701
+ .map(
702
+ (e) =>
703
+ `<div class="gate ${e.on ? 'on' : 'off'}">
704
+ <span class="dot"></span>
705
+ <div><b>${esc(e.name)}</b><div class="gdesc">${esc(e.what)}</div>
706
+ ${e.where ? `<code>${esc(e.where)}</code>` : '<span class="dim">not configured</span>'}</div>
707
+ </div>`
708
+ )
709
+ .join('\n');
710
+
711
+ const skillsNote =
712
+ skillGaps.length === 0
713
+ ? '<div class="pill good">Agent skills current for detected tools</div>'
714
+ : `<div class="pill warn">${skillGaps.length} skill gap(s) — run ark upgrade / --install-agent-gates</div>`;
715
+
716
+ const meta = [
717
+ version ? `ark-check v${esc(version)}` : '',
718
+ generatedAt ? esc(generatedAt) : '',
719
+ configPath ? `config: ${esc(configPath)}` : '',
720
+ ]
721
+ .filter(Boolean)
722
+ .join(' · ');
723
+
724
+ const govLabel =
725
+ governedPercent == null ? '—' : `${governedPercent}% (${classifiedFiles}/${totalFiles})`;
726
+
727
+ return `<!doctype html>
728
+ <html lang="en"><head><meta charset="utf-8">
729
+ <meta name="viewport" content="width=device-width, initial-scale=1">
730
+ <title>Ark · ${esc(project)}</title>
731
+ <style>
732
+ :root {
733
+ --bg: #07090d; --panel: #10141b; --panel2: #161b24; --ink: #eef1f5; --dim: #8b93a0;
734
+ --line: #243041; --green: #34d399; --red: #f87171; --accent: #38bdf8; --gold: #fbbf24;
735
+ --violet: #a78bfa; --radius: 14px;
736
+ }
737
+ @media (prefers-color-scheme: light) {
738
+ :root {
739
+ --bg: #f4f6f9; --panel: #fff; --panel2: #f8fafc; --ink: #0f172a; --dim: #64748b;
740
+ --line: #e2e8f0; --green: #059669; --red: #dc2626; --accent: #0284c7; --gold: #d97706;
741
+ --violet: #7c3aed;
742
+ }
743
+ }
744
+ * { box-sizing: border-box; }
745
+ body {
746
+ margin: 0; padding: 0 0 4rem;
747
+ background:
748
+ radial-gradient(1200px 600px at 10% -10%, color-mix(in srgb, var(--accent) 18%, transparent), transparent 60%),
749
+ radial-gradient(900px 500px at 100% 0%, color-mix(in srgb, var(--violet) 14%, transparent), transparent 55%),
750
+ var(--bg);
751
+ color: var(--ink);
752
+ font: 15px/1.55 ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
753
+ }
754
+ .wrap { max-width: 1080px; margin: 0 auto; padding: 2rem 1.25rem; }
755
+ .hero {
756
+ display: grid; grid-template-columns: 1.4fr 0.9fr; gap: 1.25rem; align-items: stretch;
757
+ margin-bottom: 1.5rem;
758
+ }
759
+ @media (max-width: 820px) { .hero { grid-template-columns: 1fr; } }
760
+ .card {
761
+ background: linear-gradient(180deg, color-mix(in srgb, var(--panel) 92%, #fff 4%), var(--panel));
762
+ border: 1px solid var(--line); border-radius: var(--radius);
763
+ padding: 1.15rem 1.25rem; box-shadow: 0 20px 50px rgba(0,0,0,.18);
764
+ }
765
+ h1 { font-size: 1.65rem; margin: 0 0 .35rem; letter-spacing: -0.02em; }
766
+ h2 { font-size: 1.05rem; margin: 0 0 .35rem; letter-spacing: -0.01em; }
767
+ h3 { font-size: .92rem; margin: 1rem 0 .4rem; color: var(--dim); text-transform: uppercase; letter-spacing: .06em; font-weight: 600; }
768
+ .lede { color: var(--dim); margin: 0 0 1rem; max-width: 42rem; }
769
+ .meta { color: var(--dim); font-size: .8rem; margin: .75rem 0 0; }
770
+ .badge, .pill {
771
+ display: inline-flex; align-items: center; gap: .35rem;
772
+ padding: .2em .65em; border-radius: 999px; font-weight: 700; font-size: .78rem;
773
+ letter-spacing: .03em; border: 1px solid transparent;
774
+ }
775
+ .PASS { background: color-mix(in srgb, var(--green) 18%, transparent); color: var(--green); border-color: color-mix(in srgb, var(--green) 35%, transparent); }
776
+ .FAIL { background: color-mix(in srgb, var(--red) 18%, transparent); color: var(--red); border-color: color-mix(in srgb, var(--red) 35%, transparent); }
777
+ .mode { background: color-mix(in srgb, var(--accent) 16%, transparent); color: var(--accent); border-color: color-mix(in srgb, var(--accent) 35%, transparent); }
778
+ .pill.good { background: color-mix(in srgb, var(--green) 14%, transparent); color: var(--green); }
779
+ .pill.warn { background: color-mix(in srgb, var(--gold) 16%, transparent); color: var(--gold); }
780
+ .score-card { display: flex; flex-direction: column; justify-content: center; text-align: center; min-height: 100%; }
781
+ .score-ring {
782
+ --p: ${score};
783
+ width: 148px; height: 148px; margin: .25rem auto 0.85rem;
784
+ border-radius: 50%;
785
+ background:
786
+ radial-gradient(var(--panel) 58%, transparent 59%),
787
+ conic-gradient(var(--accent) calc(var(--p) * 1%), var(--line) 0);
788
+ display: grid; place-items: center;
789
+ }
790
+ .score-ring.elite { background:
791
+ radial-gradient(var(--panel) 58%, transparent 59%),
792
+ conic-gradient(var(--green) calc(var(--p) * 1%), var(--line) 0); }
793
+ .score-ring.strong { background:
794
+ radial-gradient(var(--panel) 58%, transparent 59%),
795
+ conic-gradient(var(--accent) calc(var(--p) * 1%), var(--line) 0); }
796
+ .score-ring.ok { background:
797
+ radial-gradient(var(--panel) 58%, transparent 59%),
798
+ conic-gradient(var(--gold) calc(var(--p) * 1%), var(--line) 0); }
799
+ .score-ring.weak { background:
800
+ radial-gradient(var(--panel) 58%, transparent 59%),
801
+ conic-gradient(var(--red) calc(var(--p) * 1%), var(--line) 0); }
802
+ .score-n { font-size: 2.1rem; font-weight: 800; letter-spacing: -0.03em; line-height: 1; }
803
+ .score-cap { color: var(--dim); font-size: .85rem; margin: 0; }
804
+ .kpis { display: grid; grid-template-columns: repeat(4, 1fr); gap: .65rem; margin: 1rem 0 0; }
805
+ @media (max-width: 720px) { .kpis { grid-template-columns: repeat(2, 1fr); } }
806
+ .kpi { background: var(--panel2); border: 1px solid var(--line); border-radius: 12px; padding: .7rem .8rem; }
807
+ .kpi b { display: block; font-size: 1.25rem; letter-spacing: -0.02em; }
808
+ .kpi span { color: var(--dim); font-size: .75rem; text-transform: uppercase; letter-spacing: .05em; }
809
+ .section { margin-top: 1.35rem; }
810
+ .grid-2 { display: grid; grid-template-columns: 1.1fr 0.9fr; gap: 1rem; }
811
+ @media (max-width: 900px) { .grid-2 { grid-template-columns: 1fr; } }
812
+ .onion { width: 100%; height: auto; display: block; }
813
+ .svg-lbl { fill: var(--dim); font-size: 11px; font-family: ui-sans-serif, system-ui, sans-serif; }
814
+ .svg-core { fill: var(--ink); font-size: 11px; font-weight: 700; font-family: ui-sans-serif, system-ui, sans-serif; }
815
+ .svg-cap { fill: var(--dim); font-size: 11px; font-family: ui-sans-serif, system-ui, sans-serif; }
816
+ .bar-row { display: grid; grid-template-columns: 10.5rem 1fr 2.2rem minmax(0, 1fr); gap: .55rem; align-items: center; padding: .28rem 0; border-bottom: 1px solid var(--line); }
817
+ .bar-name { font-weight: 600; font-size: .86rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
818
+ .bar-track { height: 8px; background: var(--line); border-radius: 99px; overflow: hidden; }
819
+ .bar-fill { height: 100%; background: linear-gradient(90deg, var(--accent), var(--violet)); border-radius: 99px; }
820
+ .bar-n { text-align: right; font-variant-numeric: tabular-nums; color: var(--dim); font-size: .85rem; }
821
+ .bar-ex { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
822
+ .dim { color: var(--dim); }
823
+ code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .84em; }
824
+ code.pat { font-size: .78em; color: var(--dim); }
825
+ table { width: 100%; border-collapse: collapse; }
826
+ .layers td, .layers th { text-align: left; padding: .65rem .55rem; border-bottom: 1px solid var(--line); vertical-align: top; }
827
+ .layers th { color: var(--dim); font-weight: 600; font-size: .72rem; text-transform: uppercase; letter-spacing: .05em; }
828
+ .ln { font-weight: 650; }
829
+ .num { font-variant-numeric: tabular-nums; font-weight: 650; }
830
+ .tags { margin-top: .3rem; display: flex; flex-wrap: wrap; gap: .25rem; }
831
+ .tag { display: inline-block; padding: .08em .45em; border: 1px solid var(--line); border-radius: 6px; font-size: .68rem; color: var(--dim); }
832
+ .tag.warn { border-color: color-mix(in srgb, var(--gold) 40%, var(--line)); color: var(--gold); }
833
+ .dim-tag { opacity: .75; }
834
+ .flow { display: flex; gap: .5rem; align-items: baseline; padding: .45rem 0; border-bottom: 1px solid var(--line); flex-wrap: wrap; }
835
+ .flow-name { font-weight: 650; min-width: 12rem; }
836
+ .flow-arrow { color: var(--dim); font-size: .8rem; }
837
+ .flow-targets { display: flex; flex-wrap: wrap; gap: .3rem; }
838
+ .chip { display: inline-block; padding: .12em .5em; border: 1px solid var(--line); border-radius: 6px; font-size: .76rem; color: var(--dim); background: var(--panel2); }
839
+ .chip.ok { color: var(--ink); border-color: color-mix(in srgb, var(--accent) 30%, var(--line)); }
840
+ details { margin-top: .85rem; }
841
+ summary { cursor: pointer; color: var(--accent); font-size: .9rem; }
842
+ /* Matrix must NOT inherit global table{width:100%} — that bloated the label column
843
+ and shoved every cell to the right. Keep it compact and left-aligned. */
844
+ .matrix-scroll {
845
+ overflow-x: auto; margin-top: .75rem; max-width: 100%;
846
+ text-align: left; -webkit-overflow-scrolling: touch;
847
+ }
848
+ .matrix {
849
+ width: max-content; max-width: none; border-collapse: collapse;
850
+ font-size: .8rem; margin: 0; table-layout: fixed;
851
+ }
852
+ .matrix th, .matrix td { border: 1px solid var(--line); }
853
+ .matrix td {
854
+ width: 2.05rem; min-width: 2.05rem; max-width: 2.05rem;
855
+ height: 2.05rem; text-align: center; font-weight: 700; padding: 0;
856
+ }
857
+ .matrix .rowlbl {
858
+ text-align: left; padding: 0 .75rem 0 .35rem; color: var(--dim);
859
+ font-weight: 600; white-space: nowrap; width: auto; min-width: 9.5rem;
860
+ max-width: none; position: sticky; left: 0; z-index: 1;
861
+ background: var(--panel); box-shadow: 4px 0 8px -4px rgba(0,0,0,.25);
862
+ }
863
+ .matrix thead th:first-child,
864
+ .matrix tr th.rowlbl { background: var(--panel); }
865
+ .matrix .corner {
866
+ position: sticky; left: 0; z-index: 2; background: var(--panel);
867
+ min-width: 9.5rem; box-shadow: 4px 0 8px -4px rgba(0,0,0,.25);
868
+ }
869
+ .matrix .rot {
870
+ height: 9.5rem; vertical-align: bottom; padding: .2rem .15rem;
871
+ width: 2.05rem; min-width: 2.05rem; max-width: 2.05rem;
872
+ }
873
+ .matrix .rot span {
874
+ writing-mode: vertical-rl; transform: rotate(180deg); color: var(--dim);
875
+ font-weight: 600; white-space: nowrap; display: inline-block; max-height: 9rem;
876
+ overflow: hidden; text-overflow: ellipsis;
877
+ }
878
+ .allow { color: var(--green); background: color-mix(in srgb, var(--green) 12%, transparent); }
879
+ .deny { color: var(--red); background: color-mix(in srgb, var(--red) 12%, transparent); }
880
+ .implicit { color: var(--dim); }
881
+ .self { color: var(--line); }
882
+ .legend { color: var(--dim); font-size: .8rem; margin: .55rem 0 0; }
883
+ .gates { display: grid; grid-template-columns: repeat(2, 1fr); gap: .65rem; }
884
+ @media (max-width: 700px) { .gates { grid-template-columns: 1fr; } }
885
+ .gate { display: flex; gap: .65rem; align-items: flex-start; padding: .75rem .8rem; border-radius: 12px; border: 1px solid var(--line); background: var(--panel2); }
886
+ .gate .dot { width: .65rem; height: .65rem; border-radius: 50%; margin-top: .35rem; background: var(--line); flex: 0 0 auto; }
887
+ .gate.on .dot { background: var(--green); box-shadow: 0 0 0 4px color-mix(in srgb, var(--green) 20%, transparent); }
888
+ .gate.off { opacity: .72; }
889
+ .gdesc { color: var(--dim); font-size: .85rem; margin: .1rem 0 .25rem; }
890
+ .vgroup { background: var(--panel2); border: 1px solid var(--line); border-left: 3px solid var(--red); border-radius: 10px; padding: .75rem .9rem; margin-bottom: .6rem; }
891
+ .vghead { display: flex; gap: .5rem; align-items: baseline; }
892
+ .rule { font-weight: 700; font-size: .8rem; color: var(--red); }
893
+ .vitems { list-style: none; padding: 0; margin: .4rem 0 0; }
894
+ .vitems li { padding: .35rem 0; border-top: 1px solid var(--line); }
895
+ .vitems li:first-child { border-top: none; }
896
+ .edge { color: var(--accent); font-weight: 650; margin-left: .35rem; }
897
+ .fix { margin-top: .4rem; color: var(--dim); font-size: .86rem; }
898
+ .clean, .hero-clean { background: var(--panel2); border: 1px solid var(--line); border-left: 3px solid var(--green); border-radius: 12px; padding: 1rem 1.1rem; }
899
+ .clean-title { font-weight: 750; color: var(--green); margin-bottom: .25rem; }
900
+ .clean-body { color: var(--dim); }
901
+ .cmds { display: grid; gap: .35rem; background: var(--panel2); border: 1px solid var(--line); border-radius: 12px; padding: .9rem 1rem; }
902
+ .cmds code { display: block; padding: .15rem 0; overflow-x: auto; }
903
+ footer { margin-top: 2.25rem; padding-top: 1rem; border-top: 1px solid var(--line); color: var(--dim); font-size: .8rem; }
904
+ .brand { display: inline-flex; align-items: center; gap: .4rem; color: var(--dim); font-size: .78rem; font-weight: 650; letter-spacing: .08em; text-transform: uppercase; margin-bottom: .55rem; }
905
+ .brand i { width: .55rem; height: .55rem; border-radius: 2px; background: linear-gradient(135deg, var(--accent), var(--violet)); display: inline-block; }
906
+ .senior h3 { margin-top: 1.25rem; }
907
+ .senior-list { margin: .2rem 0 0; padding-left: 1.1rem; color: var(--ink); }
908
+ .senior-list li { margin: .2rem 0; }
909
+ .senior-list .edge { margin-left: 0; }
910
+ .delta.up { color: var(--green); font-weight: 700; }
911
+ .delta.down { color: var(--red); font-weight: 700; }
912
+ .delta.flat { color: var(--dim); }
913
+ .evolve { border-color: color-mix(in srgb, var(--accent) 35%, var(--line)); }
914
+ @media print {
915
+ body { background: #fff; color: #111; padding: 0; }
916
+ .card, .kpi, .gate, .cmds, .clean, .vgroup { box-shadow: none; break-inside: avoid; }
917
+ details { open: true; }
918
+ }
919
+ </style></head>
920
+ <body><div class="wrap">
921
+ <div class="hero">
922
+ <div class="card">
923
+ <div class="brand"><i></i> Ark architecture report</div>
924
+ <h1>${esc(project)} <span class="badge ${status}">${status}</span> <span class="badge mode">${esc(modeLabel)}</span></h1>
925
+ <p class="lede">${esc(modeBlurb)} One machine-readable contract · write gate · CI · optional runtime.</p>
926
+ <div class="kpis">
927
+ <div class="kpi"><b>${esc(govLabel)}</b><span>Governed</span></div>
928
+ <div class="kpi"><b>${layers.length}</b><span>Layers</span></div>
929
+ <div class="kpi"><b>${gatesOn}/${enforcement.length}</b><span>Gates live</span></div>
930
+ <div class="kpi"><b>${violations.length}${suppressed ? ` · ${suppressed}Δ` : ''}</b><span>Violations${suppressed ? ' · frozen' : ''}</span></div>
931
+ </div>
932
+ <p class="meta">${meta}</p>
933
+ ${skillsNote}
934
+ </div>
935
+ <div class="card score-card">
936
+ <div class="score-ring ${scoreTone}"><div><div class="score-n">${score}</div><div class="dim" style="font-size:.72rem;letter-spacing:.08em;text-transform:uppercase">Ark score</div></div></div>
937
+ <p class="score-cap">${esc(scoreCaption)}</p>
938
+ <p class="meta" style="margin-top:.65rem">Coverage ${scoreCoverage} · Clean ${scoreClean} · Gates ${scoreGates} · Rules ${scoreRules}</p>
939
+ </div>
940
+ </div>
941
+
942
+ <div class="section card" id="adoption">
943
+ <h2>Adoption</h2>
944
+ <p class="dim" style="margin:.15rem 0 .75rem;font-size:.88rem">
945
+ Co-pilot completeness — separate from the 0–100 fitness score above. Hosts, MCP health, origin snapshot, core optionality, baseline policy.
946
+ </p>
947
+ <div class="kpis" style="margin-bottom:.75rem">
948
+ <div class="kpi"><b>${adoptionView.gaps.length === 0 ? 'OK' : adoptionView.gaps.length}</b><span>${adoptionView.gaps.length === 0 ? 'No adoption gaps' : 'Adoption gap(s)'}</span></div>
949
+ <div class="kpi"><b>${adoptionView.originReport.present ? 'yes' : 'no'}</b><span>Origin report</span></div>
950
+ <div class="kpi"><b>${esc(adoptionView.baseline.signal)}</b><span>Baseline policy</span></div>
951
+ <div class="kpi"><b>${adoptionView.mcp.ok ? 'ok' : 'fix'}</b><span>Repo MCP argv</span></div>
952
+ </div>
953
+ ${
954
+ adoptionView.gaps.length
955
+ ? `<ul class="senior-list">${adoptionView.gaps
956
+ .map(
957
+ (g) =>
958
+ `<li><b>${esc(g.id)}</b> — ${esc(g.message)}${
959
+ g.fix ? `<br/><code>${esc(g.fix)}</code>` : ''
960
+ }</li>`
961
+ )
962
+ .join('')}</ul>`
963
+ : '<p class="clean-body">No adoption gaps detected for hosts, MCP, core optionality, or origin.</p>'
964
+ }
965
+ ${
966
+ adoptionView.coreOptional.length
967
+ ? `<p class="dim" style="margin-top:.65rem">Optional-but-populated cores: <code>${adoptionView.coreOptional
968
+ .map((c) => `${esc(c.layer)} (${c.files})`)
969
+ .join('</code>, <code>')}</code></p>`
970
+ : ''
971
+ }
972
+ ${
973
+ adoptionView.hosts.length
974
+ ? `<p class="dim" style="margin-top:.4rem">Hosts: ${adoptionView.hosts
975
+ .map((h) => `${esc(h.host)}${h.complete ? ' ✓' : ' incomplete'}`)
976
+ .join(' · ')}</p>`
977
+ : ''
978
+ }
979
+ </div>
980
+
981
+ <div class="section grid-2">
982
+ <div class="card">
983
+ <h2>Architecture map</h2>
984
+ <p class="dim" style="margin:.15rem 0 0.75rem;font-size:.88rem">Outer rings = entrypoints & adapters. Center = purest core.</p>
985
+ ${onionSvg}
986
+ </div>
987
+ <div class="card">
988
+ <h2>Files per layer</h2>
989
+ <p class="dim" style="margin:.15rem 0 0.75rem;font-size:.88rem">${classifiedFiles} classified · ${totalFiles} in scope${coverage?.unclassified?.count ? ` · ${coverage.unclassified.count} unclassified` : ''}</p>
990
+ ${barRows || '<p class="dim">No layer file counts.</p>'}
991
+ </div>
992
+ </div>
993
+
994
+ <div class="section card">
995
+ <h2>Layers</h2>
996
+ <p class="dim" style="margin:.15rem 0 .75rem;font-size:.88rem">Innermost (most restricted) → outermost (entrypoints). Forbidden globals protect pure cores.</p>
997
+ <table class="layers">
998
+ <tr><th>Layer</th><th>Purpose</th><th>Files</th><th>Patterns</th><th>Example</th></tr>
999
+ ${layerRows || '<tr><td colspan="5" class="dim">No layers configured.</td></tr>'}
1000
+ </table>
1001
+ </div>
1002
+
1003
+ <div class="section card">
1004
+ <h2>Dependency direction</h2>
1005
+ <p class="dim" style="margin:.15rem 0 .75rem;font-size:.88rem">Inner layers stay ignorant of outer ones. Each row lists what it may import.</p>
1006
+ ${flowRows || '<p class="dim">No layers configured.</p>'}
1007
+ <details open>
1008
+ <summary>Full matrix (precise ✓ / ✕ grid)</summary>
1009
+ <div class="matrix-scroll"><table class="matrix">
1010
+ <thead><tr><th class="corner"></th>${matrixHead}</tr></thead>
1011
+ <tbody>${matrixBody}</tbody>
1012
+ </table></div>
1013
+ <p class="legend">Row imports column (left → top). ✓ allowed · ✕ denied · · = no explicit rule / self. Denied edges: ${deniedCount} · explicit allows: ${allowedCount} · purity-guarded layers: ${guarded}</p>
1014
+ </details>
1015
+ </div>
1016
+
1017
+ <div class="section card">
1018
+ <h2>Violations</h2>
1019
+ ${violationBlocks}
1020
+ </div>
1021
+
1022
+ <div class="section card">
1023
+ <h2>Enforcement points</h2>
1024
+ <p class="dim" style="margin:.15rem 0 .85rem;font-size:.88rem">Write-time · merge-time · editor · ratchet. Same contract everywhere.</p>
1025
+ <div class="gates">${enforcementRows}</div>
1026
+ </div>
1027
+
1028
+ ${(() => {
1029
+ if (!currentSnapshot) return '';
1030
+ // First report: originSnapshot is null at render time (written to disk just after).
1031
+ if (originJustCreated || !originSnapshot) {
1032
+ return `<div class="section card evolve">
1033
+ <h2>Origin baseline captured</h2>
1034
+ <p class="dim" style="margin:.2rem 0 0;font-size:.9rem">
1035
+ This is the <b>first</b> architecture snapshot for this project
1036
+ (<code>.ark/reports/origin.json</code> + <code>origin.html</code>).
1037
+ Future reports will show deltas against this starting point so you can prove evolution.
1038
+ </p>
1039
+ </div>`;
1040
+ }
1041
+ const rows = [
1042
+ ['Ark score', originSnapshot.score, currentSnapshot.score, ''],
1043
+ ['Governed %', originSnapshot.governedPercent, currentSnapshot.governedPercent, 'pp'],
1044
+ ['Files in scope', originSnapshot.totalFiles, currentSnapshot.totalFiles, ''],
1045
+ ['Classified files', originSnapshot.classifiedFiles, currentSnapshot.classifiedFiles, ''],
1046
+ ['Active violations', originSnapshot.activeViolations, currentSnapshot.activeViolations, ''],
1047
+ ['Value violations', originSnapshot.valueViolations, currentSnapshot.valueViolations, ''],
1048
+ ['Type-only violations', originSnapshot.typeOnlyViolations, currentSnapshot.typeOnlyViolations, ''],
1049
+ ['Layers', originSnapshot.layerCount, currentSnapshot.layerCount, ''],
1050
+ ['Deny rules', originSnapshot.denyRules, currentSnapshot.denyRules, ''],
1051
+ ['Gates live', originSnapshot.gatesOn, currentSnapshot.gatesOn, ''],
1052
+ ];
1053
+ const originDate = (originSnapshot.generatedAt || '').slice(0, 10) || 'origin';
1054
+ const nowDate = (currentSnapshot.generatedAt || '').slice(0, 10) || 'now';
1055
+ const tr = rows
1056
+ .map(([label, from, to, unit]) => {
1057
+ const d =
1058
+ typeof from === 'number' && typeof to === 'number' ? to - from : null;
1059
+ const good =
1060
+ label.includes('violation') || label.includes('Violation')
1061
+ ? d != null && d <= 0
1062
+ : label.includes('Governed') || label.includes('score') || label.includes('Classified') || label.includes('Gates')
1063
+ ? d != null && d >= 0
1064
+ : null;
1065
+ const cls =
1066
+ d == null || d === 0 ? 'flat' : good === true ? 'up' : good === false ? 'down' : 'flat';
1067
+ const delta =
1068
+ d == null
1069
+ ? '—'
1070
+ : unit === 'pp'
1071
+ ? formatDelta(Math.round(d * 10) / 10, { suffix: ' pp' })
1072
+ : formatDelta(d);
1073
+ return `<tr>
1074
+ <td>${esc(label)}</td>
1075
+ <td class="num">${from ?? '—'}</td>
1076
+ <td class="num">${to ?? '—'}</td>
1077
+ <td class="num delta ${cls}">${esc(delta)}</td>
1078
+ </tr>`;
1079
+ })
1080
+ .join('\n');
1081
+ // Layer file deltas
1082
+ const originLayers = originSnapshot.layerFiles || {};
1083
+ const currentLayers = currentSnapshot.layerFiles || {};
1084
+ const layerKeys = [...new Set([...Object.keys(originLayers), ...Object.keys(currentLayers)])].sort();
1085
+ const layerTr = layerKeys
1086
+ .map((name) => {
1087
+ const from = originLayers[name] || 0;
1088
+ const to = currentLayers[name] || 0;
1089
+ const d = to - from;
1090
+ const cls = d === 0 ? 'flat' : d > 0 ? 'up' : 'down';
1091
+ return `<tr>
1092
+ <td class="ln">${esc(name)}</td>
1093
+ <td class="num">${from}</td>
1094
+ <td class="num">${to}</td>
1095
+ <td class="num delta ${cls}">${esc(formatDelta(d))}</td>
1096
+ </tr>`;
1097
+ })
1098
+ .join('\n');
1099
+ return `<div class="section card evolve">
1100
+ <h2>Evolution vs origin</h2>
1101
+ <p class="dim" style="margin:.15rem 0 .75rem;font-size:.88rem">
1102
+ Origin snapshot <code>${esc(originDate)}</code> → this report <code>${esc(nowDate)}</code>
1103
+ · frozen at <code>.ark/reports/origin.*</code> · reopen origin HTML anytime for the starting picture.
1104
+ </p>
1105
+ <table class="layers">
1106
+ <tr><th>Metric</th><th>Origin</th><th>Now</th><th>Δ</th></tr>
1107
+ ${tr}
1108
+ </table>
1109
+ <h3>Files per layer</h3>
1110
+ <table class="layers">
1111
+ <tr><th>Layer</th><th>Origin</th><th>Now</th><th>Δ</th></tr>
1112
+ ${layerTr || '<tr><td colspan="4" class="dim">No layer file data in snapshots.</td></tr>'}
1113
+ </table>
1114
+ <p class="legend">Green Δ = improvement for that metric (↑ coverage/score/gates, ↓ violations). History JSON under <code>.ark/reports/history/</code> (last ${ARK_REPORT_HISTORY_MAX}).</p>
1115
+ </div>`;
1116
+ })()}
1117
+
1118
+ <div class="section card senior">
1119
+ <h2>Senior diagnostics</h2>
1120
+ <p class="dim" style="margin:.15rem 0 .85rem;font-size:.88rem">
1121
+ Coupling, purity surface, contract density, and config forensics — for tech leads reviewing the fitness of the gate itself.
1122
+ </p>
1123
+
1124
+ <h3>Contract density</h3>
1125
+ <div class="kpis" style="margin-top:.35rem">
1126
+ <div class="kpi"><b>${denyRatio}%</b><span>Edges denied</span></div>
1127
+ <div class="kpi"><b>${deniedCount}</b><span>Deny rules</span></div>
1128
+ <div class="kpi"><b>${allowedCount}</b><span>Explicit allows</span></div>
1129
+ <div class="kpi"><b>${pairCount}</b><span>Directed pairs</span></div>
1130
+ </div>
1131
+ <p class="dim" style="margin:.55rem 0 0;font-size:.84rem">
1132
+ Deny ratio = denied ÷ (layers × (layers−1)). High ratio = strict inward architecture.
1133
+ Package manager detected: <code>${esc(packageManagerLabel)}</code>
1134
+ · include roots: <code>${includeRoots.map(esc).join('</code>, <code>') || '—'}</code>
1135
+ ${emptyLayers.length ? ` · empty layers: <code>${emptyLayers.map(esc).join(', ')}</code>` : ''}
1136
+ ${layersWithoutRules.length ? ` · layers with no rule edge: <code>${layersWithoutRules.map(esc).join(', ')}</code>` : ''}
1137
+ ${unclassifiedCount ? ` · unclassified files: <b>${unclassifiedCount}</b>` : ''}
1138
+ </p>
1139
+
1140
+ <h3>Layer coupling (allowed import graph)</h3>
1141
+ <p class="dim" style="margin:.1rem 0 .55rem;font-size:.84rem">
1142
+ Fan-out = layers this layer may import · Fan-in = layers that may import it · based on non-denied edges (implicit allow counts as open).
1143
+ </p>
1144
+ <table class="layers">
1145
+ <tr><th>Layer</th><th>Files</th><th>Fan-out</th><th>Fan-in</th><th>Deny-out</th><th>FO/files</th></tr>
1146
+ ${couplingRows
1147
+ .map(
1148
+ (r) => `<tr>
1149
+ <td class="ln">${esc(r.name)}</td>
1150
+ <td class="num">${r.files}</td>
1151
+ <td class="num">${r.fo}</td>
1152
+ <td class="num">${r.fi}</td>
1153
+ <td class="num">${r.denyOut}</td>
1154
+ <td class="num">${r.density}</td>
1155
+ </tr>`
1156
+ )
1157
+ .join('\n')}
1158
+ </table>
1159
+ <p class="legend">High fan-out on a large presentation layer is normal. High fan-out on a “domain” layer is a smell — the core is leaking outward privileges.</p>
1160
+
1161
+ <h3>Purity &amp; infrastructure surface</h3>
1162
+ <div class="grid-2" style="margin-top:.5rem">
1163
+ <div>
1164
+ <div class="pill ${purityLayers.length ? 'good' : 'warn'}" style="margin-bottom:.55rem">
1165
+ ${purityLayers.length} purity-guarded layer(s)
1166
+ </div>
1167
+ ${
1168
+ purityLayers.length
1169
+ ? `<ul class="senior-list">${purityLayers
1170
+ .map(
1171
+ (l) =>
1172
+ `<li><b>${esc(l.name)}</b> forbids <code>${(l.forbiddenGlobals || []).map(esc).join('</code>, <code>')}</code></li>`
1173
+ )
1174
+ .join('')}</ul>`
1175
+ : '<p class="dim">No <code>forbiddenGlobals</code> — ambient I/O can still leak into pure cores.</p>'
1176
+ }
1177
+ </div>
1178
+ <div>
1179
+ <div class="pill ${infraLayers.length ? 'good' : 'warn'}" style="margin-bottom:.55rem">
1180
+ ${infraLayers.length} infra-capable layer(s)
1181
+ </div>
1182
+ ${
1183
+ infraLayers.length
1184
+ ? `<ul class="senior-list">${infraLayers
1185
+ .map((l) => `<li><b>${esc(l.name)}</b> <span class="tag">mayImportInfrastructure</span></li>`)
1186
+ .join('')}</ul>`
1187
+ : '<p class="dim">No layer opts into infrastructure imports via <code>mayImportInfrastructure</code> (write-gate heuristic still applies to ungoverned targets).</p>'
1188
+ }
1189
+ ${
1190
+ excludeLayers.length
1191
+ ? `<p class="dim" style="margin-top:.65rem">Exclude globs (facade / kernel carve-outs):</p>
1192
+ <ul class="senior-list">${excludeLayers
1193
+ .map(
1194
+ (l) =>
1195
+ `<li><b>${esc(l.name)}</b> · <code>${(l.exclude || []).map(esc).join('</code>, <code>')}</code></li>`
1196
+ )
1197
+ .join('')}</ul>`
1198
+ : ''
1199
+ }
1200
+ </div>
1201
+ </div>
1202
+
1203
+ <h3>Intent prefixes</h3>
1204
+ ${
1205
+ intentMap.length
1206
+ ? `<table class="layers"><tr><th>Layer</th><th>Prefixes</th></tr>
1207
+ ${intentMap
1208
+ .map(
1209
+ (row) =>
1210
+ `<tr><td class="ln">${esc(row.name)}</td><td><code>${row.prefixes.map(esc).join('</code> <code>')}</code></td></tr>`
1211
+ )
1212
+ .join('\n')}</table>`
1213
+ : '<p class="dim">No <code>intentPrefixes</code> on layers — runtime intent governance and string-intent checks have less to bind to.</p>'
1214
+ }
1215
+
1216
+ <h3>Layer balance (educational)</h3>
1217
+ ${
1218
+ adoptionView.layerBalance
1219
+ ? `<p class="dim" style="margin:.1rem 0 .55rem;font-size:.88rem">${esc(adoptionView.layerBalance.educational)}</p>
1220
+ <p class="meta">PresentationAdapters ${adoptionView.layerBalance.presentationFiles} · DomainModel ${adoptionView.layerBalance.domainFiles} · total ${adoptionView.layerBalance.totalFiles}</p>`
1221
+ : '<p class="dim" style="margin:.1rem 0 .55rem;font-size:.88rem">No presentation-heavy / thin-domain imbalance flagged (educational only when Presentation ≥50% and Domain &lt;10% of files).</p>'
1222
+ }
1223
+
1224
+ <h3>Pattern forensics</h3>
1225
+ <div class="grid-2" style="margin-top:.45rem">
1226
+ <div>
1227
+ <p class="dim" style="margin:0 0 .4rem;font-size:.84rem">Broadest globs (watch for over-governance / false layer hits)</p>
1228
+ ${
1229
+ broadPatterns.length
1230
+ ? `<ul class="senior-list">${broadPatterns
1231
+ .slice(0, 8)
1232
+ .map(
1233
+ (p) =>
1234
+ `<li><b>${esc(p.layer)}</b> · <code>${esc(p.pattern)}</code> <span class="dim">spec ${p.score}</span></li>`
1235
+ )
1236
+ .join('')}</ul>`
1237
+ : '<p class="dim">No ultra-broad patterns detected.</p>'
1238
+ }
1239
+ </div>
1240
+ <div>
1241
+ <p class="dim" style="margin:0 0 .4rem;font-size:.84rem">Most precise patterns (file-level overlays, facades)</p>
1242
+ ${
1243
+ precisePatterns.length
1244
+ ? `<ul class="senior-list">${precisePatterns
1245
+ .slice(0, 8)
1246
+ .map(
1247
+ (p) =>
1248
+ `<li><b>${esc(p.layer)}</b> · <code>${esc(p.pattern)}</code> <span class="dim">spec ${p.score}</span></li>`
1249
+ )
1250
+ .join('')}</ul>`
1251
+ : '<p class="dim">No file-level patterns — only directory globs.</p>'
1252
+ }
1253
+ </div>
1254
+ </div>
1255
+
1256
+ <h3>Debt &amp; violation taxonomy</h3>
1257
+ <div class="kpis" style="margin-top:.35rem">
1258
+ <div class="kpi"><b>${violations.length}</b><span>Active</span></div>
1259
+ <div class="kpi"><b>${valueN}</b><span>Value edges</span></div>
1260
+ <div class="kpi"><b>${typeOnlyN}</b><span>Type-only</span></div>
1261
+ <div class="kpi"><b>${suppressed || baselineKeys}</b><span>Baseline keys</span></div>
1262
+ </div>
1263
+ ${
1264
+ topEdges.length
1265
+ ? `<p class="dim" style="margin:.55rem 0 .35rem;font-size:.84rem">Hottest active edges</p>
1266
+ <ul class="senior-list">${topEdges
1267
+ .map(([edge, n]) => `<li><span class="edge">${esc(edge)}</span> · <b>${n}</b></li>`)
1268
+ .join('')}</ul>`
1269
+ : '<p class="dim" style="margin-top:.55rem">No active edge concentration — either clean or all debt is baselined.</p>'
1270
+ }
1271
+
1272
+ <details style="margin-top:1rem">
1273
+ <summary>Score model (transparent)</summary>
1274
+ <p class="legend">
1275
+ Ark score = 0.4×coverage + 0.3×clean + 0.2×gates + 0.1×rule-density.
1276
+ Coverage=${scoreCoverage}, clean=${scoreClean}, gates=${scoreGates}, rules=${scoreRules} → <b>${score}</b>.
1277
+ This is a fitness signal for humans, not a CI gate.
1278
+ </p>
1279
+ </details>
1280
+ </div>
1281
+
1282
+ <div class="section card">
1283
+ <h2>Commands worth memorizing</h2>
1284
+ <div class="cmds">
1285
+ <code>${arkCheckCommand(root)}</code>
1286
+ <code>${arkCommand(root, 'ark-check', '--coverage')}</code>
1287
+ <code>${arkCommand(root, 'ark-check', '--plan')}</code>
1288
+ <code>${arkCommand(root, 'ark-check', '--doctor')}</code>
1289
+ <code>${arkCommand(root, 'ark-check', '--report ark-report.html')}</code>
1290
+ <code>/ark-place "&lt;what you're building&gt;"</code>
1291
+ <code>/ark-explain</code>
1292
+ </div>
1293
+ </div>
1294
+
1295
+ <footer>
1296
+ Generated by ${meta || 'ark-check'} · visual twin of <code>/ark-explain</code>.
1297
+ Regenerate with <code>ark-check --report</code>; add the file to <code>.gitignore</code> rather than committing it.
1298
+ </footer>
1299
+ </div></body></html>
1300
+ `;
1301
+ }