@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,413 @@
|
|
|
1
|
+
import { denominatorBehaviors } from "./graph/factories.js";
|
|
2
|
+
import { languageOf } from "./analyze/classify.js";
|
|
3
|
+
import { targetFingerprint } from "./ledger.js";
|
|
4
|
+
const STATUS_ORDER = {
|
|
5
|
+
"No integration signal": 0,
|
|
6
|
+
"Associated signal": 1,
|
|
7
|
+
"Generated-unverifiable": 2,
|
|
8
|
+
"Runtime-covered": 3,
|
|
9
|
+
"Proven": 4,
|
|
10
|
+
"Reproven (this run)": 5
|
|
11
|
+
};
|
|
12
|
+
const GENERIC_TEST_CATEGORIES = [
|
|
13
|
+
"happy-path",
|
|
14
|
+
"validation",
|
|
15
|
+
"auth-permission",
|
|
16
|
+
"state-transition",
|
|
17
|
+
"error-handling",
|
|
18
|
+
"boundary",
|
|
19
|
+
"idempotency-concurrency"
|
|
20
|
+
];
|
|
21
|
+
const ASSOCIATED_IMPORT_PROPAGATION_LIMIT = 25;
|
|
22
|
+
export function buildRtm(graph, ledger, opts = {}) {
|
|
23
|
+
const targetSet = opts.targetIds ? new Set(opts.targetIds) : null;
|
|
24
|
+
const fileSet = opts.changedFiles ? new Set(opts.changedFiles) : null;
|
|
25
|
+
const statusSet = normalizeStatusFilter(opts.statuses);
|
|
26
|
+
const ledgerBySymbol = selectLedgerBySymbol(ledger, graph);
|
|
27
|
+
const indexes = buildRtmIndexes(graph);
|
|
28
|
+
const baseRows = denominatorBehaviors(graph)
|
|
29
|
+
.filter((node) => inScope(node, targetSet, fileSet))
|
|
30
|
+
.map((node) => toRtmRow(indexes, node, ledgerBySymbol.get(node.external_id)))
|
|
31
|
+
.sort((a, b) => STATUS_ORDER[a.status] - STATUS_ORDER[b.status] || a.area.localeCompare(b.area) || a.file.localeCompare(b.file) || a.behavior.localeCompare(b.behavior));
|
|
32
|
+
const filteredRows = baseRows.filter((row) => !statusSet || statusSet.has(row.status));
|
|
33
|
+
const rows = opts.limit && opts.limit > 0 ? filteredRows.slice(0, opts.limit) : filteredRows;
|
|
34
|
+
return { summary: summarizeRows(baseRows), rows, ...(opts.scope ? { scope: opts.scope } : {}) };
|
|
35
|
+
}
|
|
36
|
+
function inScope(node, targetSet, fileSet) {
|
|
37
|
+
if (!targetSet && !fileSet)
|
|
38
|
+
return true;
|
|
39
|
+
if (targetSet?.has(node.external_id))
|
|
40
|
+
return true;
|
|
41
|
+
const file = nodeFileRef(node);
|
|
42
|
+
return file !== "" && fileSet?.has(file) === true;
|
|
43
|
+
}
|
|
44
|
+
export function renderRtmMarkdown(result) {
|
|
45
|
+
const s = result.summary;
|
|
46
|
+
const lines = [
|
|
47
|
+
"# OrangePro Traceability Matrix",
|
|
48
|
+
"",
|
|
49
|
+
"Deterministic local report. Public Proven is derived only from dynamic targeted-proof ledger records; static graph links and LLM output never set Proven.",
|
|
50
|
+
"",
|
|
51
|
+
"| Metric | Value |",
|
|
52
|
+
"|---|---:|",
|
|
53
|
+
`| Total denominator behaviors | ${s.total} |`,
|
|
54
|
+
`| Dynamically Proven | ${s.proven} |`,
|
|
55
|
+
`| Runtime-covered | ${s.runtime_covered} |`,
|
|
56
|
+
`| Associated signal | ${s.associated} |`,
|
|
57
|
+
`| No integration signal | ${s.no_link} |`,
|
|
58
|
+
`| Reproven this run | ${s.reproven_this_run} |`,
|
|
59
|
+
`| Generated unverifiable | ${s.generated_unverifiable} |`,
|
|
60
|
+
`| Dynamic Proven source | ${s.coverage_confirmed} / ${s.coverage_total} (${s.coverage_pct}%) |`,
|
|
61
|
+
`| Current graph kept rate | ${s.kept_rate}% (${s.reproven_this_run}/${s.attempted}) |`,
|
|
62
|
+
""
|
|
63
|
+
];
|
|
64
|
+
if (result.scope?.guidance) {
|
|
65
|
+
lines.push(`> ${escapeMarkdown(result.scope.guidance)}`, "");
|
|
66
|
+
}
|
|
67
|
+
if (result.rows.length < s.total) {
|
|
68
|
+
lines.push(`> Showing ${result.rows.length} row(s) from ${s.total} scoped denominator row(s). This can reflect \`--limit\` and/or \`--status\` filters. Use \`opro rtm --format json --out .orangepro/rtm-full.json\` for a full machine-readable RTM.`, "");
|
|
69
|
+
}
|
|
70
|
+
if (result.rows.length === 0) {
|
|
71
|
+
lines.push("No RTM rows matched this scope. Run `opro analyze .` first, widen filters, or use `opro gaps` for baseline opportunities.", "");
|
|
72
|
+
return lines.join("\n");
|
|
73
|
+
}
|
|
74
|
+
lines.push("| Behavior | Code Symbol | Area | Language | Test Signal | Status | Suggested Next Test | Ledger Outcome |");
|
|
75
|
+
lines.push("|---|---|---|---|---|---|---|---|");
|
|
76
|
+
for (const row of result.rows) {
|
|
77
|
+
lines.push([
|
|
78
|
+
row.behavior,
|
|
79
|
+
row.code_symbol || row.behavior_id,
|
|
80
|
+
row.area,
|
|
81
|
+
row.language,
|
|
82
|
+
row.test_signal,
|
|
83
|
+
row.status,
|
|
84
|
+
row.suggested_next_test,
|
|
85
|
+
row.ledger_outcome
|
|
86
|
+
]
|
|
87
|
+
.map((cell) => escapeMarkdown(cell))
|
|
88
|
+
.join(" | ")
|
|
89
|
+
.replace(/^/, "| ")
|
|
90
|
+
.replace(/$/, " |"));
|
|
91
|
+
}
|
|
92
|
+
lines.push("");
|
|
93
|
+
return lines.join("\n");
|
|
94
|
+
}
|
|
95
|
+
export function renderRtmCsv(result) {
|
|
96
|
+
const header = ["behavior", "behavior_id", "kind", "code_symbol", "file", "area", "language", "evidence_tier", "test_signal", "status", "suggested_next_test", "ledger_outcome", "ledger_run_id"];
|
|
97
|
+
const rows = result.rows.map((row) => [
|
|
98
|
+
row.behavior,
|
|
99
|
+
row.behavior_id,
|
|
100
|
+
row.kind,
|
|
101
|
+
row.code_symbol,
|
|
102
|
+
row.file,
|
|
103
|
+
row.area,
|
|
104
|
+
row.language,
|
|
105
|
+
row.evidence_tier,
|
|
106
|
+
row.test_signal,
|
|
107
|
+
row.status,
|
|
108
|
+
row.suggested_next_test,
|
|
109
|
+
row.ledger_outcome,
|
|
110
|
+
row.ledger_run_id
|
|
111
|
+
]);
|
|
112
|
+
return [header, ...rows].map((row) => row.map(csvCell).join(",")).join("\n") + "\n";
|
|
113
|
+
}
|
|
114
|
+
function toRtmRow(indexes, node, selected) {
|
|
115
|
+
const ledgerRecord = selected?.record;
|
|
116
|
+
const file = nodeFileRef(node);
|
|
117
|
+
// `proven` was already gated on isDynamicProofRecord AND a current-fingerprint
|
|
118
|
+
// match in selectLedgerBySymbol; a record that only passes isDynamicProofRecord
|
|
119
|
+
// (stale/absent fingerprint) arrives here with proven === false → not Proven.
|
|
120
|
+
const dynamicProof = selected?.proven === true;
|
|
121
|
+
const evidence = evidenceTierFor(indexes, node, file, dynamicProof);
|
|
122
|
+
const status = statusFor(evidence, ledgerRecord, dynamicProof);
|
|
123
|
+
const testSignal = dynamicProof ? dynamicProofSignalFor(ledgerRecord) : evidence === "runtime" ? runtimeSignalFor(node) : testSignalFor(indexes, node.external_id, file);
|
|
124
|
+
return {
|
|
125
|
+
behavior: node.title || node.external_id,
|
|
126
|
+
behavior_id: node.external_id,
|
|
127
|
+
kind: node.kind,
|
|
128
|
+
code_symbol: node.kind === "CodeSymbol" ? node.external_id : "",
|
|
129
|
+
file,
|
|
130
|
+
area: codeAreaOf(file),
|
|
131
|
+
language: node.kind === "CodeSymbol" ? languageLabel(languageOf(file)) : "Requirement",
|
|
132
|
+
evidence_tier: evidence,
|
|
133
|
+
test_signal: testSignal,
|
|
134
|
+
status,
|
|
135
|
+
suggested_next_test: suggestedNextTest(node, file, status),
|
|
136
|
+
ledger_outcome: ledgerRecord ? `${ledgerRecord.status}${ledgerRecord.closed ? " closed" : ""}` : "",
|
|
137
|
+
ledger_run_id: ledgerRecord?.run_id ?? ""
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
function evidenceTierFor(indexes, node, file, dynamicProof) {
|
|
141
|
+
if (dynamicProof)
|
|
142
|
+
return "proven";
|
|
143
|
+
if (node.kind === "CodeSymbol" && node.properties.runtime_covered === true)
|
|
144
|
+
return "runtime";
|
|
145
|
+
if (hasAssociatedSignal(indexes, node.external_id, file))
|
|
146
|
+
return "associated";
|
|
147
|
+
return "none";
|
|
148
|
+
}
|
|
149
|
+
function statusFor(evidence, ledgerRecord, dynamicProof) {
|
|
150
|
+
if (evidence === "proven")
|
|
151
|
+
return dynamicProof && ledgerRecord?.status === "reproven" && ledgerRecord.closed ? "Reproven (this run)" : "Proven";
|
|
152
|
+
if (evidence === "runtime")
|
|
153
|
+
return "Runtime-covered";
|
|
154
|
+
if (ledgerRecord?.status === "generated_unverifiable")
|
|
155
|
+
return "Generated-unverifiable";
|
|
156
|
+
if (evidence === "associated")
|
|
157
|
+
return "Associated signal";
|
|
158
|
+
return "No integration signal";
|
|
159
|
+
}
|
|
160
|
+
function summarizeRows(rows) {
|
|
161
|
+
const attempted = rows.filter((row) => row.ledger_outcome.startsWith("reproven") || row.ledger_outcome.startsWith("unproven")).length;
|
|
162
|
+
const reproven = rows.filter((row) => row.status === "Reproven (this run)").length;
|
|
163
|
+
const proven = rows.filter((row) => row.evidence_tier === "proven").length;
|
|
164
|
+
return {
|
|
165
|
+
total: rows.length,
|
|
166
|
+
proven,
|
|
167
|
+
runtime_covered: rows.filter((row) => row.evidence_tier === "runtime").length,
|
|
168
|
+
associated: rows.filter((row) => row.evidence_tier === "associated").length,
|
|
169
|
+
no_link: rows.filter((row) => row.evidence_tier === "none").length,
|
|
170
|
+
reproven_this_run: reproven,
|
|
171
|
+
generated_unverifiable: rows.filter((row) => row.status === "Generated-unverifiable").length,
|
|
172
|
+
attempted,
|
|
173
|
+
kept_rate: attempted > 0 ? Number(((reproven / attempted) * 100).toFixed(2)) : 0,
|
|
174
|
+
coverage_confirmed: proven,
|
|
175
|
+
coverage_total: rows.length,
|
|
176
|
+
coverage_pct: rows.length > 0 ? Number(((proven / rows.length) * 100).toFixed(1)) : 0
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Best-ever-proven, fingerprint-scoped ledger selection (replaces pure
|
|
181
|
+
* latest-timestamp-wins). Per symbol:
|
|
182
|
+
* 1. Pick the NEWEST record that is a valid dynamic proof (`isDynamicProofRecord`)
|
|
183
|
+
* AND whose `target_fingerprint` equals the target's current fingerprint → Proven.
|
|
184
|
+
* 2. Otherwise fall back to the latest overall record for status/diagnostics,
|
|
185
|
+
* but NOT Proven (this is the trust-conservative branch — a stale/absent
|
|
186
|
+
* fingerprint never counts as Proven).
|
|
187
|
+
* A later flaky/failed prove can no longer demote a genuine proof of the same
|
|
188
|
+
* unchanged code; a proof only lapses when the code (its file hash) changes.
|
|
189
|
+
*/
|
|
190
|
+
function selectLedgerBySymbol(ledger, graph) {
|
|
191
|
+
const bySymbol = new Map();
|
|
192
|
+
ledger.records.forEach((record, index) => {
|
|
193
|
+
const list = bySymbol.get(record.target_symbol);
|
|
194
|
+
if (list)
|
|
195
|
+
list.push({ record, index });
|
|
196
|
+
else
|
|
197
|
+
bySymbol.set(record.target_symbol, [{ record, index }]);
|
|
198
|
+
});
|
|
199
|
+
const newest = (entries) => entries.reduce((best, e) => (compareLedgerRecords(e.record, e.index, best.record, best.index) > 0 ? e : best));
|
|
200
|
+
const out = new Map();
|
|
201
|
+
for (const [symbol, entries] of bySymbol) {
|
|
202
|
+
const currentFingerprint = targetFingerprint(graph, symbol);
|
|
203
|
+
const matches = currentFingerprint
|
|
204
|
+
? entries.filter((e) => isDynamicProofRecord(e.record) && e.record.target_fingerprint === currentFingerprint)
|
|
205
|
+
: [];
|
|
206
|
+
if (matches.length > 0) {
|
|
207
|
+
out.set(symbol, { record: newest(matches).record, proven: true });
|
|
208
|
+
}
|
|
209
|
+
else {
|
|
210
|
+
out.set(symbol, { record: newest(entries).record, proven: false });
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return out;
|
|
214
|
+
}
|
|
215
|
+
function compareLedgerRecords(a, aIndex, b, bIndex) {
|
|
216
|
+
const aTs = Date.parse(a.ts);
|
|
217
|
+
const bTs = Date.parse(b.ts);
|
|
218
|
+
const safeATs = Number.isFinite(aTs) ? aTs : 0;
|
|
219
|
+
const safeBTs = Number.isFinite(bTs) ? bTs : 0;
|
|
220
|
+
return safeATs - safeBTs || aIndex - bIndex;
|
|
221
|
+
}
|
|
222
|
+
function buildRtmIndexes(graph) {
|
|
223
|
+
const nodeById = new Map(graph.nodes.map((n) => [n.external_id, n]));
|
|
224
|
+
const staticSignalsById = new Map();
|
|
225
|
+
const associatedSignalsById = new Map();
|
|
226
|
+
const associatedSignalsByFile = new Map();
|
|
227
|
+
const add = (map, key, value) => {
|
|
228
|
+
const set = map.get(key);
|
|
229
|
+
if (set)
|
|
230
|
+
set.add(value);
|
|
231
|
+
else
|
|
232
|
+
map.set(key, new Set([value]));
|
|
233
|
+
};
|
|
234
|
+
const importTargetsByFile = new Map();
|
|
235
|
+
const addFileAssociation = (file, value) => {
|
|
236
|
+
add(associatedSignalsByFile, file, value);
|
|
237
|
+
const imports = importTargetsByFile.get(file) ?? [];
|
|
238
|
+
if (imports.length > ASSOCIATED_IMPORT_PROPAGATION_LIMIT)
|
|
239
|
+
return;
|
|
240
|
+
for (const importedFile of imports)
|
|
241
|
+
add(associatedSignalsByFile, importedFile, value);
|
|
242
|
+
};
|
|
243
|
+
for (const e of graph.edges) {
|
|
244
|
+
if (e.evidence_strength !== "hard")
|
|
245
|
+
continue;
|
|
246
|
+
if (e.relationship_type === "IMPORTS" && isFileSignalKey(e.from_external_id) && isFileSignalKey(e.to_external_id)) {
|
|
247
|
+
importTargetsByFile.set(e.from_external_id, [...(importTargetsByFile.get(e.from_external_id) ?? []), e.to_external_id]);
|
|
248
|
+
}
|
|
249
|
+
if (e.relationship_type !== "COVERS" && e.relationship_type !== "TESTED_BY")
|
|
250
|
+
continue;
|
|
251
|
+
const from = nodeById.get(e.from_external_id);
|
|
252
|
+
const to = nodeById.get(e.to_external_id);
|
|
253
|
+
if (from?.kind === "TestCase" && to)
|
|
254
|
+
add(staticSignalsById, e.to_external_id, e.from_external_id);
|
|
255
|
+
if (to?.kind === "TestCase" && from)
|
|
256
|
+
add(staticSignalsById, e.from_external_id, e.to_external_id);
|
|
257
|
+
}
|
|
258
|
+
for (const e of graph.candidate_edges) {
|
|
259
|
+
if (e.review_status === "ai_suggested")
|
|
260
|
+
continue;
|
|
261
|
+
if (e.relationship_type !== "MAY_RELATE_TO" && e.relationship_type !== "MAY_BE_TESTED_BY" && e.relationship_type !== "MAY_COVER")
|
|
262
|
+
continue;
|
|
263
|
+
add(associatedSignalsById, e.from_external_id, e.to_external_id);
|
|
264
|
+
add(associatedSignalsById, e.to_external_id, e.from_external_id);
|
|
265
|
+
for (const [key, value] of [[e.from_external_id, e.to_external_id], [e.to_external_id, e.from_external_id]]) {
|
|
266
|
+
if (isFileSignalKey(key))
|
|
267
|
+
addFileAssociation(key, value);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
const freeze = (map) => new Map([...map.entries()].map(([key, values]) => [key, [...values].sort()]));
|
|
271
|
+
return {
|
|
272
|
+
staticSignalsById: freeze(staticSignalsById),
|
|
273
|
+
associatedSignalsById: freeze(associatedSignalsById),
|
|
274
|
+
associatedSignalsByFile: freeze(associatedSignalsByFile)
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
function isFileSignalKey(id) {
|
|
278
|
+
return !id.startsWith("sym:") && !id.startsWith("test:") && !id.startsWith("flow:");
|
|
279
|
+
}
|
|
280
|
+
function hasAssociatedSignal(indexes, externalId, file) {
|
|
281
|
+
return indexes.staticSignalsById.has(externalId) || indexes.associatedSignalsById.has(externalId) || (file !== "" && indexes.associatedSignalsByFile.has(file));
|
|
282
|
+
}
|
|
283
|
+
function testSignalFor(indexes, externalId, file) {
|
|
284
|
+
const staticSignals = indexes.staticSignalsById.get(externalId) ?? [];
|
|
285
|
+
if (staticSignals.length > 0)
|
|
286
|
+
return `static candidate: ${staticSignals.slice(0, 3).join("; ")}`;
|
|
287
|
+
const associated = [...(indexes.associatedSignalsById.get(externalId) ?? []), ...(file ? indexes.associatedSignalsByFile.get(file) ?? [] : [])]
|
|
288
|
+
.filter((id) => id !== externalId && id !== file)
|
|
289
|
+
.sort();
|
|
290
|
+
if (associated.length > 0)
|
|
291
|
+
return associated.slice(0, 3).join("; ");
|
|
292
|
+
return "";
|
|
293
|
+
}
|
|
294
|
+
function isDynamicProofRecord(record) {
|
|
295
|
+
const proof = record?.dynamic_proof;
|
|
296
|
+
return (record?.status === "reproven" &&
|
|
297
|
+
record.closed === true &&
|
|
298
|
+
proof?.proof_kind === "dynamic_targeted" &&
|
|
299
|
+
proof.baseline_green === true &&
|
|
300
|
+
proof.mutant_failed_assertion === true &&
|
|
301
|
+
proof.target_not_mocked === true);
|
|
302
|
+
}
|
|
303
|
+
function dynamicProofSignalFor(record) {
|
|
304
|
+
const proof = record?.dynamic_proof;
|
|
305
|
+
const parts = ["dynamic targeted proof"];
|
|
306
|
+
if (proof?.runner)
|
|
307
|
+
parts.push(proof.runner);
|
|
308
|
+
if (proof?.sentinel)
|
|
309
|
+
parts.push(`sentinel:${proof.sentinel}`);
|
|
310
|
+
if (record?.run_id)
|
|
311
|
+
parts.push(record.run_id);
|
|
312
|
+
return parts.join(" · ");
|
|
313
|
+
}
|
|
314
|
+
function suggestedNextTest(node, file, status) {
|
|
315
|
+
if (status === "Proven" || status === "Reproven (this run)" || status === "Runtime-covered")
|
|
316
|
+
return "";
|
|
317
|
+
const haystack = `${node.title ?? ""} ${node.external_id} ${file}`.toLowerCase();
|
|
318
|
+
const category = haystack.match(/auth|permission|role|rbac|login|session|token/) ? "auth-permission" :
|
|
319
|
+
haystack.match(/valid|parse|schema|sanitize|input|required/) ? "validation" :
|
|
320
|
+
haystack.match(/create|update|delete|remove|archive|status|enable|disable|transition/) ? "state-transition" :
|
|
321
|
+
haystack.match(/error|fail|recover|retry|exception/) ? "error-handling" :
|
|
322
|
+
haystack.match(/limit|range|empty|max|min|overflow|boundary/) ? "boundary" :
|
|
323
|
+
haystack.match(/concurrent|lock|race|idempot|duplicate/) ? "idempotency-concurrency" :
|
|
324
|
+
GENERIC_TEST_CATEGORIES[0];
|
|
325
|
+
return `Add a ${category} test that asserts this behavior.`;
|
|
326
|
+
}
|
|
327
|
+
function normalizeStatusFilter(statuses) {
|
|
328
|
+
if (!statuses || statuses.length === 0)
|
|
329
|
+
return null;
|
|
330
|
+
const out = new Set();
|
|
331
|
+
for (const raw of statuses) {
|
|
332
|
+
const s = raw.trim().toLowerCase().replace(/_/g, "-");
|
|
333
|
+
if (s === "proven")
|
|
334
|
+
out.add("Proven");
|
|
335
|
+
else if (s === "runtime" || s === "runtime-covered" || s === "runtimecovered")
|
|
336
|
+
out.add("Runtime-covered");
|
|
337
|
+
else if (s === "associated")
|
|
338
|
+
out.add("Associated signal");
|
|
339
|
+
else if (s === "no-link" || s === "nolink" || s === "none")
|
|
340
|
+
out.add("No integration signal");
|
|
341
|
+
else if (s === "reproven" || s === "reproven-this-run")
|
|
342
|
+
out.add("Reproven (this run)");
|
|
343
|
+
else if (s === "generated-unverifiable" || s === "unverifiable")
|
|
344
|
+
out.add("Generated-unverifiable");
|
|
345
|
+
}
|
|
346
|
+
return out.size > 0 ? out : null;
|
|
347
|
+
}
|
|
348
|
+
function runtimeSignalFor(node) {
|
|
349
|
+
const formats = Array.isArray(node.properties.runtime_coverage_formats)
|
|
350
|
+
? node.properties.runtime_coverage_formats.filter((f) => typeof f === "string")
|
|
351
|
+
: [];
|
|
352
|
+
return formats.length > 0 ? `runtime coverage (${formats.sort().join(", ")})` : "runtime coverage";
|
|
353
|
+
}
|
|
354
|
+
function nodeFileRef(n) {
|
|
355
|
+
if (typeof n.properties.file === "string")
|
|
356
|
+
return n.properties.file;
|
|
357
|
+
if (typeof n.provenance?.source_ref === "string")
|
|
358
|
+
return n.provenance.source_ref;
|
|
359
|
+
return n.external_id.startsWith("sym:") ? n.external_id.replace(/^sym:/, "").split("#")[0] : "";
|
|
360
|
+
}
|
|
361
|
+
function cleanPathParts(sourceRefOrPath) {
|
|
362
|
+
if (!sourceRefOrPath)
|
|
363
|
+
return [];
|
|
364
|
+
return sourceRefOrPath.replace(/^[./]+/, "").split(/[#?]/)[0].split(/[\\/]/).filter(Boolean);
|
|
365
|
+
}
|
|
366
|
+
function parentDir(parts) {
|
|
367
|
+
return parts.length > 1 ? parts[parts.length - 2] : parts[0] || "core";
|
|
368
|
+
}
|
|
369
|
+
function codeAreaOf(sourceRefOrPath) {
|
|
370
|
+
const parts = cleanPathParts(sourceRefOrPath);
|
|
371
|
+
if (parts.length === 0)
|
|
372
|
+
return "core";
|
|
373
|
+
const language = languageOf(sourceRefOrPath ?? "");
|
|
374
|
+
if (language === "go") {
|
|
375
|
+
if (parts[0] === "server" && parts[1] === "channels" && parts[2])
|
|
376
|
+
return `server/${parts[2]}`;
|
|
377
|
+
return parts.length > 2 ? parts.slice(0, 2).join("/") : parentDir(parts);
|
|
378
|
+
}
|
|
379
|
+
if (language === "typescript" || language === "javascript") {
|
|
380
|
+
if (parts[0] === "webapp" && parts[1] === "channels" && parts[2] === "src" && parts[3])
|
|
381
|
+
return `webapp/${parts[3]}`;
|
|
382
|
+
return parts.length > 2 ? parts.slice(0, 2).join("/") : parentDir(parts);
|
|
383
|
+
}
|
|
384
|
+
return parts.length > 2 ? parts.slice(0, 2).join("/") : parentDir(parts);
|
|
385
|
+
}
|
|
386
|
+
function languageLabel(language) {
|
|
387
|
+
const labels = {
|
|
388
|
+
typescript: "TypeScript/JavaScript",
|
|
389
|
+
javascript: "TypeScript/JavaScript",
|
|
390
|
+
python: "Python",
|
|
391
|
+
go: "Go",
|
|
392
|
+
java: "Java",
|
|
393
|
+
ruby: "Ruby",
|
|
394
|
+
kotlin: "Kotlin",
|
|
395
|
+
rust: "Rust",
|
|
396
|
+
php: "PHP",
|
|
397
|
+
csharp: "C#",
|
|
398
|
+
swift: "Swift",
|
|
399
|
+
c: "C",
|
|
400
|
+
cpp: "C++"
|
|
401
|
+
};
|
|
402
|
+
return labels[language] ?? (language || "Unknown");
|
|
403
|
+
}
|
|
404
|
+
function escapeMarkdown(value) {
|
|
405
|
+
return value.replace(/\|/g, "\\|").replace(/[\r\n\t]/g, " ");
|
|
406
|
+
}
|
|
407
|
+
function csvCell(value) {
|
|
408
|
+
const normalized = value.replace(/\r/g, " ");
|
|
409
|
+
const safe = /^[=+\-@\t]/.test(normalized) ? `'${normalized}` : normalized;
|
|
410
|
+
if (!/[",\n]/.test(safe))
|
|
411
|
+
return safe;
|
|
412
|
+
return `"${safe.replace(/"/g, '""')}"`;
|
|
413
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { denominatorComposition, isDenominatorEligible } from "../graph/factories.js";
|
|
2
|
+
/**
|
|
3
|
+
* Phase 5.1 — static assertion-candidate coverage over the DENOMINATOR
|
|
4
|
+
* behaviors (CodeSymbols + Requirements), split by the test layer that links
|
|
5
|
+
* each one.
|
|
6
|
+
*
|
|
7
|
+
* Carry-over #3: the graph.html headline counted only UserFlows (test-inferred),
|
|
8
|
+
* so it read ~0% on real repos even when the confirmer proved many CodeSymbols.
|
|
9
|
+
* This computes the metric over `denominatorBehaviors` instead.
|
|
10
|
+
*
|
|
11
|
+
* A behavior has a static candidate iff it has a hard TESTED_BY/COVERS edge.
|
|
12
|
+
* Public Proven is stricter and comes only from a dynamic targeted-proof ledger
|
|
13
|
+
* record. This module remains as a diagnostic/static-association helper.
|
|
14
|
+
*/
|
|
15
|
+
// Most-informative structural layer first. A behavior confirmed by both a unit
|
|
16
|
+
// and an e2e test is reported as `unit` (the structural confirmation); an
|
|
17
|
+
// e2e-only-confirmed behavior buckets as `e2e`.
|
|
18
|
+
const LAYER_PRECEDENCE = ["unit", "component", "integration", "api", "e2e", "manual", "unknown"];
|
|
19
|
+
const emptyByLayer = () => ({
|
|
20
|
+
unit: 0,
|
|
21
|
+
component: 0,
|
|
22
|
+
integration: 0,
|
|
23
|
+
api: 0,
|
|
24
|
+
e2e: 0,
|
|
25
|
+
manual: 0,
|
|
26
|
+
unknown: 0
|
|
27
|
+
});
|
|
28
|
+
const layerOf = (n) => {
|
|
29
|
+
const l = n && typeof n.properties.test_layer === "string" ? n.properties.test_layer : "";
|
|
30
|
+
return LAYER_PRECEDENCE.includes(l) ? l : "unknown";
|
|
31
|
+
};
|
|
32
|
+
export function confirmedCoverageByLayer(graph) {
|
|
33
|
+
const byId = new Map();
|
|
34
|
+
for (const n of graph.nodes)
|
|
35
|
+
byId.set(n.external_id, n);
|
|
36
|
+
// behavior external_id -> the layers of the tests that confirm it (hard edges only).
|
|
37
|
+
const confirmingLayers = new Map();
|
|
38
|
+
const record = (behaviorId, testNode) => {
|
|
39
|
+
let set = confirmingLayers.get(behaviorId);
|
|
40
|
+
if (!set) {
|
|
41
|
+
set = new Set();
|
|
42
|
+
confirmingLayers.set(behaviorId, set);
|
|
43
|
+
}
|
|
44
|
+
set.add(layerOf(testNode));
|
|
45
|
+
};
|
|
46
|
+
for (const e of graph.edges) {
|
|
47
|
+
if (e.relationship_type !== "TESTED_BY" && e.relationship_type !== "COVERS")
|
|
48
|
+
continue;
|
|
49
|
+
// Only HARD edges count as static assertion candidates. A weaker/reviewed
|
|
50
|
+
// edge must not inflate the diagnostic metric.
|
|
51
|
+
if (e.evidence_strength !== "hard")
|
|
52
|
+
continue;
|
|
53
|
+
const from = byId.get(e.from_external_id);
|
|
54
|
+
const to = byId.get(e.to_external_id);
|
|
55
|
+
// The test endpoint is the TestCase; the other endpoint is the behavior.
|
|
56
|
+
if (from?.kind === "TestCase")
|
|
57
|
+
record(e.to_external_id, from);
|
|
58
|
+
else if (to?.kind === "TestCase")
|
|
59
|
+
record(e.from_external_id, to);
|
|
60
|
+
}
|
|
61
|
+
const behaviors = graph.nodes.filter(isDenominatorEligible);
|
|
62
|
+
const by_layer = emptyByLayer();
|
|
63
|
+
let confirmed = 0;
|
|
64
|
+
for (const b of behaviors) {
|
|
65
|
+
const layers = confirmingLayers.get(b.external_id);
|
|
66
|
+
if (!layers)
|
|
67
|
+
continue; // no hard edge -> not confirmed
|
|
68
|
+
confirmed++;
|
|
69
|
+
const primary = LAYER_PRECEDENCE.find((l) => layers.has(l)) ?? "unknown";
|
|
70
|
+
by_layer[primary]++;
|
|
71
|
+
}
|
|
72
|
+
const total_behaviors = behaviors.length;
|
|
73
|
+
return {
|
|
74
|
+
total_behaviors,
|
|
75
|
+
confirmed,
|
|
76
|
+
confirmed_pct: total_behaviors > 0 ? round1((confirmed / total_behaviors) * 100) : 0,
|
|
77
|
+
by_layer,
|
|
78
|
+
unknown_count: by_layer.unknown,
|
|
79
|
+
unknown_pct: confirmed > 0 ? round1((by_layer.unknown / confirmed) * 100) : 0
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
function round1(n) {
|
|
83
|
+
return Math.round(n * 10) / 10;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* The static-candidate metric AND the denominator composition as ONE atomic pair.
|
|
87
|
+
*
|
|
88
|
+
* It ALWAYS recomputes both from the graph's nodes/edges and never trusts the persisted
|
|
89
|
+
* `analysis.confirmed_by_layer`/`denominator`: a persisted value can be stale (an old
|
|
90
|
+
* graph, or one edited out of band) in ways a cheap consistency check can't catch (e.g.
|
|
91
|
+
* the total agrees but `confirmed` was computed before a downgrade). Recomputing is O(n)
|
|
92
|
+
* and diagnostics are not a hot path, so the trade buys a hard guarantee — every
|
|
93
|
+
* static diagnostic reports the SAME numbers, consistent with the actual graph, with
|
|
94
|
+
* `coverage.total_behaviors === denominator.total` by construction. The persisted
|
|
95
|
+
* `analysis` fields remain in graph.json for the JSON contract; only rendering recomputes.
|
|
96
|
+
*/
|
|
97
|
+
export function resolveCoverage(graph) {
|
|
98
|
+
return { coverage: confirmedCoverageByLayer(graph), denominator: denominatorComposition(graph) };
|
|
99
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { nodesByKind } from "../graph/factories.js";
|
|
2
|
+
/**
|
|
3
|
+
* Recommend the smallest next source that would most improve generated-test
|
|
4
|
+
* quality. Recommendations are ranked by expected impact; the ranking heuristic
|
|
5
|
+
* itself is internal, only the human-facing actions are returned.
|
|
6
|
+
*/
|
|
7
|
+
export function doctorGraph(graph, score) {
|
|
8
|
+
const b = score.breakdown;
|
|
9
|
+
const candidates = [];
|
|
10
|
+
if (b.acceptance_criteria < 0.6) {
|
|
11
|
+
candidates.push({
|
|
12
|
+
priority: 0,
|
|
13
|
+
action: "Add acceptance criteria or a small requirements template (CSV/Markdown).",
|
|
14
|
+
why: "The graph has code/test context but weak business intent, so tests cannot assert expected outcomes.",
|
|
15
|
+
expected_score_impact: "+10 to +20",
|
|
16
|
+
gain: (0.6 - b.acceptance_criteria) * 20
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
if (b.interface_mapping < 0.5) {
|
|
20
|
+
candidates.push({
|
|
21
|
+
priority: 0,
|
|
22
|
+
action: "Add an OpenAPI spec or route/screen docs.",
|
|
23
|
+
why: "Endpoint/screen mapping makes API and UI tests target real interfaces instead of guesses.",
|
|
24
|
+
expected_score_impact: "+5 to +12",
|
|
25
|
+
gain: (0.5 - b.interface_mapping) * 15
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
if (b.known_regressions < 0.3) {
|
|
29
|
+
candidates.push({
|
|
30
|
+
priority: 0,
|
|
31
|
+
action: "List known bugs or past incidents (even a short Markdown list).",
|
|
32
|
+
why: "Known regressions let generation produce durable regression tests, not just happy-path checks.",
|
|
33
|
+
expected_score_impact: "+4 to +10",
|
|
34
|
+
gain: (0.3 - b.known_regressions) * 10
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
if (b.validation_evidence < 0.5) {
|
|
38
|
+
candidates.push({
|
|
39
|
+
priority: 0,
|
|
40
|
+
action: "Reference existing test names/links or manual QA steps.",
|
|
41
|
+
why: "Validation evidence ties behaviors to real checks and improves coverage confidence.",
|
|
42
|
+
expected_score_impact: "+4 to +10",
|
|
43
|
+
gain: (0.5 - b.validation_evidence) * 12
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
if (nodesByKind(graph, "Requirement").length === 0) {
|
|
47
|
+
candidates.push({
|
|
48
|
+
priority: 0,
|
|
49
|
+
action: "Add a few explicit requirements with descriptions and actors.",
|
|
50
|
+
why: "Explicit requirement anchors raise specificity well beyond code-only inference.",
|
|
51
|
+
expected_score_impact: "+8 to +18",
|
|
52
|
+
gain: 16
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
candidates.sort((a, c) => c.gain - a.gain);
|
|
56
|
+
const recommendations = candidates.slice(0, 4).map((rec, i) => ({
|
|
57
|
+
priority: i + 1,
|
|
58
|
+
action: rec.action,
|
|
59
|
+
why: rec.why,
|
|
60
|
+
expected_score_impact: rec.expected_score_impact
|
|
61
|
+
}));
|
|
62
|
+
return {
|
|
63
|
+
status: score.band,
|
|
64
|
+
recommendations,
|
|
65
|
+
can_continue_without_recommendations: score.overall >= 40
|
|
66
|
+
};
|
|
67
|
+
}
|