@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.
@@ -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.repository.merge_commit,
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 = ({ request, routes, aiRoutes, aiSelectionDecisions }) => {
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
- const baseRoute = !aiRoute?.semantics
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
- "AI semantics were unavailable for this route.",
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
- return validateSemanticSnapshotRequest({
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: 0,
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: sourceEvidenceRefs,
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
- return response.json();
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 ({ repoRoot, request: rawRequest, env }) => {
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
- const promptRoutes = routes.map((route) => ({
1789
- operation_source_key: route.operation_source_key,
1790
- method: route.method,
1791
- path_template: route.path_template,
1792
- evidence_summaries: buildSourceEvidenceRefs(route, hashScope).map(
1793
- (entry, index) => ({
1794
- evidence_index: index,
1795
- kind: entry.kind,
1796
- summary: entry.summary,
1797
- evidence_strength: entry.evidence_strength,
1798
- }),
1799
- ),
1800
- }));
1801
- const aiProviderApiKey = requireAiProviderApiKey(env);
1802
- const aiRoutes = await callAiProvider({
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 = await callAiSelectionProvider({
1813
- request,
1814
- apiKey: aiProviderApiKey,
1815
- selectionCandidates,
1816
- routeBySelectionKey,
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
+ };