@mandujs/core 0.41.2 → 0.43.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.
Files changed (88) hide show
  1. package/package.json +21 -4
  2. package/src/auth/__tests__/login.test.ts +420 -419
  3. package/src/auth/__tests__/reset.test.ts +296 -296
  4. package/src/brain/adapters/anthropic-oauth.ts +421 -420
  5. package/src/brain/adapters/index.ts +2 -1
  6. package/src/brain/adapters/ollama.ts +1 -1
  7. package/src/brain/adapters/openai-oauth.ts +534 -533
  8. package/src/brain/brain.ts +2 -1
  9. package/src/brain/redactor.ts +196 -196
  10. package/src/bundler/__tests__/cli-bench-utils.test.ts +149 -149
  11. package/src/bundler/__tests__/cold-start.test.ts +504 -504
  12. package/src/bundler/__tests__/fast-refresh.test.ts +607 -606
  13. package/src/bundler/__tests__/hdr.test.ts +1 -1
  14. package/src/bundler/analyzer.ts +958 -958
  15. package/src/bundler/build.ts +104 -14
  16. package/src/bundler/dev.ts +125 -0
  17. package/src/bundler/hmr-types.ts +1 -0
  18. package/src/bundler/plugins/__tests__/react-compiler-lint.test.ts +110 -0
  19. package/src/bundler/plugins/index.ts +14 -0
  20. package/src/bundler/plugins/react-compiler-lint.ts +253 -0
  21. package/src/bundler/plugins/react-compiler.ts +162 -0
  22. package/src/bundler/types.ts +12 -0
  23. package/src/change/integrity.ts +2 -1
  24. package/src/client/index.ts +10 -0
  25. package/src/client/island.ts +38 -11
  26. package/src/client/router.ts +6 -1
  27. package/src/config/mandu.ts +57 -0
  28. package/src/config/validate.ts +42 -0
  29. package/src/content/collection.ts +844 -809
  30. package/src/content/content-layer.ts +316 -314
  31. package/src/content/content.test.ts +433 -433
  32. package/src/content/digest.ts +133 -133
  33. package/src/content/generate-types.ts +168 -168
  34. package/src/content/index.ts +6 -1
  35. package/src/content/llms-txt.ts +277 -277
  36. package/src/contract/define.ts +474 -474
  37. package/src/contract/route-helpers.ts +2 -1
  38. package/src/contract/zod-utils.ts +158 -155
  39. package/src/db/index.ts +513 -513
  40. package/src/desktop/__tests__/smoke.test.ts +100 -100
  41. package/src/desktop/webview-fallback.ts +583 -583
  42. package/src/desktop/window.ts +3 -1
  43. package/src/dev-error-overlay/overlay-client.ts +300 -300
  44. package/src/devtools/ai/mcp-connector.ts +499 -498
  45. package/src/devtools/client/components/kitchen-root.tsx +7 -2
  46. package/src/email/resend.ts +163 -163
  47. package/src/guard/__tests__/tsgolint-bridge.test.ts +347 -0
  48. package/src/guard/ast-analyzer.ts +806 -806
  49. package/src/guard/graph.ts +898 -898
  50. package/src/guard/index.ts +16 -0
  51. package/src/guard/statistics.ts +578 -578
  52. package/src/guard/tsgolint-bridge.ts +512 -0
  53. package/src/i18n/locale-resolver.ts +214 -214
  54. package/src/id/__tests__/id.test.ts +120 -120
  55. package/src/intent/index.ts +321 -321
  56. package/src/island/index.ts +39 -23
  57. package/src/kitchen/api/contract-api.ts +15 -8
  58. package/src/kitchen/kitchen-ui.ts +2137 -2137
  59. package/src/lockfile/index.ts +3 -2
  60. package/src/middleware/oauth/__tests__/oauth.test.ts +575 -574
  61. package/src/middleware/rate-limit/__tests__/rate-limit.test.ts +642 -642
  62. package/src/middleware/secure/index.ts +417 -417
  63. package/src/observability/event-bus.ts +2 -2
  64. package/src/observability/metrics.ts +334 -334
  65. package/src/observability/tracing.ts +694 -694
  66. package/src/openapi/generator.ts +1 -1
  67. package/src/perf/user-marks.ts +553 -553
  68. package/src/plugins/registry.ts +387 -387
  69. package/src/resource/ddl/diff.ts +392 -392
  70. package/src/resource/ddl/snapshot.ts +448 -447
  71. package/src/resource/generator-schema.ts +477 -476
  72. package/src/resource/parser.ts +4 -2
  73. package/src/resource/schema.ts +1 -1
  74. package/src/router/fs-patterns.ts +422 -422
  75. package/src/runtime/fast-refresh-types.ts +126 -128
  76. package/src/runtime/image-handler.ts +206 -195
  77. package/src/runtime/router.test.ts +476 -476
  78. package/src/runtime/security.ts +155 -155
  79. package/src/runtime/server.ts +36 -19
  80. package/src/runtime/session-key.ts +328 -328
  81. package/src/scheduler/__tests__/scheduler.test.ts +514 -514
  82. package/src/seo/resolve/index.ts +353 -353
  83. package/src/spec/load.ts +1 -1
  84. package/src/testing/reporter.ts +676 -676
  85. package/src/testing/server.ts +196 -196
  86. package/src/testing/snapshot.ts +444 -444
  87. package/src/utils/__tests__/lru-cache.test.ts +186 -186
  88. package/src/utils/bun.ts +8 -8
