@orangepro/orangepro-mcp 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +328 -0
- package/dist/local/agentWorkflow.js +81 -0
- package/dist/local/aiGraph/links.js +635 -0
- package/dist/local/analyze/analyzer.js +2129 -0
- package/dist/local/analyze/behaviorContracts.js +169 -0
- package/dist/local/analyze/boilerplate.js +42 -0
- package/dist/local/analyze/callGraph.js +458 -0
- package/dist/local/analyze/classify.js +219 -0
- package/dist/local/analyze/clustering.js +357 -0
- package/dist/local/analyze/confirm.js +2422 -0
- package/dist/local/analyze/coverage.js +518 -0
- package/dist/local/analyze/coverageArtifacts.js +607 -0
- package/dist/local/analyze/frameworks.js +115 -0
- package/dist/local/analyze/linkage/conventions.js +160 -0
- package/dist/local/analyze/parseCache.js +164 -0
- package/dist/local/analyze/selfAssert.js +53 -0
- package/dist/local/analyze/symbols.js +430 -0
- package/dist/local/analyze/testLayer.js +135 -0
- package/dist/local/analyze/treeSitter/engine.js +1253 -0
- package/dist/local/analyze/treeSitter/languages.js +101 -0
- package/dist/local/autoProve.js +620 -0
- package/dist/local/cli.js +1468 -0
- package/dist/local/cliArgs.js +112 -0
- package/dist/local/corpusScope.js +162 -0
- package/dist/local/enrich/csv.js +348 -0
- package/dist/local/enrich/index.js +43 -0
- package/dist/local/enrich/markdown.js +193 -0
- package/dist/local/explain/explain.js +91 -0
- package/dist/local/exportCli.js +26 -0
- package/dist/local/flows/flowWalker.js +215 -0
- package/dist/local/flows/llmFlowDiscovery.js +567 -0
- package/dist/local/freshness/changed.js +280 -0
- package/dist/local/freshness/manifest.js +35 -0
- package/dist/local/freshness/status.js +30 -0
- package/dist/local/gaps/gaps.js +114 -0
- package/dist/local/generate/buckets.js +73 -0
- package/dist/local/generate/compareJudge.js +124 -0
- package/dist/local/generate/compareReport.js +538 -0
- package/dist/local/generate/compareScore.js +105 -0
- package/dist/local/generate/deriveImports.js +91 -0
- package/dist/local/generate/generator.js +2586 -0
- package/dist/local/generate/prompt.js +144 -0
- package/dist/local/generate/promptV5.js +438 -0
- package/dist/local/generate/providers.js +400 -0
- package/dist/local/generate/runHints.js +304 -0
- package/dist/local/graph/citations.js +73 -0
- package/dist/local/graph/confirmable.js +72 -0
- package/dist/local/graph/factories.js +210 -0
- package/dist/local/graph/ontology.js +18 -0
- package/dist/local/interactive.js +53 -0
- package/dist/local/jobs/jobStore.js +80 -0
- package/dist/local/jobs/notify.js +29 -0
- package/dist/local/jobs/runner.js +75 -0
- package/dist/local/ledger.js +117 -0
- package/dist/local/localConfig.js +112 -0
- package/dist/local/mcp.js +548 -0
- package/dist/local/operations.js +1749 -0
- package/dist/local/pack/coverageReport.js +192 -0
- package/dist/local/pack/exporter.js +195 -0
- package/dist/local/pack/schema.js +128 -0
- package/dist/local/pack/summary.js +127 -0
- package/dist/local/pack/validate.js +25 -0
- package/dist/local/proofRunnability.js +366 -0
- package/dist/local/recipe/dbSqljs.js +255 -0
- package/dist/local/reprove/paths.js +13 -0
- package/dist/local/reprove/scoped.js +136 -0
- package/dist/local/resolve/barrelWalker.js +178 -0
- package/dist/local/resolve/exportIndex.js +270 -0
- package/dist/local/resolve/importGraph.js +347 -0
- package/dist/local/resolve/resolver.js +122 -0
- package/dist/local/resolve/resolverCache.js +117 -0
- package/dist/local/rtm.js +413 -0
- package/dist/local/score/coverage.js +99 -0
- package/dist/local/score/doctor.js +67 -0
- package/dist/local/score/risk.js +362 -0
- package/dist/local/score/score.js +182 -0
- package/dist/local/types.js +1 -0
- package/dist/local/util/hash.js +16 -0
- package/dist/local/util/ids.js +16 -0
- package/dist/local/util/progress.js +8 -0
- package/dist/local/util/redact.js +39 -0
- package/dist/local/util/time.js +1 -0
- package/dist/local/util/walk.js +174 -0
- package/dist/local/viz/behaviorReportData.js +367 -0
- package/dist/local/viz/behaviorReportHtml.js +664 -0
- package/dist/local/viz/d3.bundle.js +3 -0
- package/dist/local/viz/html.js +1152 -0
- package/dist/local/viz/payload.js +525 -0
- package/dist/local/workspace.js +99 -0
- package/docs/agent-workflow.md +167 -0
- package/docs/agents/claude-code.md +43 -0
- package/docs/agents/codex.md +52 -0
- package/docs/agents/cursor.md +39 -0
- package/docs/agents/opencode.md +43 -0
- package/docs/agents/vscode.md +34 -0
- package/docs/local-proof-kit.md +269 -0
- package/package.json +92 -0
- package/scripts/spikes/dynamic-proof-jest-reporter.cjs +66 -0
- package/scripts/spikes/dynamic-proof-mocha-reporter.cjs +105 -0
- package/scripts/spikes/dynamic-proof-spike.mjs +2335 -0
- package/scripts/spikes/dynamic-proof-vitest-reporter.mjs +81 -0
- package/scripts/spikes/failure-summary.mjs +29 -0
|
@@ -0,0 +1,635 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { makeCandidateEdge } from "../graph/factories.js";
|
|
4
|
+
import { hashString, shortHash } from "../util/hash.js";
|
|
5
|
+
import { reportProgress } from "../util/progress.js";
|
|
6
|
+
import { workspacePaths } from "../workspace.js";
|
|
7
|
+
export const AI_LINKS_VERSION = "orangepro.ai_links.v2";
|
|
8
|
+
export const AI_LINKS_PROMPT_VERSION = "orangepro.ai.links.v2";
|
|
9
|
+
const DEFAULT_SYMBOLS_PER_BEHAVIOR = 40;
|
|
10
|
+
const DEFAULT_MAX_PROMPT_TOKENS = 18_000;
|
|
11
|
+
const DEFAULT_MAX_BEHAVIORS = 80;
|
|
12
|
+
const CHARS_PER_TOKEN = 4;
|
|
13
|
+
const PER_BEHAVIOR_PROMPT_OVERHEAD_TOKENS = 80;
|
|
14
|
+
const MAX_RATE_LIMIT_ATTEMPTS = 8;
|
|
15
|
+
const STOP_WORDS = new Set([
|
|
16
|
+
"and",
|
|
17
|
+
"are",
|
|
18
|
+
"behavior",
|
|
19
|
+
"code",
|
|
20
|
+
"criteria",
|
|
21
|
+
"expected",
|
|
22
|
+
"file",
|
|
23
|
+
"for",
|
|
24
|
+
"from",
|
|
25
|
+
"has",
|
|
26
|
+
"have",
|
|
27
|
+
"into",
|
|
28
|
+
"not",
|
|
29
|
+
"the",
|
|
30
|
+
"this",
|
|
31
|
+
"that",
|
|
32
|
+
"test",
|
|
33
|
+
"tests",
|
|
34
|
+
"with"
|
|
35
|
+
]);
|
|
36
|
+
export function aiLinksPath(root) {
|
|
37
|
+
return join(workspacePaths(root).dir, "ai", "links.json");
|
|
38
|
+
}
|
|
39
|
+
export async function generateAiLinks(root, graph, provider, opts, clock) {
|
|
40
|
+
const context = buildAiLinkContext(graph, provider, resolveAiLinkOptions(opts));
|
|
41
|
+
const batchProgress = (current) => {
|
|
42
|
+
if (!opts.progressRange)
|
|
43
|
+
return { current, total: Math.max(1, context.batchCount) };
|
|
44
|
+
const start = opts.progressRange.start;
|
|
45
|
+
const span = Math.max(0, opts.progressRange.end - opts.progressRange.start);
|
|
46
|
+
const fraction = context.batchCount > 0 ? current / context.batchCount : 1;
|
|
47
|
+
return { current: Math.round(start + span * fraction), total: 100 };
|
|
48
|
+
};
|
|
49
|
+
const path = aiLinksPath(root);
|
|
50
|
+
const cached = readArtifact(path);
|
|
51
|
+
const cachedCompleted = cached?.cache_key === context.cacheKey ? cached.completed_batches ?? context.batchCount : 0;
|
|
52
|
+
if (cached?.cache_key === context.cacheKey && cachedCompleted >= context.batchCount) {
|
|
53
|
+
return {
|
|
54
|
+
mode: "generate",
|
|
55
|
+
ai_links_path: path,
|
|
56
|
+
cache_hit: true,
|
|
57
|
+
model_provider: cached.model_provider,
|
|
58
|
+
model_name: cached.model_name,
|
|
59
|
+
cache_key: cached.cache_key,
|
|
60
|
+
selected_behaviors: cached.behavior_count,
|
|
61
|
+
candidate_symbols: cached.symbol_count,
|
|
62
|
+
total_symbols: cached.total_symbol_count,
|
|
63
|
+
batch_count: cached.batch_count,
|
|
64
|
+
completed_batches: cached.completed_batches,
|
|
65
|
+
skipped_behaviors: cached.skipped_behaviors,
|
|
66
|
+
links: cached.links.length,
|
|
67
|
+
dropped_links: cached.dropped_links.length,
|
|
68
|
+
warnings: retainedWarnings(cached.warnings)
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
const resume = cached?.cache_key === context.cacheKey ? cached : null;
|
|
72
|
+
const warnings = resume ? retainedWarnings(resume.warnings) : [];
|
|
73
|
+
reportProgress(`ai-links: selected ${context.behaviorCandidates.length} behavior(s), ${context.candidateSymbolCount}/${context.totalSymbolCount} CodeSymbol candidate(s), ${context.options.symbolsPerBehavior} symbol(s) per behavior, ${context.batchCount} batch(es)`, batchProgress(0));
|
|
74
|
+
if (!resume) {
|
|
75
|
+
if (!context.behaviorCandidates.length)
|
|
76
|
+
warnings.push("No behavior targets selected for AI linking.");
|
|
77
|
+
if (!context.totalSymbolCount)
|
|
78
|
+
warnings.push("No CodeSymbol targets available for AI linking.");
|
|
79
|
+
if (context.skippedBehaviors) {
|
|
80
|
+
warnings.push(`${context.skippedBehaviors} behavior target(s) skipped by the AI link budget; increase ORANGEPRO_AI_LINK_MAX_BEHAVIORS or run \`opro ai-links --max-behaviors <n>\` to widen it.`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
let suggestions = resume ? [...resume.links] : [];
|
|
84
|
+
let dropped = resume ? [...resume.dropped_links] : [];
|
|
85
|
+
let completed = resume ? Math.min(resume.completed_batches ?? 0, context.batchCount) : 0;
|
|
86
|
+
const writeCurrent = () => {
|
|
87
|
+
writeArtifact(path, {
|
|
88
|
+
schema_version: AI_LINKS_VERSION,
|
|
89
|
+
generated_at: clock(),
|
|
90
|
+
workspace_root_hash: graph.workspace.root_hash,
|
|
91
|
+
model_provider: provider.providerName,
|
|
92
|
+
model_name: provider.modelName,
|
|
93
|
+
prompt_version: AI_LINKS_PROMPT_VERSION,
|
|
94
|
+
cache_key: context.cacheKey,
|
|
95
|
+
scope: context.options.scope,
|
|
96
|
+
node_set_hash: context.nodeSetHash,
|
|
97
|
+
behavior_count: context.behaviorCandidates.length,
|
|
98
|
+
symbol_count: context.candidateSymbolCount,
|
|
99
|
+
total_symbol_count: context.totalSymbolCount,
|
|
100
|
+
symbols_per_behavior: context.options.symbolsPerBehavior,
|
|
101
|
+
max_prompt_tokens: context.options.maxPromptTokens,
|
|
102
|
+
batch_count: context.batchCount,
|
|
103
|
+
completed_batches: completed,
|
|
104
|
+
skipped_behaviors: context.skippedBehaviors,
|
|
105
|
+
links: suggestions,
|
|
106
|
+
dropped_links: dropped,
|
|
107
|
+
warnings
|
|
108
|
+
});
|
|
109
|
+
};
|
|
110
|
+
if (!context.behaviorCandidates.length || !context.totalSymbolCount) {
|
|
111
|
+
writeCurrent();
|
|
112
|
+
}
|
|
113
|
+
for (let i = completed; context.behaviorCandidates.length && context.totalSymbolCount && i < context.batches.length; i++) {
|
|
114
|
+
const batch = context.batches[i];
|
|
115
|
+
try {
|
|
116
|
+
reportProgress(`ai-links: requesting batch ${i + 1}/${context.batchCount} (${batch.behaviors.length} behavior target(s))`, {
|
|
117
|
+
...batchProgress(i)
|
|
118
|
+
});
|
|
119
|
+
const parsed = await completeBatchWithBackoff(provider, batch);
|
|
120
|
+
const validated = validateSuggestions(parsed.links, graph, allowedPairs(batch));
|
|
121
|
+
suggestions = dedupeSuggestions([...suggestions, ...validated.links]);
|
|
122
|
+
dropped = [...dropped, ...parsed.dropped, ...validated.dropped];
|
|
123
|
+
completed = i + 1;
|
|
124
|
+
writeCurrent();
|
|
125
|
+
reportProgress(`ai-links: completed batch ${completed}/${context.batchCount}; ${suggestions.length} accepted, ${dropped.length} dropped`, {
|
|
126
|
+
...batchProgress(completed)
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
catch (error) {
|
|
130
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
131
|
+
warnings.push(`AI link batch ${i + 1}/${context.batchCount} failed: ${message}`);
|
|
132
|
+
writeCurrent();
|
|
133
|
+
break;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
if (!context.batches.length)
|
|
137
|
+
writeCurrent();
|
|
138
|
+
return {
|
|
139
|
+
mode: "generate",
|
|
140
|
+
ai_links_path: path,
|
|
141
|
+
cache_hit: false,
|
|
142
|
+
model_provider: provider.providerName,
|
|
143
|
+
model_name: provider.modelName,
|
|
144
|
+
cache_key: context.cacheKey,
|
|
145
|
+
selected_behaviors: context.behaviorCandidates.length,
|
|
146
|
+
candidate_symbols: context.candidateSymbolCount,
|
|
147
|
+
total_symbols: context.totalSymbolCount,
|
|
148
|
+
batch_count: context.batchCount,
|
|
149
|
+
completed_batches: completed,
|
|
150
|
+
skipped_behaviors: context.skippedBehaviors,
|
|
151
|
+
links: suggestions.length,
|
|
152
|
+
dropped_links: dropped.length,
|
|
153
|
+
warnings
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
export function applyAiLinks(root, graph) {
|
|
157
|
+
const path = aiLinksPath(root);
|
|
158
|
+
const artifact = readArtifact(path);
|
|
159
|
+
if (!artifact)
|
|
160
|
+
throw new Error(`No AI links artifact found at ${path}. Run \`opro ai-links\` first.`);
|
|
161
|
+
const validated = validateSuggestions(artifact.links, graph);
|
|
162
|
+
const applied = validated.links.map((link) => toCandidateEdge(link, artifact));
|
|
163
|
+
const before = graph.candidate_edges.length;
|
|
164
|
+
const merged = dedupeCandidateEdges([...graph.candidate_edges, ...applied]);
|
|
165
|
+
const after = merged.length;
|
|
166
|
+
const next = { ...graph, candidate_edges: merged };
|
|
167
|
+
const warnings = [...retainedWarnings(artifact.warnings), ...validated.dropped.map((d) => `Skipped AI link: ${d.reason}`)];
|
|
168
|
+
return {
|
|
169
|
+
graph: next,
|
|
170
|
+
result: {
|
|
171
|
+
mode: "apply",
|
|
172
|
+
ai_links_path: path,
|
|
173
|
+
cache_key: artifact.cache_key,
|
|
174
|
+
applied_links: after - before,
|
|
175
|
+
skipped_links: validated.dropped.length,
|
|
176
|
+
candidate_edges_before: before,
|
|
177
|
+
candidate_edges_after: after,
|
|
178
|
+
ai_linked: summarizeAiLinks(next),
|
|
179
|
+
warnings
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
export function summarizeAiLinks(graph) {
|
|
184
|
+
const links = graph.candidate_edges.filter((edge) => edge.review_status === "ai_suggested" &&
|
|
185
|
+
edge.evidence_strength === "weak" &&
|
|
186
|
+
edge.relationship_type === "MAY_RELATE_TO");
|
|
187
|
+
return {
|
|
188
|
+
links: links.length,
|
|
189
|
+
behaviors: new Set(links.map((edge) => edge.from_external_id)).size,
|
|
190
|
+
symbols: new Set(links.map((edge) => edge.to_external_id)).size
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
function resolveAiLinkOptions(opts) {
|
|
194
|
+
return {
|
|
195
|
+
scope: opts.all ? "all" : "gaps",
|
|
196
|
+
symbolsPerBehavior: clampPositiveInt(opts.symbolsPerBehavior, DEFAULT_SYMBOLS_PER_BEHAVIOR),
|
|
197
|
+
maxPromptTokens: clampPositiveInt(opts.maxPromptTokens, DEFAULT_MAX_PROMPT_TOKENS),
|
|
198
|
+
maxBehaviors: clampPositiveInt(opts.maxBehaviors, DEFAULT_MAX_BEHAVIORS)
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
function buildAiLinkContext(graph, provider, options) {
|
|
202
|
+
const allBehaviors = graph.nodes
|
|
203
|
+
.filter((n) => isBehaviorNode(n))
|
|
204
|
+
.filter((n) => options.scope === "all" || isAiLinkGap(graph, n))
|
|
205
|
+
.sort((a, b) => a.external_id.localeCompare(b.external_id))
|
|
206
|
+
.map((n) => behaviorPayload(graph, n));
|
|
207
|
+
const selectedBehaviors = allBehaviors.slice(0, options.maxBehaviors);
|
|
208
|
+
const allSymbols = graph.nodes
|
|
209
|
+
.filter((n) => n.kind === "CodeSymbol" && n.stale !== true && n.denominator_eligible !== false)
|
|
210
|
+
.sort((a, b) => a.external_id.localeCompare(b.external_id))
|
|
211
|
+
.map(symbolPayload);
|
|
212
|
+
const behaviorCandidates = selectedBehaviors.map((behavior) => ({
|
|
213
|
+
behavior,
|
|
214
|
+
code_symbols: shortlistSymbols(behavior, allSymbols, options.symbolsPerBehavior)
|
|
215
|
+
}));
|
|
216
|
+
const candidateSymbolIds = new Set(behaviorCandidates.flatMap((b) => b.code_symbols.map((s) => s.id)));
|
|
217
|
+
const nodeSet = JSON.stringify({ behavior_candidates: behaviorCandidates, options });
|
|
218
|
+
const nodeSetHash = hashString(nodeSet);
|
|
219
|
+
const cacheKey = shortHash(JSON.stringify({
|
|
220
|
+
version: AI_LINKS_VERSION,
|
|
221
|
+
prompt_version: AI_LINKS_PROMPT_VERSION,
|
|
222
|
+
provider: provider.providerName,
|
|
223
|
+
model: provider.modelName,
|
|
224
|
+
scope: options.scope,
|
|
225
|
+
node_set_hash: nodeSetHash
|
|
226
|
+
}));
|
|
227
|
+
const batches = buildBatches(behaviorCandidates, options.maxPromptTokens, cacheKey);
|
|
228
|
+
return {
|
|
229
|
+
behaviorCandidates,
|
|
230
|
+
totalSymbolCount: allSymbols.length,
|
|
231
|
+
candidateSymbolCount: candidateSymbolIds.size,
|
|
232
|
+
skippedBehaviors: Math.max(0, allBehaviors.length - selectedBehaviors.length),
|
|
233
|
+
batchCount: batches.length,
|
|
234
|
+
batches,
|
|
235
|
+
nodeSetHash,
|
|
236
|
+
cacheKey,
|
|
237
|
+
options
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
function isBehaviorNode(n) {
|
|
241
|
+
return n.kind === "Requirement" || n.kind === "UserFlow" || n.kind === "BusinessRule";
|
|
242
|
+
}
|
|
243
|
+
function isAiLinkGap(graph, behavior) {
|
|
244
|
+
const hasHardProof = graph.edges.some((e) => e.evidence_strength === "hard" &&
|
|
245
|
+
(e.relationship_type === "TESTED_BY" || e.relationship_type === "COVERS") &&
|
|
246
|
+
(e.from_external_id === behavior.external_id || e.to_external_id === behavior.external_id));
|
|
247
|
+
if (hasHardProof)
|
|
248
|
+
return false;
|
|
249
|
+
const symbolIds = new Set(graph.nodes.filter((n) => n.kind === "CodeSymbol").map((n) => n.external_id));
|
|
250
|
+
const hasHardCodeSymbolLink = graph.edges.some((e) => e.evidence_strength === "hard" &&
|
|
251
|
+
e.from_external_id === behavior.external_id &&
|
|
252
|
+
symbolIds.has(e.to_external_id));
|
|
253
|
+
return !hasHardCodeSymbolLink;
|
|
254
|
+
}
|
|
255
|
+
function behaviorPayload(graph, n) {
|
|
256
|
+
return {
|
|
257
|
+
id: n.external_id,
|
|
258
|
+
title: n.title ?? n.external_id,
|
|
259
|
+
kind: n.kind,
|
|
260
|
+
...(typeof n.provenance.source_ref === "string" ? { source_ref: n.provenance.source_ref } : {}),
|
|
261
|
+
acceptance_criteria: acceptanceCriteria(graph, n)
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
function acceptanceCriteria(graph, n) {
|
|
265
|
+
const direct = asStringArray(n.properties.acceptance_criteria);
|
|
266
|
+
const linked = graph.edges
|
|
267
|
+
.filter((e) => e.relationship_type === "HAS_ACCEPTANCE_CRITERION" && e.from_external_id === n.external_id)
|
|
268
|
+
.map((e) => graph.nodes.find((x) => x.external_id === e.to_external_id))
|
|
269
|
+
.filter((x) => Boolean(x))
|
|
270
|
+
.flatMap((x) => asStringArray(x.properties.text).concat(x.title ? [x.title] : []));
|
|
271
|
+
return [...new Set([...direct, ...linked])].slice(0, 8);
|
|
272
|
+
}
|
|
273
|
+
function symbolPayload(n) {
|
|
274
|
+
return {
|
|
275
|
+
id: n.external_id,
|
|
276
|
+
title: n.title ?? n.external_id,
|
|
277
|
+
file: typeof n.properties.file === "string" ? n.properties.file : "",
|
|
278
|
+
...(typeof n.properties.symbol_kind === "string" ? { symbol_kind: n.properties.symbol_kind } : {}),
|
|
279
|
+
...(typeof n.properties.signature === "string" ? { signature: n.properties.signature } : {})
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
function shortlistSymbols(behavior, symbols, limit) {
|
|
283
|
+
return symbols
|
|
284
|
+
.map((symbol) => ({ symbol, score: scoreSymbolForBehavior(behavior, symbol) }))
|
|
285
|
+
.sort((a, b) => b.score - a.score || a.symbol.file.localeCompare(b.symbol.file) || a.symbol.id.localeCompare(b.symbol.id))
|
|
286
|
+
.slice(0, limit)
|
|
287
|
+
.map((x) => x.symbol);
|
|
288
|
+
}
|
|
289
|
+
function scoreSymbolForBehavior(behavior, symbol) {
|
|
290
|
+
const behaviorTokens = tokenSet([
|
|
291
|
+
behavior.id,
|
|
292
|
+
behavior.title,
|
|
293
|
+
behavior.kind,
|
|
294
|
+
behavior.source_ref ?? "",
|
|
295
|
+
...behavior.acceptance_criteria
|
|
296
|
+
]);
|
|
297
|
+
const symbolTokens = tokenSet([symbol.id, symbol.title, symbol.file, symbol.symbol_kind ?? "", symbol.signature ?? ""]);
|
|
298
|
+
const pathTokens = tokenSet([behavior.source_ref ?? ""]);
|
|
299
|
+
const fileTokens = tokenSet([symbol.file]);
|
|
300
|
+
let score = 0;
|
|
301
|
+
for (const token of behaviorTokens)
|
|
302
|
+
if (symbolTokens.has(token))
|
|
303
|
+
score += 10;
|
|
304
|
+
for (const token of pathTokens)
|
|
305
|
+
if (fileTokens.has(token))
|
|
306
|
+
score += 6;
|
|
307
|
+
const behaviorText = `${behavior.title} ${behavior.acceptance_criteria.join(" ")}`.toLowerCase();
|
|
308
|
+
const symbolTitle = symbol.title.toLowerCase();
|
|
309
|
+
if (symbolTitle && behaviorText.includes(symbolTitle))
|
|
310
|
+
score += 25;
|
|
311
|
+
if (symbol.file && behavior.source_ref && sharedPathPrefixDepth(symbol.file, behavior.source_ref) > 0) {
|
|
312
|
+
score += sharedPathPrefixDepth(symbol.file, behavior.source_ref) * 3;
|
|
313
|
+
}
|
|
314
|
+
return score;
|
|
315
|
+
}
|
|
316
|
+
async function completeBatchWithBackoff(provider, batch) {
|
|
317
|
+
let lastError = null;
|
|
318
|
+
for (let attempt = 0; attempt < MAX_RATE_LIMIT_ATTEMPTS; attempt++) {
|
|
319
|
+
try {
|
|
320
|
+
const req = buildAiLinkPrompt(batch);
|
|
321
|
+
const completion = await provider.complete({ system: req.system, user: req.user, temperature: 0, maxTokens: 2000 });
|
|
322
|
+
return await parseOrRepairAiLinkSuggestions(provider, batch, completion);
|
|
323
|
+
}
|
|
324
|
+
catch (error) {
|
|
325
|
+
lastError = error;
|
|
326
|
+
const retryMs = rateLimitRetryMs(error);
|
|
327
|
+
if (retryMs === null || attempt >= MAX_RATE_LIMIT_ATTEMPTS - 1)
|
|
328
|
+
break;
|
|
329
|
+
reportProgress(`ai-links: rate limited on batch ${batch.index + 1}; retrying in ${Math.round(retryMs / 1000)}s`);
|
|
330
|
+
await sleep(retryMs);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
throw lastError instanceof Error ? lastError : new Error(String(lastError));
|
|
334
|
+
}
|
|
335
|
+
function rateLimitRetryMs(error) {
|
|
336
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
337
|
+
const lower = message.toLowerCase();
|
|
338
|
+
if (!lower.includes("429") && !lower.includes("rate limit") && !lower.includes("tpm"))
|
|
339
|
+
return null;
|
|
340
|
+
const match = message.match(/try again in\s+([0-9.]+)s/i);
|
|
341
|
+
const seconds = match ? Number.parseFloat(match[1]) : 10;
|
|
342
|
+
if (!Number.isFinite(seconds) || seconds < 0)
|
|
343
|
+
return 10_000;
|
|
344
|
+
return Math.min(60_000, Math.max(1_000, Math.ceil(seconds * 1000) + 250));
|
|
345
|
+
}
|
|
346
|
+
function sleep(ms) {
|
|
347
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
348
|
+
}
|
|
349
|
+
function tokenSet(values) {
|
|
350
|
+
const out = new Set();
|
|
351
|
+
for (const value of values) {
|
|
352
|
+
const spaced = value.replace(/([a-z0-9])([A-Z])/g, "$1 $2").toLowerCase();
|
|
353
|
+
for (const token of spaced.split(/[^a-z0-9]+/)) {
|
|
354
|
+
if (token.length < 3 || STOP_WORDS.has(token))
|
|
355
|
+
continue;
|
|
356
|
+
out.add(token);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
return out;
|
|
360
|
+
}
|
|
361
|
+
function sharedPathPrefixDepth(a, b) {
|
|
362
|
+
const left = a.split(/[\\/]+/).filter(Boolean);
|
|
363
|
+
const right = b.split(/[\\/]+/).filter(Boolean);
|
|
364
|
+
let depth = 0;
|
|
365
|
+
while (left[depth] && left[depth] === right[depth])
|
|
366
|
+
depth++;
|
|
367
|
+
return depth;
|
|
368
|
+
}
|
|
369
|
+
function buildBatches(behaviorCandidates, maxPromptTokens, cacheKey) {
|
|
370
|
+
const batches = [];
|
|
371
|
+
let current = [];
|
|
372
|
+
let currentTokens = 0;
|
|
373
|
+
for (const candidate of behaviorCandidates) {
|
|
374
|
+
const candidateTokens = estimateTokens(JSON.stringify(candidate, null, 2)) + PER_BEHAVIOR_PROMPT_OVERHEAD_TOKENS;
|
|
375
|
+
if (current.length && currentTokens + candidateTokens > maxPromptTokens) {
|
|
376
|
+
batches.push(makeBatch(batches.length, current, cacheKey));
|
|
377
|
+
current = [];
|
|
378
|
+
currentTokens = 0;
|
|
379
|
+
}
|
|
380
|
+
current.push(candidate);
|
|
381
|
+
currentTokens += candidateTokens;
|
|
382
|
+
}
|
|
383
|
+
if (current.length)
|
|
384
|
+
batches.push(makeBatch(batches.length, current, cacheKey));
|
|
385
|
+
return batches;
|
|
386
|
+
}
|
|
387
|
+
function makeBatch(index, behaviors, cacheKey) {
|
|
388
|
+
const body = JSON.stringify({ behavior_candidates: behaviors });
|
|
389
|
+
return {
|
|
390
|
+
index,
|
|
391
|
+
behaviors,
|
|
392
|
+
estimated_tokens: estimateTokens(body),
|
|
393
|
+
batch_key: shortHash(JSON.stringify({ cacheKey, index, ids: behaviors.map((b) => b.behavior.id) }))
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
function buildAiLinkPrompt(batch) {
|
|
397
|
+
return {
|
|
398
|
+
system: "You propose weak candidate links between existing OrangePro graph node ids. Return JSON only. Never invent ids. Never claim proof or coverage.",
|
|
399
|
+
user: [
|
|
400
|
+
"TASK: Link behavior ids to related CodeSymbol ids from the per-behavior closed sets below.",
|
|
401
|
+
"Return JSON: {\"links\":[{\"behavior_id\":\"...\",\"symbol_id\":\"...\",\"confidence\":0.0,\"rationale\":\"short metadata-only reason\"}]}",
|
|
402
|
+
"Use only CodeSymbol ids listed under the same behavior. If unsure, omit the link. Do not quote or infer source-code bodies.",
|
|
403
|
+
`Batch ${batch.index + 1}; estimated prompt tokens ${batch.estimated_tokens}; batch key ${batch.batch_key}.`,
|
|
404
|
+
"",
|
|
405
|
+
"BEHAVIOR_CANDIDATES:",
|
|
406
|
+
JSON.stringify(batch.behaviors, null, 2)
|
|
407
|
+
].join("\n")
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
function buildAiLinkRepairPrompt(batch, invalidOutput) {
|
|
411
|
+
return {
|
|
412
|
+
system: "You repair an AI-link response into valid JSON only. Return no markdown, no prose, and no ids outside the provided closed sets.",
|
|
413
|
+
user: [
|
|
414
|
+
"The previous response was not valid JSON for this schema:",
|
|
415
|
+
"{\"links\":[{\"behavior_id\":\"...\",\"symbol_id\":\"...\",\"confidence\":0.0,\"rationale\":\"short metadata-only reason\"}]}",
|
|
416
|
+
"",
|
|
417
|
+
"Rules:",
|
|
418
|
+
"- Use only CodeSymbol ids listed under the same behavior.",
|
|
419
|
+
"- If a relation is unclear, omit it.",
|
|
420
|
+
"- Return JSON only.",
|
|
421
|
+
"",
|
|
422
|
+
`Batch ${batch.index + 1}; batch key ${batch.batch_key}.`,
|
|
423
|
+
"BEHAVIOR_CANDIDATES:",
|
|
424
|
+
JSON.stringify(batch.behaviors, null, 2),
|
|
425
|
+
"",
|
|
426
|
+
"INVALID_RESPONSE_SNIPPET:",
|
|
427
|
+
invalidOutput.slice(0, 4000)
|
|
428
|
+
].join("\n")
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
async function parseOrRepairAiLinkSuggestions(provider, batch, completion) {
|
|
432
|
+
const parsed = parseAiLinkSuggestions(completion);
|
|
433
|
+
if (!parsed.retryable_non_json)
|
|
434
|
+
return { links: parsed.links, dropped: parsed.dropped };
|
|
435
|
+
reportProgress(`ai-links: retrying batch ${batch.index + 1} with strict JSON repair`);
|
|
436
|
+
const req = buildAiLinkRepairPrompt(batch, completion);
|
|
437
|
+
const repaired = parseAiLinkSuggestions(await provider.complete({ system: req.system, user: req.user, temperature: 0, maxTokens: 2000 }));
|
|
438
|
+
if (!repaired.retryable_non_json)
|
|
439
|
+
return { links: repaired.links, dropped: repaired.dropped };
|
|
440
|
+
return { links: [], dropped: [{ reason: "provider returned non-JSON AI link output after retry" }] };
|
|
441
|
+
}
|
|
442
|
+
function parseAiLinkSuggestions(text) {
|
|
443
|
+
const body = stripFence(text);
|
|
444
|
+
let raw;
|
|
445
|
+
try {
|
|
446
|
+
raw = JSON.parse(body);
|
|
447
|
+
}
|
|
448
|
+
catch {
|
|
449
|
+
return { links: [], dropped: [{ reason: "provider returned non-JSON AI link output" }], retryable_non_json: true };
|
|
450
|
+
}
|
|
451
|
+
if (!raw || typeof raw !== "object") {
|
|
452
|
+
return { links: [], dropped: [{ reason: "provider returned JSON that was not an object or array" }], retryable_non_json: false };
|
|
453
|
+
}
|
|
454
|
+
const items = Array.isArray(raw) ? raw : Array.isArray(raw.links) ? raw.links : [];
|
|
455
|
+
if (!items.length)
|
|
456
|
+
return { links: [], dropped: [], retryable_non_json: false };
|
|
457
|
+
const links = [];
|
|
458
|
+
const dropped = [];
|
|
459
|
+
for (const item of items) {
|
|
460
|
+
if (!item || typeof item !== "object") {
|
|
461
|
+
dropped.push({ reason: "AI link item is not an object" });
|
|
462
|
+
continue;
|
|
463
|
+
}
|
|
464
|
+
const obj = item;
|
|
465
|
+
const behavior_id = typeof obj.behavior_id === "string" ? obj.behavior_id : "";
|
|
466
|
+
const symbol_id = typeof obj.symbol_id === "string" ? obj.symbol_id : "";
|
|
467
|
+
if (!behavior_id || !symbol_id) {
|
|
468
|
+
dropped.push({ behavior_id, symbol_id, reason: "AI link missing behavior_id or symbol_id" });
|
|
469
|
+
continue;
|
|
470
|
+
}
|
|
471
|
+
links.push({
|
|
472
|
+
behavior_id,
|
|
473
|
+
symbol_id,
|
|
474
|
+
confidence: clampConfidence(typeof obj.confidence === "number" ? obj.confidence : 0.5),
|
|
475
|
+
...(typeof obj.rationale === "string" && obj.rationale.trim() ? { rationale: obj.rationale.trim().slice(0, 300) } : {})
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
return { links, dropped, retryable_non_json: false };
|
|
479
|
+
}
|
|
480
|
+
function validateSuggestions(links, graph, allowed) {
|
|
481
|
+
const ids = new Map(graph.nodes.map((n) => [n.external_id, n]));
|
|
482
|
+
const out = [];
|
|
483
|
+
const dropped = [];
|
|
484
|
+
const seen = new Set();
|
|
485
|
+
for (const link of links) {
|
|
486
|
+
const behavior = ids.get(link.behavior_id);
|
|
487
|
+
const symbol = ids.get(link.symbol_id);
|
|
488
|
+
if (!behavior || !isBehaviorNode(behavior)) {
|
|
489
|
+
dropped.push({ behavior_id: link.behavior_id, symbol_id: link.symbol_id, reason: "behavior_id is not an existing behavior node" });
|
|
490
|
+
continue;
|
|
491
|
+
}
|
|
492
|
+
if (!symbol || symbol.kind !== "CodeSymbol") {
|
|
493
|
+
dropped.push({ behavior_id: link.behavior_id, symbol_id: link.symbol_id, reason: "symbol_id is not an existing CodeSymbol node" });
|
|
494
|
+
continue;
|
|
495
|
+
}
|
|
496
|
+
const key = `${link.behavior_id}->${link.symbol_id}`;
|
|
497
|
+
if (allowed && !allowed.has(key)) {
|
|
498
|
+
dropped.push({
|
|
499
|
+
behavior_id: link.behavior_id,
|
|
500
|
+
symbol_id: link.symbol_id,
|
|
501
|
+
reason: "symbol_id was not in the provided candidate set for behavior_id"
|
|
502
|
+
});
|
|
503
|
+
continue;
|
|
504
|
+
}
|
|
505
|
+
if (seen.has(key))
|
|
506
|
+
continue;
|
|
507
|
+
seen.add(key);
|
|
508
|
+
out.push(link);
|
|
509
|
+
}
|
|
510
|
+
return { links: out, dropped };
|
|
511
|
+
}
|
|
512
|
+
function allowedPairs(batch) {
|
|
513
|
+
return new Set(batch.behaviors.flatMap((b) => b.code_symbols.map((s) => `${b.behavior.id}->${s.id}`)));
|
|
514
|
+
}
|
|
515
|
+
function dedupeSuggestions(links) {
|
|
516
|
+
const seen = new Set();
|
|
517
|
+
const out = [];
|
|
518
|
+
for (const link of links) {
|
|
519
|
+
const key = `${link.behavior_id}->${link.symbol_id}`;
|
|
520
|
+
if (seen.has(key))
|
|
521
|
+
continue;
|
|
522
|
+
seen.add(key);
|
|
523
|
+
out.push(link);
|
|
524
|
+
}
|
|
525
|
+
return out;
|
|
526
|
+
}
|
|
527
|
+
function toCandidateEdge(link, artifact) {
|
|
528
|
+
return makeCandidateEdge({
|
|
529
|
+
from_external_id: link.behavior_id,
|
|
530
|
+
to_external_id: link.symbol_id,
|
|
531
|
+
relationship_type: "MAY_RELATE_TO",
|
|
532
|
+
evidence_strength: "weak",
|
|
533
|
+
review_status: "ai_suggested",
|
|
534
|
+
reason: link.rationale || "AI-suggested closed-set behavior/code relation.",
|
|
535
|
+
confidence: link.confidence,
|
|
536
|
+
provenance: {
|
|
537
|
+
source_scope_id: `ai:${artifact.cache_key}`,
|
|
538
|
+
source_ref: ".orangepro/ai/links.json",
|
|
539
|
+
detector: "ai_links",
|
|
540
|
+
model_provider: artifact.model_provider,
|
|
541
|
+
model_name: artifact.model_name,
|
|
542
|
+
prompt_version: artifact.prompt_version,
|
|
543
|
+
cache_key: artifact.cache_key
|
|
544
|
+
}
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
function readArtifact(path) {
|
|
548
|
+
if (!existsSync(path))
|
|
549
|
+
return null;
|
|
550
|
+
try {
|
|
551
|
+
const raw = JSON.parse(readFileSync(path, "utf8"));
|
|
552
|
+
return raw.schema_version === AI_LINKS_VERSION ? raw : null;
|
|
553
|
+
}
|
|
554
|
+
catch {
|
|
555
|
+
return null;
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
function writeArtifact(path, artifact) {
|
|
559
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
560
|
+
const tmp = `${path}.tmp`;
|
|
561
|
+
writeFileSync(tmp, JSON.stringify(artifact, null, 2) + "\n", "utf8");
|
|
562
|
+
renameSync(tmp, path);
|
|
563
|
+
}
|
|
564
|
+
function dedupeCandidateEdges(edges) {
|
|
565
|
+
const seen = new Map();
|
|
566
|
+
for (const edge of edges)
|
|
567
|
+
if (!seen.has(edge.id))
|
|
568
|
+
seen.set(edge.id, edge);
|
|
569
|
+
return [...seen.values()];
|
|
570
|
+
}
|
|
571
|
+
function retainedWarnings(warnings) {
|
|
572
|
+
return warnings.filter((warning) => !/^AI link batch \d+\/\d+ failed:/.test(warning));
|
|
573
|
+
}
|
|
574
|
+
function asStringArray(value) {
|
|
575
|
+
if (Array.isArray(value))
|
|
576
|
+
return value.map((v) => String(v)).filter(Boolean);
|
|
577
|
+
if (typeof value === "string" && value.trim())
|
|
578
|
+
return [value.trim()];
|
|
579
|
+
return [];
|
|
580
|
+
}
|
|
581
|
+
function estimateTokens(text) {
|
|
582
|
+
return Math.max(1, Math.ceil(text.length / CHARS_PER_TOKEN));
|
|
583
|
+
}
|
|
584
|
+
function clampPositiveInt(value, fallback) {
|
|
585
|
+
if (value === undefined || !Number.isFinite(value))
|
|
586
|
+
return fallback;
|
|
587
|
+
return Math.max(1, Math.floor(value));
|
|
588
|
+
}
|
|
589
|
+
/** Hardened model-output cleanup: fence-strip, then balanced JSON-slice extraction. Shared with the ai-flows lane. */
|
|
590
|
+
export function stripFence(text) {
|
|
591
|
+
const match = text.match(/```(?:json)?\s*([\s\S]*?)```/i);
|
|
592
|
+
const body = (match ? match[1] : text).trim();
|
|
593
|
+
if (body.startsWith("{") || body.startsWith("["))
|
|
594
|
+
return body;
|
|
595
|
+
return extractJsonSlice(body) ?? body;
|
|
596
|
+
}
|
|
597
|
+
function clampConfidence(value) {
|
|
598
|
+
if (!Number.isFinite(value))
|
|
599
|
+
return 0.5;
|
|
600
|
+
return Math.max(0, Math.min(1, value));
|
|
601
|
+
}
|
|
602
|
+
function extractJsonSlice(text) {
|
|
603
|
+
const starts = [text.indexOf("{"), text.indexOf("[")].filter((i) => i >= 0).sort((a, b) => a - b);
|
|
604
|
+
for (const start of starts) {
|
|
605
|
+
const open = text[start];
|
|
606
|
+
const close = open === "{" ? "}" : "]";
|
|
607
|
+
let depth = 0;
|
|
608
|
+
let inString = false;
|
|
609
|
+
let escaped = false;
|
|
610
|
+
for (let i = start; i < text.length; i++) {
|
|
611
|
+
const ch = text[i];
|
|
612
|
+
if (inString) {
|
|
613
|
+
if (escaped)
|
|
614
|
+
escaped = false;
|
|
615
|
+
else if (ch === "\\")
|
|
616
|
+
escaped = true;
|
|
617
|
+
else if (ch === "\"")
|
|
618
|
+
inString = false;
|
|
619
|
+
continue;
|
|
620
|
+
}
|
|
621
|
+
if (ch === "\"") {
|
|
622
|
+
inString = true;
|
|
623
|
+
continue;
|
|
624
|
+
}
|
|
625
|
+
if (ch === open)
|
|
626
|
+
depth++;
|
|
627
|
+
else if (ch === close) {
|
|
628
|
+
depth--;
|
|
629
|
+
if (depth === 0)
|
|
630
|
+
return text.slice(start, i + 1);
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
return null;
|
|
635
|
+
}
|