@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.
Files changed (103) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +328 -0
  3. package/dist/local/agentWorkflow.js +81 -0
  4. package/dist/local/aiGraph/links.js +635 -0
  5. package/dist/local/analyze/analyzer.js +2129 -0
  6. package/dist/local/analyze/behaviorContracts.js +169 -0
  7. package/dist/local/analyze/boilerplate.js +42 -0
  8. package/dist/local/analyze/callGraph.js +458 -0
  9. package/dist/local/analyze/classify.js +219 -0
  10. package/dist/local/analyze/clustering.js +357 -0
  11. package/dist/local/analyze/confirm.js +2422 -0
  12. package/dist/local/analyze/coverage.js +518 -0
  13. package/dist/local/analyze/coverageArtifacts.js +607 -0
  14. package/dist/local/analyze/frameworks.js +115 -0
  15. package/dist/local/analyze/linkage/conventions.js +160 -0
  16. package/dist/local/analyze/parseCache.js +164 -0
  17. package/dist/local/analyze/selfAssert.js +53 -0
  18. package/dist/local/analyze/symbols.js +430 -0
  19. package/dist/local/analyze/testLayer.js +135 -0
  20. package/dist/local/analyze/treeSitter/engine.js +1253 -0
  21. package/dist/local/analyze/treeSitter/languages.js +101 -0
  22. package/dist/local/autoProve.js +620 -0
  23. package/dist/local/cli.js +1468 -0
  24. package/dist/local/cliArgs.js +112 -0
  25. package/dist/local/corpusScope.js +162 -0
  26. package/dist/local/enrich/csv.js +348 -0
  27. package/dist/local/enrich/index.js +43 -0
  28. package/dist/local/enrich/markdown.js +193 -0
  29. package/dist/local/explain/explain.js +91 -0
  30. package/dist/local/exportCli.js +26 -0
  31. package/dist/local/flows/flowWalker.js +215 -0
  32. package/dist/local/flows/llmFlowDiscovery.js +567 -0
  33. package/dist/local/freshness/changed.js +280 -0
  34. package/dist/local/freshness/manifest.js +35 -0
  35. package/dist/local/freshness/status.js +30 -0
  36. package/dist/local/gaps/gaps.js +114 -0
  37. package/dist/local/generate/buckets.js +73 -0
  38. package/dist/local/generate/compareJudge.js +124 -0
  39. package/dist/local/generate/compareReport.js +538 -0
  40. package/dist/local/generate/compareScore.js +105 -0
  41. package/dist/local/generate/deriveImports.js +91 -0
  42. package/dist/local/generate/generator.js +2586 -0
  43. package/dist/local/generate/prompt.js +144 -0
  44. package/dist/local/generate/promptV5.js +438 -0
  45. package/dist/local/generate/providers.js +400 -0
  46. package/dist/local/generate/runHints.js +304 -0
  47. package/dist/local/graph/citations.js +73 -0
  48. package/dist/local/graph/confirmable.js +72 -0
  49. package/dist/local/graph/factories.js +210 -0
  50. package/dist/local/graph/ontology.js +18 -0
  51. package/dist/local/interactive.js +53 -0
  52. package/dist/local/jobs/jobStore.js +80 -0
  53. package/dist/local/jobs/notify.js +29 -0
  54. package/dist/local/jobs/runner.js +75 -0
  55. package/dist/local/ledger.js +117 -0
  56. package/dist/local/localConfig.js +112 -0
  57. package/dist/local/mcp.js +548 -0
  58. package/dist/local/operations.js +1749 -0
  59. package/dist/local/pack/coverageReport.js +192 -0
  60. package/dist/local/pack/exporter.js +195 -0
  61. package/dist/local/pack/schema.js +128 -0
  62. package/dist/local/pack/summary.js +127 -0
  63. package/dist/local/pack/validate.js +25 -0
  64. package/dist/local/proofRunnability.js +366 -0
  65. package/dist/local/recipe/dbSqljs.js +255 -0
  66. package/dist/local/reprove/paths.js +13 -0
  67. package/dist/local/reprove/scoped.js +136 -0
  68. package/dist/local/resolve/barrelWalker.js +178 -0
  69. package/dist/local/resolve/exportIndex.js +270 -0
  70. package/dist/local/resolve/importGraph.js +347 -0
  71. package/dist/local/resolve/resolver.js +122 -0
  72. package/dist/local/resolve/resolverCache.js +117 -0
  73. package/dist/local/rtm.js +413 -0
  74. package/dist/local/score/coverage.js +99 -0
  75. package/dist/local/score/doctor.js +67 -0
  76. package/dist/local/score/risk.js +362 -0
  77. package/dist/local/score/score.js +182 -0
  78. package/dist/local/types.js +1 -0
  79. package/dist/local/util/hash.js +16 -0
  80. package/dist/local/util/ids.js +16 -0
  81. package/dist/local/util/progress.js +8 -0
  82. package/dist/local/util/redact.js +39 -0
  83. package/dist/local/util/time.js +1 -0
  84. package/dist/local/util/walk.js +174 -0
  85. package/dist/local/viz/behaviorReportData.js +367 -0
  86. package/dist/local/viz/behaviorReportHtml.js +664 -0
  87. package/dist/local/viz/d3.bundle.js +3 -0
  88. package/dist/local/viz/html.js +1152 -0
  89. package/dist/local/viz/payload.js +525 -0
  90. package/dist/local/workspace.js +99 -0
  91. package/docs/agent-workflow.md +167 -0
  92. package/docs/agents/claude-code.md +43 -0
  93. package/docs/agents/codex.md +52 -0
  94. package/docs/agents/cursor.md +39 -0
  95. package/docs/agents/opencode.md +43 -0
  96. package/docs/agents/vscode.md +34 -0
  97. package/docs/local-proof-kit.md +269 -0
  98. package/package.json +92 -0
  99. package/scripts/spikes/dynamic-proof-jest-reporter.cjs +66 -0
  100. package/scripts/spikes/dynamic-proof-mocha-reporter.cjs +105 -0
  101. package/scripts/spikes/dynamic-proof-spike.mjs +2335 -0
  102. package/scripts/spikes/dynamic-proof-vitest-reporter.mjs +81 -0
  103. package/scripts/spikes/failure-summary.mjs +29 -0