@@ -1,898 +1,898 @@
1
- /**
2
- * Phase 18.π — Guard dependency graph visualizer.
3
- *
4
- * Scans a project's source tree, resolves imports between modules, tags each
5
- * module with its architectural layer (per the active guard preset), and
6
- * emits:
7
- *
8
- * analyzeDependencyGraph(config, rootDir) → DependencyGraph
9
- * {
10
- * nodes: ModuleNode[] // one per source file inside srcDir
11
- * edges: ImportEdge[] // one per (source file → resolved
12
- * target file) import, with violation
13
- * flag + layer tagging
14
- * layers: Layer[] // layers active in this project, keyed
15
- * by hierarchy order
16
- * violations: Violation[] // full guard report violations
17
- * summary: { … counts … }
18
- * }
19
- *
20
- * And a single-file, self-contained HTML viewer:
21
- *
22
- * renderGraphHtml(graph) → string
23
- * - Inline SVG, layered layout (deterministic — no force-directed random
24
- * seeding, CI-stable). Nodes bucketed per layer, row = layer.
25
- * - Dark theme. No external CDN, no d3, no runtime deps.
26
- * - Click node → side panel shows importers + importees + file path.
27
- * - Filter bar: "show only violations" toggle + per-layer chips.
28
- *
29
- * Design spirit matches Phase 18.η bundle analyzer (`bundler/analyzer.ts`):
30
- * deterministic, zero runtime deps, portable single HTML file.
31
- *
32
- * @module guard/graph
33
- */
34
-
35
- import { existsSync } from "fs";
36
- import { relative, resolve, sep, isAbsolute, dirname, extname } from "path";
37
- import { minimatch } from "minimatch";
38
- import type {
39
- GuardConfig,
40
- LayerDefinition,
41
- Violation,
42
- FileAnalysis,
43
- } from "./types";
44
- import { WATCH_EXTENSIONS, DEFAULT_GUARD_CONFIG } from "./types";
45
- import {
46
- analyzeFile,
47
- shouldAnalyzeFile,
48
- shouldIgnoreImport,
49
- } from "./analyzer";
50
- import {
51
- validateFileAnalysis,
52
- detectCircularDependencies,
53
- } from "./validator";
54
- import { getPreset } from "./presets";
55
-
56
- // ════════════════════════════════════════════════════════════════════════════
57
- // Public types
58
- // ════════════════════════════════════════════════════════════════════════════
59
-
60
- /**
61
- * Single module (source file) in the dependency graph.
62
- */
63
- export interface ModuleNode {
64
- /** Stable id — POSIX path relative to rootDir. */
65
- id: string;
66
- /** Absolute filesystem path. */
67
- filePath: string;
68
- /** Human-friendly label (basename w/o extension + parent dir). */
69
- label: string;
70
- /** Resolved guard layer, or `null` if outside any layer. */
71
- layer: string | null;
72
- /** FSD slice (second path segment after layer), if any. */
73
- slice?: string;
74
- }
75
-
76
- /**
77
- * A single import edge `source → target`. Both endpoints are module ids
78
- * present in `nodes`. Unresolvable imports (node_modules, ambient types,
79
- * etc.) are dropped.
80
- */
81
- export interface ImportEdge {
82
- /** Source node id. */
83
- from: string;
84
- /** Target node id. */
85
- to: string;
86
- /** Layer of source. */
87
- fromLayer: string | null;
88
- /** Layer of target. */
89
- toLayer: string | null;
90
- /** True if this edge corresponds to a guard violation. */
91
- violation: boolean;
92
- /** Import statement line number (1-indexed). */
93
- line: number;
94
- }
95
-
96
- /**
97
- * Layer bucket — a visual row in the graph.
98
- */
99
- export interface Layer {
100
- /** Layer name (matches guard preset). */
101
- name: string;
102
- /** Rank in the hierarchy (0 = top / outermost layer). */
103
- rank: number;
104
- /** Human-friendly description. */
105
- description?: string;
106
- /** Number of nodes in this layer. */
107
- nodeCount: number;
108
- }
109
-
110
- /**
111
- * Summary counts displayed in the HTML header.
112
- */
113
- export interface GraphSummary {
114
- /** Node count. */
115
- nodes: number;
116
- /** Edge count (successfully resolved imports). */
117
- edges: number;
118
- /** Number of violation edges. */
119
- violationEdges: number;
120
- /** Total guard violations (includes circulars not mapped to a single edge). */
121
- violations: number;
122
- /** Files analyzed (same as nodes, duplicated for symmetry with guard report). */
123
- filesAnalyzed: number;
124
- /** Active guard preset name. */
125
- preset: string;
126
- /** Source directory scanned. */
127
- srcDir: string;
128
- /** ISO timestamp of generation. */
129
- generatedAt: string;
130
- /** Schema version — bump when shape changes. */
131
- version: number;
132
- }
133
-
134
- /**
135
- * Full dependency graph.
136
- */
137
- export interface DependencyGraph {
138
- nodes: ModuleNode[];
139
- edges: ImportEdge[];
140
- layers: Layer[];
141
- violations: Violation[];
142
- summary: GraphSummary;
143
- }
144
-
145
- const GRAPH_SCHEMA_VERSION = 1;
146
-
147
- // ════════════════════════════════════════════════════════════════════════════
148
- // Graph builder
149
- // ════════════════════════════════════════════════════════════════════════════
150
-
151
- /**
152
- * Scan srcDir, build nodes + edges + layer tagging + violation overlay.
153
- *
154
- * Deterministic: sorts nodes / edges / layers for stable output across runs.
155
- * Pure (does not write to disk).
156
- */
157
- export async function analyzeDependencyGraph(
158
- config: GuardConfig,
159
- rootDir: string
160
- ): Promise<DependencyGraph> {
161
- const layers = resolveLayerDefinitions(config);
162
- const hierarchy = resolveHierarchy(config);
163
- const srcDir = config.srcDir ?? DEFAULT_GUARD_CONFIG.srcDir;
164
- const exclude = config.exclude ?? DEFAULT_GUARD_CONFIG.exclude;
165
-
166
- // 1. Discover files. Mirror watcher's scan logic (srcDir + optional app/).
167
- const files = await discoverFiles(rootDir, srcDir, config.fsRoutes ? "app" : null, exclude);
168
-
169
- // 2. Analyze each file — imports + layer.
170
- const analyses: FileAnalysis[] = [];
171
- for (const file of files) {
172
- if (!shouldAnalyzeFile(file, config, rootDir)) continue;
173
- try {
174
- const analysis = await analyzeFile(file, layers, rootDir);
175
- analyses.push(analysis);
176
- } catch {
177
- // Skip unreadable files — matches watcher behavior.
178
- }
179
- }
180
-
181
- // 3. Build node map keyed by POSIX relpath.
182
- const nodesById = new Map<string, ModuleNode>();
183
- for (const a of analyses) {
184
- const id = toPosix(relative(rootDir, a.filePath));
185
- nodesById.set(id, {
186
- id,
187
- filePath: a.filePath,
188
- label: computeLabel(id),
189
- layer: a.layer,
190
- slice: a.slice,
191
- });
192
- }
193
-
194
- // 4. Resolve edges. For each import in each analysis, find the matching
195
- // file on disk (same extension set as WATCH_EXTENSIONS). Drop anything
196
- // unresolvable (external deps, ambient types).
197
- const edges: ImportEdge[] = [];
198
- const violations: Violation[] = [];
199
-
200
- for (const analysis of analyses) {
201
- const fromId = toPosix(relative(rootDir, analysis.filePath));
202
- const fileViolations = validateFileAnalysis(analysis, layers, config);
203
- violations.push(...fileViolations);
204
-
205
- // Index violations by (line, path) so we can tag edges without re-running
206
- // the validator's rule logic.
207
- const violationKey = (line: number, importPath: string): string =>
208
- `${line}::${importPath}`;
209
- const violationSet = new Set<string>();
210
- for (const v of fileViolations) {
211
- violationSet.add(violationKey(v.line, v.importPath));
212
- }
213
-
214
- for (const imp of analysis.imports) {
215
- if (shouldIgnoreImport(imp.path, config)) continue;
216
- const targetAbs = resolveImportToFile(
217
- imp.path,
218
- analysis.filePath,
219
- rootDir,
220
- config,
221
- nodesById
222
- );
223
- if (!targetAbs) continue;
224
- const toId = toPosix(relative(rootDir, targetAbs));
225
- if (!nodesById.has(toId)) continue;
226
- if (toId === fromId) continue; // self-import (barrel re-export from same file)
227
-
228
- const fromNode = nodesById.get(fromId)!;
229
- const toNode = nodesById.get(toId)!;
230
-
231
- edges.push({
232
- from: fromId,
233
- to: toId,
234
- fromLayer: fromNode.layer,
235
- toLayer: toNode.layer,
236
- violation: violationSet.has(violationKey(imp.line, imp.path)),
237
- line: imp.line,
238
- });
239
- }
240
- }
241
-
242
- // 5. Circular dependencies are detected at the batch level — append to the
243
- // violations collection so the HTML counter is accurate.
244
- if (analyses.length > 0) {
245
- violations.push(...detectCircularDependencies(analyses, layers, config));
246
- }
247
-
248
- // 6. Compute layer buckets.
249
- const layerCounts = new Map<string, number>();
250
- for (const node of nodesById.values()) {
251
- if (!node.layer) continue;
252
- layerCounts.set(node.layer, (layerCounts.get(node.layer) ?? 0) + 1);
253
- }
254
-
255
- const layerBuckets: Layer[] = [];
256
- const seen = new Set<string>();
257
- // First pass: layers explicitly in hierarchy (preserves order).
258
- for (let rank = 0; rank < hierarchy.length; rank++) {
259
- const name = hierarchy[rank];
260
- if (layerCounts.has(name)) {
261
- const def = layers.find((l) => l.name === name);
262
- layerBuckets.push({
263
- name,
264
- rank,
265
- description: def?.description,
266
- nodeCount: layerCounts.get(name) ?? 0,
267
- });
268
- seen.add(name);
269
- }
270
- }
271
- // Second pass: layers present in nodes but not in hierarchy (custom / overrides).
272
- let extraRank = layerBuckets.length;
273
- for (const [name, count] of Array.from(layerCounts.entries()).sort()) {
274
- if (seen.has(name)) continue;
275
- const def = layers.find((l) => l.name === name);
276
- layerBuckets.push({
277
- name,
278
- rank: extraRank++,
279
- description: def?.description,
280
- nodeCount: count,
281
- });
282
- }
283
-
284
- // 7. Deterministic ordering.
285
- const sortedNodes = Array.from(nodesById.values()).sort((a, b) =>
286
- a.id.localeCompare(b.id)
287
- );
288
- edges.sort((a, b) => {
289
- if (a.from !== b.from) return a.from.localeCompare(b.from);
290
- if (a.to !== b.to) return a.to.localeCompare(b.to);
291
- return a.line - b.line;
292
- });
293
-
294
- const violationEdges = edges.filter((e) => e.violation).length;
295
-
296
- return {
297
- nodes: sortedNodes,
298
- edges,
299
- layers: layerBuckets,
300
- violations,
301
- summary: {
302
- nodes: sortedNodes.length,
303
- edges: edges.length,
304
- violationEdges,
305
- violations: violations.length,
306
- filesAnalyzed: sortedNodes.length,
307
- preset: config.preset ?? "custom",
308
- srcDir,
309
- generatedAt: new Date().toISOString(),
310
- version: GRAPH_SCHEMA_VERSION,
311
- },
312
- };
313
- }
314
-
315
- // ════════════════════════════════════════════════════════════════════════════
316
- // File discovery
317
- // ════════════════════════════════════════════════════════════════════════════
318
-
319
- async function discoverFiles(
320
- rootDir: string,
321
- srcDir: string,
322
- extraRoot: string | null,
323
- exclude: string[]
324
- ): Promise<string[]> {
325
- const { glob } = await import("glob");
326
- const extensions = WATCH_EXTENSIONS.map((e) => e.slice(1)).join(",");
327
- const roots = new Set<string>([srcDir]);
328
- if (extraRoot) roots.add(extraRoot);
329
-
330
- const found = new Set<string>();
331
- for (const root of roots) {
332
- const pattern = `${root}/**/*.{${extensions}}`;
333
- const hits = await glob(pattern, {
334
- cwd: rootDir,
335
- ignore: exclude,
336
- absolute: true,
337
- });
338
- for (const hit of hits) found.add(hit);
339
- }
340
- return Array.from(found);
341
- }
342
-
343
- // ════════════════════════════════════════════════════════════════════════════
344
- // Import → file resolution
345
- // ════════════════════════════════════════════════════════════════════════════
346
-
347
- /**
348
- * Best-effort resolution from an import specifier to an absolute source file
349
- * present in `nodesById`.
350
- *
351
- * Handles:
352
- * - Relative imports (./x, ../y)
353
- * - `@/`, `~/` alias (resolved against srcDir)
354
- * - Bare `srcDir/...` paths (e.g. "src/features/auth")
355
- * - Directory imports → index.(ts|tsx|js|jsx)
356
- * - Extension-less imports — tries all WATCH_EXTENSIONS
357
- *
358
- * Returns null for node_modules, URL imports, or unmatched paths. Falls back
359
- * to "does a node id exist with a matching POSIX path" to keep CI output
360
- * deterministic even when filesystem lookups would miss (e.g. virtual files
361
- * in tests).
362
- */
363
- function resolveImportToFile(
364
- importPath: string,
365
- fromFile: string,
366
- rootDir: string,
367
- config: GuardConfig,
368
- nodesById: Map<string, ModuleNode>
369
- ): string | null {
370
- const srcDir = (config.srcDir ?? "src").replace(/\\/g, "/").replace(/\/$/, "");
371
- const normalized = importPath.replace(/\\/g, "/");
372
-
373
- const candidates: string[] = [];
374
-
375
- if (normalized.startsWith("@/") || normalized.startsWith("~/")) {
376
- const alias = normalized.slice(2);
377
- if (srcDir) candidates.push(`${srcDir}/${alias}`);
378
- candidates.push(alias);
379
- } else if (normalized.startsWith(".")) {
380
- const absoluteFrom = isAbsolute(fromFile) ? fromFile : resolve(rootDir, fromFile);
381
- const resolved = resolve(dirname(absoluteFrom), normalized);
382
- const rel = toPosix(relative(rootDir, resolved));
383
- if (!rel.startsWith("..")) candidates.push(rel);
384
- } else if (
385
- srcDir &&
386
- (normalized === srcDir || normalized.startsWith(`${srcDir}/`))
387
- ) {
388
- candidates.push(normalized);
389
- } else {
390
- return null; // bare import — node_modules or unknown alias
391
- }
392
-
393
- // Try each candidate with all extension variants + index.* fallback.
394
- for (const candidate of candidates) {
395
- const abs = resolve(rootDir, candidate);
396
- // Exact file (candidate has its own extension already)
397
- if (extname(candidate) && existsSync(abs)) return abs;
398
-
399
- for (const ext of WATCH_EXTENSIONS) {
400
- const withExt = `${abs}${ext}`;
401
- if (existsSync(withExt)) return withExt;
402
- const asIndex = resolve(abs, `index${ext}`);
403
- if (existsSync(asIndex)) return asIndex;
404
- }
405
-
406
- // Virtual-fs fallback — for tests where nodes may be registered but
407
- // fs.existsSync isn't reachable (never hits in normal runs, but keeps
408
- // edge generation deterministic in edge cases).
409
- for (const ext of WATCH_EXTENSIONS) {
410
- const id = `${toPosix(candidate)}${ext}`;
411
- if (nodesById.has(id)) return resolve(rootDir, id);
412
- const idIdx = `${toPosix(candidate)}/index${ext}`;
413
- if (nodesById.has(idIdx)) return resolve(rootDir, idIdx);
414
- }
415
- }
416
- return null;
417
- }
418
-
419
- // ════════════════════════════════════════════════════════════════════════════
420
- // Utilities
421
- // ════════════════════════════════════════════════════════════════════════════
422
-
423
- function toPosix(p: string): string {
424
- return p.replace(/\\/g, "/");
425
- }
426
-
427
- function computeLabel(id: string): string {
428
- const parts = id.split("/");
429
- const base = parts[parts.length - 1] ?? id;
430
- const parent = parts.length > 1 ? parts[parts.length - 2] : "";
431
- const stem = base.replace(/\.[^.]+$/, "");
432
- return parent ? `${parent}/${stem}` : stem;
433
- }
434
-
435
- function resolveLayerDefinitions(config: GuardConfig): LayerDefinition[] {
436
- if (config.layers && config.layers.length > 0) {
437
- return config.layers;
438
- }
439
- if (config.preset) {
440
- const preset = getPreset(config.preset);
441
- let layers = [...preset.layers];
442
- if (config.override?.layers) {
443
- layers = layers.map((layer) => {
444
- const override = config.override?.layers?.[layer.name];
445
- return override ? { ...layer, ...override } : layer;
446
- });
447
- }
448
- return layers;
449
- }
450
- return [];
451
- }
452
-
453
- function resolveHierarchy(config: GuardConfig): string[] {
454
- if (config.preset) {
455
- return [...getPreset(config.preset).hierarchy];
456
- }
457
- return (config.layers ?? []).map((l) => l.name);
458
- }
459
-
460
- // ════════════════════════════════════════════════════════════════════════════
461
- // HTML renderer
462
- // ════════════════════════════════════════════════════════════════════════════
463
-
464
- const LAYER_PALETTE = [
465
- "#1e3a8a", "#155e75", "#166534", "#854d0e", "#7c2d12",
466
- "#581c87", "#831843", "#0f766e", "#3730a3", "#92400e",
467
- "#064e3b", "#6b21a8", "#134e4a", "#7f1d1d", "#1e40af",
468
- ];
469
-
470
- /**
471
- * Render a self-contained HTML graph viewer.
472
- *
473
- * Layout:
474
- * - Rows = layers (rank 0 = top). Nodes in each layer packed horizontally,
475
- * sorted by id → deterministic across runs.
476
- * - Edges drawn as cubic Bezier splines between node centers. Violation
477
- * edges rendered in red, dashed, with markerEnd arrow. Normal edges in
478
- * muted blue.
479
- *
480
- * Interactivity:
481
- * - Click node → side panel lists importers + importees + file path.
482
- * - Top filter bar: "Only violations" toggle + "All layers" / per-layer
483
- * toggle chips.
484
- *
485
- * Portability:
486
- * - Zero external JS / CSS. Drag-and-drop into any browser.
487
- * - File size stays well under 500 KB for projects up to ~2k modules
488
- * (tested on auth-starter: ~30 KB).
489
- */
490
- export function renderGraphHtml(graph: DependencyGraph): string {
491
- const { nodes, edges, layers, summary } = graph;
492
-
493
- // ── Layout ────────────────────────────────────────────────────────────────
494
- const MARGIN_X = 24;
495
- const MARGIN_TOP = 48;
496
- const LAYER_GAP = 120;
497
- const NODE_W = 130;
498
- const NODE_H = 28;
499
- const NODE_GAP = 10;
500
-
501
- // Bucket nodes per layer.
502
- const nodesByLayer = new Map<string, ModuleNode[]>();
503
- for (const n of nodes) {
504
- const key = n.layer ?? "__unassigned__";
505
- if (!nodesByLayer.has(key)) nodesByLayer.set(key, []);
506
- nodesByLayer.get(key)!.push(n);
507
- }
508
-
509
- // Determine layer ordering — layer array + trailing "__unassigned__".
510
- const layerOrder: string[] = layers.map((l) => l.name);
511
- if (nodesByLayer.has("__unassigned__")) layerOrder.push("__unassigned__");
512
-
513
- // Compute per-node coordinates.
514
- const nodePos = new Map<string, { x: number; y: number; layerIdx: number }>();
515
- let maxRowWidth = 0;
516
-
517
- for (let rowIdx = 0; rowIdx < layerOrder.length; rowIdx++) {
518
- const layerName = layerOrder[rowIdx];
519
- const members = nodesByLayer.get(layerName) ?? [];
520
- const rowWidth = members.length * NODE_W + Math.max(0, members.length - 1) * NODE_GAP;
521
- maxRowWidth = Math.max(maxRowWidth, rowWidth);
522
-
523
- for (let i = 0; i < members.length; i++) {
524
- const x = MARGIN_X + i * (NODE_W + NODE_GAP);
525
- const y = MARGIN_TOP + rowIdx * LAYER_GAP;
526
- nodePos.set(members[i].id, { x, y, layerIdx: rowIdx });
527
- }
528
- }
529
-
530
- const svgWidth = Math.max(maxRowWidth + MARGIN_X * 2, 960);
531
- const svgHeight = MARGIN_TOP + layerOrder.length * LAYER_GAP + 40;
532
-
533
- // Layer row bands (background stripes).
534
- const layerBands = layerOrder
535
- .map((name, rowIdx) => {
536
- const y = MARGIN_TOP + rowIdx * LAYER_GAP - 18;
537
- const fill = rowIdx % 2 === 0 ? "#0f172a" : "#0b1220";
538
- const count = nodesByLayer.get(name)?.length ?? 0;
539
- return `
540
- <g class="layer-band" data-layer="${escAttr(name)}">
541
- <rect x="0" y="${y}" width="${svgWidth}" height="${LAYER_GAP}" fill="${fill}" opacity="0.6"/>
542
- <text x="${MARGIN_X}" y="${y + 14}" fill="#64748b" font-size="11" font-family="ui-monospace,Menlo,Consolas,monospace">${escText(
543
- name === "__unassigned__" ? "(unassigned)" : name
544
- )} · ${count} module${count === 1 ? "" : "s"}</text>
545
- </g>`;
546
- })
547
- .join("");
548
-
549
- // Edges.
550
- const edgePaths = edges
551
- .map((e, idx) => {
552
- const a = nodePos.get(e.from);
553
- const b = nodePos.get(e.to);
554
- if (!a || !b) return "";
555
- const x1 = a.x + NODE_W / 2;
556
- const y1 = a.y + NODE_H / 2;
557
- const x2 = b.x + NODE_W / 2;
558
- const y2 = b.y + NODE_H / 2;
559
- const dy = (y2 - y1) * 0.5;
560
- const d = `M ${x1} ${y1} C ${x1} ${y1 + dy}, ${x2} ${y2 - dy}, ${x2} ${y2}`;
561
- const cls = e.violation ? "edge violation" : "edge";
562
- return `<path class="${cls}" d="${d}" data-from="${escAttr(e.from)}" data-to="${escAttr(
563
- e.to
564
- )}" data-idx="${idx}" />`;
565
- })
566
- .join("");
567
-
568
- // Node rects.
569
- const layerColor = (layerName: string | null): string => {
570
- if (!layerName) return "#1f2937";
571
- const idx = layerOrder.indexOf(layerName);
572
- if (idx < 0) return "#1f2937";
573
- return LAYER_PALETTE[idx % LAYER_PALETTE.length];
574
- };
575
-
576
- const nodeRects = nodes
577
- .map((n, idx) => {
578
- const p = nodePos.get(n.id);
579
- if (!p) return "";
580
- const color = layerColor(n.layer);
581
- const label = n.label.length > 16 ? n.label.slice(0, 15) + "…" : n.label;
582
- return `
583
- <g class="node" data-id="${escAttr(n.id)}" data-layer="${escAttr(
584
- n.layer ?? "__unassigned__"
585
- )}" data-idx="${idx}" tabindex="0" role="button">
586
- <rect x="${p.x}" y="${p.y}" width="${NODE_W}" height="${NODE_H}" rx="4" fill="${color}" stroke="#0a0f14" stroke-width="1"/>
587
- <text x="${p.x + NODE_W / 2}" y="${p.y + NODE_H / 2 + 4}" text-anchor="middle" fill="#f3f4f6" font-size="11" font-family="ui-monospace,Menlo,Consolas,monospace">${escText(
588
- label
589
- )}</text>
590
- <title>${escText(n.id)}${n.layer ? ` (${n.layer})` : ""}</title>
591
- </g>`;
592
- })
593
- .join("");
594
-
595
- // Layer filter chips.
596
- const layerChips = layerOrder
597
- .map((name) => {
598
- const count = nodesByLayer.get(name)?.length ?? 0;
599
- const color = layerColor(name);
600
- return `<button class="chip chip-layer" data-layer="${escAttr(
601
- name
602
- )}" data-active="1" style="border-color:${color}"><span class="swatch" style="background:${color}"></span>${escText(
603
- name === "__unassigned__" ? "(unassigned)" : name
604
- )} <span class="count">${count}</span></button>`;
605
- })
606
- .join("");
607
-
608
- // Sidebar: precomputed importer / importee lookup.
609
- const neighbours = buildNeighbourIndex(edges);
610
-
611
- // Serialize the data the client-side script needs. Keep it minimal — we
612
- // don't embed filePath (already in node titles) because the side panel
613
- // re-reads it from the DOM.
614
- const clientData = {
615
- nodes: nodes.map((n) => ({
616
- id: n.id,
617
- filePath: n.filePath,
618
- layer: n.layer,
619
- })),
620
- importers: neighbours.importers,
621
- importees: neighbours.importees,
622
- };
623
-
624
- const summaryCards = `
625
- <div class="card"><div class="label">Modules</div><div class="value">${summary.nodes}</div></div>
626
- <div class="card"><div class="label">Edges</div><div class="value">${summary.edges}</div></div>
627
- <div class="card"><div class="label">Violations</div><div class="value ${
628
- summary.violationEdges > 0 ? "bad" : ""
629
- }">${summary.violationEdges}</div></div>
630
- <div class="card"><div class="label">Preset</div><div class="value">${escText(
631
- summary.preset
632
- )}</div></div>
633
- <div class="card"><div class="label">Layers</div><div class="value">${layers.length}</div></div>
634
- <div class="card"><div class="label">Src</div><div class="value">${escText(
635
- summary.srcDir
636
- )}</div></div>
637
- `;
638
-
639
- return `<!doctype html>
640
- <html lang="en">
641
- <head>
642
- <meta charset="utf-8">
643
- <meta name="viewport" content="width=device-width,initial-scale=1">
644
- <title>Mandu Guard Graph</title>
645
- <style>
646
- :root { color-scheme: dark; }
647
- html, body { margin: 0; padding: 0; background: #0a0f14; color: #e5e7eb; font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 13px; }
648
- header { padding: 14px 20px; border-bottom: 1px solid #1f2937; display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap; }
649
- header h1 { font-size: 16px; margin: 0; font-weight: 600; color: #f3f4f6; }
650
- header .meta { color: #64748b; font-size: 11px; }
651
- main { display: grid; grid-template-columns: 1fr 340px; gap: 12px; padding: 12px 20px 20px; max-width: 1400px; margin: 0 auto; }
652
- .toolbar { padding: 0 20px 12px; display: flex; flex-wrap: wrap; gap: 8px; align-items: center; border-bottom: 1px solid #1f2937; }
653
- .toolbar .cards { display: grid; grid-template-columns: repeat(6, 1fr); gap: 6px; width: 100%; margin-bottom: 10px; }
654
- .card { background: #111827; border: 1px solid #1f2937; border-radius: 4px; padding: 6px 10px; }
655
- .card .label { color: #64748b; font-size: 10px; text-transform: uppercase; letter-spacing: 0.06em; }
656
- .card .value { color: #f3f4f6; font-size: 14px; margin-top: 2px; word-break: break-all; }
657
- .card .value.bad { color: #f87171; }
658
- .chip { background: #111827; color: #e5e7eb; border: 1px solid #374151; padding: 3px 8px; border-radius: 12px; cursor: pointer; font-family: inherit; font-size: 11px; display: inline-flex; align-items: center; gap: 4px; }
659
- .chip:hover { background: #1f2937; }
660
- .chip[data-active="0"] { opacity: 0.35; }
661
- .chip .swatch { width: 8px; height: 8px; border-radius: 2px; display: inline-block; }
662
- .chip .count { color: #64748b; font-size: 10px; margin-left: 2px; }
663
- .chip.primary { border-color: #ef4444; color: #fca5a5; }
664
- .chip.primary[data-active="1"] { background: #7f1d1d; color: #fff; }
665
- svg.graph { background: #0a0f14; border: 1px solid #1f2937; border-radius: 4px; display: block; width: 100%; height: auto; }
666
- svg.graph .edge { stroke: #334155; stroke-width: 1; fill: none; opacity: 0.55; }
667
- svg.graph .edge.violation { stroke: #ef4444; stroke-dasharray: 3 3; opacity: 0.85; }
668
- svg.graph .edge.hidden { display: none; }
669
- svg.graph .node:hover rect { stroke: #38bdf8; stroke-width: 2; }
670
- svg.graph .node:focus rect { stroke: #38bdf8; stroke-width: 2; outline: none; }
671
- svg.graph .node.selected rect { stroke: #fbbf24; stroke-width: 2; }
672
- svg.graph .node.dim { opacity: 0.22; }
673
- aside { background: #0f172a; border: 1px solid #1f2937; border-radius: 4px; padding: 12px; min-height: 480px; }
674
- aside h3 { font-size: 13px; margin: 0 0 6px; color: #e5e7eb; }
675
- aside .path { color: #93c5fd; font-size: 11px; word-break: break-all; background: #1f2937; padding: 4px 6px; border-radius: 3px; margin-bottom: 10px; }
676
- aside h4 { font-size: 11px; color: #64748b; text-transform: uppercase; letter-spacing: 0.06em; margin: 12px 0 4px; }
677
- aside ul { list-style: none; padding: 0; margin: 0; max-height: 200px; overflow: auto; }
678
- aside li { padding: 2px 4px; font-size: 11px; color: #cbd5e1; cursor: pointer; border-radius: 2px; }
679
- aside li:hover { background: #1f2937; color: #fff; }
680
- aside .empty { color: #64748b; font-size: 11px; font-style: italic; }
681
- .muted { color: #64748b; }
682
- .legend { display: flex; gap: 12px; font-size: 10px; color: #64748b; margin-left: auto; }
683
- .legend span { display: inline-flex; align-items: center; gap: 4px; }
684
- .legend .sw { display: inline-block; width: 18px; height: 2px; background: #334155; }
685
- .legend .sw.v { background: #ef4444; }
686
- </style>
687
- </head>
688
- <body>
689
- <header>
690
- <h1>Mandu Guard Graph</h1>
691
- <span class="meta">generated ${escText(summary.generatedAt)} · preset <code>${escText(
692
- summary.preset
693
- )}</code> · src <code>${escText(summary.srcDir)}</code> · schema v${summary.version}</span>
694
- </header>
695
- <div class="toolbar">
696
- <div class="cards">${summaryCards}</div>
697
- <button class="chip primary" id="toggle-violations" data-active="0">Only violations</button>
698
- <button class="chip" id="toggle-all" data-active="1">All layers</button>
699
- ${layerChips}
700
- <div class="legend"><span><span class="sw"></span>import</span><span><span class="sw v"></span>violation</span></div>
701
- </div>
702
- <main>
703
- <svg class="graph" viewBox="0 0 ${svgWidth} ${svgHeight}" preserveAspectRatio="xMidYMid meet" role="img" aria-label="Dependency graph">
704
- <defs>
705
- <marker id="arrow" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
706
- <path d="M 0 0 L 10 5 L 0 10 z" fill="#334155"/>
707
- </marker>
708
- <marker id="arrow-v" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
709
- <path d="M 0 0 L 10 5 L 0 10 z" fill="#ef4444"/>
710
- </marker>
711
- </defs>
712
- ${layerBands}
713
- <g id="edges">${edgePaths}</g>
714
- <g id="nodes">${nodeRects}</g>
715
- </svg>
716
- <aside id="sidepanel">
717
- <h3>Select a module</h3>
718
- <p class="empty">Click a node in the graph to inspect its file path, importers and importees.</p>
719
- </aside>
720
- </main>
721
- <script>
722
- (function () {
723
- var DATA = ${serializeJson(clientData)};
724
- var svg = document.querySelector("svg.graph");
725
- var panel = document.getElementById("sidepanel");
726
- var onlyVio = document.getElementById("toggle-violations");
727
- var allBtn = document.getElementById("toggle-all");
728
- var layerChips = Array.prototype.slice.call(document.querySelectorAll(".chip-layer"));
729
- var nodeIndex = {};
730
- for (var i = 0; i < DATA.nodes.length; i++) { nodeIndex[DATA.nodes[i].id] = DATA.nodes[i]; }
731
-
732
- function applyFilters() {
733
- var activeLayers = {};
734
- for (var i = 0; i < layerChips.length; i++) {
735
- if (layerChips[i].getAttribute("data-active") === "1") {
736
- activeLayers[layerChips[i].getAttribute("data-layer")] = true;
737
- }
738
- }
739
- var vio = onlyVio.getAttribute("data-active") === "1";
740
- var nodes = svg.querySelectorAll(".node");
741
- var visibleNodeIds = {};
742
- for (var j = 0; j < nodes.length; j++) {
743
- var layer = nodes[j].getAttribute("data-layer");
744
- if (activeLayers[layer]) {
745
- nodes[j].classList.remove("dim");
746
- visibleNodeIds[nodes[j].getAttribute("data-id")] = true;
747
- } else {
748
- nodes[j].classList.add("dim");
749
- }
750
- }
751
- var edges = svg.querySelectorAll(".edge");
752
- for (var k = 0; k < edges.length; k++) {
753
- var from = edges[k].getAttribute("data-from");
754
- var to = edges[k].getAttribute("data-to");
755
- var isVio = edges[k].classList.contains("violation");
756
- var show = visibleNodeIds[from] && visibleNodeIds[to];
757
- if (vio && !isVio) show = false;
758
- if (show) edges[k].classList.remove("hidden"); else edges[k].classList.add("hidden");
759
- if (isVio) edges[k].setAttribute("marker-end","url(#arrow-v)");
760
- else edges[k].setAttribute("marker-end","url(#arrow)");
761
- }
762
- }
763
-
764
- function selectNode(id) {
765
- var nodes = svg.querySelectorAll(".node");
766
- for (var i = 0; i < nodes.length; i++) nodes[i].classList.remove("selected");
767
- var target = svg.querySelector('.node[data-id="' + cssEscape(id) + '"]');
768
- if (target) target.classList.add("selected");
769
- renderPanel(id);
770
- }
771
-
772
- function renderPanel(id) {
773
- var node = nodeIndex[id];
774
- if (!node) { panel.innerHTML = ""; return; }
775
- var ins = DATA.importers[id] || [];
776
- var outs = DATA.importees[id] || [];
777
- var html = "";
778
- html += '<h3>' + escapeHtml(id) + '</h3>';
779
- html += '<div class="path">' + escapeHtml(node.filePath) + '</div>';
780
- html += '<div class="muted" style="font-size:11px">layer: <code>' + escapeHtml(node.layer || "(none)") + '</code></div>';
781
- html += '<h4>Imported by (' + ins.length + ')</h4>';
782
- if (ins.length === 0) html += '<p class="empty">none</p>';
783
- else { html += '<ul>'; for (var a=0;a<ins.length;a++){ html += '<li data-jump="' + escapeAttr(ins[a]) + '">' + escapeHtml(ins[a]) + '</li>'; } html += '</ul>'; }
784
- html += '<h4>Imports (' + outs.length + ')</h4>';
785
- if (outs.length === 0) html += '<p class="empty">none</p>';
786
- else { html += '<ul>'; for (var b=0;b<outs.length;b++){ html += '<li data-jump="' + escapeAttr(outs[b]) + '">' + escapeHtml(outs[b]) + '</li>'; } html += '</ul>'; }
787
- panel.innerHTML = html;
788
- }
789
-
790
- function cssEscape(s) { return s.replace(/["\\\\]/g, "\\\\$&"); }
791
- function escapeHtml(s) { return String(s).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;"); }
792
- function escapeAttr(s) { return escapeHtml(s).replace(/"/g,"&quot;"); }
793
-
794
- svg.addEventListener("click", function (e) {
795
- var t = e.target;
796
- while (t && t.nodeType === 1) {
797
- if (t.classList && t.classList.contains("node")) {
798
- selectNode(t.getAttribute("data-id"));
799
- return;
800
- }
801
- t = t.parentNode;
802
- }
803
- });
804
- svg.addEventListener("keydown", function (e) {
805
- if (e.key === "Enter" || e.key === " ") {
806
- var t = e.target;
807
- if (t && t.classList && t.classList.contains("node")) {
808
- e.preventDefault();
809
- selectNode(t.getAttribute("data-id"));
810
- }
811
- }
812
- });
813
- panel.addEventListener("click", function (e) {
814
- var t = e.target;
815
- while (t && t.nodeType === 1) {
816
- var jump = t.getAttribute && t.getAttribute("data-jump");
817
- if (jump) { selectNode(jump); return; }
818
- t = t.parentNode;
819
- }
820
- });
821
- onlyVio.addEventListener("click", function () {
822
- var active = onlyVio.getAttribute("data-active") === "1" ? "0" : "1";
823
- onlyVio.setAttribute("data-active", active);
824
- applyFilters();
825
- });
826
- allBtn.addEventListener("click", function () {
827
- var activating = allBtn.getAttribute("data-active") === "0";
828
- allBtn.setAttribute("data-active", activating ? "1" : "0");
829
- for (var i = 0; i < layerChips.length; i++) {
830
- layerChips[i].setAttribute("data-active", activating ? "1" : "0");
831
- }
832
- applyFilters();
833
- });
834
- for (var i = 0; i < layerChips.length; i++) {
835
- layerChips[i].addEventListener("click", (function (chip) {
836
- return function () {
837
- var active = chip.getAttribute("data-active") === "1" ? "0" : "1";
838
- chip.setAttribute("data-active", active);
839
- applyFilters();
840
- };
841
- })(layerChips[i]));
842
- }
843
- applyFilters();
844
- })();
845
- </script>
846
- </body>
847
- </html>`;
848
- }
849
-
850
- /**
851
- * Build adjacency lookup used by the side panel.
852
- * - importers[id] = list of ids that import `id`
853
- * - importees[id] = list of ids that `id` imports
854
- *
855
- * Deduped + sorted for stable client-side rendering.
856
- */
857
- function buildNeighbourIndex(
858
- edges: ImportEdge[]
859
- ): { importers: Record<string, string[]>; importees: Record<string, string[]> } {
860
- const importers: Record<string, Set<string>> = {};
861
- const importees: Record<string, Set<string>> = {};
862
- for (const e of edges) {
863
- (importees[e.from] ??= new Set()).add(e.to);
864
- (importers[e.to] ??= new Set()).add(e.from);
865
- }
866
- const toSorted = (rec: Record<string, Set<string>>): Record<string, string[]> => {
867
- const out: Record<string, string[]> = {};
868
- for (const key of Object.keys(rec).sort()) {
869
- out[key] = Array.from(rec[key]).sort();
870
- }
871
- return out;
872
- };
873
- return { importers: toSorted(importers), importees: toSorted(importees) };
874
- }
875
-
876
- /**
877
- * JSON embedded in a `<script>` tag — must escape `</` so the browser parser
878
- * doesn't prematurely end the script block (classic XSS vector). Also escapes
879
- * U+2028 / U+2029 which break JS string literals when present verbatim.
880
- */
881
- function serializeJson(data: unknown): string {
882
- return JSON.stringify(data)
883
- .replace(/</g, "\\u003c")
884
- .replace(/>/g, "\\u003e")
885
- .replace(/\u2028/g, "\\u2028")
886
- .replace(/\u2029/g, "\\u2029");
887
- }
888
-
889
- function escText(s: string): string {
890
- return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
891
- }
892
-
893
- function escAttr(s: string): string {
894
- return escText(s).replace(/"/g, "&quot;").replace(/'/g, "&#39;");
895
- }
896
-
897
- // Keep unused-sep silent in case bundler strips path namespace import
898
- void sep;
1
+ /**
2
+ * Phase 18.π — Guard dependency graph visualizer.
3
+ *
4
+ * Scans a project's source tree, resolves imports between modules, tags each
5
+ * module with its architectural layer (per the active guard preset), and
6
+ * emits:
7
+ *
8
+ * analyzeDependencyGraph(config, rootDir) → DependencyGraph
9
+ * {
10
+ * nodes: ModuleNode[] // one per source file inside srcDir
11
+ * edges: ImportEdge[] // one per (source file → resolved
12
+ * target file) import, with violation
13
+ * flag + layer tagging
14
+ * layers: Layer[] // layers active in this project, keyed
15
+ * by hierarchy order
16
+ * violations: Violation[] // full guard report violations
17
+ * summary: { … counts … }
18
+ * }
19
+ *
20
+ * And a single-file, self-contained HTML viewer:
21
+ *
22
+ * renderGraphHtml(graph) → string
23
+ * - Inline SVG, layered layout (deterministic — no force-directed random
24
+ * seeding, CI-stable). Nodes bucketed per layer, row = layer.
25
+ * - Dark theme. No external CDN, no d3, no runtime deps.
26
+ * - Click node → side panel shows importers + importees + file path.
27
+ * - Filter bar: "show only violations" toggle + per-layer chips.
28
+ *
29
+ * Design spirit matches Phase 18.η bundle analyzer (`bundler/analyzer.ts`):
30
+ * deterministic, zero runtime deps, portable single HTML file.
31
+ *
32
+ * @module guard/graph
33
+ */
34
+
35
+ import { existsSync } from "fs";
36
+ import { relative, resolve, sep, isAbsolute, dirname, extname } from "path";
37
+ import { minimatch } from "minimatch";
38
+ import type {
39
+ GuardConfig,
40
+ LayerDefinition,
41
+ Violation,
42
+ FileAnalysis,
43
+ } from "./types";
44
+ import { WATCH_EXTENSIONS, DEFAULT_GUARD_CONFIG } from "./types";
45
+ import {
46
+ analyzeFile,
47
+ shouldAnalyzeFile,
48
+ shouldIgnoreImport,
49
+ } from "./analyzer";
50
+ import {
51
+ validateFileAnalysis,
52
+ detectCircularDependencies,
53
+ } from "./validator";
54
+ import { getPreset } from "./presets";
55
+
56
+ // ════════════════════════════════════════════════════════════════════════════
57
+ // Public types
58
+ // ════════════════════════════════════════════════════════════════════════════
59
+
60
+ /**
61
+ * Single module (source file) in the dependency graph.
62
+ */
63
+ export interface ModuleNode {
64
+ /** Stable id — POSIX path relative to rootDir. */
65
+ id: string;
66
+ /** Absolute filesystem path. */
67
+ filePath: string;
68
+ /** Human-friendly label (basename w/o extension + parent dir). */
69
+ label: string;
70
+ /** Resolved guard layer, or `null` if outside any layer. */
71
+ layer: string | null;
72
+ /** FSD slice (second path segment after layer), if any. */
73
+ slice?: string;
74
+ }
75
+
76
+ /**
77
+ * A single import edge `source → target`. Both endpoints are module ids
78
+ * present in `nodes`. Unresolvable imports (node_modules, ambient types,
79
+ * etc.) are dropped.
80
+ */
81
+ export interface ImportEdge {
82
+ /** Source node id. */
83
+ from: string;
84
+ /** Target node id. */
85
+ to: string;
86
+ /** Layer of source. */
87
+ fromLayer: string | null;
88
+ /** Layer of target. */
89
+ toLayer: string | null;
90
+ /** True if this edge corresponds to a guard violation. */
91
+ violation: boolean;
92
+ /** Import statement line number (1-indexed). */
93
+ line: number;
94
+ }
95
+
96
+ /**
97
+ * Layer bucket — a visual row in the graph.
98
+ */
99
+ export interface Layer {
100
+ /** Layer name (matches guard preset). */
101
+ name: string;
102
+ /** Rank in the hierarchy (0 = top / outermost layer). */
103
+ rank: number;
104
+ /** Human-friendly description. */
105
+ description?: string;
106
+ /** Number of nodes in this layer. */
107
+ nodeCount: number;
108
+ }
109
+
110
+ /**
111
+ * Summary counts displayed in the HTML header.
112
+ */
113
+ export interface GraphSummary {
114
+ /** Node count. */
115
+ nodes: number;
116
+ /** Edge count (successfully resolved imports). */
117
+ edges: number;
118
+ /** Number of violation edges. */
119
+ violationEdges: number;
120
+ /** Total guard violations (includes circulars not mapped to a single edge). */
121
+ violations: number;
122
+ /** Files analyzed (same as nodes, duplicated for symmetry with guard report). */
123
+ filesAnalyzed: number;
124
+ /** Active guard preset name. */
125
+ preset: string;
126
+ /** Source directory scanned. */
127
+ srcDir: string;
128
+ /** ISO timestamp of generation. */
129
+ generatedAt: string;
130
+ /** Schema version — bump when shape changes. */
131
+ version: number;
132
+ }
133
+
134
+ /**
135
+ * Full dependency graph.
136
+ */
137
+ export interface DependencyGraph {
138
+ nodes: ModuleNode[];
139
+ edges: ImportEdge[];
140
+ layers: Layer[];
141
+ violations: Violation[];
142
+ summary: GraphSummary;
143
+ }
144
+
145
+ const GRAPH_SCHEMA_VERSION = 1;
146
+
147
+ // ════════════════════════════════════════════════════════════════════════════
148
+ // Graph builder
149
+ // ════════════════════════════════════════════════════════════════════════════
150
+
151
+ /**
152
+ * Scan srcDir, build nodes + edges + layer tagging + violation overlay.
153
+ *
154
+ * Deterministic: sorts nodes / edges / layers for stable output across runs.
155
+ * Pure (does not write to disk).
156
+ */
157
+ export async function analyzeDependencyGraph(
158
+ config: GuardConfig,
159
+ rootDir: string
160
+ ): Promise<DependencyGraph> {
161
+ const layers = resolveLayerDefinitions(config);
162
+ const hierarchy = resolveHierarchy(config);
163
+ const srcDir = config.srcDir ?? DEFAULT_GUARD_CONFIG.srcDir;
164
+ const exclude = config.exclude ?? DEFAULT_GUARD_CONFIG.exclude;
165
+
166
+ // 1. Discover files. Mirror watcher's scan logic (srcDir + optional app/).
167
+ const files = await discoverFiles(rootDir, srcDir, config.fsRoutes ? "app" : null, exclude);
168
+
169
+ // 2. Analyze each file — imports + layer.
170
+ const analyses: FileAnalysis[] = [];
171
+ for (const file of files) {
172
+ if (!shouldAnalyzeFile(file, config, rootDir)) continue;
173
+ try {
174
+ const analysis = await analyzeFile(file, layers, rootDir);
175
+ analyses.push(analysis);
176
+ } catch {
177
+ // Skip unreadable files — matches watcher behavior.
178
+ }
179
+ }
180
+
181
+ // 3. Build node map keyed by POSIX relpath.
182
+ const nodesById = new Map<string, ModuleNode>();
183
+ for (const a of analyses) {
184
+ const id = toPosix(relative(rootDir, a.filePath));
185
+ nodesById.set(id, {
186
+ id,
187
+ filePath: a.filePath,
188
+ label: computeLabel(id),
189
+ layer: a.layer,
190
+ slice: a.slice,
191
+ });
192
+ }
193
+
194
+ // 4. Resolve edges. For each import in each analysis, find the matching
195
+ // file on disk (same extension set as WATCH_EXTENSIONS). Drop anything
196
+ // unresolvable (external deps, ambient types).
197
+ const edges: ImportEdge[] = [];
198
+ const violations: Violation[] = [];
199
+
200
+ for (const analysis of analyses) {
201
+ const fromId = toPosix(relative(rootDir, analysis.filePath));
202
+ const fileViolations = validateFileAnalysis(analysis, layers, config);
203
+ violations.push(...fileViolations);
204
+
205
+ // Index violations by (line, path) so we can tag edges without re-running
206
+ // the validator's rule logic.
207
+ const violationKey = (line: number, importPath: string): string =>
208
+ `${line}::${importPath}`;
209
+ const violationSet = new Set<string>();
210
+ for (const v of fileViolations) {
211
+ violationSet.add(violationKey(v.line, v.importPath));
212
+ }
213
+
214
+ for (const imp of analysis.imports) {
215
+ if (shouldIgnoreImport(imp.path, config)) continue;
216
+ const targetAbs = resolveImportToFile(
217
+ imp.path,
218
+ analysis.filePath,
219
+ rootDir,
220
+ config,
221
+ nodesById
222
+ );
223
+ if (!targetAbs) continue;
224
+ const toId = toPosix(relative(rootDir, targetAbs));
225
+ if (!nodesById.has(toId)) continue;
226
+ if (toId === fromId) continue; // self-import (barrel re-export from same file)
227
+
228
+ const fromNode = nodesById.get(fromId)!;
229
+ const toNode = nodesById.get(toId)!;
230
+
231
+ edges.push({
232
+ from: fromId,
233
+ to: toId,
234
+ fromLayer: fromNode.layer,
235
+ toLayer: toNode.layer,
236
+ violation: violationSet.has(violationKey(imp.line, imp.path)),
237
+ line: imp.line,
238
+ });
239
+ }
240
+ }
241
+
242
+ // 5. Circular dependencies are detected at the batch level — append to the
243
+ // violations collection so the HTML counter is accurate.
244
+ if (analyses.length > 0) {
245
+ violations.push(...detectCircularDependencies(analyses, layers, config));
246
+ }
247
+
248
+ // 6. Compute layer buckets.
249
+ const layerCounts = new Map<string, number>();
250
+ for (const node of nodesById.values()) {
251
+ if (!node.layer) continue;
252
+ layerCounts.set(node.layer, (layerCounts.get(node.layer) ?? 0) + 1);
253
+ }
254
+
255
+ const layerBuckets: Layer[] = [];
256
+ const seen = new Set<string>();
257
+ // First pass: layers explicitly in hierarchy (preserves order).
258
+ for (let rank = 0; rank < hierarchy.length; rank++) {
259
+ const name = hierarchy[rank];
260
+ if (layerCounts.has(name)) {
261
+ const def = layers.find((l) => l.name === name);
262
+ layerBuckets.push({
263
+ name,
264
+ rank,
265
+ description: def?.description,
266
+ nodeCount: layerCounts.get(name) ?? 0,
267
+ });
268
+ seen.add(name);
269
+ }
270
+ }
271
+ // Second pass: layers present in nodes but not in hierarchy (custom / overrides).
272
+ let extraRank = layerBuckets.length;
273
+ for (const [name, count] of Array.from(layerCounts.entries()).sort()) {
274
+ if (seen.has(name)) continue;
275
+ const def = layers.find((l) => l.name === name);
276
+ layerBuckets.push({
277
+ name,
278
+ rank: extraRank++,
279
+ description: def?.description,
280
+ nodeCount: count,
281
+ });
282
+ }
283
+
284
+ // 7. Deterministic ordering.
285
+ const sortedNodes = Array.from(nodesById.values()).sort((a, b) =>
286
+ a.id.localeCompare(b.id)
287
+ );
288
+ edges.sort((a, b) => {
289
+ if (a.from !== b.from) return a.from.localeCompare(b.from);
290
+ if (a.to !== b.to) return a.to.localeCompare(b.to);
291
+ return a.line - b.line;
292
+ });
293
+
294
+ const violationEdges = edges.filter((e) => e.violation).length;
295
+
296
+ return {
297
+ nodes: sortedNodes,
298
+ edges,
299
+ layers: layerBuckets,
300
+ violations,
301
+ summary: {
302
+ nodes: sortedNodes.length,
303
+ edges: edges.length,
304
+ violationEdges,
305
+ violations: violations.length,
306
+ filesAnalyzed: sortedNodes.length,
307
+ preset: config.preset ?? "custom",
308
+ srcDir,
309
+ generatedAt: new Date().toISOString(),
310
+ version: GRAPH_SCHEMA_VERSION,
311
+ },
312
+ };
313
+ }
314
+
315
+ // ════════════════════════════════════════════════════════════════════════════
316
+ // File discovery
317
+ // ════════════════════════════════════════════════════════════════════════════
318
+
319
+ async function discoverFiles(
320
+ rootDir: string,
321
+ srcDir: string,
322
+ extraRoot: string | null,
323
+ exclude: string[]
324
+ ): Promise<string[]> {
325
+ const { glob } = await import("glob");
326
+ const extensions = WATCH_EXTENSIONS.map((e) => e.slice(1)).join(",");
327
+ const roots = new Set<string>([srcDir]);
328
+ if (extraRoot) roots.add(extraRoot);
329
+
330
+ const found = new Set<string>();
331
+ for (const root of roots) {
332
+ const pattern = `${root}/**/*.{${extensions}}`;
333
+ const hits = await glob(pattern, {
334
+ cwd: rootDir,
335
+ ignore: exclude,
336
+ absolute: true,
337
+ });
338
+ for (const hit of hits) found.add(hit);
339
+ }
340
+ return Array.from(found);
341
+ }
342
+
343
+ // ════════════════════════════════════════════════════════════════════════════
344
+ // Import → file resolution
345
+ // ════════════════════════════════════════════════════════════════════════════
346
+
347
+ /**
348
+ * Best-effort resolution from an import specifier to an absolute source file
349
+ * present in `nodesById`.
350
+ *
351
+ * Handles:
352
+ * - Relative imports (./x, ../y)
353
+ * - `@/`, `~/` alias (resolved against srcDir)
354
+ * - Bare `srcDir/...` paths (e.g. "src/features/auth")
355
+ * - Directory imports → index.(ts|tsx|js|jsx)
356
+ * - Extension-less imports — tries all WATCH_EXTENSIONS
357
+ *
358
+ * Returns null for node_modules, URL imports, or unmatched paths. Falls back
359
+ * to "does a node id exist with a matching POSIX path" to keep CI output
360
+ * deterministic even when filesystem lookups would miss (e.g. virtual files
361
+ * in tests).
362
+ */
363
+ function resolveImportToFile(
364
+ importPath: string,
365
+ fromFile: string,
366
+ rootDir: string,
367
+ config: GuardConfig,
368
+ nodesById: Map<string, ModuleNode>
369
+ ): string | null {
370
+ const srcDir = (config.srcDir ?? "src").replace(/\\/g, "/").replace(/\/$/, "");
371
+ const normalized = importPath.replace(/\\/g, "/");
372
+
373
+ const candidates: string[] = [];
374
+
375
+ if (normalized.startsWith("@/") || normalized.startsWith("~/")) {
376
+ const alias = normalized.slice(2);
377
+ if (srcDir) candidates.push(`${srcDir}/${alias}`);
378
+ candidates.push(alias);
379
+ } else if (normalized.startsWith(".")) {
380
+ const absoluteFrom = isAbsolute(fromFile) ? fromFile : resolve(rootDir, fromFile);
381
+ const resolved = resolve(dirname(absoluteFrom), normalized);
382
+ const rel = toPosix(relative(rootDir, resolved));
383
+ if (!rel.startsWith("..")) candidates.push(rel);
384
+ } else if (
385
+ srcDir &&
386
+ (normalized === srcDir || normalized.startsWith(`${srcDir}/`))
387
+ ) {
388
+ candidates.push(normalized);
389
+ } else {
390
+ return null; // bare import — node_modules or unknown alias
391
+ }
392
+
393
+ // Try each candidate with all extension variants + index.* fallback.
394
+ for (const candidate of candidates) {
395
+ const abs = resolve(rootDir, candidate);
396
+ // Exact file (candidate has its own extension already)
397
+ if (extname(candidate) && existsSync(abs)) return abs;
398
+
399
+ for (const ext of WATCH_EXTENSIONS) {
400
+ const withExt = `${abs}${ext}`;
401
+ if (existsSync(withExt)) return withExt;
402
+ const asIndex = resolve(abs, `index${ext}`);
403
+ if (existsSync(asIndex)) return asIndex;
404
+ }
405
+
406
+ // Virtual-fs fallback — for tests where nodes may be registered but
407
+ // fs.existsSync isn't reachable (never hits in normal runs, but keeps
408
+ // edge generation deterministic in edge cases).
409
+ for (const ext of WATCH_EXTENSIONS) {
410
+ const id = `${toPosix(candidate)}${ext}`;
411
+ if (nodesById.has(id)) return resolve(rootDir, id);
412
+ const idIdx = `${toPosix(candidate)}/index${ext}`;
413
+ if (nodesById.has(idIdx)) return resolve(rootDir, idIdx);
414
+ }
415
+ }
416
+ return null;
417
+ }
418
+
419
+ // ════════════════════════════════════════════════════════════════════════════
420
+ // Utilities
421
+ // ════════════════════════════════════════════════════════════════════════════
422
+
423
+ function toPosix(p: string): string {
424
+ return p.replace(/\\/g, "/");
425
+ }
426
+
427
+ function computeLabel(id: string): string {
428
+ const parts = id.split("/");
429
+ const base = parts[parts.length - 1] ?? id;
430
+ const parent = parts.length > 1 ? parts[parts.length - 2] : "";
431
+ const stem = base.replace(/\.[^.]+$/, "");
432
+ return parent ? `${parent}/${stem}` : stem;
433
+ }
434
+
435
+ function resolveLayerDefinitions(config: GuardConfig): LayerDefinition[] {
436
+ if (config.layers && config.layers.length > 0) {
437
+ return config.layers;
438
+ }
439
+ if (config.preset) {
440
+ const preset = getPreset(config.preset);
441
+ let layers = [...preset.layers];
442
+ if (config.override?.layers) {
443
+ layers = layers.map((layer) => {
444
+ const override = config.override?.layers?.[layer.name];
445
+ return override ? { ...layer, ...override } : layer;
446
+ });
447
+ }
448
+ return layers;
449
+ }
450
+ return [];
451
+ }
452
+
453
+ function resolveHierarchy(config: GuardConfig): string[] {
454
+ if (config.preset) {
455
+ return [...getPreset(config.preset).hierarchy];
456
+ }
457
+ return (config.layers ?? []).map((l) => l.name);
458
+ }
459
+
460
+ // ════════════════════════════════════════════════════════════════════════════
461
+ // HTML renderer
462
+ // ════════════════════════════════════════════════════════════════════════════
463
+
464
+ const LAYER_PALETTE = [
465
+ "#1e3a8a", "#155e75", "#166534", "#854d0e", "#7c2d12",
466
+ "#581c87", "#831843", "#0f766e", "#3730a3", "#92400e",
467
+ "#064e3b", "#6b21a8", "#134e4a", "#7f1d1d", "#1e40af",
468
+ ];
469
+
470
+ /**
471
+ * Render a self-contained HTML graph viewer.
472
+ *
473
+ * Layout:
474
+ * - Rows = layers (rank 0 = top). Nodes in each layer packed horizontally,
475
+ * sorted by id → deterministic across runs.
476
+ * - Edges drawn as cubic Bezier splines between node centers. Violation
477
+ * edges rendered in red, dashed, with markerEnd arrow. Normal edges in
478
+ * muted blue.
479
+ *
480
+ * Interactivity:
481
+ * - Click node → side panel lists importers + importees + file path.
482
+ * - Top filter bar: "Only violations" toggle + "All layers" / per-layer
483
+ * toggle chips.
484
+ *
485
+ * Portability:
486
+ * - Zero external JS / CSS. Drag-and-drop into any browser.
487
+ * - File size stays well under 500 KB for projects up to ~2k modules
488
+ * (tested on auth-starter: ~30 KB).
489
+ */
490
+ export function renderGraphHtml(graph: DependencyGraph): string {
491
+ const { nodes, edges, layers, summary } = graph;
492
+
493
+ // ── Layout ────────────────────────────────────────────────────────────────
494
+ const MARGIN_X = 24;
495
+ const MARGIN_TOP = 48;
496
+ const LAYER_GAP = 120;
497
+ const NODE_W = 130;
498
+ const NODE_H = 28;
499
+ const NODE_GAP = 10;
500
+
501
+ // Bucket nodes per layer.
502
+ const nodesByLayer = new Map<string, ModuleNode[]>();
503
+ for (const n of nodes) {
504
+ const key = n.layer ?? "__unassigned__";
505
+ if (!nodesByLayer.has(key)) nodesByLayer.set(key, []);
506
+ nodesByLayer.get(key)!.push(n);
507
+ }
508
+
509
+ // Determine layer ordering — layer array + trailing "__unassigned__".
510
+ const layerOrder: string[] = layers.map((l) => l.name);
511
+ if (nodesByLayer.has("__unassigned__")) layerOrder.push("__unassigned__");
512
+
513
+ // Compute per-node coordinates.
514
+ const nodePos = new Map<string, { x: number; y: number; layerIdx: number }>();
515
+ let maxRowWidth = 0;
516
+
517
+ for (let rowIdx = 0; rowIdx < layerOrder.length; rowIdx++) {
518
+ const layerName = layerOrder[rowIdx];
519
+ const members = nodesByLayer.get(layerName) ?? [];
520
+ const rowWidth = members.length * NODE_W + Math.max(0, members.length - 1) * NODE_GAP;
521
+ maxRowWidth = Math.max(maxRowWidth, rowWidth);
522
+
523
+ for (let i = 0; i < members.length; i++) {
524
+ const x = MARGIN_X + i * (NODE_W + NODE_GAP);
525
+ const y = MARGIN_TOP + rowIdx * LAYER_GAP;
526
+ nodePos.set(members[i].id, { x, y, layerIdx: rowIdx });
527
+ }
528
+ }
529
+
530
+ const svgWidth = Math.max(maxRowWidth + MARGIN_X * 2, 960);
531
+ const svgHeight = MARGIN_TOP + layerOrder.length * LAYER_GAP + 40;
532
+
533
+ // Layer row bands (background stripes).
534
+ const layerBands = layerOrder
535
+ .map((name, rowIdx) => {
536
+ const y = MARGIN_TOP + rowIdx * LAYER_GAP - 18;
537
+ const fill = rowIdx % 2 === 0 ? "#0f172a" : "#0b1220";
538
+ const count = nodesByLayer.get(name)?.length ?? 0;
539
+ return `
540
+ <g class="layer-band" data-layer="${escAttr(name)}">
541
+ <rect x="0" y="${y}" width="${svgWidth}" height="${LAYER_GAP}" fill="${fill}" opacity="0.6"/>
542
+ <text x="${MARGIN_X}" y="${y + 14}" fill="#64748b" font-size="11" font-family="ui-monospace,Menlo,Consolas,monospace">${escText(
543
+ name === "__unassigned__" ? "(unassigned)" : name
544
+ )} · ${count} module${count === 1 ? "" : "s"}</text>
545
+ </g>`;
546
+ })
547
+ .join("");
548
+
549
+ // Edges.
550
+ const edgePaths = edges
551
+ .map((e, idx) => {
552
+ const a = nodePos.get(e.from);
553
+ const b = nodePos.get(e.to);
554
+ if (!a || !b) return "";
555
+ const x1 = a.x + NODE_W / 2;
556
+ const y1 = a.y + NODE_H / 2;
557
+ const x2 = b.x + NODE_W / 2;
558
+ const y2 = b.y + NODE_H / 2;
559
+ const dy = (y2 - y1) * 0.5;
560
+ const d = `M ${x1} ${y1} C ${x1} ${y1 + dy}, ${x2} ${y2 - dy}, ${x2} ${y2}`;
561
+ const cls = e.violation ? "edge violation" : "edge";
562
+ return `<path class="${cls}" d="${d}" data-from="${escAttr(e.from)}" data-to="${escAttr(
563
+ e.to
564
+ )}" data-idx="${idx}" />`;
565
+ })
566
+ .join("");
567
+
568
+ // Node rects.
569
+ const layerColor = (layerName: string | null): string => {
570
+ if (!layerName) return "#1f2937";
571
+ const idx = layerOrder.indexOf(layerName);
572
+ if (idx < 0) return "#1f2937";
573
+ return LAYER_PALETTE[idx % LAYER_PALETTE.length];
574
+ };
575
+
576
+ const nodeRects = nodes
577
+ .map((n, idx) => {
578
+ const p = nodePos.get(n.id);
579
+ if (!p) return "";
580
+ const color = layerColor(n.layer);
581
+ const label = n.label.length > 16 ? n.label.slice(0, 15) + "…" : n.label;
582
+ return `
583
+ <g class="node" data-id="${escAttr(n.id)}" data-layer="${escAttr(
584
+ n.layer ?? "__unassigned__"
585
+ )}" data-idx="${idx}" tabindex="0" role="button">
586
+ <rect x="${p.x}" y="${p.y}" width="${NODE_W}" height="${NODE_H}" rx="4" fill="${color}" stroke="#0a0f14" stroke-width="1"/>
587
+ <text x="${p.x + NODE_W / 2}" y="${p.y + NODE_H / 2 + 4}" text-anchor="middle" fill="#f3f4f6" font-size="11" font-family="ui-monospace,Menlo,Consolas,monospace">${escText(
588
+ label
589
+ )}</text>
590
+ <title>${escText(n.id)}${n.layer ? ` (${n.layer})` : ""}</title>
591
+ </g>`;
592
+ })
593
+ .join("");
594
+
595
+ // Layer filter chips.
596
+ const layerChips = layerOrder
597
+ .map((name) => {
598
+ const count = nodesByLayer.get(name)?.length ?? 0;
599
+ const color = layerColor(name);
600
+ return `<button class="chip chip-layer" data-layer="${escAttr(
601
+ name
602
+ )}" data-active="1" style="border-color:${color}"><span class="swatch" style="background:${color}"></span>${escText(
603
+ name === "__unassigned__" ? "(unassigned)" : name
604
+ )} <span class="count">${count}</span></button>`;
605
+ })
606
+ .join("");
607
+
608
+ // Sidebar: precomputed importer / importee lookup.
609
+ const neighbours = buildNeighbourIndex(edges);
610
+
611
+ // Serialize the data the client-side script needs. Keep it minimal — we
612
+ // don't embed filePath (already in node titles) because the side panel
613
+ // re-reads it from the DOM.
614
+ const clientData = {
615
+ nodes: nodes.map((n) => ({
616
+ id: n.id,
617
+ filePath: n.filePath,
618
+ layer: n.layer,
619
+ })),
620
+ importers: neighbours.importers,
621
+ importees: neighbours.importees,
622
+ };
623
+
624
+ const summaryCards = `
625
+ <div class="card"><div class="label">Modules</div><div class="value">${summary.nodes}</div></div>
626
+ <div class="card"><div class="label">Edges</div><div class="value">${summary.edges}</div></div>
627
+ <div class="card"><div class="label">Violations</div><div class="value ${
628
+ summary.violationEdges > 0 ? "bad" : ""
629
+ }">${summary.violationEdges}</div></div>
630
+ <div class="card"><div class="label">Preset</div><div class="value">${escText(
631
+ summary.preset
632
+ )}</div></div>
633
+ <div class="card"><div class="label">Layers</div><div class="value">${layers.length}</div></div>
634
+ <div class="card"><div class="label">Src</div><div class="value">${escText(
635
+ summary.srcDir
636
+ )}</div></div>
637
+ `;
638
+
639
+ return `<!doctype html>
640
+ <html lang="en">
641
+ <head>
642
+ <meta charset="utf-8">
643
+ <meta name="viewport" content="width=device-width,initial-scale=1">
644
+ <title>Mandu Guard Graph</title>
645
+ <style>
646
+ :root { color-scheme: dark; }
647
+ html, body { margin: 0; padding: 0; background: #0a0f14; color: #e5e7eb; font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 13px; }
648
+ header { padding: 14px 20px; border-bottom: 1px solid #1f2937; display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap; }
649
+ header h1 { font-size: 16px; margin: 0; font-weight: 600; color: #f3f4f6; }
650
+ header .meta { color: #64748b; font-size: 11px; }
651
+ main { display: grid; grid-template-columns: 1fr 340px; gap: 12px; padding: 12px 20px 20px; max-width: 1400px; margin: 0 auto; }
652
+ .toolbar { padding: 0 20px 12px; display: flex; flex-wrap: wrap; gap: 8px; align-items: center; border-bottom: 1px solid #1f2937; }
653
+ .toolbar .cards { display: grid; grid-template-columns: repeat(6, 1fr); gap: 6px; width: 100%; margin-bottom: 10px; }
654
+ .card { background: #111827; border: 1px solid #1f2937; border-radius: 4px; padding: 6px 10px; }
655
+ .card .label { color: #64748b; font-size: 10px; text-transform: uppercase; letter-spacing: 0.06em; }
656
+ .card .value { color: #f3f4f6; font-size: 14px; margin-top: 2px; word-break: break-all; }
657
+ .card .value.bad { color: #f87171; }
658
+ .chip { background: #111827; color: #e5e7eb; border: 1px solid #374151; padding: 3px 8px; border-radius: 12px; cursor: pointer; font-family: inherit; font-size: 11px; display: inline-flex; align-items: center; gap: 4px; }
659
+ .chip:hover { background: #1f2937; }
660
+ .chip[data-active="0"] { opacity: 0.35; }
661
+ .chip .swatch { width: 8px; height: 8px; border-radius: 2px; display: inline-block; }
662
+ .chip .count { color: #64748b; font-size: 10px; margin-left: 2px; }
663
+ .chip.primary { border-color: #ef4444; color: #fca5a5; }
664
+ .chip.primary[data-active="1"] { background: #7f1d1d; color: #fff; }
665
+ svg.graph { background: #0a0f14; border: 1px solid #1f2937; border-radius: 4px; display: block; width: 100%; height: auto; }
666
+ svg.graph .edge { stroke: #334155; stroke-width: 1; fill: none; opacity: 0.55; }
667
+ svg.graph .edge.violation { stroke: #ef4444; stroke-dasharray: 3 3; opacity: 0.85; }
668
+ svg.graph .edge.hidden { display: none; }
669
+ svg.graph .node:hover rect { stroke: #38bdf8; stroke-width: 2; }
670
+ svg.graph .node:focus rect { stroke: #38bdf8; stroke-width: 2; outline: none; }
671
+ svg.graph .node.selected rect { stroke: #fbbf24; stroke-width: 2; }
672
+ svg.graph .node.dim { opacity: 0.22; }
673
+ aside { background: #0f172a; border: 1px solid #1f2937; border-radius: 4px; padding: 12px; min-height: 480px; }
674
+ aside h3 { font-size: 13px; margin: 0 0 6px; color: #e5e7eb; }
675
+ aside .path { color: #93c5fd; font-size: 11px; word-break: break-all; background: #1f2937; padding: 4px 6px; border-radius: 3px; margin-bottom: 10px; }
676
+ aside h4 { font-size: 11px; color: #64748b; text-transform: uppercase; letter-spacing: 0.06em; margin: 12px 0 4px; }
677
+ aside ul { list-style: none; padding: 0; margin: 0; max-height: 200px; overflow: auto; }
678
+ aside li { padding: 2px 4px; font-size: 11px; color: #cbd5e1; cursor: pointer; border-radius: 2px; }
679
+ aside li:hover { background: #1f2937; color: #fff; }
680
+ aside .empty { color: #64748b; font-size: 11px; font-style: italic; }
681
+ .muted { color: #64748b; }
682
+ .legend { display: flex; gap: 12px; font-size: 10px; color: #64748b; margin-left: auto; }
683
+ .legend span { display: inline-flex; align-items: center; gap: 4px; }
684
+ .legend .sw { display: inline-block; width: 18px; height: 2px; background: #334155; }
685
+ .legend .sw.v { background: #ef4444; }
686
+ </style>
687
+ </head>
688
+ <body>
689
+ <header>
690
+ <h1>Mandu Guard Graph</h1>
691
+ <span class="meta">generated ${escText(summary.generatedAt)} · preset <code>${escText(
692
+ summary.preset
693
+ )}</code> · src <code>${escText(summary.srcDir)}</code> · schema v${summary.version}</span>
694
+ </header>
695
+ <div class="toolbar">
696
+ <div class="cards">${summaryCards}</div>
697
+ <button class="chip primary" id="toggle-violations" data-active="0">Only violations</button>
698
+ <button class="chip" id="toggle-all" data-active="1">All layers</button>
699
+ ${layerChips}
700
+ <div class="legend"><span><span class="sw"></span>import</span><span><span class="sw v"></span>violation</span></div>
701
+ </div>
702
+ <main>
703
+ <svg class="graph" viewBox="0 0 ${svgWidth} ${svgHeight}" preserveAspectRatio="xMidYMid meet" role="img" aria-label="Dependency graph">
704
+ <defs>
705
+ <marker id="arrow" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
706
+ <path d="M 0 0 L 10 5 L 0 10 z" fill="#334155"/>
707
+ </marker>
708
+ <marker id="arrow-v" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
709
+ <path d="M 0 0 L 10 5 L 0 10 z" fill="#ef4444"/>
710
+ </marker>
711
+ </defs>
712
+ ${layerBands}
713
+ <g id="edges">${edgePaths}</g>
714
+ <g id="nodes">${nodeRects}</g>
715
+ </svg>
716
+ <aside id="sidepanel">
717
+ <h3>Select a module</h3>
718
+ <p class="empty">Click a node in the graph to inspect its file path, importers and importees.</p>
719
+ </aside>
720
+ </main>
721
+ <script>
722
+ (function () {
723
+ var DATA = ${serializeJson(clientData)};
724
+ var svg = document.querySelector("svg.graph");
725
+ var panel = document.getElementById("sidepanel");
726
+ var onlyVio = document.getElementById("toggle-violations");
727
+ var allBtn = document.getElementById("toggle-all");
728
+ var layerChips = Array.prototype.slice.call(document.querySelectorAll(".chip-layer"));
729
+ var nodeIndex = {};
730
+ for (var i = 0; i < DATA.nodes.length; i++) { nodeIndex[DATA.nodes[i].id] = DATA.nodes[i]; }
731
+
732
+ function applyFilters() {
733
+ var activeLayers = {};
734
+ for (var i = 0; i < layerChips.length; i++) {
735
+ if (layerChips[i].getAttribute("data-active") === "1") {
736
+ activeLayers[layerChips[i].getAttribute("data-layer")] = true;
737
+ }
738
+ }
739
+ var vio = onlyVio.getAttribute("data-active") === "1";
740
+ var nodes = svg.querySelectorAll(".node");
741
+ var visibleNodeIds = {};
742
+ for (var j = 0; j < nodes.length; j++) {
743
+ var layer = nodes[j].getAttribute("data-layer");
744
+ if (activeLayers[layer]) {
745
+ nodes[j].classList.remove("dim");
746
+ visibleNodeIds[nodes[j].getAttribute("data-id")] = true;
747
+ } else {
748
+ nodes[j].classList.add("dim");
749
+ }
750
+ }
751
+ var edges = svg.querySelectorAll(".edge");
752
+ for (var k = 0; k < edges.length; k++) {
753
+ var from = edges[k].getAttribute("data-from");
754
+ var to = edges[k].getAttribute("data-to");
755
+ var isVio = edges[k].classList.contains("violation");
756
+ var show = visibleNodeIds[from] && visibleNodeIds[to];
757
+ if (vio && !isVio) show = false;
758
+ if (show) edges[k].classList.remove("hidden"); else edges[k].classList.add("hidden");
759
+ if (isVio) edges[k].setAttribute("marker-end","url(#arrow-v)");
760
+ else edges[k].setAttribute("marker-end","url(#arrow)");
761
+ }
762
+ }
763
+
764
+ function selectNode(id) {
765
+ var nodes = svg.querySelectorAll(".node");
766
+ for (var i = 0; i < nodes.length; i++) nodes[i].classList.remove("selected");
767
+ var target = svg.querySelector('.node[data-id="' + cssEscape(id) + '"]');
768
+ if (target) target.classList.add("selected");
769
+ renderPanel(id);
770
+ }
771
+
772
+ function renderPanel(id) {
773
+ var node = nodeIndex[id];
774
+ if (!node) { panel.innerHTML = ""; return; }
775
+ var ins = DATA.importers[id] || [];
776
+ var outs = DATA.importees[id] || [];
777
+ var html = "";
778
+ html += '<h3>' + escapeHtml(id) + '</h3>';
779
+ html += '<div class="path">' + escapeHtml(node.filePath) + '</div>';
780
+ html += '<div class="muted" style="font-size:11px">layer: <code>' + escapeHtml(node.layer || "(none)") + '</code></div>';
781
+ html += '<h4>Imported by (' + ins.length + ')</h4>';
782
+ if (ins.length === 0) html += '<p class="empty">none</p>';
783
+ else { html += '<ul>'; for (var a=0;a<ins.length;a++){ html += '<li data-jump="' + escapeAttr(ins[a]) + '">' + escapeHtml(ins[a]) + '</li>'; } html += '</ul>'; }
784
+ html += '<h4>Imports (' + outs.length + ')</h4>';
785
+ if (outs.length === 0) html += '<p class="empty">none</p>';
786
+ else { html += '<ul>'; for (var b=0;b<outs.length;b++){ html += '<li data-jump="' + escapeAttr(outs[b]) + '">' + escapeHtml(outs[b]) + '</li>'; } html += '</ul>'; }
787
+ panel.innerHTML = html;
788
+ }
789
+
790
+ function cssEscape(s) { return s.replace(/["\\\\]/g, "\\\\$&"); }
791
+ function escapeHtml(s) { return String(s).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;"); }
792
+ function escapeAttr(s) { return escapeHtml(s).replace(/"/g,"&quot;"); }
793
+
794
+ svg.addEventListener("click", function (e) {
795
+ var t = e.target;
796
+ while (t && t.nodeType === 1) {
797
+ if (t.classList && t.classList.contains("node")) {
798
+ selectNode(t.getAttribute("data-id"));
799
+ return;
800
+ }
801
+ t = t.parentNode;
802
+ }
803
+ });
804
+ svg.addEventListener("keydown", function (e) {
805
+ if (e.key === "Enter" || e.key === " ") {
806
+ var t = e.target;
807
+ if (t && t.classList && t.classList.contains("node")) {
808
+ e.preventDefault();
809
+ selectNode(t.getAttribute("data-id"));
810
+ }
811
+ }
812
+ });
813
+ panel.addEventListener("click", function (e) {
814
+ var t = e.target;
815
+ while (t && t.nodeType === 1) {
816
+ var jump = t.getAttribute && t.getAttribute("data-jump");
817
+ if (jump) { selectNode(jump); return; }
818
+ t = t.parentNode;
819
+ }
820
+ });
821
+ onlyVio.addEventListener("click", function () {
822
+ var active = onlyVio.getAttribute("data-active") === "1" ? "0" : "1";
823
+ onlyVio.setAttribute("data-active", active);
824
+ applyFilters();
825
+ });
826
+ allBtn.addEventListener("click", function () {
827
+ var activating = allBtn.getAttribute("data-active") === "0";
828
+ allBtn.setAttribute("data-active", activating ? "1" : "0");
829
+ for (var i = 0; i < layerChips.length; i++) {
830
+ layerChips[i].setAttribute("data-active", activating ? "1" : "0");
831
+ }
832
+ applyFilters();
833
+ });
834
+ for (var i = 0; i < layerChips.length; i++) {
835
+ layerChips[i].addEventListener("click", (function (chip) {
836
+ return function () {
837
+ var active = chip.getAttribute("data-active") === "1" ? "0" : "1";
838
+ chip.setAttribute("data-active", active);
839
+ applyFilters();
840
+ };
841
+ })(layerChips[i]));
842
+ }
843
+ applyFilters();
844
+ })();
845
+ </script>
846
+ </body>
847
+ </html>`;
848
+ }
849
+
850
+ /**
851
+ * Build adjacency lookup used by the side panel.
852
+ * - importers[id] = list of ids that import `id`
853
+ * - importees[id] = list of ids that `id` imports
854
+ *
855
+ * Deduped + sorted for stable client-side rendering.
856
+ */
857
+ function buildNeighbourIndex(
858
+ edges: ImportEdge[]
859
+ ): { importers: Record<string, string[]>; importees: Record<string, string[]> } {
860
+ const importers: Record<string, Set<string>> = {};
861
+ const importees: Record<string, Set<string>> = {};
862
+ for (const e of edges) {
863
+ (importees[e.from] ??= new Set()).add(e.to);
864
+ (importers[e.to] ??= new Set()).add(e.from);
865
+ }
866
+ const toSorted = (rec: Record<string, Set<string>>): Record<string, string[]> => {
867
+ const out: Record<string, string[]> = {};
868
+ for (const key of Object.keys(rec).sort()) {
869
+ out[key] = Array.from(rec[key]).sort();
870
+ }
871
+ return out;
872
+ };
873
+ return { importers: toSorted(importers), importees: toSorted(importees) };
874
+ }
875
+
876
+ /**
877
+ * JSON embedded in a `<script>` tag — must escape `</` so the browser parser
878
+ * doesn't prematurely end the script block (classic XSS vector). Also escapes
879
+ * U+2028 / U+2029 which break JS string literals when present verbatim.
880
+ */
881
+ function serializeJson(data: unknown): string {
882
+ return JSON.stringify(data)
883
+ .replace(/</g, "\\u003c")
884
+ .replace(/>/g, "\\u003e")
885
+ .replace(/\u2028/g, "\\u2028")
886
+ .replace(/\u2029/g, "\\u2029");
887
+ }
888
+
889
+ function escText(s: string): string {
890
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
891
+ }
892
+
893
+ function escAttr(s: string): string {
894
+ return escText(s).replace(/"/g, "&quot;").replace(/'/g, "&#39;");
895
+ }
896
+
897
+ // Keep unused-sep silent in case bundler strips path namespace import
898
+ void sep;