@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,174 @@
1
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { hashBuffer } from "./hash.js";
4
+ const DEFAULT_IGNORE_NAMES = [
5
+ "node_modules",
6
+ ".git",
7
+ ".hg",
8
+ ".svn",
9
+ "dist",
10
+ "build",
11
+ "out",
12
+ ".next",
13
+ ".nuxt",
14
+ ".turbo",
15
+ "coverage",
16
+ ".orangepro",
17
+ ".venv",
18
+ "venv",
19
+ "__pycache__",
20
+ ".pytest_cache",
21
+ ".mypy_cache",
22
+ "vendor",
23
+ "target",
24
+ ".gradle",
25
+ ".idea",
26
+ ".vscode",
27
+ ".DS_Store",
28
+ // Agent/tooling scaffolding and nested worktrees — noise, not the project under test.
29
+ ".agent-worktrees",
30
+ ".worktrees",
31
+ ".claude",
32
+ ".cursor",
33
+ ".agent",
34
+ ".paul",
35
+ ".omc",
36
+ ".repowise",
37
+ ".agent-handoff",
38
+ // The kit's own exported artifacts must not be re-ingested as source.
39
+ "orangepro-evidence-pack.json",
40
+ "orangepro-evidence-pack.md"
41
+ ];
42
+ const DEFAULT_IGNORE_MATCHERS = [/(^|\/)[^/]*evidence-pack\.(json|md)$/];
43
+ /**
44
+ * High file-count ceiling: a pathological-run guard, NOT a tuning knob. The
45
+ * default is high enough to scan large monorepos (Mattermost-scale) end to end;
46
+ * lower it via ORANGEPRO_MAX_FILES only to bound an accidental huge directory.
47
+ */
48
+ export const DEFAULT_MAX_FILES = 100_000;
49
+ const DEFAULT_MAX_FILE_BYTES = 1_000_000;
50
+ /** Load ignore rules from defaults plus `.gitignore` and `.orangeproignore`. */
51
+ export function loadIgnore(root) {
52
+ const names = new Set(DEFAULT_IGNORE_NAMES);
53
+ const matchers = [...DEFAULT_IGNORE_MATCHERS];
54
+ for (const file of [".gitignore", ".orangeproignore"]) {
55
+ const abs = join(root, file);
56
+ if (!existsSync(abs))
57
+ continue;
58
+ let lines;
59
+ try {
60
+ lines = readFileSync(abs, "utf8").split(/\r?\n/);
61
+ }
62
+ catch {
63
+ continue;
64
+ }
65
+ for (const raw of lines) {
66
+ const line = raw.trim();
67
+ if (!line || line.startsWith("#") || line.startsWith("!"))
68
+ continue;
69
+ const cleaned = line.replace(/^\/+/, "").replace(/\/+$/, "");
70
+ if (!cleaned)
71
+ continue;
72
+ if (!cleaned.includes("/") && !cleaned.includes("*")) {
73
+ names.add(cleaned);
74
+ }
75
+ else {
76
+ matchers.push(globToRegExp(cleaned));
77
+ }
78
+ }
79
+ }
80
+ return { names, matchers };
81
+ }
82
+ function globToRegExp(glob) {
83
+ // Use a plain-ASCII sentinel for `**` so the file stays text (no NUL bytes)
84
+ // and `*` substitution does not re-match the globstar.
85
+ const GLOBSTAR = "__ORANGEPRO_GLOBSTAR__";
86
+ const escaped = glob
87
+ .replace(/[.+^${}()|[\]\\]/g, "\\$&")
88
+ .replace(/\*\*/g, GLOBSTAR)
89
+ .replace(/\*/g, "[^/]*")
90
+ .split(GLOBSTAR)
91
+ .join(".*")
92
+ .replace(/\?/g, "[^/]");
93
+ return new RegExp(`(^|/)${escaped}(/|$)`);
94
+ }
95
+ function isIgnored(relPath, baseName, rules) {
96
+ if (rules.names.has(baseName))
97
+ return true;
98
+ return rules.matchers.some((m) => m.test(relPath));
99
+ }
100
+ /** Whether a workspace-relative path is ignored (any segment in names, or a matcher hit). */
101
+ export function isPathIgnored(relPath, rules) {
102
+ if (relPath.split("/").some((segment) => rules.names.has(segment)))
103
+ return true;
104
+ return rules.matchers.some((m) => m.test(relPath));
105
+ }
106
+ /**
107
+ * Recursively walk the workspace, returning content-hashed file records.
108
+ * Honors ignore rules, size caps, and a global file cap so large or noisy
109
+ * checkouts stay bounded. Symlinks are not followed.
110
+ */
111
+ export function walkFiles(root, rules, opts = {}) {
112
+ const maxFiles = opts.maxFiles ?? DEFAULT_MAX_FILES;
113
+ const maxFileBytes = opts.maxFileBytes ?? DEFAULT_MAX_FILE_BYTES;
114
+ const records = [];
115
+ const visit = (dir, rel) => {
116
+ if (records.length >= maxFiles)
117
+ return;
118
+ let entries;
119
+ try {
120
+ entries = readdirSync(dir, { withFileTypes: true });
121
+ }
122
+ catch {
123
+ return;
124
+ }
125
+ entries.sort((a, b) => a.name.localeCompare(b.name));
126
+ for (const entry of entries) {
127
+ if (records.length >= maxFiles)
128
+ return;
129
+ const childRel = rel ? `${rel}/${entry.name}` : entry.name;
130
+ if (isIgnored(childRel, entry.name, rules))
131
+ continue;
132
+ const childAbs = join(dir, entry.name);
133
+ if (entry.isSymbolicLink())
134
+ continue;
135
+ if (entry.isDirectory()) {
136
+ visit(childAbs, childRel);
137
+ }
138
+ else if (entry.isFile()) {
139
+ let size;
140
+ try {
141
+ size = statSync(childAbs).size;
142
+ }
143
+ catch {
144
+ continue;
145
+ }
146
+ if (size > maxFileBytes)
147
+ continue;
148
+ let hash;
149
+ try {
150
+ hash = hashBuffer(readFileSync(childAbs));
151
+ }
152
+ catch {
153
+ continue;
154
+ }
155
+ records.push({ relPath: childRel, absPath: childAbs, size, hash });
156
+ }
157
+ }
158
+ };
159
+ visit(root, "");
160
+ return records;
161
+ }
162
+ /**
163
+ * Like {@link walkFiles}, but also reports whether the file-count cap was hit so
164
+ * the analyzer can surface scanned-vs-skipped counts instead of silently dropping
165
+ * files. (walkFiles stops at exactly maxFiles, so reaching it means more remained.)
166
+ */
167
+ export function walkFilesWithMeta(root, rules, opts = {}) {
168
+ const max_files = opts.maxFiles ?? DEFAULT_MAX_FILES;
169
+ // Probe one past the cap so "exactly max_files files" is not misreported as
170
+ // truncated (only > max_files means files were actually skipped).
171
+ const probed = walkFiles(root, rules, { ...opts, maxFiles: max_files + 1 });
172
+ const truncated = probed.length > max_files;
173
+ return { files: truncated ? probed.slice(0, max_files) : probed, truncated, max_files };
174
+ }
@@ -0,0 +1,367 @@
1
+ import path from "node:path";
2
+ import { buildRtm } from "../rtm.js";
3
+ import { rankRiskGaps } from "../score/risk.js";
4
+ /** Short human phrase per R-1 needs_setup category, for the "blocked because: …" panel copy. */
5
+ const BLOCK_CATEGORY_LABEL = {
6
+ module_not_found: "a missing module or dependency in the sandbox",
7
+ tsconfig_missing: "a monorepo tsconfig the sandbox can't resolve (a parent config the package extends)",
8
+ experimental_builtin: "an experimental Node builtin that needs a runtime flag",
9
+ engine_mismatch: "the runner Node being outside the declared engines range",
10
+ db_or_external: "a database or external service the sandbox lacks"
11
+ };
12
+ /**
13
+ * Group this run's needs_setup attempts by R-1 category and name the most common one. NOT a new
14
+ * classifier — it only tallies the category/reason autoProve already returned. Attempts with no
15
+ * category (unrunnable setup) fall into a generic "setup/runnability" bucket.
16
+ */
17
+ export function dominantBlockReason(needsSetup) {
18
+ if (needsSetup.length === 0)
19
+ return null;
20
+ const counts = new Map();
21
+ for (const a of needsSetup) {
22
+ const key = a.category && BLOCK_CATEGORY_LABEL[a.category] ? a.category : "runnability";
23
+ counts.set(key, (counts.get(key) ?? 0) + 1);
24
+ }
25
+ let topKey = "runnability";
26
+ let top = 0;
27
+ for (const [k, c] of counts) {
28
+ if (c > top) {
29
+ top = c;
30
+ topKey = k;
31
+ }
32
+ }
33
+ return { label: BLOCK_CATEGORY_LABEL[topKey] ?? "setup/runnability of the test in the sandbox", count: top, total: needsSetup.length };
34
+ }
35
+ function nodeFile(node) {
36
+ if (!node)
37
+ return "";
38
+ if (typeof node.properties.file === "string")
39
+ return node.properties.file;
40
+ return node.provenance.source_ref ?? "";
41
+ }
42
+ function groupOf(file) {
43
+ const parts = file.split("/").filter(Boolean);
44
+ if (parts[0] === "packages" && parts[1])
45
+ return parts[1];
46
+ return parts[0] ?? "core";
47
+ }
48
+ function symbolDisplay(id, nodesById) {
49
+ const node = nodesById.get(id);
50
+ return node?.title || id.replace(/^sym:/, "").split("#").pop() || id;
51
+ }
52
+ function nodeDescription(id, nodesById) {
53
+ const node = nodesById.get(id);
54
+ const candidate = node?.properties.behavior_description ??
55
+ node?.properties.description ??
56
+ node?.properties.summary ??
57
+ node?.properties.intent ??
58
+ node?.title;
59
+ return typeof candidate === "string" && candidate.trim() ? candidate.trim() : symbolDisplay(id, nodesById);
60
+ }
61
+ function serviceName(sig) {
62
+ const base = sig.split(".")[0] || sig;
63
+ return base.replace(/^.*#/, "");
64
+ }
65
+ function endpointTrigger(node) {
66
+ if (!node || node.kind !== "Endpoint")
67
+ return null;
68
+ const method = typeof node.properties.method === "string" ? node.properties.method : "";
69
+ const routePath = typeof node.properties.path === "string" ? node.properties.path : "";
70
+ if (!method && !routePath)
71
+ return null;
72
+ return { verb: method || "CALL", path: routePath || "/" };
73
+ }
74
+ /**
75
+ * Every symbol external_id that appears in a static flow (entry points + hop
76
+ * endpoints). Display-only: used to split the untested `none` bucket into
77
+ * "Reachable Untested" (symbol is in a flow) vs "No Signal". Reads existing
78
+ * flow data; mints nothing and touches no tier classification.
79
+ */
80
+ function flowSymbolIds(graph) {
81
+ const ids = new Set();
82
+ for (const flow of graph.analysis?.flows?.flows ?? []) {
83
+ ids.add(flow.entry_point.external_id);
84
+ for (const hop of flow.hops) {
85
+ ids.add(hop.from);
86
+ ids.add(hop.to);
87
+ }
88
+ }
89
+ return ids;
90
+ }
91
+ function isNoneTier(tier) {
92
+ return tier !== "proven" && tier !== "associated" && tier !== "runtime";
93
+ }
94
+ function summaryFromRows(rows, flowIds) {
95
+ const proven = rows.filter((r) => r.evidence_tier === "proven").length;
96
+ const associated = rows.filter((r) => r.evidence_tier === "associated" || r.evidence_tier === "runtime").length;
97
+ const noneRows = rows.filter((r) => isNoneTier(r.evidence_tier));
98
+ // DISPLAY-ONLY split of `none`: a none-tier symbol that shows up in a static flow is "Reachable Untested".
99
+ const reachableUntested = noneRows.filter((r) => flowIds.has(r.behavior_id)).length;
100
+ return { total: rows.length, proven, associated, none: noneRows.length, reachableUntested, noSignal: noneRows.length - reachableUntested };
101
+ }
102
+ /** Verbatim 0-dynamic-proof explainer copy. Rendered only when summary.proven === 0. */
103
+ const ZERO_PROOF_EXPLAINER = {
104
+ title: "Why dynamic proof is 0",
105
+ body: [
106
+ "OrangePro mapped behaviors, flows, and static test links without running your app. Dynamic proof requires executing tests in a sandbox and checking whether a test fails when the target behavior is mutated.",
107
+ "The statically linked behaviors have test evidence, but they are not verified yet. Treat them as likely covered, not proven.",
108
+ "OrangePro tries to dynamically prove the top 5 highest-risk behaviors by default. If setup is missing, the report shows the reason, such as missing dependencies, database setup, environment variables, or unsupported test runner configuration."
109
+ ]
110
+ };
111
+ function pipeline(graph, ledger, summary) {
112
+ const endpointCount = graph.nodes.filter((n) => n.kind === "Endpoint").length;
113
+ const implementedIn = graph.edges.filter((e) => e.relationship_type === "IMPLEMENTED_IN").length;
114
+ const calls = graph.edges.filter((e) => e.relationship_type === "CALLS");
115
+ const injectedCalls = calls.filter((e) => e.properties?.call_via === "injected");
116
+ const frameworkCalls = calls.filter((e) => e.evidence_strength === "framework-derived");
117
+ const flowMeta = graph.analysis?.flows;
118
+ const flowCount = flowMeta?.total_flows ?? 0;
119
+ const allSingleHop = flowCount > 0 && (flowMeta?.flows ?? []).every((f) => f.depth <= 1);
120
+ const truncatedHeavy = flowCount > 0 && (flowMeta?.truncated_flows ?? 0) > flowCount / 2;
121
+ const proofAttempted = ledger.records.some((r) => r.dynamic_proof?.proof_kind === "dynamic_targeted");
122
+ return [
123
+ { key: "behaviors", label: "Behaviors", pr: "#146", on: summary.total > 0 ? "1" : "0" },
124
+ { key: "endpoints", label: "Endpoint→handler", pr: "#148", on: implementedIn > 0 ? "1" : endpointCount > 0 ? "partial" : "0" },
125
+ { key: "calls", label: "Hard CALLS (DI)", pr: "#149", on: injectedCalls.length > 0 ? "1" : calls.length > 0 ? "partial" : "0" },
126
+ { key: "fw", label: "Framework-derived", pr: "#150", on: frameworkCalls.length > 0 ? "1" : "0" },
127
+ { key: "flows", label: "Flow walker", pr: "#151", on: flowCount > 0 ? (allSingleHop || truncatedHeavy ? "partial" : "1") : "0" },
128
+ { key: "proof", label: "Dynamic proof", pr: "proof", on: summary.proven > 0 ? "1" : proofAttempted ? "partial" : "0" }
129
+ ];
130
+ }
131
+ const KEEP_ACTION = "Keep generating tests for high-risk gaps and close each one through the proof handoff; Statically Linked signals stay separate.";
132
+ const HANDOFF_ACTION = "Run OrangePro from a coding agent with a model key; follow the returned proof handoff so the agent generates a runnable test and calls orangepro_prove_loop. Direct CLI users can run prove-loop after writing or choosing a real passing test.";
133
+ function proofGuidance(ledger, summary, dyn) {
134
+ if (summary.proven > 0) {
135
+ return {
136
+ state: "proven",
137
+ title: "Dynamically Proven is active",
138
+ body: `${summary.proven.toLocaleString()} behavior${summary.proven === 1 ? "" : "s"} closed with dynamic targeted proof. Each Dynamically Proven row requires a passing baseline, a targeted mutant failure at assertion, and the target running unmocked. Statically Linked signals stay separate.`,
139
+ action: KEEP_ACTION
140
+ };
141
+ }
142
+ // Prefer THIS RUN's dynamic-proof outcome (names the dominant block reason). Static breadth
143
+ // — behaviors, flows, Statically Linked signals — is unaffected; only the dynamic pass is small.
144
+ if (dyn) {
145
+ if (dyn.attempted === 0) {
146
+ return {
147
+ state: "not_started",
148
+ title: "0 Dynamically Proven — dynamic proof was not attempted",
149
+ body: "Dynamic proof was not attempted in this run. Static behavior mapping and Statically Linked signals are still available.",
150
+ action: HANDOFF_ACTION
151
+ };
152
+ }
153
+ const dom = dominantBlockReason(dyn.needsSetup);
154
+ const allBlocked = dyn.needsSetup.length > 0 && dyn.needsSetup.length >= dyn.attempted;
155
+ const plural = dyn.attempted === 1 ? "" : "s";
156
+ if (allBlocked && dom) {
157
+ return {
158
+ state: "attempted",
159
+ title: `0 Dynamically Proven — top ${dyn.attempted} attempted, all setup-blocked`,
160
+ body: `Dynamic proof attempted ${dyn.attempted} target${plural}; all were blocked by ${dom.label} (${dom.count}/${dom.total}). This is a sandbox setup gap, not a static-test failure — the Statically Linked signals are still shown.`,
161
+ action: HANDOFF_ACTION
162
+ };
163
+ }
164
+ const because = dom ? ` Blocked because: ${dom.label} (${dom.count}/${dom.total}).` : "";
165
+ return {
166
+ state: "attempted",
167
+ title: `0 Dynamically Proven — top ${dyn.attempted} attempted, 0 closed`,
168
+ body: `OrangePro mapped this repo statically. Dynamic proof is a targeted verification pass: it runs existing or generated tests, mutates the exact behavior, and promotes only tests that fail at an assertion. This run attempted the top ${dyn.attempted} eligible behavior${plural} and closed 0.${because} Static test signals stay Statically Linked.`,
169
+ action: HANDOFF_ACTION
170
+ };
171
+ }
172
+ // Standalone report regen (no THIS-RUN data) → derive from the ledger only.
173
+ const dynamicAttempts = ledger.records.filter((r) => r.dynamic_proof?.proof_kind === "dynamic_targeted");
174
+ if (dynamicAttempts.length > 0) {
175
+ return {
176
+ state: "attempted",
177
+ title: "0 Dynamically Proven — dynamic proof ran, none closed yet",
178
+ body: "OrangePro found dynamic proof attempts in the local ledger, but none satisfied the full Dynamically Proven gate. Static tests still show as Statically Linked only.",
179
+ action: "Use the coding-agent handoff to repair or regenerate the test, then run the provided prove_loop step again."
180
+ };
181
+ }
182
+ return {
183
+ state: "not_started",
184
+ title: "0 Dynamically Proven means no dynamic proof has run yet",
185
+ body: "This report is an analysis pass. It can find behaviors, static test evidence, risk, and reachable flows, but it will not label anything Dynamically Proven until a real test kills a targeted mutant of the behavior.",
186
+ action: HANDOFF_ACTION
187
+ };
188
+ }
189
+ function scanBlock(graph, rows) {
190
+ const services = new Map();
191
+ for (const row of rows) {
192
+ const sig = row.behavior || row.code_symbol;
193
+ const name = serviceName(sig);
194
+ if (!name)
195
+ continue;
196
+ services.set(name, (services.get(name) ?? 0) + 1);
197
+ }
198
+ const tests = graph.nodes.filter((n) => n.kind === "TestCase");
199
+ const integration = tests.filter((n) => n.properties.test_layer === "integration" || n.properties.test_layer === "api" || n.properties.test_layer === "e2e").length;
200
+ const unit = tests.filter((n) => n.properties.test_layer === "unit" || n.properties.test_layer === "component").length;
201
+ const denominator = graph.analysis?.denominator;
202
+ const excludedCount = (denominator?.excluded_boilerplate ?? 0) +
203
+ (denominator?.excluded_infra ?? 0) +
204
+ (denominator?.excluded_generated ?? 0) +
205
+ (denominator?.excluded_test_inferred ?? 0);
206
+ return {
207
+ services: [...services.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, 50),
208
+ serviceTotal: services.size,
209
+ tests: { total: tests.length, integration, unit },
210
+ excluded: {
211
+ count: excludedCount > 0 ? String(excludedCount) : "0",
212
+ text: "non-behavior symbols were excluded from the behavior count — generated code, framework internals, test-inferred flows, and infrastructure plumbing."
213
+ }
214
+ };
215
+ }
216
+ function behaviorLists(rows, flowIds) {
217
+ const groups = new Map();
218
+ const behaviors = rows.map((row) => {
219
+ const group = groupOf(row.file);
220
+ groups.set(group, (groups.get(group) ?? 0) + 1);
221
+ const tier = row.evidence_tier === "proven" ? "proven" : row.evidence_tier === "associated" || row.evidence_tier === "runtime" ? "assoc" : "none";
222
+ return {
223
+ sig: row.behavior || row.code_symbol,
224
+ group,
225
+ file: row.file,
226
+ tier,
227
+ // DISPLAY-ONLY: whether this symbol appears in a static flow (splits none → Reachable Untested vs No Signal).
228
+ reachable: flowIds.has(row.behavior_id),
229
+ desc: row.suggested_next_test || row.test_signal || row.status
230
+ };
231
+ });
232
+ return {
233
+ behaviorGroups: [...groups.entries()].map(([key, count]) => ({ key, count })).sort((a, b) => b.count - a.count || a.key.localeCompare(b.key)),
234
+ behaviors
235
+ };
236
+ }
237
+ function riskBucket(score) {
238
+ if (score >= 500)
239
+ return "critical";
240
+ if (score >= 200)
241
+ return "high";
242
+ if (score > 0)
243
+ return "medium";
244
+ return null;
245
+ }
246
+ function flowWhy(flow, proof) {
247
+ const tier = flow.flow_tier === "framework-derived: reachable" ? "framework-derived reachable" : "hard reachable";
248
+ const proofText = proof === "proven" ? "the entry behavior has dynamic proof" : "no dynamic proof exercises this chain yet";
249
+ return `This chain is ${tier}; ${proofText}. Reachability is static and is not an execution claim.`;
250
+ }
251
+ function flows(graph, rows, risks) {
252
+ const nodesById = new Map(graph.nodes.map((n) => [n.external_id, n]));
253
+ const proven = new Set(rows.filter((r) => r.evidence_tier === "proven").map((r) => r.behavior_id));
254
+ const riskById = new Map(risks.map((r) => [r.id, r]));
255
+ return (graph.analysis?.flows?.flows ?? []).map((flow) => {
256
+ const trigger = endpointTrigger(nodesById.get(flow.entry_point.external_id));
257
+ const proof = proven.has(flow.entry_point.external_id) || proven.has(flow.hops[0]?.from ?? "") ? "proven" : "none";
258
+ const steps = [
259
+ {
260
+ sig: symbolDisplay(flow.hops[0]?.from ?? flow.entry_point.external_id, nodesById),
261
+ tier: "hard",
262
+ edge: null,
263
+ desc: nodeDescription(flow.hops[0]?.from ?? flow.entry_point.external_id, nodesById)
264
+ },
265
+ ...flow.hops.map((hop) => ({
266
+ sig: symbolDisplay(hop.to, nodesById),
267
+ tier: hop.evidence_strength,
268
+ edge: hop.evidence_strength,
269
+ desc: nodeDescription(hop.to, nodesById)
270
+ }))
271
+ ];
272
+ const services = new Set(steps.map((s) => serviceName(s.sig)).filter(Boolean)).size;
273
+ const risk = riskById.get(flow.entry_point.external_id) ?? riskById.get(flow.hops[0]?.from ?? "");
274
+ return {
275
+ title: flow.entry_point.title || flow.entry_point.external_id,
276
+ trigger,
277
+ risk: risk ? riskBucket(risk.risk_score) : null,
278
+ proof,
279
+ services,
280
+ flow_tier: flow.flow_tier,
281
+ why: flowWhy(flow, proof),
282
+ steps
283
+ };
284
+ });
285
+ }
286
+ /** Report-only reader of analysis.candidate_flows — never touches deterministic flows or tiers. */
287
+ function candidateFlows(graph) {
288
+ const meta = graph.analysis?.candidate_flows;
289
+ if (!meta)
290
+ return null;
291
+ const nodesById = new Map(graph.nodes.map((n) => [n.external_id, n]));
292
+ return {
293
+ proposed: meta.rejections.proposed,
294
+ accepted: meta.rejections.accepted,
295
+ model: `${meta.provenance.model_provider}/${meta.provenance.model_name}`,
296
+ prompt_version: meta.provenance.prompt_version,
297
+ flows: meta.flows.map((flow) => ({
298
+ title: flow.title || flow.entry_point.title || flow.entry_point.external_id,
299
+ confidence: flow.confidence,
300
+ rationale: flow.rationale ?? "",
301
+ steps: [
302
+ {
303
+ sig: symbolDisplay(flow.hops[0]?.from ?? flow.entry_point.external_id, nodesById),
304
+ desc: nodeDescription(flow.hops[0]?.from ?? flow.entry_point.external_id, nodesById),
305
+ hop: null
306
+ },
307
+ ...flow.hops.map((hop) => ({
308
+ sig: symbolDisplay(hop.to, nodesById),
309
+ desc: nodeDescription(hop.to, nodesById),
310
+ hop: hop.hop_status
311
+ }))
312
+ ]
313
+ }))
314
+ };
315
+ }
316
+ function riskRows(risks) {
317
+ return risks.map((risk, idx) => {
318
+ const methodMatch = risk.title.match(/^(GET|POST|PUT|PATCH|DELETE)\s+(.+)$/i);
319
+ const pathMatch = risk.file.match(/\/api\/(.+)$/);
320
+ const tags = [];
321
+ const bucket = riskBucket(risk.risk_score);
322
+ if (bucket)
323
+ tags.push([`${bucket} risk`, "risk"]);
324
+ tags.push([`${risk.incoming_refs} incoming refs`, "info"]);
325
+ if (risk.entry_point)
326
+ tags.push(["Entry point", "entry"]);
327
+ return {
328
+ rank: idx + 1,
329
+ verb: methodMatch?.[1]?.toUpperCase() ?? (risk.entry_point ? "ENTRY" : "CODE"),
330
+ path: methodMatch?.[2] ?? (pathMatch ? `/${pathMatch[1]}` : risk.file),
331
+ desc: risk.reasons.join(" · "),
332
+ tags,
333
+ todo: "Write an integration or behavior test that calls this behavior and asserts the observable outcome."
334
+ };
335
+ });
336
+ }
337
+ function frameworkLabel(graph) {
338
+ const frameworks = graph.nodes
339
+ .filter((n) => n.kind === "Framework")
340
+ .map((n) => n.title || n.external_id.replace(/^framework:/, ""))
341
+ .sort();
342
+ return frameworks.length ? frameworks.slice(0, 4).join(", ") : "Unknown framework";
343
+ }
344
+ export function buildBehaviorReportData(graph, ledger, opts = {}) {
345
+ const { rows } = buildRtm(graph, ledger);
346
+ const flowIds = flowSymbolIds(graph);
347
+ const summary = summaryFromRows(rows, flowIds);
348
+ const repoRoot = opts.repoRoot ?? graph.workspace.root;
349
+ const riskGaps = rankRiskGaps(graph, { repoRoot, limit: opts.riskLimit ?? 20 });
350
+ const lists = behaviorLists(rows, flowIds);
351
+ return {
352
+ repo: path.basename(repoRoot || graph.workspace.name || "repo"),
353
+ scanned: (graph.updated_at || graph.created_at || new Date(0).toISOString()).slice(0, 10),
354
+ framework: frameworkLabel(graph),
355
+ analysisKind: summary.proven > 0 ? "static+dynamic" : "static",
356
+ summary,
357
+ proofGuidance: proofGuidance(ledger, summary, opts.dynamicProof),
358
+ pipeline: pipeline(graph, ledger, summary),
359
+ scan: scanBlock(graph, rows),
360
+ behaviorGroups: lists.behaviorGroups,
361
+ behaviors: lists.behaviors,
362
+ flows: flows(graph, rows, riskGaps),
363
+ candidateFlows: candidateFlows(graph),
364
+ risks: riskRows(riskGaps),
365
+ zeroProofExplainer: summary.proven === 0 ? { title: ZERO_PROOF_EXPLAINER.title, body: [...ZERO_PROOF_EXPLAINER.body] } : null
366
+ };
367
+ }