@clue-ai/cli 0.0.4 → 0.0.6
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 +59 -2
- package/bin/clue-cli.mjs +836 -17
- package/commands/claude-code/clue-init.md +7 -1
- package/commands/codex/clue-init.md +7 -1
- package/package.json +1 -1
- package/src/ai-provider.mjs +146 -0
- package/src/command-spec.mjs +7 -7
- package/src/contracts.mjs +53 -14
- package/src/init-tool.mjs +153 -20
- package/src/lifecycle-guard.mjs +141 -0
- package/src/lifecycle-init.mjs +91 -73
- package/src/path-policy.mjs +2 -0
- package/src/public-schema.cjs +27 -1
- package/src/semantic-ci.mjs +771 -122
- package/src/setup-check.mjs +436 -0
- package/src/setup-detect.mjs +198 -0
- package/src/setup-prepare.mjs +289 -0
- package/src/setup-tool.mjs +94 -27
package/src/semantic-ci.mjs
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { createHash, createHmac } from "node:crypto";
|
|
2
|
+
import { callJsonAiProvider, resolveAiProviderConfig } from "./ai-provider.mjs";
|
|
2
3
|
import {
|
|
3
4
|
validateSemanticCiRequest,
|
|
4
5
|
validateSemanticSnapshotRequest,
|
|
6
|
+
validateSemanticSnapshotResponse,
|
|
5
7
|
} from "./contracts.mjs";
|
|
6
8
|
import { analyzeFastApiRoutes } from "./fastapi-analyzer.mjs";
|
|
7
9
|
import { listAllowedPythonFiles } from "./path-policy.mjs";
|
|
@@ -73,10 +75,43 @@ const semanticSnapshotHashScope = (request) =>
|
|
|
73
75
|
"clue_tools_semantic_snapshot",
|
|
74
76
|
request.project_key,
|
|
75
77
|
request.repository.repository_id,
|
|
76
|
-
request.
|
|
77
|
-
request.repository.workflow_run_id ?? "no_workflow_run",
|
|
78
|
+
request.service.service_key,
|
|
78
79
|
].join(":");
|
|
79
80
|
|
|
81
|
+
const canonicalJson = (value) => {
|
|
82
|
+
if (Array.isArray(value)) {
|
|
83
|
+
return `[${value.map((entry) => canonicalJson(entry)).join(",")}]`;
|
|
84
|
+
}
|
|
85
|
+
if (value && typeof value === "object") {
|
|
86
|
+
return `{${Object.keys(value)
|
|
87
|
+
.sort()
|
|
88
|
+
.map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`)
|
|
89
|
+
.join(",")}}`;
|
|
90
|
+
}
|
|
91
|
+
return JSON.stringify(value);
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const routeInputHash = (route) =>
|
|
95
|
+
sha256(
|
|
96
|
+
canonicalJson({
|
|
97
|
+
operation_source_key: route.operation_source_key,
|
|
98
|
+
method: route.method,
|
|
99
|
+
path_template: route.path_template,
|
|
100
|
+
source: route.source,
|
|
101
|
+
code_structure: route.ai_context?.code_structure ?? null,
|
|
102
|
+
}),
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
const routeSemanticHash = (route) =>
|
|
106
|
+
sha256(
|
|
107
|
+
canonicalJson({
|
|
108
|
+
semantics: route.semantics,
|
|
109
|
+
layer_evidence: route.layer_evidence,
|
|
110
|
+
operation_effects: route.operation_effects ?? [],
|
|
111
|
+
unresolved_operation_effects: route.unresolved_operation_effects ?? [],
|
|
112
|
+
}),
|
|
113
|
+
);
|
|
114
|
+
|
|
80
115
|
const opaqueRouteHash = (route, purpose, hashScope) =>
|
|
81
116
|
hmacSha256(
|
|
82
117
|
hashScope,
|
|
@@ -165,45 +200,133 @@ const buildAiPrompt = (routes) =>
|
|
|
165
200
|
})),
|
|
166
201
|
});
|
|
167
202
|
|
|
168
|
-
const
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
203
|
+
const buildAiReuseDecisionPrompt = ({ route, previousRoute, currentHash }) =>
|
|
204
|
+
JSON.stringify({
|
|
205
|
+
task: "Decide whether previous privacy-safe route semantics still apply after a route source change. Return JSON only.",
|
|
206
|
+
rules: [
|
|
207
|
+
"Only decide for the provided operation_source_key.",
|
|
208
|
+
"Return decision as reuse_previous, regenerate, or needs_review.",
|
|
209
|
+
"Choose reuse_previous only when the customer-visible route meaning, operation effects, and value interpretation remain the same.",
|
|
210
|
+
"Choose regenerate when the route appears to create, update, delete, read, send, schedule, call, or validate a different business object or effect.",
|
|
211
|
+
"Choose needs_review when evidence is insufficient or conflicting.",
|
|
212
|
+
"Do not include raw source code, raw SQL, file paths, function names, class names, prompts, completions, secrets, ids, or hashes except the provided hashes.",
|
|
213
|
+
],
|
|
214
|
+
output_shape: {
|
|
215
|
+
decisions: [
|
|
216
|
+
{
|
|
217
|
+
operation_source_key: "route.POST./reports",
|
|
218
|
+
decision: "reuse_previous",
|
|
219
|
+
confidence: 0.82,
|
|
220
|
+
reason:
|
|
221
|
+
"The source changed but the privacy-safe evidence still supports the previous reports.create meaning.",
|
|
222
|
+
},
|
|
223
|
+
],
|
|
224
|
+
},
|
|
225
|
+
route: {
|
|
226
|
+
operation_source_key: route.operation_source_key,
|
|
227
|
+
method: route.method,
|
|
228
|
+
path_template: route.path_template,
|
|
229
|
+
current_route_input_hash: currentHash,
|
|
230
|
+
previous_route_input_hash: previousRoute.route_input_hash ?? null,
|
|
231
|
+
previous_semantics: previousRoute.semantics,
|
|
232
|
+
previous_operation_effects: previousRoute.operation_effects ?? [],
|
|
233
|
+
current_evidence_summaries: route.evidence_summaries,
|
|
234
|
+
},
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
const normalizeReuseDecision = ({ parsed, operationSourceKey }) => {
|
|
238
|
+
const decision = safeArray(parsed?.decisions).find(
|
|
239
|
+
(entry) => entry?.operation_source_key === operationSourceKey,
|
|
240
|
+
);
|
|
241
|
+
if (!decision || typeof decision !== "object") {
|
|
242
|
+
return {
|
|
243
|
+
decision: "needs_review",
|
|
244
|
+
confidence: 0,
|
|
245
|
+
reason: "AI reuse decision omitted this changed route.",
|
|
246
|
+
};
|
|
172
247
|
}
|
|
173
|
-
|
|
248
|
+
const normalizedDecision = [
|
|
249
|
+
"reuse_previous",
|
|
250
|
+
"regenerate",
|
|
251
|
+
"needs_review",
|
|
252
|
+
].includes(decision.decision)
|
|
253
|
+
? decision.decision
|
|
254
|
+
: "needs_review";
|
|
255
|
+
return {
|
|
256
|
+
decision: normalizedDecision,
|
|
257
|
+
confidence: Number.isFinite(Number(decision.confidence))
|
|
258
|
+
? Math.max(0, Math.min(1, Number(decision.confidence)))
|
|
259
|
+
: 0,
|
|
260
|
+
reason:
|
|
261
|
+
typeof decision.reason === "string" && decision.reason.trim()
|
|
262
|
+
? decision.reason.trim()
|
|
263
|
+
: "AI reuse decision did not include a reason.",
|
|
264
|
+
};
|
|
174
265
|
};
|
|
175
266
|
|
|
176
|
-
const
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
],
|
|
194
|
-
response_format: { type: "json_object" },
|
|
267
|
+
const callAiReuseDecisionProvider = async ({
|
|
268
|
+
request,
|
|
269
|
+
env,
|
|
270
|
+
apiKey,
|
|
271
|
+
route,
|
|
272
|
+
previousRoute,
|
|
273
|
+
currentHash,
|
|
274
|
+
}) => {
|
|
275
|
+
try {
|
|
276
|
+
const parsed = await callJsonAiProvider({
|
|
277
|
+
config: resolveAiProviderConfig({ env, request, apiKey }),
|
|
278
|
+
system:
|
|
279
|
+
"You judge whether previous route semantics can be reused from privacy-safe evidence.",
|
|
280
|
+
user: buildAiReuseDecisionPrompt({
|
|
281
|
+
route,
|
|
282
|
+
previousRoute,
|
|
283
|
+
currentHash,
|
|
195
284
|
}),
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
285
|
+
toolName: "return_reuse_decision",
|
|
286
|
+
toolDescription:
|
|
287
|
+
"Return whether the previous route semantics can be reused.",
|
|
288
|
+
failureMessage: "AI reuse decision failed",
|
|
289
|
+
emptyMessage: "AI reuse decision returned empty content.",
|
|
290
|
+
});
|
|
291
|
+
return normalizeReuseDecision({
|
|
292
|
+
parsed,
|
|
293
|
+
operationSourceKey: route.operation_source_key,
|
|
294
|
+
});
|
|
295
|
+
} catch (error) {
|
|
296
|
+
const providerStatusMatch =
|
|
297
|
+
error instanceof Error
|
|
298
|
+
? /AI reuse decision failed: (?<status>\d+)/.exec(error.message)
|
|
299
|
+
: null;
|
|
300
|
+
return {
|
|
301
|
+
decision: "needs_review",
|
|
302
|
+
confidence: 0,
|
|
303
|
+
reason: providerStatusMatch?.groups?.status
|
|
304
|
+
? `AI reuse decision failed: provider_${providerStatusMatch.groups.status}.`
|
|
305
|
+
: "AI reuse decision returned malformed JSON.",
|
|
306
|
+
};
|
|
200
307
|
}
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
const requireAiProviderApiKey = (env) => {
|
|
311
|
+
const apiKey = env.CLUE_AI_PROVIDER_API_KEY;
|
|
312
|
+
if (!apiKey) {
|
|
313
|
+
throw new Error(
|
|
314
|
+
"CLUE_AI_PROVIDER_API_KEY is required for semantic generation",
|
|
315
|
+
);
|
|
205
316
|
}
|
|
206
|
-
|
|
317
|
+
return apiKey;
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
const callAiProvider = async ({ request, env, apiKey, routes }) => {
|
|
321
|
+
const parsed = await callJsonAiProvider({
|
|
322
|
+
config: resolveAiProviderConfig({ env, request, apiKey }),
|
|
323
|
+
system: "You generate privacy-safe route semantics JSON.",
|
|
324
|
+
user: buildAiPrompt(routes),
|
|
325
|
+
toolName: "return_route_semantics",
|
|
326
|
+
toolDescription: "Return privacy-safe route semantics JSON.",
|
|
327
|
+
failureMessage: "AI provider failed",
|
|
328
|
+
emptyMessage: "AI provider returned empty semantic generation content",
|
|
329
|
+
});
|
|
207
330
|
return new Map(
|
|
208
331
|
(parsed.routes ?? []).map((route) => [route.operation_source_key, route]),
|
|
209
332
|
);
|
|
@@ -233,12 +356,31 @@ const unavailableAiRoute = (operationSourceKey, confidenceReason) => ({
|
|
|
233
356
|
confidence_reason: confidenceReason,
|
|
234
357
|
});
|
|
235
358
|
|
|
236
|
-
const
|
|
359
|
+
const assertSnapshotRouteCoverage = ({ routes, snapshotRoutes }) => {
|
|
360
|
+
const expectedKeys = routes.map((route) => route.operation_source_key);
|
|
361
|
+
const actualKeys = snapshotRoutes.map((route) => route.operation_source_key);
|
|
362
|
+
const expected = new Set(expectedKeys);
|
|
363
|
+
const actual = new Set(actualKeys);
|
|
364
|
+
const missing = expectedKeys.filter((key) => !actual.has(key));
|
|
365
|
+
const extra = actualKeys.filter((key) => !expected.has(key));
|
|
366
|
+
if (
|
|
367
|
+
expectedKeys.length !== actualKeys.length ||
|
|
368
|
+
missing.length > 0 ||
|
|
369
|
+
extra.length > 0
|
|
370
|
+
) {
|
|
371
|
+
throw new Error(
|
|
372
|
+
`semantic snapshot route coverage mismatch: missing=${missing.join(",") || "none"} extra=${extra.join(",") || "none"}`,
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
};
|
|
376
|
+
|
|
377
|
+
const generateAiRoutesPerRoute = async ({ request, env, apiKey, routes }) => {
|
|
237
378
|
const aiRoutes = new Map();
|
|
238
379
|
for (const route of routes) {
|
|
239
380
|
try {
|
|
240
381
|
const routeAiRoutes = await callAiProvider({
|
|
241
382
|
request,
|
|
383
|
+
env,
|
|
242
384
|
apiKey,
|
|
243
385
|
routes: [route],
|
|
244
386
|
});
|
|
@@ -296,6 +438,7 @@ const buildAiSelectionPrompt = (selectionCandidates) =>
|
|
|
296
438
|
|
|
297
439
|
const callAiSelectionProvider = async ({
|
|
298
440
|
request,
|
|
441
|
+
env,
|
|
299
442
|
apiKey,
|
|
300
443
|
selectionCandidates,
|
|
301
444
|
routeBySelectionKey,
|
|
@@ -304,40 +447,16 @@ const callAiSelectionProvider = async ({
|
|
|
304
447
|
return new Map();
|
|
305
448
|
}
|
|
306
449
|
assertNoRawCodeStructure(selectionCandidates, "semantic_selection_prompt");
|
|
307
|
-
const
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
messages: [
|
|
318
|
-
{
|
|
319
|
-
role: "system",
|
|
320
|
-
content:
|
|
321
|
-
"You select adopted semantic values from privacy-safe candidates.",
|
|
322
|
-
},
|
|
323
|
-
{
|
|
324
|
-
role: "user",
|
|
325
|
-
content: buildAiSelectionPrompt(selectionCandidates),
|
|
326
|
-
},
|
|
327
|
-
],
|
|
328
|
-
response_format: { type: "json_object" },
|
|
329
|
-
}),
|
|
330
|
-
},
|
|
331
|
-
);
|
|
332
|
-
if (!response.ok) {
|
|
333
|
-
throw new Error(`AI selector failed: ${response.status}`);
|
|
334
|
-
}
|
|
335
|
-
const body = await response.json();
|
|
336
|
-
const content = body?.choices?.[0]?.message?.content;
|
|
337
|
-
if (typeof content !== "string" || content.trim() === "") {
|
|
338
|
-
throw new Error("AI selector returned empty semantic selection content");
|
|
339
|
-
}
|
|
340
|
-
const parsed = JSON.parse(content);
|
|
450
|
+
const parsed = await callJsonAiProvider({
|
|
451
|
+
config: resolveAiProviderConfig({ env, request, apiKey }),
|
|
452
|
+
system: "You select adopted semantic values from privacy-safe candidates.",
|
|
453
|
+
user: buildAiSelectionPrompt(selectionCandidates),
|
|
454
|
+
toolName: "return_semantic_selection",
|
|
455
|
+
toolDescription:
|
|
456
|
+
"Return selected semantic values from privacy-safe candidates.",
|
|
457
|
+
failureMessage: "AI selector failed",
|
|
458
|
+
emptyMessage: "AI selector returned empty semantic selection content",
|
|
459
|
+
});
|
|
341
460
|
return normalizeAiSelectionResponse({
|
|
342
461
|
parsed,
|
|
343
462
|
routeBySelectionKey,
|
|
@@ -1450,7 +1569,15 @@ const buildOperationAssignmentCollections = ({
|
|
|
1450
1569
|
};
|
|
1451
1570
|
};
|
|
1452
1571
|
|
|
1453
|
-
const buildSnapshot = ({
|
|
1572
|
+
const buildSnapshot = ({
|
|
1573
|
+
request,
|
|
1574
|
+
routes,
|
|
1575
|
+
aiRoutes,
|
|
1576
|
+
aiSelectionDecisions,
|
|
1577
|
+
routePlans = new Map(),
|
|
1578
|
+
previousSnapshot,
|
|
1579
|
+
deletedRouteCount = 0,
|
|
1580
|
+
}) => {
|
|
1454
1581
|
const generatedAt = new Date().toISOString();
|
|
1455
1582
|
const hashScope = semanticSnapshotHashScope(request);
|
|
1456
1583
|
const semanticSnapshotVersion = buildSemanticSnapshotVersion({
|
|
@@ -1463,44 +1590,87 @@ const buildSnapshot = ({ request, routes, aiRoutes, aiSelectionDecisions }) => {
|
|
|
1463
1590
|
const aiInferenceEvidence = [];
|
|
1464
1591
|
const semanticMeaningCandidates = [];
|
|
1465
1592
|
const catalogByGroup = new Map();
|
|
1593
|
+
let failedRouteCount = 0;
|
|
1594
|
+
const reusedRouteKeys = [];
|
|
1595
|
+
for (const [operationSourceKey, plan] of routePlans.entries()) {
|
|
1596
|
+
if (
|
|
1597
|
+
plan.origin === "unchanged_route_reused" ||
|
|
1598
|
+
plan.origin === "changed_route_semantic_reused"
|
|
1599
|
+
) {
|
|
1600
|
+
reusedRouteKeys.push(operationSourceKey);
|
|
1601
|
+
}
|
|
1602
|
+
}
|
|
1603
|
+
const previousContext = reusablePreviousContext({
|
|
1604
|
+
previousSnapshot,
|
|
1605
|
+
reusedRouteKeys,
|
|
1606
|
+
});
|
|
1607
|
+
sourceEvidenceRefs.push(...previousContext.sourceEvidenceRefs);
|
|
1608
|
+
targetObjectProfiles.push(...previousContext.profiles);
|
|
1609
|
+
targetObjectMappings.push(...previousContext.mappings);
|
|
1610
|
+
aiInferenceEvidence.push(...previousContext.aiInferenceEvidence);
|
|
1611
|
+
semanticMeaningCandidates.push(...previousContext.semanticMeaningCandidates);
|
|
1612
|
+
for (const entry of previousContext.catalogs) {
|
|
1613
|
+
const groupKey = `${entry.concept.toLowerCase()}::${entry.business_role.toLowerCase()}`;
|
|
1614
|
+
catalogByGroup.set(groupKey, { ...entry });
|
|
1615
|
+
}
|
|
1466
1616
|
|
|
1467
1617
|
const snapshotRoutes = routes.map((route) => {
|
|
1468
1618
|
const routeWithVersion = {
|
|
1469
1619
|
...route,
|
|
1470
1620
|
semantic_snapshot_version: semanticSnapshotVersion,
|
|
1471
1621
|
};
|
|
1622
|
+
const plan = routePlans.get(route.operation_source_key) ?? {
|
|
1623
|
+
origin: "new_route_ai_generated",
|
|
1624
|
+
route_input_hash: routeInputHash(route),
|
|
1625
|
+
};
|
|
1472
1626
|
const routeEvidenceRefs = buildSourceEvidenceRefs(
|
|
1473
1627
|
routeWithVersion,
|
|
1474
1628
|
hashScope,
|
|
1475
1629
|
);
|
|
1476
1630
|
sourceEvidenceRefs.push(...routeEvidenceRefs);
|
|
1631
|
+
if (
|
|
1632
|
+
(plan.origin === "unchanged_route_reused" ||
|
|
1633
|
+
plan.origin === "changed_route_semantic_reused") &&
|
|
1634
|
+
plan.previous_route
|
|
1635
|
+
) {
|
|
1636
|
+
return rebasePreviousRoute({
|
|
1637
|
+
previousRoute: plan.previous_route,
|
|
1638
|
+
currentRoute: route,
|
|
1639
|
+
semanticSnapshotVersion,
|
|
1640
|
+
plan,
|
|
1641
|
+
});
|
|
1642
|
+
}
|
|
1477
1643
|
const aiRoute = aiRoutes.get(route.operation_source_key);
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
route,
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
"Generated by AI from privacy-safe route evidence.",
|
|
1644
|
+
if (!aiRoute?.semantics || plan.origin === "changed_route_needs_review") {
|
|
1645
|
+
failedRouteCount += 1;
|
|
1646
|
+
}
|
|
1647
|
+
const unavailableReason =
|
|
1648
|
+
plan.origin === "changed_route_needs_review"
|
|
1649
|
+
? `changed_route_needs_review: ${plan.semantic_change_reason}`
|
|
1650
|
+
: (aiRoute?.confidence_reason ??
|
|
1651
|
+
"AI semantics were unavailable for this route.");
|
|
1652
|
+
const baseRoute =
|
|
1653
|
+
!aiRoute?.semantics || plan.origin === "changed_route_needs_review"
|
|
1654
|
+
? fallbackSemantics(routeWithVersion, unavailableReason, hashScope)
|
|
1655
|
+
: {
|
|
1656
|
+
operation_source_key: route.operation_source_key,
|
|
1657
|
+
semantic_snapshot_version: semanticSnapshotVersion,
|
|
1658
|
+
method: route.method,
|
|
1659
|
+
path_template: route.path_template,
|
|
1660
|
+
semantics: sanitizeSemantics(aiRoute.semantics, route),
|
|
1661
|
+
layer_evidence: sanitizeLayerEvidence(
|
|
1662
|
+
aiRoute.layer_evidence,
|
|
1663
|
+
route,
|
|
1664
|
+
hashScope,
|
|
1500
1665
|
),
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1666
|
+
confidence_reason: sanitizeText(
|
|
1667
|
+
String(
|
|
1668
|
+
aiRoute.confidence_reason ||
|
|
1669
|
+
"Generated by AI from privacy-safe route evidence.",
|
|
1670
|
+
),
|
|
1671
|
+
route,
|
|
1672
|
+
),
|
|
1673
|
+
};
|
|
1504
1674
|
const assignmentCollections = buildOperationAssignmentCollections({
|
|
1505
1675
|
route,
|
|
1506
1676
|
aiRoute,
|
|
@@ -1520,10 +1690,53 @@ const buildSnapshot = ({ request, routes, aiRoutes, aiSelectionDecisions }) => {
|
|
|
1520
1690
|
unresolved_operation_effects:
|
|
1521
1691
|
assignmentCollections.unresolvedOperationEffects,
|
|
1522
1692
|
source_evidence_refs: routeEvidenceRefs.map((entry) => entry.id),
|
|
1693
|
+
route_input_hash: plan.route_input_hash,
|
|
1694
|
+
previous_route_input_hash: plan.previous_route_input_hash,
|
|
1695
|
+
previous_route_semantic_hash: plan.previous_route_semantic_hash,
|
|
1696
|
+
semantic_origin:
|
|
1697
|
+
plan.origin === "changed_route_needs_review" && aiRoute?.semantics
|
|
1698
|
+
? "changed_route_needs_review"
|
|
1699
|
+
: plan.origin,
|
|
1700
|
+
semantic_change_reason: plan.semantic_change_reason,
|
|
1701
|
+
previous_semantic_snapshot_version:
|
|
1702
|
+
plan.previous_route?.semantic_snapshot_version,
|
|
1703
|
+
route_semantic_hash: routeSemanticHash({
|
|
1704
|
+
...baseRoute,
|
|
1705
|
+
operation_effects: assignmentCollections.operationEffects,
|
|
1706
|
+
unresolved_operation_effects:
|
|
1707
|
+
assignmentCollections.unresolvedOperationEffects,
|
|
1708
|
+
}),
|
|
1523
1709
|
};
|
|
1524
1710
|
});
|
|
1711
|
+
addUniqueBy(
|
|
1712
|
+
targetObjectProfiles,
|
|
1713
|
+
previousContext.profiles,
|
|
1714
|
+
(entry) => entry.id,
|
|
1715
|
+
);
|
|
1716
|
+
addUniqueBy(
|
|
1717
|
+
targetObjectMappings,
|
|
1718
|
+
previousContext.mappings,
|
|
1719
|
+
(entry) => entry.id,
|
|
1720
|
+
);
|
|
1721
|
+
addUniqueBy(
|
|
1722
|
+
aiInferenceEvidence,
|
|
1723
|
+
previousContext.aiInferenceEvidence,
|
|
1724
|
+
(entry) => entry.id,
|
|
1725
|
+
);
|
|
1726
|
+
addUniqueBy(
|
|
1727
|
+
semanticMeaningCandidates,
|
|
1728
|
+
previousContext.semanticMeaningCandidates,
|
|
1729
|
+
(entry) => entry.id,
|
|
1730
|
+
);
|
|
1525
1731
|
|
|
1526
|
-
|
|
1732
|
+
const uniqueSourceEvidenceRefs = [];
|
|
1733
|
+
addUniqueBy(
|
|
1734
|
+
uniqueSourceEvidenceRefs,
|
|
1735
|
+
sourceEvidenceRefs,
|
|
1736
|
+
(entry) => entry.id,
|
|
1737
|
+
);
|
|
1738
|
+
|
|
1739
|
+
const snapshot = validateSemanticSnapshotRequest({
|
|
1527
1740
|
project_key: request.project_key,
|
|
1528
1741
|
idempotency_key: sha256(
|
|
1529
1742
|
`${request.project_key}:${request.repository.repository_id}:${request.repository.merge_commit}:${request.repository.workflow_run_id ?? generatedAt}`,
|
|
@@ -1541,16 +1754,40 @@ const buildSnapshot = ({ request, routes, aiRoutes, aiSelectionDecisions }) => {
|
|
|
1541
1754
|
uncertain_routes: snapshotRoutes.filter(
|
|
1542
1755
|
(route) => route.semantics.route_confidence < 0.5,
|
|
1543
1756
|
).length,
|
|
1544
|
-
failed_routes:
|
|
1757
|
+
failed_routes: failedRouteCount,
|
|
1758
|
+
routes_reused: snapshotRoutes.filter((route) =>
|
|
1759
|
+
["unchanged_route_reused", "changed_route_semantic_reused"].includes(
|
|
1760
|
+
route.semantic_origin,
|
|
1761
|
+
),
|
|
1762
|
+
).length,
|
|
1763
|
+
routes_ai_generated: snapshotRoutes.filter((route) =>
|
|
1764
|
+
[
|
|
1765
|
+
"new_route_ai_generated",
|
|
1766
|
+
"changed_route_semantic_regenerated",
|
|
1767
|
+
].includes(route.semantic_origin),
|
|
1768
|
+
).length,
|
|
1769
|
+
routes_deleted: deletedRouteCount,
|
|
1770
|
+
routes_needs_review: snapshotRoutes.filter(
|
|
1771
|
+
(route) => route.semantic_origin === "changed_route_needs_review",
|
|
1772
|
+
).length,
|
|
1773
|
+
changed_routes_semantic_reused: snapshotRoutes.filter(
|
|
1774
|
+
(route) => route.semantic_origin === "changed_route_semantic_reused",
|
|
1775
|
+
).length,
|
|
1776
|
+
changed_routes_semantic_regenerated: snapshotRoutes.filter(
|
|
1777
|
+
(route) =>
|
|
1778
|
+
route.semantic_origin === "changed_route_semantic_regenerated",
|
|
1779
|
+
).length,
|
|
1545
1780
|
},
|
|
1546
1781
|
routes: snapshotRoutes,
|
|
1547
1782
|
target_object_profiles: targetObjectProfiles,
|
|
1548
1783
|
target_object_catalog: [...catalogByGroup.values()],
|
|
1549
1784
|
target_object_mappings: targetObjectMappings,
|
|
1550
|
-
source_evidence_refs:
|
|
1785
|
+
source_evidence_refs: uniqueSourceEvidenceRefs,
|
|
1551
1786
|
ai_inference_evidence: aiInferenceEvidence,
|
|
1552
1787
|
semantic_meaning_candidates: semanticMeaningCandidates,
|
|
1553
1788
|
});
|
|
1789
|
+
assertSnapshotRouteCoverage({ routes, snapshotRoutes: snapshot.routes });
|
|
1790
|
+
return snapshot;
|
|
1554
1791
|
};
|
|
1555
1792
|
|
|
1556
1793
|
const sanitizeText = (value, route) => {
|
|
@@ -1717,6 +1954,299 @@ const sanitizeLayerEvidence = (value, route, hashScope) => {
|
|
|
1717
1954
|
};
|
|
1718
1955
|
};
|
|
1719
1956
|
|
|
1957
|
+
const previousRouteMap = (previousSnapshot) =>
|
|
1958
|
+
new Map(
|
|
1959
|
+
safeArray(previousSnapshot?.routes).map((route) => [
|
|
1960
|
+
route.operation_source_key,
|
|
1961
|
+
route,
|
|
1962
|
+
]),
|
|
1963
|
+
);
|
|
1964
|
+
|
|
1965
|
+
const buildRouteEvidencePromptEntry = ({ route, hashScope }) => ({
|
|
1966
|
+
operation_source_key: route.operation_source_key,
|
|
1967
|
+
method: route.method,
|
|
1968
|
+
path_template: route.path_template,
|
|
1969
|
+
evidence_summaries: buildSourceEvidenceRefs(route, hashScope).map(
|
|
1970
|
+
(entry, index) => ({
|
|
1971
|
+
evidence_index: index,
|
|
1972
|
+
kind: entry.kind,
|
|
1973
|
+
summary: entry.summary,
|
|
1974
|
+
evidence_strength: entry.evidence_strength,
|
|
1975
|
+
}),
|
|
1976
|
+
),
|
|
1977
|
+
});
|
|
1978
|
+
|
|
1979
|
+
const classifyRoutesForSnapshot = async ({
|
|
1980
|
+
request,
|
|
1981
|
+
env,
|
|
1982
|
+
routes,
|
|
1983
|
+
previousSnapshot,
|
|
1984
|
+
aiProviderApiKey,
|
|
1985
|
+
hashScope,
|
|
1986
|
+
}) => {
|
|
1987
|
+
const previousRoutes = previousRouteMap(previousSnapshot);
|
|
1988
|
+
const plans = new Map();
|
|
1989
|
+
const routesRequiringGeneration = [];
|
|
1990
|
+
|
|
1991
|
+
for (const route of routes) {
|
|
1992
|
+
const currentHash = routeInputHash(route);
|
|
1993
|
+
const previousRoute = previousRoutes.get(route.operation_source_key);
|
|
1994
|
+
if (!previousRoute) {
|
|
1995
|
+
plans.set(route.operation_source_key, {
|
|
1996
|
+
origin: "new_route_ai_generated",
|
|
1997
|
+
route_input_hash: currentHash,
|
|
1998
|
+
});
|
|
1999
|
+
routesRequiringGeneration.push(route);
|
|
2000
|
+
continue;
|
|
2001
|
+
}
|
|
2002
|
+
|
|
2003
|
+
if (previousRoute.route_input_hash === currentHash) {
|
|
2004
|
+
plans.set(route.operation_source_key, {
|
|
2005
|
+
origin: "unchanged_route_reused",
|
|
2006
|
+
route_input_hash: currentHash,
|
|
2007
|
+
previous_route: previousRoute,
|
|
2008
|
+
previous_route_input_hash: previousRoute.route_input_hash,
|
|
2009
|
+
previous_route_semantic_hash:
|
|
2010
|
+
previousRoute.route_semantic_hash ?? routeSemanticHash(previousRoute),
|
|
2011
|
+
semantic_change_reason:
|
|
2012
|
+
"Route input hash matched the previous snapshot, so previous semantics were reused exactly.",
|
|
2013
|
+
});
|
|
2014
|
+
continue;
|
|
2015
|
+
}
|
|
2016
|
+
|
|
2017
|
+
const decision = await callAiReuseDecisionProvider({
|
|
2018
|
+
request,
|
|
2019
|
+
env,
|
|
2020
|
+
apiKey: aiProviderApiKey,
|
|
2021
|
+
route: buildRouteEvidencePromptEntry({ route, hashScope }),
|
|
2022
|
+
previousRoute,
|
|
2023
|
+
currentHash,
|
|
2024
|
+
});
|
|
2025
|
+
if (decision.decision === "reuse_previous") {
|
|
2026
|
+
plans.set(route.operation_source_key, {
|
|
2027
|
+
origin: "changed_route_semantic_reused",
|
|
2028
|
+
route_input_hash: currentHash,
|
|
2029
|
+
previous_route: previousRoute,
|
|
2030
|
+
previous_route_input_hash: previousRoute.route_input_hash,
|
|
2031
|
+
previous_route_semantic_hash:
|
|
2032
|
+
previousRoute.route_semantic_hash ?? routeSemanticHash(previousRoute),
|
|
2033
|
+
semantic_change_reason: sanitizeText(decision.reason, route),
|
|
2034
|
+
});
|
|
2035
|
+
continue;
|
|
2036
|
+
}
|
|
2037
|
+
if (decision.decision === "regenerate") {
|
|
2038
|
+
plans.set(route.operation_source_key, {
|
|
2039
|
+
origin: "changed_route_semantic_regenerated",
|
|
2040
|
+
route_input_hash: currentHash,
|
|
2041
|
+
previous_route: previousRoute,
|
|
2042
|
+
previous_route_input_hash: previousRoute.route_input_hash,
|
|
2043
|
+
previous_route_semantic_hash:
|
|
2044
|
+
previousRoute.route_semantic_hash ?? routeSemanticHash(previousRoute),
|
|
2045
|
+
semantic_change_reason: sanitizeText(decision.reason, route),
|
|
2046
|
+
});
|
|
2047
|
+
routesRequiringGeneration.push(route);
|
|
2048
|
+
continue;
|
|
2049
|
+
}
|
|
2050
|
+
plans.set(route.operation_source_key, {
|
|
2051
|
+
origin: "changed_route_needs_review",
|
|
2052
|
+
route_input_hash: currentHash,
|
|
2053
|
+
previous_route: previousRoute,
|
|
2054
|
+
previous_route_input_hash: previousRoute.route_input_hash,
|
|
2055
|
+
previous_route_semantic_hash:
|
|
2056
|
+
previousRoute.route_semantic_hash ?? routeSemanticHash(previousRoute),
|
|
2057
|
+
semantic_change_reason: sanitizeText(decision.reason, route),
|
|
2058
|
+
});
|
|
2059
|
+
}
|
|
2060
|
+
|
|
2061
|
+
const currentRouteKeys = new Set(
|
|
2062
|
+
routes.map((route) => route.operation_source_key),
|
|
2063
|
+
);
|
|
2064
|
+
const deletedRouteCount = safeArray(previousSnapshot?.routes).filter(
|
|
2065
|
+
(route) => !currentRouteKeys.has(route.operation_source_key),
|
|
2066
|
+
).length;
|
|
2067
|
+
|
|
2068
|
+
return {
|
|
2069
|
+
plans,
|
|
2070
|
+
routesRequiringGeneration,
|
|
2071
|
+
deletedRouteCount,
|
|
2072
|
+
};
|
|
2073
|
+
};
|
|
2074
|
+
|
|
2075
|
+
const rebasePreviousRoute = ({
|
|
2076
|
+
previousRoute,
|
|
2077
|
+
currentRoute,
|
|
2078
|
+
semanticSnapshotVersion,
|
|
2079
|
+
plan,
|
|
2080
|
+
}) => {
|
|
2081
|
+
const route = {
|
|
2082
|
+
...previousRoute,
|
|
2083
|
+
operation_source_key: currentRoute.operation_source_key,
|
|
2084
|
+
semantic_snapshot_version: semanticSnapshotVersion,
|
|
2085
|
+
previous_semantic_snapshot_version:
|
|
2086
|
+
previousRoute.semantic_snapshot_version ??
|
|
2087
|
+
plan.previous_semantic_snapshot_version,
|
|
2088
|
+
method: currentRoute.method,
|
|
2089
|
+
path_template: currentRoute.path_template,
|
|
2090
|
+
route_input_hash: plan.route_input_hash,
|
|
2091
|
+
previous_route_input_hash: plan.previous_route_input_hash,
|
|
2092
|
+
previous_route_semantic_hash: plan.previous_route_semantic_hash,
|
|
2093
|
+
semantic_origin: plan.origin,
|
|
2094
|
+
semantic_change_reason: plan.semantic_change_reason,
|
|
2095
|
+
};
|
|
2096
|
+
return {
|
|
2097
|
+
...route,
|
|
2098
|
+
route_semantic_hash: routeSemanticHash(route),
|
|
2099
|
+
};
|
|
2100
|
+
};
|
|
2101
|
+
|
|
2102
|
+
const fieldPathMatchesKeys = ({
|
|
2103
|
+
fieldPath,
|
|
2104
|
+
routeKeys,
|
|
2105
|
+
profileIds,
|
|
2106
|
+
mappingIds,
|
|
2107
|
+
catalogKeys,
|
|
2108
|
+
}) => {
|
|
2109
|
+
for (const routeKey of routeKeys) {
|
|
2110
|
+
if (fieldPath.startsWith(`routes[${routeKey}]`)) {
|
|
2111
|
+
return true;
|
|
2112
|
+
}
|
|
2113
|
+
}
|
|
2114
|
+
for (const profileId of profileIds) {
|
|
2115
|
+
if (fieldPath.startsWith(`target_object_profiles[${profileId}]`)) {
|
|
2116
|
+
return true;
|
|
2117
|
+
}
|
|
2118
|
+
}
|
|
2119
|
+
for (const mappingId of mappingIds) {
|
|
2120
|
+
if (fieldPath.startsWith(`target_object_mappings[${mappingId}]`)) {
|
|
2121
|
+
return true;
|
|
2122
|
+
}
|
|
2123
|
+
}
|
|
2124
|
+
for (const catalogKey of catalogKeys) {
|
|
2125
|
+
if (fieldPath.startsWith(`target_object_catalog[${catalogKey}]`)) {
|
|
2126
|
+
return true;
|
|
2127
|
+
}
|
|
2128
|
+
}
|
|
2129
|
+
return false;
|
|
2130
|
+
};
|
|
2131
|
+
|
|
2132
|
+
const reusablePreviousContext = ({ previousSnapshot, reusedRouteKeys }) => {
|
|
2133
|
+
const routeKeys = new Set(reusedRouteKeys);
|
|
2134
|
+
const profiles = safeArray(previousSnapshot?.target_object_profiles).filter(
|
|
2135
|
+
(profile) => routeKeys.has(profile.operation_source_key),
|
|
2136
|
+
);
|
|
2137
|
+
const mappings = safeArray(previousSnapshot?.target_object_mappings).filter(
|
|
2138
|
+
(mapping) => routeKeys.has(mapping.operation_source_key),
|
|
2139
|
+
);
|
|
2140
|
+
const profileIds = new Set(profiles.map((profile) => profile.id));
|
|
2141
|
+
const mappingIds = new Set(mappings.map((mapping) => mapping.id));
|
|
2142
|
+
const catalogKeys = new Set([
|
|
2143
|
+
...mappings.map((mapping) => mapping.target_object_key),
|
|
2144
|
+
...safeArray(previousSnapshot?.routes)
|
|
2145
|
+
.filter((route) => routeKeys.has(route.operation_source_key))
|
|
2146
|
+
.flatMap((route) =>
|
|
2147
|
+
safeArray(route.operation_effects).map(
|
|
2148
|
+
(effect) => effect.target_object_key,
|
|
2149
|
+
),
|
|
2150
|
+
),
|
|
2151
|
+
]);
|
|
2152
|
+
const catalogs = safeArray(previousSnapshot?.target_object_catalog).filter(
|
|
2153
|
+
(entry) => catalogKeys.has(entry.target_object_key),
|
|
2154
|
+
);
|
|
2155
|
+
const matches = (fieldPath) =>
|
|
2156
|
+
fieldPathMatchesKeys({
|
|
2157
|
+
fieldPath,
|
|
2158
|
+
routeKeys,
|
|
2159
|
+
profileIds,
|
|
2160
|
+
mappingIds,
|
|
2161
|
+
catalogKeys,
|
|
2162
|
+
});
|
|
2163
|
+
const aiInferenceEvidence = safeArray(
|
|
2164
|
+
previousSnapshot?.ai_inference_evidence,
|
|
2165
|
+
).filter(
|
|
2166
|
+
(entry) =>
|
|
2167
|
+
safeArray(entry.output_field_paths).length > 0 &&
|
|
2168
|
+
safeArray(entry.output_field_paths).every(matches),
|
|
2169
|
+
);
|
|
2170
|
+
const aiInferenceIds = new Set(aiInferenceEvidence.map((entry) => entry.id));
|
|
2171
|
+
const semanticMeaningCandidates = safeArray(
|
|
2172
|
+
previousSnapshot?.semantic_meaning_candidates,
|
|
2173
|
+
).filter(
|
|
2174
|
+
(entry) =>
|
|
2175
|
+
matches(entry.field_path) &&
|
|
2176
|
+
aiInferenceIds.has(entry.ai_candidate.ai_inference_evidence_ref) &&
|
|
2177
|
+
aiInferenceIds.has(entry.selection_ai_inference_evidence_ref),
|
|
2178
|
+
);
|
|
2179
|
+
const sourceEvidenceIds = new Set();
|
|
2180
|
+
for (const route of safeArray(previousSnapshot?.routes).filter((entry) =>
|
|
2181
|
+
routeKeys.has(entry.operation_source_key),
|
|
2182
|
+
)) {
|
|
2183
|
+
for (const ref of safeArray(route.source_evidence_refs)) {
|
|
2184
|
+
sourceEvidenceIds.add(ref);
|
|
2185
|
+
}
|
|
2186
|
+
for (const effect of safeArray(route.operation_effects)) {
|
|
2187
|
+
for (const ref of safeArray(effect.source_evidence_refs)) {
|
|
2188
|
+
sourceEvidenceIds.add(ref);
|
|
2189
|
+
}
|
|
2190
|
+
}
|
|
2191
|
+
for (const effect of safeArray(route.unresolved_operation_effects)) {
|
|
2192
|
+
for (const ref of safeArray(effect.source_evidence_refs)) {
|
|
2193
|
+
sourceEvidenceIds.add(ref);
|
|
2194
|
+
}
|
|
2195
|
+
}
|
|
2196
|
+
}
|
|
2197
|
+
for (const profile of profiles) {
|
|
2198
|
+
for (const ref of [
|
|
2199
|
+
...safeArray(profile.evidence_refs),
|
|
2200
|
+
...safeArray(profile.source_evidence_refs),
|
|
2201
|
+
]) {
|
|
2202
|
+
sourceEvidenceIds.add(ref);
|
|
2203
|
+
}
|
|
2204
|
+
}
|
|
2205
|
+
for (const mapping of mappings) {
|
|
2206
|
+
sourceEvidenceIds.add(mapping.grouping_evidence_ref);
|
|
2207
|
+
}
|
|
2208
|
+
for (const catalog of catalogs) {
|
|
2209
|
+
for (const ref of safeArray(catalog.grouping_evidence_refs)) {
|
|
2210
|
+
sourceEvidenceIds.add(ref);
|
|
2211
|
+
}
|
|
2212
|
+
}
|
|
2213
|
+
for (const evidence of aiInferenceEvidence) {
|
|
2214
|
+
for (const ref of safeArray(evidence.input_source_evidence_refs)) {
|
|
2215
|
+
sourceEvidenceIds.add(ref);
|
|
2216
|
+
}
|
|
2217
|
+
}
|
|
2218
|
+
for (const candidate of semanticMeaningCandidates) {
|
|
2219
|
+
for (const ref of [
|
|
2220
|
+
...safeArray(candidate.deterministic_candidate?.source_evidence_refs),
|
|
2221
|
+
...safeArray(candidate.ai_candidate?.source_evidence_refs),
|
|
2222
|
+
]) {
|
|
2223
|
+
sourceEvidenceIds.add(ref);
|
|
2224
|
+
}
|
|
2225
|
+
}
|
|
2226
|
+
const sourceEvidenceRefs = safeArray(
|
|
2227
|
+
previousSnapshot?.source_evidence_refs,
|
|
2228
|
+
).filter((entry) => sourceEvidenceIds.has(entry.id));
|
|
2229
|
+
return {
|
|
2230
|
+
profiles,
|
|
2231
|
+
mappings,
|
|
2232
|
+
catalogs,
|
|
2233
|
+
sourceEvidenceRefs,
|
|
2234
|
+
aiInferenceEvidence,
|
|
2235
|
+
semanticMeaningCandidates,
|
|
2236
|
+
};
|
|
2237
|
+
};
|
|
2238
|
+
|
|
2239
|
+
const addUniqueBy = (target, entries, keyFn) => {
|
|
2240
|
+
const seen = new Set(target.map(keyFn));
|
|
2241
|
+
for (const entry of entries) {
|
|
2242
|
+
const key = keyFn(entry);
|
|
2243
|
+
if (!seen.has(key)) {
|
|
2244
|
+
target.push(entry);
|
|
2245
|
+
seen.add(key);
|
|
2246
|
+
}
|
|
2247
|
+
}
|
|
2248
|
+
};
|
|
2249
|
+
|
|
1720
2250
|
const FORBIDDEN_CODE_STRUCTURE_PATTERNS = [
|
|
1721
2251
|
{ label: "file path", pattern: /\b[A-Za-z0-9_./-]+\.py\b/i },
|
|
1722
2252
|
{
|
|
@@ -1814,6 +2344,58 @@ const semanticSnapshotUrl = (baseUrl) => {
|
|
|
1814
2344
|
return `${normalized}/api/v1/semantic-snapshots`;
|
|
1815
2345
|
};
|
|
1816
2346
|
|
|
2347
|
+
const semanticSnapshotLatestUrl = (request) => {
|
|
2348
|
+
const url = new URL(
|
|
2349
|
+
`${semanticSnapshotUrl(request.clue_api_base_url)}/latest`,
|
|
2350
|
+
);
|
|
2351
|
+
url.searchParams.set("project_key", request.project_key);
|
|
2352
|
+
url.searchParams.set("environment", request.environment);
|
|
2353
|
+
url.searchParams.set("repository_id", request.repository.repository_id);
|
|
2354
|
+
url.searchParams.set("service_key", request.service.service_key);
|
|
2355
|
+
return url.toString();
|
|
2356
|
+
};
|
|
2357
|
+
|
|
2358
|
+
const fetchLatestSnapshot = async ({ request, env }) => {
|
|
2359
|
+
const apiKey = env.CLUE_API_KEY;
|
|
2360
|
+
if (!apiKey) {
|
|
2361
|
+
return null;
|
|
2362
|
+
}
|
|
2363
|
+
let response;
|
|
2364
|
+
try {
|
|
2365
|
+
response = await fetch(semanticSnapshotLatestUrl(request), {
|
|
2366
|
+
method: "GET",
|
|
2367
|
+
headers: {
|
|
2368
|
+
authorization: `Bearer ${apiKey}`,
|
|
2369
|
+
},
|
|
2370
|
+
});
|
|
2371
|
+
} catch {
|
|
2372
|
+
return null;
|
|
2373
|
+
}
|
|
2374
|
+
if (response.status === 404 || response.status === 204) {
|
|
2375
|
+
return null;
|
|
2376
|
+
}
|
|
2377
|
+
if (!response.ok) {
|
|
2378
|
+
return null;
|
|
2379
|
+
}
|
|
2380
|
+
const body = await response.json();
|
|
2381
|
+
const candidate = body?.snapshot ?? body;
|
|
2382
|
+
if (!candidate || !Array.isArray(candidate.routes)) {
|
|
2383
|
+
return null;
|
|
2384
|
+
}
|
|
2385
|
+
const parsed = validateSemanticSnapshotRequest(candidate);
|
|
2386
|
+
assertNoRawCodeStructure(parsed);
|
|
2387
|
+
return parsed;
|
|
2388
|
+
};
|
|
2389
|
+
|
|
2390
|
+
const validatePreviousSnapshotForReuse = (snapshot) => {
|
|
2391
|
+
if (!snapshot) {
|
|
2392
|
+
return null;
|
|
2393
|
+
}
|
|
2394
|
+
const parsed = validateSemanticSnapshotRequest(snapshot);
|
|
2395
|
+
assertNoRawCodeStructure(parsed);
|
|
2396
|
+
return parsed;
|
|
2397
|
+
};
|
|
2398
|
+
|
|
1817
2399
|
const sendSnapshot = async ({ request, env, snapshot }) => {
|
|
1818
2400
|
const apiKey = env.CLUE_API_KEY;
|
|
1819
2401
|
if (!apiKey) {
|
|
@@ -1832,10 +2414,24 @@ const sendSnapshot = async ({ request, env, snapshot }) => {
|
|
|
1832
2414
|
if (!response.ok) {
|
|
1833
2415
|
throw new Error(`Clue semantic snapshot upload failed: ${response.status}`);
|
|
1834
2416
|
}
|
|
1835
|
-
|
|
2417
|
+
const body = await response.json();
|
|
2418
|
+
try {
|
|
2419
|
+
return validateSemanticSnapshotResponse(body);
|
|
2420
|
+
} catch (error) {
|
|
2421
|
+
throw new Error(
|
|
2422
|
+
`Clue semantic snapshot upload response is invalid: ${
|
|
2423
|
+
error instanceof Error ? error.message : String(error)
|
|
2424
|
+
}`,
|
|
2425
|
+
);
|
|
2426
|
+
}
|
|
1836
2427
|
};
|
|
1837
2428
|
|
|
1838
|
-
export const runSemanticCi = async ({
|
|
2429
|
+
export const runSemanticCi = async ({
|
|
2430
|
+
repoRoot,
|
|
2431
|
+
request: rawRequest,
|
|
2432
|
+
env,
|
|
2433
|
+
previousSnapshot: providedPreviousSnapshot,
|
|
2434
|
+
}) => {
|
|
1839
2435
|
const request = validateSemanticCiRequest(rawRequest);
|
|
1840
2436
|
const hashScope = semanticSnapshotHashScope(request);
|
|
1841
2437
|
const files = await listAllowedPythonFiles({
|
|
@@ -1844,41 +2440,67 @@ export const runSemanticCi = async ({ repoRoot, request: rawRequest, env }) => {
|
|
|
1844
2440
|
excludedSourcePaths: request.excluded_source_paths,
|
|
1845
2441
|
});
|
|
1846
2442
|
const routes = await analyzeFastApiRoutes({ repoRoot, files });
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
)
|
|
1859
|
-
|
|
1860
|
-
|
|
2443
|
+
if (routes.length === 0) {
|
|
2444
|
+
throw new Error(
|
|
2445
|
+
"semantic CI discovered zero routes; check framework, backend root path, and allowed_source_paths",
|
|
2446
|
+
);
|
|
2447
|
+
}
|
|
2448
|
+
const previousSnapshot =
|
|
2449
|
+
providedPreviousSnapshot === undefined
|
|
2450
|
+
? await fetchLatestSnapshot({ request, env })
|
|
2451
|
+
: validatePreviousSnapshotForReuse(providedPreviousSnapshot);
|
|
2452
|
+
const previousRoutes = previousRouteMap(previousSnapshot);
|
|
2453
|
+
const routeNeedsAi = routes.some((route) => {
|
|
2454
|
+
const previousRoute = previousRoutes.get(route.operation_source_key);
|
|
2455
|
+
return (
|
|
2456
|
+
!previousRoute || previousRoute.route_input_hash !== routeInputHash(route)
|
|
2457
|
+
);
|
|
2458
|
+
});
|
|
2459
|
+
const aiProviderApiKey = routeNeedsAi ? requireAiProviderApiKey(env) : null;
|
|
2460
|
+
const {
|
|
2461
|
+
plans: routePlans,
|
|
2462
|
+
routesRequiringGeneration,
|
|
2463
|
+
deletedRouteCount,
|
|
2464
|
+
} = await classifyRoutesForSnapshot({
|
|
2465
|
+
request,
|
|
2466
|
+
env,
|
|
2467
|
+
routes,
|
|
2468
|
+
previousSnapshot,
|
|
2469
|
+
aiProviderApiKey,
|
|
2470
|
+
hashScope,
|
|
2471
|
+
});
|
|
2472
|
+
const promptRoutes = routesRequiringGeneration.map((route) =>
|
|
2473
|
+
buildRouteEvidencePromptEntry({ route, hashScope }),
|
|
2474
|
+
);
|
|
1861
2475
|
const aiRoutes = await generateAiRoutesPerRoute({
|
|
1862
2476
|
request,
|
|
2477
|
+
env,
|
|
1863
2478
|
apiKey: aiProviderApiKey,
|
|
1864
2479
|
routes: promptRoutes,
|
|
1865
2480
|
});
|
|
1866
2481
|
const { selectionCandidates, routeBySelectionKey } = buildSelectorCandidates({
|
|
1867
|
-
routes,
|
|
2482
|
+
routes: routesRequiringGeneration,
|
|
1868
2483
|
aiRoutes,
|
|
1869
2484
|
hashScope,
|
|
1870
2485
|
});
|
|
1871
|
-
const aiSelectionDecisions =
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
2486
|
+
const aiSelectionDecisions =
|
|
2487
|
+
selectionCandidates.length === 0
|
|
2488
|
+
? new Map()
|
|
2489
|
+
: await callAiSelectionProvider({
|
|
2490
|
+
request,
|
|
2491
|
+
env,
|
|
2492
|
+
apiKey: aiProviderApiKey,
|
|
2493
|
+
selectionCandidates,
|
|
2494
|
+
routeBySelectionKey,
|
|
2495
|
+
});
|
|
1877
2496
|
const snapshot = buildSnapshot({
|
|
1878
2497
|
request,
|
|
1879
2498
|
routes,
|
|
1880
2499
|
aiRoutes,
|
|
1881
2500
|
aiSelectionDecisions,
|
|
2501
|
+
routePlans,
|
|
2502
|
+
previousSnapshot,
|
|
2503
|
+
deletedRouteCount,
|
|
1882
2504
|
});
|
|
1883
2505
|
const upload = await sendSnapshot({ request, env, snapshot });
|
|
1884
2506
|
return {
|
|
@@ -1887,3 +2509,30 @@ export const runSemanticCi = async ({ repoRoot, request: rawRequest, env }) => {
|
|
|
1887
2509
|
upload,
|
|
1888
2510
|
};
|
|
1889
2511
|
};
|
|
2512
|
+
|
|
2513
|
+
export const runSemanticInventory = async ({ repoRoot, request }) => {
|
|
2514
|
+
const files = await listAllowedPythonFiles({
|
|
2515
|
+
repoRoot,
|
|
2516
|
+
allowedSourcePaths: request.allowed_source_paths,
|
|
2517
|
+
excludedSourcePaths: request.excluded_source_paths,
|
|
2518
|
+
});
|
|
2519
|
+
const routes = await analyzeFastApiRoutes({ repoRoot, files });
|
|
2520
|
+
if (routes.length === 0) {
|
|
2521
|
+
throw new Error(
|
|
2522
|
+
"semantic inventory discovered zero routes; check framework, backend root path, and allowed_source_paths",
|
|
2523
|
+
);
|
|
2524
|
+
}
|
|
2525
|
+
return {
|
|
2526
|
+
framework: request.framework,
|
|
2527
|
+
service_key: request.service_key,
|
|
2528
|
+
allowed_source_paths: request.allowed_source_paths,
|
|
2529
|
+
excluded_source_paths: request.excluded_source_paths,
|
|
2530
|
+
route_count: routes.length,
|
|
2531
|
+
operation_source_keys: routes.map((route) => route.operation_source_key),
|
|
2532
|
+
routes: routes.map((route) => ({
|
|
2533
|
+
operation_source_key: route.operation_source_key,
|
|
2534
|
+
method: route.method,
|
|
2535
|
+
path_template: route.path_template,
|
|
2536
|
+
})),
|
|
2537
|
+
};
|
|
2538
|
+
};
|