@clue-ai/cli 0.0.3 → 0.0.5
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/README.md +43 -0
- package/bin/clue-cli.mjs +859 -0
- package/commands/claude-code/clue-init.md +8 -6
- package/commands/codex/clue-init.md +8 -6
- package/package.json +2 -2
- package/src/command-spec.mjs +21 -2
- package/src/contracts.mjs +5 -0
- package/src/fastapi-analyzer.mjs +150 -19
- package/src/init-tool.mjs +154 -55
- package/src/lifecycle-guard.mjs +141 -0
- package/src/lifecycle-init.mjs +210 -167
- package/src/path-policy.mjs +2 -0
- package/src/public-schema.cjs +26 -0
- package/src/semantic-ci.mjs +781 -32
- package/src/setup-check.mjs +435 -0
- package/src/setup-detect.mjs +198 -0
- package/src/setup-prepare.mjs +170 -0
- package/src/setup-tool.mjs +231 -114
- package/bin/clue-tool.mjs +0 -83
package/src/semantic-ci.mjs
CHANGED
|
@@ -2,6 +2,7 @@ import { createHash, createHmac } from "node:crypto";
|
|
|
2
2
|
import {
|
|
3
3
|
validateSemanticCiRequest,
|
|
4
4
|
validateSemanticSnapshotRequest,
|
|
5
|
+
validateSemanticSnapshotResponse,
|
|
5
6
|
} from "./contracts.mjs";
|
|
6
7
|
import { analyzeFastApiRoutes } from "./fastapi-analyzer.mjs";
|
|
7
8
|
import { listAllowedPythonFiles } from "./path-policy.mjs";
|
|
@@ -73,10 +74,43 @@ const semanticSnapshotHashScope = (request) =>
|
|
|
73
74
|
"clue_tools_semantic_snapshot",
|
|
74
75
|
request.project_key,
|
|
75
76
|
request.repository.repository_id,
|
|
76
|
-
request.
|
|
77
|
-
request.repository.workflow_run_id ?? "no_workflow_run",
|
|
77
|
+
request.service.service_key,
|
|
78
78
|
].join(":");
|
|
79
79
|
|
|
80
|
+
const canonicalJson = (value) => {
|
|
81
|
+
if (Array.isArray(value)) {
|
|
82
|
+
return `[${value.map((entry) => canonicalJson(entry)).join(",")}]`;
|
|
83
|
+
}
|
|
84
|
+
if (value && typeof value === "object") {
|
|
85
|
+
return `{${Object.keys(value)
|
|
86
|
+
.sort()
|
|
87
|
+
.map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`)
|
|
88
|
+
.join(",")}}`;
|
|
89
|
+
}
|
|
90
|
+
return JSON.stringify(value);
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const routeInputHash = (route) =>
|
|
94
|
+
sha256(
|
|
95
|
+
canonicalJson({
|
|
96
|
+
operation_source_key: route.operation_source_key,
|
|
97
|
+
method: route.method,
|
|
98
|
+
path_template: route.path_template,
|
|
99
|
+
source: route.source,
|
|
100
|
+
code_structure: route.ai_context?.code_structure ?? null,
|
|
101
|
+
}),
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
const routeSemanticHash = (route) =>
|
|
105
|
+
sha256(
|
|
106
|
+
canonicalJson({
|
|
107
|
+
semantics: route.semantics,
|
|
108
|
+
layer_evidence: route.layer_evidence,
|
|
109
|
+
operation_effects: route.operation_effects ?? [],
|
|
110
|
+
unresolved_operation_effects: route.unresolved_operation_effects ?? [],
|
|
111
|
+
}),
|
|
112
|
+
);
|
|
113
|
+
|
|
80
114
|
const opaqueRouteHash = (route, purpose, hashScope) =>
|
|
81
115
|
hmacSha256(
|
|
82
116
|
hashScope,
|
|
@@ -165,6 +199,134 @@ const buildAiPrompt = (routes) =>
|
|
|
165
199
|
})),
|
|
166
200
|
});
|
|
167
201
|
|
|
202
|
+
const buildAiReuseDecisionPrompt = ({ route, previousRoute, currentHash }) =>
|
|
203
|
+
JSON.stringify({
|
|
204
|
+
task: "Decide whether previous privacy-safe route semantics still apply after a route source change. Return JSON only.",
|
|
205
|
+
rules: [
|
|
206
|
+
"Only decide for the provided operation_source_key.",
|
|
207
|
+
"Return decision as reuse_previous, regenerate, or needs_review.",
|
|
208
|
+
"Choose reuse_previous only when the customer-visible route meaning, operation effects, and value interpretation remain the same.",
|
|
209
|
+
"Choose regenerate when the route appears to create, update, delete, read, send, schedule, call, or validate a different business object or effect.",
|
|
210
|
+
"Choose needs_review when evidence is insufficient or conflicting.",
|
|
211
|
+
"Do not include raw source code, raw SQL, file paths, function names, class names, prompts, completions, secrets, ids, or hashes except the provided hashes.",
|
|
212
|
+
],
|
|
213
|
+
output_shape: {
|
|
214
|
+
decisions: [
|
|
215
|
+
{
|
|
216
|
+
operation_source_key: "route.POST./reports",
|
|
217
|
+
decision: "reuse_previous",
|
|
218
|
+
confidence: 0.82,
|
|
219
|
+
reason:
|
|
220
|
+
"The source changed but the privacy-safe evidence still supports the previous reports.create meaning.",
|
|
221
|
+
},
|
|
222
|
+
],
|
|
223
|
+
},
|
|
224
|
+
route: {
|
|
225
|
+
operation_source_key: route.operation_source_key,
|
|
226
|
+
method: route.method,
|
|
227
|
+
path_template: route.path_template,
|
|
228
|
+
current_route_input_hash: currentHash,
|
|
229
|
+
previous_route_input_hash: previousRoute.route_input_hash ?? null,
|
|
230
|
+
previous_semantics: previousRoute.semantics,
|
|
231
|
+
previous_operation_effects: previousRoute.operation_effects ?? [],
|
|
232
|
+
current_evidence_summaries: route.evidence_summaries,
|
|
233
|
+
},
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
const normalizeReuseDecision = ({ parsed, operationSourceKey }) => {
|
|
237
|
+
const decision = safeArray(parsed?.decisions).find(
|
|
238
|
+
(entry) => entry?.operation_source_key === operationSourceKey,
|
|
239
|
+
);
|
|
240
|
+
if (!decision || typeof decision !== "object") {
|
|
241
|
+
return {
|
|
242
|
+
decision: "needs_review",
|
|
243
|
+
confidence: 0,
|
|
244
|
+
reason: "AI reuse decision omitted this changed route.",
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
const normalizedDecision = ["reuse_previous", "regenerate", "needs_review"].includes(
|
|
248
|
+
decision.decision,
|
|
249
|
+
)
|
|
250
|
+
? decision.decision
|
|
251
|
+
: "needs_review";
|
|
252
|
+
return {
|
|
253
|
+
decision: normalizedDecision,
|
|
254
|
+
confidence: Number.isFinite(Number(decision.confidence))
|
|
255
|
+
? Math.max(0, Math.min(1, Number(decision.confidence)))
|
|
256
|
+
: 0,
|
|
257
|
+
reason:
|
|
258
|
+
typeof decision.reason === "string" && decision.reason.trim()
|
|
259
|
+
? decision.reason.trim()
|
|
260
|
+
: "AI reuse decision did not include a reason.",
|
|
261
|
+
};
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
const callAiReuseDecisionProvider = async ({
|
|
265
|
+
request,
|
|
266
|
+
apiKey,
|
|
267
|
+
route,
|
|
268
|
+
previousRoute,
|
|
269
|
+
currentHash,
|
|
270
|
+
}) => {
|
|
271
|
+
const response = await fetch(
|
|
272
|
+
`${request.ai_provider_base_url.replace(/\/+$/, "")}/chat/completions`,
|
|
273
|
+
{
|
|
274
|
+
method: "POST",
|
|
275
|
+
headers: {
|
|
276
|
+
"content-type": "application/json",
|
|
277
|
+
authorization: `Bearer ${apiKey}`,
|
|
278
|
+
},
|
|
279
|
+
body: JSON.stringify({
|
|
280
|
+
model: request.ai_model,
|
|
281
|
+
messages: [
|
|
282
|
+
{
|
|
283
|
+
role: "system",
|
|
284
|
+
content:
|
|
285
|
+
"You judge whether previous route semantics can be reused from privacy-safe evidence.",
|
|
286
|
+
},
|
|
287
|
+
{
|
|
288
|
+
role: "user",
|
|
289
|
+
content: buildAiReuseDecisionPrompt({
|
|
290
|
+
route,
|
|
291
|
+
previousRoute,
|
|
292
|
+
currentHash,
|
|
293
|
+
}),
|
|
294
|
+
},
|
|
295
|
+
],
|
|
296
|
+
response_format: { type: "json_object" },
|
|
297
|
+
}),
|
|
298
|
+
},
|
|
299
|
+
);
|
|
300
|
+
if (!response.ok) {
|
|
301
|
+
return {
|
|
302
|
+
decision: "needs_review",
|
|
303
|
+
confidence: 0,
|
|
304
|
+
reason: `AI reuse decision failed: provider_${response.status}.`,
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
const body = await response.json();
|
|
308
|
+
const content = body?.choices?.[0]?.message?.content;
|
|
309
|
+
if (typeof content !== "string" || content.trim() === "") {
|
|
310
|
+
return {
|
|
311
|
+
decision: "needs_review",
|
|
312
|
+
confidence: 0,
|
|
313
|
+
reason: "AI reuse decision returned empty content.",
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
try {
|
|
317
|
+
return normalizeReuseDecision({
|
|
318
|
+
parsed: JSON.parse(content),
|
|
319
|
+
operationSourceKey: route.operation_source_key,
|
|
320
|
+
});
|
|
321
|
+
} catch {
|
|
322
|
+
return {
|
|
323
|
+
decision: "needs_review",
|
|
324
|
+
confidence: 0,
|
|
325
|
+
reason: "AI reuse decision returned malformed JSON.",
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
};
|
|
329
|
+
|
|
168
330
|
const requireAiProviderApiKey = (env) => {
|
|
169
331
|
const apiKey = env.AI_PROVIDER_API_KEY;
|
|
170
332
|
if (!apiKey) {
|
|
@@ -209,6 +371,82 @@ const callAiProvider = async ({ request, apiKey, routes }) => {
|
|
|
209
371
|
);
|
|
210
372
|
};
|
|
211
373
|
|
|
374
|
+
const semanticGenerationFailureReason = (error) => {
|
|
375
|
+
if (!(error instanceof Error)) {
|
|
376
|
+
return "AI semantic generation failed for this route.";
|
|
377
|
+
}
|
|
378
|
+
const providerStatusMatch = /AI provider failed: (?<status>\d+)/.exec(
|
|
379
|
+
error.message,
|
|
380
|
+
);
|
|
381
|
+
if (providerStatusMatch?.groups?.status) {
|
|
382
|
+
return `AI semantic generation failed for this route: provider_${providerStatusMatch.groups.status}.`;
|
|
383
|
+
}
|
|
384
|
+
if (/empty semantic generation content/.test(error.message)) {
|
|
385
|
+
return "AI semantic generation returned empty content for this route.";
|
|
386
|
+
}
|
|
387
|
+
if (error instanceof SyntaxError) {
|
|
388
|
+
return "AI semantic generation returned malformed JSON for this route.";
|
|
389
|
+
}
|
|
390
|
+
return "AI semantic generation failed for this route.";
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
const unavailableAiRoute = (operationSourceKey, confidenceReason) => ({
|
|
394
|
+
operation_source_key: operationSourceKey,
|
|
395
|
+
confidence_reason: confidenceReason,
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
const assertSnapshotRouteCoverage = ({ routes, snapshotRoutes }) => {
|
|
399
|
+
const expectedKeys = routes.map((route) => route.operation_source_key);
|
|
400
|
+
const actualKeys = snapshotRoutes.map((route) => route.operation_source_key);
|
|
401
|
+
const expected = new Set(expectedKeys);
|
|
402
|
+
const actual = new Set(actualKeys);
|
|
403
|
+
const missing = expectedKeys.filter((key) => !actual.has(key));
|
|
404
|
+
const extra = actualKeys.filter((key) => !expected.has(key));
|
|
405
|
+
if (
|
|
406
|
+
expectedKeys.length !== actualKeys.length ||
|
|
407
|
+
missing.length > 0 ||
|
|
408
|
+
extra.length > 0
|
|
409
|
+
) {
|
|
410
|
+
throw new Error(
|
|
411
|
+
`semantic snapshot route coverage mismatch: missing=${missing.join(",") || "none"} extra=${extra.join(",") || "none"}`,
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
};
|
|
415
|
+
|
|
416
|
+
const generateAiRoutesPerRoute = async ({ request, apiKey, routes }) => {
|
|
417
|
+
const aiRoutes = new Map();
|
|
418
|
+
for (const route of routes) {
|
|
419
|
+
try {
|
|
420
|
+
const routeAiRoutes = await callAiProvider({
|
|
421
|
+
request,
|
|
422
|
+
apiKey,
|
|
423
|
+
routes: [route],
|
|
424
|
+
});
|
|
425
|
+
const aiRoute = routeAiRoutes.get(route.operation_source_key);
|
|
426
|
+
if (aiRoute?.semantics) {
|
|
427
|
+
aiRoutes.set(route.operation_source_key, aiRoute);
|
|
428
|
+
} else {
|
|
429
|
+
aiRoutes.set(
|
|
430
|
+
route.operation_source_key,
|
|
431
|
+
unavailableAiRoute(
|
|
432
|
+
route.operation_source_key,
|
|
433
|
+
"AI semantic generation omitted this route.",
|
|
434
|
+
),
|
|
435
|
+
);
|
|
436
|
+
}
|
|
437
|
+
} catch (error) {
|
|
438
|
+
aiRoutes.set(
|
|
439
|
+
route.operation_source_key,
|
|
440
|
+
unavailableAiRoute(
|
|
441
|
+
route.operation_source_key,
|
|
442
|
+
semanticGenerationFailureReason(error),
|
|
443
|
+
),
|
|
444
|
+
);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
return aiRoutes;
|
|
448
|
+
};
|
|
449
|
+
|
|
212
450
|
const buildAiSelectionPrompt = (selectionCandidates) =>
|
|
213
451
|
JSON.stringify({
|
|
214
452
|
task: "Choose adopted semantic values from deterministic and AI candidates. Return JSON only.",
|
|
@@ -1392,7 +1630,15 @@ const buildOperationAssignmentCollections = ({
|
|
|
1392
1630
|
};
|
|
1393
1631
|
};
|
|
1394
1632
|
|
|
1395
|
-
const buildSnapshot = ({
|
|
1633
|
+
const buildSnapshot = ({
|
|
1634
|
+
request,
|
|
1635
|
+
routes,
|
|
1636
|
+
aiRoutes,
|
|
1637
|
+
aiSelectionDecisions,
|
|
1638
|
+
routePlans = new Map(),
|
|
1639
|
+
previousSnapshot,
|
|
1640
|
+
deletedRouteCount = 0,
|
|
1641
|
+
}) => {
|
|
1396
1642
|
const generatedAt = new Date().toISOString();
|
|
1397
1643
|
const hashScope = semanticSnapshotHashScope(request);
|
|
1398
1644
|
const semanticSnapshotVersion = buildSemanticSnapshotVersion({
|
|
@@ -1405,22 +1651,69 @@ const buildSnapshot = ({ request, routes, aiRoutes, aiSelectionDecisions }) => {
|
|
|
1405
1651
|
const aiInferenceEvidence = [];
|
|
1406
1652
|
const semanticMeaningCandidates = [];
|
|
1407
1653
|
const catalogByGroup = new Map();
|
|
1654
|
+
let failedRouteCount = 0;
|
|
1655
|
+
const reusedRouteKeys = [];
|
|
1656
|
+
for (const [operationSourceKey, plan] of routePlans.entries()) {
|
|
1657
|
+
if (
|
|
1658
|
+
plan.origin === "unchanged_route_reused" ||
|
|
1659
|
+
plan.origin === "changed_route_semantic_reused"
|
|
1660
|
+
) {
|
|
1661
|
+
reusedRouteKeys.push(operationSourceKey);
|
|
1662
|
+
}
|
|
1663
|
+
}
|
|
1664
|
+
const previousContext = reusablePreviousContext({
|
|
1665
|
+
previousSnapshot,
|
|
1666
|
+
reusedRouteKeys,
|
|
1667
|
+
});
|
|
1668
|
+
sourceEvidenceRefs.push(...previousContext.sourceEvidenceRefs);
|
|
1669
|
+
targetObjectProfiles.push(...previousContext.profiles);
|
|
1670
|
+
targetObjectMappings.push(...previousContext.mappings);
|
|
1671
|
+
aiInferenceEvidence.push(...previousContext.aiInferenceEvidence);
|
|
1672
|
+
semanticMeaningCandidates.push(...previousContext.semanticMeaningCandidates);
|
|
1673
|
+
for (const entry of previousContext.catalogs) {
|
|
1674
|
+
const groupKey = `${entry.concept.toLowerCase()}::${entry.business_role.toLowerCase()}`;
|
|
1675
|
+
catalogByGroup.set(groupKey, { ...entry });
|
|
1676
|
+
}
|
|
1408
1677
|
|
|
1409
1678
|
const snapshotRoutes = routes.map((route) => {
|
|
1410
1679
|
const routeWithVersion = {
|
|
1411
1680
|
...route,
|
|
1412
1681
|
semantic_snapshot_version: semanticSnapshotVersion,
|
|
1413
1682
|
};
|
|
1683
|
+
const plan = routePlans.get(route.operation_source_key) ?? {
|
|
1684
|
+
origin: "new_route_ai_generated",
|
|
1685
|
+
route_input_hash: routeInputHash(route),
|
|
1686
|
+
};
|
|
1414
1687
|
const routeEvidenceRefs = buildSourceEvidenceRefs(
|
|
1415
1688
|
routeWithVersion,
|
|
1416
1689
|
hashScope,
|
|
1417
1690
|
);
|
|
1418
1691
|
sourceEvidenceRefs.push(...routeEvidenceRefs);
|
|
1692
|
+
if (
|
|
1693
|
+
(plan.origin === "unchanged_route_reused" ||
|
|
1694
|
+
plan.origin === "changed_route_semantic_reused") &&
|
|
1695
|
+
plan.previous_route
|
|
1696
|
+
) {
|
|
1697
|
+
return rebasePreviousRoute({
|
|
1698
|
+
previousRoute: plan.previous_route,
|
|
1699
|
+
currentRoute: route,
|
|
1700
|
+
semanticSnapshotVersion,
|
|
1701
|
+
plan,
|
|
1702
|
+
});
|
|
1703
|
+
}
|
|
1419
1704
|
const aiRoute = aiRoutes.get(route.operation_source_key);
|
|
1420
|
-
|
|
1705
|
+
if (!aiRoute?.semantics || plan.origin === "changed_route_needs_review") {
|
|
1706
|
+
failedRouteCount += 1;
|
|
1707
|
+
}
|
|
1708
|
+
const unavailableReason =
|
|
1709
|
+
plan.origin === "changed_route_needs_review"
|
|
1710
|
+
? `changed_route_needs_review: ${plan.semantic_change_reason}`
|
|
1711
|
+
: (aiRoute?.confidence_reason ??
|
|
1712
|
+
"AI semantics were unavailable for this route.");
|
|
1713
|
+
const baseRoute = !aiRoute?.semantics || plan.origin === "changed_route_needs_review"
|
|
1421
1714
|
? fallbackSemantics(
|
|
1422
1715
|
routeWithVersion,
|
|
1423
|
-
|
|
1716
|
+
unavailableReason,
|
|
1424
1717
|
hashScope,
|
|
1425
1718
|
)
|
|
1426
1719
|
: {
|
|
@@ -1461,10 +1754,49 @@ const buildSnapshot = ({ request, routes, aiRoutes, aiSelectionDecisions }) => {
|
|
|
1461
1754
|
unresolved_operation_effects:
|
|
1462
1755
|
assignmentCollections.unresolvedOperationEffects,
|
|
1463
1756
|
source_evidence_refs: routeEvidenceRefs.map((entry) => entry.id),
|
|
1757
|
+
route_input_hash: plan.route_input_hash,
|
|
1758
|
+
previous_route_input_hash: plan.previous_route_input_hash,
|
|
1759
|
+
previous_route_semantic_hash: plan.previous_route_semantic_hash,
|
|
1760
|
+
semantic_origin:
|
|
1761
|
+
plan.origin === "changed_route_needs_review" && aiRoute?.semantics
|
|
1762
|
+
? "changed_route_needs_review"
|
|
1763
|
+
: plan.origin,
|
|
1764
|
+
semantic_change_reason: plan.semantic_change_reason,
|
|
1765
|
+
previous_semantic_snapshot_version:
|
|
1766
|
+
plan.previous_route?.semantic_snapshot_version,
|
|
1767
|
+
route_semantic_hash: routeSemanticHash({
|
|
1768
|
+
...baseRoute,
|
|
1769
|
+
operation_effects: assignmentCollections.operationEffects,
|
|
1770
|
+
unresolved_operation_effects:
|
|
1771
|
+
assignmentCollections.unresolvedOperationEffects,
|
|
1772
|
+
}),
|
|
1464
1773
|
};
|
|
1465
1774
|
});
|
|
1775
|
+
addUniqueBy(
|
|
1776
|
+
targetObjectProfiles,
|
|
1777
|
+
previousContext.profiles,
|
|
1778
|
+
(entry) => entry.id,
|
|
1779
|
+
);
|
|
1780
|
+
addUniqueBy(
|
|
1781
|
+
targetObjectMappings,
|
|
1782
|
+
previousContext.mappings,
|
|
1783
|
+
(entry) => entry.id,
|
|
1784
|
+
);
|
|
1785
|
+
addUniqueBy(
|
|
1786
|
+
aiInferenceEvidence,
|
|
1787
|
+
previousContext.aiInferenceEvidence,
|
|
1788
|
+
(entry) => entry.id,
|
|
1789
|
+
);
|
|
1790
|
+
addUniqueBy(
|
|
1791
|
+
semanticMeaningCandidates,
|
|
1792
|
+
previousContext.semanticMeaningCandidates,
|
|
1793
|
+
(entry) => entry.id,
|
|
1794
|
+
);
|
|
1466
1795
|
|
|
1467
|
-
|
|
1796
|
+
const uniqueSourceEvidenceRefs = [];
|
|
1797
|
+
addUniqueBy(uniqueSourceEvidenceRefs, sourceEvidenceRefs, (entry) => entry.id);
|
|
1798
|
+
|
|
1799
|
+
const snapshot = validateSemanticSnapshotRequest({
|
|
1468
1800
|
project_key: request.project_key,
|
|
1469
1801
|
idempotency_key: sha256(
|
|
1470
1802
|
`${request.project_key}:${request.repository.repository_id}:${request.repository.merge_commit}:${request.repository.workflow_run_id ?? generatedAt}`,
|
|
@@ -1482,16 +1814,41 @@ const buildSnapshot = ({ request, routes, aiRoutes, aiSelectionDecisions }) => {
|
|
|
1482
1814
|
uncertain_routes: snapshotRoutes.filter(
|
|
1483
1815
|
(route) => route.semantics.route_confidence < 0.5,
|
|
1484
1816
|
).length,
|
|
1485
|
-
failed_routes:
|
|
1817
|
+
failed_routes: failedRouteCount,
|
|
1818
|
+
routes_reused: snapshotRoutes.filter((route) =>
|
|
1819
|
+
[
|
|
1820
|
+
"unchanged_route_reused",
|
|
1821
|
+
"changed_route_semantic_reused",
|
|
1822
|
+
].includes(route.semantic_origin),
|
|
1823
|
+
).length,
|
|
1824
|
+
routes_ai_generated: snapshotRoutes.filter((route) =>
|
|
1825
|
+
[
|
|
1826
|
+
"new_route_ai_generated",
|
|
1827
|
+
"changed_route_semantic_regenerated",
|
|
1828
|
+
].includes(route.semantic_origin),
|
|
1829
|
+
).length,
|
|
1830
|
+
routes_deleted: deletedRouteCount,
|
|
1831
|
+
routes_needs_review: snapshotRoutes.filter(
|
|
1832
|
+
(route) => route.semantic_origin === "changed_route_needs_review",
|
|
1833
|
+
).length,
|
|
1834
|
+
changed_routes_semantic_reused: snapshotRoutes.filter(
|
|
1835
|
+
(route) => route.semantic_origin === "changed_route_semantic_reused",
|
|
1836
|
+
).length,
|
|
1837
|
+
changed_routes_semantic_regenerated: snapshotRoutes.filter(
|
|
1838
|
+
(route) =>
|
|
1839
|
+
route.semantic_origin === "changed_route_semantic_regenerated",
|
|
1840
|
+
).length,
|
|
1486
1841
|
},
|
|
1487
1842
|
routes: snapshotRoutes,
|
|
1488
1843
|
target_object_profiles: targetObjectProfiles,
|
|
1489
1844
|
target_object_catalog: [...catalogByGroup.values()],
|
|
1490
1845
|
target_object_mappings: targetObjectMappings,
|
|
1491
|
-
source_evidence_refs:
|
|
1846
|
+
source_evidence_refs: uniqueSourceEvidenceRefs,
|
|
1492
1847
|
ai_inference_evidence: aiInferenceEvidence,
|
|
1493
1848
|
semantic_meaning_candidates: semanticMeaningCandidates,
|
|
1494
1849
|
});
|
|
1850
|
+
assertSnapshotRouteCoverage({ routes, snapshotRoutes: snapshot.routes });
|
|
1851
|
+
return snapshot;
|
|
1495
1852
|
};
|
|
1496
1853
|
|
|
1497
1854
|
const sanitizeText = (value, route) => {
|
|
@@ -1658,6 +2015,289 @@ const sanitizeLayerEvidence = (value, route, hashScope) => {
|
|
|
1658
2015
|
};
|
|
1659
2016
|
};
|
|
1660
2017
|
|
|
2018
|
+
const previousRouteMap = (previousSnapshot) =>
|
|
2019
|
+
new Map(
|
|
2020
|
+
safeArray(previousSnapshot?.routes).map((route) => [
|
|
2021
|
+
route.operation_source_key,
|
|
2022
|
+
route,
|
|
2023
|
+
]),
|
|
2024
|
+
);
|
|
2025
|
+
|
|
2026
|
+
const buildRouteEvidencePromptEntry = ({ route, hashScope }) => ({
|
|
2027
|
+
operation_source_key: route.operation_source_key,
|
|
2028
|
+
method: route.method,
|
|
2029
|
+
path_template: route.path_template,
|
|
2030
|
+
evidence_summaries: buildSourceEvidenceRefs(route, hashScope).map(
|
|
2031
|
+
(entry, index) => ({
|
|
2032
|
+
evidence_index: index,
|
|
2033
|
+
kind: entry.kind,
|
|
2034
|
+
summary: entry.summary,
|
|
2035
|
+
evidence_strength: entry.evidence_strength,
|
|
2036
|
+
}),
|
|
2037
|
+
),
|
|
2038
|
+
});
|
|
2039
|
+
|
|
2040
|
+
const classifyRoutesForSnapshot = async ({
|
|
2041
|
+
request,
|
|
2042
|
+
routes,
|
|
2043
|
+
previousSnapshot,
|
|
2044
|
+
aiProviderApiKey,
|
|
2045
|
+
hashScope,
|
|
2046
|
+
}) => {
|
|
2047
|
+
const previousRoutes = previousRouteMap(previousSnapshot);
|
|
2048
|
+
const plans = new Map();
|
|
2049
|
+
const routesRequiringGeneration = [];
|
|
2050
|
+
|
|
2051
|
+
for (const route of routes) {
|
|
2052
|
+
const currentHash = routeInputHash(route);
|
|
2053
|
+
const previousRoute = previousRoutes.get(route.operation_source_key);
|
|
2054
|
+
if (!previousRoute) {
|
|
2055
|
+
plans.set(route.operation_source_key, {
|
|
2056
|
+
origin: "new_route_ai_generated",
|
|
2057
|
+
route_input_hash: currentHash,
|
|
2058
|
+
});
|
|
2059
|
+
routesRequiringGeneration.push(route);
|
|
2060
|
+
continue;
|
|
2061
|
+
}
|
|
2062
|
+
|
|
2063
|
+
if (previousRoute.route_input_hash === currentHash) {
|
|
2064
|
+
plans.set(route.operation_source_key, {
|
|
2065
|
+
origin: "unchanged_route_reused",
|
|
2066
|
+
route_input_hash: currentHash,
|
|
2067
|
+
previous_route: previousRoute,
|
|
2068
|
+
previous_route_input_hash: previousRoute.route_input_hash,
|
|
2069
|
+
previous_route_semantic_hash:
|
|
2070
|
+
previousRoute.route_semantic_hash ?? routeSemanticHash(previousRoute),
|
|
2071
|
+
semantic_change_reason:
|
|
2072
|
+
"Route input hash matched the previous snapshot, so previous semantics were reused exactly.",
|
|
2073
|
+
});
|
|
2074
|
+
continue;
|
|
2075
|
+
}
|
|
2076
|
+
|
|
2077
|
+
const decision = await callAiReuseDecisionProvider({
|
|
2078
|
+
request,
|
|
2079
|
+
apiKey: aiProviderApiKey,
|
|
2080
|
+
route: buildRouteEvidencePromptEntry({ route, hashScope }),
|
|
2081
|
+
previousRoute,
|
|
2082
|
+
currentHash,
|
|
2083
|
+
});
|
|
2084
|
+
if (decision.decision === "reuse_previous") {
|
|
2085
|
+
plans.set(route.operation_source_key, {
|
|
2086
|
+
origin: "changed_route_semantic_reused",
|
|
2087
|
+
route_input_hash: currentHash,
|
|
2088
|
+
previous_route: previousRoute,
|
|
2089
|
+
previous_route_input_hash: previousRoute.route_input_hash,
|
|
2090
|
+
previous_route_semantic_hash:
|
|
2091
|
+
previousRoute.route_semantic_hash ?? routeSemanticHash(previousRoute),
|
|
2092
|
+
semantic_change_reason: sanitizeText(decision.reason, route),
|
|
2093
|
+
});
|
|
2094
|
+
continue;
|
|
2095
|
+
}
|
|
2096
|
+
if (decision.decision === "regenerate") {
|
|
2097
|
+
plans.set(route.operation_source_key, {
|
|
2098
|
+
origin: "changed_route_semantic_regenerated",
|
|
2099
|
+
route_input_hash: currentHash,
|
|
2100
|
+
previous_route: previousRoute,
|
|
2101
|
+
previous_route_input_hash: previousRoute.route_input_hash,
|
|
2102
|
+
previous_route_semantic_hash:
|
|
2103
|
+
previousRoute.route_semantic_hash ?? routeSemanticHash(previousRoute),
|
|
2104
|
+
semantic_change_reason: sanitizeText(decision.reason, route),
|
|
2105
|
+
});
|
|
2106
|
+
routesRequiringGeneration.push(route);
|
|
2107
|
+
continue;
|
|
2108
|
+
}
|
|
2109
|
+
plans.set(route.operation_source_key, {
|
|
2110
|
+
origin: "changed_route_needs_review",
|
|
2111
|
+
route_input_hash: currentHash,
|
|
2112
|
+
previous_route: previousRoute,
|
|
2113
|
+
previous_route_input_hash: previousRoute.route_input_hash,
|
|
2114
|
+
previous_route_semantic_hash:
|
|
2115
|
+
previousRoute.route_semantic_hash ?? routeSemanticHash(previousRoute),
|
|
2116
|
+
semantic_change_reason: sanitizeText(decision.reason, route),
|
|
2117
|
+
});
|
|
2118
|
+
}
|
|
2119
|
+
|
|
2120
|
+
const currentRouteKeys = new Set(
|
|
2121
|
+
routes.map((route) => route.operation_source_key),
|
|
2122
|
+
);
|
|
2123
|
+
const deletedRouteCount = safeArray(previousSnapshot?.routes).filter(
|
|
2124
|
+
(route) => !currentRouteKeys.has(route.operation_source_key),
|
|
2125
|
+
).length;
|
|
2126
|
+
|
|
2127
|
+
return {
|
|
2128
|
+
plans,
|
|
2129
|
+
routesRequiringGeneration,
|
|
2130
|
+
deletedRouteCount,
|
|
2131
|
+
};
|
|
2132
|
+
};
|
|
2133
|
+
|
|
2134
|
+
const rebasePreviousRoute = ({
|
|
2135
|
+
previousRoute,
|
|
2136
|
+
currentRoute,
|
|
2137
|
+
semanticSnapshotVersion,
|
|
2138
|
+
plan,
|
|
2139
|
+
}) => {
|
|
2140
|
+
const route = {
|
|
2141
|
+
...previousRoute,
|
|
2142
|
+
operation_source_key: currentRoute.operation_source_key,
|
|
2143
|
+
semantic_snapshot_version: semanticSnapshotVersion,
|
|
2144
|
+
previous_semantic_snapshot_version:
|
|
2145
|
+
previousRoute.semantic_snapshot_version ??
|
|
2146
|
+
plan.previous_semantic_snapshot_version,
|
|
2147
|
+
method: currentRoute.method,
|
|
2148
|
+
path_template: currentRoute.path_template,
|
|
2149
|
+
route_input_hash: plan.route_input_hash,
|
|
2150
|
+
previous_route_input_hash: plan.previous_route_input_hash,
|
|
2151
|
+
previous_route_semantic_hash: plan.previous_route_semantic_hash,
|
|
2152
|
+
semantic_origin: plan.origin,
|
|
2153
|
+
semantic_change_reason: plan.semantic_change_reason,
|
|
2154
|
+
};
|
|
2155
|
+
return {
|
|
2156
|
+
...route,
|
|
2157
|
+
route_semantic_hash: routeSemanticHash(route),
|
|
2158
|
+
};
|
|
2159
|
+
};
|
|
2160
|
+
|
|
2161
|
+
const fieldPathMatchesKeys = ({ fieldPath, routeKeys, profileIds, mappingIds, catalogKeys }) => {
|
|
2162
|
+
for (const routeKey of routeKeys) {
|
|
2163
|
+
if (fieldPath.startsWith(`routes[${routeKey}]`)) {
|
|
2164
|
+
return true;
|
|
2165
|
+
}
|
|
2166
|
+
}
|
|
2167
|
+
for (const profileId of profileIds) {
|
|
2168
|
+
if (fieldPath.startsWith(`target_object_profiles[${profileId}]`)) {
|
|
2169
|
+
return true;
|
|
2170
|
+
}
|
|
2171
|
+
}
|
|
2172
|
+
for (const mappingId of mappingIds) {
|
|
2173
|
+
if (fieldPath.startsWith(`target_object_mappings[${mappingId}]`)) {
|
|
2174
|
+
return true;
|
|
2175
|
+
}
|
|
2176
|
+
}
|
|
2177
|
+
for (const catalogKey of catalogKeys) {
|
|
2178
|
+
if (fieldPath.startsWith(`target_object_catalog[${catalogKey}]`)) {
|
|
2179
|
+
return true;
|
|
2180
|
+
}
|
|
2181
|
+
}
|
|
2182
|
+
return false;
|
|
2183
|
+
};
|
|
2184
|
+
|
|
2185
|
+
const reusablePreviousContext = ({ previousSnapshot, reusedRouteKeys }) => {
|
|
2186
|
+
const routeKeys = new Set(reusedRouteKeys);
|
|
2187
|
+
const profiles = safeArray(previousSnapshot?.target_object_profiles).filter(
|
|
2188
|
+
(profile) => routeKeys.has(profile.operation_source_key),
|
|
2189
|
+
);
|
|
2190
|
+
const mappings = safeArray(previousSnapshot?.target_object_mappings).filter(
|
|
2191
|
+
(mapping) => routeKeys.has(mapping.operation_source_key),
|
|
2192
|
+
);
|
|
2193
|
+
const profileIds = new Set(profiles.map((profile) => profile.id));
|
|
2194
|
+
const mappingIds = new Set(mappings.map((mapping) => mapping.id));
|
|
2195
|
+
const catalogKeys = new Set([
|
|
2196
|
+
...mappings.map((mapping) => mapping.target_object_key),
|
|
2197
|
+
...safeArray(previousSnapshot?.routes)
|
|
2198
|
+
.filter((route) => routeKeys.has(route.operation_source_key))
|
|
2199
|
+
.flatMap((route) =>
|
|
2200
|
+
safeArray(route.operation_effects).map((effect) => effect.target_object_key),
|
|
2201
|
+
),
|
|
2202
|
+
]);
|
|
2203
|
+
const catalogs = safeArray(previousSnapshot?.target_object_catalog).filter(
|
|
2204
|
+
(entry) => catalogKeys.has(entry.target_object_key),
|
|
2205
|
+
);
|
|
2206
|
+
const matches = (fieldPath) =>
|
|
2207
|
+
fieldPathMatchesKeys({
|
|
2208
|
+
fieldPath,
|
|
2209
|
+
routeKeys,
|
|
2210
|
+
profileIds,
|
|
2211
|
+
mappingIds,
|
|
2212
|
+
catalogKeys,
|
|
2213
|
+
});
|
|
2214
|
+
const aiInferenceEvidence = safeArray(
|
|
2215
|
+
previousSnapshot?.ai_inference_evidence,
|
|
2216
|
+
).filter(
|
|
2217
|
+
(entry) =>
|
|
2218
|
+
safeArray(entry.output_field_paths).length > 0 &&
|
|
2219
|
+
safeArray(entry.output_field_paths).every(matches),
|
|
2220
|
+
);
|
|
2221
|
+
const aiInferenceIds = new Set(aiInferenceEvidence.map((entry) => entry.id));
|
|
2222
|
+
const semanticMeaningCandidates = safeArray(
|
|
2223
|
+
previousSnapshot?.semantic_meaning_candidates,
|
|
2224
|
+
).filter(
|
|
2225
|
+
(entry) =>
|
|
2226
|
+
matches(entry.field_path) &&
|
|
2227
|
+
aiInferenceIds.has(entry.ai_candidate.ai_inference_evidence_ref) &&
|
|
2228
|
+
aiInferenceIds.has(entry.selection_ai_inference_evidence_ref),
|
|
2229
|
+
);
|
|
2230
|
+
const sourceEvidenceIds = new Set();
|
|
2231
|
+
for (const route of safeArray(previousSnapshot?.routes).filter((entry) =>
|
|
2232
|
+
routeKeys.has(entry.operation_source_key),
|
|
2233
|
+
)) {
|
|
2234
|
+
for (const ref of safeArray(route.source_evidence_refs)) {
|
|
2235
|
+
sourceEvidenceIds.add(ref);
|
|
2236
|
+
}
|
|
2237
|
+
for (const effect of safeArray(route.operation_effects)) {
|
|
2238
|
+
for (const ref of safeArray(effect.source_evidence_refs)) {
|
|
2239
|
+
sourceEvidenceIds.add(ref);
|
|
2240
|
+
}
|
|
2241
|
+
}
|
|
2242
|
+
for (const effect of safeArray(route.unresolved_operation_effects)) {
|
|
2243
|
+
for (const ref of safeArray(effect.source_evidence_refs)) {
|
|
2244
|
+
sourceEvidenceIds.add(ref);
|
|
2245
|
+
}
|
|
2246
|
+
}
|
|
2247
|
+
}
|
|
2248
|
+
for (const profile of profiles) {
|
|
2249
|
+
for (const ref of [
|
|
2250
|
+
...safeArray(profile.evidence_refs),
|
|
2251
|
+
...safeArray(profile.source_evidence_refs),
|
|
2252
|
+
]) {
|
|
2253
|
+
sourceEvidenceIds.add(ref);
|
|
2254
|
+
}
|
|
2255
|
+
}
|
|
2256
|
+
for (const mapping of mappings) {
|
|
2257
|
+
sourceEvidenceIds.add(mapping.grouping_evidence_ref);
|
|
2258
|
+
}
|
|
2259
|
+
for (const catalog of catalogs) {
|
|
2260
|
+
for (const ref of safeArray(catalog.grouping_evidence_refs)) {
|
|
2261
|
+
sourceEvidenceIds.add(ref);
|
|
2262
|
+
}
|
|
2263
|
+
}
|
|
2264
|
+
for (const evidence of aiInferenceEvidence) {
|
|
2265
|
+
for (const ref of safeArray(evidence.input_source_evidence_refs)) {
|
|
2266
|
+
sourceEvidenceIds.add(ref);
|
|
2267
|
+
}
|
|
2268
|
+
}
|
|
2269
|
+
for (const candidate of semanticMeaningCandidates) {
|
|
2270
|
+
for (const ref of [
|
|
2271
|
+
...safeArray(candidate.deterministic_candidate?.source_evidence_refs),
|
|
2272
|
+
...safeArray(candidate.ai_candidate?.source_evidence_refs),
|
|
2273
|
+
]) {
|
|
2274
|
+
sourceEvidenceIds.add(ref);
|
|
2275
|
+
}
|
|
2276
|
+
}
|
|
2277
|
+
const sourceEvidenceRefs = safeArray(previousSnapshot?.source_evidence_refs).filter(
|
|
2278
|
+
(entry) => sourceEvidenceIds.has(entry.id),
|
|
2279
|
+
);
|
|
2280
|
+
return {
|
|
2281
|
+
profiles,
|
|
2282
|
+
mappings,
|
|
2283
|
+
catalogs,
|
|
2284
|
+
sourceEvidenceRefs,
|
|
2285
|
+
aiInferenceEvidence,
|
|
2286
|
+
semanticMeaningCandidates,
|
|
2287
|
+
};
|
|
2288
|
+
};
|
|
2289
|
+
|
|
2290
|
+
const addUniqueBy = (target, entries, keyFn) => {
|
|
2291
|
+
const seen = new Set(target.map(keyFn));
|
|
2292
|
+
for (const entry of entries) {
|
|
2293
|
+
const key = keyFn(entry);
|
|
2294
|
+
if (!seen.has(key)) {
|
|
2295
|
+
target.push(entry);
|
|
2296
|
+
seen.add(key);
|
|
2297
|
+
}
|
|
2298
|
+
}
|
|
2299
|
+
};
|
|
2300
|
+
|
|
1661
2301
|
const FORBIDDEN_CODE_STRUCTURE_PATTERNS = [
|
|
1662
2302
|
{ label: "file path", pattern: /\b[A-Za-z0-9_./-]+\.py\b/i },
|
|
1663
2303
|
{
|
|
@@ -1755,6 +2395,56 @@ const semanticSnapshotUrl = (baseUrl) => {
|
|
|
1755
2395
|
return `${normalized}/api/v1/semantic-snapshots`;
|
|
1756
2396
|
};
|
|
1757
2397
|
|
|
2398
|
+
const semanticSnapshotLatestUrl = (request) => {
|
|
2399
|
+
const url = new URL(`${semanticSnapshotUrl(request.clue_api_base_url)}/latest`);
|
|
2400
|
+
url.searchParams.set("project_key", request.project_key);
|
|
2401
|
+
url.searchParams.set("environment", request.environment);
|
|
2402
|
+
url.searchParams.set("repository_id", request.repository.repository_id);
|
|
2403
|
+
url.searchParams.set("service_key", request.service.service_key);
|
|
2404
|
+
return url.toString();
|
|
2405
|
+
};
|
|
2406
|
+
|
|
2407
|
+
const fetchLatestSnapshot = async ({ request, env }) => {
|
|
2408
|
+
const apiKey = env.CLUE_API_KEY;
|
|
2409
|
+
if (!apiKey) {
|
|
2410
|
+
return null;
|
|
2411
|
+
}
|
|
2412
|
+
let response;
|
|
2413
|
+
try {
|
|
2414
|
+
response = await fetch(semanticSnapshotLatestUrl(request), {
|
|
2415
|
+
method: "GET",
|
|
2416
|
+
headers: {
|
|
2417
|
+
authorization: `Bearer ${apiKey}`,
|
|
2418
|
+
},
|
|
2419
|
+
});
|
|
2420
|
+
} catch {
|
|
2421
|
+
return null;
|
|
2422
|
+
}
|
|
2423
|
+
if (response.status === 404 || response.status === 204) {
|
|
2424
|
+
return null;
|
|
2425
|
+
}
|
|
2426
|
+
if (!response.ok) {
|
|
2427
|
+
return null;
|
|
2428
|
+
}
|
|
2429
|
+
const body = await response.json();
|
|
2430
|
+
const candidate = body?.snapshot ?? body;
|
|
2431
|
+
if (!candidate || !Array.isArray(candidate.routes)) {
|
|
2432
|
+
return null;
|
|
2433
|
+
}
|
|
2434
|
+
const parsed = validateSemanticSnapshotRequest(candidate);
|
|
2435
|
+
assertNoRawCodeStructure(parsed);
|
|
2436
|
+
return parsed;
|
|
2437
|
+
};
|
|
2438
|
+
|
|
2439
|
+
const validatePreviousSnapshotForReuse = (snapshot) => {
|
|
2440
|
+
if (!snapshot) {
|
|
2441
|
+
return null;
|
|
2442
|
+
}
|
|
2443
|
+
const parsed = validateSemanticSnapshotRequest(snapshot);
|
|
2444
|
+
assertNoRawCodeStructure(parsed);
|
|
2445
|
+
return parsed;
|
|
2446
|
+
};
|
|
2447
|
+
|
|
1758
2448
|
const sendSnapshot = async ({ request, env, snapshot }) => {
|
|
1759
2449
|
const apiKey = env.CLUE_API_KEY;
|
|
1760
2450
|
if (!apiKey) {
|
|
@@ -1773,10 +2463,24 @@ const sendSnapshot = async ({ request, env, snapshot }) => {
|
|
|
1773
2463
|
if (!response.ok) {
|
|
1774
2464
|
throw new Error(`Clue semantic snapshot upload failed: ${response.status}`);
|
|
1775
2465
|
}
|
|
1776
|
-
|
|
2466
|
+
const body = await response.json();
|
|
2467
|
+
try {
|
|
2468
|
+
return validateSemanticSnapshotResponse(body);
|
|
2469
|
+
} catch (error) {
|
|
2470
|
+
throw new Error(
|
|
2471
|
+
`Clue semantic snapshot upload response is invalid: ${
|
|
2472
|
+
error instanceof Error ? error.message : String(error)
|
|
2473
|
+
}`,
|
|
2474
|
+
);
|
|
2475
|
+
}
|
|
1777
2476
|
};
|
|
1778
2477
|
|
|
1779
|
-
export const runSemanticCi = async ({
|
|
2478
|
+
export const runSemanticCi = async ({
|
|
2479
|
+
repoRoot,
|
|
2480
|
+
request: rawRequest,
|
|
2481
|
+
env,
|
|
2482
|
+
previousSnapshot: providedPreviousSnapshot,
|
|
2483
|
+
}) => {
|
|
1780
2484
|
const request = validateSemanticCiRequest(rawRequest);
|
|
1781
2485
|
const hashScope = semanticSnapshotHashScope(request);
|
|
1782
2486
|
const files = await listAllowedPythonFiles({
|
|
@@ -1785,41 +2489,59 @@ export const runSemanticCi = async ({ repoRoot, request: rawRequest, env }) => {
|
|
|
1785
2489
|
excludedSourcePaths: request.excluded_source_paths,
|
|
1786
2490
|
});
|
|
1787
2491
|
const routes = await analyzeFastApiRoutes({ repoRoot, files });
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
)
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
const
|
|
2492
|
+
if (routes.length === 0) {
|
|
2493
|
+
throw new Error(
|
|
2494
|
+
"semantic CI discovered zero routes; check framework, backend root path, and allowed_source_paths",
|
|
2495
|
+
);
|
|
2496
|
+
}
|
|
2497
|
+
const previousSnapshot =
|
|
2498
|
+
providedPreviousSnapshot === undefined
|
|
2499
|
+
? await fetchLatestSnapshot({ request, env })
|
|
2500
|
+
: validatePreviousSnapshotForReuse(providedPreviousSnapshot);
|
|
2501
|
+
const previousRoutes = previousRouteMap(previousSnapshot);
|
|
2502
|
+
const routeNeedsAi = routes.some((route) => {
|
|
2503
|
+
const previousRoute = previousRoutes.get(route.operation_source_key);
|
|
2504
|
+
return !previousRoute || previousRoute.route_input_hash !== routeInputHash(route);
|
|
2505
|
+
});
|
|
2506
|
+
const aiProviderApiKey = routeNeedsAi ? requireAiProviderApiKey(env) : null;
|
|
2507
|
+
const { plans: routePlans, routesRequiringGeneration, deletedRouteCount } =
|
|
2508
|
+
await classifyRoutesForSnapshot({
|
|
2509
|
+
request,
|
|
2510
|
+
routes,
|
|
2511
|
+
previousSnapshot,
|
|
2512
|
+
aiProviderApiKey,
|
|
2513
|
+
hashScope,
|
|
2514
|
+
});
|
|
2515
|
+
const promptRoutes = routesRequiringGeneration.map((route) =>
|
|
2516
|
+
buildRouteEvidencePromptEntry({ route, hashScope }),
|
|
2517
|
+
);
|
|
2518
|
+
const aiRoutes = await generateAiRoutesPerRoute({
|
|
1803
2519
|
request,
|
|
1804
2520
|
apiKey: aiProviderApiKey,
|
|
1805
2521
|
routes: promptRoutes,
|
|
1806
2522
|
});
|
|
1807
2523
|
const { selectionCandidates, routeBySelectionKey } = buildSelectorCandidates({
|
|
1808
|
-
routes,
|
|
2524
|
+
routes: routesRequiringGeneration,
|
|
1809
2525
|
aiRoutes,
|
|
1810
2526
|
hashScope,
|
|
1811
2527
|
});
|
|
1812
|
-
const aiSelectionDecisions =
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
2528
|
+
const aiSelectionDecisions =
|
|
2529
|
+
selectionCandidates.length === 0
|
|
2530
|
+
? new Map()
|
|
2531
|
+
: await callAiSelectionProvider({
|
|
2532
|
+
request,
|
|
2533
|
+
apiKey: aiProviderApiKey,
|
|
2534
|
+
selectionCandidates,
|
|
2535
|
+
routeBySelectionKey,
|
|
2536
|
+
});
|
|
1818
2537
|
const snapshot = buildSnapshot({
|
|
1819
2538
|
request,
|
|
1820
2539
|
routes,
|
|
1821
2540
|
aiRoutes,
|
|
1822
2541
|
aiSelectionDecisions,
|
|
2542
|
+
routePlans,
|
|
2543
|
+
previousSnapshot,
|
|
2544
|
+
deletedRouteCount,
|
|
1823
2545
|
});
|
|
1824
2546
|
const upload = await sendSnapshot({ request, env, snapshot });
|
|
1825
2547
|
return {
|
|
@@ -1828,3 +2550,30 @@ export const runSemanticCi = async ({ repoRoot, request: rawRequest, env }) => {
|
|
|
1828
2550
|
upload,
|
|
1829
2551
|
};
|
|
1830
2552
|
};
|
|
2553
|
+
|
|
2554
|
+
export const runSemanticInventory = async ({ repoRoot, request }) => {
|
|
2555
|
+
const files = await listAllowedPythonFiles({
|
|
2556
|
+
repoRoot,
|
|
2557
|
+
allowedSourcePaths: request.allowed_source_paths,
|
|
2558
|
+
excludedSourcePaths: request.excluded_source_paths,
|
|
2559
|
+
});
|
|
2560
|
+
const routes = await analyzeFastApiRoutes({ repoRoot, files });
|
|
2561
|
+
if (routes.length === 0) {
|
|
2562
|
+
throw new Error(
|
|
2563
|
+
"semantic inventory discovered zero routes; check framework, backend root path, and allowed_source_paths",
|
|
2564
|
+
);
|
|
2565
|
+
}
|
|
2566
|
+
return {
|
|
2567
|
+
framework: request.framework,
|
|
2568
|
+
service_key: request.service_key,
|
|
2569
|
+
allowed_source_paths: request.allowed_source_paths,
|
|
2570
|
+
excluded_source_paths: request.excluded_source_paths,
|
|
2571
|
+
route_count: routes.length,
|
|
2572
|
+
operation_source_keys: routes.map((route) => route.operation_source_key),
|
|
2573
|
+
routes: routes.map((route) => ({
|
|
2574
|
+
operation_source_key: route.operation_source_key,
|
|
2575
|
+
method: route.method,
|
|
2576
|
+
path_template: route.path_template,
|
|
2577
|
+
})),
|
|
2578
|
+
};
|
|
2579
|
+
};
|