@toolbaux/guardian 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +366 -0
  3. package/dist/adapters/csharp-adapter.js +149 -0
  4. package/dist/adapters/go-adapter.js +96 -0
  5. package/dist/adapters/index.js +16 -0
  6. package/dist/adapters/java-adapter.js +122 -0
  7. package/dist/adapters/python-adapter.js +183 -0
  8. package/dist/adapters/runner.js +69 -0
  9. package/dist/adapters/types.js +1 -0
  10. package/dist/adapters/typescript-adapter.js +179 -0
  11. package/dist/benchmarking/framework.js +91 -0
  12. package/dist/cli.js +343 -0
  13. package/dist/commands/analyze-depth.js +43 -0
  14. package/dist/commands/api-spec-extractor.js +52 -0
  15. package/dist/commands/breaking-change-analyzer.js +334 -0
  16. package/dist/commands/config-compliance.js +219 -0
  17. package/dist/commands/constraints.js +221 -0
  18. package/dist/commands/context.js +101 -0
  19. package/dist/commands/data-flow-tracer.js +291 -0
  20. package/dist/commands/dependency-impact-analyzer.js +27 -0
  21. package/dist/commands/diff.js +146 -0
  22. package/dist/commands/discrepancy.js +71 -0
  23. package/dist/commands/doc-generate.js +163 -0
  24. package/dist/commands/doc-html.js +120 -0
  25. package/dist/commands/drift.js +88 -0
  26. package/dist/commands/extract.js +16 -0
  27. package/dist/commands/feature-context.js +116 -0
  28. package/dist/commands/generate.js +339 -0
  29. package/dist/commands/guard.js +182 -0
  30. package/dist/commands/init.js +209 -0
  31. package/dist/commands/intel.js +20 -0
  32. package/dist/commands/license-dependency-auditor.js +33 -0
  33. package/dist/commands/performance-hotspot-profiler.js +42 -0
  34. package/dist/commands/search.js +314 -0
  35. package/dist/commands/security-boundary-auditor.js +359 -0
  36. package/dist/commands/simulate.js +294 -0
  37. package/dist/commands/summary.js +27 -0
  38. package/dist/commands/test-coverage-mapper.js +264 -0
  39. package/dist/commands/verify-drift.js +62 -0
  40. package/dist/config.js +441 -0
  41. package/dist/extract/ai-context-hints.js +107 -0
  42. package/dist/extract/analyzers/backend.js +1704 -0
  43. package/dist/extract/analyzers/depth.js +264 -0
  44. package/dist/extract/analyzers/frontend.js +2221 -0
  45. package/dist/extract/api-usage-tracker.js +19 -0
  46. package/dist/extract/cache.js +53 -0
  47. package/dist/extract/codebase-intel.js +190 -0
  48. package/dist/extract/compress.js +452 -0
  49. package/dist/extract/context-block.js +356 -0
  50. package/dist/extract/contracts.js +183 -0
  51. package/dist/extract/discrepancies.js +233 -0
  52. package/dist/extract/docs-loader.js +110 -0
  53. package/dist/extract/docs.js +2379 -0
  54. package/dist/extract/drift.js +1578 -0
  55. package/dist/extract/duplicates.js +435 -0
  56. package/dist/extract/feature-arcs.js +138 -0
  57. package/dist/extract/graph.js +76 -0
  58. package/dist/extract/html-doc.js +1409 -0
  59. package/dist/extract/ignore.js +45 -0
  60. package/dist/extract/index.js +455 -0
  61. package/dist/extract/llm-client.js +159 -0
  62. package/dist/extract/pattern-registry.js +141 -0
  63. package/dist/extract/product-doc.js +497 -0
  64. package/dist/extract/python.js +1202 -0
  65. package/dist/extract/runtime.js +193 -0
  66. package/dist/extract/schema-evolution-validator.js +35 -0
  67. package/dist/extract/test-gap-analyzer.js +20 -0
  68. package/dist/extract/tests.js +74 -0
  69. package/dist/extract/types.js +1 -0
  70. package/dist/extract/validate-backend.js +30 -0
  71. package/dist/extract/writer.js +11 -0
  72. package/dist/output-layout.js +37 -0
  73. package/dist/project-discovery.js +309 -0
  74. package/dist/schema/architecture.js +350 -0
  75. package/dist/schema/feature-spec.js +89 -0
  76. package/dist/schema/index.js +8 -0
  77. package/dist/schema/ux.js +46 -0
  78. package/package.json +75 -0
