@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,193 @@
|
|
|
1
|
+
import { makeEdge, makeNode } from "../graph/factories.js";
|
|
2
|
+
import { hashString } from "../util/hash.js";
|
|
3
|
+
import { slugify } from "../util/ids.js";
|
|
4
|
+
import { redactSecrets } from "../util/redact.js";
|
|
5
|
+
const DETECTOR = "markdown_docs";
|
|
6
|
+
const MAX_REQUIREMENTS = 60;
|
|
7
|
+
/** Words that suggest a heading describes a requirement/feature behavior. */
|
|
8
|
+
const REQUIREMENT_HINTS = [
|
|
9
|
+
"requirement",
|
|
10
|
+
"feature",
|
|
11
|
+
"behavior",
|
|
12
|
+
"behaviour",
|
|
13
|
+
"user story",
|
|
14
|
+
"story",
|
|
15
|
+
"use case",
|
|
16
|
+
"scenario",
|
|
17
|
+
"capability",
|
|
18
|
+
"flow",
|
|
19
|
+
"rule",
|
|
20
|
+
"must",
|
|
21
|
+
"should",
|
|
22
|
+
"shall",
|
|
23
|
+
"support",
|
|
24
|
+
"allow"
|
|
25
|
+
];
|
|
26
|
+
/** True when a heading's text looks like a requirement/feature anchor. */
|
|
27
|
+
function looksLikeRequirement(text) {
|
|
28
|
+
const lower = text.toLowerCase();
|
|
29
|
+
return REQUIREMENT_HINTS.some((hint) => lower.includes(hint));
|
|
30
|
+
}
|
|
31
|
+
/** True when a heading marks an acceptance-criteria section. */
|
|
32
|
+
function isAcceptanceHeading(text) {
|
|
33
|
+
return text.toLowerCase().includes("acceptance criteria");
|
|
34
|
+
}
|
|
35
|
+
/** Parse an ATX heading line (#, ##, ###). Returns null for non-headings. */
|
|
36
|
+
function parseHeading(line) {
|
|
37
|
+
const m = /^(#{1,6})\s+(.+?)\s*#*\s*$/.exec(line);
|
|
38
|
+
if (!m)
|
|
39
|
+
return null;
|
|
40
|
+
return { level: m[1].length, text: m[2].trim() };
|
|
41
|
+
}
|
|
42
|
+
/** Parse a bullet/list-item line, returning its trimmed text or null. */
|
|
43
|
+
function parseBullet(line) {
|
|
44
|
+
const m = /^\s*(?:[-*+]|\d+[.)])\s+(.+?)\s*$/.exec(line);
|
|
45
|
+
return m ? m[1].trim() : null;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Deterministic Markdown enrichment.
|
|
49
|
+
*
|
|
50
|
+
* Headings that look like requirements become candidate Requirement nodes
|
|
51
|
+
* (inferred from doc structure). Bullets under an "acceptance criteria" section
|
|
52
|
+
* become AcceptanceCriterion nodes linked to the nearest preceding requirement.
|
|
53
|
+
* Bounded to ~60 requirements; all captured text is secret-redacted.
|
|
54
|
+
*/
|
|
55
|
+
export function enrichFromMarkdown(relPath, content) {
|
|
56
|
+
const nodes = [];
|
|
57
|
+
const edges = [];
|
|
58
|
+
const warnings = [];
|
|
59
|
+
const sourceScopeId = "markdown_docs:" + slugify(relPath);
|
|
60
|
+
const source = {
|
|
61
|
+
source_scope_id: sourceScopeId,
|
|
62
|
+
source_system: "markdown_docs",
|
|
63
|
+
source_type: "customer_supplied",
|
|
64
|
+
display_name: relPath,
|
|
65
|
+
content_hash: hashString(content),
|
|
66
|
+
metadata: { filename: relPath }
|
|
67
|
+
};
|
|
68
|
+
const prov = (line) => ({
|
|
69
|
+
source_scope_id: sourceScopeId,
|
|
70
|
+
source_ref: `${relPath}#L${line}`,
|
|
71
|
+
detector: DETECTOR
|
|
72
|
+
});
|
|
73
|
+
const lines = content.split(/\r\n|\r|\n/);
|
|
74
|
+
const reqSlugs = new Set();
|
|
75
|
+
let reqCount = 0;
|
|
76
|
+
let inFence = false;
|
|
77
|
+
// Tracks the most recent requirement (for AC linkage) and AC-section state.
|
|
78
|
+
let currentReqExternalId = null;
|
|
79
|
+
let lastHeading = null;
|
|
80
|
+
let inAcceptanceSection = false;
|
|
81
|
+
let acHeadingLevel = 0;
|
|
82
|
+
const acCounterByReq = new Map();
|
|
83
|
+
let capped = false;
|
|
84
|
+
/** Create a candidate Requirement node from a heading, returning its external id. */
|
|
85
|
+
const createRequirement = (text, level, line) => {
|
|
86
|
+
if (capped)
|
|
87
|
+
return null;
|
|
88
|
+
if (reqCount >= MAX_REQUIREMENTS) {
|
|
89
|
+
capped = true;
|
|
90
|
+
warnings.push(`Markdown '${relPath}' requirement cap (${MAX_REQUIREMENTS}) reached; later headings omitted.`);
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
const clean = redactSecrets(text);
|
|
94
|
+
let slug = slugify(clean);
|
|
95
|
+
let suffix = 1;
|
|
96
|
+
while (reqSlugs.has(slug)) {
|
|
97
|
+
suffix++;
|
|
98
|
+
slug = slugify(clean) + "-" + suffix;
|
|
99
|
+
}
|
|
100
|
+
reqSlugs.add(slug);
|
|
101
|
+
const reqExternalId = "REQ-md-" + slug;
|
|
102
|
+
nodes.push(makeNode({
|
|
103
|
+
kind: "Requirement",
|
|
104
|
+
external_id: reqExternalId,
|
|
105
|
+
title: clean,
|
|
106
|
+
properties: { description: clean, heading_level: level, inferred_from: "markdown_heading" },
|
|
107
|
+
evidence_strength: "candidate",
|
|
108
|
+
review_status: "inferred",
|
|
109
|
+
confidence: 0.55,
|
|
110
|
+
provenance: prov(line),
|
|
111
|
+
content_hash: hashString(clean),
|
|
112
|
+
behavior_source: "markdown_requirement",
|
|
113
|
+
denominator_eligible: true,
|
|
114
|
+
denominator_reason: "Markdown requirement heading — explicit behavior."
|
|
115
|
+
}));
|
|
116
|
+
reqCount++;
|
|
117
|
+
return reqExternalId;
|
|
118
|
+
};
|
|
119
|
+
for (let i = 0; i < lines.length; i++) {
|
|
120
|
+
const rawLine = lines[i];
|
|
121
|
+
const lineNumber = i + 1;
|
|
122
|
+
// Respect fenced code blocks: never treat their contents as headings/bullets.
|
|
123
|
+
if (/^\s*(```|~~~)/.test(rawLine)) {
|
|
124
|
+
inFence = !inFence;
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
if (inFence)
|
|
128
|
+
continue;
|
|
129
|
+
const heading = parseHeading(rawLine);
|
|
130
|
+
if (heading) {
|
|
131
|
+
if (isAcceptanceHeading(heading.text)) {
|
|
132
|
+
inAcceptanceSection = true;
|
|
133
|
+
acHeadingLevel = heading.level;
|
|
134
|
+
// An explicit Acceptance Criteria section is strong evidence its preceding
|
|
135
|
+
// heading is a requirement — promote it even if it lacked a hint word.
|
|
136
|
+
if (!currentReqExternalId && lastHeading) {
|
|
137
|
+
currentReqExternalId = createRequirement(lastHeading.text, lastHeading.level, lastHeading.line);
|
|
138
|
+
}
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
// A heading at or above the AC section's level closes that section.
|
|
142
|
+
if (inAcceptanceSection && heading.level <= acHeadingLevel) {
|
|
143
|
+
inAcceptanceSection = false;
|
|
144
|
+
}
|
|
145
|
+
lastHeading = { text: heading.text, level: heading.level, line: lineNumber };
|
|
146
|
+
if (looksLikeRequirement(heading.text)) {
|
|
147
|
+
const id = createRequirement(heading.text, heading.level, lineNumber);
|
|
148
|
+
if (id)
|
|
149
|
+
currentReqExternalId = id;
|
|
150
|
+
}
|
|
151
|
+
else {
|
|
152
|
+
// New non-requirement heading starts a fresh section; forget the prior req
|
|
153
|
+
// so stray bullets don't attach across unrelated sections.
|
|
154
|
+
currentReqExternalId = null;
|
|
155
|
+
}
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
// Bullets inside an acceptance-criteria section → AcceptanceCriterion nodes.
|
|
159
|
+
if (inAcceptanceSection && currentReqExternalId) {
|
|
160
|
+
const bullet = parseBullet(rawLine);
|
|
161
|
+
if (bullet) {
|
|
162
|
+
const acText = redactSecrets(bullet);
|
|
163
|
+
const reqId = currentReqExternalId;
|
|
164
|
+
const next = (acCounterByReq.get(reqId) ?? 0) + 1;
|
|
165
|
+
acCounterByReq.set(reqId, next);
|
|
166
|
+
const acExternalId = "AC-md-" + slugify(reqId.replace(/^REQ-md-/, "")) + "-" + String(next).padStart(3, "0");
|
|
167
|
+
nodes.push(makeNode({
|
|
168
|
+
kind: "AcceptanceCriterion",
|
|
169
|
+
external_id: acExternalId,
|
|
170
|
+
title: acText,
|
|
171
|
+
properties: { text: acText, behavior: reqId, inferred_from: "markdown_acceptance_section" },
|
|
172
|
+
evidence_strength: "reviewed",
|
|
173
|
+
review_status: "local_reviewed",
|
|
174
|
+
confidence: 0.6,
|
|
175
|
+
provenance: prov(lineNumber),
|
|
176
|
+
content_hash: hashString(acText)
|
|
177
|
+
}));
|
|
178
|
+
edges.push(makeEdge({
|
|
179
|
+
from_external_id: reqId,
|
|
180
|
+
to_external_id: acExternalId,
|
|
181
|
+
relationship_type: "HAS_ACCEPTANCE_CRITERION",
|
|
182
|
+
evidence_strength: "reviewed",
|
|
183
|
+
review_status: "local_reviewed",
|
|
184
|
+
provenance: prov(lineNumber)
|
|
185
|
+
}));
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
if (reqCount === 0) {
|
|
190
|
+
warnings.push(`Markdown '${relPath}' yielded no requirement headings.`);
|
|
191
|
+
}
|
|
192
|
+
return { nodes, edges, candidate_edges: [], sources: [source], warnings };
|
|
193
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { findNode, generatedTestById } from "../graph/factories.js";
|
|
2
|
+
/**
|
|
3
|
+
* Weak relationship labels are emitted by the generator in the form
|
|
4
|
+
* `REL:from->to` (e.g. `MAY_BE_TESTED_BY:REQ-001->test:src/a.test.ts`).
|
|
5
|
+
* The relationship prefix ends at the first `:`; the remainder is split on the
|
|
6
|
+
* first `->`. Labels without a `->` separator (e.g. `inferred_anchor:REQ-001`)
|
|
7
|
+
* are not parseable and yield null.
|
|
8
|
+
*/
|
|
9
|
+
function parseWeakLabel(label) {
|
|
10
|
+
const colon = label.indexOf(":");
|
|
11
|
+
if (colon <= 0)
|
|
12
|
+
return null;
|
|
13
|
+
const relationship_type = label.slice(0, colon);
|
|
14
|
+
const rest = label.slice(colon + 1);
|
|
15
|
+
const arrow = rest.indexOf("->");
|
|
16
|
+
if (arrow < 0)
|
|
17
|
+
return null;
|
|
18
|
+
const from = rest.slice(0, arrow);
|
|
19
|
+
const to = rest.slice(arrow + 2);
|
|
20
|
+
if (from.length === 0 || to.length === 0)
|
|
21
|
+
return null;
|
|
22
|
+
return { relationship_type, from, to };
|
|
23
|
+
}
|
|
24
|
+
function matchCandidateEdge(graph, parsed) {
|
|
25
|
+
return graph.candidate_edges.find((e) => e.relationship_type === parsed.relationship_type &&
|
|
26
|
+
e.from_external_id === parsed.from &&
|
|
27
|
+
e.to_external_id === parsed.to);
|
|
28
|
+
}
|
|
29
|
+
export function explainTest(graph, generated_test_id) {
|
|
30
|
+
const test = generatedTestById(graph, generated_test_id);
|
|
31
|
+
if (!test) {
|
|
32
|
+
throw new Error(`Generated test ${generated_test_id} not found in the local graph.`);
|
|
33
|
+
}
|
|
34
|
+
const grounded_by = test.grounding.entity_ids.map((externalId) => {
|
|
35
|
+
const node = findNode(graph, externalId);
|
|
36
|
+
if (!node) {
|
|
37
|
+
return {
|
|
38
|
+
external_id: externalId,
|
|
39
|
+
kind: "unknown",
|
|
40
|
+
title: externalId,
|
|
41
|
+
evidence_strength: "unknown"
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
const entry = {
|
|
45
|
+
external_id: node.external_id,
|
|
46
|
+
kind: node.kind,
|
|
47
|
+
title: node.title ?? node.external_id,
|
|
48
|
+
evidence_strength: node.evidence_strength
|
|
49
|
+
};
|
|
50
|
+
if (node.provenance?.source_ref) {
|
|
51
|
+
entry.source_ref = node.provenance.source_ref;
|
|
52
|
+
}
|
|
53
|
+
return entry;
|
|
54
|
+
});
|
|
55
|
+
const weak_relationships = test.grounding.weak_relationships_used.map((label) => {
|
|
56
|
+
// The generator emits `inferred_anchor:<id>` for the weak behavior anchor itself.
|
|
57
|
+
if (label.startsWith("inferred_anchor:")) {
|
|
58
|
+
const id = label.slice("inferred_anchor:".length);
|
|
59
|
+
const node = findNode(graph, id);
|
|
60
|
+
return {
|
|
61
|
+
from: id,
|
|
62
|
+
relation: "INFERRED_ANCHOR",
|
|
63
|
+
to: "(this behavior)",
|
|
64
|
+
reason: "Behavior anchor inferred from local test names — weak evidence.",
|
|
65
|
+
confidence: node ? node.confidence : 0
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
const parsed = parseWeakLabel(label);
|
|
69
|
+
if (!parsed) {
|
|
70
|
+
return { from: label, relation: "RELATES_TO", to: "(unresolved)", reason: label, confidence: 0 };
|
|
71
|
+
}
|
|
72
|
+
const edge = matchCandidateEdge(graph, parsed);
|
|
73
|
+
return {
|
|
74
|
+
from: parsed.from,
|
|
75
|
+
relation: parsed.relationship_type,
|
|
76
|
+
to: parsed.to,
|
|
77
|
+
reason: edge ? edge.reason : label,
|
|
78
|
+
confidence: edge ? edge.confidence : 0
|
|
79
|
+
};
|
|
80
|
+
});
|
|
81
|
+
return {
|
|
82
|
+
generated_test_id: test.id,
|
|
83
|
+
title: test.title,
|
|
84
|
+
behavior_tested: test.title,
|
|
85
|
+
grounded_by,
|
|
86
|
+
source_refs: test.grounding.source_refs,
|
|
87
|
+
weak_evidence_used: test.weak_evidence_used,
|
|
88
|
+
weak_relationships,
|
|
89
|
+
stale: Boolean(test.stale)
|
|
90
|
+
};
|
|
91
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { opExport, opGraphHtml } from "./operations.js";
|
|
2
|
+
/**
|
|
3
|
+
* Dispatch the `export` command.
|
|
4
|
+
*
|
|
5
|
+
* Only `--format graph-html` takes the explorer-only path; the boolean
|
|
6
|
+
* `--graph-html` flag falls through to a full pack export (JSON + Markdown, and
|
|
7
|
+
* the explorer too). Keeping this pure makes both modes unit-testable.
|
|
8
|
+
*/
|
|
9
|
+
export function runExportCli(cwd, flags) {
|
|
10
|
+
if (flags.format === "graph-html") {
|
|
11
|
+
const g = opGraphHtml(cwd, flags.out ?? "orangepro-graph.html");
|
|
12
|
+
return { mode: "graph_html", graph_html_path: g.graph_html_path };
|
|
13
|
+
}
|
|
14
|
+
const res = opExport(cwd, flags.out ?? "orangepro-evidence-pack.json", {
|
|
15
|
+
include_generated_bodies: flags.include_generated_bodies ?? false,
|
|
16
|
+
graph_html: flags.graph_html ?? false
|
|
17
|
+
});
|
|
18
|
+
return {
|
|
19
|
+
mode: "pack",
|
|
20
|
+
pack_path: res.pack_path,
|
|
21
|
+
summary_path: res.summary_path,
|
|
22
|
+
valid: res.validation.valid,
|
|
23
|
+
errors: res.validation.errors,
|
|
24
|
+
graph_html_path: res.graph_html_path
|
|
25
|
+
};
|
|
26
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { buildCallsAdjacency } from "../analyze/clustering.js";
|
|
2
|
+
import { LOCAL_GRAPH_SCHEMA_VERSION } from "../graph/ontology.js";
|
|
3
|
+
import { rankRiskGaps } from "../score/risk.js";
|
|
4
|
+
import { stableId } from "../util/ids.js";
|
|
5
|
+
const DEFAULT_MAX_DEPTH = 8;
|
|
6
|
+
const DEFAULT_MAX_FLOWS_PER_ENTRY = 20;
|
|
7
|
+
const DEFAULT_GLOBAL_CAP = 500;
|
|
8
|
+
const HIGH_ROUTE_RE = /payment|refund|checkout|cart|order|auth|login|token|customer|user|tax|fulfillment|ship/i;
|
|
9
|
+
const MUTATION_METHOD_RE = /^(POST|PUT|PATCH|DELETE)\b/i;
|
|
10
|
+
function flowTier(hops) {
|
|
11
|
+
return hops.some((h) => h.evidence_strength === "framework-derived")
|
|
12
|
+
? "framework-derived: reachable"
|
|
13
|
+
: "hard: reachable";
|
|
14
|
+
}
|
|
15
|
+
export function behaviorEntries(nodes, edges) {
|
|
16
|
+
const endpointStarts = new Set(edges.filter((e) => e.relationship_type === "IMPLEMENTED_IN").map((e) => e.to_external_id));
|
|
17
|
+
const incomingEligibleCalls = new Set();
|
|
18
|
+
for (const [, outgoing] of buildCallsAdjacency(nodes, edges, { denominatorEligibleOnly: true })) {
|
|
19
|
+
for (const edge of outgoing)
|
|
20
|
+
incomingEligibleCalls.add(edge.to);
|
|
21
|
+
}
|
|
22
|
+
return nodes
|
|
23
|
+
.filter((n) => n.kind === "CodeSymbol" &&
|
|
24
|
+
n.denominator_eligible === true &&
|
|
25
|
+
n.stale !== true &&
|
|
26
|
+
!endpointStarts.has(n.external_id) &&
|
|
27
|
+
!incomingEligibleCalls.has(n.external_id))
|
|
28
|
+
.map((n) => ({
|
|
29
|
+
external_id: n.external_id,
|
|
30
|
+
kind: "Behavior",
|
|
31
|
+
title: n.title,
|
|
32
|
+
start: n.external_id
|
|
33
|
+
}));
|
|
34
|
+
}
|
|
35
|
+
export function endpointEntries(nodes, edges) {
|
|
36
|
+
const nodesById = new Map(nodes.map((n) => [n.external_id, n]));
|
|
37
|
+
const codeSymbolIds = new Set(nodes.filter((n) => n.kind === "CodeSymbol" && n.stale !== true).map((n) => n.external_id));
|
|
38
|
+
const entries = [];
|
|
39
|
+
for (const e of edges) {
|
|
40
|
+
if (e.relationship_type !== "IMPLEMENTED_IN" || !codeSymbolIds.has(e.to_external_id))
|
|
41
|
+
continue;
|
|
42
|
+
const endpoint = nodesById.get(e.from_external_id);
|
|
43
|
+
if (!endpoint || endpoint.kind !== "Endpoint")
|
|
44
|
+
continue;
|
|
45
|
+
entries.push({
|
|
46
|
+
external_id: endpoint.external_id,
|
|
47
|
+
kind: "Endpoint",
|
|
48
|
+
title: endpoint.title,
|
|
49
|
+
start: e.to_external_id
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
return entries.sort((a, b) => a.external_id.localeCompare(b.external_id) || a.start.localeCompare(b.start));
|
|
53
|
+
}
|
|
54
|
+
export function dedupeEntries(entries) {
|
|
55
|
+
const seen = new Set();
|
|
56
|
+
const out = [];
|
|
57
|
+
for (const e of entries) {
|
|
58
|
+
const key = `${e.kind}|${e.external_id}|${e.start}`;
|
|
59
|
+
if (seen.has(key))
|
|
60
|
+
continue;
|
|
61
|
+
seen.add(key);
|
|
62
|
+
out.push(e);
|
|
63
|
+
}
|
|
64
|
+
return out.sort((a, b) => a.kind.localeCompare(b.kind) || a.external_id.localeCompare(b.external_id) || a.start.localeCompare(b.start));
|
|
65
|
+
}
|
|
66
|
+
function minimalGraph(input) {
|
|
67
|
+
return {
|
|
68
|
+
schema_version: LOCAL_GRAPH_SCHEMA_VERSION,
|
|
69
|
+
workspace: {
|
|
70
|
+
name: "flow-walker",
|
|
71
|
+
root: input.workspaceRoot ?? "",
|
|
72
|
+
root_hash: "",
|
|
73
|
+
source_upload_policy: "metadata_only"
|
|
74
|
+
},
|
|
75
|
+
created_at: "",
|
|
76
|
+
updated_at: "",
|
|
77
|
+
sources: [],
|
|
78
|
+
nodes: input.nodes,
|
|
79
|
+
edges: input.edges,
|
|
80
|
+
candidate_edges: input.candidate_edges ?? [],
|
|
81
|
+
generation_runs: [],
|
|
82
|
+
generated_tests: [],
|
|
83
|
+
manifest: { generated_at: "", git: null, files: {} }
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
function routeFallbackScore(entry) {
|
|
87
|
+
if (entry.kind !== "Endpoint")
|
|
88
|
+
return 0;
|
|
89
|
+
const text = `${entry.title ?? ""} ${entry.external_id}`;
|
|
90
|
+
let score = 500;
|
|
91
|
+
if (MUTATION_METHOD_RE.test(text))
|
|
92
|
+
score += 250;
|
|
93
|
+
if (HIGH_ROUTE_RE.test(text))
|
|
94
|
+
score += 250;
|
|
95
|
+
return score;
|
|
96
|
+
}
|
|
97
|
+
function fallbackScore(entry, adjacency) {
|
|
98
|
+
const fanOut = adjacency.get(entry.start)?.length ?? 0;
|
|
99
|
+
return routeFallbackScore(entry) + fanOut * 10;
|
|
100
|
+
}
|
|
101
|
+
export function rankEntries(graph, entries, adjacency) {
|
|
102
|
+
const riskScores = new Map(rankRiskGaps(minimalGraph(graph), { repoRoot: graph.workspaceRoot, limit: Math.max(graph.nodes.length, entries.length) }).map((gap) => [
|
|
103
|
+
gap.id,
|
|
104
|
+
gap.risk_score
|
|
105
|
+
]));
|
|
106
|
+
return [...entries].sort((a, b) => {
|
|
107
|
+
const aScore = Math.max(riskScores.get(a.start) ?? 0, fallbackScore(a, adjacency));
|
|
108
|
+
const bScore = Math.max(riskScores.get(b.start) ?? 0, fallbackScore(b, adjacency));
|
|
109
|
+
return bScore - aScore || a.kind.localeCompare(b.kind) || a.external_id.localeCompare(b.external_id) || a.start.localeCompare(b.start);
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
function prunePrefixSubsumed(flows) {
|
|
113
|
+
const sorted = [...flows].sort((a, b) => b.path.length - a.path.length || a.id.localeCompare(b.id));
|
|
114
|
+
const kept = [];
|
|
115
|
+
for (const flow of sorted) {
|
|
116
|
+
const subsumed = kept.some((other) => other.entry_point.external_id === flow.entry_point.external_id &&
|
|
117
|
+
flow.path.length < other.path.length &&
|
|
118
|
+
flow.path.every((part, idx) => other.path[idx] === part));
|
|
119
|
+
if (!subsumed)
|
|
120
|
+
kept.push(flow);
|
|
121
|
+
}
|
|
122
|
+
return kept.sort((a, b) => a.entry_point.external_id.localeCompare(b.entry_point.external_id) || a.id.localeCompare(b.id));
|
|
123
|
+
}
|
|
124
|
+
export function enumerateFlows(graph, opts = {}) {
|
|
125
|
+
const maxDepth = opts.maxDepth ?? DEFAULT_MAX_DEPTH;
|
|
126
|
+
const maxFlowsPerEntry = opts.maxFlowsPerEntry ?? DEFAULT_MAX_FLOWS_PER_ENTRY;
|
|
127
|
+
const globalCap = opts.globalCap ?? DEFAULT_GLOBAL_CAP;
|
|
128
|
+
const adjacency = buildCallsAdjacency(graph.nodes, graph.edges);
|
|
129
|
+
const entries = rankEntries(graph, dedupeEntries([...endpointEntries(graph.nodes, graph.edges), ...behaviorEntries(graph.nodes, graph.edges)]), adjacency);
|
|
130
|
+
const flows = [];
|
|
131
|
+
const dropped = { max_depth: 0, max_flows_per_entry: 0, global_cap: 0 };
|
|
132
|
+
for (const entry of entries) {
|
|
133
|
+
if (flows.length >= globalCap) {
|
|
134
|
+
if ((adjacency.get(entry.start)?.length ?? 0) > 0)
|
|
135
|
+
dropped.global_cap++;
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
const entryFlows = [];
|
|
139
|
+
const seenPath = new Set();
|
|
140
|
+
const record = (path, hops, truncated) => {
|
|
141
|
+
if (hops.length === 0)
|
|
142
|
+
return true;
|
|
143
|
+
const key = path.join(">");
|
|
144
|
+
if (seenPath.has(key))
|
|
145
|
+
return true;
|
|
146
|
+
if (entryFlows.length >= maxFlowsPerEntry) {
|
|
147
|
+
dropped.max_flows_per_entry++;
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
seenPath.add(key);
|
|
151
|
+
const tier = flowTier(hops);
|
|
152
|
+
entryFlows.push({
|
|
153
|
+
id: stableId("flow", `${entry.kind}|${entry.external_id}|${key}|${tier}`),
|
|
154
|
+
entry_point: { external_id: entry.external_id, kind: entry.kind, title: entry.title },
|
|
155
|
+
hops,
|
|
156
|
+
terminal: path[path.length - 1] ?? entry.start,
|
|
157
|
+
depth: hops.length,
|
|
158
|
+
flow_tier: tier,
|
|
159
|
+
...(truncated ? { truncated: true } : {}),
|
|
160
|
+
path
|
|
161
|
+
});
|
|
162
|
+
return true;
|
|
163
|
+
};
|
|
164
|
+
const walk = (current, path, hops, visited) => {
|
|
165
|
+
const outgoing = adjacency.get(current) ?? [];
|
|
166
|
+
if (hops.length >= maxDepth) {
|
|
167
|
+
dropped.max_depth++;
|
|
168
|
+
return record(path, hops, true);
|
|
169
|
+
}
|
|
170
|
+
const nextEdges = outgoing.filter((edge) => !visited.has(edge.to));
|
|
171
|
+
if (nextEdges.length === 0)
|
|
172
|
+
return record(path, hops, false);
|
|
173
|
+
for (const edge of nextEdges) {
|
|
174
|
+
const nextVisited = new Set(visited);
|
|
175
|
+
nextVisited.add(edge.to);
|
|
176
|
+
const nextHops = [
|
|
177
|
+
...hops,
|
|
178
|
+
{
|
|
179
|
+
from: edge.from,
|
|
180
|
+
to: edge.to,
|
|
181
|
+
evidence_strength: edge.evidence_strength,
|
|
182
|
+
...(edge.resolution ? { resolution: edge.resolution } : {})
|
|
183
|
+
}
|
|
184
|
+
];
|
|
185
|
+
if (!walk(edge.to, [...path, edge.to], nextHops, nextVisited))
|
|
186
|
+
return false;
|
|
187
|
+
}
|
|
188
|
+
return true;
|
|
189
|
+
};
|
|
190
|
+
walk(entry.start, [entry.start], [], new Set([entry.start]));
|
|
191
|
+
for (const flow of prunePrefixSubsumed(entryFlows)) {
|
|
192
|
+
if (flows.length >= globalCap) {
|
|
193
|
+
dropped.global_cap++;
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
const { path: _path, ...publicFlow } = flow;
|
|
197
|
+
flows.push(publicFlow);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
const by_tier = {
|
|
201
|
+
"hard: reachable": 0,
|
|
202
|
+
"framework-derived: reachable": 0
|
|
203
|
+
};
|
|
204
|
+
for (const flow of flows)
|
|
205
|
+
by_tier[flow.flow_tier]++;
|
|
206
|
+
return {
|
|
207
|
+
method: "static_calls_weakest_link",
|
|
208
|
+
total_flows: flows.length,
|
|
209
|
+
by_tier,
|
|
210
|
+
truncated_flows: flows.filter((f) => f.truncated === true).length,
|
|
211
|
+
dropped,
|
|
212
|
+
options: { max_depth: maxDepth, max_flows_per_entry: maxFlowsPerEntry, global_cap: globalCap },
|
|
213
|
+
flows
|
|
214
|
+
};
|
|
215
|
+
}
|