@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.
- package/LICENSE +21 -0
- package/README.md +366 -0
- package/dist/adapters/csharp-adapter.js +149 -0
- package/dist/adapters/go-adapter.js +96 -0
- package/dist/adapters/index.js +16 -0
- package/dist/adapters/java-adapter.js +122 -0
- package/dist/adapters/python-adapter.js +183 -0
- package/dist/adapters/runner.js +69 -0
- package/dist/adapters/types.js +1 -0
- package/dist/adapters/typescript-adapter.js +179 -0
- package/dist/benchmarking/framework.js +91 -0
- package/dist/cli.js +343 -0
- package/dist/commands/analyze-depth.js +43 -0
- package/dist/commands/api-spec-extractor.js +52 -0
- package/dist/commands/breaking-change-analyzer.js +334 -0
- package/dist/commands/config-compliance.js +219 -0
- package/dist/commands/constraints.js +221 -0
- package/dist/commands/context.js +101 -0
- package/dist/commands/data-flow-tracer.js +291 -0
- package/dist/commands/dependency-impact-analyzer.js +27 -0
- package/dist/commands/diff.js +146 -0
- package/dist/commands/discrepancy.js +71 -0
- package/dist/commands/doc-generate.js +163 -0
- package/dist/commands/doc-html.js +120 -0
- package/dist/commands/drift.js +88 -0
- package/dist/commands/extract.js +16 -0
- package/dist/commands/feature-context.js +116 -0
- package/dist/commands/generate.js +339 -0
- package/dist/commands/guard.js +182 -0
- package/dist/commands/init.js +209 -0
- package/dist/commands/intel.js +20 -0
- package/dist/commands/license-dependency-auditor.js +33 -0
- package/dist/commands/performance-hotspot-profiler.js +42 -0
- package/dist/commands/search.js +314 -0
- package/dist/commands/security-boundary-auditor.js +359 -0
- package/dist/commands/simulate.js +294 -0
- package/dist/commands/summary.js +27 -0
- package/dist/commands/test-coverage-mapper.js +264 -0
- package/dist/commands/verify-drift.js +62 -0
- package/dist/config.js +441 -0
- package/dist/extract/ai-context-hints.js +107 -0
- package/dist/extract/analyzers/backend.js +1704 -0
- package/dist/extract/analyzers/depth.js +264 -0
- package/dist/extract/analyzers/frontend.js +2221 -0
- package/dist/extract/api-usage-tracker.js +19 -0
- package/dist/extract/cache.js +53 -0
- package/dist/extract/codebase-intel.js +190 -0
- package/dist/extract/compress.js +452 -0
- package/dist/extract/context-block.js +356 -0
- package/dist/extract/contracts.js +183 -0
- package/dist/extract/discrepancies.js +233 -0
- package/dist/extract/docs-loader.js +110 -0
- package/dist/extract/docs.js +2379 -0
- package/dist/extract/drift.js +1578 -0
- package/dist/extract/duplicates.js +435 -0
- package/dist/extract/feature-arcs.js +138 -0
- package/dist/extract/graph.js +76 -0
- package/dist/extract/html-doc.js +1409 -0
- package/dist/extract/ignore.js +45 -0
- package/dist/extract/index.js +455 -0
- package/dist/extract/llm-client.js +159 -0
- package/dist/extract/pattern-registry.js +141 -0
- package/dist/extract/product-doc.js +497 -0
- package/dist/extract/python.js +1202 -0
- package/dist/extract/runtime.js +193 -0
- package/dist/extract/schema-evolution-validator.js +35 -0
- package/dist/extract/test-gap-analyzer.js +20 -0
- package/dist/extract/tests.js +74 -0
- package/dist/extract/types.js +1 -0
- package/dist/extract/validate-backend.js +30 -0
- package/dist/extract/writer.js +11 -0
- package/dist/output-layout.js +37 -0
- package/dist/project-discovery.js +309 -0
- package/dist/schema/architecture.js +350 -0
- package/dist/schema/feature-spec.js +89 -0
- package/dist/schema/index.js +8 -0
- package/dist/schema/ux.js +46 -0
- 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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
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
|
+
`;
|