@@ -0,0 +1,567 @@
1
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { buildCallsAdjacency } from "../analyze/clustering.js";
4
+ import { stripFence } from "../aiGraph/links.js";
5
+ import { stableId } from "../util/ids.js";
6
+ import { hashString, shortHash } from "../util/hash.js";
7
+ import { reportProgress } from "../util/progress.js";
8
+ import { workspacePaths } from "../workspace.js";
9
+ import { behaviorEntries, dedupeEntries, endpointEntries, rankEntries } from "./flowWalker.js";
10
+ export const AI_FLOWS_VERSION = "orangepro.ai_flows.v1";
11
+ export const AI_FLOWS_PROMPT_VERSION = "orangepro.ai.flows.v1";
12
+ /** ≤ 25 stored flows, ≤ 8 hops/flow (spec caps — over-cap proposals are rejected + counted). */
13
+ export const MAX_CANDIDATE_FLOWS = 25;
14
+ export const MAX_CANDIDATE_FLOW_HOPS = 8;
15
+ // Prompt-size bounds: the closed sets shown to the model stay deterministic
16
+ // and bounded so a monorepo cannot blow the context window. Entries are ranked
17
+ // endpoints-first; symbols come from the shown entries' CALLS neighborhoods.
18
+ const MAX_PROMPT_ENTRIES = 60;
19
+ const MAX_PROMPT_SYMBOLS = 300;
20
+ const MAX_COMPLETION_TOKENS = 3000;
21
+ export function aiFlowsPath(root) {
22
+ return join(workspacePaths(root).dir, "ai", "flows.json");
23
+ }
24
+ function buildAnchorContext(graph) {
25
+ const entries = dedupeEntries([
26
+ ...endpointEntries(graph.nodes, graph.edges),
27
+ ...behaviorEntries(graph.nodes, graph.edges)
28
+ ]);
29
+ const adjacency = buildCallsAdjacency(graph.nodes, graph.edges);
30
+ const anchorIds = new Set(entries.map((e) => e.start));
31
+ const knownEdges = new Set();
32
+ for (const [from, outgoing] of adjacency) {
33
+ anchorIds.add(from);
34
+ for (const edge of outgoing) {
35
+ anchorIds.add(edge.to);
36
+ knownEdges.add(`${from}->${edge.to}`);
37
+ }
38
+ }
39
+ return { entries, anchorIds, knownEdges, adjacency };
40
+ }
41
+ export function emptyRejections(proposed = 0) {
42
+ return {
43
+ proposed,
44
+ accepted: 0,
45
+ rejected_missing_anchor: 0,
46
+ rejected_unresolved_hop: 0,
47
+ rejected_cycle: 0,
48
+ rejected_over_cap: 0,
49
+ rejected_duplicate: 0,
50
+ rejected_malformed: 0
51
+ };
52
+ }
53
+ const REJECTION_BUCKETS = [
54
+ "rejected_missing_anchor",
55
+ "rejected_unresolved_hop",
56
+ "rejected_cycle",
57
+ "rejected_over_cap",
58
+ "rejected_duplicate",
59
+ "rejected_malformed"
60
+ ];
61
+ /**
62
+ * Merge a re-validation pass into prior accounting: earlier rejections stay,
63
+ * every flow dropped by the new pass moves from `accepted` into its bucket, so
64
+ * `proposed === accepted + Σ rejected_*` keeps holding at every stage.
65
+ */
66
+ function combineRejections(base, reval) {
67
+ const out = { ...base, accepted: reval.accepted };
68
+ for (const bucket of REJECTION_BUCKETS)
69
+ out[bucket] = base[bucket] + reval[bucket];
70
+ return out;
71
+ }
72
+ function flowKey(entryId, hopIds) {
73
+ return `${entryId}|${hopIds.join(">")}`;
74
+ }
75
+ /**
76
+ * Exclusive per-flow disposition: a proposed flow is either accepted or lands in
77
+ * exactly one rejection bucket, so `proposed === accepted + Σ rejected_*` holds
78
+ * by construction. Used identically by generate (closed prompt sets), apply
79
+ * (intersection of the recorded shown set with the current graph), and the
80
+ * re-analysis preservation path.
81
+ */
82
+ function classifyFlow(raw, ctx, acceptedKeys, acceptedCount) {
83
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
84
+ return { ok: false, bucket: "rejected_malformed", reason: "flow item is not an object" };
85
+ }
86
+ const obj = raw;
87
+ const entryId = typeof obj.entry_id === "string" ? obj.entry_id : "";
88
+ const hopIds = Array.isArray(obj.hop_ids) && obj.hop_ids.every((h) => typeof h === "string") ? obj.hop_ids : null;
89
+ if (!entryId || !hopIds || hopIds.length === 0) {
90
+ return { ok: false, bucket: "rejected_malformed", reason: "flow is missing entry_id or a non-empty hop_ids list", entry_id: entryId };
91
+ }
92
+ const entry = ctx.entriesById.get(entryId);
93
+ if (!entry) {
94
+ return { ok: false, bucket: "rejected_missing_anchor", reason: `entry_id "${entryId}" is not a real entry point`, entry_id: entryId };
95
+ }
96
+ const unresolved = hopIds.find((h) => !ctx.validHopIds.has(h));
97
+ if (unresolved !== undefined) {
98
+ return { ok: false, bucket: "rejected_unresolved_hop", reason: `hop id "${unresolved}" is not in the closed anchor set`, entry_id: entryId };
99
+ }
100
+ const path = [entry.start, ...hopIds];
101
+ if (new Set(path).size !== path.length) {
102
+ return { ok: false, bucket: "rejected_cycle", reason: "hop chain revisits a node", entry_id: entryId };
103
+ }
104
+ if (hopIds.length > MAX_CANDIDATE_FLOW_HOPS) {
105
+ return { ok: false, bucket: "rejected_over_cap", reason: `hop chain exceeds the ${MAX_CANDIDATE_FLOW_HOPS}-hop cap`, entry_id: entryId };
106
+ }
107
+ if (acceptedKeys.has(flowKey(entryId, hopIds))) {
108
+ return { ok: false, bucket: "rejected_duplicate", reason: "duplicate of an already-accepted flow", entry_id: entryId };
109
+ }
110
+ if (acceptedCount >= MAX_CANDIDATE_FLOWS) {
111
+ return { ok: false, bucket: "rejected_over_cap", reason: `flow count exceeds the ${MAX_CANDIDATE_FLOWS}-flow cap`, entry_id: entryId };
112
+ }
113
+ return {
114
+ ok: true,
115
+ flow: {
116
+ entry_id: entryId,
117
+ hop_ids: hopIds,
118
+ confidence: clampConfidence(typeof obj.confidence === "number" ? obj.confidence : 0.5),
119
+ ...(typeof obj.title === "string" && obj.title.trim() ? { title: obj.title.trim().slice(0, 120) } : {}),
120
+ ...(typeof obj.rationale === "string" && obj.rationale.trim() ? { rationale: obj.rationale.trim().slice(0, 300) } : {})
121
+ }
122
+ };
123
+ }
124
+ function validateProposals(items, ctx) {
125
+ const rejections = emptyRejections(items.length);
126
+ const flows = [];
127
+ const dropped = [];
128
+ const acceptedKeys = new Set();
129
+ for (const item of items) {
130
+ const verdict = classifyFlow(item, ctx, acceptedKeys, flows.length);
131
+ if (verdict.ok) {
132
+ flows.push(verdict.flow);
133
+ acceptedKeys.add(flowKey(verdict.flow.entry_id, verdict.flow.hop_ids));
134
+ rejections.accepted++;
135
+ }
136
+ else {
137
+ rejections[verdict.bucket]++;
138
+ dropped.push({ ...(verdict.entry_id ? { entry_id: verdict.entry_id } : {}), reason: verdict.reason });
139
+ }
140
+ }
141
+ return { flows, rejections, dropped };
142
+ }
143
+ /** Endpoints first (they are the flow anchors), then behaviors — both risk-ranked via flowWalker's rankEntries. */
144
+ function selectShownEntries(graph, anchors) {
145
+ const ranked = rankEntries({ nodes: graph.nodes, edges: graph.edges, candidate_edges: graph.candidate_edges, workspaceRoot: graph.workspace.root }, anchors.entries, anchors.adjacency);
146
+ const ordered = [...ranked.filter((e) => e.kind === "Endpoint"), ...ranked.filter((e) => e.kind === "Behavior")].slice(0, MAX_PROMPT_ENTRIES);
147
+ const counts = new Map();
148
+ for (const e of ordered)
149
+ counts.set(e.external_id, (counts.get(e.external_id) ?? 0) + 1);
150
+ const shown = ordered.map((e) => ({
151
+ prompt_id: (counts.get(e.external_id) ?? 0) > 1 ? `${e.external_id}#${e.start}` : e.external_id,
152
+ external_id: e.external_id,
153
+ kind: e.kind,
154
+ ...(e.title ? { title: e.title } : {}),
155
+ start: e.start
156
+ }));
157
+ const byPromptId = new Map(shown.map((se, i) => [se.prompt_id, ordered[i]]));
158
+ return { shown, byPromptId };
159
+ }
160
+ /** BFS over CALLS from the shown entry starts — the flow-relevant neighborhood, not an alphabetical slice. */
161
+ function selectShownSymbols(starts, adjacency, cap) {
162
+ const queue = [...new Set(starts)].sort((a, b) => a.localeCompare(b));
163
+ const seen = new Set(queue);
164
+ const ids = [];
165
+ let truncated = false;
166
+ while (queue.length) {
167
+ const id = queue.shift();
168
+ if (ids.length >= cap) {
169
+ truncated = true;
170
+ break;
171
+ }
172
+ ids.push(id);
173
+ const next = (adjacency.get(id) ?? [])
174
+ .map((e) => e.to)
175
+ .filter((to) => !seen.has(to))
176
+ .sort((a, b) => a.localeCompare(b));
177
+ for (const to of next) {
178
+ seen.add(to);
179
+ queue.push(to);
180
+ }
181
+ }
182
+ return { ids, truncated };
183
+ }
184
+ export async function generateAiFlows(root, graph, provider, clock) {
185
+ const anchors = buildAnchorContext(graph);
186
+ const nodesById = new Map(graph.nodes.map((n) => [n.external_id, n]));
187
+ const { shown: shownEntries, byPromptId } = selectShownEntries(graph, anchors);
188
+ const symbolSelection = selectShownSymbols(shownEntries.map((e) => e.start), anchors.adjacency, MAX_PROMPT_SYMBOLS);
189
+ const shownSymbols = symbolSelection.ids.map((id) => {
190
+ const node = nodesById.get(id);
191
+ return {
192
+ id,
193
+ ...(node?.title ? { title: node.title } : {}),
194
+ ...(typeof node?.properties.file === "string" ? { file: node.properties.file } : {})
195
+ };
196
+ });
197
+ const validHopIds = new Set(symbolSelection.ids);
198
+ const promptEntries = shownEntries.map((e) => ({
199
+ id: e.prompt_id,
200
+ kind: e.kind,
201
+ ...(e.title ? { title: e.title } : {}),
202
+ start: e.start
203
+ }));
204
+ const nodeSetHash = hashString(JSON.stringify({ entries: promptEntries, symbols: shownSymbols }));
205
+ const cacheKey = shortHash(JSON.stringify({
206
+ version: AI_FLOWS_VERSION,
207
+ prompt_version: AI_FLOWS_PROMPT_VERSION,
208
+ provider: provider.providerName,
209
+ model: provider.modelName,
210
+ node_set_hash: nodeSetHash
211
+ }));
212
+ const path = aiFlowsPath(root);
213
+ const cached = readArtifact(path);
214
+ if (cached.artifact?.cache_key === cacheKey) {
215
+ const hit = cached.artifact;
216
+ return {
217
+ mode: "generate",
218
+ ai_flows_path: path,
219
+ cache_hit: true,
220
+ model_provider: hit.model_provider,
221
+ model_name: hit.model_name,
222
+ cache_key: hit.cache_key,
223
+ entry_points: hit.entry_count,
224
+ anchor_symbols: hit.anchor_count,
225
+ flows: hit.flows.length,
226
+ rejections: hit.rejections,
227
+ warnings: hit.warnings
228
+ };
229
+ }
230
+ const warnings = [];
231
+ if (cached.invalid)
232
+ warnings.push("Existing AI flows artifact was invalid and is being regenerated.");
233
+ if (!shownEntries.length)
234
+ warnings.push("No entry points found for AI flow discovery.");
235
+ if (anchors.entries.length > MAX_PROMPT_ENTRIES) {
236
+ warnings.push(`Entry list capped at ${MAX_PROMPT_ENTRIES} of ${anchors.entries.length} for the prompt (endpoints first, risk-ranked).`);
237
+ }
238
+ if (symbolSelection.truncated) {
239
+ warnings.push(`Anchor symbol neighborhood capped at ${MAX_PROMPT_SYMBOLS} symbol(s) for the prompt.`);
240
+ }
241
+ reportProgress(`ai-flows: closed anchor set has ${shownEntries.length} entry point(s) and ${shownSymbols.length} symbol(s)`);
242
+ let items = [];
243
+ let uncacheable = false;
244
+ if (shownEntries.length) {
245
+ const req = buildAiFlowsPrompt(promptEntries, shownSymbols);
246
+ const completion = await provider.complete({
247
+ system: req.system,
248
+ user: req.user,
249
+ temperature: 0,
250
+ maxTokens: MAX_COMPLETION_TOKENS
251
+ });
252
+ const parsed = parseAiFlowItems(completion);
253
+ if (parsed === null || parsed.length === 0) {
254
+ // Wrong envelope / prose / empty list: warn and DO NOT cache, so a retry
255
+ // re-asks the model instead of replaying a sticky zero forever.
256
+ uncacheable = true;
257
+ warnings.push("Provider returned no parseable AI flow proposals; nothing staged or cached — re-run `opro ai-flows` to retry.");
258
+ }
259
+ else {
260
+ items = parsed;
261
+ }
262
+ }
263
+ const validated = validateProposals(items, { entriesById: byPromptId, validHopIds });
264
+ if (!uncacheable) {
265
+ writeArtifact(path, {
266
+ schema_version: AI_FLOWS_VERSION,
267
+ generated_at: clock(),
268
+ workspace_root_hash: graph.workspace.root_hash,
269
+ model_provider: provider.providerName,
270
+ model_name: provider.modelName,
271
+ prompt_version: AI_FLOWS_PROMPT_VERSION,
272
+ cache_key: cacheKey,
273
+ node_set_hash: nodeSetHash,
274
+ entry_count: shownEntries.length,
275
+ anchor_count: shownSymbols.length,
276
+ shown_entries: shownEntries,
277
+ shown_symbol_ids: symbolSelection.ids,
278
+ rejections: validated.rejections,
279
+ flows: validated.flows,
280
+ dropped_flows: validated.dropped,
281
+ warnings
282
+ });
283
+ }
284
+ reportProgress(`ai-flows: model proposed ${validated.rejections.proposed} flow(s), accepted ${validated.rejections.accepted}`);
285
+ return {
286
+ mode: "generate",
287
+ ai_flows_path: path,
288
+ cache_hit: false,
289
+ model_provider: provider.providerName,
290
+ model_name: provider.modelName,
291
+ cache_key: cacheKey,
292
+ entry_points: shownEntries.length,
293
+ anchor_symbols: shownSymbols.length,
294
+ flows: validated.flows.length,
295
+ rejections: validated.rejections,
296
+ warnings
297
+ };
298
+ }
299
+ /**
300
+ * Apply the staged artifact: reject a stale workspace outright (root_hash
301
+ * mismatch), then re-validate every flow against the INTERSECTION of the
302
+ * recorded shown set and the current graph (same exclusive rejection buckets —
303
+ * a generate-accepted flow that no longer resolves moves from accepted into
304
+ * the matching rejected_* counter), then store the survivors under
305
+ * `analysis.candidate_flows`. This writer never touches
306
+ * edges/candidate_edges/nodes/analysis.flows.
307
+ */
308
+ export function applyAiFlows(root, graph) {
309
+ const path = aiFlowsPath(root);
310
+ const { artifact, invalid } = readArtifact(path);
311
+ if (invalid) {
312
+ throw new Error(`AI flows artifact at ${path} is invalid or corrupted; re-run \`opro ai-flows\` to regenerate it.`);
313
+ }
314
+ if (!artifact)
315
+ throw new Error(`No AI flows artifact found at ${path}. Run \`opro ai-flows\` first.`);
316
+ if (artifact.workspace_root_hash !== graph.workspace.root_hash) {
317
+ throw new Error("AI flows artifact was generated for a different workspace state (root hash mismatch); re-run `opro ai-flows` against the current graph.");
318
+ }
319
+ const anchors = buildAnchorContext(graph);
320
+ // Closed set at apply: only entries/symbols that were SHOWN to the model AND
321
+ // still exist in the current graph are valid.
322
+ const currentEntryKeys = new Set(anchors.entries.map((e) => `${e.external_id}|${e.start}`));
323
+ const entriesById = new Map(artifact.shown_entries
324
+ .filter((se) => currentEntryKeys.has(`${se.external_id}|${se.start}`))
325
+ .map((se) => [se.prompt_id, { external_id: se.external_id, kind: se.kind, ...(se.title ? { title: se.title } : {}), start: se.start }]));
326
+ const validHopIds = new Set(artifact.shown_symbol_ids.filter((id) => anchors.anchorIds.has(id)));
327
+ const revalidated = validateProposals(artifact.flows, { entriesById, validHopIds });
328
+ const rejections = combineRejections(artifact.rejections, revalidated.rejections);
329
+ const provenance = {
330
+ model_provider: artifact.model_provider,
331
+ model_name: artifact.model_name,
332
+ prompt_version: artifact.prompt_version,
333
+ cache_key: artifact.cache_key
334
+ };
335
+ const flows = revalidated.flows.map((flow) => toCandidateFlow(flow, entriesById, anchors.knownEdges, provenance));
336
+ const meta = {
337
+ method: "llm_closed_anchor_proposal",
338
+ rejections,
339
+ options: { max_flows: MAX_CANDIDATE_FLOWS, max_hops: MAX_CANDIDATE_FLOW_HOPS },
340
+ provenance,
341
+ flows
342
+ };
343
+ const analysis = { ...(graph.analysis ?? emptyAnalysis()), candidate_flows: meta };
344
+ const warnings = [...artifact.warnings, ...revalidated.dropped.map((d) => `Skipped stale AI flow: ${d.reason}`)];
345
+ return {
346
+ graph: { ...graph, analysis },
347
+ result: {
348
+ mode: "apply",
349
+ ai_flows_path: path,
350
+ cache_key: artifact.cache_key,
351
+ applied_flows: flows.length,
352
+ rejections,
353
+ warnings
354
+ }
355
+ };
356
+ }
357
+ /**
358
+ * Re-validate previously applied candidate flows against a freshly rebuilt
359
+ * graph so they SURVIVE re-analysis (without this, every `analyze`/`start`
360
+ * re-run silently dropped the lane's output). Same exclusive rejection
361
+ * buckets: a stored flow whose entry vanished → rejected_missing_anchor; a
362
+ * stored flow with an unresolvable hop → rejected_unresolved_hop. hop_status
363
+ * is recomputed against the new graph's known CALLS edges.
364
+ */
365
+ export function revalidateCandidateFlowMeta(meta, graph) {
366
+ const anchors = buildAnchorContext(graph);
367
+ const entryByKey = new Map(anchors.entries.map((e) => [`${e.external_id}|${e.start}`, e]));
368
+ const entriesById = new Map();
369
+ const items = meta.flows.map((flow) => {
370
+ const start = flow.hops[0]?.from ?? "";
371
+ const key = `${flow.entry_point.external_id}|${start}`;
372
+ const entry = entryByKey.get(key);
373
+ if (entry)
374
+ entriesById.set(key, entry);
375
+ return {
376
+ entry_id: key,
377
+ hop_ids: flow.hops.map((h) => h.to),
378
+ ...(flow.title ? { title: flow.title } : {}),
379
+ ...(flow.rationale ? { rationale: flow.rationale } : {}),
380
+ confidence: flow.confidence
381
+ };
382
+ });
383
+ const revalidated = validateProposals(items, { entriesById, validHopIds: anchors.anchorIds });
384
+ return {
385
+ ...meta,
386
+ rejections: combineRejections(meta.rejections, revalidated.rejections),
387
+ flows: revalidated.flows.map((flow) => toCandidateFlow(flow, entriesById, anchors.knownEdges, meta.provenance))
388
+ };
389
+ }
390
+ function emptyAnalysis() {
391
+ return { test_files: 0, inferred_flows: 0, flows_truncated: 0, max_inferred_flows: 0, symbol_cap_hit: false };
392
+ }
393
+ function toCandidateFlow(flow, entriesById, knownEdges, provenance) {
394
+ const entry = entriesById.get(flow.entry_id);
395
+ if (!entry)
396
+ throw new Error(`AI flow entry "${flow.entry_id}" vanished between validation and apply.`);
397
+ const path = [entry.start, ...flow.hop_ids];
398
+ const hops = flow.hop_ids.map((to, i) => ({
399
+ from: path[i],
400
+ to,
401
+ evidence_strength: "candidate",
402
+ hop_status: knownEdges.has(`${path[i]}->${to}`) ? "matches_known_edge" : "unverified"
403
+ }));
404
+ return {
405
+ id: stableId("candidateflow", `${entry.kind}|${entry.external_id}|${path.join(">")}`),
406
+ entry_point: { external_id: entry.external_id, kind: entry.kind, ...(entry.title ? { title: entry.title } : {}) },
407
+ hops,
408
+ terminal: path[path.length - 1],
409
+ depth: hops.length,
410
+ review_status: "ai_suggested",
411
+ confidence: flow.confidence,
412
+ ...(flow.title ? { title: flow.title } : {}),
413
+ ...(flow.rationale ? { rationale: flow.rationale } : {}),
414
+ provenance: {
415
+ source_scope_id: `ai:${provenance.cache_key}`,
416
+ source_ref: ".orangepro/ai/flows.json",
417
+ detector: "ai_flows",
418
+ model_provider: provenance.model_provider,
419
+ model_name: provenance.model_name,
420
+ prompt_version: provenance.prompt_version,
421
+ cache_key: provenance.cache_key
422
+ }
423
+ };
424
+ }
425
+ function buildAiFlowsPrompt(entries, symbols) {
426
+ return {
427
+ system: "You propose CANDIDATE behavior flows over existing OrangePro graph ids. Return JSON only. Never invent ids. Never claim proof, coverage, or evidence — candidates are a worklist to verify.",
428
+ user: [
429
+ `TASK: Propose up to ${MAX_CANDIDATE_FLOWS} candidate flows. Each flow starts at one ENTRY id and chains up to ${MAX_CANDIDATE_FLOW_HOPS} SYMBOL ids in plausible call order.`,
430
+ 'Return JSON: {"flows":[{"entry_id":"...","hop_ids":["..."],"title":"short name","rationale":"short metadata-only reason","confidence":0.0}]}',
431
+ "Use only ids listed below. If unsure, omit the flow. Do not quote or infer source-code bodies.",
432
+ "",
433
+ "ENTRY_POINTS:",
434
+ JSON.stringify(entries, null, 2),
435
+ "",
436
+ "SYMBOLS:",
437
+ JSON.stringify(symbols, null, 2)
438
+ ].join("\n")
439
+ };
440
+ }
441
+ /** Parse the model output into raw flow items; null when the body is not JSON. */
442
+ function parseAiFlowItems(text) {
443
+ const body = stripFence(text);
444
+ let raw;
445
+ try {
446
+ raw = JSON.parse(body);
447
+ }
448
+ catch {
449
+ return null;
450
+ }
451
+ if (Array.isArray(raw))
452
+ return raw;
453
+ if (raw && typeof raw === "object" && Array.isArray(raw.flows)) {
454
+ return raw.flows;
455
+ }
456
+ return [];
457
+ }
458
+ function isNonNegativeInt(value) {
459
+ return typeof value === "number" && Number.isInteger(value) && value >= 0;
460
+ }
461
+ function isStringArray(value) {
462
+ return Array.isArray(value) && value.every((v) => typeof v === "string");
463
+ }
464
+ /**
465
+ * A rejection ledger is valid only when every counter is a non-negative integer
466
+ * AND the exclusive-bucket invariant holds: proposed === accepted + Σ rejected_*.
467
+ * A tampered/stale artifact with e.g. accepted > proposed is impossible and must
468
+ * be refused so the report's "model proposed N → accepted M" line can't lie.
469
+ */
470
+ function rejectionsAreValid(r) {
471
+ if (!r || typeof r !== "object")
472
+ return false;
473
+ const rec = r;
474
+ for (const key of ["proposed", "accepted", ...REJECTION_BUCKETS]) {
475
+ if (!isNonNegativeInt(rec[key]))
476
+ return false;
477
+ }
478
+ const bucketSum = REJECTION_BUCKETS.reduce((sum, key) => sum + rec[key], 0);
479
+ return rec.proposed === rec.accepted + bucketSum;
480
+ }
481
+ /**
482
+ * Stored `analysis.candidate_flows` is also untrusted (any process can rewrite
483
+ * graph.json): shape- + invariant-check it before re-validation so a malformed
484
+ * lane is dropped rather than crashing `analyze`.
485
+ */
486
+ export function isValidStoredCandidateFlowMeta(meta) {
487
+ if (!meta || typeof meta !== "object")
488
+ return false;
489
+ const m = meta;
490
+ return m.method === "llm_closed_anchor_proposal" && Array.isArray(m.flows) && rejectionsAreValid(m.rejections);
491
+ }
492
+ /**
493
+ * The artifact is UNTRUSTED input (a model produced its contents and any local
494
+ * process can rewrite the file): schema-validate every field before use so no
495
+ * unchecked value reaches the graph, the accounting, or the report.
496
+ */
497
+ function isValidArtifact(raw) {
498
+ if (!raw || typeof raw !== "object")
499
+ return false;
500
+ const a = raw;
501
+ if (a.schema_version !== AI_FLOWS_VERSION || a.prompt_version !== AI_FLOWS_PROMPT_VERSION)
502
+ return false;
503
+ for (const key of ["generated_at", "workspace_root_hash", "model_provider", "model_name", "cache_key", "node_set_hash"]) {
504
+ if (typeof a[key] !== "string")
505
+ return false;
506
+ }
507
+ if (!isNonNegativeInt(a.entry_count) || !isNonNegativeInt(a.anchor_count))
508
+ return false;
509
+ if (!rejectionsAreValid(a.rejections))
510
+ return false;
511
+ const r = a.rejections;
512
+ if (!Array.isArray(a.flows) || !Array.isArray(a.dropped_flows) || !isStringArray(a.warnings))
513
+ return false;
514
+ if (a.flows.length !== r.accepted)
515
+ return false;
516
+ for (const item of a.flows) {
517
+ if (!item || typeof item !== "object")
518
+ return false;
519
+ const f = item;
520
+ if (typeof f.entry_id !== "string" || !isStringArray(f.hop_ids) || f.hop_ids.length === 0)
521
+ return false;
522
+ if (typeof f.confidence !== "number" || !Number.isFinite(f.confidence))
523
+ return false;
524
+ if (f.title !== undefined && typeof f.title !== "string")
525
+ return false;
526
+ if (f.rationale !== undefined && typeof f.rationale !== "string")
527
+ return false;
528
+ }
529
+ if (!Array.isArray(a.shown_entries) || !isStringArray(a.shown_symbol_ids))
530
+ return false;
531
+ for (const item of a.shown_entries) {
532
+ if (!item || typeof item !== "object")
533
+ return false;
534
+ const se = item;
535
+ if (typeof se.prompt_id !== "string" || typeof se.external_id !== "string" || typeof se.start !== "string")
536
+ return false;
537
+ if (se.kind !== "Endpoint" && se.kind !== "Behavior")
538
+ return false;
539
+ if (se.title !== undefined && typeof se.title !== "string")
540
+ return false;
541
+ }
542
+ return true;
543
+ }
544
+ function readArtifact(path) {
545
+ if (!existsSync(path))
546
+ return { artifact: null, invalid: false };
547
+ try {
548
+ const raw = JSON.parse(readFileSync(path, "utf8"));
549
+ if (isValidArtifact(raw))
550
+ return { artifact: raw, invalid: false };
551
+ return { artifact: null, invalid: true };
552
+ }
553
+ catch {
554
+ return { artifact: null, invalid: true };
555
+ }
556
+ }
557
+ function writeArtifact(path, artifact) {
558
+ mkdirSync(dirname(path), { recursive: true });
559
+ const tmp = `${path}.tmp`;
560
+ writeFileSync(tmp, JSON.stringify(artifact, null, 2) + "\n", "utf8");
561
+ renameSync(tmp, path);
562
+ }
563
+ function clampConfidence(value) {
564
+ if (!Number.isFinite(value))
565
+ return 0.5;
566
+ return Math.max(0, Math.min(1, value));
567
+ }