@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.
- package/package.json +21 -4
- package/src/auth/__tests__/login.test.ts +420 -419
- package/src/auth/__tests__/reset.test.ts +296 -296
- package/src/brain/adapters/anthropic-oauth.ts +421 -420
- package/src/brain/adapters/index.ts +2 -1
- package/src/brain/adapters/ollama.ts +1 -1
- package/src/brain/adapters/openai-oauth.ts +534 -533
- package/src/brain/brain.ts +2 -1
- package/src/brain/redactor.ts +196 -196
- package/src/bundler/__tests__/cli-bench-utils.test.ts +149 -149
- package/src/bundler/__tests__/cold-start.test.ts +504 -504
- package/src/bundler/__tests__/fast-refresh.test.ts +607 -606
- package/src/bundler/__tests__/hdr.test.ts +1 -1
- package/src/bundler/analyzer.ts +958 -958
- package/src/bundler/build.ts +104 -14
- package/src/bundler/dev.ts +125 -0
- package/src/bundler/hmr-types.ts +1 -0
- package/src/bundler/plugins/__tests__/react-compiler-lint.test.ts +110 -0
- package/src/bundler/plugins/index.ts +14 -0
- package/src/bundler/plugins/react-compiler-lint.ts +253 -0
- package/src/bundler/plugins/react-compiler.ts +162 -0
- package/src/bundler/types.ts +12 -0
- package/src/change/integrity.ts +2 -1
- package/src/client/index.ts +10 -0
- package/src/client/island.ts +38 -11
- package/src/client/router.ts +6 -1
- package/src/config/mandu.ts +57 -0
- package/src/config/validate.ts +42 -0
- package/src/content/collection.ts +844 -809
- package/src/content/content-layer.ts +316 -314
- package/src/content/content.test.ts +433 -433
- package/src/content/digest.ts +133 -133
- package/src/content/generate-types.ts +168 -168
- package/src/content/index.ts +6 -1
- package/src/content/llms-txt.ts +277 -277
- package/src/contract/define.ts +474 -474
- package/src/contract/route-helpers.ts +2 -1
- package/src/contract/zod-utils.ts +158 -155
- package/src/db/index.ts +513 -513
- package/src/desktop/__tests__/smoke.test.ts +100 -100
- package/src/desktop/webview-fallback.ts +583 -583
- package/src/desktop/window.ts +3 -1
- package/src/dev-error-overlay/overlay-client.ts +300 -300
- package/src/devtools/ai/mcp-connector.ts +499 -498
- package/src/devtools/client/components/kitchen-root.tsx +7 -2
- package/src/email/resend.ts +163 -163
- package/src/guard/__tests__/tsgolint-bridge.test.ts +347 -0
- package/src/guard/ast-analyzer.ts +806 -806
- package/src/guard/graph.ts +898 -898
- package/src/guard/index.ts +16 -0
- package/src/guard/statistics.ts +578 -578
- package/src/guard/tsgolint-bridge.ts +512 -0
- package/src/i18n/locale-resolver.ts +214 -214
- package/src/id/__tests__/id.test.ts +120 -120
- package/src/intent/index.ts +321 -321
- package/src/island/index.ts +39 -23
- package/src/kitchen/api/contract-api.ts +15 -8
- package/src/kitchen/kitchen-ui.ts +2137 -2137
- package/src/lockfile/index.ts +3 -2
- package/src/middleware/oauth/__tests__/oauth.test.ts +575 -574
- package/src/middleware/rate-limit/__tests__/rate-limit.test.ts +642 -642
- package/src/middleware/secure/index.ts +417 -417
- package/src/observability/event-bus.ts +2 -2
- package/src/observability/metrics.ts +334 -334
- package/src/observability/tracing.ts +694 -694
- package/src/openapi/generator.ts +1 -1
- package/src/perf/user-marks.ts +553 -553
- package/src/plugins/registry.ts +387 -387
- package/src/resource/ddl/diff.ts +392 -392
- package/src/resource/ddl/snapshot.ts +448 -447
- package/src/resource/generator-schema.ts +477 -476
- package/src/resource/parser.ts +4 -2
- package/src/resource/schema.ts +1 -1
- package/src/router/fs-patterns.ts +422 -422
- package/src/runtime/fast-refresh-types.ts +126 -128
- package/src/runtime/image-handler.ts +206 -195
- package/src/runtime/router.test.ts +476 -476
- package/src/runtime/security.ts +155 -155
- package/src/runtime/server.ts +36 -19
- package/src/runtime/session-key.ts +328 -328
- package/src/scheduler/__tests__/scheduler.test.ts +514 -514
- package/src/seo/resolve/index.ts +353 -353
- package/src/spec/load.ts +1 -1
- package/src/testing/reporter.ts +676 -676
- package/src/testing/server.ts +196 -196
- package/src/testing/snapshot.ts +444 -444
- package/src/utils/__tests__/lru-cache.test.ts +186 -186
- package/src/utils/bun.ts +8 -8
package/src/guard/graph.ts
CHANGED
|
@@ -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,"&").replace(/</g,"<").replace(/>/g,">"); }
|
|
792
|
-
function escapeAttr(s) { return escapeHtml(s).replace(/"/g,"""); }
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
891
|
-
}
|
|
892
|
-
|
|
893
|
-
function escAttr(s: string): string {
|
|
894
|
-
return escText(s).replace(/"/g, """).replace(/'/g, "'");
|
|
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,"&").replace(/</g,"<").replace(/>/g,">"); }
|
|
792
|
+
function escapeAttr(s) { return escapeHtml(s).replace(/"/g,"""); }
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
function escAttr(s: string): string {
|
|
894
|
+
return escText(s).replace(/"/g, """).replace(/'/g, "'");
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
// Keep unused-sep silent in case bundler strips path namespace import
|
|
898
|
+
void sep;
|