@@ -0,0 +1,1409 @@
1
+ /**
2
+ * HTML Doc Renderer — generates a multi-page Javadoc-style HTML viewer.
3
+ *
4
+ * Output: Record<filename, html> — one file per section, shared nav sidebar.
5
+ *
6
+ * Pages:
7
+ * index.html Overview (stats + stakeholder metrics)
8
+ * architecture.html System Architecture (inter-module diagram + hld.md)
9
+ * api-surface.html API Surface (all domains)
10
+ * data-models.html Data Models (ER diagrams + full schemas)
11
+ * quality.html Quality Signals + Pattern Registry
12
+ * tasks.html Background Tasks (conditional)
13
+ * timeline.html Feature Timeline (conditional)
14
+ * frontend.html Frontend Pages (conditional)
15
+ * discrepancies.html Discrepancies (conditional)
16
+ */
17
+ import { parseIntegrationDomains } from "./docs-loader.js";
18
+ const COLLAPSE_THRESHOLD = 15;
19
+ // ── Entry point ───────────────────────────────────────────────────────────
20
+ export function renderHtmlDoc(options) {
21
+ const { intel, featureArcs, discrepancies, existingDocs, uxSnapshot, productContext } = options;
22
+ const integrationDomains = existingDocs?.integrationByDomain
23
+ ? parseIntegrationDomains(existingDocs.integrationByDomain)
24
+ : null;
25
+ const domainNames = integrationDomains
26
+ ? Array.from(integrationDomains.keys()).sort()
27
+ : Array.from(groupEndpointsByDomain(intel).keys()).sort();
28
+ const modelGroups = groupModelsByModule(intel);
29
+ const groupNames = Array.from(modelGroups.keys()).sort();
30
+ const hasArcs = !!(featureArcs && Object.keys(featureArcs.arcs).length > 0);
31
+ const hasTasks = intel.background_tasks.length > 0;
32
+ const hasPages = intel.frontend_pages.length > 0;
33
+ const hasDiscrepancies = !!discrepancies;
34
+ // ── Page definitions (drives sidebar nav) ────────────────────────────────
35
+ const pages = [
36
+ { file: "index.html", label: `${intel.meta.project} Overview` },
37
+ { file: "architecture.html", label: "System Architecture" },
38
+ {
39
+ file: "api-surface.html",
40
+ label: "API Endpoints",
41
+ badge: String(intel.meta.counts.endpoints),
42
+ anchors: domainNames.map((d) => ({ id: mkId(d), label: d })),
43
+ },
44
+ {
45
+ file: "data-models.html",
46
+ label: "Data Models & Schemas",
47
+ badge: String(intel.meta.counts.models),
48
+ anchors: groupNames.map((g) => ({ id: mkId(`${g}-group`), label: g })),
49
+ },
50
+ {
51
+ file: "quality.html",
52
+ label: "Code Quality",
53
+ badge: String(intel.pattern_registry.patterns.filter((p) => p.occurrences > 0).length),
54
+ },
55
+ ];
56
+ if (hasTasks) {
57
+ const unique = deduplicateTasks(intel.background_tasks);
58
+ pages.push({ file: "tasks.html", label: "Background Tasks", badge: String(unique.length) });
59
+ }
60
+ if (hasArcs && featureArcs) {
61
+ pages.push({
62
+ file: "timeline.html",
63
+ label: "Feature Timeline",
64
+ badge: String(Object.keys(featureArcs.arcs).length),
65
+ });
66
+ }
67
+ if (hasPages) {
68
+ pages.push({ file: "frontend.html", label: "Frontend Pages", badge: String(intel.frontend_pages.length) });
69
+ }
70
+ if (hasDiscrepancies) {
71
+ pages.push({
72
+ file: "discrepancies.html",
73
+ label: "Discrepancies",
74
+ badge: discrepancies.summary.total_issues > 0 ? String(discrepancies.summary.total_issues) : undefined,
75
+ });
76
+ }
77
+ const project = intel.meta.project;
78
+ // ── Build all pages ───────────────────────────────────────────────────────
79
+ const files = {};
80
+ files["index.html"] = buildPage(project, pages, "index.html", `${project} Overview`, renderOverviewPage(intel, existingDocs ?? {}, productContext ?? null, uxSnapshot ?? null));
81
+ files["architecture.html"] = buildPage(project, pages, "architecture.html", "System Architecture", renderArchitecturePage(intel, existingDocs ?? {}));
82
+ files["api-surface.html"] = buildPage(project, pages, "api-surface.html", "API Surface", renderApiSurfacePage(intel, integrationDomains, domainNames));
83
+ files["data-models.html"] = buildPage(project, pages, "data-models.html", "Data Models", renderDataModelsPage(intel, modelGroups, groupNames));
84
+ files["quality.html"] = buildPage(project, pages, "quality.html", "Quality & Patterns", renderQualityPage(intel, existingDocs ?? {}));
85
+ if (hasTasks) {
86
+ files["tasks.html"] = buildPage(project, pages, "tasks.html", "Background Tasks", renderTasksPage(intel));
87
+ }
88
+ if (hasArcs && featureArcs) {
89
+ files["timeline.html"] = buildPage(project, pages, "timeline.html", "Feature Timeline", renderTimelinePage(featureArcs));
90
+ }
91
+ if (hasPages) {
92
+ files["frontend.html"] = buildPage(project, pages, "frontend.html", "Frontend Pages", renderFrontendPage(intel, uxSnapshot ?? null));
93
+ }
94
+ if (hasDiscrepancies && discrepancies) {
95
+ files["discrepancies.html"] = buildPage(project, pages, "discrepancies.html", "Discrepancies", renderDiscrepanciesPage(discrepancies));
96
+ }
97
+ return files;
98
+ }
99
+ // ── Page shell ────────────────────────────────────────────────────────────
100
+ function buildPage(project, pages, currentFile, title, body) {
101
+ const nav = buildNav(pages, currentFile);
102
+ return `<!DOCTYPE html>
103
+ <html lang="en">
104
+ <head>
105
+ <meta charset="UTF-8">
106
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
107
+ <title>${e(title)} — ${e(project)}</title>
108
+ <style>${CSS}</style>
109
+ </head>
110
+ <body>
111
+ <div id="layout">
112
+ <nav id="sidebar">
113
+ <div id="sidebar-header">
114
+ <a href="index.html" id="project-name">${e(project)}</a>
115
+ <input id="search-box" type="text" placeholder="Filter..." autocomplete="off" />
116
+ </div>
117
+ <div id="nav-tree">${nav}</div>
118
+ </nav>
119
+ <main id="content">
120
+ <h1 class="page-title">${e(title)}</h1>
121
+ ${body}
122
+ </main>
123
+ </div>
124
+ <script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script>
125
+ <script>${JS}</script>
126
+ </body>
127
+ </html>`;
128
+ }
129
+ // ── Sidebar nav ───────────────────────────────────────────────────────────
130
+ function buildNav(pages, currentFile) {
131
+ return pages.map((page) => {
132
+ const isActive = page.file === currentFile;
133
+ const badge = page.badge ? `<span class="nav-badge">${e(page.badge)}</span>` : "";
134
+ const activeClass = isActive ? " active" : "";
135
+ if (!page.anchors || page.anchors.length === 0) {
136
+ return `<a class="nav-top-link${activeClass}" href="${page.file}">${e(page.label)}${badge}</a>`;
137
+ }
138
+ const childrenHtml = page.anchors.map((a) => `<a class="nav-item" href="${page.file}#${a.id}">${e(a.label)}</a>`).join("");
139
+ return `<div class="nav-group${isActive ? " open" : ""}">
140
+ <div class="nav-group-header" onclick="toggleNav(this)">
141
+ <span class="chevron">▼</span>
142
+ <a class="nav-group-link${activeClass}" href="${page.file}">${e(page.label)}${badge}</a>
143
+ </div>
144
+ <div class="nav-children">${childrenHtml}</div>
145
+ </div>`;
146
+ }).join("\n");
147
+ }
148
+ // ── Page renderers ────────────────────────────────────────────────────────
149
+ function renderOverviewPage(intel, docs, productContext, uxSnapshot) {
150
+ const parts = [];
151
+ // Product description from README
152
+ if (productContext) {
153
+ parts.push(`<section class="product-context"><div class="product-description">${renderMd(productContext)}</div></section>`);
154
+ }
155
+ // Stats grid
156
+ const stats = [
157
+ { label: "Endpoints", value: intel.meta.counts.endpoints },
158
+ { label: "Models", value: intel.meta.counts.models },
159
+ { label: "Pages", value: intel.meta.counts.pages },
160
+ { label: "Components", value: uxSnapshot?.components?.length ?? 0 },
161
+ { label: "Tasks", value: intel.meta.counts.tasks },
162
+ { label: "Modules", value: intel.meta.counts.modules },
163
+ ];
164
+ parts.push(`<div class="stats-grid">
165
+ ${stats.map((s) => `<div class="stat-card"><div class="stat-value">${s.value}</div><div class="stat-label">${s.label}</div></div>`).join("")}
166
+ </div>`);
167
+ // Backend modules summary
168
+ const backendModules = intel.service_map
169
+ .filter((m) => m.type === "backend" && m.file_count > 0)
170
+ .sort((a, b) => b.endpoint_count - a.endpoint_count);
171
+ if (backendModules.length > 0) {
172
+ const moduleRows = backendModules.map((m) => {
173
+ const deps = m.imports.length > 0 ? m.imports.join(", ") : "—";
174
+ return `<tr><td><strong>${e(m.id)}</strong></td><td>${m.endpoint_count}</td><td>${m.file_count}</td><td>${e(deps)}</td></tr>`;
175
+ }).join("");
176
+ parts.push(`<section><h2>Backend Modules</h2>
177
+ <table><thead><tr><th>Module</th><th>Endpoints</th><th>Files</th><th>Dependencies</th></tr></thead>
178
+ <tbody>${moduleRows}</tbody></table></section>`);
179
+ }
180
+ // Frontend pages summary
181
+ if (intel.frontend_pages.length > 0) {
182
+ const pageRows = intel.frontend_pages.map((p) => {
183
+ const components = p.direct_components?.length ?? 0;
184
+ const apis = p.api_calls?.length ?? 0;
185
+ const apiList = apis > 0 ? p.api_calls.slice(0, 3).join(", ") + (apis > 3 ? ` +${apis - 3}` : "") : "—";
186
+ return `<tr><td><code>${e(p.path)}</code></td><td>${e(p.component ?? "")}</td><td>${components}</td><td>${apiList}</td></tr>`;
187
+ }).join("");
188
+ parts.push(`<section><h2>Frontend Pages</h2>
189
+ <table><thead><tr><th>Route</th><th>Component</th><th>Children</th><th>API Calls</th></tr></thead>
190
+ <tbody>${pageRows}</tbody></table></section>`);
191
+ }
192
+ // Stakeholder metrics
193
+ if (docs.stakeholderMetrics) {
194
+ parts.push(`<section><h2>Health</h2>${renderMd(docs.stakeholderMetrics)}</section>`);
195
+ }
196
+ // System scale
197
+ if (docs.systemScale) {
198
+ parts.push(`<section><h2>System Scale</h2>${renderMd(docs.systemScale)}</section>`);
199
+ }
200
+ return parts.join("\n");
201
+ }
202
+ function renderArchitecturePage(intel, docs) {
203
+ const parts = [];
204
+ // ── 1. Per-module, per-domain service interaction diagrams ────────────────
205
+ const { modules: interactionModules, serviceUseCount } = buildModuleInteractionData(intel);
206
+ const sharedServices = new Set(Array.from(serviceUseCount.entries()).filter(([, n]) => n > 1).map(([s]) => s));
207
+ if (interactionModules.size > 0) {
208
+ const moduleSections = Array.from(interactionModules.entries()).map(([mod, { domains }]) => {
209
+ const totalDomains = domains.size;
210
+ const totalSvcs = new Set(Array.from(domains.values()).flatMap((d) => d.services)).size;
211
+ const domainParts = Array.from(domains.entries()).map(([domain, { services, epCount }]) => {
212
+ const diagram = buildDomainDiagram(domain, services, sharedServices);
213
+ return `<details class="domain-diagram">
214
+ <summary><strong>${e(domain)}</strong> <span class="badge">${epCount} ep</span> <span class="badge-svc">${services.length} services</span></summary>
215
+ <div class="details-body">${lightbox(`<div class="mermaid">\n${diagram}\n</div>`)}</div>
216
+ </details>`;
217
+ }).join("\n");
218
+ return `<div class="module-block" id="mod-${mkId(mod)}">
219
+ <h3>${e(mod)} <span class="muted">${totalDomains} domains · ${totalSvcs} unique services</span></h3>
220
+ ${domainParts}
221
+ </div>`;
222
+ }).join("\n");
223
+ parts.push(`<section id="module-interaction">
224
+ <h2>Module Interaction</h2>
225
+ <p class="muted">Per-domain service dependencies grouped by backend module. Click a domain to expand its diagram.</p>
226
+ ${moduleSections}
227
+ </section>`);
228
+ }
229
+ // ── 1b. Key Workflow Diagrams (sequence diagrams from endpoint traces) ──────
230
+ {
231
+ const workflows = buildWorkflowDiagrams(intel);
232
+ if (workflows.length > 0) {
233
+ const workflowHtml = workflows.map((w) => `<details class="domain-diagram"${w.important ? " open" : ""}>
234
+ <summary><strong>${e(w.title)}</strong> <span class="badge">${w.method} ${w.path}</span></summary>
235
+ <div class="details-body">${lightbox(`<div class="mermaid">\n${w.diagram}\n</div>`)}</div>
236
+ </details>`).join("\n");
237
+ parts.push(`<section id="workflows">
238
+ <h2>Key Workflows</h2>
239
+ <p class="muted">Sequence diagrams showing how data flows through the system for key operations.</p>
240
+ ${workflowHtml}
241
+ </section>`);
242
+ }
243
+ }
244
+ // ── 1c. Cross-Service Communication Diagram ────────────────────────────────
245
+ {
246
+ const crossServiceDiagram = buildCrossServiceDiagram(intel);
247
+ if (crossServiceDiagram) {
248
+ parts.push(`<section id="cross-service">
249
+ <h2>Service Communication Map</h2>
250
+ <p class="muted">How services call each other. Derived from service call patterns and proxy endpoints.</p>
251
+ ${lightbox(`<div class="mermaid">\n${crossServiceDiagram}\n</div>`)}
252
+ </section>`);
253
+ }
254
+ }
255
+ // ── 1d. Full System Architecture Diagram ────────────────────────────────────
256
+ {
257
+ const systemDiagram = buildFullSystemDiagram(intel);
258
+ if (systemDiagram) {
259
+ parts.push(`<section id="system-diagram">
260
+ <h2>System Architecture</h2>
261
+ <p class="muted">Full system view: services, data stores, and external dependencies.</p>
262
+ ${lightbox(`<div class="mermaid">\n${systemDiagram}\n</div>`)}
263
+ </section>`);
264
+ }
265
+ }
266
+ // ── 3. Backend subsystems (may include per-module diagrams) ────────────────
267
+ if (docs.backendSubsystems) {
268
+ parts.push(`<section id="backend-subsystems">
269
+ <h2>Backend Subsystems</h2>
270
+ ${lightbox(renderMd(docs.backendSubsystems))}
271
+ </section>`);
272
+ }
273
+ // ── 4. Coupling heatmap ────────────────────────────────────────────────────
274
+ if (docs.couplingHeatmap) {
275
+ parts.push(`<section id="coupling">
276
+ <h2>Structural Coupling Heatmap</h2>
277
+ ${renderMd(docs.couplingHeatmap)}
278
+ </section>`);
279
+ }
280
+ // ── 5. Drift summary ───────────────────────────────────────────────────────
281
+ if (docs.driftSummary) {
282
+ parts.push(`<section id="drift">
283
+ <h2>Drift Summary</h2>
284
+ ${renderMd(docs.driftSummary)}
285
+ </section>`);
286
+ }
287
+ // ── 6. Module table fallback ───────────────────────────────────────────────
288
+ const backendModules = intel.service_map.filter((m) => m.type === "backend");
289
+ const rows = backendModules.sort((a, b) => b.endpoint_count - a.endpoint_count)
290
+ .map((m) => `<tr><td><code>${e(m.id)}</code></td><td>${e(m.layer)}</td><td>${m.file_count}</td><td>${m.endpoint_count}</td></tr>`)
291
+ .join("");
292
+ parts.push(`<section id="modules">
293
+ <h2>Modules</h2>
294
+ ${table(["Module", "Layer", "Files", "Endpoints"], rows)}
295
+ </section>`);
296
+ return parts.join("\n");
297
+ }
298
+ function renderApiSurfacePage(intel, integrationDomains, domainNames) {
299
+ const quickIndex = `<div class="quick-index">${domainNames.map((d) => `<a href="#${mkId(d)}">${e(d)}</a>`).join("")}</div>`;
300
+ const sections = [quickIndex];
301
+ if (integrationDomains && integrationDomains.size > 0) {
302
+ for (const domain of domainNames) {
303
+ const entry = integrationDomains.get(domain);
304
+ if (!entry)
305
+ continue;
306
+ const rowCount = Math.max(0, (entry.content.match(/^\|/gm) ?? []).length - 2);
307
+ const inner = renderMd(entry.content);
308
+ const open = rowCount <= COLLAPSE_THRESHOLD;
309
+ sections.push(`<section id="${mkId(domain)}" class="domain-section">
310
+ <h2>${e(domain)} <span class="badge">${rowCount}</span></h2>
311
+ <details${open ? " open" : ""}><summary>Show endpoints</summary><div class="details-body">${inner}</div></details>
312
+ </section>`);
313
+ }
314
+ }
315
+ else {
316
+ const grouped = groupEndpointsByDomain(intel);
317
+ for (const domain of domainNames) {
318
+ const endpoints = grouped.get(domain) ?? [];
319
+ const rows = endpoints.map(([, ep]) => {
320
+ const pats = ep.patterns.length > 0 ? ep.patterns.join(", ") : "—";
321
+ return `<tr><td><code>${e(ep.method)}</code></td><td><code>${e(ep.path)}</code></td><td><code>${e(ep.handler)}</code></td><td>${e(pats)}</td></tr>`;
322
+ }).join("");
323
+ const open = endpoints.length <= COLLAPSE_THRESHOLD;
324
+ sections.push(`<section id="${mkId(domain)}" class="domain-section">
325
+ <h2>${e(domain)} <span class="badge">${endpoints.length}</span></h2>
326
+ <details${open ? " open" : ""}><summary>Show endpoints</summary><div class="details-body">${table(["Method", "Path", "Handler", "Patterns"], rows)}</div></details>
327
+ </section>`);
328
+ }
329
+ }
330
+ return sections.join("\n");
331
+ }
332
+ function renderDataModelsPage(intel, modelGroups, groupNames) {
333
+ const quickIndex = `<div class="quick-index">${groupNames.map((g) => `<a href="#${mkId(`${g}-group`)}">${e(g)}</a>`).join("")}</div>`;
334
+ const sections = [quickIndex];
335
+ for (const group of groupNames) {
336
+ const models = modelGroups.get(group) ?? [];
337
+ const groupId = mkId(`${group}-group`);
338
+ // Split by framework — ORM models (SQLAlchemy/Django) vs schema models (Pydantic)
339
+ const ormModels = models.filter(([, m]) => m.framework !== "pydantic");
340
+ const schemaModels = models.filter(([, m]) => m.framework === "pydantic");
341
+ // ER diagram for ORM models only
342
+ const erDiagram = buildErDiagram(ormModels);
343
+ const diagramHtml = erDiagram
344
+ ? `<div class="subsection">${lightbox(`<div class="mermaid">\n${erDiagram}\n</div>`)}</div>`
345
+ : "";
346
+ const renderModelBlock = (label, list) => {
347
+ if (list.length === 0)
348
+ return "";
349
+ const schemas = list.map(([name, m]) => {
350
+ const schema = renderModelSchema(name, m);
351
+ const role = inferModelRole(name, m, intel);
352
+ const roleHtml = role ? `<span class="model-role">${e(role)}</span>` : "";
353
+ return `<details>
354
+ <summary><strong>${e(name)}</strong> ${roleHtml}<small class="muted">${m.fields.length} fields${m.relationships.length > 0 ? ` · ${m.relationships.length} rels` : ""}</small></summary>
355
+ <div class="details-body">${schema}</div>
356
+ </details>`;
357
+ }).join("\n");
358
+ return `<div class="model-layer">
359
+ <h3>${label} <span class="badge">${list.length}</span></h3>
360
+ ${schemas}
361
+ </div>`;
362
+ };
363
+ sections.push(`<section id="${groupId}">
364
+ <h2>${e(group)} <span class="badge">${models.length} models</span></h2>
365
+ ${diagramHtml}
366
+ ${renderModelBlock("ORM Models (database tables)", ormModels)}
367
+ ${renderModelBlock("Schema Models (request / response)", schemaModels)}
368
+ </section>`);
369
+ }
370
+ return sections.join("\n");
371
+ }
372
+ function renderQualityPage(intel, docs) {
373
+ const parts = [];
374
+ // Quality signals
375
+ parts.push(`<section id="quality-signals"><h2>Quality Signals</h2>${docs.qualitySignals
376
+ ? renderMd(docs.qualitySignals)
377
+ : `<p class="muted">Run <code>specguard extract</code> to populate quality signals.</p>`}</section>`);
378
+ // Pattern registry
379
+ const active = intel.pattern_registry.patterns.filter((p) => p.occurrences > 0);
380
+ const rows = active.map((p) => {
381
+ const example = p.example_endpoints[0] ? `<code>${e(p.example_endpoints[0])}</code>` : "—";
382
+ return `<tr><td>${e(p.id)}</td><td><strong>${e(p.name)}</strong></td><td>${p.occurrences}</td><td>${e(p.description)}</td><td>${example}</td></tr>`;
383
+ }).join("");
384
+ parts.push(`<section id="pattern-registry">
385
+ <h2>Pattern Registry <span class="badge">${active.length} active</span></h2>
386
+ ${table(["ID", "Pattern", "Occurrences", "Description", "Example"], rows)}
387
+ </section>`);
388
+ return parts.join("\n");
389
+ }
390
+ function renderTasksPage(intel) {
391
+ const unique = deduplicateTasks(intel.background_tasks);
392
+ // Build trigger map: task name → endpoint keys that call it
393
+ const triggerMap = buildTaskTriggerMap(intel);
394
+ const cards = unique.map((task) => {
395
+ const triggers = triggerMap.get(task.name) ?? [];
396
+ const triggerHtml = triggers.length > 0
397
+ ? `<div class="trigger-list"><strong>Called from:</strong><ul>${triggers.map((t) => `<li><code>${e(t)}</code></li>`).join("")}</ul></div>`
398
+ : `<p class="muted">No direct endpoint triggers found in service_calls.</p>`;
399
+ const sources = task.sources;
400
+ const redundancyNote = sources.length > 1
401
+ ? `<p class="note-reuse">♻ Reused across ${sources.length} files — same function, different workflows (not redundant).</p>`
402
+ : sources.length === 0 ? "" : `<p class="muted">Source: <code>${e(sources[0])}</code></p>`;
403
+ return `<div class="task-card" id="${mkId(task.name)}">
404
+ <div class="task-header">
405
+ <code class="task-name">${e(task.name)}</code>
406
+ <span class="badge">${e(task.kind)}</span>
407
+ ${sources.length > 1 ? `<span class="badge warn">${sources.length} call sites</span>` : ""}
408
+ </div>
409
+ ${redundancyNote}
410
+ ${sources.length > 1 ? `<div class="source-list"><strong>Source files:</strong><ul>${sources.map((s) => `<li><code>${e(s)}</code></li>`).join("")}</ul></div>` : ""}
411
+ ${triggerHtml}
412
+ </div>`;
413
+ }).join("\n");
414
+ return `<div class="task-grid">${cards}</div>`;
415
+ }
416
+ function renderTimelinePage(featureArcs) {
417
+ const parts = [];
418
+ for (const [tag, arc] of Object.entries(featureArcs.arcs)) {
419
+ const sprintRows = Object.entries(arc.sprints).map(([sprint, snap]) => {
420
+ const eps = snap.endpoints.length > 0
421
+ ? snap.endpoints.map((ep) => `<code>${e(ep)}</code>`).join(", ") : "—";
422
+ const mods = snap.models.length > 0
423
+ ? snap.models.map((m) => `<code>${e(m)}</code>`).join(", ") : "—";
424
+ return `<tr><td>${e(sprint)}</td><td>${snap.features.map(e).join(", ")}</td><td>${eps}</td><td>${mods}</td></tr>`;
425
+ }).join("");
426
+ parts.push(`<section id="${mkId(`arc-${tag}`)}">
427
+ <h2>${e(tag)} <span class="badge">${arc.total_endpoints} endpoints · ${arc.total_models} models</span></h2>
428
+ ${table(["Sprint", "Features", "Endpoints", "Models"], sprintRows)}
429
+ </section>`);
430
+ }
431
+ return parts.join("\n");
432
+ }
433
+ function renderFrontendPage(intel, ux) {
434
+ return intel.frontend_pages.map((page) => {
435
+ // ── Component interaction diagram from UX snapshot ─────────────────────
436
+ let componentDiagram = "";
437
+ if (ux?.component_graph) {
438
+ const diagram = buildComponentDiagram(page.component, ux);
439
+ if (diagram) {
440
+ componentDiagram = `<div class="subsection">
441
+ <h3>Component Tree</h3>
442
+ ${lightbox(`<div class="mermaid">\n${diagram}\n</div>`)}
443
+ </div>`;
444
+ }
445
+ }
446
+ // ── API calls grouped by domain ────────────────────────────────────────
447
+ const byDomain = new Map();
448
+ for (const call of page.api_calls) {
449
+ const normalised = call.replace(/^[A-Z]+ /, "").replace(/^\$\{[^}]+\}\//, "/").replace(/^\$\{[^}]+\}/, "").replace(/^\//, "");
450
+ const parts = normalised.split("/");
451
+ const skip = new Set(["api", "v1", "v2", ""]);
452
+ const domain = parts.find((p) => !skip.has(p) && !p.startsWith("{") && !p.startsWith("$")) ?? "other";
453
+ const list = byDomain.get(domain) ?? [];
454
+ list.push(call);
455
+ byDomain.set(domain, list);
456
+ }
457
+ const domainHtml = Array.from(byDomain.entries()).sort((a, b) => a[0].localeCompare(b[0])).map(([domain, calls]) => {
458
+ const rows = calls.map((c) => `<tr><td><code>${e(c)}</code></td></tr>`).join("");
459
+ return `<details open><summary><strong>${e(domain)}</strong> <span class="badge">${calls.length}</span></summary>
460
+ <div class="details-body">${table(["Endpoint"], rows)}</div></details>`;
461
+ }).join("\n");
462
+ return `<section id="${mkId(page.path)}">
463
+ <h2><code>${e(page.path)}</code> <span class="muted">${e(page.component)}</span></h2>
464
+ <p><strong>${page.api_calls.length} API calls</strong> across ${byDomain.size} domain(s)</p>
465
+ ${componentDiagram}
466
+ ${domainHtml}
467
+ </section>`;
468
+ }).join("\n");
469
+ }
470
+ function buildComponentDiagram(rootComponent, ux) {
471
+ // Find the root node by component name
472
+ const rootNode = ux.components.find((c) => c.name === rootComponent || c.id.endsWith(`#${rootComponent}`));
473
+ if (!rootNode)
474
+ return "";
475
+ // BFS from root through component_graph edges (max depth 4, max nodes 30)
476
+ const edges = ux.component_graph ?? [];
477
+ const visited = new Set([rootNode.id]);
478
+ const queue = [rootNode.id];
479
+ const usedEdges = [];
480
+ while (queue.length > 0 && visited.size < 20) {
481
+ const current = queue.shift();
482
+ const children = edges.filter((e) => e.from === current);
483
+ for (const edge of children) {
484
+ if (!visited.has(edge.to) && visited.size < 20) {
485
+ visited.add(edge.to);
486
+ queue.push(edge.to);
487
+ usedEdges.push(edge);
488
+ }
489
+ }
490
+ }
491
+ if (usedEdges.length === 0)
492
+ return "";
493
+ // Build name lookup
494
+ const idToName = new Map(ux.components.map((c) => [c.id, c.name]));
495
+ const getName = (id) => idToName.get(id) ?? id.split("#").pop() ?? id;
496
+ const lines = ["flowchart TD"];
497
+ const nodeIds = new Set();
498
+ for (const edge of usedEdges) {
499
+ const fromName = safeLabel(getName(edge.from));
500
+ const toName = safeLabel(getName(edge.to));
501
+ const fromId = safeMermaidId(edge.from);
502
+ const toId = safeMermaidId(edge.to);
503
+ if (!nodeIds.has(fromId)) {
504
+ lines.push(` ${fromId}["${fromName}"]`);
505
+ nodeIds.add(fromId);
506
+ }
507
+ if (!nodeIds.has(toId)) {
508
+ lines.push(` ${toId}["${toName}"]`);
509
+ nodeIds.add(toId);
510
+ }
511
+ lines.push(` ${fromId} --> ${toId}`);
512
+ }
513
+ return lines.join("\n");
514
+ }
515
+ function renderDiscrepanciesPage(discrepancies) {
516
+ if (discrepancies.summary.total_issues === 0) {
517
+ return `<p class="ok">✓ No discrepancies. Code and specs are in sync.</p>`;
518
+ }
519
+ const parts = [];
520
+ const crit = discrepancies.summary.has_critical;
521
+ parts.push(`<p class="${crit ? "critical" : "warn"}">${crit ? "⚠ " : ""}${discrepancies.summary.total_issues} issue(s) detected.</p>`);
522
+ if (discrepancies.new_endpoints.length > 0) {
523
+ const rows = discrepancies.new_endpoints.map((ep) => `<tr><td><code>${e(ep)}</code></td></tr>`).join("");
524
+ parts.push(`<section><h2>New Endpoints (${discrepancies.new_endpoints.length})</h2>${table(["Endpoint"], rows)}</section>`);
525
+ }
526
+ if (discrepancies.removed_endpoints.length > 0) {
527
+ const rows = discrepancies.removed_endpoints.map((ep) => `<tr><td><code>${e(ep)}</code></td></tr>`).join("");
528
+ parts.push(`<section><h2>⚠ Removed Endpoints (${discrepancies.removed_endpoints.length})</h2>${table(["Endpoint"], rows)}</section>`);
529
+ }
530
+ if (discrepancies.drifted_models.length > 0) {
531
+ const rows = discrepancies.drifted_models.map((d) => `<tr><td><code>${e(d.name)}</code></td><td>${d.baseline_field_count}</td><td>${d.current_field_count}</td></tr>`).join("");
532
+ parts.push(`<section><h2>Drifted Models (${discrepancies.drifted_models.length})</h2>${table(["Model", "Before", "After"], rows)}</section>`);
533
+ }
534
+ if (discrepancies.orphan_specs.length > 0) {
535
+ const rows = discrepancies.orphan_specs.map((s) => `<tr><td><code>${e(s.spec_file)}</code></td><td>${s.missing_endpoints.map(e).join(", ")}</td></tr>`).join("");
536
+ parts.push(`<section><h2>⚠ Orphan Specs (${discrepancies.orphan_specs.length})</h2>${table(["Spec File", "Missing Endpoints"], rows)}</section>`);
537
+ }
538
+ return parts.join("\n");
539
+ }
540
+ /**
541
+ * Build structured per-module, per-domain service interaction data from live service_call data.
542
+ * Groups: backend_module → api_domain → service_classes.
543
+ */
544
+ function buildModuleInteractionData(intel) {
545
+ const IGNORE = new Set([
546
+ "HTTPException", "str", "int", "bool", "dict", "list", "UUID", "Optional",
547
+ "datetime", "db", "session", "response", "request", "None", "True", "False",
548
+ "type", "cls", "self", "super", "object",
549
+ ]);
550
+ // module → domain → { services, epCount }
551
+ const result = new Map();
552
+ const serviceUseCount = new Map();
553
+ for (const ep of Object.values(intel.api_registry)) {
554
+ const mod = ep.module || "other";
555
+ const domain = extractDomain(ep.path);
556
+ if (!result.has(mod))
557
+ result.set(mod, { domains: new Map() });
558
+ const modEntry = result.get(mod);
559
+ if (!modEntry.domains.has(domain))
560
+ modEntry.domains.set(domain, { services: new Set(), epCount: 0 });
561
+ const domEntry = modEntry.domains.get(domain);
562
+ domEntry.epCount += 1;
563
+ for (const call of ep.service_calls) {
564
+ const classMatch = call.match(/^([A-Z][a-zA-Z]{3,})\./);
565
+ const svc = classMatch?.[1] ?? (/^[A-Z][a-zA-Z]{4,}$/.test(call) ? call : null);
566
+ if (svc && !IGNORE.has(svc)) {
567
+ domEntry.services.add(svc);
568
+ serviceUseCount.set(svc, (serviceUseCount.get(svc) ?? 0) + 1);
569
+ }
570
+ }
571
+ }
572
+ // Convert inner Sets to sorted arrays, drop domains with no service calls
573
+ const modules = new Map();
574
+ for (const [mod, { domains }] of Array.from(result.entries()).sort((a, b) => a[0].localeCompare(b[0]))) {
575
+ const cleanDomains = new Map();
576
+ for (const [domain, { services, epCount }] of Array.from(domains.entries()).sort((a, b) => a[0].localeCompare(b[0]))) {
577
+ if (services.size > 0)
578
+ cleanDomains.set(domain, { services: Array.from(services).sort(), epCount });
579
+ }
580
+ if (cleanDomains.size > 0)
581
+ modules.set(mod, { domains: cleanDomains });
582
+ }
583
+ return { modules, serviceUseCount };
584
+ }
585
+ // ── Full System Architecture Diagram ─────────────────────────────────────
586
+ function buildFullSystemDiagram(intel) {
587
+ const modules = intel.service_map.filter((m) => m.type === "backend" && m.file_count > 0);
588
+ if (modules.length < 2)
589
+ return null;
590
+ const lines = ["flowchart TB"];
591
+ // Frontend layer
592
+ if (intel.frontend_pages.length > 0) {
593
+ lines.push(` subgraph FE["Frontend"]`);
594
+ lines.push(` direction LR`);
595
+ const pageNames = intel.frontend_pages.slice(0, 6).map((p) => p.path).join(", ");
596
+ const extra = intel.frontend_pages.length > 6 ? ` +${intel.frontend_pages.length - 6}` : "";
597
+ lines.push(` Pages["${safeLabel(pageNames + extra)}"]`);
598
+ lines.push(` end`);
599
+ }
600
+ // Backend services
601
+ lines.push(` subgraph BE["Backend Services"]`);
602
+ lines.push(` direction TB`);
603
+ for (const mod of modules) {
604
+ // Get the key classes/endpoints for this module
605
+ const endpoints = Object.values(intel.api_registry).filter((ep) => ep.module === mod.id);
606
+ const epPaths = endpoints.slice(0, 3).map((ep) => `${ep.method} ${ep.path}`);
607
+ const label = epPaths.length > 0
608
+ ? `${mod.id}\\n${epPaths.join("\\n")}${endpoints.length > 3 ? `\\n+${endpoints.length - 3} more` : ""}`
609
+ : mod.id;
610
+ lines.push(` ${safeMermaidId(mod.id)}["${safeLabel(label)}"]`);
611
+ }
612
+ lines.push(` end`);
613
+ // Data stores (from runtime services)
614
+ const runtimeServices = intel.background_tasks.length > 0 ? ["Postgres", "Redis"] : ["Postgres"];
615
+ // Check if any service references redis/postgres/minio in service calls
616
+ const allServiceCalls = Object.values(intel.api_registry).flatMap((ep) => ep.service_calls).join(" ");
617
+ const dataStores = [];
618
+ if (allServiceCalls.includes("db_pool") || allServiceCalls.includes("conn."))
619
+ dataStores.push("Postgres");
620
+ if (allServiceCalls.includes("redis"))
621
+ dataStores.push("Redis");
622
+ if (dataStores.length > 0) {
623
+ lines.push(` subgraph Data["Data Stores"]`);
624
+ lines.push(` direction LR`);
625
+ for (const ds of dataStores) {
626
+ lines.push(` ${safeMermaidId(ds)}[("${safeLabel(ds)}")]`);
627
+ }
628
+ lines.push(` end`);
629
+ }
630
+ // External services
631
+ const hasProxy = Object.values(intel.api_registry).some((ep) => ep.service_calls.some((s) => s.includes("_proxy_to_") || s.includes("httpx")));
632
+ if (hasProxy) {
633
+ lines.push(` External[("External APIs\\nLLM / TTS")]`);
634
+ }
635
+ // Edges: Frontend → Backend
636
+ if (intel.frontend_pages.length > 0) {
637
+ // Find which backend services the frontend calls
638
+ const calledModules = new Set();
639
+ for (const page of intel.frontend_pages) {
640
+ for (const call of page.api_calls) {
641
+ // Match API call path to endpoint module
642
+ const matchedEp = Object.values(intel.api_registry).find((ep) => call.includes(ep.path.split("{")[0].replace(/\/$/, "")));
643
+ if (matchedEp)
644
+ calledModules.add(matchedEp.module);
645
+ }
646
+ }
647
+ // If no match, connect to all modules with endpoints
648
+ if (calledModules.size === 0) {
649
+ for (const mod of modules.filter((m) => m.endpoint_count > 0)) {
650
+ calledModules.add(mod.id);
651
+ }
652
+ }
653
+ for (const modId of calledModules) {
654
+ lines.push(` Pages --> ${safeMermaidId(modId)}`);
655
+ }
656
+ }
657
+ // Edges: Backend → Backend (from imports)
658
+ for (const mod of modules) {
659
+ for (const imp of mod.imports) {
660
+ if (modules.some((m) => m.id === imp)) {
661
+ lines.push(` ${safeMermaidId(mod.id)} --> ${safeMermaidId(imp)}`);
662
+ }
663
+ }
664
+ }
665
+ // Edges: Backend → Data stores
666
+ for (const mod of modules) {
667
+ const modEndpoints = Object.values(intel.api_registry).filter((ep) => ep.module === mod.id);
668
+ const modServiceCalls = modEndpoints.flatMap((ep) => ep.service_calls).join(" ");
669
+ if (modServiceCalls.includes("db_pool") || modServiceCalls.includes("conn.")) {
670
+ if (dataStores.includes("Postgres")) {
671
+ lines.push(` ${safeMermaidId(mod.id)} --> ${safeMermaidId("Postgres")}`);
672
+ }
673
+ }
674
+ }
675
+ // Edges: Proxy → External
676
+ if (hasProxy) {
677
+ const proxyMod = modules.find((m) => Object.values(intel.api_registry).some((ep) => ep.module === m.id && ep.service_calls.some((s) => s.includes("_proxy_to_"))));
678
+ if (proxyMod) {
679
+ lines.push(` ${safeMermaidId(proxyMod.id)} --> External`);
680
+ }
681
+ }
682
+ return lines.join("\n");
683
+ }
684
+ function inferModelRole(name, model, intel) {
685
+ const nameLower = name.toLowerCase();
686
+ const fieldNames = model.fields.map((f) => f.toLowerCase());
687
+ // Request/Response patterns
688
+ if (nameLower.includes("request"))
689
+ return "API Request";
690
+ if (nameLower.includes("response"))
691
+ return "API Response";
692
+ // Auth patterns
693
+ if (nameLower.includes("login") || nameLower.includes("signup") || nameLower.includes("auth"))
694
+ return "Authentication";
695
+ // CRUD patterns
696
+ if (nameLower.includes("create"))
697
+ return "Create Input";
698
+ if (nameLower.includes("update"))
699
+ return "Update Input";
700
+ // Config/Policy patterns
701
+ if (nameLower.includes("config") || nameLower.includes("rules") || nameLower.includes("settings"))
702
+ return "Configuration";
703
+ if (nameLower.includes("safety") || nameLower.includes("guardrail"))
704
+ return "Safety Policy";
705
+ if (nameLower.includes("persona") || nameLower.includes("profile"))
706
+ return "Entity Profile";
707
+ // Content/Domain patterns
708
+ if (nameLower.includes("schema") || nameLower.includes("topic"))
709
+ return "Domain Schema";
710
+ if (nameLower.includes("content") || nameLower.includes("unit"))
711
+ return "Content Entity";
712
+ // Check endpoint usage
713
+ const usedByEndpoints = [];
714
+ for (const [key, ep] of Object.entries(intel.api_registry)) {
715
+ if (ep.request_schema === name)
716
+ usedByEndpoints.push(`${ep.method} ${ep.path} (request)`);
717
+ if (ep.response_schema === name)
718
+ usedByEndpoints.push(`${ep.method} ${ep.path} (response)`);
719
+ }
720
+ if (usedByEndpoints.length > 0) {
721
+ const firstUsage = usedByEndpoints[0];
722
+ if (firstUsage.includes("request"))
723
+ return "API Request";
724
+ if (firstUsage.includes("response"))
725
+ return "API Response";
726
+ }
727
+ // Session/state patterns
728
+ if (fieldNames.includes("session_id") || nameLower.includes("session"))
729
+ return "Session State";
730
+ // Default: check if it's an ORM model
731
+ if (model.framework !== "pydantic")
732
+ return "Database Entity";
733
+ return "";
734
+ }
735
+ // ── Cross-Service Communication Diagram ──────────────────────────────────
736
+ function buildCrossServiceDiagram(intel) {
737
+ // Detect service-to-service calls from:
738
+ // 1. Proxy endpoints (service_calls contain _proxy_to_*)
739
+ // 2. Class instantiation across modules (ConversationEngine using ContentRetriever)
740
+ // 3. HTTP client calls (httpx, fetch to other service URLs)
741
+ const modules = intel.service_map.filter((m) => m.type === "backend" && m.file_count > 0);
742
+ if (modules.length < 2)
743
+ return null;
744
+ const edges = [];
745
+ const nodeSet = new Set();
746
+ // Detect from dependency imports
747
+ for (const mod of modules) {
748
+ if (mod.imports.length > 0) {
749
+ nodeSet.add(mod.id);
750
+ for (const imp of mod.imports) {
751
+ const target = modules.find((m) => m.id === imp);
752
+ if (target) {
753
+ nodeSet.add(target.id);
754
+ edges.push({ from: mod.id, to: target.id, label: "imports" });
755
+ }
756
+ }
757
+ }
758
+ }
759
+ // Detect proxy patterns from service calls
760
+ for (const ep of Object.values(intel.api_registry)) {
761
+ const mod = ep.module;
762
+ for (const svc of ep.service_calls) {
763
+ if (svc.includes("_proxy_to_") || svc.includes("httpx") || svc.includes("AsyncClient")) {
764
+ nodeSet.add(mod);
765
+ const target = svc.includes("openai") ? "LLM Provider" : "External API";
766
+ nodeSet.add(target);
767
+ if (!edges.some((e) => e.from === mod && e.to === target)) {
768
+ edges.push({ from: mod, to: target, label: "proxy" });
769
+ }
770
+ }
771
+ }
772
+ }
773
+ // Detect cross-module class usage from service calls
774
+ const classToModule = new Map();
775
+ for (const model of Object.values(intel.model_registry)) {
776
+ const modelMod = modules.find((m) => model.file.startsWith(m.path));
777
+ if (modelMod)
778
+ classToModule.set(model.name, modelMod.id);
779
+ }
780
+ for (const ep of Object.values(intel.api_registry)) {
781
+ for (const svc of ep.service_calls) {
782
+ const className = svc.split(".")[0];
783
+ const targetMod = classToModule.get(className);
784
+ if (targetMod && targetMod !== ep.module) {
785
+ nodeSet.add(ep.module);
786
+ nodeSet.add(targetMod);
787
+ if (!edges.some((e) => e.from === ep.module && e.to === targetMod && e.label === className)) {
788
+ edges.push({ from: ep.module, to: targetMod, label: className });
789
+ }
790
+ }
791
+ }
792
+ }
793
+ // Add runtime services (Database, Redis, etc.)
794
+ for (const rt of intel.background_tasks) {
795
+ nodeSet.add("Background Tasks");
796
+ }
797
+ if (edges.length === 0)
798
+ return null;
799
+ const lines = ["flowchart TB"];
800
+ // Classify nodes
801
+ const backendNodes = Array.from(nodeSet).filter((n) => modules.some((m) => m.id === n));
802
+ const externalNodes = Array.from(nodeSet).filter((n) => !modules.some((m) => m.id === n));
803
+ if (backendNodes.length > 0) {
804
+ lines.push(` subgraph Backend["Backend Services"]`);
805
+ for (const node of backendNodes) {
806
+ lines.push(` ${safeMermaidId(node)}["${safeLabel(node)}"]`);
807
+ }
808
+ lines.push(" end");
809
+ }
810
+ if (externalNodes.length > 0) {
811
+ for (const node of externalNodes) {
812
+ lines.push(` ${safeMermaidId(node)}[("${safeLabel(node)}")]`);
813
+ }
814
+ }
815
+ for (const edge of edges) {
816
+ lines.push(` ${safeMermaidId(edge.from)} -->|${safeLabel(edge.label)}| ${safeMermaidId(edge.to)}`);
817
+ }
818
+ return lines.join("\n");
819
+ }
820
+ /** Service call categories for cleaner diagram labels */
821
+ const SERVICE_CATEGORIES = {
822
+ "db_pool.execute": "Database",
823
+ "db_pool.fetch": "Database",
824
+ "db_pool.fetchrow": "Database",
825
+ "conn.fetch": "Database",
826
+ "conn.fetchrow": "Database",
827
+ "conn.execute": "Database",
828
+ "conn.close": "Database",
829
+ };
830
+ function categorizeService(svc) {
831
+ // Direct category match
832
+ if (SERVICE_CATEGORIES[svc]) {
833
+ return { actor: SERVICE_CATEGORIES[svc], action: svc.split(".").pop() ?? svc };
834
+ }
835
+ // Class method pattern: ClassName.method or instance.method
836
+ const dotParts = svc.split(".");
837
+ if (dotParts.length >= 2) {
838
+ return { actor: dotParts[0], action: dotParts.slice(1).join(".") };
839
+ }
840
+ // Function call
841
+ return { actor: "Service", action: svc };
842
+ }
843
+ function buildWorkflowDiagrams(intel) {
844
+ const workflows = [];
845
+ // Pick the most interesting endpoints (non-health, have service calls, sorted by complexity)
846
+ const candidates = Object.values(intel.api_registry)
847
+ .filter((ep) => ep.path !== "/health" && ep.service_calls.length > 2)
848
+ .sort((a, b) => b.service_calls.length - a.service_calls.length)
849
+ .slice(0, 8);
850
+ for (const ep of candidates) {
851
+ const lines = ["sequenceDiagram"];
852
+ // Determine participants from service calls
853
+ const actors = new Map(); // actor_id → display_name
854
+ actors.set("Client", "Client");
855
+ actors.set("Handler", ep.handler || ep.path);
856
+ // Categorize all service calls
857
+ const steps = [];
858
+ const skipActions = new Set(["str", "dict", "len", "int", "float", "join", "getattr", "max", "lower", "open"]);
859
+ for (const svc of ep.service_calls) {
860
+ if (skipActions.has(svc) || svc.startsWith("params.") || svc.startsWith("updates."))
861
+ continue;
862
+ const { actor, action } = categorizeService(svc);
863
+ if (!actors.has(actor)) {
864
+ actors.set(actor, actor);
865
+ }
866
+ steps.push({ actor, action });
867
+ }
868
+ if (steps.length < 2)
869
+ continue;
870
+ // Render participants
871
+ for (const [id, name] of actors) {
872
+ lines.push(` participant ${safeMermaidId(id)} as ${safeLabel(name)}`);
873
+ }
874
+ // Client → Handler
875
+ const reqLabel = ep.request_schema ? `${ep.method} ${ep.path}<br/>${ep.request_schema}` : `${ep.method} ${ep.path}`;
876
+ lines.push(` ${safeMermaidId("Client")}->>+${safeMermaidId("Handler")}: ${safeLabel(reqLabel)}`);
877
+ // Handler → services (deduplicate sequential same-actor calls)
878
+ let lastActor = "Handler";
879
+ const seenActors = new Set();
880
+ for (const step of steps) {
881
+ const actorId = safeMermaidId(step.actor);
882
+ const handlerId = safeMermaidId("Handler");
883
+ if (step.actor === "Handler")
884
+ continue;
885
+ if (!seenActors.has(step.actor)) {
886
+ lines.push(` ${handlerId}->>${actorId}: ${safeLabel(step.action)}`);
887
+ lines.push(` ${actorId}-->>${handlerId}: result`);
888
+ seenActors.add(step.actor);
889
+ }
890
+ }
891
+ // Handler → Client (response)
892
+ const respLabel = ep.response_schema || "response";
893
+ lines.push(` ${safeMermaidId("Handler")}-->>-${safeMermaidId("Client")}: ${safeLabel(respLabel)}`);
894
+ // Determine importance (POST endpoints with many services are key flows)
895
+ const important = ep.method === "POST" && seenActors.size >= 3;
896
+ // Build a readable title
897
+ const pathParts = ep.path.split("/").filter(Boolean);
898
+ const title = pathParts.length > 0
899
+ ? pathParts.map((p) => p.replace(/[{}]/g, "").replace(/_/g, " ")).join(" → ")
900
+ : ep.path;
901
+ workflows.push({
902
+ title: title.charAt(0).toUpperCase() + title.slice(1),
903
+ method: ep.method,
904
+ path: ep.path,
905
+ diagram: lines.join("\n"),
906
+ important,
907
+ });
908
+ }
909
+ return workflows;
910
+ }
911
+ /**
912
+ * Build a single Mermaid flowchart for one API domain showing its service dependencies.
913
+ * Star topology: domain node → each service node. Clean, small, no subgraphs.
914
+ */
915
+ function buildDomainDiagram(domain, services, sharedServices) {
916
+ const domId = safeMermaidId("api_" + domain);
917
+ const lines = ["flowchart LR"];
918
+ lines.push(` ${domId}["${safeLabel(domain)}"]:::api`);
919
+ for (const svc of services.slice(0, 15)) {
920
+ const svcId = safeMermaidId(svc);
921
+ const label = sharedServices.has(svc) ? `${safeLabel(svc)} (shared)` : safeLabel(svc);
922
+ lines.push(` ${svcId}["${label}"]:::svc`);
923
+ lines.push(` ${domId} --> ${svcId}`);
924
+ }
925
+ lines.push(" classDef api fill:#dbeafe,stroke:#3b82f6,color:#1e3a8a");
926
+ lines.push(" classDef svc fill:#f0fdf4,stroke:#22c55e,color:#14532d");
927
+ return lines.join("\n");
928
+ }
929
+ // ── Model schema renderers ────────────────────────────────────────────────
930
+ function renderModelSchema(name, model) {
931
+ if (model.field_details.length === 0) {
932
+ const rows = model.fields.map((f) => `<tr><td><code>${e(f)}</code></td><td>—</td><td>—</td></tr>`).join("");
933
+ return table(["Field", "Type", "Nullable"], rows);
934
+ }
935
+ const rows = model.field_details.map((f) => {
936
+ const pk = f.primary_key ? `<span class="badge-pk">PK</span>` : "";
937
+ const fk = f.foreign_key ? `<span class="badge-fk" title="${e(f.foreign_key ?? "")}">FK→${e((f.foreign_key ?? "").split(".")[0] ?? "")}</span>` : "";
938
+ const nullable = f.nullable === true ? "✓" : f.nullable === false ? "✗" : "—";
939
+ const enumRef = f.enum ? ` <span class="badge-enum">${e(f.enum)}</span>` : "";
940
+ return `<tr><td><code>${e(f.name)}</code> ${pk}${fk}</td><td>${e(f.type ?? "—")}${enumRef}</td><td class="center">${nullable}</td></tr>`;
941
+ }).join("");
942
+ const relRow = model.relationships.length > 0
943
+ ? `<tr class="rel-row"><td colspan="3"><em>→ ${model.relationships.map(e).join(", ")}</em></td></tr>`
944
+ : "";
945
+ return table(["Field", "Type", "Nullable"], rows + relRow);
946
+ }
947
+ function buildErDiagram(models) {
948
+ const sqlModels = models.filter(([, m]) => m.framework === "sqlalchemy" && m.field_details.some((f) => f.primary_key || f.foreign_key));
949
+ if (sqlModels.length < 2)
950
+ return "";
951
+ const modelNames = new Set(sqlModels.map(([name]) => name));
952
+ // Build relationship list first — only include models connected by FK edges
953
+ const relList = [];
954
+ const drawn = new Set();
955
+ for (const [name, m] of sqlModels) {
956
+ for (const f of m.field_details) {
957
+ if (!f.foreign_key)
958
+ continue;
959
+ const targetTable = (f.foreign_key.split(".")[0] ?? "").replace(/_/g, "");
960
+ const targetModel = sqlModels.find(([tName]) => tName.toLowerCase() === targetTable ||
961
+ tName.toLowerCase() === targetTable.replace(/s$/, "") ||
962
+ targetTable.startsWith(tName.toLowerCase()))?.[0];
963
+ if (targetModel && modelNames.has(targetModel) && targetModel !== name) {
964
+ const key = `${targetModel}→${name}`;
965
+ if (!drawn.has(key)) {
966
+ relList.push([targetModel, name]);
967
+ drawn.add(key);
968
+ }
969
+ }
970
+ }
971
+ }
972
+ // Only include models that participate in at least one relationship, cap at 20
973
+ const connectedNames = new Set(relList.flatMap(([a, b]) => [a, b]));
974
+ const connectedModels = sqlModels.filter(([name]) => connectedNames.has(name)).slice(0, 20);
975
+ if (connectedModels.length < 2)
976
+ return "";
977
+ // Re-filter relList to only include pairs where both models survived the cap
978
+ const cappedNames = new Set(connectedModels.map(([n]) => n));
979
+ const cappedRels = relList.filter(([a, b]) => cappedNames.has(a) && cappedNames.has(b));
980
+ const lines = ["erDiagram"];
981
+ for (const [name, m] of connectedModels) {
982
+ const safeName = safeMermaidId(name);
983
+ const keyFields = m.field_details.filter((f) => f.primary_key || f.foreign_key);
984
+ const typeFields = m.field_details.filter((f) => !f.primary_key && !f.foreign_key && f.type).slice(0, 3);
985
+ const allFields = [...keyFields, ...typeFields].slice(0, 6);
986
+ const fieldLines = allFields.map((f) => {
987
+ const tag = f.primary_key ? " PK" : f.foreign_key ? " FK" : "";
988
+ const rawType = (f.type ?? "string").split("[")[0].split("(")[0];
989
+ const typeName = rawType.replace(/[^a-zA-Z0-9_]/g, "_").replace(/^_+|_+$/g, "").slice(0, 20) || "string";
990
+ const fieldName = (f.name || "field").replace(/[^a-zA-Z0-9_]/g, "_").replace(/^_+|_+$/g, "") || "field";
991
+ return ` ${typeName} ${fieldName}${tag}`;
992
+ });
993
+ lines.push(` ${safeName} {`);
994
+ lines.push(...fieldLines);
995
+ lines.push(` }`);
996
+ }
997
+ for (const [from, to] of cappedRels) {
998
+ lines.push(` ${safeMermaidId(from)} ||--o{ ${safeMermaidId(to)} : has`);
999
+ }
1000
+ return lines.join("\n");
1001
+ }
1002
+ function deduplicateTasks(tasks) {
1003
+ const byName = new Map();
1004
+ for (const t of tasks) {
1005
+ const existing = byName.get(t.name);
1006
+ if (existing) {
1007
+ if (!existing.sources.includes(t.file))
1008
+ existing.sources.push(t.file);
1009
+ }
1010
+ else {
1011
+ byName.set(t.name, { name: t.name, kind: t.kind, queue: t.queue ?? null, sources: [t.file] });
1012
+ }
1013
+ }
1014
+ return Array.from(byName.values());
1015
+ }
1016
+ function buildTaskTriggerMap(intel) {
1017
+ const taskNames = new Set(intel.background_tasks.map((t) => t.name));
1018
+ const triggerMap = new Map();
1019
+ for (const [epKey, ep] of Object.entries(intel.api_registry)) {
1020
+ for (const call of ep.service_calls) {
1021
+ // Match: "service.run_import_task" or "run_import_task"
1022
+ for (const taskName of taskNames) {
1023
+ const bare = taskName.replace(/^[^.]+\./, "");
1024
+ if (call === taskName || call === bare || call.endsWith(`.${bare}`)) {
1025
+ const list = triggerMap.get(taskName) ?? [];
1026
+ if (!list.includes(epKey))
1027
+ list.push(epKey);
1028
+ triggerMap.set(taskName, list);
1029
+ }
1030
+ }
1031
+ }
1032
+ }
1033
+ return triggerMap;
1034
+ }
1035
+ // ── Markdown → HTML ───────────────────────────────────────────────────────
1036
+ function renderMd(markdown) {
1037
+ const segments = extractMermaidBlocks(markdown);
1038
+ return segments.map(({ before, diagram, after }) => [
1039
+ before ? mdBlocksToHtml(before) : "",
1040
+ diagram ? lightbox(`<div class="mermaid">\n${diagram}\n</div>`) : "",
1041
+ after ? mdBlocksToHtml(after) : "",
1042
+ ].join("")).join("");
1043
+ }
1044
+ function mdBlocksToHtml(markdown) {
1045
+ const lines = markdown.split("\n");
1046
+ const out = [];
1047
+ let tableLines = [];
1048
+ let paraLines = [];
1049
+ const flushTable = () => {
1050
+ if (tableLines.length === 0)
1051
+ return;
1052
+ out.push(mdTableToHtml(tableLines));
1053
+ tableLines = [];
1054
+ };
1055
+ const flushPara = () => {
1056
+ const text = paraLines.join(" ").trim();
1057
+ if (text)
1058
+ out.push(`<p>${inlineMd(text)}</p>`);
1059
+ paraLines = [];
1060
+ };
1061
+ for (const line of lines) {
1062
+ if (line.trimStart().startsWith("|")) {
1063
+ flushPara();
1064
+ tableLines.push(line);
1065
+ }
1066
+ else if (line.trim() === "" && tableLines.length > 0) {
1067
+ flushTable();
1068
+ }
1069
+ else if (line.trim() === "") {
1070
+ flushPara();
1071
+ }
1072
+ else if (line.startsWith("### ")) {
1073
+ flushTable();
1074
+ flushPara();
1075
+ out.push(`<h4>${inlineMd(line.slice(4))}</h4>`);
1076
+ }
1077
+ else if (line.startsWith("## ")) {
1078
+ flushTable();
1079
+ flushPara();
1080
+ out.push(`<h3>${inlineMd(line.slice(3))}</h3>`);
1081
+ }
1082
+ else if (line.startsWith("# ")) {
1083
+ flushTable();
1084
+ flushPara();
1085
+ out.push(`<h3>${inlineMd(line.slice(2))}</h3>`);
1086
+ }
1087
+ else if (line.match(/^[-*] /)) {
1088
+ flushTable();
1089
+ flushPara();
1090
+ out.push(`<li>${inlineMd(line.slice(2))}</li>`);
1091
+ }
1092
+ else {
1093
+ flushTable();
1094
+ paraLines.push(line);
1095
+ }
1096
+ }
1097
+ flushTable();
1098
+ flushPara();
1099
+ return out.join("\n");
1100
+ }
1101
+ function mdTableToHtml(lines) {
1102
+ const dataLines = lines.filter((l) => !l.match(/^\|[\s|:-]+\|$/));
1103
+ if (dataLines.length === 0)
1104
+ return "";
1105
+ const parseRow = (line) => line.replace(/^\|/, "").replace(/\|$/, "").split("|").map((c) => c.trim());
1106
+ const [header, ...body] = dataLines;
1107
+ const ths = parseRow(header).map((c) => `<th>${inlineMd(c)}</th>`).join("");
1108
+ const rows = body.map((row) => `<tr>${parseRow(row).map((c) => `<td>${inlineMd(c)}</td>`).join("")}</tr>`).join("\n");
1109
+ return `<table><thead><tr>${ths}</tr></thead><tbody>${rows}</tbody></table>`;
1110
+ }
1111
+ function extractMermaidBlocks(markdown) {
1112
+ const results = [];
1113
+ const fenceRe = /```mermaid\n([\s\S]*?)```/g;
1114
+ let lastIndex = 0;
1115
+ let match;
1116
+ while ((match = fenceRe.exec(markdown)) !== null) {
1117
+ results.push({ before: markdown.slice(lastIndex, match.index), diagram: match[1].trim(), after: "" });
1118
+ lastIndex = match.index + match[0].length;
1119
+ }
1120
+ if (results.length === 0)
1121
+ return [{ before: markdown, diagram: "", after: "" }];
1122
+ results[results.length - 1].after = markdown.slice(lastIndex);
1123
+ return results;
1124
+ }
1125
+ function inlineMd(text) {
1126
+ const trimmed = text.trim();
1127
+ // Suppress empty/null values from generated docs
1128
+ if (trimmed === "None" || trimmed === "null" || trimmed === "N/A")
1129
+ return '<span class="none">—</span>';
1130
+ let s = e(text);
1131
+ s = s.replace(/`([^`]+)`/g, "<code>$1</code>");
1132
+ s = s.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
1133
+ s = s.replace(/(?<!\*)\*([^*]+)\*(?!\*)/g, "<em>$1</em>");
1134
+ s = s.replace(/_([^_]+)_/g, "<em>$1</em>");
1135
+ s = s.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>');
1136
+ return s;
1137
+ }
1138
+ // ── HTML helpers ──────────────────────────────────────────────────────────
1139
+ function e(text) {
1140
+ return String(text)
1141
+ .replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
1142
+ }
1143
+ function mkId(text) {
1144
+ return text.toLowerCase().replace(/[^a-z0-9\s-]/g, "").trim().replace(/\s+/g, "-");
1145
+ }
1146
+ /** Generate a Mermaid-safe node ID: alphanumeric + underscore, starts with letter. */
1147
+ function safeMermaidId(text) {
1148
+ const safe = text.replace(/[^a-zA-Z0-9]/g, "_");
1149
+ return /^[0-9_]/.test(safe) ? `n_${safe}` : safe || "node";
1150
+ }
1151
+ /**
1152
+ * Sanitize text for use inside a Mermaid node label ["..."].
1153
+ * Strips non-ASCII (emoji, symbols), escapes double quotes, removes angle brackets.
1154
+ */
1155
+ function safeLabel(text) {
1156
+ return text
1157
+ .replace(/"/g, "'")
1158
+ .replace(/[<>[\]]/g, "")
1159
+ .replace(/[^\x20-\x7E]/g, "")
1160
+ .trim() || "node";
1161
+ }
1162
+ function table(headers, rows) {
1163
+ const ths = headers.map((h) => `<th>${e(h)}</th>`).join("");
1164
+ return `<table><thead><tr>${ths}</tr></thead><tbody>${rows}</tbody></table>`;
1165
+ }
1166
+ /** Wrap a diagram in a click-to-expand lightbox. */
1167
+ function lightbox(inner) {
1168
+ return `<div class="diagram-wrapper" onclick="openLightbox(this)" title="Click to expand">
1169
+ <div class="diagram-expand-hint">🔍 Click to expand</div>
1170
+ ${inner}
1171
+ </div>`;
1172
+ }
1173
+ // ── Grouping helpers ──────────────────────────────────────────────────────
1174
+ function extractDomain(epPath) {
1175
+ const parts = epPath.replace(/^\//, "").split("/");
1176
+ const skip = ["api", "v1", "v2"];
1177
+ return parts.find((p) => !skip.includes(p) && !p.startsWith("{")) ?? parts[0] ?? "root";
1178
+ }
1179
+ function groupEndpointsByDomain(intel) {
1180
+ const domains = new Map();
1181
+ for (const [key, ep] of Object.entries(intel.api_registry)) {
1182
+ const domain = extractDomain(ep.path);
1183
+ const entry = domains.get(domain) ?? [];
1184
+ entry.push([key, ep]);
1185
+ domains.set(domain, entry);
1186
+ }
1187
+ return domains;
1188
+ }
1189
+ function groupModelsByModule(intel) {
1190
+ const groups = new Map();
1191
+ const fileToModule = new Map();
1192
+ for (const m of intel.service_map) {
1193
+ for (const [, model] of Object.entries(intel.model_registry)) {
1194
+ if (model.file.includes(m.path) || m.path.includes((model.file.split("/")[0]) ?? "")) {
1195
+ fileToModule.set(model.file, m.id);
1196
+ }
1197
+ }
1198
+ }
1199
+ for (const [name, model] of Object.entries(intel.model_registry)) {
1200
+ const module = fileToModule.get(model.file) ?? deriveGroup(model.file);
1201
+ const entry = groups.get(module) ?? [];
1202
+ entry.push([name, model]);
1203
+ groups.set(module, entry);
1204
+ }
1205
+ for (const [key, models] of groups) {
1206
+ groups.set(key, models.sort((a, b) => a[0].localeCompare(b[0])));
1207
+ }
1208
+ return groups;
1209
+ }
1210
+ function deriveGroup(file) {
1211
+ const parts = file.split("/").filter(Boolean);
1212
+ const appIdx = parts.indexOf("app");
1213
+ if (appIdx !== -1)
1214
+ return parts[appIdx + 1] ?? parts[appIdx] ?? "other";
1215
+ return parts[1] ?? parts[0] ?? "other";
1216
+ }
1217
+ // ── CSS ───────────────────────────────────────────────────────────────────
1218
+ const CSS = `
1219
+ *{box-sizing:border-box;margin:0;padding:0}
1220
+ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:14px;color:#1a1a1a;background:#fff;line-height:1.5}
1221
+ a{color:#1a4cbf;text-decoration:none}a:hover{text-decoration:underline}
1222
+ code{font-family:'SF Mono','Fira Code',monospace;font-size:12px;background:#f0f0f2;padding:1px 5px;border-radius:3px;color:#c7254e}
1223
+ p{margin:8px 0}li{margin:3px 0 3px 18px}
1224
+ h4{font-size:13px;font-weight:600;margin:16px 0 6px;color:#333}
1225
+ small{font-size:11px}.muted{color:#777;font-style:italic}
1226
+
1227
+ #layout{display:flex;height:100vh;overflow:hidden}
1228
+
1229
+ /* Sidebar */
1230
+ #sidebar{width:240px;min-width:240px;background:#f7f7f8;border-right:1px solid #e0e0e0;display:flex;flex-direction:column;overflow:hidden;flex-shrink:0}
1231
+ #sidebar-header{padding:10px 12px;border-bottom:1px solid #e0e0e0;flex-shrink:0}
1232
+ #project-name{display:block;font-weight:700;font-size:13px;color:#111;margin-bottom:6px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
1233
+ #search-box{width:100%;padding:4px 7px;border:1px solid #ccc;border-radius:4px;font-size:12px;outline:none}
1234
+ #search-box:focus{border-color:#6b9fff}
1235
+ #nav-tree{overflow-y:auto;flex:1;padding:6px 0}
1236
+
1237
+ .nav-top-link{display:flex;align-items:center;gap:4px;padding:5px 12px;font-size:12px;font-weight:500;color:#444;text-decoration:none;white-space:nowrap}
1238
+ .nav-top-link:hover{background:#eaeaec;color:#111}
1239
+ .nav-top-link.active{background:#e0eaff;color:#1a4cbf;font-weight:600}
1240
+ .nav-badge{background:#e2e2e4;color:#555;border-radius:9px;padding:1px 6px;font-size:10px;font-weight:600;margin-left:auto;flex-shrink:0}
1241
+ .nav-top-link.active .nav-badge{background:#bfcfff;color:#1a4cbf}
1242
+
1243
+ .nav-group{user-select:none}
1244
+ .nav-group-header{display:flex;align-items:center;gap:4px;padding:5px 12px;cursor:pointer;font-size:11px;color:#555;text-transform:uppercase;font-weight:600;letter-spacing:.04em}
1245
+ .nav-group-header:hover{background:#eaeaec}
1246
+ .chevron{font-size:9px;color:#888;transition:transform .15s;flex-shrink:0}
1247
+ .nav-group:not(.open) .chevron{transform:rotate(-90deg)}
1248
+ .nav-group:not(.open) .nav-children{display:none}
1249
+ .nav-group-link{flex:1;font-size:11px;font-weight:600;color:#555;text-transform:uppercase;letter-spacing:.04em;text-decoration:none}
1250
+ .nav-group-link.active{color:#1a4cbf}
1251
+ .nav-children{padding-bottom:2px}
1252
+ .nav-item{display:block;padding:2px 12px 2px 24px;font-size:12px;color:#444;text-decoration:none;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
1253
+ .nav-item:hover{background:#e4e4e8;color:#111}
1254
+
1255
+ /* Content */
1256
+ #content{flex:1;overflow-y:auto;padding:28px 40px 60px;}
1257
+ .page-title{font-size:22px;font-weight:700;margin-bottom:24px;padding-bottom:10px;border-bottom:2px solid #e8e8ea;color:#111}
1258
+ section{margin-bottom:44px;scroll-margin-top:16px}
1259
+ section h2{font-size:17px;font-weight:700;margin-bottom:14px;color:#111;display:flex;align-items:center;gap:8px;flex-wrap:wrap}
1260
+ section h3,section.subsection h3{font-size:14px;font-weight:600;margin:16px 0 8px;color:#333}
1261
+
1262
+ /* Tables */
1263
+ table{width:100%;border-collapse:collapse;font-size:13px;margin:8px 0}
1264
+ thead th{background:#f2f2f4;text-align:left;padding:6px 10px;font-weight:600;border-bottom:2px solid #ddd;white-space:nowrap}
1265
+ tbody tr:nth-child(even){background:#fafafa}
1266
+ tbody td{padding:5px 10px;border-bottom:1px solid #eee;vertical-align:top;word-break:break-word}
1267
+ tbody tr:hover{background:#f0f4ff}
1268
+ td.center{text-align:center}
1269
+ .rel-row td{background:#f8f8fb;color:#555;font-size:12px;border-top:1px dashed #ddd;font-style:italic}
1270
+
1271
+ /* Details */
1272
+ details{border:1px solid #e0e0e0;border-radius:5px;margin:6px 0}
1273
+ details summary{padding:7px 12px;cursor:pointer;font-size:13px;font-weight:600;list-style:none;user-select:none}
1274
+ details summary::-webkit-details-marker{display:none}
1275
+ details summary::before{content:"▶ ";font-size:9px;color:#999}
1276
+ details[open] summary::before{content:"▼ "}
1277
+ details[open] summary{border-bottom:1px solid #e0e0e0}
1278
+ .details-body{padding:10px}
1279
+
1280
+ /* Stats */
1281
+ .model-role{display:inline-block;background:#e8f4fd;color:#1565c0;padding:1px 8px;border-radius:10px;font-size:11px;font-weight:600;margin:0 6px;vertical-align:middle}
1282
+ .product-context{margin-bottom:24px;padding:16px 20px;background:#f8f9fb;border-left:4px solid #1a4cbf;border-radius:0 6px 6px 0}
1283
+ .product-description{font-size:14px;line-height:1.6;color:#333}
1284
+ .product-description p:first-child{font-size:15px;font-weight:500;color:#111}
1285
+ .stats-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:12px;margin-bottom:20px}
1286
+ .stat-card{background:#f7f7f8;border:1px solid #e0e0e0;border-radius:6px;padding:14px;text-align:center}
1287
+ .stat-value{font-size:28px;font-weight:700}
1288
+ .stat-label{font-size:11px;color:#666;text-transform:uppercase;letter-spacing:.05em;margin-top:2px}
1289
+
1290
+ /* Badges */
1291
+ .badge{display:inline-block;background:#e8eaf6;color:#3949ab;border-radius:10px;padding:1px 8px;font-size:11px;font-weight:600}
1292
+ .badge.warn{background:#fff3e0;color:#e65100}
1293
+ .badge-pk{display:inline-block;background:#fff3cd;color:#856404;border:1px solid #ffc107;border-radius:3px;padding:0 4px;font-size:10px;font-weight:700;vertical-align:middle;margin-left:3px}
1294
+ .badge-fk{display:inline-block;background:#cfe2ff;color:#0a3880;border:1px solid #9ec5fe;border-radius:3px;padding:0 4px;font-size:10px;font-weight:700;vertical-align:middle;margin-left:3px}
1295
+ .badge-enum{display:inline-block;background:#f0e6ff;color:#5a2d82;border:1px solid #c8a7f0;border-radius:3px;padding:0 4px;font-size:10px;font-weight:600;vertical-align:middle;margin-left:3px}
1296
+
1297
+ /* Status */
1298
+ .ok{color:#1b5e20;background:#e8f5e9;padding:8px 12px;border-radius:4px;border:1px solid #a5d6a7}
1299
+ .warn{color:#e65100;background:#fff3e0;padding:8px 12px;border-radius:4px;border:1px solid #ffcc80}
1300
+ .critical{color:#c62828;background:#fdecea;padding:8px 12px;border-radius:4px;border:1px solid #ef9a9a}
1301
+
1302
+ /* Quick index */
1303
+ .quick-index{display:flex;flex-wrap:wrap;gap:5px;margin-bottom:16px;padding:8px;background:#f7f7f8;border-radius:5px;border:1px solid #e0e0e0}
1304
+ .quick-index a{font-size:12px;color:#444;background:#fff;border:1px solid #ddd;border-radius:3px;padding:2px 7px;text-decoration:none}
1305
+ .quick-index a:hover{background:#e0eaff;border-color:#6b9fff;color:#1a4cbf}
1306
+
1307
+ /* Diagram wrapper — click to open lightbox */
1308
+ .diagram-wrapper{position:relative;border:1px solid #e0e0e0;border-radius:6px;padding:8px;margin:12px 0;background:#fafafa;cursor:zoom-in;overflow:auto}
1309
+ .diagram-wrapper .mermaid{pointer-events:none}
1310
+ .diagram-expand-hint{position:absolute;top:6px;right:8px;font-size:11px;color:#888;background:rgba(255,255,255,.85);padding:2px 6px;border-radius:3px;pointer-events:none;opacity:0;transition:opacity .15s}
1311
+ .diagram-wrapper:hover .diagram-expand-hint{opacity:1}
1312
+
1313
+ /* Lightbox overlay */
1314
+ #lightbox{display:none;position:fixed;inset:0;background:rgba(0,0,0,.75);z-index:1000;align-items:center;justify-content:center;padding:24px}
1315
+ #lightbox.open{display:flex}
1316
+ #lightbox-content{background:#fff;border-radius:8px;padding:20px;max-width:calc(100vw - 48px);max-height:calc(100vh - 48px);overflow:auto;position:relative}
1317
+ #lightbox-close{position:absolute;top:10px;right:14px;font-size:22px;cursor:pointer;color:#666;line-height:1;background:none;border:none;padding:4px}
1318
+ #lightbox-close:hover{color:#111}
1319
+ #lightbox-content .mermaid{min-width:600px}
1320
+
1321
+ /* Task cards */
1322
+ .task-grid{display:grid;gap:14px}
1323
+ .task-card{border:1px solid #e0e0e0;border-radius:6px;padding:14px}
1324
+ .task-header{display:flex;align-items:center;gap:8px;margin-bottom:8px;flex-wrap:wrap}
1325
+ .task-name{font-size:13px}
1326
+ .note-reuse{color:#1b5e20;background:#e8f5e9;padding:6px 10px;border-radius:4px;font-size:12px;margin-bottom:6px}
1327
+ .source-list,.trigger-list{font-size:12px;color:#555;margin:6px 0}
1328
+ .source-list ul,.trigger-list ul{margin:4px 0 0 16px}
1329
+
1330
+ /* Domain sections */
1331
+ .domain-section h2{font-size:15px}
1332
+ .none{color:#bbb}
1333
+ span.none{font-style:normal}
1334
+
1335
+ /* Model schemas */
1336
+ .model-schemas{margin-top:10px;display:grid;gap:4px}
1337
+ .subsection{margin-bottom:16px}
1338
+ .model-layer{margin:16px 0}
1339
+ .model-layer h3{font-size:13px;font-weight:600;color:#555;text-transform:uppercase;letter-spacing:.04em;margin-bottom:6px;padding:4px 0;border-bottom:1px solid #eee}
1340
+ .module-block{margin:24px 0;padding:16px;border:1px solid #e4e4e7;border-radius:8px;background:#fafafa}
1341
+ .module-block h3{font-size:14px;font-weight:700;color:#111;margin-bottom:12px;padding-bottom:8px;border-bottom:1px solid #e4e4e7}
1342
+ .domain-diagram{margin:8px 0;border:1px solid #e0e0e0;border-radius:6px;background:#fff}
1343
+ .domain-diagram summary{padding:8px 12px;cursor:pointer;font-size:13px;list-style:none;display:flex;align-items:center;gap:8px}
1344
+ .domain-diagram summary::-webkit-details-marker{display:none}
1345
+ .domain-diagram summary::before{content:"▶";font-size:10px;color:#888;transition:transform .15s;flex-shrink:0}
1346
+ .domain-diagram[open] summary::before{transform:rotate(90deg)}
1347
+ .domain-diagram .details-body{padding:8px 12px 12px}
1348
+ .badge-svc{background:#dcfce7;color:#15803d;border-radius:9px;padding:1px 6px;font-size:10px;font-weight:600}
1349
+ `;
1350
+ // ── JavaScript ────────────────────────────────────────────────────────────
1351
+ const JS = `
1352
+ document.addEventListener('DOMContentLoaded', () => {
1353
+ // Mermaid
1354
+ if (typeof mermaid !== 'undefined') {
1355
+ mermaid.initialize({ startOnLoad: false, theme: 'neutral', securityLevel: 'loose', maxTextSize: 200000 });
1356
+ mermaid.run({ querySelector: '.mermaid' }).catch(() => {});
1357
+ }
1358
+
1359
+ // Inject lightbox container
1360
+ const lb = document.createElement('div');
1361
+ lb.id = 'lightbox';
1362
+ lb.innerHTML = '<div id="lightbox-content"><button id="lightbox-close" onclick="closeLightbox()">✕</button><div id="lightbox-body"></div></div>';
1363
+ document.body.appendChild(lb);
1364
+ lb.addEventListener('click', (e) => { if (e.target === lb) closeLightbox(); });
1365
+
1366
+ // Search
1367
+ const box = document.getElementById('search-box');
1368
+ if (box) {
1369
+ box.addEventListener('input', () => {
1370
+ const q = box.value.toLowerCase().trim();
1371
+ document.querySelectorAll('.nav-top-link, .nav-group').forEach(el => {
1372
+ const text = el.textContent.toLowerCase();
1373
+ el.style.display = (!q || text.includes(q)) ? '' : 'none';
1374
+ });
1375
+ });
1376
+ }
1377
+ });
1378
+
1379
+ function toggleNav(header) {
1380
+ const group = header.closest('.nav-group');
1381
+ if (group) group.classList.toggle('open');
1382
+ }
1383
+
1384
+ function openLightbox(wrapper) {
1385
+ const mermaidEl = wrapper.querySelector('.mermaid');
1386
+ if (!mermaidEl) return;
1387
+ const clone = mermaidEl.cloneNode(true);
1388
+ // Re-render mermaid in the clone
1389
+ clone.removeAttribute('data-processed');
1390
+ const body = document.getElementById('lightbox-body');
1391
+ if (!body) return;
1392
+ body.innerHTML = '';
1393
+ body.appendChild(clone);
1394
+ document.getElementById('lightbox').classList.add('open');
1395
+ document.body.style.overflow = 'hidden';
1396
+ if (typeof mermaid !== 'undefined') {
1397
+ mermaid.run({ nodes: [clone] }).catch(() => {});
1398
+ }
1399
+ }
1400
+
1401
+ function closeLightbox() {
1402
+ document.getElementById('lightbox').classList.remove('open');
1403
+ document.body.style.overflow = '';
1404
+ }
1405
+
1406
+ document.addEventListener('keydown', (e) => {
1407
+ if (e.key === 'Escape') closeLightbox();
1408
+ });
1409
+ `;