@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,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
|
+
}
|