@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,2379 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { loadArchitectureSummary, loadArchitectureDiff, loadHeatmap } from "./compress.js";
|
|
4
|
+
import { getOutputLayout } from "../output-layout.js";
|
|
5
|
+
const LEAN_INDEX_FILES = [
|
|
6
|
+
"summary.md",
|
|
7
|
+
"stakeholder.md",
|
|
8
|
+
"hld.md",
|
|
9
|
+
"integration.md",
|
|
10
|
+
"diff.md",
|
|
11
|
+
"runtime.md",
|
|
12
|
+
"infra.md",
|
|
13
|
+
"ux.md",
|
|
14
|
+
"data.md",
|
|
15
|
+
"tests.md"
|
|
16
|
+
];
|
|
17
|
+
const FULL_INDEX_FILES = [
|
|
18
|
+
"summary.md",
|
|
19
|
+
"stakeholder.md",
|
|
20
|
+
"architecture.md",
|
|
21
|
+
"ux.md",
|
|
22
|
+
"data.md",
|
|
23
|
+
"data_dictionary.md",
|
|
24
|
+
"integration.md",
|
|
25
|
+
"diff.md",
|
|
26
|
+
"test_coverage.md",
|
|
27
|
+
"runtime.md",
|
|
28
|
+
"infra.md",
|
|
29
|
+
"hld.md",
|
|
30
|
+
"lld.md",
|
|
31
|
+
"tests.md"
|
|
32
|
+
];
|
|
33
|
+
function section(title) {
|
|
34
|
+
return `# ${title}\n\n`;
|
|
35
|
+
}
|
|
36
|
+
function bullet(lines) {
|
|
37
|
+
if (lines.length === 0) {
|
|
38
|
+
return "*None*\n";
|
|
39
|
+
}
|
|
40
|
+
return lines.map((line) => `- ${line}`).join("\n") + "\n";
|
|
41
|
+
}
|
|
42
|
+
function renderTests(architecture) {
|
|
43
|
+
if (!architecture.tests || architecture.tests.length === 0) {
|
|
44
|
+
return [
|
|
45
|
+
section("Behavioral Test Specifications"),
|
|
46
|
+
"No tests extracted in this snapshot.",
|
|
47
|
+
""
|
|
48
|
+
].join("\n");
|
|
49
|
+
}
|
|
50
|
+
const testsByFile = new Map();
|
|
51
|
+
for (const test of architecture.tests) {
|
|
52
|
+
if (!test.file)
|
|
53
|
+
continue;
|
|
54
|
+
if (!testsByFile.has(test.file))
|
|
55
|
+
testsByFile.set(test.file, []);
|
|
56
|
+
testsByFile.get(test.file).push(test);
|
|
57
|
+
}
|
|
58
|
+
const rows = [];
|
|
59
|
+
for (const [file, tests] of Array.from(testsByFile.entries()).sort((a, b) => a[0].localeCompare(b[0]))) {
|
|
60
|
+
for (const test of tests) {
|
|
61
|
+
rows.push([
|
|
62
|
+
file,
|
|
63
|
+
test.suite_name || "-",
|
|
64
|
+
test.test_name
|
|
65
|
+
]);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return [
|
|
69
|
+
section("Behavioral Test Specifications"),
|
|
70
|
+
`Total extracted tests: **${architecture.tests.length}** across **${testsByFile.size}** files.`,
|
|
71
|
+
"",
|
|
72
|
+
renderTable(["File", "Suite", "Test Name"], rows),
|
|
73
|
+
""
|
|
74
|
+
].join("\n");
|
|
75
|
+
}
|
|
76
|
+
function renderTable(headers, rows) {
|
|
77
|
+
if (rows.length === 0) {
|
|
78
|
+
return "*None*\n\n";
|
|
79
|
+
}
|
|
80
|
+
const safe = (value) => value.replace(/\|/g, "\\|").replace(/\n/g, " ").trim() || "—";
|
|
81
|
+
const headerLine = `| ${headers.map(safe).join(" | ")} |`;
|
|
82
|
+
const separator = `| ${headers.map(() => "---").join(" | ")} |`;
|
|
83
|
+
const body = rows.map((row) => `| ${row.map(safe).join(" | ")} |`).join("\n");
|
|
84
|
+
return `${headerLine}\n${separator}\n${body}\n\n`;
|
|
85
|
+
}
|
|
86
|
+
function mermaidId(raw) {
|
|
87
|
+
return raw.replace(/[^a-zA-Z0-9_]/g, "_");
|
|
88
|
+
}
|
|
89
|
+
function mermaidLabel(raw) {
|
|
90
|
+
return raw.replace(/"/g, '\\"');
|
|
91
|
+
}
|
|
92
|
+
function renderMermaid(lines) {
|
|
93
|
+
return ["```mermaid\n", ...lines, "```\n\n"].join("");
|
|
94
|
+
}
|
|
95
|
+
function summarizeList(items, limit = 5) {
|
|
96
|
+
if (items.length === 0) {
|
|
97
|
+
return "none";
|
|
98
|
+
}
|
|
99
|
+
if (items.length <= limit) {
|
|
100
|
+
return items.join(", ");
|
|
101
|
+
}
|
|
102
|
+
return `${items.slice(0, limit).join(", ")} +${items.length - limit} more`;
|
|
103
|
+
}
|
|
104
|
+
function formatComponentProps(props) {
|
|
105
|
+
if (!props || props.length === 0) {
|
|
106
|
+
return "none";
|
|
107
|
+
}
|
|
108
|
+
const entries = props.map((prop) => {
|
|
109
|
+
const suffix = prop.optional ? "?" : "";
|
|
110
|
+
return `${prop.name}${suffix}: ${prop.type || "unknown"}`;
|
|
111
|
+
});
|
|
112
|
+
return summarizeList(entries, 6);
|
|
113
|
+
}
|
|
114
|
+
function splitModelsByFramework(snapshot) {
|
|
115
|
+
return {
|
|
116
|
+
orm: snapshot.data_models.filter((model) => model.framework !== "pydantic"),
|
|
117
|
+
schemas: snapshot.data_models.filter((model) => model.framework === "pydantic")
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
function pickHeatmapLevel(heatmap, level) {
|
|
121
|
+
if (!heatmap || !Array.isArray(heatmap.levels)) {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
return heatmap.levels.find((entry) => entry.level === level) ?? heatmap.levels[0] ?? null;
|
|
125
|
+
}
|
|
126
|
+
function pickHeatmapEntries(heatmap, level) {
|
|
127
|
+
return pickHeatmapLevel(heatmap, level)?.entries ?? [];
|
|
128
|
+
}
|
|
129
|
+
function renderHeatmapTable(label, entries, limit) {
|
|
130
|
+
if (!entries || entries.length === 0) {
|
|
131
|
+
return "*Not available*\n\n";
|
|
132
|
+
}
|
|
133
|
+
return renderTable([label, "Layer", "Score", "Degree", "Cross-Layer", "Cycle"], entries.slice(0, limit).map((entry) => [
|
|
134
|
+
entry.id,
|
|
135
|
+
entry.layer,
|
|
136
|
+
entry.score.toFixed(3),
|
|
137
|
+
String(entry.components.degree),
|
|
138
|
+
entry.components.cross_layer_ratio.toFixed(3),
|
|
139
|
+
String(entry.components.cycle)
|
|
140
|
+
]));
|
|
141
|
+
}
|
|
142
|
+
function formatAiOperations(operations) {
|
|
143
|
+
if (!operations || operations.length === 0) {
|
|
144
|
+
return "none";
|
|
145
|
+
}
|
|
146
|
+
return operations
|
|
147
|
+
.map((op) => {
|
|
148
|
+
const parts = [];
|
|
149
|
+
if (op.model) {
|
|
150
|
+
parts.push(op.model);
|
|
151
|
+
}
|
|
152
|
+
const tokenBudget = op.token_budget ?? op.max_output_tokens ?? op.max_tokens;
|
|
153
|
+
if (typeof tokenBudget === "number") {
|
|
154
|
+
parts.push(`tokens ${tokenBudget}`);
|
|
155
|
+
}
|
|
156
|
+
return parts.length > 0 ? `${op.operation} (${parts.join(", ")})` : op.operation;
|
|
157
|
+
})
|
|
158
|
+
.join(", ");
|
|
159
|
+
}
|
|
160
|
+
function round(value, precision) {
|
|
161
|
+
const factor = 10 ** precision;
|
|
162
|
+
return Math.round(value * factor) / factor;
|
|
163
|
+
}
|
|
164
|
+
function formatTimestamp(value) {
|
|
165
|
+
const date = new Date(value);
|
|
166
|
+
if (Number.isNaN(date.getTime())) {
|
|
167
|
+
return value;
|
|
168
|
+
}
|
|
169
|
+
const month = String(date.getMonth() + 1).padStart(2, "0");
|
|
170
|
+
const day = String(date.getDate()).padStart(2, "0");
|
|
171
|
+
const hours = String(date.getHours()).padStart(2, "0");
|
|
172
|
+
const minutes = String(date.getMinutes()).padStart(2, "0");
|
|
173
|
+
return `${month}-${day} ${hours}:${minutes}`;
|
|
174
|
+
}
|
|
175
|
+
function accessLabel(access) {
|
|
176
|
+
if (access === "read") {
|
|
177
|
+
return "read";
|
|
178
|
+
}
|
|
179
|
+
if (access === "write") {
|
|
180
|
+
return "write";
|
|
181
|
+
}
|
|
182
|
+
if (access === "read_write") {
|
|
183
|
+
return "read/write";
|
|
184
|
+
}
|
|
185
|
+
return "uses";
|
|
186
|
+
}
|
|
187
|
+
function renderIndex(architecture, ux, options) {
|
|
188
|
+
const docsFiles = options.docsFiles;
|
|
189
|
+
const internalFiles = options.internalFiles ?? [];
|
|
190
|
+
const internalDir = options.internalDir ?? "internal";
|
|
191
|
+
return [
|
|
192
|
+
section("SpecGuard Overview"),
|
|
193
|
+
`Project: **${architecture.project.name}**\n\n`,
|
|
194
|
+
renderTable(["Metric", "Count", "Notes"], [
|
|
195
|
+
["Modules", String(architecture.modules.length), "Backend modules"],
|
|
196
|
+
["Endpoints", String(architecture.endpoints.length), "HTTP surface"],
|
|
197
|
+
["Models", String(architecture.data_models.length), "ORM entities and schemas"],
|
|
198
|
+
["Pages", String(ux.pages.length), "UX routes"],
|
|
199
|
+
["Components", String(ux.components.length), "UI nodes"]
|
|
200
|
+
]),
|
|
201
|
+
"Files\n\n",
|
|
202
|
+
docsFiles.map((file) => `- \`${file}\``).join("\n") + "\n\n",
|
|
203
|
+
internalFiles.length > 0
|
|
204
|
+
? `Internal (full reference) files in \`${internalDir}/\`\n\n` +
|
|
205
|
+
internalFiles.map((file) => `- \`${internalDir}/${file}\``).join("\n") +
|
|
206
|
+
"\n\n"
|
|
207
|
+
: "",
|
|
208
|
+
"Generated Artifacts\n\n",
|
|
209
|
+
"- `architecture.summary.json`\n- `architecture.diff.summary.json`\n- `drift.heatmap.json`\n- `constraints.json`\n- `drift.simulation.json`\n\n"
|
|
210
|
+
].join("");
|
|
211
|
+
}
|
|
212
|
+
function renderHumanRootReadme(architecture) {
|
|
213
|
+
return [
|
|
214
|
+
"# SpecGuard Output",
|
|
215
|
+
"",
|
|
216
|
+
`Project: **${architecture.project.name}**`,
|
|
217
|
+
"",
|
|
218
|
+
"This output is split intentionally so humans and tools can read different layers without bloating context.",
|
|
219
|
+
"",
|
|
220
|
+
"## Human-Readable",
|
|
221
|
+
"",
|
|
222
|
+
"- Start in [`human/start-here.md`](./human/start-here.md)",
|
|
223
|
+
"- Narrative onboarding docs for engineers",
|
|
224
|
+
"- Plain-English explanation of system structure, flows, risks, and changes",
|
|
225
|
+
"",
|
|
226
|
+
"## Machine-Readable",
|
|
227
|
+
"",
|
|
228
|
+
"- Start in [`machine/docs/index.md`](./machine/docs/index.md)",
|
|
229
|
+
"- Deterministic snapshots, summaries, heatmaps, constraints, and technical reference docs",
|
|
230
|
+
"- Intended for tooling, IDE context, and AI guardrails",
|
|
231
|
+
""
|
|
232
|
+
].join("\n");
|
|
233
|
+
}
|
|
234
|
+
function renderHumanStartHere(architecture, ux) {
|
|
235
|
+
const { orm, schemas } = splitModelsByFramework(architecture);
|
|
236
|
+
return [
|
|
237
|
+
section("Start Here"),
|
|
238
|
+
`This snapshot describes **${architecture.project.name}** in a human-friendly way. Read these files in order if you are new to the codebase or preparing an AI-assisted work session.`,
|
|
239
|
+
"",
|
|
240
|
+
"## Recommended Path",
|
|
241
|
+
"",
|
|
242
|
+
"- [System Overview](./system-overview.md)",
|
|
243
|
+
"- [Backend Overview](./backend-overview.md)",
|
|
244
|
+
"- [Frontend Overview](./frontend-overview.md)",
|
|
245
|
+
"- [Data and Flows](./data-and-flows.md)",
|
|
246
|
+
"- [Change Guide](./change-guide.md)",
|
|
247
|
+
"",
|
|
248
|
+
"## Snapshot At A Glance",
|
|
249
|
+
"",
|
|
250
|
+
renderTable(["Area", "Count", "What it means"], [
|
|
251
|
+
["Backend modules", String(architecture.modules.length), "Logical backend units and service slices"],
|
|
252
|
+
["API endpoints", String(architecture.endpoints.length), "HTTP surface area"],
|
|
253
|
+
["ORM models", String(orm.length), "Persistent data entities"],
|
|
254
|
+
["Schemas", String(schemas.length), "Request/response and validation models"],
|
|
255
|
+
["Pages", String(ux.pages.length), "User-facing routes"],
|
|
256
|
+
["Components", String(ux.components.length), "Reusable UI building blocks"]
|
|
257
|
+
]),
|
|
258
|
+
"## Separation Of Concerns",
|
|
259
|
+
"",
|
|
260
|
+
"- Human-readable docs live in this `human/` directory.",
|
|
261
|
+
"- Machine-readable snapshots and technical docs live under `../machine/`.",
|
|
262
|
+
"- If you are feeding context to an IDE agent, prefer the `machine/` tree unless you specifically want narrative onboarding context.",
|
|
263
|
+
""
|
|
264
|
+
].join("\n");
|
|
265
|
+
}
|
|
266
|
+
function renderHumanSystemOverview(architecture, ux, meta) {
|
|
267
|
+
const modules = architecture.modules.map((module) => module.id);
|
|
268
|
+
const topHotspots = pickHeatmapEntries(meta?.heatmap, "file")
|
|
269
|
+
.slice(0, 5)
|
|
270
|
+
.map((entry) => `${entry.id} (${entry.score.toFixed(2)})`);
|
|
271
|
+
return [
|
|
272
|
+
section("System Overview"),
|
|
273
|
+
`**${architecture.project.name}** is represented here as one backend workspace and one frontend workspace. The goal of this document set is to explain how the system is split up, where key responsibilities live, and where changes are likely to have wider impact.`,
|
|
274
|
+
"",
|
|
275
|
+
"## Boundaries",
|
|
276
|
+
"",
|
|
277
|
+
`Backend root: \`${architecture.project.backend_root}\``,
|
|
278
|
+
"",
|
|
279
|
+
`Frontend root: \`${architecture.project.frontend_root}\``,
|
|
280
|
+
"",
|
|
281
|
+
`Workspace root: \`${architecture.project.workspace_root}\``,
|
|
282
|
+
"",
|
|
283
|
+
"## Main Moving Parts",
|
|
284
|
+
"",
|
|
285
|
+
bullet([
|
|
286
|
+
`Backend modules tracked: ${architecture.modules.length} (${summarizeList(modules, 6)})`,
|
|
287
|
+
`Frontend pages tracked: ${ux.pages.length}`,
|
|
288
|
+
`Runtime services tracked: ${architecture.runtime.services.length}`,
|
|
289
|
+
architecture.drift.status === "stable"
|
|
290
|
+
? "Structural drift is currently stable."
|
|
291
|
+
: architecture.drift.status === "critical"
|
|
292
|
+
? "Structural drift is currently in a critical state and should be treated carefully."
|
|
293
|
+
: "Structural drift is present, so changes may have more blast radius."
|
|
294
|
+
]),
|
|
295
|
+
"## Where Changes Are Riskier",
|
|
296
|
+
"",
|
|
297
|
+
topHotspots.length > 0
|
|
298
|
+
? bullet(topHotspots.map((entry) => `${entry} — higher coupling here means changes may affect more neighbors.`))
|
|
299
|
+
: "*No major hotspots identified in this snapshot.*\n",
|
|
300
|
+
"Next: [Backend Overview](./backend-overview.md)",
|
|
301
|
+
""
|
|
302
|
+
].join("\n");
|
|
303
|
+
}
|
|
304
|
+
function renderHumanBackendOverview(architecture) {
|
|
305
|
+
const backendModules = architecture.modules.filter((module) => module.type === "backend");
|
|
306
|
+
const services = groupModulesForHumans(backendModules);
|
|
307
|
+
const lines = [section("Backend Overview")];
|
|
308
|
+
lines.push("This file groups backend modules into service-like areas so you can quickly see ownership boundaries and where APIs are concentrated.\n");
|
|
309
|
+
for (const service of services) {
|
|
310
|
+
const endpointCount = service.modules.reduce((sum, module) => sum + module.endpoints.length, 0);
|
|
311
|
+
const files = service.modules.flatMap((module) => module.files);
|
|
312
|
+
lines.push(`## ${service.name}\n`);
|
|
313
|
+
lines.push(bullet([
|
|
314
|
+
`Modules: ${service.modules.length}`,
|
|
315
|
+
`Files: ${files.length}`,
|
|
316
|
+
`Endpoints: ${endpointCount}`,
|
|
317
|
+
`Layers present: ${summarizeList(Array.from(new Set(service.modules.map((module) => module.layer))), 4)}`
|
|
318
|
+
]));
|
|
319
|
+
lines.push(renderTable(["Module", "Layer", "Files", "Endpoints"], service.modules.map((module) => [
|
|
320
|
+
module.id,
|
|
321
|
+
module.layer,
|
|
322
|
+
String(module.files.length),
|
|
323
|
+
String(module.endpoints.length)
|
|
324
|
+
])));
|
|
325
|
+
}
|
|
326
|
+
lines.push("Next: [Frontend Overview](./frontend-overview.md)\n");
|
|
327
|
+
return lines.join("\n");
|
|
328
|
+
}
|
|
329
|
+
function renderHumanFrontendOverview(ux) {
|
|
330
|
+
const lines = [section("Frontend Overview")];
|
|
331
|
+
lines.push("This file describes the user-facing surface area and the main UI pieces a developer is likely to touch first.\n");
|
|
332
|
+
lines.push(renderTable(["Page", "Root Component", "Components", "API Calls"], ux.pages.map((page) => [
|
|
333
|
+
page.path,
|
|
334
|
+
page.component,
|
|
335
|
+
String(page.components.length),
|
|
336
|
+
String(page.api_calls.length)
|
|
337
|
+
])));
|
|
338
|
+
const topComponents = ux.components
|
|
339
|
+
.slice()
|
|
340
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
341
|
+
.slice(0, 15);
|
|
342
|
+
lines.push("## Representative Components\n");
|
|
343
|
+
lines.push(renderTable(["Component", "Kind", "File", "Import Style"], topComponents.map((component) => [
|
|
344
|
+
component.name,
|
|
345
|
+
component.kind,
|
|
346
|
+
component.file,
|
|
347
|
+
component.export_kind
|
|
348
|
+
])));
|
|
349
|
+
lines.push("Next: [Data and Flows](./data-and-flows.md)\n");
|
|
350
|
+
return lines.join("\n");
|
|
351
|
+
}
|
|
352
|
+
function renderHumanDataAndFlows(architecture, ux) {
|
|
353
|
+
const { orm, schemas } = splitModelsByFramework(architecture);
|
|
354
|
+
const lines = [section("Data and Flows")];
|
|
355
|
+
lines.push("This file focuses on the data shape of the system and the most important frontend-to-backend paths.\n");
|
|
356
|
+
lines.push(renderTable(["Type", "Count", "Meaning"], [
|
|
357
|
+
["ORM models", String(orm.length), "Database-backed persistent entities"],
|
|
358
|
+
["Schemas", String(schemas.length), "Validation and API payload structures"],
|
|
359
|
+
["Cross-stack contracts", String(architecture.cross_stack_contracts.length), "Frontend/backend API matches"],
|
|
360
|
+
["Data flows", String(architecture.data_flows.length), "Page-to-endpoint-to-model traces"]
|
|
361
|
+
]));
|
|
362
|
+
lines.push("## Representative Data Flows\n");
|
|
363
|
+
lines.push(renderTable(["Page", "Endpoint", "Models"], architecture.data_flows.slice(0, 12).map((flow) => [
|
|
364
|
+
flow.page,
|
|
365
|
+
flow.endpoint_id,
|
|
366
|
+
flow.models.join(", ") || "none"
|
|
367
|
+
])));
|
|
368
|
+
lines.push("## Contract Status\n");
|
|
369
|
+
const verified = architecture.cross_stack_contracts.filter((contract) => contract.status === "ok" || contract.status === "mismatched");
|
|
370
|
+
lines.push(renderTable(["Status", "Count", "Meaning"], [
|
|
371
|
+
[
|
|
372
|
+
"ok",
|
|
373
|
+
String(verified.filter((contract) => contract.status === "ok").length),
|
|
374
|
+
"Frontend fields line up with backend expectations"
|
|
375
|
+
],
|
|
376
|
+
[
|
|
377
|
+
"mismatched",
|
|
378
|
+
String(verified.filter((contract) => contract.status === "mismatched").length),
|
|
379
|
+
"A frontend/backend contract likely needs attention"
|
|
380
|
+
],
|
|
381
|
+
[
|
|
382
|
+
"unverified",
|
|
383
|
+
String(architecture.cross_stack_contracts.length - verified.length),
|
|
384
|
+
"SpecGuard could not confidently infer enough fields yet"
|
|
385
|
+
]
|
|
386
|
+
]));
|
|
387
|
+
lines.push("Next: [Change Guide](./change-guide.md)\n");
|
|
388
|
+
return lines.join("\n");
|
|
389
|
+
}
|
|
390
|
+
function renderHumanChangeGuide(architecture, meta) {
|
|
391
|
+
const lines = [section("Change Guide")];
|
|
392
|
+
lines.push("Use this file before making code changes. It highlights where the architecture is more fragile and what changed since the previous snapshot.\n");
|
|
393
|
+
lines.push("## Structural Risk\n");
|
|
394
|
+
lines.push(bullet([
|
|
395
|
+
`Current drift status: ${architecture.drift.status}`,
|
|
396
|
+
`Current delta: ${architecture.drift.delta.toFixed(4)}`,
|
|
397
|
+
architecture.analysis.circular_dependencies.length > 0
|
|
398
|
+
? `Circular dependencies detected: ${architecture.analysis.circular_dependencies.length}`
|
|
399
|
+
: "No circular dependencies detected",
|
|
400
|
+
architecture.analysis.unused_endpoints.length > 0
|
|
401
|
+
? `Unused endpoints: ${architecture.analysis.unused_endpoints.length}`
|
|
402
|
+
: "No unused endpoints detected"
|
|
403
|
+
]));
|
|
404
|
+
lines.push("## High-Coupling Files\n");
|
|
405
|
+
const hotspots = pickHeatmapEntries(meta?.heatmap, "file").slice(0, 10);
|
|
406
|
+
lines.push(renderTable(["File", "Coupling Score", "Meaning"], hotspots.map((entry) => [
|
|
407
|
+
entry.id,
|
|
408
|
+
entry.score.toFixed(3),
|
|
409
|
+
"Higher scores usually mean broader blast radius when edited"
|
|
410
|
+
])));
|
|
411
|
+
lines.push("## Since The Previous Snapshot\n");
|
|
412
|
+
if (!meta?.diff) {
|
|
413
|
+
lines.push("No previous snapshot was available, so this run cannot summarize structural changes yet.\n");
|
|
414
|
+
}
|
|
415
|
+
else if (!meta.diff.structural_change) {
|
|
416
|
+
lines.push("No structural changes were detected since the previous snapshot.\n");
|
|
417
|
+
}
|
|
418
|
+
else {
|
|
419
|
+
const deltas = Object.entries(meta.diff.counts_delta)
|
|
420
|
+
.filter(([, value]) => value !== 0)
|
|
421
|
+
.map(([key, value]) => `${key}: ${value > 0 ? `+${value}` : value}`);
|
|
422
|
+
lines.push(bullet(deltas.length > 0 ? deltas : ["Structural changes detected"]));
|
|
423
|
+
}
|
|
424
|
+
lines.push("Return to [Start Here](./start-here.md)\n");
|
|
425
|
+
return lines.join("\n");
|
|
426
|
+
}
|
|
427
|
+
function groupModulesForHumans(modules) {
|
|
428
|
+
const grouped = new Map();
|
|
429
|
+
for (const module of modules) {
|
|
430
|
+
const parts = module.path.split("/").filter(Boolean);
|
|
431
|
+
const key = parts.find((part) => part.startsWith("service-")) ??
|
|
432
|
+
parts.find((part) => part !== "backend" && part !== "src") ??
|
|
433
|
+
module.id;
|
|
434
|
+
const entry = grouped.get(key) ?? [];
|
|
435
|
+
entry.push(module);
|
|
436
|
+
grouped.set(key, entry);
|
|
437
|
+
}
|
|
438
|
+
return Array.from(grouped.entries())
|
|
439
|
+
.map(([name, groupedModules]) => ({
|
|
440
|
+
name,
|
|
441
|
+
modules: groupedModules.sort((a, b) => a.id.localeCompare(b.id))
|
|
442
|
+
}))
|
|
443
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
444
|
+
}
|
|
445
|
+
export function renderExecutiveSummary(architecture, ux, meta) {
|
|
446
|
+
const summary = meta?.summary ?? null;
|
|
447
|
+
const diff = meta?.diff ?? null;
|
|
448
|
+
const heatmap = meta?.heatmap ?? null;
|
|
449
|
+
const docsMode = meta?.docsMode ?? "lean";
|
|
450
|
+
const internalDir = meta?.internalDir ?? "internal";
|
|
451
|
+
const generatedAt = summary?.generated_at ?? new Date().toISOString();
|
|
452
|
+
const modules = architecture.modules.map((module) => module.id);
|
|
453
|
+
const moduleNames = summarizeList(modules, 6);
|
|
454
|
+
const entrypoints = summarizeList(architecture.project.entrypoints ?? [], 4);
|
|
455
|
+
const cycleCount = architecture.analysis.circular_dependencies.length;
|
|
456
|
+
const orphanModules = architecture.analysis.orphan_modules.length;
|
|
457
|
+
const orphanFiles = architecture.analysis.orphan_files.length;
|
|
458
|
+
const unusedEndpoints = architecture.analysis.unused_endpoints.length;
|
|
459
|
+
const unusedCalls = architecture.analysis.frontend_unused_api_calls.length;
|
|
460
|
+
const duplicateGroups = architecture.analysis.duplicate_functions.length;
|
|
461
|
+
const similarGroups = architecture.analysis.similar_functions.length;
|
|
462
|
+
const untestedFiles = architecture.analysis.test_coverage.untested_source_files.length;
|
|
463
|
+
const topFunctions = pickHeatmapEntries(heatmap, "function")
|
|
464
|
+
.slice(0, 5)
|
|
465
|
+
.map((entry) => `${entry.id} (${entry.score.toFixed(2)})`);
|
|
466
|
+
const driftStatus = architecture.drift.status === "stable"
|
|
467
|
+
? "Stable"
|
|
468
|
+
: architecture.drift.status === "critical"
|
|
469
|
+
? "Needs Attention"
|
|
470
|
+
: "Drift Detected";
|
|
471
|
+
const summaryLines = [];
|
|
472
|
+
summaryLines.push(section("Product Summary"));
|
|
473
|
+
summaryLines.push(`Project: **${architecture.project.name}**\n\n`);
|
|
474
|
+
summaryLines.push(`Snapshot date: **${formatTimestamp(generatedAt)}**\n\n`);
|
|
475
|
+
summaryLines.push("SpecGuard produces living, machine‑verified documentation for your codebase so teams can align on architecture, detect drift, and share an accurate system spec without manual doc maintenance.\n\n");
|
|
476
|
+
summaryLines.push("## Vision\n\n");
|
|
477
|
+
summaryLines.push("Enable teams to treat architecture as a first‑class, continuously verifiable product artifact—" +
|
|
478
|
+
"not a static diagram or an outdated wiki.\n\n");
|
|
479
|
+
summaryLines.push("## Goals\n\n");
|
|
480
|
+
summaryLines.push(bullet([
|
|
481
|
+
"Capture the current system structure (modules, APIs, data models, UI routes).",
|
|
482
|
+
"Expose architectural drift signals early (cycles, unused endpoints, duplicates).",
|
|
483
|
+
"Provide fast, shareable documentation for engineers, PMs, and tech writers.",
|
|
484
|
+
"Reduce exploration time for AI‑assisted development sessions."
|
|
485
|
+
]));
|
|
486
|
+
summaryLines.push("## What This Snapshot Covers\n\n");
|
|
487
|
+
summaryLines.push(bullet([
|
|
488
|
+
`Backend root: \`${architecture.project.backend_root}\``,
|
|
489
|
+
`Frontend root: \`${architecture.project.frontend_root}\``,
|
|
490
|
+
`Entrypoints: ${entrypoints}`
|
|
491
|
+
]));
|
|
492
|
+
summaryLines.push("## System Scale (Current State)\n\n");
|
|
493
|
+
summaryLines.push(renderTable(["Area", "Count", "Notes"], [
|
|
494
|
+
["Backend modules", String(architecture.modules.length), moduleNames],
|
|
495
|
+
["API endpoints", String(architecture.endpoints.length), "HTTP surface area"],
|
|
496
|
+
["Data models", String(architecture.data_models.length), "Database entities"],
|
|
497
|
+
["UI pages", String(ux.pages.length), "User-facing routes"],
|
|
498
|
+
["UI components", String(ux.components.length), "Reusable UI elements"],
|
|
499
|
+
["Background tasks", String(architecture.tasks.length), "Async or scheduled jobs"],
|
|
500
|
+
["Runtime services", String(architecture.runtime.services.length), "Docker/services"]
|
|
501
|
+
]));
|
|
502
|
+
summaryLines.push("## Architecture Overview\n\n");
|
|
503
|
+
summaryLines.push(bullet([
|
|
504
|
+
`Primary backend modules: ${moduleNames}`,
|
|
505
|
+
`Module dependencies captured with a directed graph (${architecture.dependencies.module_graph.length} edges).`,
|
|
506
|
+
cycleCount > 0
|
|
507
|
+
? `Circular dependencies detected: ${cycleCount}`
|
|
508
|
+
: "No circular dependencies detected"
|
|
509
|
+
]));
|
|
510
|
+
summaryLines.push("## API & Service Surface\n\n");
|
|
511
|
+
summaryLines.push(bullet([
|
|
512
|
+
`Endpoints cataloged: ${architecture.endpoints.length}`,
|
|
513
|
+
"Request/response schemas tracked per endpoint when available.",
|
|
514
|
+
"Endpoint‑to‑model usage captured for database impact mapping."
|
|
515
|
+
]));
|
|
516
|
+
summaryLines.push("## Data Layer\n\n");
|
|
517
|
+
summaryLines.push(bullet([
|
|
518
|
+
`Models detected: ${architecture.data_models.length}`,
|
|
519
|
+
docsMode === "full"
|
|
520
|
+
? "Field‑level details (types, nullable, PK/FK, defaults) are captured in the data dictionary."
|
|
521
|
+
: "Field‑level details (types, nullable, PK/FK, defaults) are available in the full data dictionary (internal).",
|
|
522
|
+
"Enums and constants are cataloged for shared domain vocabulary."
|
|
523
|
+
]));
|
|
524
|
+
summaryLines.push("## UX Layer\n\n");
|
|
525
|
+
summaryLines.push(bullet([
|
|
526
|
+
`Pages detected: ${ux.pages.length}`,
|
|
527
|
+
`Components detected: ${ux.components.length}`,
|
|
528
|
+
"Component graphs include API calls, state, and navigation signals."
|
|
529
|
+
]));
|
|
530
|
+
summaryLines.push("## Quality & Drift Signals\n\n");
|
|
531
|
+
summaryLines.push(bullet([
|
|
532
|
+
`Overall structural status: **${driftStatus}**`,
|
|
533
|
+
orphanModules > 0 ? `Orphan modules: ${orphanModules}` : "No orphan modules detected",
|
|
534
|
+
orphanFiles > 0 ? `Orphan files: ${orphanFiles}` : "No orphan files detected",
|
|
535
|
+
unusedEndpoints > 0
|
|
536
|
+
? `Backend endpoints without a known frontend caller: ${unusedEndpoints}`
|
|
537
|
+
: "Every backend endpoint has a known frontend caller",
|
|
538
|
+
unusedCalls > 0
|
|
539
|
+
? `Frontend API calls without a known backend endpoint: ${unusedCalls}`
|
|
540
|
+
: "No unmatched frontend API calls detected",
|
|
541
|
+
duplicateGroups > 0
|
|
542
|
+
? `Exact duplicate function groups detected: ${duplicateGroups}`
|
|
543
|
+
: "No exact duplicate function groups detected",
|
|
544
|
+
similarGroups > 0
|
|
545
|
+
? `Similar function groups detected: ${similarGroups}`
|
|
546
|
+
: "No similar function groups detected",
|
|
547
|
+
untestedFiles > 0
|
|
548
|
+
? `Untested source files: ${untestedFiles}`
|
|
549
|
+
: "All source files have at least one mapped test",
|
|
550
|
+
topFunctions.length > 0
|
|
551
|
+
? `Top drift hotspots (functions): ${summarizeList(topFunctions, 5)}`
|
|
552
|
+
: "No function-level drift hotspots identified"
|
|
553
|
+
]));
|
|
554
|
+
summaryLines.push("## Changes Since Last Snapshot\n\n");
|
|
555
|
+
if (!diff) {
|
|
556
|
+
summaryLines.push("No previous snapshot available for comparison.\n\n");
|
|
557
|
+
}
|
|
558
|
+
else if (!diff.structural_change) {
|
|
559
|
+
summaryLines.push("No structural changes detected since the previous snapshot.\n\n");
|
|
560
|
+
}
|
|
561
|
+
else {
|
|
562
|
+
const deltas = Object.entries(diff.counts_delta)
|
|
563
|
+
.filter(([, value]) => value !== 0)
|
|
564
|
+
.map(([key, value]) => `${key}: ${value > 0 ? `+${value}` : value}`);
|
|
565
|
+
summaryLines.push(bullet([
|
|
566
|
+
diff.shape_equivalent
|
|
567
|
+
? "Changes are additive (shape‑equivalent)."
|
|
568
|
+
: "Changes include structural refactors.",
|
|
569
|
+
deltas.length > 0 ? `Count changes: ${summarizeList(deltas, 6)}` : "Count changes detected"
|
|
570
|
+
]));
|
|
571
|
+
}
|
|
572
|
+
summaryLines.push("## Roadmap (Suggested)\n\n");
|
|
573
|
+
summaryLines.push(bullet([
|
|
574
|
+
"Track drift over time with history charts and alerts.",
|
|
575
|
+
"Enforce architectural capacity budgets for critical layers.",
|
|
576
|
+
"Deepen UI prop & state understanding for richer UX specs.",
|
|
577
|
+
"Add snapshot‑to‑snapshot change narratives for PM updates."
|
|
578
|
+
]));
|
|
579
|
+
summaryLines.push("## Documentation Outputs\n\n");
|
|
580
|
+
summaryLines.push(bullet([
|
|
581
|
+
"`summary.md` provides this product‑spec overview.",
|
|
582
|
+
"`stakeholder.md` is the one‑page executive view.",
|
|
583
|
+
"`hld.md` provides the system block diagram, drift summary, and module dependencies.",
|
|
584
|
+
"`integration.md` groups APIs by domain with schemas and service links.",
|
|
585
|
+
"`ux.md` captures UI pages, components, props, API calls, and interaction maps.",
|
|
586
|
+
"`data.md` lists data models and where they live.",
|
|
587
|
+
"`diff.md` summarizes what changed between snapshots.",
|
|
588
|
+
"`runtime.md` captures services, ports, and tasks."
|
|
589
|
+
]));
|
|
590
|
+
if (docsMode === "full") {
|
|
591
|
+
summaryLines.push(`Full reference docs (architecture dump, field‑level data dictionary, interaction maps, and tests) are available in \`${internalDir}/\`.\n\n`);
|
|
592
|
+
}
|
|
593
|
+
return summaryLines.join("");
|
|
594
|
+
}
|
|
595
|
+
export function renderStakeholderSummary(architecture, ux, meta) {
|
|
596
|
+
const summary = meta?.summary ?? null;
|
|
597
|
+
const diff = meta?.diff ?? null;
|
|
598
|
+
const docsMode = meta?.docsMode ?? "lean";
|
|
599
|
+
const internalDir = meta?.internalDir ?? "internal";
|
|
600
|
+
const generatedAt = summary?.generated_at ?? new Date().toISOString();
|
|
601
|
+
const cycleCount = architecture.analysis.circular_dependencies.length;
|
|
602
|
+
const unusedEndpoints = architecture.analysis.unused_endpoints.length;
|
|
603
|
+
const untestedFiles = architecture.analysis.test_coverage.untested_source_files.length;
|
|
604
|
+
const driftStatus = architecture.drift.status === "stable"
|
|
605
|
+
? "Stable"
|
|
606
|
+
: architecture.drift.status === "critical"
|
|
607
|
+
? "Needs Attention"
|
|
608
|
+
: "Drift Detected";
|
|
609
|
+
const lines = [];
|
|
610
|
+
lines.push(section("Stakeholder Summary"));
|
|
611
|
+
lines.push(`Project: **${architecture.project.name}**\n\n`);
|
|
612
|
+
lines.push(`Snapshot date: **${formatTimestamp(generatedAt)}**\n\n`);
|
|
613
|
+
lines.push("A one‑page overview for non‑technical stakeholders highlighting scale, health, and changes.\n\n");
|
|
614
|
+
lines.push(renderTable(["Metric", "Count", "Notes"], [
|
|
615
|
+
["Modules", String(architecture.modules.length), "Backend units"],
|
|
616
|
+
["Endpoints", String(architecture.endpoints.length), "API surface"],
|
|
617
|
+
["Models", String(architecture.data_models.length), "Data entities"],
|
|
618
|
+
["UI Pages", String(ux.pages.length), "User routes"],
|
|
619
|
+
["UI Components", String(ux.components.length), "Reusable UI parts"]
|
|
620
|
+
]));
|
|
621
|
+
lines.push("## Health Snapshot\n\n");
|
|
622
|
+
lines.push(bullet([
|
|
623
|
+
`Architecture status: **${driftStatus}**`,
|
|
624
|
+
cycleCount > 0
|
|
625
|
+
? `Circular dependencies detected: ${cycleCount}`
|
|
626
|
+
: "No circular dependencies detected",
|
|
627
|
+
unusedEndpoints > 0
|
|
628
|
+
? `Unused backend endpoints: ${unusedEndpoints}`
|
|
629
|
+
: "All backend endpoints are used",
|
|
630
|
+
untestedFiles > 0
|
|
631
|
+
? `Untested source files: ${untestedFiles}`
|
|
632
|
+
: "All source files have mapped tests"
|
|
633
|
+
]));
|
|
634
|
+
lines.push("## What Changed Since Last Snapshot\n\n");
|
|
635
|
+
if (!diff) {
|
|
636
|
+
lines.push("No previous snapshot available for comparison.\n\n");
|
|
637
|
+
}
|
|
638
|
+
else if (!diff.structural_change) {
|
|
639
|
+
lines.push("No structural changes detected since the previous snapshot.\n\n");
|
|
640
|
+
}
|
|
641
|
+
else {
|
|
642
|
+
const deltas = Object.entries(diff.counts_delta)
|
|
643
|
+
.filter(([, value]) => value !== 0)
|
|
644
|
+
.map(([key, value]) => `${key}: ${value > 0 ? `+${value}` : value}`);
|
|
645
|
+
lines.push(bullet([
|
|
646
|
+
diff.shape_equivalent
|
|
647
|
+
? "Changes are additive (shape‑equivalent)."
|
|
648
|
+
: "Changes include structural refactors.",
|
|
649
|
+
deltas.length > 0 ? `Count changes: ${summarizeList(deltas, 6)}` : "Count changes detected"
|
|
650
|
+
]));
|
|
651
|
+
}
|
|
652
|
+
lines.push("## Where To Look Next\n\n");
|
|
653
|
+
lines.push(bullet([
|
|
654
|
+
"`summary.md` for the product-level overview",
|
|
655
|
+
"`hld.md` for system overview",
|
|
656
|
+
"`integration.md` for domain-level API summaries",
|
|
657
|
+
"`ux.md` for UI interactions",
|
|
658
|
+
"`data.md` for model inventory"
|
|
659
|
+
]));
|
|
660
|
+
if (docsMode === "full") {
|
|
661
|
+
lines.push(`\nFull reference docs are available in \`${internalDir}/\`.\n\n`);
|
|
662
|
+
}
|
|
663
|
+
return lines.join("");
|
|
664
|
+
}
|
|
665
|
+
function renderArchitecture(snapshot, meta) {
|
|
666
|
+
const lines = [];
|
|
667
|
+
lines.push(section("Architecture Snapshot"));
|
|
668
|
+
lines.push(`Backend: \`${snapshot.project.backend_root}\`\n\n`);
|
|
669
|
+
lines.push(`Frontend: \`${snapshot.project.frontend_root}\`\n\n`);
|
|
670
|
+
lines.push("## Drift Summary\n\n");
|
|
671
|
+
lines.push(renderTable(["Status", "Graph", "D_t", "K_t", "Delta"], [[
|
|
672
|
+
snapshot.drift.status,
|
|
673
|
+
snapshot.drift.graph_level,
|
|
674
|
+
snapshot.drift.D_t.toFixed(4),
|
|
675
|
+
snapshot.drift.K_t.toFixed(4),
|
|
676
|
+
snapshot.drift.delta.toFixed(4)
|
|
677
|
+
]]));
|
|
678
|
+
lines.push(renderTable(["Entropy", "Cross-Layer", "Cycle Density", "Modularity Gap"], [[
|
|
679
|
+
snapshot.drift.metrics.entropy.toFixed(4),
|
|
680
|
+
snapshot.drift.metrics.cross_layer_ratio.toFixed(4),
|
|
681
|
+
snapshot.drift.metrics.cycle_density.toFixed(4),
|
|
682
|
+
snapshot.drift.metrics.modularity_gap.toFixed(4)
|
|
683
|
+
]]));
|
|
684
|
+
lines.push("## Multi-Scale Drift\n\n");
|
|
685
|
+
lines.push(renderTable(["Level", "Status", "Delta", "D_t", "K_t", "Edges", "Nodes"], snapshot.drift.scales.map((scale) => [
|
|
686
|
+
scale.level,
|
|
687
|
+
scale.status,
|
|
688
|
+
scale.delta.toFixed(4),
|
|
689
|
+
scale.D_t.toFixed(4),
|
|
690
|
+
scale.K_t.toFixed(4),
|
|
691
|
+
String(scale.details.edges),
|
|
692
|
+
String(scale.details.nodes)
|
|
693
|
+
])));
|
|
694
|
+
lines.push("## Architecture Fingerprint\n\n");
|
|
695
|
+
if (meta?.summary) {
|
|
696
|
+
lines.push(`Fingerprint: \`${meta.summary.fingerprint}\`\n\n`);
|
|
697
|
+
lines.push(`Shape Fingerprint: \`${meta.summary.shape_fingerprint ?? "n/a"}\`\n\n`);
|
|
698
|
+
lines.push("Legend: shape fingerprint changes indicate structural refactors (dependency pattern shifts), " +
|
|
699
|
+
"while fingerprint changes with the same shape indicate additive changes.\n\n");
|
|
700
|
+
lines.push(renderTable(["Modules", "Edges", "Files", "Endpoints", "Models", "Pages", "Components"], [[
|
|
701
|
+
String(meta.summary.counts.modules),
|
|
702
|
+
String(meta.summary.counts.module_edges),
|
|
703
|
+
String(meta.summary.counts.files),
|
|
704
|
+
String(meta.summary.counts.endpoints),
|
|
705
|
+
String(meta.summary.counts.models),
|
|
706
|
+
String(meta.summary.counts.pages),
|
|
707
|
+
String(meta.summary.counts.components)
|
|
708
|
+
]]));
|
|
709
|
+
}
|
|
710
|
+
else {
|
|
711
|
+
lines.push("*Not available*\n\n");
|
|
712
|
+
}
|
|
713
|
+
lines.push("## Compressed Diff Summary\n\n");
|
|
714
|
+
if (meta?.diff) {
|
|
715
|
+
lines.push(`Structural change: **${meta.diff.structural_change ? "yes" : "no"}** \n` +
|
|
716
|
+
`Shape equivalent: **${meta.diff.shape_equivalent ? "yes" : "no"}**\n\n`);
|
|
717
|
+
const addedModules = meta.diff.added.modules.slice(0, 10);
|
|
718
|
+
const removedModules = meta.diff.removed.modules.slice(0, 10);
|
|
719
|
+
lines.push(renderTable(["Field", "Delta"], Object.entries(meta.diff.counts_delta).map(([field, delta]) => [field, String(delta)])));
|
|
720
|
+
lines.push("### Added (Top 10)\n\n");
|
|
721
|
+
lines.push(bullet(addedModules.length ? addedModules : ["None"]));
|
|
722
|
+
lines.push("\n### Removed (Top 10)\n\n");
|
|
723
|
+
lines.push(bullet(removedModules.length ? removedModules : ["None"]));
|
|
724
|
+
}
|
|
725
|
+
else {
|
|
726
|
+
lines.push("*No previous summary available*\n\n");
|
|
727
|
+
}
|
|
728
|
+
lines.push("## Drift Heatmap (Files)\n\n");
|
|
729
|
+
const fileHeatmap = pickHeatmapEntries(meta?.heatmap, "file");
|
|
730
|
+
lines.push(renderHeatmapTable("File", fileHeatmap, 10));
|
|
731
|
+
lines.push("## Drift Heatmap (Functions)\n\n");
|
|
732
|
+
const functionHeatmap = pickHeatmapEntries(meta?.heatmap, "function");
|
|
733
|
+
lines.push(renderHeatmapTable("Function", functionHeatmap, 10));
|
|
734
|
+
lines.push("## Duplication Signals\n\n");
|
|
735
|
+
const duplicates = snapshot.analysis.duplicate_functions ?? [];
|
|
736
|
+
if (duplicates.length === 0) {
|
|
737
|
+
lines.push("*None*\n\n");
|
|
738
|
+
}
|
|
739
|
+
else {
|
|
740
|
+
lines.push(renderTable(["Hash", "Count", "Size", "Examples"], duplicates.slice(0, 10).map((group) => [
|
|
741
|
+
group.hash.slice(0, 8),
|
|
742
|
+
String(group.functions.length),
|
|
743
|
+
String(group.size),
|
|
744
|
+
group.functions
|
|
745
|
+
.slice(0, 3)
|
|
746
|
+
.map((fn) => `${path.basename(fn.file)}#${fn.name}`)
|
|
747
|
+
.join(", ") || "n/a"
|
|
748
|
+
])));
|
|
749
|
+
}
|
|
750
|
+
const similar = snapshot.analysis.similar_functions ?? [];
|
|
751
|
+
if (similar.length > 0) {
|
|
752
|
+
lines.push(renderTable(["Similarity", "Basis", "Function A", "Function B"], similar.slice(0, 10).map((pair) => [
|
|
753
|
+
pair.similarity.toFixed(2),
|
|
754
|
+
pair.basis,
|
|
755
|
+
`${path.basename(pair.functions[0]?.file ?? "")}#${pair.functions[0]?.name ?? ""}`,
|
|
756
|
+
`${path.basename(pair.functions[1]?.file ?? "")}#${pair.functions[1]?.name ?? ""}`
|
|
757
|
+
])));
|
|
758
|
+
}
|
|
759
|
+
const capacityHasBudget = snapshot.drift.capacity.layers.some((layer) => layer.status !== "unbudgeted") ||
|
|
760
|
+
(snapshot.drift.capacity.total && snapshot.drift.capacity.total.status !== "unbudgeted");
|
|
761
|
+
lines.push("## Capacity Summary\n\n");
|
|
762
|
+
if (!capacityHasBudget) {
|
|
763
|
+
lines.push("*Capacity budgets not configured*\n\n");
|
|
764
|
+
}
|
|
765
|
+
else {
|
|
766
|
+
if (snapshot.drift.capacity.layers.length > 0) {
|
|
767
|
+
lines.push(renderTable(["Layer", "Budget", "Used", "Ratio", "Status", "Cross-Layer Out"], snapshot.drift.capacity.layers.map((layer) => [
|
|
768
|
+
layer.layer,
|
|
769
|
+
layer.budget !== undefined ? String(layer.budget) : "n/a",
|
|
770
|
+
String(layer.edges),
|
|
771
|
+
layer.ratio !== undefined ? layer.ratio.toFixed(2) : "n/a",
|
|
772
|
+
layer.status,
|
|
773
|
+
String(layer.cross_layer_out)
|
|
774
|
+
])));
|
|
775
|
+
}
|
|
776
|
+
if (snapshot.drift.capacity.total && snapshot.drift.capacity.total.budget !== undefined) {
|
|
777
|
+
lines.push(renderTable(["Total Budget", "Used", "Ratio", "Status"], [[
|
|
778
|
+
String(snapshot.drift.capacity.total.budget),
|
|
779
|
+
String(snapshot.drift.capacity.total.used),
|
|
780
|
+
snapshot.drift.capacity.total.ratio !== undefined
|
|
781
|
+
? snapshot.drift.capacity.total.ratio.toFixed(2)
|
|
782
|
+
: "n/a",
|
|
783
|
+
snapshot.drift.capacity.total.status
|
|
784
|
+
]]));
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
lines.push("## Growth Summary\n\n");
|
|
788
|
+
lines.push(renderTable(["Edges/Hour", "Edges/Day", "Trend", "Status", "Window"], [[
|
|
789
|
+
snapshot.drift.growth.edges_per_hour.toFixed(2),
|
|
790
|
+
snapshot.drift.growth.edges_per_day.toFixed(2),
|
|
791
|
+
snapshot.drift.growth.trend,
|
|
792
|
+
snapshot.drift.growth.status,
|
|
793
|
+
snapshot.drift.growth.window.from && snapshot.drift.growth.window.to
|
|
794
|
+
? `${snapshot.drift.growth.window.from} → ${snapshot.drift.growth.window.to}`
|
|
795
|
+
: "n/a"
|
|
796
|
+
]]));
|
|
797
|
+
lines.push("## Modules\n\n");
|
|
798
|
+
lines.push(renderTable(["Module", "Layer", "Files", "Imports"], snapshot.modules.map((module) => [
|
|
799
|
+
module.id,
|
|
800
|
+
module.layer,
|
|
801
|
+
String(module.files.length),
|
|
802
|
+
module.imports.join(", ") || "none"
|
|
803
|
+
])));
|
|
804
|
+
lines.push("## Endpoints\n\n");
|
|
805
|
+
lines.push(renderTable(["Method", "Path", "Handler", "Module", "Request Schema", "Response Schema", "AI Operations"], snapshot.endpoints.map((endpoint) => [
|
|
806
|
+
endpoint.method,
|
|
807
|
+
endpoint.path,
|
|
808
|
+
endpoint.handler,
|
|
809
|
+
endpoint.module,
|
|
810
|
+
endpoint.request_schema || "none",
|
|
811
|
+
endpoint.response_schema || "none",
|
|
812
|
+
formatAiOperations(endpoint.ai_operations)
|
|
813
|
+
])));
|
|
814
|
+
const endpointsWithServices = snapshot.endpoints.filter((endpoint) => endpoint.service_calls && endpoint.service_calls.length > 0);
|
|
815
|
+
lines.push("## Endpoint Service Dependencies\n\n");
|
|
816
|
+
if (endpointsWithServices.length === 0) {
|
|
817
|
+
lines.push("*None*\n\n");
|
|
818
|
+
}
|
|
819
|
+
else {
|
|
820
|
+
lines.push(renderTable(["Endpoint", "Services"], endpointsWithServices.map((endpoint) => [
|
|
821
|
+
`${endpoint.method} ${endpoint.path}`,
|
|
822
|
+
endpoint.service_calls.join(", ")
|
|
823
|
+
])));
|
|
824
|
+
}
|
|
825
|
+
lines.push("## Data Models\n\n");
|
|
826
|
+
lines.push(renderTable(["Name", "Framework", "File", "Fields"], snapshot.data_models.map((model) => [
|
|
827
|
+
model.name,
|
|
828
|
+
model.framework,
|
|
829
|
+
model.file,
|
|
830
|
+
model.fields.join(", ") || "none"
|
|
831
|
+
])));
|
|
832
|
+
lines.push("## Endpoint → Model Usage\n\n");
|
|
833
|
+
lines.push(renderTable(["Endpoint", "Models"], snapshot.endpoint_model_usage.map((usage) => [
|
|
834
|
+
usage.endpoint,
|
|
835
|
+
usage.models
|
|
836
|
+
.map((model) => `${model.name} (${model.access})`)
|
|
837
|
+
.join(", ") || "none"
|
|
838
|
+
])));
|
|
839
|
+
lines.push("## Tasks\n\n");
|
|
840
|
+
lines.push(renderTable(["Name", "Kind", "File", "Queue"], snapshot.tasks.map((task) => [
|
|
841
|
+
task.name,
|
|
842
|
+
task.kind,
|
|
843
|
+
task.file,
|
|
844
|
+
task.queue ?? "n/a"
|
|
845
|
+
])));
|
|
846
|
+
lines.push("## Runtime\n\n");
|
|
847
|
+
lines.push("### Dockerfiles\n\n");
|
|
848
|
+
lines.push(bullet(snapshot.runtime.dockerfiles));
|
|
849
|
+
lines.push("\n### Services\n\n");
|
|
850
|
+
lines.push(renderTable(["Service", "Image/Build", "Ports", "Depends On"], snapshot.runtime.services.map((service) => [
|
|
851
|
+
service.name,
|
|
852
|
+
service.image ?? service.build ?? "n/a",
|
|
853
|
+
(service.ports ?? []).join(", ") || "n/a",
|
|
854
|
+
(service.depends_on ?? []).join(", ") || "n/a"
|
|
855
|
+
])));
|
|
856
|
+
if (snapshot.analysis.unused_endpoints.length > 0) {
|
|
857
|
+
lines.push("## Unused Endpoints (Dead Code)\n\n");
|
|
858
|
+
lines.push("The following backend endpoints are not called by any known frontend code:\n\n");
|
|
859
|
+
lines.push(bullet(snapshot.analysis.unused_endpoints));
|
|
860
|
+
lines.push("\n");
|
|
861
|
+
}
|
|
862
|
+
if (snapshot.analysis.frontend_unused_api_calls && snapshot.analysis.frontend_unused_api_calls.length > 0) {
|
|
863
|
+
lines.push("## Orphaned Frontend API Calls\n\n");
|
|
864
|
+
lines.push("The following API calls made by the frontend do not have a matching backend endpoint:\n\n");
|
|
865
|
+
lines.push(bullet(snapshot.analysis.frontend_unused_api_calls));
|
|
866
|
+
lines.push("\n");
|
|
867
|
+
}
|
|
868
|
+
lines.push("## Backend Interaction Map\n\n");
|
|
869
|
+
const backendLines = ["flowchart LR\n"];
|
|
870
|
+
const endpointUsage = new Map(snapshot.endpoint_model_usage.map((usage) => [usage.endpoint_id, usage.models]));
|
|
871
|
+
const apiNodes = [];
|
|
872
|
+
const fnNodes = [];
|
|
873
|
+
const svcNodes = [];
|
|
874
|
+
const dbNodes = [];
|
|
875
|
+
const edgeLines = [];
|
|
876
|
+
const seenApiNodes = new Set();
|
|
877
|
+
const seenFnNodes = new Set();
|
|
878
|
+
const seenSvcNodes = new Set();
|
|
879
|
+
const seenDbNodes = new Set();
|
|
880
|
+
const addApiNode = (id, label) => {
|
|
881
|
+
if (seenApiNodes.has(id)) {
|
|
882
|
+
return;
|
|
883
|
+
}
|
|
884
|
+
seenApiNodes.add(id);
|
|
885
|
+
apiNodes.push(` ${id}["${mermaidLabel(label)}"]\n`);
|
|
886
|
+
};
|
|
887
|
+
const addFnNode = (id, label) => {
|
|
888
|
+
if (seenFnNodes.has(id)) {
|
|
889
|
+
return;
|
|
890
|
+
}
|
|
891
|
+
seenFnNodes.add(id);
|
|
892
|
+
fnNodes.push(` ${id}["${mermaidLabel(label)}"]\n`);
|
|
893
|
+
};
|
|
894
|
+
const addDbNode = (id, label) => {
|
|
895
|
+
if (seenDbNodes.has(id)) {
|
|
896
|
+
return;
|
|
897
|
+
}
|
|
898
|
+
seenDbNodes.add(id);
|
|
899
|
+
dbNodes.push(` ${id}["${mermaidLabel(label)}"]\n`);
|
|
900
|
+
};
|
|
901
|
+
const addSvcNode = (id, label) => {
|
|
902
|
+
if (seenSvcNodes.has(id)) {
|
|
903
|
+
return;
|
|
904
|
+
}
|
|
905
|
+
seenSvcNodes.add(id);
|
|
906
|
+
svcNodes.push(` ${id}["${mermaidLabel(label)}"]\n`);
|
|
907
|
+
};
|
|
908
|
+
for (const endpoint of snapshot.endpoints) {
|
|
909
|
+
const apiId = `api_${mermaidId(endpoint.id)}`;
|
|
910
|
+
const handlerKey = `${endpoint.file}#${endpoint.handler}`;
|
|
911
|
+
const handlerId = `fn_${mermaidId(handlerKey)}`;
|
|
912
|
+
addApiNode(apiId, `API ${endpoint.method} ${endpoint.path}`);
|
|
913
|
+
addFnNode(handlerId, `Fn ${endpoint.handler}`);
|
|
914
|
+
edgeLines.push(` ${apiId} --> ${handlerId}\n`);
|
|
915
|
+
const models = endpointUsage.get(endpoint.id) ?? [];
|
|
916
|
+
for (const model of models) {
|
|
917
|
+
const modelId = `db_${mermaidId(model.name)}`;
|
|
918
|
+
addDbNode(modelId, `DB ${model.name}`);
|
|
919
|
+
edgeLines.push(` ${handlerId} -- ${accessLabel(model.access)} --> ${modelId}\n`);
|
|
920
|
+
}
|
|
921
|
+
if (endpoint.service_calls && endpoint.service_calls.length > 0) {
|
|
922
|
+
for (const svc of endpoint.service_calls) {
|
|
923
|
+
const svcId = `svc_${mermaidId(svc)}`;
|
|
924
|
+
addSvcNode(svcId, `Svc ${svc}`);
|
|
925
|
+
edgeLines.push(` ${handlerId} --> ${svcId}\n`);
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
backendLines.push(' subgraph API["API"]\n', ...apiNodes, " end\n");
|
|
930
|
+
backendLines.push(' subgraph Functions["Functions"]\n', ...fnNodes, " end\n");
|
|
931
|
+
backendLines.push(' subgraph Services["Services"]\n', ...svcNodes, " end\n");
|
|
932
|
+
backendLines.push(' subgraph Data["Data"]\n', ...dbNodes, " end\n");
|
|
933
|
+
backendLines.push(...edgeLines);
|
|
934
|
+
lines.push(renderMermaid(backendLines));
|
|
935
|
+
lines.push("## Module Interaction Maps\n\n");
|
|
936
|
+
const endpointsByModule = new Map();
|
|
937
|
+
for (const endpoint of snapshot.endpoints) {
|
|
938
|
+
const list = endpointsByModule.get(endpoint.module) ?? [];
|
|
939
|
+
list.push(endpoint);
|
|
940
|
+
endpointsByModule.set(endpoint.module, list);
|
|
941
|
+
}
|
|
942
|
+
for (const module of snapshot.modules) {
|
|
943
|
+
const moduleEndpoints = endpointsByModule.get(module.id) ?? [];
|
|
944
|
+
lines.push(`### ${module.id}\n\n`);
|
|
945
|
+
if (moduleEndpoints.length === 0) {
|
|
946
|
+
lines.push("*None*\n\n");
|
|
947
|
+
continue;
|
|
948
|
+
}
|
|
949
|
+
const moduleLines = ["flowchart LR\n"];
|
|
950
|
+
const moduleApiNodes = [];
|
|
951
|
+
const moduleFnNodes = [];
|
|
952
|
+
const moduleSvcNodes = [];
|
|
953
|
+
const moduleDbNodes = [];
|
|
954
|
+
const moduleEdges = [];
|
|
955
|
+
const seenModuleApiNodes = new Set();
|
|
956
|
+
const seenModuleFnNodes = new Set();
|
|
957
|
+
const seenModuleSvcNodes = new Set();
|
|
958
|
+
const seenModuleDbNodes = new Set();
|
|
959
|
+
const addModuleApi = (id, label) => {
|
|
960
|
+
if (seenModuleApiNodes.has(id)) {
|
|
961
|
+
return;
|
|
962
|
+
}
|
|
963
|
+
seenModuleApiNodes.add(id);
|
|
964
|
+
moduleApiNodes.push(` ${id}["${mermaidLabel(label)}"]\n`);
|
|
965
|
+
};
|
|
966
|
+
const addModuleFn = (id, label) => {
|
|
967
|
+
if (seenModuleFnNodes.has(id)) {
|
|
968
|
+
return;
|
|
969
|
+
}
|
|
970
|
+
seenModuleFnNodes.add(id);
|
|
971
|
+
moduleFnNodes.push(` ${id}["${mermaidLabel(label)}"]\n`);
|
|
972
|
+
};
|
|
973
|
+
const addModuleDb = (id, label) => {
|
|
974
|
+
if (seenModuleDbNodes.has(id)) {
|
|
975
|
+
return;
|
|
976
|
+
}
|
|
977
|
+
seenModuleDbNodes.add(id);
|
|
978
|
+
moduleDbNodes.push(` ${id}["${mermaidLabel(label)}"]\n`);
|
|
979
|
+
};
|
|
980
|
+
const addModuleSvc = (id, label) => {
|
|
981
|
+
if (seenModuleSvcNodes.has(id)) {
|
|
982
|
+
return;
|
|
983
|
+
}
|
|
984
|
+
seenModuleSvcNodes.add(id);
|
|
985
|
+
moduleSvcNodes.push(` ${id}["${mermaidLabel(label)}"]\n`);
|
|
986
|
+
};
|
|
987
|
+
for (const endpoint of moduleEndpoints) {
|
|
988
|
+
const apiId = `api_${mermaidId(endpoint.id)}`;
|
|
989
|
+
const handlerKey = `${endpoint.file}#${endpoint.handler}`;
|
|
990
|
+
const handlerId = `fn_${mermaidId(handlerKey)}`;
|
|
991
|
+
addModuleApi(apiId, `API ${endpoint.method} ${endpoint.path}`);
|
|
992
|
+
addModuleFn(handlerId, `Fn ${endpoint.handler}`);
|
|
993
|
+
moduleEdges.push(` ${apiId} --> ${handlerId}\n`);
|
|
994
|
+
const models = endpointUsage.get(endpoint.id) ?? [];
|
|
995
|
+
for (const model of models) {
|
|
996
|
+
const modelId = `db_${mermaidId(model.name)}`;
|
|
997
|
+
addModuleDb(modelId, `DB ${model.name}`);
|
|
998
|
+
moduleEdges.push(` ${handlerId} -- ${accessLabel(model.access)} --> ${modelId}\n`);
|
|
999
|
+
}
|
|
1000
|
+
if (endpoint.service_calls && endpoint.service_calls.length > 0) {
|
|
1001
|
+
for (const svc of endpoint.service_calls) {
|
|
1002
|
+
const svcId = `svc_${mermaidId(svc)}`;
|
|
1003
|
+
addModuleSvc(svcId, `Svc ${svc}`);
|
|
1004
|
+
moduleEdges.push(` ${handlerId} --> ${svcId}\n`);
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
moduleLines.push(' subgraph API["API"]\n', ...moduleApiNodes, " end\n");
|
|
1009
|
+
moduleLines.push(' subgraph Functions["Functions"]\n', ...moduleFnNodes, " end\n");
|
|
1010
|
+
moduleLines.push(' subgraph Services["Services"]\n', ...moduleSvcNodes, " end\n");
|
|
1011
|
+
moduleLines.push(' subgraph Data["Data"]\n', ...moduleDbNodes, " end\n");
|
|
1012
|
+
moduleLines.push(...moduleEdges);
|
|
1013
|
+
lines.push(renderMermaid(moduleLines));
|
|
1014
|
+
}
|
|
1015
|
+
return lines.join("");
|
|
1016
|
+
}
|
|
1017
|
+
function renderUx(snapshot) {
|
|
1018
|
+
const lines = [];
|
|
1019
|
+
lines.push(section("UX Snapshot"));
|
|
1020
|
+
lines.push("## Components\n\n");
|
|
1021
|
+
lines.push(renderTable(["Component", "File", "Props", "Import Style"], snapshot.components.map((component) => [
|
|
1022
|
+
component.name,
|
|
1023
|
+
component.file,
|
|
1024
|
+
formatComponentProps(component.props),
|
|
1025
|
+
component.export_kind
|
|
1026
|
+
])));
|
|
1027
|
+
lines.push("## Component Graph\n\n");
|
|
1028
|
+
const graphLines = ["flowchart LR\n"];
|
|
1029
|
+
for (const edge of snapshot.component_graph) {
|
|
1030
|
+
const from = mermaidId(edge.from);
|
|
1031
|
+
const to = mermaidId(edge.to);
|
|
1032
|
+
graphLines.push(` ${from} --> ${to}\n`);
|
|
1033
|
+
}
|
|
1034
|
+
lines.push(renderMermaid(graphLines));
|
|
1035
|
+
lines.push("## Pages\n\n");
|
|
1036
|
+
for (const page of snapshot.pages) {
|
|
1037
|
+
lines.push(`### ${page.path}\n\n`);
|
|
1038
|
+
lines.push(`Component: \`${page.component}\`\n\n`);
|
|
1039
|
+
lines.push("Components (Direct)\n\n");
|
|
1040
|
+
lines.push(bullet(page.components_direct));
|
|
1041
|
+
lines.push("\nComponents (Descendants)\n\n");
|
|
1042
|
+
lines.push(bullet(page.components_descendants));
|
|
1043
|
+
lines.push("\nLocal State\n\n");
|
|
1044
|
+
lines.push(bullet(page.local_state_variables));
|
|
1045
|
+
lines.push("\nAPI Calls\n\n");
|
|
1046
|
+
lines.push(bullet(page.api_calls));
|
|
1047
|
+
lines.push("\nComponent API Calls\n\n");
|
|
1048
|
+
lines.push(renderTable(["Component", "API Calls"], page.component_api_calls.map((entry) => [
|
|
1049
|
+
entry.component,
|
|
1050
|
+
entry.api_calls.join(", ") || "none"
|
|
1051
|
+
])));
|
|
1052
|
+
lines.push("\nComponent State\n\n");
|
|
1053
|
+
lines.push(renderTable(["Component", "State Variables"], page.component_state_variables.map((entry) => [
|
|
1054
|
+
entry.component,
|
|
1055
|
+
entry.local_state_variables.join(", ") || "none"
|
|
1056
|
+
])));
|
|
1057
|
+
lines.push("\nNavigation\n\n");
|
|
1058
|
+
lines.push(bullet(page.possible_navigation));
|
|
1059
|
+
lines.push("\n");
|
|
1060
|
+
}
|
|
1061
|
+
lines.push("## Page Interaction Maps\n\n");
|
|
1062
|
+
const componentNames = new Map(snapshot.components.map((component) => [component.id, component.name]));
|
|
1063
|
+
for (const page of snapshot.pages) {
|
|
1064
|
+
const pageNodes = [];
|
|
1065
|
+
const componentNodes = [];
|
|
1066
|
+
const apiNodes = [];
|
|
1067
|
+
const stateNodes = [];
|
|
1068
|
+
const actionNodes = [];
|
|
1069
|
+
const uiEdges = [];
|
|
1070
|
+
const seenUiNodes = new Set();
|
|
1071
|
+
const addUiNode = (id, label, category) => {
|
|
1072
|
+
if (seenUiNodes.has(id)) {
|
|
1073
|
+
return;
|
|
1074
|
+
}
|
|
1075
|
+
seenUiNodes.add(id);
|
|
1076
|
+
const entry = ` ${id}["${mermaidLabel(label)}"]\n`;
|
|
1077
|
+
if (category === "page") {
|
|
1078
|
+
pageNodes.push(entry);
|
|
1079
|
+
}
|
|
1080
|
+
else if (category === "api") {
|
|
1081
|
+
apiNodes.push(entry);
|
|
1082
|
+
}
|
|
1083
|
+
else if (category === "state") {
|
|
1084
|
+
stateNodes.push(entry);
|
|
1085
|
+
}
|
|
1086
|
+
else if (category === "action") {
|
|
1087
|
+
actionNodes.push(entry);
|
|
1088
|
+
}
|
|
1089
|
+
else {
|
|
1090
|
+
componentNodes.push(entry);
|
|
1091
|
+
}
|
|
1092
|
+
};
|
|
1093
|
+
const pageId = `page_${mermaidId(page.path)}`;
|
|
1094
|
+
addUiNode(pageId, `Page ${page.path}`, "page");
|
|
1095
|
+
const componentScope = new Set([
|
|
1096
|
+
page.component_id,
|
|
1097
|
+
...page.components_direct_ids,
|
|
1098
|
+
...page.components_descendants_ids
|
|
1099
|
+
]);
|
|
1100
|
+
for (const componentId of page.components_direct_ids) {
|
|
1101
|
+
const componentName = componentNames.get(componentId) ?? componentId;
|
|
1102
|
+
const nodeId = `comp_${mermaidId(componentId)}`;
|
|
1103
|
+
addUiNode(nodeId, `Component ${componentName}`, "component");
|
|
1104
|
+
uiEdges.push(` ${pageId} --> ${nodeId}\n`);
|
|
1105
|
+
}
|
|
1106
|
+
for (const entry of page.component_api_calls) {
|
|
1107
|
+
const componentId = entry.component_id;
|
|
1108
|
+
const componentName = entry.component;
|
|
1109
|
+
if (componentId && !componentScope.has(componentId)) {
|
|
1110
|
+
componentScope.add(componentId);
|
|
1111
|
+
}
|
|
1112
|
+
const componentNode = `comp_${mermaidId(componentId)}`;
|
|
1113
|
+
addUiNode(componentNode, `Component ${componentName}`, "component");
|
|
1114
|
+
for (const call of entry.api_calls) {
|
|
1115
|
+
const apiNode = `api_${mermaidId(call)}`;
|
|
1116
|
+
addUiNode(apiNode, `API ${call}`, "api");
|
|
1117
|
+
uiEdges.push(` ${componentNode} --> ${apiNode}\n`);
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
for (const entry of page.component_state_variables) {
|
|
1121
|
+
const componentId = entry.component_id;
|
|
1122
|
+
const componentName = entry.component;
|
|
1123
|
+
if (componentId && !componentScope.has(componentId)) {
|
|
1124
|
+
componentScope.add(componentId);
|
|
1125
|
+
}
|
|
1126
|
+
const componentNode = `comp_${mermaidId(componentId)}`;
|
|
1127
|
+
addUiNode(componentNode, `Component ${componentName}`, "component");
|
|
1128
|
+
for (const state of entry.local_state_variables) {
|
|
1129
|
+
const stateNode = `state_${mermaidId(componentId)}_${mermaidId(state)}`;
|
|
1130
|
+
addUiNode(stateNode, `State ${state}`, "state");
|
|
1131
|
+
uiEdges.push(` ${componentNode} --> ${stateNode}\n`);
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
for (const target of page.possible_navigation) {
|
|
1135
|
+
const navNode = `nav_${mermaidId(target)}`;
|
|
1136
|
+
addUiNode(navNode, `Action ${target}`, "action");
|
|
1137
|
+
uiEdges.push(` ${pageId} --> ${navNode}\n`);
|
|
1138
|
+
}
|
|
1139
|
+
for (const edge of snapshot.component_graph) {
|
|
1140
|
+
if (!componentScope.has(edge.from) && !componentScope.has(edge.to)) {
|
|
1141
|
+
continue;
|
|
1142
|
+
}
|
|
1143
|
+
const fromName = componentNames.get(edge.from) ?? edge.from;
|
|
1144
|
+
const toName = componentNames.get(edge.to) ?? edge.to;
|
|
1145
|
+
const fromId = `comp_${mermaidId(edge.from)}`;
|
|
1146
|
+
const toId = `comp_${mermaidId(edge.to)}`;
|
|
1147
|
+
addUiNode(fromId, `Component ${fromName}`, "component");
|
|
1148
|
+
addUiNode(toId, `Component ${toName}`, "component");
|
|
1149
|
+
uiEdges.push(` ${fromId} --> ${toId}\n`);
|
|
1150
|
+
}
|
|
1151
|
+
lines.push(`### ${page.path} Interaction Map\n\n`);
|
|
1152
|
+
const mapLines = ["flowchart LR\n"];
|
|
1153
|
+
mapLines.push(' subgraph Page["Page"]\n', ...pageNodes, " end\n");
|
|
1154
|
+
mapLines.push(' subgraph Components["Components"]\n', ...componentNodes, " end\n");
|
|
1155
|
+
mapLines.push(' subgraph API["API"]\n', ...apiNodes, " end\n");
|
|
1156
|
+
mapLines.push(' subgraph State["State"]\n', ...stateNodes, " end\n");
|
|
1157
|
+
mapLines.push(' subgraph Actions["Actions"]\n', ...actionNodes, " end\n");
|
|
1158
|
+
mapLines.push(...uiEdges);
|
|
1159
|
+
lines.push(renderMermaid(mapLines));
|
|
1160
|
+
}
|
|
1161
|
+
return lines.join("");
|
|
1162
|
+
}
|
|
1163
|
+
function renderHld(architecture, ux, driftHistory, meta) {
|
|
1164
|
+
const lines = [];
|
|
1165
|
+
const { orm, schemas } = splitModelsByFramework(architecture);
|
|
1166
|
+
lines.push(section("High-Level Design"));
|
|
1167
|
+
lines.push("## System Block Diagram\n\n");
|
|
1168
|
+
const blockLines = [
|
|
1169
|
+
"flowchart TB\n",
|
|
1170
|
+
' subgraph Frontend["Frontend"]\n',
|
|
1171
|
+
` FE_Pages["Pages: ${ux.pages.length}"]\n`,
|
|
1172
|
+
` FE_Components["Components: ${ux.components.length}"]\n`,
|
|
1173
|
+
" end\n",
|
|
1174
|
+
' subgraph Backend["Backend"]\n',
|
|
1175
|
+
` BE_Modules["Modules: ${architecture.modules.length}"]\n`,
|
|
1176
|
+
` BE_Endpoints["Endpoints: ${architecture.endpoints.length}"]\n`,
|
|
1177
|
+
" end\n",
|
|
1178
|
+
' subgraph Data["Data"]\n',
|
|
1179
|
+
` DATA_Models["Models: ${orm.length} ORM + ${schemas.length} schemas"]\n`,
|
|
1180
|
+
" end\n",
|
|
1181
|
+
' subgraph Runtime["Runtime"]\n',
|
|
1182
|
+
` RT_Services["Services: ${architecture.runtime.services.length}"]\n`,
|
|
1183
|
+
` RT_Tasks["Tasks: ${architecture.tasks.length}"]\n`,
|
|
1184
|
+
" end\n",
|
|
1185
|
+
" FE_Components --> BE_Endpoints\n",
|
|
1186
|
+
" BE_Endpoints --> DATA_Models\n",
|
|
1187
|
+
" BE_Endpoints --> RT_Services\n",
|
|
1188
|
+
" BE_Endpoints --> RT_Tasks\n"
|
|
1189
|
+
];
|
|
1190
|
+
lines.push(renderMermaid(blockLines));
|
|
1191
|
+
lines.push("## Drift Summary\n\n");
|
|
1192
|
+
lines.push(renderTable(["Status", "Graph", "D_t", "K_t", "Delta"], [[
|
|
1193
|
+
architecture.drift.status,
|
|
1194
|
+
architecture.drift.graph_level,
|
|
1195
|
+
architecture.drift.D_t.toFixed(4),
|
|
1196
|
+
architecture.drift.K_t.toFixed(4),
|
|
1197
|
+
architecture.drift.delta.toFixed(4)
|
|
1198
|
+
]]));
|
|
1199
|
+
lines.push(renderTable(["Entropy", "Cross-Layer", "Cycle Density", "Modularity Gap"], [[
|
|
1200
|
+
architecture.drift.metrics.entropy.toFixed(4),
|
|
1201
|
+
architecture.drift.metrics.cross_layer_ratio.toFixed(4),
|
|
1202
|
+
architecture.drift.metrics.cycle_density.toFixed(4),
|
|
1203
|
+
architecture.drift.metrics.modularity_gap.toFixed(4)
|
|
1204
|
+
]]));
|
|
1205
|
+
lines.push("## Architecture Fingerprint\n\n");
|
|
1206
|
+
if (meta?.summary) {
|
|
1207
|
+
lines.push(`Fingerprint: \`${meta.summary.fingerprint}\`\n\n`);
|
|
1208
|
+
lines.push(`Shape Fingerprint: \`${meta.summary.shape_fingerprint ?? "n/a"}\`\n\n`);
|
|
1209
|
+
lines.push("Legend: shape fingerprint changes indicate structural refactors (dependency pattern shifts), " +
|
|
1210
|
+
"while fingerprint changes with the same shape indicate additive changes.\n\n");
|
|
1211
|
+
}
|
|
1212
|
+
else {
|
|
1213
|
+
lines.push("*Not available*\n\n");
|
|
1214
|
+
}
|
|
1215
|
+
lines.push("## Structural Coupling Heatmap (Top Files)\n\n");
|
|
1216
|
+
lines.push("Coupling score reflects structural dependency pressure, not git recency or file churn.\n\n");
|
|
1217
|
+
const hldFileHeatmap = pickHeatmapEntries(meta?.heatmap, "file");
|
|
1218
|
+
if (hldFileHeatmap.length > 0) {
|
|
1219
|
+
lines.push(renderTable(["File", "Coupling Score", "Layer"], hldFileHeatmap.slice(0, 8).map((entry) => [
|
|
1220
|
+
entry.id,
|
|
1221
|
+
entry.score.toFixed(3),
|
|
1222
|
+
entry.layer
|
|
1223
|
+
])));
|
|
1224
|
+
}
|
|
1225
|
+
else {
|
|
1226
|
+
lines.push("*Not available*\n\n");
|
|
1227
|
+
}
|
|
1228
|
+
lines.push("## Structural Coupling Heatmap (Top Functions)\n\n");
|
|
1229
|
+
const hldFunctionHeatmap = pickHeatmapEntries(meta?.heatmap, "function");
|
|
1230
|
+
if (hldFunctionHeatmap.length > 0) {
|
|
1231
|
+
lines.push(renderTable(["Function", "Coupling Score", "Layer"], hldFunctionHeatmap.slice(0, 8).map((entry) => [
|
|
1232
|
+
entry.id,
|
|
1233
|
+
entry.score.toFixed(3),
|
|
1234
|
+
entry.layer
|
|
1235
|
+
])));
|
|
1236
|
+
}
|
|
1237
|
+
else {
|
|
1238
|
+
lines.push("*Not available*\n\n");
|
|
1239
|
+
}
|
|
1240
|
+
lines.push("## Multi-Scale Drift\n\n");
|
|
1241
|
+
lines.push(renderTable(["Level", "Status", "Delta", "D_t", "K_t", "Edges", "Nodes"], architecture.drift.scales.map((scale) => [
|
|
1242
|
+
scale.level,
|
|
1243
|
+
scale.status,
|
|
1244
|
+
scale.delta.toFixed(4),
|
|
1245
|
+
scale.D_t.toFixed(4),
|
|
1246
|
+
scale.K_t.toFixed(4),
|
|
1247
|
+
String(scale.details.edges),
|
|
1248
|
+
String(scale.details.nodes)
|
|
1249
|
+
])));
|
|
1250
|
+
const capacityHasBudget = architecture.drift.capacity.layers.some((layer) => layer.status !== "unbudgeted") ||
|
|
1251
|
+
(architecture.drift.capacity.total && architecture.drift.capacity.total.status !== "unbudgeted");
|
|
1252
|
+
lines.push("## Capacity Summary\n\n");
|
|
1253
|
+
if (!capacityHasBudget) {
|
|
1254
|
+
lines.push("*Capacity budgets not configured*\n\n");
|
|
1255
|
+
}
|
|
1256
|
+
else {
|
|
1257
|
+
if (architecture.drift.capacity.layers.length > 0) {
|
|
1258
|
+
lines.push(renderTable(["Layer", "Budget", "Used", "Ratio", "Status"], architecture.drift.capacity.layers.map((layer) => [
|
|
1259
|
+
layer.layer,
|
|
1260
|
+
layer.budget !== undefined ? String(layer.budget) : "n/a",
|
|
1261
|
+
String(layer.edges),
|
|
1262
|
+
layer.ratio !== undefined ? layer.ratio.toFixed(2) : "n/a",
|
|
1263
|
+
layer.status
|
|
1264
|
+
])));
|
|
1265
|
+
}
|
|
1266
|
+
if (architecture.drift.capacity.total && architecture.drift.capacity.total.budget !== undefined) {
|
|
1267
|
+
lines.push(renderTable(["Total Budget", "Used", "Ratio", "Status"], [[
|
|
1268
|
+
String(architecture.drift.capacity.total.budget),
|
|
1269
|
+
String(architecture.drift.capacity.total.used),
|
|
1270
|
+
architecture.drift.capacity.total.ratio !== undefined
|
|
1271
|
+
? architecture.drift.capacity.total.ratio.toFixed(2)
|
|
1272
|
+
: "n/a",
|
|
1273
|
+
architecture.drift.capacity.total.status
|
|
1274
|
+
]]));
|
|
1275
|
+
}
|
|
1276
|
+
}
|
|
1277
|
+
lines.push("## Growth Summary\n\n");
|
|
1278
|
+
lines.push(renderTable(["Edges/Day", "Trend", "Status"], [[
|
|
1279
|
+
architecture.drift.growth.edges_per_day.toFixed(2),
|
|
1280
|
+
architecture.drift.growth.trend,
|
|
1281
|
+
architecture.drift.growth.status
|
|
1282
|
+
]]));
|
|
1283
|
+
lines.push("## Drift Trend\n\n");
|
|
1284
|
+
if (driftHistory.length < 2) {
|
|
1285
|
+
lines.push("*No drift history available*\n\n");
|
|
1286
|
+
}
|
|
1287
|
+
else {
|
|
1288
|
+
const recent = driftHistory.slice(-20);
|
|
1289
|
+
const labels = recent.map((entry) => `"${formatTimestamp(entry.timestamp)}"`);
|
|
1290
|
+
const deltaValues = recent.map((entry) => round(entry.delta, 3));
|
|
1291
|
+
const dtValues = recent.map((entry) => round(entry.D_t, 3));
|
|
1292
|
+
const allValues = [...deltaValues, ...dtValues];
|
|
1293
|
+
const minValue = Math.min(...allValues);
|
|
1294
|
+
const maxValue = Math.max(...allValues);
|
|
1295
|
+
const padding = minValue === maxValue ? 1 : Math.max(0.1, Math.abs(maxValue - minValue) * 0.1);
|
|
1296
|
+
const yMin = round(minValue - padding, 3);
|
|
1297
|
+
const yMax = round(maxValue + padding, 3);
|
|
1298
|
+
lines.push(renderMermaid([
|
|
1299
|
+
"xychart-beta\n",
|
|
1300
|
+
" title \"Drift Trend\"\n",
|
|
1301
|
+
` x-axis [${labels.join(", ")}]\n`,
|
|
1302
|
+
` y-axis \"Value\" ${yMin} --> ${yMax}\n`,
|
|
1303
|
+
` line \"Delta\" [${deltaValues.join(", ")}]\n`,
|
|
1304
|
+
` line \"D_t\" [${dtValues.join(", ")}]\n`
|
|
1305
|
+
]));
|
|
1306
|
+
}
|
|
1307
|
+
lines.push("## Backend Subsystems\n\n");
|
|
1308
|
+
const modulesById = new Map(architecture.modules.map((module) => [module.id, module]));
|
|
1309
|
+
const findModuleForFile = (file) => {
|
|
1310
|
+
let best = null;
|
|
1311
|
+
for (const module of architecture.modules) {
|
|
1312
|
+
const prefix = module.path.endsWith("/") ? module.path : `${module.path}/`;
|
|
1313
|
+
if (file.startsWith(prefix)) {
|
|
1314
|
+
if (!best || module.path.length > best.path.length) {
|
|
1315
|
+
best = module;
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1319
|
+
return best;
|
|
1320
|
+
};
|
|
1321
|
+
const groupKeyForFile = (module, file) => {
|
|
1322
|
+
const prefix = module.path.endsWith("/") ? module.path : `${module.path}/`;
|
|
1323
|
+
const relative = file.startsWith(prefix) ? file.slice(prefix.length) : file;
|
|
1324
|
+
const segments = relative.split("/").filter(Boolean);
|
|
1325
|
+
if (segments.length === 0) {
|
|
1326
|
+
return "root";
|
|
1327
|
+
}
|
|
1328
|
+
if (segments.length === 1 && segments[0].includes(".")) {
|
|
1329
|
+
return "root";
|
|
1330
|
+
}
|
|
1331
|
+
return segments[0];
|
|
1332
|
+
};
|
|
1333
|
+
const modelByName = new Map(architecture.data_models.map((model) => [model.name, model]));
|
|
1334
|
+
const endpointById = new Map(architecture.endpoints.map((endpoint) => [endpoint.id, endpoint]));
|
|
1335
|
+
for (const module of architecture.modules) {
|
|
1336
|
+
const groupStats = new Map();
|
|
1337
|
+
const edgeAccess = new Map();
|
|
1338
|
+
const ensureGroup = (group) => {
|
|
1339
|
+
if (!groupStats.has(group)) {
|
|
1340
|
+
groupStats.set(group, { files: 0, endpoints: 0, models: 0 });
|
|
1341
|
+
}
|
|
1342
|
+
};
|
|
1343
|
+
for (const file of module.files) {
|
|
1344
|
+
const group = groupKeyForFile(module, file);
|
|
1345
|
+
ensureGroup(group);
|
|
1346
|
+
groupStats.get(group).files += 1;
|
|
1347
|
+
}
|
|
1348
|
+
for (const endpoint of architecture.endpoints) {
|
|
1349
|
+
if (endpoint.module !== module.id) {
|
|
1350
|
+
continue;
|
|
1351
|
+
}
|
|
1352
|
+
const group = groupKeyForFile(module, endpoint.file);
|
|
1353
|
+
ensureGroup(group);
|
|
1354
|
+
groupStats.get(group).endpoints += 1;
|
|
1355
|
+
}
|
|
1356
|
+
for (const model of architecture.data_models) {
|
|
1357
|
+
const modelModule = findModuleForFile(model.file);
|
|
1358
|
+
if (!modelModule || modelModule.id !== module.id) {
|
|
1359
|
+
continue;
|
|
1360
|
+
}
|
|
1361
|
+
const group = groupKeyForFile(module, model.file);
|
|
1362
|
+
ensureGroup(group);
|
|
1363
|
+
groupStats.get(group).models += 1;
|
|
1364
|
+
}
|
|
1365
|
+
for (const usage of architecture.endpoint_model_usage) {
|
|
1366
|
+
const endpoint = endpointById.get(usage.endpoint_id);
|
|
1367
|
+
if (!endpoint || endpoint.module !== module.id) {
|
|
1368
|
+
continue;
|
|
1369
|
+
}
|
|
1370
|
+
const endpointGroup = groupKeyForFile(module, endpoint.file);
|
|
1371
|
+
for (const modelUsage of usage.models) {
|
|
1372
|
+
const model = modelByName.get(modelUsage.name);
|
|
1373
|
+
if (!model) {
|
|
1374
|
+
continue;
|
|
1375
|
+
}
|
|
1376
|
+
const modelModule = findModuleForFile(model.file);
|
|
1377
|
+
if (!modelModule || modelModule.id !== module.id) {
|
|
1378
|
+
continue;
|
|
1379
|
+
}
|
|
1380
|
+
const modelGroup = groupKeyForFile(module, model.file);
|
|
1381
|
+
if (endpointGroup === modelGroup) {
|
|
1382
|
+
continue;
|
|
1383
|
+
}
|
|
1384
|
+
const key = `${endpointGroup}::${modelGroup}`;
|
|
1385
|
+
const entry = edgeAccess.get(key) ?? { read: false, write: false };
|
|
1386
|
+
if (modelUsage.access === "read" || modelUsage.access === "read_write") {
|
|
1387
|
+
entry.read = true;
|
|
1388
|
+
}
|
|
1389
|
+
if (modelUsage.access === "write" || modelUsage.access === "read_write") {
|
|
1390
|
+
entry.write = true;
|
|
1391
|
+
}
|
|
1392
|
+
edgeAccess.set(key, entry);
|
|
1393
|
+
}
|
|
1394
|
+
}
|
|
1395
|
+
lines.push(`### ${module.id}\n\n`);
|
|
1396
|
+
if (groupStats.size === 0) {
|
|
1397
|
+
lines.push("*None*\n\n");
|
|
1398
|
+
continue;
|
|
1399
|
+
}
|
|
1400
|
+
// Collect actual entity names per group
|
|
1401
|
+
const groupEndpoints = new Map(); // group → handler names
|
|
1402
|
+
const groupModels = new Map(); // group → model names
|
|
1403
|
+
const groupClasses = new Map(); // group → exported class/function names
|
|
1404
|
+
for (const endpoint of architecture.endpoints) {
|
|
1405
|
+
if (endpoint.module !== module.id)
|
|
1406
|
+
continue;
|
|
1407
|
+
const group = groupKeyForFile(module, endpoint.file);
|
|
1408
|
+
const eps = groupEndpoints.get(group) ?? [];
|
|
1409
|
+
eps.push(`${endpoint.method} ${endpoint.path}`);
|
|
1410
|
+
groupEndpoints.set(group, eps);
|
|
1411
|
+
}
|
|
1412
|
+
for (const model of architecture.data_models) {
|
|
1413
|
+
const modelModule = findModuleForFile(model.file);
|
|
1414
|
+
if (!modelModule || modelModule.id !== module.id)
|
|
1415
|
+
continue;
|
|
1416
|
+
const group = groupKeyForFile(module, model.file);
|
|
1417
|
+
const models = groupModels.get(group) ?? [];
|
|
1418
|
+
models.push(model.name);
|
|
1419
|
+
groupModels.set(group, models);
|
|
1420
|
+
}
|
|
1421
|
+
for (const exportInfo of module.exports) {
|
|
1422
|
+
const group = groupKeyForFile(module, exportInfo.file);
|
|
1423
|
+
const classes = groupClasses.get(group) ?? [];
|
|
1424
|
+
for (const symbol of exportInfo.symbols) {
|
|
1425
|
+
// Only include PascalCase names (classes) or significant names
|
|
1426
|
+
if (/^[A-Z]/.test(symbol) && !classes.includes(symbol)) {
|
|
1427
|
+
classes.push(symbol);
|
|
1428
|
+
}
|
|
1429
|
+
}
|
|
1430
|
+
groupClasses.set(group, classes);
|
|
1431
|
+
}
|
|
1432
|
+
const moduleLines = ["flowchart LR\n"];
|
|
1433
|
+
const groupNodes = [];
|
|
1434
|
+
for (const [group, stats] of groupStats.entries()) {
|
|
1435
|
+
const nodeId = `grp_${mermaidId(`${module.id}_${group}`)}`;
|
|
1436
|
+
const entities = [];
|
|
1437
|
+
// Show class/function names instead of just counts
|
|
1438
|
+
const classes = groupClasses.get(group) ?? [];
|
|
1439
|
+
const models = groupModels.get(group) ?? [];
|
|
1440
|
+
const eps = groupEndpoints.get(group) ?? [];
|
|
1441
|
+
if (classes.length > 0) {
|
|
1442
|
+
entities.push(classes.slice(0, 4).join(", ") + (classes.length > 4 ? ` +${classes.length - 4}` : ""));
|
|
1443
|
+
}
|
|
1444
|
+
else if (models.length > 0) {
|
|
1445
|
+
entities.push(models.slice(0, 3).join(", ") + (models.length > 3 ? ` +${models.length - 3}` : ""));
|
|
1446
|
+
}
|
|
1447
|
+
if (eps.length > 0 && entities.length === 0) {
|
|
1448
|
+
entities.push(eps.slice(0, 2).join(", ") + (eps.length > 2 ? ` +${eps.length - 2}` : ""));
|
|
1449
|
+
}
|
|
1450
|
+
const label = entities.length > 0
|
|
1451
|
+
? `${group}\\n${entities.join("\\n")}`
|
|
1452
|
+
: `${group} · ${stats.files} files`;
|
|
1453
|
+
groupNodes.push(` ${nodeId}["${mermaidLabel(label)}"]\n`);
|
|
1454
|
+
}
|
|
1455
|
+
moduleLines.push(` subgraph ${mermaidId(module.id)}["${mermaidLabel(module.id)}"]\n`);
|
|
1456
|
+
moduleLines.push(...groupNodes);
|
|
1457
|
+
moduleLines.push(" end\n");
|
|
1458
|
+
for (const [key, access] of edgeAccess.entries()) {
|
|
1459
|
+
const [from, to] = key.split("::");
|
|
1460
|
+
const fromId = `grp_${mermaidId(`${module.id}_${from}`)}`;
|
|
1461
|
+
const toId = `grp_${mermaidId(`${module.id}_${to}`)}`;
|
|
1462
|
+
const label = access.read && access.write ? "read/write" : access.read ? "read" : "write";
|
|
1463
|
+
moduleLines.push(` ${fromId} -- ${label} --> ${toId}\n`);
|
|
1464
|
+
}
|
|
1465
|
+
lines.push(renderMermaid(moduleLines));
|
|
1466
|
+
}
|
|
1467
|
+
lines.push("## Module Dependency Graph\n\n");
|
|
1468
|
+
const moduleLines = ["flowchart LR\n"];
|
|
1469
|
+
const layerBuckets = new Map();
|
|
1470
|
+
for (const module of architecture.modules) {
|
|
1471
|
+
const nodeId = `mod_${mermaidId(module.id)}`;
|
|
1472
|
+
const entry = ` ${nodeId}["${mermaidLabel(module.id)}"]\n`;
|
|
1473
|
+
const bucket = layerBuckets.get(module.layer) ?? [];
|
|
1474
|
+
bucket.push(entry);
|
|
1475
|
+
layerBuckets.set(module.layer, bucket);
|
|
1476
|
+
}
|
|
1477
|
+
const orderedLayers = [
|
|
1478
|
+
"core",
|
|
1479
|
+
"middle",
|
|
1480
|
+
"top",
|
|
1481
|
+
"isolated"
|
|
1482
|
+
];
|
|
1483
|
+
for (const layer of orderedLayers) {
|
|
1484
|
+
const bucket = layerBuckets.get(layer);
|
|
1485
|
+
if (!bucket || bucket.length === 0) {
|
|
1486
|
+
continue;
|
|
1487
|
+
}
|
|
1488
|
+
moduleLines.push(` subgraph ${layer}["${layer.toUpperCase()}"]\n`, ...bucket, " end\n");
|
|
1489
|
+
}
|
|
1490
|
+
const edgeSet = new Set();
|
|
1491
|
+
for (const edge of architecture.dependencies.module_graph) {
|
|
1492
|
+
const key = `${edge.from}-->${edge.to}`;
|
|
1493
|
+
if (edgeSet.has(key)) {
|
|
1494
|
+
continue;
|
|
1495
|
+
}
|
|
1496
|
+
edgeSet.add(key);
|
|
1497
|
+
const fromId = `mod_${mermaidId(edge.from)}`;
|
|
1498
|
+
const toId = `mod_${mermaidId(edge.to)}`;
|
|
1499
|
+
moduleLines.push(` ${fromId} --> ${toId}\n`);
|
|
1500
|
+
}
|
|
1501
|
+
lines.push(renderMermaid(moduleLines));
|
|
1502
|
+
lines.push("## API Domain Map\n\n");
|
|
1503
|
+
const domainLines = ["flowchart LR\n"];
|
|
1504
|
+
const domainNodes = [];
|
|
1505
|
+
const pageNodes = [];
|
|
1506
|
+
const domainEdges = [];
|
|
1507
|
+
const seenDomainNodes = new Set();
|
|
1508
|
+
const seenPageNodes = new Set();
|
|
1509
|
+
const seenDomainEdges = new Set();
|
|
1510
|
+
const toDomain = (value) => {
|
|
1511
|
+
const pathPart = value.includes(" ") ? value.split(" ").slice(1).join(" ") : value;
|
|
1512
|
+
const clean = pathPart.split("?")[0];
|
|
1513
|
+
const segments = clean.split("/").filter(Boolean);
|
|
1514
|
+
if (segments.length === 0) {
|
|
1515
|
+
return null;
|
|
1516
|
+
}
|
|
1517
|
+
const first = segments.find((segment) => !segment.startsWith("{") && !segment.startsWith(":"));
|
|
1518
|
+
if (!first) {
|
|
1519
|
+
return null;
|
|
1520
|
+
}
|
|
1521
|
+
if (first === "api" && segments.length > 1) {
|
|
1522
|
+
const second = segments.slice(1).find((segment) => !segment.startsWith("{") && !segment.startsWith(":"));
|
|
1523
|
+
return second ? `api/${second}` : "api";
|
|
1524
|
+
}
|
|
1525
|
+
return first;
|
|
1526
|
+
};
|
|
1527
|
+
for (const page of ux.pages) {
|
|
1528
|
+
const pageId = `page_${mermaidId(page.path)}`;
|
|
1529
|
+
if (!seenPageNodes.has(pageId)) {
|
|
1530
|
+
seenPageNodes.add(pageId);
|
|
1531
|
+
pageNodes.push(` ${pageId}["${mermaidLabel(page.path)}"]\n`);
|
|
1532
|
+
}
|
|
1533
|
+
const pageDomains = new Set();
|
|
1534
|
+
for (const call of page.api_calls) {
|
|
1535
|
+
const domain = toDomain(call);
|
|
1536
|
+
if (domain) {
|
|
1537
|
+
pageDomains.add(domain);
|
|
1538
|
+
}
|
|
1539
|
+
}
|
|
1540
|
+
for (const domain of pageDomains) {
|
|
1541
|
+
const domainId = `domain_${mermaidId(domain)}`;
|
|
1542
|
+
if (!seenDomainNodes.has(domainId)) {
|
|
1543
|
+
seenDomainNodes.add(domainId);
|
|
1544
|
+
domainNodes.push(` ${domainId}["${mermaidLabel(domain)}"]\n`);
|
|
1545
|
+
}
|
|
1546
|
+
const key = `${pageId}::${domainId}`;
|
|
1547
|
+
if (!seenDomainEdges.has(key)) {
|
|
1548
|
+
seenDomainEdges.add(key);
|
|
1549
|
+
domainEdges.push(` ${pageId} --> ${domainId}\n`);
|
|
1550
|
+
}
|
|
1551
|
+
}
|
|
1552
|
+
}
|
|
1553
|
+
domainLines.push(' subgraph Pages["Pages"]\n', ...pageNodes, " end\n");
|
|
1554
|
+
domainLines.push(' subgraph Domains["API Domains"]\n', ...domainNodes, " end\n");
|
|
1555
|
+
domainLines.push(...domainEdges);
|
|
1556
|
+
lines.push(renderMermaid(domainLines));
|
|
1557
|
+
lines.push("## Data Domain Summary\n\n");
|
|
1558
|
+
const dataRows = [];
|
|
1559
|
+
const modelGroups = new Map();
|
|
1560
|
+
for (const model of architecture.data_models) {
|
|
1561
|
+
const module = findModuleForFile(model.file);
|
|
1562
|
+
const group = module ? `${module.id}/${groupKeyForFile(module, model.file)}` : "unknown";
|
|
1563
|
+
const entry = modelGroups.get(group) ?? { count: 0, files: new Set() };
|
|
1564
|
+
entry.count += 1;
|
|
1565
|
+
entry.files.add(model.file);
|
|
1566
|
+
modelGroups.set(group, entry);
|
|
1567
|
+
}
|
|
1568
|
+
for (const [group, stats] of modelGroups.entries()) {
|
|
1569
|
+
dataRows.push([group, String(stats.count), String(stats.files.size)]);
|
|
1570
|
+
}
|
|
1571
|
+
lines.push(renderTable(["Group", "Models", "Files"], dataRows));
|
|
1572
|
+
lines.push("## Runtime Services\n\n");
|
|
1573
|
+
const runtimeLines = ["flowchart LR\n"];
|
|
1574
|
+
const serviceNodes = [];
|
|
1575
|
+
const taskNodes = [];
|
|
1576
|
+
const runtimeEdges = [];
|
|
1577
|
+
const runtimeNodeSet = new Set();
|
|
1578
|
+
const addRuntimeNode = (bucket, id, label) => {
|
|
1579
|
+
if (runtimeNodeSet.has(id)) {
|
|
1580
|
+
return;
|
|
1581
|
+
}
|
|
1582
|
+
runtimeNodeSet.add(id);
|
|
1583
|
+
const entry = ` ${id}["${mermaidLabel(label)}"]\n`;
|
|
1584
|
+
if (bucket === "task") {
|
|
1585
|
+
taskNodes.push(entry);
|
|
1586
|
+
}
|
|
1587
|
+
else {
|
|
1588
|
+
serviceNodes.push(entry);
|
|
1589
|
+
}
|
|
1590
|
+
};
|
|
1591
|
+
for (const service of architecture.runtime.services) {
|
|
1592
|
+
const serviceId = `svc_${mermaidId(service.name)}`;
|
|
1593
|
+
addRuntimeNode("service", serviceId, service.name);
|
|
1594
|
+
for (const dep of service.depends_on ?? []) {
|
|
1595
|
+
const depId = `svc_${mermaidId(dep)}`;
|
|
1596
|
+
addRuntimeNode("service", depId, dep);
|
|
1597
|
+
runtimeEdges.push(` ${serviceId} --> ${depId}\n`);
|
|
1598
|
+
}
|
|
1599
|
+
}
|
|
1600
|
+
for (const task of architecture.tasks) {
|
|
1601
|
+
const taskId = `task_${mermaidId(`${task.kind}_${task.name}`)}`;
|
|
1602
|
+
addRuntimeNode("task", taskId, `${task.kind}: ${task.name}`);
|
|
1603
|
+
}
|
|
1604
|
+
runtimeLines.push(' subgraph Services["Services"]\n', ...serviceNodes, " end\n");
|
|
1605
|
+
runtimeLines.push(' subgraph Tasks["Tasks"]\n', ...taskNodes, " end\n");
|
|
1606
|
+
runtimeLines.push(...runtimeEdges);
|
|
1607
|
+
lines.push(renderMermaid(runtimeLines));
|
|
1608
|
+
return lines.join("");
|
|
1609
|
+
}
|
|
1610
|
+
function renderLld(architecture, ux, mode = "full") {
|
|
1611
|
+
const lines = [];
|
|
1612
|
+
lines.push(section("Low-Level Design"));
|
|
1613
|
+
if (mode === "full") {
|
|
1614
|
+
lines.push("## Endpoint Inventory\n\n");
|
|
1615
|
+
lines.push(renderTable(["Method", "Path", "Handler", "Module", "Request", "Response", "Services", "AI Ops"], architecture.endpoints.map((endpoint) => [
|
|
1616
|
+
endpoint.method,
|
|
1617
|
+
endpoint.path,
|
|
1618
|
+
endpoint.handler,
|
|
1619
|
+
endpoint.module,
|
|
1620
|
+
endpoint.request_schema || "none",
|
|
1621
|
+
endpoint.response_schema || "none",
|
|
1622
|
+
endpoint.service_calls.join(", ") || "none",
|
|
1623
|
+
formatAiOperations(endpoint.ai_operations)
|
|
1624
|
+
])));
|
|
1625
|
+
}
|
|
1626
|
+
lines.push("## Backend Interaction Maps\n\n");
|
|
1627
|
+
const endpointUsage = new Map(architecture.endpoint_model_usage.map((usage) => [usage.endpoint_id, usage.models]));
|
|
1628
|
+
const endpointsByModule = new Map();
|
|
1629
|
+
for (const endpoint of architecture.endpoints) {
|
|
1630
|
+
const list = endpointsByModule.get(endpoint.module) ?? [];
|
|
1631
|
+
list.push(endpoint);
|
|
1632
|
+
endpointsByModule.set(endpoint.module, list);
|
|
1633
|
+
}
|
|
1634
|
+
for (const module of architecture.modules) {
|
|
1635
|
+
const moduleEndpoints = endpointsByModule.get(module.id) ?? [];
|
|
1636
|
+
lines.push(`### ${module.id}\n\n`);
|
|
1637
|
+
if (moduleEndpoints.length === 0) {
|
|
1638
|
+
lines.push("*None*\n\n");
|
|
1639
|
+
continue;
|
|
1640
|
+
}
|
|
1641
|
+
const moduleLines = ["flowchart LR\n"];
|
|
1642
|
+
const moduleApiNodes = [];
|
|
1643
|
+
const moduleFnNodes = [];
|
|
1644
|
+
const moduleDbNodes = [];
|
|
1645
|
+
const moduleEdges = [];
|
|
1646
|
+
const seenModuleApiNodes = new Set();
|
|
1647
|
+
const seenModuleFnNodes = new Set();
|
|
1648
|
+
const seenModuleDbNodes = new Set();
|
|
1649
|
+
const addModuleApi = (id, label) => {
|
|
1650
|
+
if (seenModuleApiNodes.has(id)) {
|
|
1651
|
+
return;
|
|
1652
|
+
}
|
|
1653
|
+
seenModuleApiNodes.add(id);
|
|
1654
|
+
moduleApiNodes.push(` ${id}["${mermaidLabel(label)}"]\n`);
|
|
1655
|
+
};
|
|
1656
|
+
const addModuleFn = (id, label) => {
|
|
1657
|
+
if (seenModuleFnNodes.has(id)) {
|
|
1658
|
+
return;
|
|
1659
|
+
}
|
|
1660
|
+
seenModuleFnNodes.add(id);
|
|
1661
|
+
moduleFnNodes.push(` ${id}["${mermaidLabel(label)}"]\n`);
|
|
1662
|
+
};
|
|
1663
|
+
const addModuleDb = (id, label) => {
|
|
1664
|
+
if (seenModuleDbNodes.has(id)) {
|
|
1665
|
+
return;
|
|
1666
|
+
}
|
|
1667
|
+
seenModuleDbNodes.add(id);
|
|
1668
|
+
moduleDbNodes.push(` ${id}["${mermaidLabel(label)}"]\n`);
|
|
1669
|
+
};
|
|
1670
|
+
for (const endpoint of moduleEndpoints) {
|
|
1671
|
+
const apiId = `api_${mermaidId(endpoint.id)}`;
|
|
1672
|
+
const handlerKey = `${endpoint.file}#${endpoint.handler}`;
|
|
1673
|
+
const handlerId = `fn_${mermaidId(handlerKey)}`;
|
|
1674
|
+
addModuleApi(apiId, `API ${endpoint.method} ${endpoint.path}`);
|
|
1675
|
+
addModuleFn(handlerId, `Fn ${endpoint.handler}`);
|
|
1676
|
+
moduleEdges.push(` ${apiId} --> ${handlerId}\n`);
|
|
1677
|
+
const models = endpointUsage.get(endpoint.id) ?? [];
|
|
1678
|
+
for (const model of models) {
|
|
1679
|
+
const modelId = `db_${mermaidId(model.name)}`;
|
|
1680
|
+
addModuleDb(modelId, `DB ${model.name}`);
|
|
1681
|
+
moduleEdges.push(` ${handlerId} -- ${accessLabel(model.access)} --> ${modelId}\n`);
|
|
1682
|
+
}
|
|
1683
|
+
}
|
|
1684
|
+
moduleLines.push(' subgraph API["API"]\n', ...moduleApiNodes, " end\n");
|
|
1685
|
+
moduleLines.push(' subgraph Functions["Functions"]\n', ...moduleFnNodes, " end\n");
|
|
1686
|
+
moduleLines.push(' subgraph Data["Data"]\n', ...moduleDbNodes, " end\n");
|
|
1687
|
+
moduleLines.push(...moduleEdges);
|
|
1688
|
+
lines.push(renderMermaid(moduleLines));
|
|
1689
|
+
}
|
|
1690
|
+
lines.push("## UI Interaction Maps\n\n");
|
|
1691
|
+
const componentNames = new Map(ux.components.map((component) => [component.id, component.name]));
|
|
1692
|
+
for (const page of ux.pages) {
|
|
1693
|
+
const pageNodes = [];
|
|
1694
|
+
const componentNodes = [];
|
|
1695
|
+
const apiNodes = [];
|
|
1696
|
+
const stateNodes = [];
|
|
1697
|
+
const actionNodes = [];
|
|
1698
|
+
const uiEdges = [];
|
|
1699
|
+
const seenUiNodes = new Set();
|
|
1700
|
+
const addUiNode = (id, label, category) => {
|
|
1701
|
+
if (seenUiNodes.has(id)) {
|
|
1702
|
+
return;
|
|
1703
|
+
}
|
|
1704
|
+
seenUiNodes.add(id);
|
|
1705
|
+
const entry = ` ${id}["${mermaidLabel(label)}"]\n`;
|
|
1706
|
+
if (category === "page") {
|
|
1707
|
+
pageNodes.push(entry);
|
|
1708
|
+
}
|
|
1709
|
+
else if (category === "api") {
|
|
1710
|
+
apiNodes.push(entry);
|
|
1711
|
+
}
|
|
1712
|
+
else if (category === "state") {
|
|
1713
|
+
stateNodes.push(entry);
|
|
1714
|
+
}
|
|
1715
|
+
else if (category === "action") {
|
|
1716
|
+
actionNodes.push(entry);
|
|
1717
|
+
}
|
|
1718
|
+
else {
|
|
1719
|
+
componentNodes.push(entry);
|
|
1720
|
+
}
|
|
1721
|
+
};
|
|
1722
|
+
const pageId = `page_${mermaidId(page.path)}`;
|
|
1723
|
+
addUiNode(pageId, `Page ${page.path}`, "page");
|
|
1724
|
+
const componentScope = new Set([
|
|
1725
|
+
page.component_id,
|
|
1726
|
+
...page.components_direct_ids,
|
|
1727
|
+
...page.components_descendants_ids
|
|
1728
|
+
]);
|
|
1729
|
+
for (const componentId of page.components_direct_ids) {
|
|
1730
|
+
const componentName = componentNames.get(componentId) ?? componentId;
|
|
1731
|
+
const nodeId = `comp_${mermaidId(componentId)}`;
|
|
1732
|
+
addUiNode(nodeId, `Component ${componentName}`, "component");
|
|
1733
|
+
uiEdges.push(` ${pageId} --> ${nodeId}\n`);
|
|
1734
|
+
}
|
|
1735
|
+
for (const entry of page.component_api_calls) {
|
|
1736
|
+
const componentId = entry.component_id;
|
|
1737
|
+
const componentName = entry.component;
|
|
1738
|
+
const componentNode = `comp_${mermaidId(componentId)}`;
|
|
1739
|
+
addUiNode(componentNode, `Component ${componentName}`, "component");
|
|
1740
|
+
for (const call of entry.api_calls) {
|
|
1741
|
+
const apiNode = `api_${mermaidId(call)}`;
|
|
1742
|
+
addUiNode(apiNode, `API ${call}`, "api");
|
|
1743
|
+
uiEdges.push(` ${componentNode} --> ${apiNode}\n`);
|
|
1744
|
+
}
|
|
1745
|
+
}
|
|
1746
|
+
for (const entry of page.component_state_variables) {
|
|
1747
|
+
const componentId = entry.component_id;
|
|
1748
|
+
const componentName = entry.component;
|
|
1749
|
+
const componentNode = `comp_${mermaidId(componentId)}`;
|
|
1750
|
+
addUiNode(componentNode, `Component ${componentName}`, "component");
|
|
1751
|
+
for (const state of entry.local_state_variables) {
|
|
1752
|
+
const stateNode = `state_${mermaidId(componentId)}_${mermaidId(state)}`;
|
|
1753
|
+
addUiNode(stateNode, `State ${state}`, "state");
|
|
1754
|
+
uiEdges.push(` ${componentNode} --> ${stateNode}\n`);
|
|
1755
|
+
}
|
|
1756
|
+
}
|
|
1757
|
+
for (const target of page.possible_navigation) {
|
|
1758
|
+
const navNode = `nav_${mermaidId(target)}`;
|
|
1759
|
+
addUiNode(navNode, `Action ${target}`, "action");
|
|
1760
|
+
uiEdges.push(` ${pageId} --> ${navNode}\n`);
|
|
1761
|
+
}
|
|
1762
|
+
for (const edge of ux.component_graph) {
|
|
1763
|
+
if (!componentScope.has(edge.from) && !componentScope.has(edge.to)) {
|
|
1764
|
+
continue;
|
|
1765
|
+
}
|
|
1766
|
+
const fromName = componentNames.get(edge.from) ?? edge.from;
|
|
1767
|
+
const toName = componentNames.get(edge.to) ?? edge.to;
|
|
1768
|
+
const fromId = `comp_${mermaidId(edge.from)}`;
|
|
1769
|
+
const toId = `comp_${mermaidId(edge.to)}`;
|
|
1770
|
+
addUiNode(fromId, `Component ${fromName}`, "component");
|
|
1771
|
+
addUiNode(toId, `Component ${toName}`, "component");
|
|
1772
|
+
uiEdges.push(` ${fromId} --> ${toId}\n`);
|
|
1773
|
+
}
|
|
1774
|
+
lines.push(`### ${page.path}\n\n`);
|
|
1775
|
+
const mapLines = ["flowchart LR\n"];
|
|
1776
|
+
mapLines.push(' subgraph Page["Page"]\n', ...pageNodes, " end\n");
|
|
1777
|
+
mapLines.push(' subgraph Components["Components"]\n', ...componentNodes, " end\n");
|
|
1778
|
+
mapLines.push(' subgraph API["API"]\n', ...apiNodes, " end\n");
|
|
1779
|
+
mapLines.push(' subgraph State["State"]\n', ...stateNodes, " end\n");
|
|
1780
|
+
mapLines.push(' subgraph Actions["Actions"]\n', ...actionNodes, " end\n");
|
|
1781
|
+
mapLines.push(...uiEdges);
|
|
1782
|
+
lines.push(renderMermaid(mapLines));
|
|
1783
|
+
}
|
|
1784
|
+
return lines.join("");
|
|
1785
|
+
}
|
|
1786
|
+
function renderData(snapshot, mode = "full") {
|
|
1787
|
+
const lines = [];
|
|
1788
|
+
const { orm, schemas } = splitModelsByFramework(snapshot);
|
|
1789
|
+
lines.push(section("Data Flow"));
|
|
1790
|
+
lines.push(`Model inventory: ${orm.length} ORM models, ${schemas.length} Pydantic schemas.\n\n`);
|
|
1791
|
+
lines.push("## ORM Models\n\n");
|
|
1792
|
+
lines.push(renderTable(["Name", "Framework", "Fields", "Relationships"], orm.map((model) => [
|
|
1793
|
+
model.name,
|
|
1794
|
+
model.framework,
|
|
1795
|
+
model.fields.join(", ") || "none",
|
|
1796
|
+
model.relationships.join(", ") || "none"
|
|
1797
|
+
])));
|
|
1798
|
+
lines.push("## API Schemas\n\n");
|
|
1799
|
+
lines.push(renderTable(["Name", "Framework", "Fields", "Relationships"], schemas.map((model) => [
|
|
1800
|
+
model.name,
|
|
1801
|
+
model.framework,
|
|
1802
|
+
model.fields.join(", ") || "none",
|
|
1803
|
+
model.relationships.join(", ") || "none"
|
|
1804
|
+
])));
|
|
1805
|
+
if (mode === "lean") {
|
|
1806
|
+
return lines.join("");
|
|
1807
|
+
}
|
|
1808
|
+
lines.push("## Model Field Details\n\n");
|
|
1809
|
+
if (snapshot.data_models.length === 0) {
|
|
1810
|
+
lines.push("*None*\n\n");
|
|
1811
|
+
}
|
|
1812
|
+
else {
|
|
1813
|
+
for (const model of snapshot.data_models) {
|
|
1814
|
+
lines.push(`### ${model.name} [${model.framework}]\n\n`);
|
|
1815
|
+
if (!model.field_details || model.field_details.length === 0) {
|
|
1816
|
+
lines.push("*Not available*\n\n");
|
|
1817
|
+
continue;
|
|
1818
|
+
}
|
|
1819
|
+
lines.push(renderTable(["Field", "Type", "Nullable", "PK", "FK", "Enum", "Default"], model.field_details.map((field) => [
|
|
1820
|
+
field.name,
|
|
1821
|
+
field.type ?? "unknown",
|
|
1822
|
+
field.nullable === null || field.nullable === undefined ? "unknown" : field.nullable ? "yes" : "no",
|
|
1823
|
+
field.primary_key === null || field.primary_key === undefined
|
|
1824
|
+
? "unknown"
|
|
1825
|
+
: field.primary_key
|
|
1826
|
+
? "yes"
|
|
1827
|
+
: "no",
|
|
1828
|
+
field.foreign_key ?? "none",
|
|
1829
|
+
field.enum ?? "none",
|
|
1830
|
+
field.default ?? "none"
|
|
1831
|
+
])));
|
|
1832
|
+
}
|
|
1833
|
+
}
|
|
1834
|
+
lines.push("## Endpoint Usage\n\n");
|
|
1835
|
+
lines.push(renderTable(["Endpoint", "Models"], snapshot.endpoint_model_usage.map((usage) => [
|
|
1836
|
+
usage.endpoint,
|
|
1837
|
+
usage.models
|
|
1838
|
+
.map((model) => `${model.name} (${model.access})`)
|
|
1839
|
+
.join(", ") || "none"
|
|
1840
|
+
])));
|
|
1841
|
+
lines.push("## Data Flows\n\n");
|
|
1842
|
+
lines.push(bullet(snapshot.data_flows.map((flow) => `${flow.page} → ${flow.endpoint_id} → ${flow.models.join(", ") || "none"}`)));
|
|
1843
|
+
return lines.join("");
|
|
1844
|
+
}
|
|
1845
|
+
function renderRuntime(snapshot) {
|
|
1846
|
+
const lines = [];
|
|
1847
|
+
lines.push(section("Runtime"));
|
|
1848
|
+
lines.push("## Dockerfiles\n\n");
|
|
1849
|
+
lines.push(bullet(snapshot.runtime.dockerfiles));
|
|
1850
|
+
lines.push("\n## Services\n\n");
|
|
1851
|
+
lines.push(renderTable(["Service", "Source", "Ports", "Env"], snapshot.runtime.services.map((service) => [
|
|
1852
|
+
service.name,
|
|
1853
|
+
service.source,
|
|
1854
|
+
(service.ports ?? []).join(", ") || "n/a",
|
|
1855
|
+
(service.environment ?? []).join(", ") || "n/a"
|
|
1856
|
+
])));
|
|
1857
|
+
lines.push("## Tasks\n\n");
|
|
1858
|
+
lines.push(renderTable(["Name", "Kind", "Queue", "File"], snapshot.tasks.map((task) => [
|
|
1859
|
+
task.name,
|
|
1860
|
+
task.kind,
|
|
1861
|
+
task.queue ?? "n/a",
|
|
1862
|
+
task.file
|
|
1863
|
+
])));
|
|
1864
|
+
return lines.join("");
|
|
1865
|
+
}
|
|
1866
|
+
function renderInfra(snapshot) {
|
|
1867
|
+
const lines = [];
|
|
1868
|
+
lines.push(section("Infrastructure & Manifests"));
|
|
1869
|
+
if (snapshot.runtime.manifests && snapshot.runtime.manifests.length > 0) {
|
|
1870
|
+
lines.push("## System Manifests\n\n");
|
|
1871
|
+
for (const manifest of snapshot.runtime.manifests) {
|
|
1872
|
+
lines.push(`### ${manifest.file} [${manifest.kind}]\n\n`);
|
|
1873
|
+
if (manifest.description)
|
|
1874
|
+
lines.push(`**Description:** ${manifest.description}\n\n`);
|
|
1875
|
+
if (manifest.commands && manifest.commands.length > 0) {
|
|
1876
|
+
lines.push(`**Commands:** ${manifest.commands.join(", ")}\n\n`);
|
|
1877
|
+
}
|
|
1878
|
+
if (manifest.dependencies && manifest.dependencies.length > 0) {
|
|
1879
|
+
lines.push(`**Dependencies:** ${manifest.dependencies.length} entries\n\n`);
|
|
1880
|
+
}
|
|
1881
|
+
if (manifest.dev_dependencies && manifest.dev_dependencies.length > 0) {
|
|
1882
|
+
lines.push(`**Dev Dependencies:** ${manifest.dev_dependencies.length} entries\n\n`);
|
|
1883
|
+
}
|
|
1884
|
+
}
|
|
1885
|
+
}
|
|
1886
|
+
if (snapshot.runtime.shell_scripts && snapshot.runtime.shell_scripts.length > 0) {
|
|
1887
|
+
lines.push("## Shell Scripts\n\n");
|
|
1888
|
+
lines.push(bullet(snapshot.runtime.shell_scripts));
|
|
1889
|
+
}
|
|
1890
|
+
if (lines.length === 1) {
|
|
1891
|
+
lines.push("*No infrastructure configuration detected.*\n\n");
|
|
1892
|
+
}
|
|
1893
|
+
return lines.join("");
|
|
1894
|
+
}
|
|
1895
|
+
function renderDataDictionary(snapshot) {
|
|
1896
|
+
const lines = [];
|
|
1897
|
+
lines.push(section("Data Dictionary"));
|
|
1898
|
+
lines.push("## Model Fields\n\n");
|
|
1899
|
+
if (snapshot.data_models.length === 0) {
|
|
1900
|
+
lines.push("*None*\n\n");
|
|
1901
|
+
}
|
|
1902
|
+
else {
|
|
1903
|
+
for (const model of snapshot.data_models) {
|
|
1904
|
+
lines.push(`### ${model.name}\n\n`);
|
|
1905
|
+
if (model.field_details && model.field_details.length > 0) {
|
|
1906
|
+
lines.push(renderTable(["Field", "Type", "Nullable", "Primary Key", "Foreign Key", "Enum", "Default"], model.field_details.map((field) => [
|
|
1907
|
+
field.name,
|
|
1908
|
+
field.type ?? "n/a",
|
|
1909
|
+
field.nullable === null || typeof field.nullable === "undefined"
|
|
1910
|
+
? "n/a"
|
|
1911
|
+
: field.nullable
|
|
1912
|
+
? "yes"
|
|
1913
|
+
: "no",
|
|
1914
|
+
field.primary_key === null || typeof field.primary_key === "undefined"
|
|
1915
|
+
? "n/a"
|
|
1916
|
+
: field.primary_key
|
|
1917
|
+
? "yes"
|
|
1918
|
+
: "no",
|
|
1919
|
+
field.foreign_key ?? "n/a",
|
|
1920
|
+
field.enum ?? "n/a",
|
|
1921
|
+
field.default ?? "n/a"
|
|
1922
|
+
])));
|
|
1923
|
+
}
|
|
1924
|
+
else {
|
|
1925
|
+
lines.push(renderTable(["Field", "Type", "Nullable", "Primary Key", "Foreign Key", "Enum", "Default"], model.fields.map((field) => [field, "n/a", "n/a", "n/a", "n/a", "n/a", "n/a"])));
|
|
1926
|
+
}
|
|
1927
|
+
}
|
|
1928
|
+
}
|
|
1929
|
+
lines.push("## Backend Enums\n\n");
|
|
1930
|
+
lines.push(renderTable(["Enum Name", "File", "Values"], snapshot.enums.map((e) => [
|
|
1931
|
+
e.name,
|
|
1932
|
+
e.file,
|
|
1933
|
+
e.values.join(", ") || "none"
|
|
1934
|
+
])));
|
|
1935
|
+
const enumUsage = [];
|
|
1936
|
+
for (const model of snapshot.data_models) {
|
|
1937
|
+
for (const field of model.field_details ?? []) {
|
|
1938
|
+
if (field.enum) {
|
|
1939
|
+
enumUsage.push({
|
|
1940
|
+
enumName: field.enum,
|
|
1941
|
+
usage: `${model.name}.${field.name}`
|
|
1942
|
+
});
|
|
1943
|
+
}
|
|
1944
|
+
}
|
|
1945
|
+
}
|
|
1946
|
+
lines.push("## Enum Usage\n\n");
|
|
1947
|
+
if (enumUsage.length === 0) {
|
|
1948
|
+
lines.push("*None*\n\n");
|
|
1949
|
+
}
|
|
1950
|
+
else {
|
|
1951
|
+
lines.push(renderTable(["Enum", "Model.Field"], enumUsage.map((entry) => [entry.enumName, entry.usage])));
|
|
1952
|
+
}
|
|
1953
|
+
lines.push("## Global Constants\n\n");
|
|
1954
|
+
lines.push(renderTable(["Constant Name", "File", "Type", "Value"], snapshot.constants.map((c) => [
|
|
1955
|
+
c.name,
|
|
1956
|
+
c.file,
|
|
1957
|
+
c.type,
|
|
1958
|
+
c.value
|
|
1959
|
+
])));
|
|
1960
|
+
return lines.join("");
|
|
1961
|
+
}
|
|
1962
|
+
function renderTestCoverage(snapshot) {
|
|
1963
|
+
const lines = [];
|
|
1964
|
+
lines.push(section("Test Coverage Map"));
|
|
1965
|
+
const coverage = snapshot.analysis.test_coverage;
|
|
1966
|
+
if (!coverage) {
|
|
1967
|
+
return lines.join("");
|
|
1968
|
+
}
|
|
1969
|
+
lines.push("## Coverage Map\n\n");
|
|
1970
|
+
lines.push(renderTable(["Test File", "Source File", "Match Type"], coverage.coverage_map.map((c) => [
|
|
1971
|
+
c.test_file,
|
|
1972
|
+
c.source_file ?? "*Unmapped*",
|
|
1973
|
+
c.match_type
|
|
1974
|
+
])));
|
|
1975
|
+
if (snapshot.analysis.endpoint_test_coverage.length > 0) {
|
|
1976
|
+
lines.push("## Endpoint Coverage\n\n");
|
|
1977
|
+
lines.push(renderTable(["Endpoint", "File", "Covered", "Test Files"], snapshot.analysis.endpoint_test_coverage.map((entry) => [
|
|
1978
|
+
entry.endpoint,
|
|
1979
|
+
entry.file,
|
|
1980
|
+
entry.covered ? "yes" : "no",
|
|
1981
|
+
entry.test_files.join(", ") || "none"
|
|
1982
|
+
])));
|
|
1983
|
+
}
|
|
1984
|
+
if (snapshot.analysis.function_test_coverage.length > 0) {
|
|
1985
|
+
lines.push("## Function Coverage (File-Level)\n\n");
|
|
1986
|
+
lines.push(renderTable(["Function", "File", "Covered", "Test Files"], snapshot.analysis.function_test_coverage.slice(0, 200).map((entry) => [
|
|
1987
|
+
entry.function_id,
|
|
1988
|
+
entry.file,
|
|
1989
|
+
entry.covered ? "yes" : "no",
|
|
1990
|
+
entry.test_files.join(", ") || "none"
|
|
1991
|
+
])));
|
|
1992
|
+
if (snapshot.analysis.function_test_coverage.length > 200) {
|
|
1993
|
+
lines.push(`*Showing first 200 of ${snapshot.analysis.function_test_coverage.length} functions*\n\n`);
|
|
1994
|
+
}
|
|
1995
|
+
}
|
|
1996
|
+
if (coverage.untested_source_files.length > 0) {
|
|
1997
|
+
lines.push("## Untested Source Files\n\n");
|
|
1998
|
+
lines.push(bullet(coverage.untested_source_files));
|
|
1999
|
+
lines.push("\n");
|
|
2000
|
+
}
|
|
2001
|
+
if (coverage.test_files_missing_source.length > 0) {
|
|
2002
|
+
lines.push("## Unmapped Test Files\n\n");
|
|
2003
|
+
lines.push(bullet(coverage.test_files_missing_source));
|
|
2004
|
+
lines.push("\n");
|
|
2005
|
+
}
|
|
2006
|
+
return lines.join("");
|
|
2007
|
+
}
|
|
2008
|
+
function computeChangedEndpoints(previous, current) {
|
|
2009
|
+
const prevMap = new Map();
|
|
2010
|
+
for (const endpoint of previous.endpoints) {
|
|
2011
|
+
prevMap.set(endpointKey(endpoint), endpoint);
|
|
2012
|
+
}
|
|
2013
|
+
const changes = [];
|
|
2014
|
+
for (const endpoint of current.endpoints) {
|
|
2015
|
+
const key = endpointKey(endpoint);
|
|
2016
|
+
const prev = prevMap.get(key);
|
|
2017
|
+
if (!prev) {
|
|
2018
|
+
continue;
|
|
2019
|
+
}
|
|
2020
|
+
const entryChanges = [];
|
|
2021
|
+
if (prev.handler !== endpoint.handler) {
|
|
2022
|
+
entryChanges.push(`handler: ${prev.handler} → ${endpoint.handler}`);
|
|
2023
|
+
}
|
|
2024
|
+
if (prev.module !== endpoint.module) {
|
|
2025
|
+
entryChanges.push(`module: ${prev.module} → ${endpoint.module}`);
|
|
2026
|
+
}
|
|
2027
|
+
if ((prev.request_schema || "") !== (endpoint.request_schema || "")) {
|
|
2028
|
+
entryChanges.push(`request_schema: ${prev.request_schema || "none"} → ${endpoint.request_schema || "none"}`);
|
|
2029
|
+
}
|
|
2030
|
+
if ((prev.response_schema || "") !== (endpoint.response_schema || "")) {
|
|
2031
|
+
entryChanges.push(`response_schema: ${prev.response_schema || "none"} → ${endpoint.response_schema || "none"}`);
|
|
2032
|
+
}
|
|
2033
|
+
const serviceDiff = diffList(prev.service_calls, endpoint.service_calls);
|
|
2034
|
+
if (serviceDiff.added.length > 0 || serviceDiff.removed.length > 0) {
|
|
2035
|
+
entryChanges.push(`services: +${serviceDiff.added.join(", ") || "none"} -${serviceDiff.removed.join(", ") || "none"}`);
|
|
2036
|
+
}
|
|
2037
|
+
const prevAi = normalizeAiOps(prev.ai_operations);
|
|
2038
|
+
const currAi = normalizeAiOps(endpoint.ai_operations);
|
|
2039
|
+
if (prevAi.join("|") !== currAi.join("|")) {
|
|
2040
|
+
entryChanges.push(`ai_ops: ${prevAi.join(", ") || "none"} → ${currAi.join(", ") || "none"}`);
|
|
2041
|
+
}
|
|
2042
|
+
if (entryChanges.length > 0) {
|
|
2043
|
+
changes.push({ endpoint: key, changes: entryChanges });
|
|
2044
|
+
}
|
|
2045
|
+
}
|
|
2046
|
+
return changes.sort((a, b) => a.endpoint.localeCompare(b.endpoint));
|
|
2047
|
+
}
|
|
2048
|
+
function endpointKey(endpoint) {
|
|
2049
|
+
return `${endpoint.method.toUpperCase()} ${endpoint.path}`;
|
|
2050
|
+
}
|
|
2051
|
+
function diffList(previous, current) {
|
|
2052
|
+
const prevSet = new Set(previous ?? []);
|
|
2053
|
+
const currSet = new Set(current ?? []);
|
|
2054
|
+
const added = [];
|
|
2055
|
+
const removed = [];
|
|
2056
|
+
for (const entry of currSet) {
|
|
2057
|
+
if (!prevSet.has(entry)) {
|
|
2058
|
+
added.push(entry);
|
|
2059
|
+
}
|
|
2060
|
+
}
|
|
2061
|
+
for (const entry of prevSet) {
|
|
2062
|
+
if (!currSet.has(entry)) {
|
|
2063
|
+
removed.push(entry);
|
|
2064
|
+
}
|
|
2065
|
+
}
|
|
2066
|
+
added.sort((a, b) => a.localeCompare(b));
|
|
2067
|
+
removed.sort((a, b) => a.localeCompare(b));
|
|
2068
|
+
return { added, removed };
|
|
2069
|
+
}
|
|
2070
|
+
function normalizeAiOps(operations) {
|
|
2071
|
+
if (!operations || operations.length === 0) {
|
|
2072
|
+
return [];
|
|
2073
|
+
}
|
|
2074
|
+
return operations
|
|
2075
|
+
.map((op) => {
|
|
2076
|
+
const tokenBudget = op.token_budget ?? op.max_output_tokens ?? op.max_tokens;
|
|
2077
|
+
return [
|
|
2078
|
+
op.provider,
|
|
2079
|
+
op.operation,
|
|
2080
|
+
op.model ?? "none",
|
|
2081
|
+
typeof tokenBudget === "number" ? `tokens:${tokenBudget}` : "tokens:na"
|
|
2082
|
+
].join(":");
|
|
2083
|
+
})
|
|
2084
|
+
.sort();
|
|
2085
|
+
}
|
|
2086
|
+
function renderDiff(architecture, meta) {
|
|
2087
|
+
const lines = [];
|
|
2088
|
+
lines.push(section("Snapshot Changelog"));
|
|
2089
|
+
if (!meta?.diff) {
|
|
2090
|
+
lines.push("*No previous summary available. Run SpecGuard twice to generate a diff.*\n\n");
|
|
2091
|
+
return lines.join("");
|
|
2092
|
+
}
|
|
2093
|
+
lines.push(`Structural change: **${meta.diff.structural_change ? "yes" : "no"}** \n` +
|
|
2094
|
+
`Shape equivalent: **${meta.diff.shape_equivalent ? "yes" : "no"}**\n\n`);
|
|
2095
|
+
lines.push(renderTable(["Field", "Delta"], Object.entries(meta.diff.counts_delta).map(([field, delta]) => [field, String(delta)])));
|
|
2096
|
+
const sections = [
|
|
2097
|
+
"modules",
|
|
2098
|
+
"endpoints",
|
|
2099
|
+
"models",
|
|
2100
|
+
"pages",
|
|
2101
|
+
"components",
|
|
2102
|
+
"tasks",
|
|
2103
|
+
"runtime_services"
|
|
2104
|
+
];
|
|
2105
|
+
for (const key of sections) {
|
|
2106
|
+
const added = meta.diff.added[key] ?? [];
|
|
2107
|
+
const removed = meta.diff.removed[key] ?? [];
|
|
2108
|
+
lines.push(`## ${key.replace(/_/g, " ")}\n\n`);
|
|
2109
|
+
lines.push("### Added\n\n");
|
|
2110
|
+
lines.push(bullet(added.length > 0 ? added.slice(0, 20) : ["None"]));
|
|
2111
|
+
lines.push("\n### Removed\n\n");
|
|
2112
|
+
lines.push(bullet(removed.length > 0 ? removed.slice(0, 20) : ["None"]));
|
|
2113
|
+
}
|
|
2114
|
+
lines.push("## Changed Endpoints\n\n");
|
|
2115
|
+
if (!meta.previous) {
|
|
2116
|
+
lines.push("*Previous snapshot not available*\n\n");
|
|
2117
|
+
}
|
|
2118
|
+
else {
|
|
2119
|
+
const changes = computeChangedEndpoints(meta.previous, architecture);
|
|
2120
|
+
if (changes.length === 0) {
|
|
2121
|
+
lines.push("*None*\n\n");
|
|
2122
|
+
}
|
|
2123
|
+
else {
|
|
2124
|
+
lines.push(renderTable(["Endpoint", "Changes"], changes.map((change) => [change.endpoint, change.changes.join("; ")])));
|
|
2125
|
+
}
|
|
2126
|
+
}
|
|
2127
|
+
return lines.join("");
|
|
2128
|
+
}
|
|
2129
|
+
function renderIntegrationGuide(snapshot) {
|
|
2130
|
+
const lines = [];
|
|
2131
|
+
lines.push(section("Integration Guide"));
|
|
2132
|
+
if (snapshot.endpoints.length === 0) {
|
|
2133
|
+
lines.push("*No endpoints detected.*\n\n");
|
|
2134
|
+
return lines.join("");
|
|
2135
|
+
}
|
|
2136
|
+
const usageMap = new Map(snapshot.endpoint_model_usage.map((usage) => [usage.endpoint_id, usage.models]));
|
|
2137
|
+
const byDomain = new Map();
|
|
2138
|
+
for (const endpoint of snapshot.endpoints) {
|
|
2139
|
+
const domain = integrationDomainForPath(endpoint.path);
|
|
2140
|
+
const list = byDomain.get(domain) ?? [];
|
|
2141
|
+
list.push(endpoint);
|
|
2142
|
+
byDomain.set(domain, list);
|
|
2143
|
+
}
|
|
2144
|
+
const sortedDomains = Array.from(byDomain.keys()).sort((a, b) => a.localeCompare(b));
|
|
2145
|
+
for (const domain of sortedDomains) {
|
|
2146
|
+
lines.push(`## ${domain}\n\n`);
|
|
2147
|
+
const endpoints = byDomain.get(domain) ?? [];
|
|
2148
|
+
lines.push(renderTable(["Method", "Path", "Request", "Response", "Models", "Services", "AI Ops"], endpoints.map((endpoint) => [
|
|
2149
|
+
endpoint.method,
|
|
2150
|
+
endpoint.path,
|
|
2151
|
+
endpoint.request_schema || "none",
|
|
2152
|
+
endpoint.response_schema || "none",
|
|
2153
|
+
(usageMap.get(endpoint.id) ?? [])
|
|
2154
|
+
.map((model) => `${model.name} (${model.access})`)
|
|
2155
|
+
.join(", ") || "none",
|
|
2156
|
+
endpoint.service_calls.join(", ") || "none",
|
|
2157
|
+
formatAiOperations(endpoint.ai_operations)
|
|
2158
|
+
])));
|
|
2159
|
+
}
|
|
2160
|
+
lines.push("## Cross-Stack Contracts\n\n");
|
|
2161
|
+
if (snapshot.cross_stack_contracts.length === 0) {
|
|
2162
|
+
lines.push("*No matched frontend/backend contracts detected.*\n\n");
|
|
2163
|
+
}
|
|
2164
|
+
else {
|
|
2165
|
+
const verifiedContracts = snapshot.cross_stack_contracts.filter((contract) => contract.status === "ok" || contract.status === "mismatched");
|
|
2166
|
+
const mismatchedCount = verifiedContracts.filter((contract) => contract.status === "mismatched").length;
|
|
2167
|
+
const unverifiedCount = snapshot.cross_stack_contracts.length - verifiedContracts.length;
|
|
2168
|
+
lines.push(`Verified: ${verifiedContracts.length} contracts (${mismatchedCount} mismatched). Unverified: ${unverifiedCount}.`);
|
|
2169
|
+
lines.push(" Run `specguard extract --include-file-graph` for richer caller inference.\n\n");
|
|
2170
|
+
if (verifiedContracts.length === 0) {
|
|
2171
|
+
lines.push("*No verified frontend/backend contracts detected yet.*\n\n");
|
|
2172
|
+
}
|
|
2173
|
+
else {
|
|
2174
|
+
lines.push(renderTable(["Endpoint", "Status", "Backend Schema", "Frontend Fields", "Callers", "Issues"], verifiedContracts.map((contract) => [
|
|
2175
|
+
`${contract.method} ${contract.path}`,
|
|
2176
|
+
contract.status,
|
|
2177
|
+
contract.backend_request_schema || contract.backend_response_schema || "none",
|
|
2178
|
+
contract.frontend_request_fields.join(", ") || "none",
|
|
2179
|
+
contract.frontend_callers
|
|
2180
|
+
.map((caller) => `${caller.component} (${caller.file})`)
|
|
2181
|
+
.join(", ") || "none",
|
|
2182
|
+
contract.issues.join(", ") || "—"
|
|
2183
|
+
])));
|
|
2184
|
+
}
|
|
2185
|
+
}
|
|
2186
|
+
return lines.join("");
|
|
2187
|
+
}
|
|
2188
|
+
function integrationDomainForPath(endpointPath) {
|
|
2189
|
+
const segments = endpointPath.split("/").filter(Boolean);
|
|
2190
|
+
if (segments.length === 0) {
|
|
2191
|
+
return "/";
|
|
2192
|
+
}
|
|
2193
|
+
if ((segments[0] === "api" || segments[0] === "v1") && segments[1]) {
|
|
2194
|
+
return `/${segments[0]}/${segments[1]}`;
|
|
2195
|
+
}
|
|
2196
|
+
return `/${segments[0]}`;
|
|
2197
|
+
}
|
|
2198
|
+
export async function writeDocs(outputRoot, architecture, ux, options) {
|
|
2199
|
+
const docsMode = options?.docsMode ?? "lean";
|
|
2200
|
+
const internalDirName = options?.internalDir ?? "internal";
|
|
2201
|
+
const layout = getOutputLayout(outputRoot, internalDirName);
|
|
2202
|
+
await fs.mkdir(layout.machineDocsDir, { recursive: true });
|
|
2203
|
+
await fs.mkdir(layout.humanDir, { recursive: true });
|
|
2204
|
+
const driftHistory = await loadDriftHistory(layout.machineDir, options);
|
|
2205
|
+
const summary = await loadArchitectureSummary(layout.machineDir);
|
|
2206
|
+
const diff = await loadArchitectureDiff(layout.machineDir);
|
|
2207
|
+
const heatmap = await loadHeatmap(layout.machineDir);
|
|
2208
|
+
const leanFiles = [
|
|
2209
|
+
{
|
|
2210
|
+
name: "index.md",
|
|
2211
|
+
content: renderIndex(architecture, ux, {
|
|
2212
|
+
docsFiles: LEAN_INDEX_FILES,
|
|
2213
|
+
internalFiles: docsMode === "full"
|
|
2214
|
+
? FULL_INDEX_FILES.filter((file) => !LEAN_INDEX_FILES.includes(file))
|
|
2215
|
+
: [],
|
|
2216
|
+
internalDir: internalDirName
|
|
2217
|
+
})
|
|
2218
|
+
},
|
|
2219
|
+
{
|
|
2220
|
+
name: "summary.md",
|
|
2221
|
+
content: renderExecutiveSummary(architecture, ux, {
|
|
2222
|
+
summary,
|
|
2223
|
+
diff,
|
|
2224
|
+
heatmap,
|
|
2225
|
+
docsMode,
|
|
2226
|
+
internalDir: internalDirName
|
|
2227
|
+
})
|
|
2228
|
+
},
|
|
2229
|
+
{
|
|
2230
|
+
name: "stakeholder.md",
|
|
2231
|
+
content: renderStakeholderSummary(architecture, ux, {
|
|
2232
|
+
summary,
|
|
2233
|
+
diff,
|
|
2234
|
+
docsMode,
|
|
2235
|
+
internalDir: internalDirName
|
|
2236
|
+
})
|
|
2237
|
+
},
|
|
2238
|
+
{ name: "hld.md", content: renderHld(architecture, ux, driftHistory, { summary, diff, heatmap }) },
|
|
2239
|
+
{ name: "integration.md", content: renderIntegrationGuide(architecture) },
|
|
2240
|
+
{
|
|
2241
|
+
name: "diff.md",
|
|
2242
|
+
content: renderDiff(architecture, {
|
|
2243
|
+
summary,
|
|
2244
|
+
diff,
|
|
2245
|
+
previous: options?.previous?.architecture ?? null
|
|
2246
|
+
})
|
|
2247
|
+
},
|
|
2248
|
+
{ name: "runtime.md", content: renderRuntime(architecture) },
|
|
2249
|
+
{ name: "infra.md", content: renderInfra(architecture) },
|
|
2250
|
+
{ name: "ux.md", content: renderUx(ux) },
|
|
2251
|
+
{ name: "data.md", content: renderData(architecture, "lean") },
|
|
2252
|
+
{ name: "tests.md", content: renderTests(architecture) }
|
|
2253
|
+
];
|
|
2254
|
+
const fullFiles = [
|
|
2255
|
+
{ name: "index.md", content: renderIndex(architecture, ux, { docsFiles: FULL_INDEX_FILES }) },
|
|
2256
|
+
{
|
|
2257
|
+
name: "summary.md",
|
|
2258
|
+
content: renderExecutiveSummary(architecture, ux, {
|
|
2259
|
+
summary,
|
|
2260
|
+
diff,
|
|
2261
|
+
heatmap,
|
|
2262
|
+
docsMode: "full",
|
|
2263
|
+
internalDir: internalDirName
|
|
2264
|
+
})
|
|
2265
|
+
},
|
|
2266
|
+
{
|
|
2267
|
+
name: "stakeholder.md",
|
|
2268
|
+
content: renderStakeholderSummary(architecture, ux, {
|
|
2269
|
+
summary,
|
|
2270
|
+
diff,
|
|
2271
|
+
docsMode: "full",
|
|
2272
|
+
internalDir: internalDirName
|
|
2273
|
+
})
|
|
2274
|
+
},
|
|
2275
|
+
{ name: "architecture.md", content: renderArchitecture(architecture, { summary, diff, heatmap }) },
|
|
2276
|
+
{ name: "ux.md", content: renderUx(ux) },
|
|
2277
|
+
{ name: "data.md", content: renderData(architecture, "full") },
|
|
2278
|
+
{ name: "data_dictionary.md", content: renderDataDictionary(architecture) },
|
|
2279
|
+
{ name: "integration.md", content: renderIntegrationGuide(architecture) },
|
|
2280
|
+
{
|
|
2281
|
+
name: "diff.md",
|
|
2282
|
+
content: renderDiff(architecture, {
|
|
2283
|
+
summary,
|
|
2284
|
+
diff,
|
|
2285
|
+
previous: options?.previous?.architecture ?? null
|
|
2286
|
+
})
|
|
2287
|
+
},
|
|
2288
|
+
{ name: "test_coverage.md", content: renderTestCoverage(architecture) },
|
|
2289
|
+
{ name: "runtime.md", content: renderRuntime(architecture) },
|
|
2290
|
+
{ name: "infra.md", content: renderInfra(architecture) },
|
|
2291
|
+
{
|
|
2292
|
+
name: "hld.md",
|
|
2293
|
+
content: renderHld(architecture, ux, driftHistory, { summary, diff, heatmap })
|
|
2294
|
+
},
|
|
2295
|
+
{ name: "lld.md", content: renderLld(architecture, ux, "full") },
|
|
2296
|
+
{ name: "tests.md", content: renderTests(architecture) }
|
|
2297
|
+
];
|
|
2298
|
+
const written = [];
|
|
2299
|
+
await fs.writeFile(path.join(layout.rootDir, "README.md"), renderHumanRootReadme(architecture));
|
|
2300
|
+
written.push(path.join(layout.rootDir, "README.md"));
|
|
2301
|
+
for (const file of leanFiles) {
|
|
2302
|
+
const target = path.join(layout.machineDocsDir, file.name);
|
|
2303
|
+
await fs.writeFile(target, file.content);
|
|
2304
|
+
written.push(target);
|
|
2305
|
+
}
|
|
2306
|
+
if (docsMode === "full") {
|
|
2307
|
+
const internalDir = layout.machineInternalDir;
|
|
2308
|
+
await fs.mkdir(internalDir, { recursive: true });
|
|
2309
|
+
for (const file of fullFiles) {
|
|
2310
|
+
const target = path.join(internalDir, file.name);
|
|
2311
|
+
await fs.writeFile(target, file.content);
|
|
2312
|
+
written.push(target);
|
|
2313
|
+
}
|
|
2314
|
+
}
|
|
2315
|
+
const humanFiles = [
|
|
2316
|
+
{ name: "start-here.md", content: renderHumanStartHere(architecture, ux) },
|
|
2317
|
+
{
|
|
2318
|
+
name: "system-overview.md",
|
|
2319
|
+
content: renderHumanSystemOverview(architecture, ux, { heatmap })
|
|
2320
|
+
},
|
|
2321
|
+
{ name: "backend-overview.md", content: renderHumanBackendOverview(architecture) },
|
|
2322
|
+
{ name: "frontend-overview.md", content: renderHumanFrontendOverview(ux) },
|
|
2323
|
+
{ name: "data-and-flows.md", content: renderHumanDataAndFlows(architecture, ux) },
|
|
2324
|
+
{
|
|
2325
|
+
name: "change-guide.md",
|
|
2326
|
+
content: renderHumanChangeGuide(architecture, { diff, heatmap })
|
|
2327
|
+
}
|
|
2328
|
+
];
|
|
2329
|
+
for (const file of humanFiles) {
|
|
2330
|
+
const target = path.join(layout.humanDir, file.name);
|
|
2331
|
+
await fs.writeFile(target, file.content);
|
|
2332
|
+
written.push(target);
|
|
2333
|
+
}
|
|
2334
|
+
return written;
|
|
2335
|
+
}
|
|
2336
|
+
async function loadDriftHistory(machineDir, options) {
|
|
2337
|
+
const candidates = [];
|
|
2338
|
+
if (options?.driftHistoryPath) {
|
|
2339
|
+
const resolved = path.isAbsolute(options.driftHistoryPath)
|
|
2340
|
+
? options.driftHistoryPath
|
|
2341
|
+
: path.resolve(options.projectRoot ?? machineDir, options.driftHistoryPath);
|
|
2342
|
+
candidates.push(resolved);
|
|
2343
|
+
}
|
|
2344
|
+
if (options?.projectRoot) {
|
|
2345
|
+
candidates.push(path.resolve(options.projectRoot, "specs-out/machine/drift.history.jsonl"));
|
|
2346
|
+
candidates.push(path.resolve(options.projectRoot, "specs-out/drift.history.jsonl"));
|
|
2347
|
+
}
|
|
2348
|
+
candidates.push(path.join(machineDir, "drift.history.jsonl"));
|
|
2349
|
+
for (const candidate of candidates) {
|
|
2350
|
+
try {
|
|
2351
|
+
const raw = await fs.readFile(candidate, "utf8");
|
|
2352
|
+
const entries = [];
|
|
2353
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
2354
|
+
if (!line.trim()) {
|
|
2355
|
+
continue;
|
|
2356
|
+
}
|
|
2357
|
+
try {
|
|
2358
|
+
const parsed = JSON.parse(line);
|
|
2359
|
+
if (typeof parsed.timestamp === "string" &&
|
|
2360
|
+
typeof parsed.D_t === "number" &&
|
|
2361
|
+
typeof parsed.delta === "number") {
|
|
2362
|
+
entries.push(parsed);
|
|
2363
|
+
}
|
|
2364
|
+
}
|
|
2365
|
+
catch {
|
|
2366
|
+
continue;
|
|
2367
|
+
}
|
|
2368
|
+
}
|
|
2369
|
+
if (entries.length > 0) {
|
|
2370
|
+
entries.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
|
|
2371
|
+
return entries;
|
|
2372
|
+
}
|
|
2373
|
+
}
|
|
2374
|
+
catch {
|
|
2375
|
+
continue;
|
|
2376
|
+
}
|
|
2377
|
+
}
|
|
2378
|
+
return [];
|
|
2379
|
+
}
|