@toolbaux/guardian 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +366 -0
  3. package/dist/adapters/csharp-adapter.js +149 -0
  4. package/dist/adapters/go-adapter.js +96 -0
  5. package/dist/adapters/index.js +16 -0
  6. package/dist/adapters/java-adapter.js +122 -0
  7. package/dist/adapters/python-adapter.js +183 -0
  8. package/dist/adapters/runner.js +69 -0
  9. package/dist/adapters/types.js +1 -0
  10. package/dist/adapters/typescript-adapter.js +179 -0
  11. package/dist/benchmarking/framework.js +91 -0
  12. package/dist/cli.js +343 -0
  13. package/dist/commands/analyze-depth.js +43 -0
  14. package/dist/commands/api-spec-extractor.js +52 -0
  15. package/dist/commands/breaking-change-analyzer.js +334 -0
  16. package/dist/commands/config-compliance.js +219 -0
  17. package/dist/commands/constraints.js +221 -0
  18. package/dist/commands/context.js +101 -0
  19. package/dist/commands/data-flow-tracer.js +291 -0
  20. package/dist/commands/dependency-impact-analyzer.js +27 -0
  21. package/dist/commands/diff.js +146 -0
  22. package/dist/commands/discrepancy.js +71 -0
  23. package/dist/commands/doc-generate.js +163 -0
  24. package/dist/commands/doc-html.js +120 -0
  25. package/dist/commands/drift.js +88 -0
  26. package/dist/commands/extract.js +16 -0
  27. package/dist/commands/feature-context.js +116 -0
  28. package/dist/commands/generate.js +339 -0
  29. package/dist/commands/guard.js +182 -0
  30. package/dist/commands/init.js +209 -0
  31. package/dist/commands/intel.js +20 -0
  32. package/dist/commands/license-dependency-auditor.js +33 -0
  33. package/dist/commands/performance-hotspot-profiler.js +42 -0
  34. package/dist/commands/search.js +314 -0
  35. package/dist/commands/security-boundary-auditor.js +359 -0
  36. package/dist/commands/simulate.js +294 -0
  37. package/dist/commands/summary.js +27 -0
  38. package/dist/commands/test-coverage-mapper.js +264 -0
  39. package/dist/commands/verify-drift.js +62 -0
  40. package/dist/config.js +441 -0
  41. package/dist/extract/ai-context-hints.js +107 -0
  42. package/dist/extract/analyzers/backend.js +1704 -0
  43. package/dist/extract/analyzers/depth.js +264 -0
  44. package/dist/extract/analyzers/frontend.js +2221 -0
  45. package/dist/extract/api-usage-tracker.js +19 -0
  46. package/dist/extract/cache.js +53 -0
  47. package/dist/extract/codebase-intel.js +190 -0
  48. package/dist/extract/compress.js +452 -0
  49. package/dist/extract/context-block.js +356 -0
  50. package/dist/extract/contracts.js +183 -0
  51. package/dist/extract/discrepancies.js +233 -0
  52. package/dist/extract/docs-loader.js +110 -0
  53. package/dist/extract/docs.js +2379 -0
  54. package/dist/extract/drift.js +1578 -0
  55. package/dist/extract/duplicates.js +435 -0
  56. package/dist/extract/feature-arcs.js +138 -0
  57. package/dist/extract/graph.js +76 -0
  58. package/dist/extract/html-doc.js +1409 -0
  59. package/dist/extract/ignore.js +45 -0
  60. package/dist/extract/index.js +455 -0
  61. package/dist/extract/llm-client.js +159 -0
  62. package/dist/extract/pattern-registry.js +141 -0
  63. package/dist/extract/product-doc.js +497 -0
  64. package/dist/extract/python.js +1202 -0
  65. package/dist/extract/runtime.js +193 -0
  66. package/dist/extract/schema-evolution-validator.js +35 -0
  67. package/dist/extract/test-gap-analyzer.js +20 -0
  68. package/dist/extract/tests.js +74 -0
  69. package/dist/extract/types.js +1 -0
  70. package/dist/extract/validate-backend.js +30 -0
  71. package/dist/extract/writer.js +11 -0
  72. package/dist/output-layout.js +37 -0
  73. package/dist/project-discovery.js +309 -0
  74. package/dist/schema/architecture.js +350 -0
  75. package/dist/schema/feature-spec.js +89 -0
  76. package/dist/schema/index.js +8 -0
  77. package/dist/schema/ux.js +46 -0
  78. package/package.json +75 -0
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Pattern Registry — deterministically extracts implementation patterns from an ArchitectureSnapshot.
3
+ *
4
+ * Analogous to the ref book entity tables (FM/AT/MM) — a catalog of established patterns
5
+ * in the codebase that LLMs and developers can reference when implementing features.
6
+ *
7
+ * Patterns detected:
8
+ * P1 Service Delegation — endpoint delegates to a named service method
9
+ * P2 Auth-Gated Endpoint — endpoint file contains auth/permission decorators
10
+ * P3 LLM Operation — endpoint calls an AI provider
11
+ * P4 Background Task Dispatch— endpoint dispatches a celery/background task
12
+ * P5 CRUD Endpoint — endpoint is part of a standard CRUD set on a resource
13
+ * P6 Multi-Model Write — endpoint writes to 3+ models
14
+ * P7 Cross-Stack Contract — endpoint has a verified frontend caller
15
+ * P8 Paginated List — endpoint is a GET list with "page"/"limit" in path/query naming
16
+ */
17
+ export function buildPatternRegistry(architecture) {
18
+ const endpoints = architecture.endpoints;
19
+ const endpointModelUsage = architecture.endpoint_model_usage;
20
+ const crossStack = architecture.cross_stack_contracts ?? [];
21
+ const modelWriteMap = new Map();
22
+ for (const usage of endpointModelUsage) {
23
+ const writes = usage.models.filter((m) => m.access === "write" || m.access === "read_write").length;
24
+ modelWriteMap.set(usage.endpoint_id, writes);
25
+ }
26
+ const crossStackVerified = new Set(crossStack.filter((c) => c.status === "ok").map((c) => c.endpoint_id));
27
+ // P1 — Service Delegation
28
+ const p1 = endpoints.filter((ep) => ep.service_calls.length > 0);
29
+ // P2 — Auth-Gated (heuristic: file path contains "auth", "permission", "security", or handler mentions it)
30
+ const p2 = endpoints.filter((ep) => {
31
+ const lower = (ep.file + ep.handler).toLowerCase();
32
+ return (lower.includes("auth") ||
33
+ lower.includes("permission") ||
34
+ lower.includes("require_") ||
35
+ lower.includes("depends(get_current"));
36
+ });
37
+ // P3 — LLM Operation
38
+ const p3 = endpoints.filter((ep) => ep.ai_operations && ep.ai_operations.length > 0);
39
+ // P4 — Background Task Dispatch (service_calls referencing task patterns)
40
+ const p4 = endpoints.filter((ep) => ep.service_calls.some((s) => {
41
+ const lower = s.toLowerCase();
42
+ return (lower.includes("task") ||
43
+ lower.includes(".delay(") ||
44
+ lower.includes(".apply_async") ||
45
+ lower.includes("background"));
46
+ }));
47
+ // P5 — CRUD set: resource has GET list + GET detail + POST + PATCH/PUT + DELETE
48
+ const resourceMethods = new Map();
49
+ for (const ep of endpoints) {
50
+ // Normalise path to resource key: strip trailing /{id} variant
51
+ const resource = ep.path.replace(/\/\{[^}]+\}$/, "").replace(/\/:[^/]+$/, "");
52
+ const entry = resourceMethods.get(resource) ?? new Set();
53
+ entry.add(ep.method.toUpperCase());
54
+ resourceMethods.set(resource, entry);
55
+ }
56
+ const crudResources = new Set();
57
+ for (const [resource, methods] of resourceMethods) {
58
+ if (methods.has("GET") && methods.has("POST") && (methods.has("PATCH") || methods.has("PUT"))) {
59
+ crudResources.add(resource);
60
+ }
61
+ }
62
+ const p5 = endpoints.filter((ep) => {
63
+ const resource = ep.path.replace(/\/\{[^}]+\}$/, "").replace(/\/:[^/]+$/, "");
64
+ return crudResources.has(resource);
65
+ });
66
+ // P6 — Multi-Model Write (3+ model writes)
67
+ const p6 = endpoints.filter((ep) => (modelWriteMap.get(ep.id) ?? 0) >= 3);
68
+ // P7 — Cross-Stack Contract (verified frontend caller)
69
+ const p7 = endpoints.filter((ep) => crossStackVerified.has(ep.id));
70
+ // P8 — Paginated List (GET endpoints with "list" or "page" in name/path)
71
+ const p8 = endpoints.filter((ep) => {
72
+ if (ep.method.toUpperCase() !== "GET")
73
+ return false;
74
+ const lower = (ep.path + ep.handler).toLowerCase();
75
+ return lower.includes("list") || lower.includes("page") || lower.includes("paginate");
76
+ });
77
+ const definitions = [
78
+ {
79
+ id: "P1",
80
+ name: "Service Delegation",
81
+ description: "Endpoint delegates business logic to a named service method. Route handler is thin; all logic lives in service layer.",
82
+ matches: p1,
83
+ },
84
+ {
85
+ id: "P2",
86
+ name: "Auth-Gated Endpoint",
87
+ description: "Endpoint requires authentication or permission check before executing. Uses auth dependency injection or decorator.",
88
+ matches: p2,
89
+ },
90
+ {
91
+ id: "P3",
92
+ name: "LLM Operation",
93
+ description: "Endpoint calls an AI provider (OpenAI, Anthropic, etc.). May involve prompt construction, model selection, and token budgeting.",
94
+ matches: p3,
95
+ },
96
+ {
97
+ id: "P4",
98
+ name: "Background Task Dispatch",
99
+ description: "Endpoint dispatches work to a background task queue (Celery, FastAPI BackgroundTasks) and returns immediately.",
100
+ matches: p4,
101
+ },
102
+ {
103
+ id: "P5",
104
+ name: "CRUD Resource",
105
+ description: "Endpoint is part of a full CRUD set (GET list + GET detail + POST + PATCH/PUT + DELETE) on a single resource.",
106
+ matches: p5,
107
+ },
108
+ {
109
+ id: "P6",
110
+ name: "Multi-Model Write",
111
+ description: "Endpoint writes to 3 or more ORM models in a single request. Likely requires a transaction.",
112
+ matches: p6,
113
+ },
114
+ {
115
+ id: "P7",
116
+ name: "Cross-Stack Contract",
117
+ description: "Endpoint has a verified frontend caller with matching request/response fields. Schema contract is enforced.",
118
+ matches: p7,
119
+ },
120
+ {
121
+ id: "P8",
122
+ name: "Paginated List",
123
+ description: "GET endpoint returns a paginated collection. Supports page/limit/cursor parameters.",
124
+ matches: p8,
125
+ },
126
+ ];
127
+ const patterns = definitions.map(({ id, name, description, matches }) => ({
128
+ id,
129
+ name,
130
+ description,
131
+ occurrences: matches.length,
132
+ example_endpoints: matches
133
+ .slice(0, 3)
134
+ .map((ep) => `${ep.method} ${ep.path}`),
135
+ example_files: Array.from(new Set(matches.slice(0, 3).map((ep) => ep.file))),
136
+ }));
137
+ return {
138
+ generated_at: new Date().toISOString(),
139
+ patterns,
140
+ };
141
+ }
@@ -0,0 +1,497 @@
1
+ /**
2
+ * Product Document Renderer
3
+ *
4
+ * Generates a human-readable, navigable product document from codebase intelligence
5
+ * and existing guardian doc files (hld.md, integration.md, summary.md).
6
+ *
7
+ * Navigation features:
8
+ * - Auto-generated TOC with anchor links + domain/model sub-links
9
+ * - Collapsible <details> blocks for large tables (>15 rows)
10
+ * - Domain quick-index at top of API Surface
11
+ * - Back-to-top links at end of each major section
12
+ * - Section stats in headings (count, warnings)
13
+ *
14
+ * Sections:
15
+ * Overview ← LLM or fallback
16
+ * System Architecture ← Mermaid diagram + coupling heatmap from hld.md
17
+ * API Surface ← integration.md content (has schemas + model usage)
18
+ * Data Models ← grouped by module, collapsible per group
19
+ * Quality Signals ← orphans, duplicates, drift hotspots from summary.md
20
+ * Pattern Registry ← detected patterns
21
+ * Background Tasks ← task list
22
+ * Feature Timeline ← from feature arcs (if present)
23
+ * Frontend Pages ← from UX snapshot
24
+ * Discrepancies ← code vs spec diff
25
+ */
26
+ import { parseIntegrationDomains } from "./docs-loader.js";
27
+ import { llmComplete } from "./llm-client.js";
28
+ const SYSTEM_PROMPT = `You are a technical writer generating a product document section for a software engineering team.
29
+ Write concisely and precisely. No marketing language. No filler. Use plain English.
30
+ Respond with only the content for the section — no headers, no preamble.`;
31
+ const COLLAPSE_THRESHOLD = 15; // rows before wrapping in <details>
32
+ const TOP_LINK = `\n[↑ Top](#table-of-contents)\n`;
33
+ function logSection(name) {
34
+ process.stdout.write(` ${name}... `);
35
+ return (note) => console.log(note ?? "done");
36
+ }
37
+ // ── Anchor helpers ────────────────────────────────────────────────────────
38
+ function anchor(text) {
39
+ return text
40
+ .toLowerCase()
41
+ .replace(/[^a-z0-9\s-]/g, "")
42
+ .trim()
43
+ .replace(/\s+/g, "-");
44
+ }
45
+ function link(label, id) {
46
+ return `[${label}](#${anchor(id)})`;
47
+ }
48
+ // ── Collapsible block ─────────────────────────────────────────────────────
49
+ function collapsible(summary, content, open = false) {
50
+ const openAttr = open ? " open" : "";
51
+ return `<details${openAttr}>\n<summary><strong>${summary}</strong></summary>\n\n${content}\n\n</details>`;
52
+ }
53
+ // ── Main renderer ─────────────────────────────────────────────────────────
54
+ export async function renderProductDocument(options) {
55
+ const { intel, featureArcs, discrepancies, llmConfig, existingDocs } = options;
56
+ const useLlm = !!llmConfig;
57
+ const ts = new Date().toISOString().slice(0, 19).replace("T", " ");
58
+ const sections = [];
59
+ const parts = [];
60
+ // ── Header ────────────────────────────────────────────────────────────────
61
+ parts.push(`# Product Document — ${intel.meta.project}`);
62
+ parts.push(`_Auto-generated by \`guardian doc-generate\`. Do not edit manually._`);
63
+ parts.push(`_Updated: ${ts} UTC · ` +
64
+ `${intel.meta.counts.endpoints} endpoints · ` +
65
+ `${intel.meta.counts.models} models · ` +
66
+ `${intel.meta.counts.tasks} tasks · ` +
67
+ `${intel.meta.counts.pages} pages_`);
68
+ // Stakeholder metrics table if available
69
+ if (existingDocs?.stakeholderMetrics) {
70
+ parts.push("");
71
+ parts.push(existingDocs.stakeholderMetrics);
72
+ }
73
+ parts.push("");
74
+ // ── Collect section metadata for TOC ─────────────────────────────────────
75
+ const integrationDomains = existingDocs?.integrationByDomain
76
+ ? parseIntegrationDomains(existingDocs.integrationByDomain)
77
+ : null;
78
+ const domainNames = integrationDomains
79
+ ? Array.from(integrationDomains.keys()).sort()
80
+ : Object.keys(groupEndpointsByDomain(intel)).sort();
81
+ const modelGroups = groupModelsByModule(intel);
82
+ const groupNames = Array.from(modelGroups.keys()).sort();
83
+ const hasArcs = !!(featureArcs && Object.keys(featureArcs.arcs).length > 0);
84
+ const hasPages = intel.frontend_pages.length > 0;
85
+ const hasTasks = intel.background_tasks.length > 0;
86
+ const hasDiscrepancies = !!discrepancies;
87
+ sections.push({ id: "overview", label: "Overview" });
88
+ sections.push({ id: "system-architecture", label: "System Architecture" });
89
+ sections.push({
90
+ id: "api-surface",
91
+ label: `API Surface — ${domainNames.length} domains · ${intel.meta.counts.endpoints} endpoints`,
92
+ subLinks: domainNames.slice(0, 20).map((d) => link(d, d)),
93
+ });
94
+ sections.push({
95
+ id: "data-models",
96
+ label: `Data Models — ${intel.meta.counts.models} models`,
97
+ subLinks: groupNames.map((g) => link(g, `${g} group`)),
98
+ });
99
+ sections.push({ id: "quality-signals", label: "Quality Signals" });
100
+ sections.push({ id: "pattern-registry", label: "Pattern Registry" });
101
+ if (hasTasks)
102
+ sections.push({ id: "background-tasks", label: `Background Tasks — ${intel.meta.counts.tasks}` });
103
+ if (hasArcs)
104
+ sections.push({ id: "feature-timeline", label: "Feature Timeline" });
105
+ if (hasPages)
106
+ sections.push({ id: "frontend-pages", label: "Frontend Pages" });
107
+ if (hasDiscrepancies)
108
+ sections.push({ id: "discrepancies", label: "Discrepancies" });
109
+ // ── Table of Contents ─────────────────────────────────────────────────────
110
+ const tocLines = ["## Table of Contents", ""];
111
+ for (const sec of sections) {
112
+ tocLines.push(`- ${link(sec.label, sec.id)}`);
113
+ if (sec.subLinks && sec.subLinks.length > 0) {
114
+ const line = ` ${sec.subLinks.join(" · ")}`;
115
+ const suffix = domainNames.length > 20 && sec.id === "api-surface"
116
+ ? ` · _+${domainNames.length - 20} more_`
117
+ : "";
118
+ tocLines.push(line + suffix);
119
+ }
120
+ }
121
+ parts.push(tocLines.join("\n"));
122
+ parts.push("");
123
+ // ── Overview ──────────────────────────────────────────────────────────────
124
+ {
125
+ const done = logSection(`Overview${useLlm ? " [LLM]" : ""}`);
126
+ parts.push(`## Overview`);
127
+ parts.push("");
128
+ // Inject product context from config or README if available
129
+ if (options.productContext) {
130
+ parts.push(options.productContext);
131
+ parts.push("");
132
+ }
133
+ if (llmConfig) {
134
+ const contextHint = options.productContext
135
+ ? `\nProduct context:\n${options.productContext.slice(0, 800)}`
136
+ : "";
137
+ const text = await generateOverview(llmConfig, intel, contextHint);
138
+ parts.push(text);
139
+ done();
140
+ }
141
+ else {
142
+ parts.push(`**${intel.meta.project}** — ${intel.meta.counts.endpoints} endpoints across ${intel.meta.counts.modules} modules, ` +
143
+ `${intel.meta.counts.models} data models, ${intel.meta.counts.tasks} background tasks, ${intel.meta.counts.pages} frontend pages.`);
144
+ parts.push("");
145
+ parts.push(`_Set \`SPECGUARD_LLM_ENDPOINT\` + \`SPECGUARD_LLM_API_KEY\`, or run Ollama locally, for a narrative summary._`);
146
+ done("skipped (no LLM)");
147
+ }
148
+ parts.push(TOP_LINK);
149
+ }
150
+ // ── System Architecture ───────────────────────────────────────────────────
151
+ {
152
+ const done = logSection("System Architecture");
153
+ parts.push(`## System Architecture`);
154
+ parts.push("");
155
+ if (existingDocs?.systemDiagram) {
156
+ parts.push("### System Block Diagram");
157
+ parts.push("");
158
+ parts.push(existingDocs.systemDiagram);
159
+ parts.push("");
160
+ }
161
+ if (existingDocs?.driftSummary) {
162
+ parts.push(collapsible("Drift Summary", existingDocs.driftSummary));
163
+ parts.push("");
164
+ }
165
+ if (existingDocs?.couplingHeatmap) {
166
+ parts.push(collapsible("Structural Coupling Heatmap", existingDocs.couplingHeatmap));
167
+ parts.push("");
168
+ }
169
+ if (existingDocs?.backendSubsystems) {
170
+ parts.push(collapsible("Backend Subsystems", existingDocs.backendSubsystems));
171
+ parts.push("");
172
+ }
173
+ if (!existingDocs?.systemDiagram && !existingDocs?.couplingHeatmap) {
174
+ // Fallback: simple module table
175
+ parts.push("| Module | Layer | Files | Endpoints |");
176
+ parts.push("|---|---|---|---|");
177
+ for (const m of intel.service_map.filter((m) => m.type === "backend")
178
+ .sort((a, b) => b.endpoint_count - a.endpoint_count)) {
179
+ parts.push(`| \`${m.id}\` | ${m.layer} | ${m.file_count} | ${m.endpoint_count} |`);
180
+ }
181
+ }
182
+ parts.push(TOP_LINK);
183
+ done();
184
+ }
185
+ // ── API Surface ───────────────────────────────────────────────────────────
186
+ {
187
+ const done = logSection(`API Surface (${domainNames.length} domains${useLlm ? " + LLM descriptions" : ""})`);
188
+ parts.push(`## API Surface`);
189
+ parts.push(`_${domainNames.length} domains · ${intel.meta.counts.endpoints} endpoints_`);
190
+ parts.push("");
191
+ // Quick-index
192
+ parts.push("**Jump to:** " +
193
+ domainNames.map((d) => `[${d}](#${anchor(d)})`).join(" · "));
194
+ parts.push("");
195
+ if (integrationDomains && integrationDomains.size > 0) {
196
+ // Use integration.md content — richer (has schemas + model usage)
197
+ for (const [domain, { content }] of Array.from(integrationDomains.entries()).sort((a, b) => a[0].localeCompare(b[0]))) {
198
+ const rowCount = (content.match(/^\|/gm) ?? []).length;
199
+ const endpointCount = Math.max(0, rowCount - 2); // subtract header + separator rows
200
+ let sectionContent = `<a id="${anchor(domain)}"></a>\n\n`;
201
+ if (llmConfig && endpointCount > 0) {
202
+ const desc = await generateDomainDescriptionFromText(llmConfig, domain, content);
203
+ if (desc)
204
+ sectionContent += `${desc}\n\n`;
205
+ }
206
+ sectionContent += content;
207
+ const isOpen = endpointCount <= COLLAPSE_THRESHOLD;
208
+ parts.push(collapsible(`${domain} — ${endpointCount} endpoint(s)`, sectionContent, isOpen));
209
+ parts.push("");
210
+ }
211
+ }
212
+ else {
213
+ // Fallback: build from intel
214
+ const domains = groupEndpointsByDomain(intel);
215
+ for (const [domain, endpoints] of Array.from(domains.entries()).sort((a, b) => a[0].localeCompare(b[0]))) {
216
+ let sectionContent = `<a id="${anchor(domain)}"></a>\n\n`;
217
+ if (llmConfig) {
218
+ const desc = await generateDomainDescription(llmConfig, domain, endpoints);
219
+ if (desc)
220
+ sectionContent += `${desc}\n\n`;
221
+ }
222
+ sectionContent += "| Method | Path | Handler | Patterns |\n|---|---|---|---|\n";
223
+ for (const [, ep] of endpoints) {
224
+ const patterns = ep.patterns.length > 0 ? ep.patterns.join(", ") : "—";
225
+ sectionContent += `| \`${ep.method}\` | \`${ep.path}\` | \`${ep.handler}\` | ${patterns} |\n`;
226
+ }
227
+ const isOpen = endpoints.length <= COLLAPSE_THRESHOLD;
228
+ parts.push(collapsible(`${domain} — ${endpoints.length} endpoint(s)`, sectionContent, isOpen));
229
+ parts.push("");
230
+ }
231
+ }
232
+ parts.push(TOP_LINK);
233
+ done();
234
+ }
235
+ // ── Data Models ───────────────────────────────────────────────────────────
236
+ {
237
+ const done = logSection(`Data Models (${intel.meta.counts.models}${useLlm ? " + LLM descriptions" : ""})`);
238
+ parts.push(`## Data Models`);
239
+ parts.push(`_${intel.meta.counts.models} models in ${modelGroups.size} group(s)_`);
240
+ parts.push("");
241
+ // Group quick-index
242
+ parts.push("**Jump to:** " +
243
+ groupNames.map((g) => `[${g}](#${anchor(`${g} group`)})`).join(" · "));
244
+ parts.push("");
245
+ for (const [group, models] of Array.from(modelGroups.entries()).sort((a, b) => a[0].localeCompare(b[0]))) {
246
+ let groupContent = `<a id="${anchor(`${group} group`)}"></a>\n\n`;
247
+ groupContent += "| Model | Framework | Fields | Relationships |\n|---|---|---|---|\n";
248
+ for (const [name, model] of models) {
249
+ const fields = model.fields.length > 6
250
+ ? `${model.fields.slice(0, 6).join(", ")} +${model.fields.length - 6} more`
251
+ : model.fields.join(", ") || "—";
252
+ const rels = model.relationships.slice(0, 3).join(", ") || "—";
253
+ groupContent += `| **${name}** | ${model.framework} | ${fields} | ${rels} |\n`;
254
+ }
255
+ if (llmConfig && models.length > 0) {
256
+ const topModels = models.slice(0, 4).map(([name, m]) => `${name}: ${m.fields.slice(0, 8).join(", ")}`).join("\n");
257
+ const desc = await generateGroupDescription(llmConfig, group, topModels);
258
+ if (desc)
259
+ groupContent = `<a id="${anchor(`${group} group`)}"></a>\n\n${desc}\n\n` + groupContent.replace(`<a id="${anchor(`${group} group`)}"></a>\n\n`, "");
260
+ }
261
+ const isOpen = models.length <= COLLAPSE_THRESHOLD;
262
+ parts.push(collapsible(`${group} — ${models.length} model(s)`, groupContent, isOpen));
263
+ parts.push("");
264
+ }
265
+ parts.push(TOP_LINK);
266
+ done();
267
+ }
268
+ // ── Quality Signals ───────────────────────────────────────────────────────
269
+ {
270
+ const done = logSection("Quality Signals");
271
+ parts.push(`## Quality Signals`);
272
+ parts.push("");
273
+ if (existingDocs?.qualitySignals) {
274
+ parts.push(existingDocs.qualitySignals);
275
+ }
276
+ else {
277
+ // Fallback from intel analysis
278
+ parts.push("_Run `guardian extract` to populate quality signals._");
279
+ }
280
+ parts.push(TOP_LINK);
281
+ done();
282
+ }
283
+ // ── Pattern Registry ──────────────────────────────────────────────────────
284
+ {
285
+ const activePatterns = intel.pattern_registry.patterns.filter((p) => p.occurrences > 0);
286
+ const done = logSection(`Pattern Registry (${activePatterns.length} active)`);
287
+ parts.push(`## Pattern Registry`);
288
+ parts.push(`_${activePatterns.length} implementation patterns detected in the codebase._`);
289
+ parts.push("");
290
+ parts.push("| ID | Pattern | Occurrences | Description | Example |");
291
+ parts.push("|---|---|---|---|---|");
292
+ for (const p of activePatterns) {
293
+ const example = p.example_endpoints[0] ? `\`${p.example_endpoints[0]}\`` : "—";
294
+ parts.push(`| ${p.id} | **${p.name}** | ${p.occurrences} | ${p.description} | ${example} |`);
295
+ }
296
+ parts.push(TOP_LINK);
297
+ done();
298
+ }
299
+ // ── Background Tasks ──────────────────────────────────────────────────────
300
+ if (hasTasks) {
301
+ const done = logSection(`Background Tasks (${intel.background_tasks.length})`);
302
+ parts.push(`## Background Tasks`);
303
+ parts.push("");
304
+ parts.push("| Task | Kind | File | Queue |");
305
+ parts.push("|---|---|---|---|");
306
+ for (const t of intel.background_tasks) {
307
+ parts.push(`| \`${t.name}\` | ${t.kind} | \`${t.file}\` | ${t.queue ?? "—"} |`);
308
+ }
309
+ parts.push(TOP_LINK);
310
+ done();
311
+ }
312
+ // ── Feature Timeline ──────────────────────────────────────────────────────
313
+ if (hasArcs && featureArcs) {
314
+ const arcCount = Object.keys(featureArcs.arcs).length;
315
+ const done = logSection(`Feature Timeline (${arcCount} arc(s))`);
316
+ parts.push(`## Feature Timeline`);
317
+ parts.push(`_${arcCount} feature area(s) tracked across sprints._`);
318
+ parts.push("");
319
+ for (const [tag, arc] of Object.entries(featureArcs.arcs)) {
320
+ const sprintCount = Object.keys(arc.sprints).length;
321
+ const sprintLines = [];
322
+ for (const [sprint, snap] of Object.entries(arc.sprints)) {
323
+ sprintLines.push(`**${sprint}** — ${snap.features.join(", ")}`);
324
+ if (snap.endpoints.length > 0)
325
+ sprintLines.push(` Endpoints: ${snap.endpoints.map((e) => `\`${e}\``).join(", ")}`);
326
+ if (snap.models.length > 0)
327
+ sprintLines.push(` Models: ${snap.models.map((m) => `\`${m}\``).join(", ")}`);
328
+ }
329
+ const summary = `${arc.total_endpoints} endpoints · ${arc.total_models} models · ${sprintCount} sprint(s)`;
330
+ parts.push(collapsible(`${tag} — ${summary}`, sprintLines.join("\n"), true));
331
+ parts.push("");
332
+ }
333
+ parts.push(TOP_LINK);
334
+ done();
335
+ }
336
+ // ── Frontend Pages ────────────────────────────────────────────────────────
337
+ if (hasPages) {
338
+ const done = logSection(`Frontend Pages (${intel.frontend_pages.length})`);
339
+ parts.push(`## Frontend Pages`);
340
+ parts.push("");
341
+ parts.push("| Page | Component | API Calls | Direct Components |");
342
+ parts.push("|---|---|---|---|");
343
+ for (const page of intel.frontend_pages) {
344
+ const calls = page.api_calls.length > 0
345
+ ? page.api_calls.slice(0, 3).join(", ") + (page.api_calls.length > 3 ? ` +${page.api_calls.length - 3}` : "")
346
+ : "—";
347
+ const comps = page.direct_components.length > 0
348
+ ? page.direct_components.slice(0, 3).join(", ") + (page.direct_components.length > 3 ? ` +${page.direct_components.length - 3}` : "")
349
+ : "—";
350
+ parts.push(`| \`${page.path}\` | ${page.component} | ${calls} | ${comps} |`);
351
+ }
352
+ parts.push(TOP_LINK);
353
+ done();
354
+ }
355
+ // ── Discrepancies ─────────────────────────────────────────────────────────
356
+ if (hasDiscrepancies && discrepancies) {
357
+ const issueLabel = discrepancies.summary.total_issues === 0
358
+ ? "none"
359
+ : `${discrepancies.summary.total_issues} issue(s)${discrepancies.summary.has_critical ? " ⚠" : ""}`;
360
+ const done = logSection(`Discrepancies (${issueLabel})`);
361
+ parts.push(`## Discrepancies`);
362
+ parts.push("");
363
+ if (discrepancies.summary.total_issues === 0) {
364
+ parts.push("✓ No discrepancies found. Code and specs are in sync.");
365
+ }
366
+ else {
367
+ parts.push(`${discrepancies.summary.has_critical ? "⚠ " : ""}**${discrepancies.summary.total_issues} issue(s)** detected.`);
368
+ parts.push("");
369
+ const bullets = [];
370
+ if (discrepancies.new_endpoints.length > 0)
371
+ bullets.push(`**${discrepancies.new_endpoints.length} new endpoint(s)** since baseline`);
372
+ if (discrepancies.removed_endpoints.length > 0)
373
+ bullets.push(`**⚠ ${discrepancies.removed_endpoints.length} removed endpoint(s)** since baseline`);
374
+ if (discrepancies.untracked_endpoints.length > 0)
375
+ bullets.push(`**${discrepancies.untracked_endpoints.length} endpoint(s)** not in any feature spec`);
376
+ if (discrepancies.drifted_models.length > 0)
377
+ bullets.push(`**${discrepancies.drifted_models.length} model(s)** with field changes`);
378
+ if (discrepancies.orphan_specs.length > 0)
379
+ bullets.push(`**⚠ ${discrepancies.orphan_specs.length} spec(s)** reference missing endpoints`);
380
+ for (const b of bullets)
381
+ parts.push(`- ${b}`);
382
+ parts.push("");
383
+ parts.push(`_Run \`guardian discrepancy\` for the full report._`);
384
+ }
385
+ parts.push(TOP_LINK);
386
+ done();
387
+ }
388
+ return parts.join("\n");
389
+ }
390
+ // ── LLM generators ────────────────────────────────────────────────────────
391
+ async function generateOverview(config, intel, contextHint = "") {
392
+ const summary = [
393
+ `Project: ${intel.meta.project}`,
394
+ `${intel.meta.counts.endpoints} endpoints, ${intel.meta.counts.models} models, ${intel.meta.counts.tasks} tasks`,
395
+ `Modules: ${intel.service_map.filter((m) => m.type === "backend").sort((a, b) => b.endpoint_count - a.endpoint_count).slice(0, 4).map((m) => `${m.id}(${m.endpoint_count})`).join(", ")}`,
396
+ `Patterns: ${intel.pattern_registry.patterns.filter((p) => p.occurrences > 0).map((p) => `${p.name}(${p.occurrences}x)`).slice(0, 4).join(", ")}`,
397
+ contextHint,
398
+ ].join("\n");
399
+ try {
400
+ return await llmComplete(config, [
401
+ { role: "system", content: SYSTEM_PROMPT },
402
+ { role: "user", content: `Write a 2-3 sentence product overview. Be specific about what this product does, who it's for, and its key architectural decisions:\n\n${summary}` },
403
+ ], 512);
404
+ }
405
+ catch (err) {
406
+ return `_LLM summary unavailable: ${err.message}_`;
407
+ }
408
+ }
409
+ async function generateDomainDescriptionFromText(config, domain, integrationContent) {
410
+ const endpoints = integrationContent
411
+ .split("\n")
412
+ .filter((l) => l.startsWith("| ") && !l.startsWith("| Method") && !l.startsWith("| ---"))
413
+ .slice(0, 8)
414
+ .map((l) => { const cols = l.split("|").map((c) => c.trim()); return `${cols[1]} ${cols[2]}`; })
415
+ .join("\n");
416
+ try {
417
+ return await llmComplete(config, [
418
+ { role: "system", content: SYSTEM_PROMPT },
419
+ { role: "user", content: `One sentence: what does the "${domain}" API domain do?\n${endpoints}` },
420
+ ], 200);
421
+ }
422
+ catch {
423
+ return "";
424
+ }
425
+ }
426
+ async function generateDomainDescription(config, domain, endpoints) {
427
+ const list = endpoints.slice(0, 8).map(([, ep]) => `${ep.method} ${ep.path}`).join("\n");
428
+ try {
429
+ return await llmComplete(config, [
430
+ { role: "system", content: SYSTEM_PROMPT },
431
+ { role: "user", content: `One sentence: what does the "${domain}" API domain do?\n${list}` },
432
+ ], 200);
433
+ }
434
+ catch {
435
+ return "";
436
+ }
437
+ }
438
+ async function generateGroupDescription(config, group, modelSummary) {
439
+ try {
440
+ return await llmComplete(config, [
441
+ { role: "system", content: SYSTEM_PROMPT },
442
+ { role: "user", content: `One sentence: what does the "${group}" model group represent?\n${modelSummary}` },
443
+ ], 200);
444
+ }
445
+ catch {
446
+ return "";
447
+ }
448
+ }
449
+ // ── Grouping helpers ──────────────────────────────────────────────────────
450
+ function groupModelsByModule(intel) {
451
+ const groups = new Map();
452
+ // Build file → module index from service_map
453
+ const fileToModule = new Map();
454
+ for (const m of intel.service_map) {
455
+ // service_map paths are directory paths; match model files by prefix
456
+ for (const [name, model] of Object.entries(intel.model_registry)) {
457
+ if (model.file.includes(m.path) || m.path.includes(model.file.split("/")[0] ?? "")) {
458
+ fileToModule.set(model.file, m.id);
459
+ }
460
+ }
461
+ }
462
+ for (const [name, model] of Object.entries(intel.model_registry)) {
463
+ const module = fileToModule.get(model.file) ?? deriveGroupFromFile(model.file);
464
+ const entry = groups.get(module) ?? [];
465
+ entry.push([name, model]);
466
+ groups.set(module, entry);
467
+ }
468
+ // Sort models within each group
469
+ for (const [key, models] of groups) {
470
+ groups.set(key, models.sort((a, b) => a[0].localeCompare(b[0])));
471
+ }
472
+ return groups;
473
+ }
474
+ function deriveGroupFromFile(file) {
475
+ // "backend/app/models.py" → "app"
476
+ // "backend/app/services/auth.py" → "app"
477
+ const parts = file.split("/").filter(Boolean);
478
+ const appIdx = parts.indexOf("app");
479
+ if (appIdx !== -1)
480
+ return parts[appIdx + 1] ?? parts[appIdx] ?? "other";
481
+ return parts[1] ?? parts[0] ?? "other";
482
+ }
483
+ function groupEndpointsByDomain(intel) {
484
+ const domains = new Map();
485
+ for (const [key, ep] of Object.entries(intel.api_registry)) {
486
+ const domain = extractDomain(ep.path);
487
+ const entry = domains.get(domain) ?? [];
488
+ entry.push([key, ep]);
489
+ domains.set(domain, entry);
490
+ }
491
+ return domains;
492
+ }
493
+ function extractDomain(epPath) {
494
+ const parts = epPath.replace(/^\//, "").split("/");
495
+ const start = parts[0] === "api" ? 1 : 0;
496
+ return parts[start] ?? "root";
497
+ }