@clue-ai/cli 0.0.4 → 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) {
@@ -233,6 +395,24 @@ const unavailableAiRoute = (operationSourceKey, confidenceReason) => ({
233
395
  confidence_reason: confidenceReason,
234
396
  });
235
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
+
236
416
  const generateAiRoutesPerRoute = async ({ request, apiKey, routes }) => {
237
417
  const aiRoutes = new Map();
238
418
  for (const route of routes) {
@@ -1450,7 +1630,15 @@ const buildOperationAssignmentCollections = ({
1450
1630
  };
1451
1631
  };
1452
1632
 
1453
- 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
+ }) => {
1454
1642
  const generatedAt = new Date().toISOString();
1455
1643
  const hashScope = semanticSnapshotHashScope(request);
1456
1644
  const semanticSnapshotVersion = buildSemanticSnapshotVersion({
@@ -1463,23 +1651,69 @@ const buildSnapshot = ({ request, routes, aiRoutes, aiSelectionDecisions }) => {
1463
1651
  const aiInferenceEvidence = [];
1464
1652
  const semanticMeaningCandidates = [];
1465
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
+ }
1466
1677
 
1467
1678
  const snapshotRoutes = routes.map((route) => {
1468
1679
  const routeWithVersion = {
1469
1680
  ...route,
1470
1681
  semantic_snapshot_version: semanticSnapshotVersion,
1471
1682
  };
1683
+ const plan = routePlans.get(route.operation_source_key) ?? {
1684
+ origin: "new_route_ai_generated",
1685
+ route_input_hash: routeInputHash(route),
1686
+ };
1472
1687
  const routeEvidenceRefs = buildSourceEvidenceRefs(
1473
1688
  routeWithVersion,
1474
1689
  hashScope,
1475
1690
  );
1476
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
+ }
1477
1704
  const aiRoute = aiRoutes.get(route.operation_source_key);
1478
- 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"
1479
1714
  ? fallbackSemantics(
1480
1715
  routeWithVersion,
1481
- aiRoute?.confidence_reason ??
1482
- "AI semantics were unavailable for this route.",
1716
+ unavailableReason,
1483
1717
  hashScope,
1484
1718
  )
1485
1719
  : {
@@ -1520,10 +1754,49 @@ const buildSnapshot = ({ request, routes, aiRoutes, aiSelectionDecisions }) => {
1520
1754
  unresolved_operation_effects:
1521
1755
  assignmentCollections.unresolvedOperationEffects,
1522
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
+ }),
1523
1773
  };
1524
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
+ );
1525
1795
 
1526
- return validateSemanticSnapshotRequest({
1796
+ const uniqueSourceEvidenceRefs = [];
1797
+ addUniqueBy(uniqueSourceEvidenceRefs, sourceEvidenceRefs, (entry) => entry.id);
1798
+
1799
+ const snapshot = validateSemanticSnapshotRequest({
1527
1800
  project_key: request.project_key,
1528
1801
  idempotency_key: sha256(
1529
1802
  `${request.project_key}:${request.repository.repository_id}:${request.repository.merge_commit}:${request.repository.workflow_run_id ?? generatedAt}`,
@@ -1541,16 +1814,41 @@ const buildSnapshot = ({ request, routes, aiRoutes, aiSelectionDecisions }) => {
1541
1814
  uncertain_routes: snapshotRoutes.filter(
1542
1815
  (route) => route.semantics.route_confidence < 0.5,
1543
1816
  ).length,
1544
- 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,
1545
1841
  },
1546
1842
  routes: snapshotRoutes,
1547
1843
  target_object_profiles: targetObjectProfiles,
1548
1844
  target_object_catalog: [...catalogByGroup.values()],
1549
1845
  target_object_mappings: targetObjectMappings,
1550
- source_evidence_refs: sourceEvidenceRefs,
1846
+ source_evidence_refs: uniqueSourceEvidenceRefs,
1551
1847
  ai_inference_evidence: aiInferenceEvidence,
1552
1848
  semantic_meaning_candidates: semanticMeaningCandidates,
1553
1849
  });
1850
+ assertSnapshotRouteCoverage({ routes, snapshotRoutes: snapshot.routes });
1851
+ return snapshot;
1554
1852
  };
1555
1853
 
1556
1854
  const sanitizeText = (value, route) => {
@@ -1717,6 +2015,289 @@ const sanitizeLayerEvidence = (value, route, hashScope) => {
1717
2015
  };
1718
2016
  };
1719
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
+
1720
2301
  const FORBIDDEN_CODE_STRUCTURE_PATTERNS = [
1721
2302
  { label: "file path", pattern: /\b[A-Za-z0-9_./-]+\.py\b/i },
1722
2303
  {
@@ -1814,6 +2395,56 @@ const semanticSnapshotUrl = (baseUrl) => {
1814
2395
  return `${normalized}/api/v1/semantic-snapshots`;
1815
2396
  };
1816
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
+
1817
2448
  const sendSnapshot = async ({ request, env, snapshot }) => {
1818
2449
  const apiKey = env.CLUE_API_KEY;
1819
2450
  if (!apiKey) {
@@ -1832,10 +2463,24 @@ const sendSnapshot = async ({ request, env, snapshot }) => {
1832
2463
  if (!response.ok) {
1833
2464
  throw new Error(`Clue semantic snapshot upload failed: ${response.status}`);
1834
2465
  }
1835
- 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
+ }
1836
2476
  };
1837
2477
 
1838
- export const runSemanticCi = async ({ repoRoot, request: rawRequest, env }) => {
2478
+ export const runSemanticCi = async ({
2479
+ repoRoot,
2480
+ request: rawRequest,
2481
+ env,
2482
+ previousSnapshot: providedPreviousSnapshot,
2483
+ }) => {
1839
2484
  const request = validateSemanticCiRequest(rawRequest);
1840
2485
  const hashScope = semanticSnapshotHashScope(request);
1841
2486
  const files = await listAllowedPythonFiles({
@@ -1844,41 +2489,59 @@ export const runSemanticCi = async ({ repoRoot, request: rawRequest, env }) => {
1844
2489
  excludedSourcePaths: request.excluded_source_paths,
1845
2490
  });
1846
2491
  const routes = await analyzeFastApiRoutes({ repoRoot, files });
1847
- const promptRoutes = routes.map((route) => ({
1848
- operation_source_key: route.operation_source_key,
1849
- method: route.method,
1850
- path_template: route.path_template,
1851
- evidence_summaries: buildSourceEvidenceRefs(route, hashScope).map(
1852
- (entry, index) => ({
1853
- evidence_index: index,
1854
- kind: entry.kind,
1855
- summary: entry.summary,
1856
- evidence_strength: entry.evidence_strength,
1857
- }),
1858
- ),
1859
- }));
1860
- const aiProviderApiKey = requireAiProviderApiKey(env);
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
+ );
1861
2518
  const aiRoutes = await generateAiRoutesPerRoute({
1862
2519
  request,
1863
2520
  apiKey: aiProviderApiKey,
1864
2521
  routes: promptRoutes,
1865
2522
  });
1866
2523
  const { selectionCandidates, routeBySelectionKey } = buildSelectorCandidates({
1867
- routes,
2524
+ routes: routesRequiringGeneration,
1868
2525
  aiRoutes,
1869
2526
  hashScope,
1870
2527
  });
1871
- const aiSelectionDecisions = await callAiSelectionProvider({
1872
- request,
1873
- apiKey: aiProviderApiKey,
1874
- selectionCandidates,
1875
- routeBySelectionKey,
1876
- });
2528
+ const aiSelectionDecisions =
2529
+ selectionCandidates.length === 0
2530
+ ? new Map()
2531
+ : await callAiSelectionProvider({
2532
+ request,
2533
+ apiKey: aiProviderApiKey,
2534
+ selectionCandidates,
2535
+ routeBySelectionKey,
2536
+ });
1877
2537
  const snapshot = buildSnapshot({
1878
2538
  request,
1879
2539
  routes,
1880
2540
  aiRoutes,
1881
2541
  aiSelectionDecisions,
2542
+ routePlans,
2543
+ previousSnapshot,
2544
+ deletedRouteCount,
1882
2545
  });
1883
2546
  const upload = await sendSnapshot({ request, env, snapshot });
1884
2547
  return {
@@ -1887,3 +2550,30 @@ export const runSemanticCi = async ({ repoRoot, request: rawRequest, env }) => {
1887
2550
  upload,
1888
2551
  };
1889
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
+ };