@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,356 @@
|
|
|
1
|
+
export function renderContextBlock(architecture, ux, options) {
|
|
2
|
+
const focusTokens = tokenize(options?.focusQuery ?? "");
|
|
3
|
+
const ormModels = architecture.data_models.filter((model) => model.framework !== "pydantic");
|
|
4
|
+
const schemaModels = architecture.data_models.length - ormModels.length;
|
|
5
|
+
const lines = [];
|
|
6
|
+
lines.push(`<!-- guardian:context generated=${new Date().toISOString()} -->`);
|
|
7
|
+
lines.push("## Codebase Map");
|
|
8
|
+
lines.push("");
|
|
9
|
+
lines.push(`**Backend:** ${ormModels.length} ORM models + ${schemaModels} schemas · ${architecture.endpoints.length} endpoints · ${architecture.modules.length} modules`);
|
|
10
|
+
lines.push(`**Frontend:** ${ux.components.length} components · ${ux.pages.length} pages`);
|
|
11
|
+
lines.push("");
|
|
12
|
+
const couplingFiles = pickTopCouplingFiles(architecture, options?.heatmap, 5);
|
|
13
|
+
if (couplingFiles.length > 0) {
|
|
14
|
+
lines.push("### High-Coupling Files");
|
|
15
|
+
for (const entry of couplingFiles) {
|
|
16
|
+
lines.push(`- ${entry.id} (score ${entry.score.toFixed(2)})`);
|
|
17
|
+
}
|
|
18
|
+
lines.push("");
|
|
19
|
+
}
|
|
20
|
+
const recentChanges = summarizeDiff(options?.diff);
|
|
21
|
+
if (recentChanges.length > 0) {
|
|
22
|
+
lines.push("### Recent Structural Changes");
|
|
23
|
+
for (const change of recentChanges) {
|
|
24
|
+
lines.push(`- ${change}`);
|
|
25
|
+
}
|
|
26
|
+
lines.push("");
|
|
27
|
+
}
|
|
28
|
+
const exportComponents = pickRelevantComponents(ux, focusTokens).slice(0, 8);
|
|
29
|
+
if (exportComponents.length > 0) {
|
|
30
|
+
lines.push("### Component Import Reference");
|
|
31
|
+
for (const component of exportComponents) {
|
|
32
|
+
const importExample = component.export_kind === "default"
|
|
33
|
+
? `import ${component.name} from`
|
|
34
|
+
: `import { ${component.name} } from`;
|
|
35
|
+
lines.push(`- ${component.name} -> \`${importExample}\` (${component.export_kind ?? "unknown"})`);
|
|
36
|
+
}
|
|
37
|
+
lines.push("");
|
|
38
|
+
}
|
|
39
|
+
if (architecture.tests && architecture.tests.length > 0) {
|
|
40
|
+
const testsByFile = new Map();
|
|
41
|
+
for (const test of architecture.tests) {
|
|
42
|
+
if (!test.file)
|
|
43
|
+
continue;
|
|
44
|
+
if (!testsByFile.has(test.file))
|
|
45
|
+
testsByFile.set(test.file, []);
|
|
46
|
+
testsByFile.get(test.file).push(test);
|
|
47
|
+
}
|
|
48
|
+
const testFiles = Array.from(testsByFile.entries())
|
|
49
|
+
.sort((a, b) => b[1].length - a[1].length || a[0].localeCompare(b[0]))
|
|
50
|
+
.slice(0, 5);
|
|
51
|
+
if (testFiles.length > 0) {
|
|
52
|
+
lines.push("### Behavioral Test Specifications");
|
|
53
|
+
for (const [file, tests] of testFiles) {
|
|
54
|
+
lines.push(`- \`${file}\` (${tests.length} tests)`);
|
|
55
|
+
}
|
|
56
|
+
lines.push("");
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
const siReports = (options?.structuralIntelligence ?? architecture.structural_intelligence ?? []).filter((r) => r.structure.nodes > 0).slice(0, 5);
|
|
60
|
+
if (siReports.length > 0) {
|
|
61
|
+
lines.push("### Structural Intelligence");
|
|
62
|
+
for (const r of siReports) {
|
|
63
|
+
const compressLabel = r.classification.compressible === "NON_COMPRESSIBLE"
|
|
64
|
+
? "compressible=**no**"
|
|
65
|
+
: r.classification.compressible === "PARTIAL"
|
|
66
|
+
? "compressible=partial"
|
|
67
|
+
: "compressible=yes";
|
|
68
|
+
lines.push(`- ${r.feature}: depth=${r.classification.depth_level} · propagation=${r.classification.propagation} · ${compressLabel} · pattern=${r.recommendation.primary.pattern} (confidence ${r.confidence.value.toFixed(2)})`);
|
|
69
|
+
}
|
|
70
|
+
lines.push("");
|
|
71
|
+
const highRisk = siReports.filter((r) => r.classification.compressible === "NON_COMPRESSIBLE" &&
|
|
72
|
+
r.confidence.value >= r.guardrails.enforce_if_confidence_above);
|
|
73
|
+
if (highRisk.length > 0) {
|
|
74
|
+
lines.push("### AI Reasoning Rules");
|
|
75
|
+
lines.push("> [!WARNING]");
|
|
76
|
+
lines.push("> The following features are HIGH complexity. Do NOT implement as a single function.");
|
|
77
|
+
lines.push("");
|
|
78
|
+
for (const r of highRisk) {
|
|
79
|
+
lines.push(`- **${r.feature}** (confidence ${r.confidence.value.toFixed(2)}): use ${r.recommendation.primary.pattern}`);
|
|
80
|
+
if (r.recommendation.avoid.length > 0) {
|
|
81
|
+
lines.push(` - Avoid: ${r.recommendation.avoid.join(", ")}`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
lines.push("");
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
const modelMap = buildModelEndpointMap(architecture, focusTokens);
|
|
88
|
+
if (modelMap.length > 0) {
|
|
89
|
+
lines.push("### Key Model -> Endpoint Map");
|
|
90
|
+
for (const entry of modelMap.slice(0, 8)) {
|
|
91
|
+
lines.push(`- ${entry.model} (${entry.endpoints.length} endpoints) -> ${formatEndpointPreview(entry.endpoints, 5)}`);
|
|
92
|
+
}
|
|
93
|
+
lines.push("");
|
|
94
|
+
}
|
|
95
|
+
if (focusTokens.length > 0) {
|
|
96
|
+
const focusMatches = buildFocusSummary(architecture, ux, focusTokens);
|
|
97
|
+
if (focusMatches.length > 0) {
|
|
98
|
+
lines.push(`### Focus: ${options?.focusQuery}`);
|
|
99
|
+
for (const match of focusMatches.slice(0, 10)) {
|
|
100
|
+
lines.push(`- ${match}`);
|
|
101
|
+
}
|
|
102
|
+
lines.push("");
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
lines.push("<!-- /guardian:context -->");
|
|
106
|
+
const maxLines = options?.maxLines ?? 120;
|
|
107
|
+
if (lines.length <= maxLines) {
|
|
108
|
+
return lines.join("\n");
|
|
109
|
+
}
|
|
110
|
+
return [...lines.slice(0, maxLines - 2), "- context truncated for line budget", "<!-- /guardian:context -->"].join("\n");
|
|
111
|
+
}
|
|
112
|
+
function tokenize(value) {
|
|
113
|
+
return value
|
|
114
|
+
.toLowerCase()
|
|
115
|
+
.split(/[^a-z0-9_/.{}-]+/)
|
|
116
|
+
.map((token) => token.trim())
|
|
117
|
+
.filter(Boolean);
|
|
118
|
+
}
|
|
119
|
+
function matchesFocus(tokens, values) {
|
|
120
|
+
if (tokens.length === 0) {
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
const haystack = values.join(" ").toLowerCase();
|
|
124
|
+
return tokens.some((token) => haystack.includes(token));
|
|
125
|
+
}
|
|
126
|
+
function pickTopCouplingFiles(architecture, heatmap, limit) {
|
|
127
|
+
const fileEntries = heatmap?.levels.find((level) => level.level === "file")?.entries ?? [];
|
|
128
|
+
if (fileEntries.length > 0) {
|
|
129
|
+
return fileEntries.slice(0, limit).map((entry) => ({
|
|
130
|
+
id: entry.id,
|
|
131
|
+
score: entry.score
|
|
132
|
+
}));
|
|
133
|
+
}
|
|
134
|
+
const degree = new Map();
|
|
135
|
+
for (const edge of architecture.dependencies.file_graph) {
|
|
136
|
+
degree.set(edge.from, (degree.get(edge.from) ?? 0) + 1);
|
|
137
|
+
degree.set(edge.to, (degree.get(edge.to) ?? 0) + 1);
|
|
138
|
+
}
|
|
139
|
+
const maxDegree = Math.max(...degree.values(), 1);
|
|
140
|
+
return Array.from(degree.entries())
|
|
141
|
+
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
|
142
|
+
.slice(0, limit)
|
|
143
|
+
.map(([id, score]) => ({ id, score: score / maxDegree }));
|
|
144
|
+
}
|
|
145
|
+
function summarizeDiff(diff) {
|
|
146
|
+
if (!diff) {
|
|
147
|
+
return [];
|
|
148
|
+
}
|
|
149
|
+
const changes = [];
|
|
150
|
+
for (const [kind, items] of Object.entries(diff.added)) {
|
|
151
|
+
for (const item of items.slice(0, 3)) {
|
|
152
|
+
changes.push(`${kind}: +${item}`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
for (const [kind, items] of Object.entries(diff.removed)) {
|
|
156
|
+
for (const item of items.slice(0, 2)) {
|
|
157
|
+
changes.push(`${kind}: -${item}`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return changes.slice(0, 6);
|
|
161
|
+
}
|
|
162
|
+
function pickRelevantComponents(ux, focusTokens) {
|
|
163
|
+
const pageContext = buildComponentPageContext(ux);
|
|
164
|
+
const inDegree = buildComponentInDegree(ux);
|
|
165
|
+
return ux.components
|
|
166
|
+
.map((component) => {
|
|
167
|
+
const pages = pageContext.get(component.id) ?? [];
|
|
168
|
+
const values = [
|
|
169
|
+
component.name,
|
|
170
|
+
component.file,
|
|
171
|
+
component.export_kind ?? "unknown",
|
|
172
|
+
...pages,
|
|
173
|
+
...(component.props ?? []).map((prop) => `${prop.name}:${prop.type}`)
|
|
174
|
+
];
|
|
175
|
+
const score = focusTokens.length === 0
|
|
176
|
+
? (inDegree.get(component.id) ?? 0) * 0.5 +
|
|
177
|
+
pages.length * 0.2 +
|
|
178
|
+
(component.kind === "page" ? 0.1 : 0)
|
|
179
|
+
: scoreFocus(focusTokens, component.name, component.file, values);
|
|
180
|
+
return { component, pages, score };
|
|
181
|
+
})
|
|
182
|
+
.filter((entry) => focusTokens.length === 0 || entry.score > 0)
|
|
183
|
+
.sort((a, b) => b.score - a.score ||
|
|
184
|
+
b.pages.length - a.pages.length ||
|
|
185
|
+
a.component.name.localeCompare(b.component.name))
|
|
186
|
+
.map((entry) => entry.component);
|
|
187
|
+
}
|
|
188
|
+
function buildComponentInDegree(ux) {
|
|
189
|
+
const inDegree = new Map();
|
|
190
|
+
for (const edge of ux.component_graph) {
|
|
191
|
+
inDegree.set(edge.to, (inDegree.get(edge.to) ?? 0) + 1);
|
|
192
|
+
}
|
|
193
|
+
return inDegree;
|
|
194
|
+
}
|
|
195
|
+
function buildComponentPageContext(ux) {
|
|
196
|
+
const pageContext = new Map();
|
|
197
|
+
for (const page of ux.pages) {
|
|
198
|
+
const targets = new Set([
|
|
199
|
+
page.component_id,
|
|
200
|
+
...(page.components_direct_ids ?? []),
|
|
201
|
+
...(page.components_descendants_ids ?? [])
|
|
202
|
+
]);
|
|
203
|
+
for (const target of targets) {
|
|
204
|
+
if (!target) {
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
const pages = pageContext.get(target) ?? new Set();
|
|
208
|
+
pages.add(page.path);
|
|
209
|
+
pageContext.set(target, pages);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return new Map(Array.from(pageContext.entries()).map(([key, value]) => [
|
|
213
|
+
key,
|
|
214
|
+
Array.from(value).sort((a, b) => a.localeCompare(b))
|
|
215
|
+
]));
|
|
216
|
+
}
|
|
217
|
+
function scoreFocus(tokens, name, file, values) {
|
|
218
|
+
if (tokens.length === 0) {
|
|
219
|
+
return 0;
|
|
220
|
+
}
|
|
221
|
+
const normalizedName = name.toLowerCase();
|
|
222
|
+
const normalizedFile = file.toLowerCase();
|
|
223
|
+
const haystack = values.map((value) => value.toLowerCase());
|
|
224
|
+
let score = 0;
|
|
225
|
+
for (const token of tokens) {
|
|
226
|
+
if (normalizedName === token) {
|
|
227
|
+
score += 1;
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
if (normalizedName.includes(token)) {
|
|
231
|
+
score += 0.8;
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
if (normalizedFile.includes(token)) {
|
|
235
|
+
score += 0.55;
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
if (haystack.some((value) => value.includes(token))) {
|
|
239
|
+
score += 0.4;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return score / tokens.length;
|
|
243
|
+
}
|
|
244
|
+
function buildModelEndpointMap(architecture, focusTokens) {
|
|
245
|
+
const endpointLabel = new Map(architecture.endpoints.map((endpoint) => [endpoint.id, `${endpoint.method} ${endpoint.path}`]));
|
|
246
|
+
const modelMap = new Map();
|
|
247
|
+
for (const usage of architecture.endpoint_model_usage) {
|
|
248
|
+
const endpoint = endpointLabel.get(usage.endpoint_id) ?? usage.endpoint;
|
|
249
|
+
for (const model of usage.models) {
|
|
250
|
+
const entry = modelMap.get(model.name) ?? new Set();
|
|
251
|
+
entry.add(endpoint);
|
|
252
|
+
modelMap.set(model.name, entry);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
const rows = Array.from(modelMap.entries()).map(([model, endpoints]) => ({
|
|
256
|
+
model,
|
|
257
|
+
endpoints: Array.from(endpoints).sort((a, b) => a.localeCompare(b))
|
|
258
|
+
}));
|
|
259
|
+
const filtered = rows.filter((row) => matchesFocus(focusTokens, [row.model, ...row.endpoints]));
|
|
260
|
+
return (filtered.length > 0 ? filtered : rows).sort((a, b) => b.endpoints.length - a.endpoints.length || a.model.localeCompare(b.model));
|
|
261
|
+
}
|
|
262
|
+
function formatEndpointPreview(endpoints, limit) {
|
|
263
|
+
if (endpoints.length <= limit) {
|
|
264
|
+
return endpoints.join(", ");
|
|
265
|
+
}
|
|
266
|
+
const remaining = endpoints.length - limit;
|
|
267
|
+
return `${endpoints.slice(0, limit).join(", ")} +${remaining} more`;
|
|
268
|
+
}
|
|
269
|
+
function buildFocusSummary(architecture, ux, focusTokens) {
|
|
270
|
+
const summary = [];
|
|
271
|
+
const matchedModels = new Set();
|
|
272
|
+
const matchedEndpoints = new Set();
|
|
273
|
+
const matchedComponents = new Set();
|
|
274
|
+
const matchedModules = new Set();
|
|
275
|
+
// Pass 1: Direct Matching
|
|
276
|
+
for (const model of architecture.data_models) {
|
|
277
|
+
if (matchesFocus(focusTokens, [model.name, model.file, ...model.fields, ...model.relationships])) {
|
|
278
|
+
matchedModels.add(model.name);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
for (const endpoint of architecture.endpoints) {
|
|
282
|
+
if (matchesFocus(focusTokens, [
|
|
283
|
+
endpoint.path,
|
|
284
|
+
endpoint.handler,
|
|
285
|
+
endpoint.file,
|
|
286
|
+
endpoint.module,
|
|
287
|
+
endpoint.request_schema ?? "",
|
|
288
|
+
endpoint.response_schema ?? ""
|
|
289
|
+
])) {
|
|
290
|
+
matchedEndpoints.add(endpoint.id);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
for (const component of ux.components) {
|
|
294
|
+
if (matchesFocus(focusTokens, [
|
|
295
|
+
component.name,
|
|
296
|
+
component.file,
|
|
297
|
+
component.export_kind ?? "unknown",
|
|
298
|
+
...(component.props ?? []).map((prop) => `${prop.name}:${prop.type}`)
|
|
299
|
+
])) {
|
|
300
|
+
matchedComponents.add(component.id);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
for (const module of architecture.modules) {
|
|
304
|
+
if (matchesFocus(focusTokens, [module.id, module.path, ...module.files])) {
|
|
305
|
+
matchedModules.add(module.id);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
// Pass 2: 1-Degree Graph Expansion
|
|
309
|
+
for (const usage of architecture.endpoint_model_usage) {
|
|
310
|
+
const usesMatchedModel = usage.models.some((m) => matchedModels.has(m.name));
|
|
311
|
+
if (usesMatchedModel) {
|
|
312
|
+
matchedEndpoints.add(usage.endpoint_id);
|
|
313
|
+
}
|
|
314
|
+
if (matchedEndpoints.has(usage.endpoint_id)) {
|
|
315
|
+
usage.models.forEach((m) => matchedModels.add(m.name));
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
for (const edge of ux.component_graph) {
|
|
319
|
+
if (matchedComponents.has(edge.from)) {
|
|
320
|
+
matchedComponents.add(edge.to);
|
|
321
|
+
}
|
|
322
|
+
if (matchedComponents.has(edge.to)) {
|
|
323
|
+
matchedComponents.add(edge.from);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
for (const edge of architecture.dependencies.module_graph) {
|
|
327
|
+
if (matchedModules.has(edge.from)) {
|
|
328
|
+
matchedModules.add(edge.to);
|
|
329
|
+
}
|
|
330
|
+
if (matchedModules.has(edge.to)) {
|
|
331
|
+
matchedModules.add(edge.from);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
// Format Output
|
|
335
|
+
for (const model of architecture.data_models) {
|
|
336
|
+
if (matchedModels.has(model.name)) {
|
|
337
|
+
summary.push(`model: ${model.name} (${model.file})`);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
for (const endpoint of architecture.endpoints) {
|
|
341
|
+
if (matchedEndpoints.has(endpoint.id)) {
|
|
342
|
+
summary.push(`endpoint: ${endpoint.method} ${endpoint.path} -> ${endpoint.handler}`);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
for (const component of ux.components) {
|
|
346
|
+
if (matchedComponents.has(component.id)) {
|
|
347
|
+
summary.push(`component: ${component.name} (${component.file})`);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
for (const module of architecture.modules) {
|
|
351
|
+
if (matchedModules.has(module.id)) {
|
|
352
|
+
summary.push(`module: ${module.id} (${module.path})`);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
return summary;
|
|
356
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
export function buildCrossStackContracts(params) {
|
|
2
|
+
const { endpoints, apiCalls, ux, dataModels } = params;
|
|
3
|
+
const endpointByKey = new Map();
|
|
4
|
+
for (const endpoint of endpoints) {
|
|
5
|
+
const key = `${normalizeMethod(endpoint.method)} ${normalizePathPattern(endpoint.path)}`;
|
|
6
|
+
const entry = endpointByKey.get(key) ?? [];
|
|
7
|
+
entry.push(endpoint);
|
|
8
|
+
endpointByKey.set(key, entry);
|
|
9
|
+
}
|
|
10
|
+
const dataModelsByName = new Map(dataModels.map((model) => [model.name, model]));
|
|
11
|
+
const componentById = new Map(ux.components.map((component) => [component.id, component]));
|
|
12
|
+
const callersByEndpoint = buildCallerIndex(ux, componentById, apiCalls);
|
|
13
|
+
const groupedCalls = new Map();
|
|
14
|
+
for (const call of apiCalls) {
|
|
15
|
+
const key = `${normalizeMethod(call.method)} ${normalizePathPattern(call.path)}`;
|
|
16
|
+
const entry = groupedCalls.get(key) ?? [];
|
|
17
|
+
entry.push(call);
|
|
18
|
+
groupedCalls.set(key, entry);
|
|
19
|
+
}
|
|
20
|
+
const contracts = [];
|
|
21
|
+
const seen = new Set();
|
|
22
|
+
for (const [key, calls] of groupedCalls) {
|
|
23
|
+
const [method, ...pathParts] = key.split(" ");
|
|
24
|
+
const pathValue = pathParts.join(" ");
|
|
25
|
+
const candidates = findMatchingEndpoints({
|
|
26
|
+
endpoints,
|
|
27
|
+
endpointByKey,
|
|
28
|
+
method,
|
|
29
|
+
pathValue
|
|
30
|
+
});
|
|
31
|
+
for (const endpoint of candidates) {
|
|
32
|
+
const contractKey = endpoint.id;
|
|
33
|
+
if (seen.has(contractKey)) {
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
seen.add(contractKey);
|
|
37
|
+
const backendRequestModel = endpoint.request_schema
|
|
38
|
+
? dataModelsByName.get(endpoint.request_schema)
|
|
39
|
+
: null;
|
|
40
|
+
const backendRequestFields = backendRequestModel?.fields ?? [];
|
|
41
|
+
const frontendRequestFields = Array.from(new Set(calls.flatMap((call) => call.request_fields ?? []))).sort((a, b) => a.localeCompare(b));
|
|
42
|
+
const callers = callersByEndpoint.get(key) ?? callersByEndpoint.get(`ANY ${pathValue}`) ?? [];
|
|
43
|
+
const issues = [];
|
|
44
|
+
if (endpoint.request_schema && !backendRequestModel) {
|
|
45
|
+
issues.push(`backend request schema '${endpoint.request_schema}' not extracted`);
|
|
46
|
+
}
|
|
47
|
+
if (backendRequestFields.length > 0 && frontendRequestFields.length === 0) {
|
|
48
|
+
issues.push("frontend request fields not inferred");
|
|
49
|
+
}
|
|
50
|
+
if (backendRequestFields.length > 0 && frontendRequestFields.length > 0) {
|
|
51
|
+
const backendOnly = backendRequestFields.filter((field) => !frontendRequestFields.includes(field));
|
|
52
|
+
const frontendOnly = frontendRequestFields.filter((field) => !backendRequestFields.includes(field));
|
|
53
|
+
for (const field of backendOnly) {
|
|
54
|
+
issues.push(`frontend missing field '${field}'`);
|
|
55
|
+
}
|
|
56
|
+
for (const field of frontendOnly) {
|
|
57
|
+
issues.push(`frontend extra field '${field}'`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
let status = "ok";
|
|
61
|
+
if (issues.length > 0 && issues.some((issue) => issue.startsWith("frontend"))) {
|
|
62
|
+
status = "mismatched";
|
|
63
|
+
}
|
|
64
|
+
else if (endpoint.request_schema ||
|
|
65
|
+
endpoint.response_schema ||
|
|
66
|
+
frontendRequestFields.length > 0) {
|
|
67
|
+
status =
|
|
68
|
+
backendRequestFields.length > 0 && issues.length === 0 ? "ok" : "unverified";
|
|
69
|
+
}
|
|
70
|
+
contracts.push({
|
|
71
|
+
endpoint_id: endpoint.id,
|
|
72
|
+
method: endpoint.method,
|
|
73
|
+
path: endpoint.path,
|
|
74
|
+
backend_request_schema: endpoint.request_schema ?? null,
|
|
75
|
+
backend_response_schema: endpoint.response_schema ?? null,
|
|
76
|
+
backend_request_fields: backendRequestFields,
|
|
77
|
+
frontend_request_fields: frontendRequestFields,
|
|
78
|
+
frontend_callers: callers,
|
|
79
|
+
status,
|
|
80
|
+
issues
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
contracts.sort((a, b) => {
|
|
85
|
+
const path = a.path.localeCompare(b.path);
|
|
86
|
+
if (path !== 0) {
|
|
87
|
+
return path;
|
|
88
|
+
}
|
|
89
|
+
return a.method.localeCompare(b.method);
|
|
90
|
+
});
|
|
91
|
+
return contracts;
|
|
92
|
+
}
|
|
93
|
+
function buildCallerIndex(ux, componentById, apiCalls) {
|
|
94
|
+
const callers = new Map();
|
|
95
|
+
for (const page of ux.pages) {
|
|
96
|
+
for (const entry of page.component_api_calls) {
|
|
97
|
+
for (const call of entry.api_calls) {
|
|
98
|
+
const key = normalizeApiCallKey(call);
|
|
99
|
+
if (!key) {
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
const file = componentById.get(entry.component_id)?.file ?? "unknown";
|
|
103
|
+
const existing = callers.get(key) ?? new Map();
|
|
104
|
+
existing.set(`${entry.component}|${file}`, {
|
|
105
|
+
component: entry.component,
|
|
106
|
+
file
|
|
107
|
+
});
|
|
108
|
+
callers.set(key, existing);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
const componentsByFile = new Map();
|
|
113
|
+
for (const component of ux.components) {
|
|
114
|
+
const entry = componentsByFile.get(component.file) ?? [];
|
|
115
|
+
entry.push({ component: component.name, file: component.file });
|
|
116
|
+
componentsByFile.set(component.file, entry);
|
|
117
|
+
}
|
|
118
|
+
for (const call of apiCalls) {
|
|
119
|
+
const key = `${normalizeMethod(call.method)} ${normalizePathPattern(call.path)}`;
|
|
120
|
+
if (callers.has(key)) {
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
const components = componentsByFile.get(call.source) ?? [];
|
|
124
|
+
if (components.length === 0) {
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
callers.set(key, new Map(components.map((component) => [`${component.component}|${component.file}`, component])));
|
|
128
|
+
}
|
|
129
|
+
return new Map(Array.from(callers.entries()).map(([key, value]) => [
|
|
130
|
+
key,
|
|
131
|
+
Array.from(value.values()).sort((a, b) => {
|
|
132
|
+
const component = a.component.localeCompare(b.component);
|
|
133
|
+
if (component !== 0) {
|
|
134
|
+
return component;
|
|
135
|
+
}
|
|
136
|
+
return a.file.localeCompare(b.file);
|
|
137
|
+
})
|
|
138
|
+
]));
|
|
139
|
+
}
|
|
140
|
+
function findMatchingEndpoints(params) {
|
|
141
|
+
const exact = params.endpointByKey.get(`${params.method} ${params.pathValue}`) ??
|
|
142
|
+
params.endpointByKey.get(`ANY ${params.pathValue}`);
|
|
143
|
+
if (exact && exact.length > 0) {
|
|
144
|
+
return exact;
|
|
145
|
+
}
|
|
146
|
+
return params.endpoints.filter((endpoint) => {
|
|
147
|
+
const endpointMethod = normalizeMethod(endpoint.method);
|
|
148
|
+
if (endpointMethod !== "ANY" && endpointMethod !== params.method) {
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
const endpointPath = normalizePathPattern(endpoint.path);
|
|
152
|
+
return pathsCompatible(endpointPath, params.pathValue);
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
function pathsCompatible(a, b) {
|
|
156
|
+
if (a === b) {
|
|
157
|
+
return true;
|
|
158
|
+
}
|
|
159
|
+
return a.endsWith(b) || b.endsWith(a);
|
|
160
|
+
}
|
|
161
|
+
function normalizeApiCallKey(value) {
|
|
162
|
+
const parts = value.split(" ");
|
|
163
|
+
if (parts.length < 2) {
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
return `${normalizeMethod(parts[0])} ${normalizePathPattern(parts.slice(1).join(" "))}`;
|
|
167
|
+
}
|
|
168
|
+
function normalizeMethod(method) {
|
|
169
|
+
return method ? method.toUpperCase() : "GET";
|
|
170
|
+
}
|
|
171
|
+
function normalizePathPattern(value) {
|
|
172
|
+
if (!value) {
|
|
173
|
+
return "/";
|
|
174
|
+
}
|
|
175
|
+
const withoutQuery = value.split("?")[0] ?? value;
|
|
176
|
+
return withoutQuery
|
|
177
|
+
.replace(/\$\{[^}]+\}/g, ":param")
|
|
178
|
+
.replace(/\{[^}]+\}/g, ":param")
|
|
179
|
+
.replace(/<[^>]+>/g, ":param")
|
|
180
|
+
.replace(/:\w+/g, ":param")
|
|
181
|
+
.replace(/\/+/g, "/")
|
|
182
|
+
.replace(/\/$/, "") || "/";
|
|
183
|
+
}
|