@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,538 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A/B comparison artifacts for `generate` (compare mode), written fresh to the
|
|
3
|
+
* local workspace on every run. Local testing artifacts — never the evidence
|
|
4
|
+
* pack, never uploaded.
|
|
5
|
+
*
|
|
6
|
+
* Outputs:
|
|
7
|
+
* - four per-arm test files so a tester can run + compare each arm: runnable
|
|
8
|
+
* framework code (`.local-kg.<ext>` / `.baseline.<ext>`) and beautified
|
|
9
|
+
* structured cases (`.local-kg.json` / `.baseline.json`) — the durable
|
|
10
|
+
* artifacts that remain after A/B testing;
|
|
11
|
+
* - a slim Markdown report: the scores, the matrix, and links to those files
|
|
12
|
+
* (no full body dumps).
|
|
13
|
+
*/
|
|
14
|
+
import ts from "typescript";
|
|
15
|
+
import { BUCKET_LABEL } from "./buckets.js";
|
|
16
|
+
const REDACTION_MARKER = "[orangepro: source excerpt redacted]";
|
|
17
|
+
const normHeader = (s) => s.toUpperCase().replace(/[^A-Z0-9]+/g, " ").trim();
|
|
18
|
+
const LEAKED_HEADERS = new Set(Object.values(BUCKET_LABEL).map(normHeader));
|
|
19
|
+
/**
|
|
20
|
+
* Tidy a model-generated body for the draft test file: drop our own
|
|
21
|
+
* source-excerpt redaction placeholders (the source is already gone — the marker
|
|
22
|
+
* is just a comment) and standalone scenario/bucket headers the model echoed from
|
|
23
|
+
* the prompt (e.g. a bare "HAPPY PATH" line that is not real code). A tidy, not a
|
|
24
|
+
* rewrite: real code is left untouched, so the body stays a faithful draft.
|
|
25
|
+
*/
|
|
26
|
+
export function cleanDraftTestBody(body) {
|
|
27
|
+
const out = [];
|
|
28
|
+
let blanks = 0;
|
|
29
|
+
for (const line of body.split(/\r?\n/)) {
|
|
30
|
+
const t = line.trim();
|
|
31
|
+
if (t.includes(REDACTION_MARKER))
|
|
32
|
+
continue;
|
|
33
|
+
const label = t.replace(/^[#/*\s]+/, "").replace(/[:*\s]+$/, "");
|
|
34
|
+
if (label && LEAKED_HEADERS.has(normHeader(label)))
|
|
35
|
+
continue;
|
|
36
|
+
if (t === "") {
|
|
37
|
+
if (++blanks > 1)
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
blanks = 0;
|
|
42
|
+
}
|
|
43
|
+
out.push(line);
|
|
44
|
+
}
|
|
45
|
+
return out.join("\n").trim();
|
|
46
|
+
}
|
|
47
|
+
/** Pick a sensible file extension for the generated test cases from the framework. */
|
|
48
|
+
/** True when a body contains real JSX (TS-AST walk — generics never false-positive). */
|
|
49
|
+
export function bodyHasJsx(body) {
|
|
50
|
+
const sf = ts.createSourceFile("b.tsx", body || "", ts.ScriptTarget.Latest, /*setParentNodes*/ true, ts.ScriptKind.TSX);
|
|
51
|
+
let found = false;
|
|
52
|
+
const visit = (n) => {
|
|
53
|
+
if (found)
|
|
54
|
+
return;
|
|
55
|
+
if (n.kind === ts.SyntaxKind.JsxElement ||
|
|
56
|
+
n.kind === ts.SyntaxKind.JsxSelfClosingElement ||
|
|
57
|
+
n.kind === ts.SyntaxKind.JsxFragment) {
|
|
58
|
+
found = true;
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
ts.forEachChild(n, visit);
|
|
62
|
+
};
|
|
63
|
+
visit(sf);
|
|
64
|
+
return found;
|
|
65
|
+
}
|
|
66
|
+
export function testsFileExt(framework, bodies = []) {
|
|
67
|
+
const f = (framework || "").toLowerCase();
|
|
68
|
+
// JSX inside a .ts file is a TypeScript error — emit .tsx when any body carries JSX.
|
|
69
|
+
const x = bodies.some(bodyHasJsx) ? "x" : "";
|
|
70
|
+
if (f.includes("playwright"))
|
|
71
|
+
return "spec.ts" + x;
|
|
72
|
+
if (f.includes("cypress"))
|
|
73
|
+
return "cy.ts" + x;
|
|
74
|
+
if (f.includes("vitest"))
|
|
75
|
+
return "test.ts" + x;
|
|
76
|
+
if (f.includes("jest"))
|
|
77
|
+
return "test.ts" + x;
|
|
78
|
+
if (f.includes("ava"))
|
|
79
|
+
return "test.js";
|
|
80
|
+
if (f.includes("pytest") || f.includes("python"))
|
|
81
|
+
return "py";
|
|
82
|
+
if (f.includes("go"))
|
|
83
|
+
return "go";
|
|
84
|
+
if (f.includes("junit") || f.includes("java"))
|
|
85
|
+
return "java";
|
|
86
|
+
return "test.txt";
|
|
87
|
+
}
|
|
88
|
+
export function testsArtifactName(prefix, ext) {
|
|
89
|
+
return ext === "go" ? `${prefix}_test.go` : `${prefix}.${ext}`;
|
|
90
|
+
}
|
|
91
|
+
/** Framework shared by a run's generated tests (grounded arm preferred). */
|
|
92
|
+
export function compareTestsFramework(cmp) {
|
|
93
|
+
const first = cmp.grounded.generated_tests[0] ?? cmp.baseline.generated_tests[0];
|
|
94
|
+
return first?.framework_hint ?? "generic";
|
|
95
|
+
}
|
|
96
|
+
/** Every non-empty generated body across both arms (grounded first). */
|
|
97
|
+
function allBodies(cmp) {
|
|
98
|
+
return [...cmp.grounded.generated_tests, ...cmp.baseline.generated_tests]
|
|
99
|
+
.map((t) => t.body?.trim() ?? "")
|
|
100
|
+
.filter(Boolean);
|
|
101
|
+
}
|
|
102
|
+
/** Whether a body is JSON test-cases — tolerant of a leading or prose-wrapped ```json fence. */
|
|
103
|
+
export function looksJson(body) {
|
|
104
|
+
const t = body.replace(/^```(?:json)?\s*/i, "").trimStart();
|
|
105
|
+
if (t.startsWith("{") || t.startsWith("["))
|
|
106
|
+
return true;
|
|
107
|
+
return /```json/i.test(body); // prose-wrapped JSON (loose model output)
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* File extension for the test-cases file. Some comparison outputs are JSON
|
|
111
|
+
* test-case specs (`.testcases.json`); detect that (and legacy XML) before falling back
|
|
112
|
+
* to the framework extension for single-call runnable drafts. Scans ALL bodies in
|
|
113
|
+
* both arms so an empty grounded[0] never hides a JSON/XML body elsewhere.
|
|
114
|
+
*/
|
|
115
|
+
export function compareTestsExt(cmp) {
|
|
116
|
+
const bodies = allBodies(cmp);
|
|
117
|
+
if (bodies.some((b) => b.startsWith("<")))
|
|
118
|
+
return "testcases.xml";
|
|
119
|
+
if (bodies.some((b) => looksJson(b)))
|
|
120
|
+
return "testcases.json";
|
|
121
|
+
return testsFileExt(compareTestsFramework(cmp), bodies);
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Partition a TS/JS test body into its import statements and the rest, via a
|
|
125
|
+
* parse-only `ts.createSourceFile` pass (handles multi-line imports a regex
|
|
126
|
+
* cannot). Used to HOIST + de-duplicate imports when several per-test bodies
|
|
127
|
+
* are concatenated into one file — duplicate import declarations are invalid TS
|
|
128
|
+
* and made the combined artifact unreadable/unrunnable.
|
|
129
|
+
*/
|
|
130
|
+
function partitionImports(body) {
|
|
131
|
+
const sf = ts.createSourceFile("arm.tsx", body, ts.ScriptTarget.Latest, /*setParentNodes*/ true, ts.ScriptKind.TSX);
|
|
132
|
+
const ranges = [];
|
|
133
|
+
for (const stmt of sf.statements) {
|
|
134
|
+
if (ts.isImportDeclaration(stmt)) {
|
|
135
|
+
ranges.push({ start: stmt.getStart(sf), end: stmt.getEnd(), text: stmt.getText(sf).trim() });
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
if (!ranges.length)
|
|
139
|
+
return { imports: [], rest: body.trim() };
|
|
140
|
+
let rest = body;
|
|
141
|
+
for (let i = ranges.length - 1; i >= 0; i--)
|
|
142
|
+
rest = rest.slice(0, ranges[i].start) + rest.slice(ranges[i].end);
|
|
143
|
+
rest = rest
|
|
144
|
+
.split(/\r?\n/)
|
|
145
|
+
.filter((l) => !l.includes("Imports reconstructed by OrangePro") && !l.includes("Imports synthesized by OrangePro"))
|
|
146
|
+
.join("\n")
|
|
147
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
148
|
+
.trim();
|
|
149
|
+
return { imports: ranges.map((r) => r.text), rest };
|
|
150
|
+
}
|
|
151
|
+
function partitionGoTestBody(body) {
|
|
152
|
+
const imports = [];
|
|
153
|
+
const rest = [];
|
|
154
|
+
let packageName = null;
|
|
155
|
+
const lines = body.split(/\r?\n/);
|
|
156
|
+
for (let i = 0; i < lines.length; i++) {
|
|
157
|
+
const line = lines[i];
|
|
158
|
+
const trimmed = line.trim();
|
|
159
|
+
const pkg = trimmed.match(/^package\s+([A-Za-z_][A-Za-z0-9_]*)\b/);
|
|
160
|
+
if (pkg) {
|
|
161
|
+
packageName ??= pkg[1];
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
const singleImport = trimmed.match(/^import\s+(.+)$/);
|
|
165
|
+
if (singleImport && singleImport[1].trim() !== "(") {
|
|
166
|
+
imports.push(singleImport[1].trim());
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
if (/^import\s*\($/.test(trimmed)) {
|
|
170
|
+
for (i++; i < lines.length; i++) {
|
|
171
|
+
const importLine = lines[i].trim();
|
|
172
|
+
if (importLine === ")")
|
|
173
|
+
break;
|
|
174
|
+
if (importLine && !importLine.startsWith("//"))
|
|
175
|
+
imports.push(importLine);
|
|
176
|
+
}
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
rest.push(line);
|
|
180
|
+
}
|
|
181
|
+
return { packageName, imports, rest: rest.join("\n").trim() };
|
|
182
|
+
}
|
|
183
|
+
function renderGoImportBlock(imports) {
|
|
184
|
+
const unique = [...new Set(imports.map((i) => i.trim()).filter(Boolean))].sort();
|
|
185
|
+
if (!unique.length)
|
|
186
|
+
return [];
|
|
187
|
+
if (unique.length === 1)
|
|
188
|
+
return [`import ${unique[0]}`];
|
|
189
|
+
return ["import (", ...unique.map((i) => `\t${i}`), ")"];
|
|
190
|
+
}
|
|
191
|
+
function partitionJavaTestBody(body) {
|
|
192
|
+
const imports = [];
|
|
193
|
+
const rest = [];
|
|
194
|
+
let packageName = null;
|
|
195
|
+
for (const line of body.split(/\r?\n/)) {
|
|
196
|
+
const trimmed = line.trim();
|
|
197
|
+
const pkg = trimmed.match(/^package\s+([A-Za-z_][A-Za-z0-9_.]*)\s*;/);
|
|
198
|
+
if (pkg) {
|
|
199
|
+
packageName ??= pkg[1];
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
if (/^import\s+/.test(trimmed)) {
|
|
203
|
+
imports.push(trimmed);
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
rest.push(line);
|
|
207
|
+
}
|
|
208
|
+
return { packageName, imports, rest: rest.join("\n").trim() };
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Full binding identity per LOCAL name declared by one import statement:
|
|
212
|
+
* local → "<imported>@<module>", where <imported> is the exported symbol
|
|
213
|
+
* (propertyName for aliases), "default" for default imports, and "*" for
|
|
214
|
+
* namespace imports. Two imports are interchangeable only when identities
|
|
215
|
+
* match — same local + same module is NOT enough (`saveCard as subject` and
|
|
216
|
+
* `deleteCard as subject` are different bindings, as are default/namespace
|
|
217
|
+
* vs named imports of the same local name).
|
|
218
|
+
*/
|
|
219
|
+
function importBindingIdentities(importText) {
|
|
220
|
+
const out = new Map();
|
|
221
|
+
const sf = ts.createSourceFile("imp.ts", importText, ts.ScriptTarget.Latest, /*setParentNodes*/ false);
|
|
222
|
+
for (const stmt of sf.statements) {
|
|
223
|
+
if (!ts.isImportDeclaration(stmt) || !stmt.importClause)
|
|
224
|
+
continue;
|
|
225
|
+
const mod = ts.isStringLiteral(stmt.moduleSpecifier) ? stmt.moduleSpecifier.text : "";
|
|
226
|
+
const clause = stmt.importClause;
|
|
227
|
+
if (clause.name)
|
|
228
|
+
out.set(clause.name.text, `default@${mod}`);
|
|
229
|
+
const nb = clause.namedBindings;
|
|
230
|
+
if (nb && ts.isNamespaceImport(nb))
|
|
231
|
+
out.set(nb.name.text, `*@${mod}`);
|
|
232
|
+
else if (nb && ts.isNamedImports(nb)) {
|
|
233
|
+
for (const el of nb.elements)
|
|
234
|
+
out.set(el.name.text, `${(el.propertyName ?? el.name).text}@${mod}`);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return out;
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Render ONE arm's generated tests into its own runnable framework-code file.
|
|
241
|
+
* Local KG (graph-grounded) is the keep-arm; the prompt-only baseline is for
|
|
242
|
+
* comparison only. Bodies are the model's raw output (drafts, not guaranteed
|
|
243
|
+
* runnable), tidied of redaction placeholders and leaked headers — a tidy, not a
|
|
244
|
+
* rewrite. Comment style follows the file extension (JS/TS `//` vs Python `#`).
|
|
245
|
+
*/
|
|
246
|
+
export function renderArmTestsFile(cmp, arm, createdAt) {
|
|
247
|
+
const fw = compareTestsFramework(cmp);
|
|
248
|
+
const ext = testsFileExt(fw);
|
|
249
|
+
const cmt = (line) => `${ext === "py" ? "#" : "//"} ${line}`;
|
|
250
|
+
const bar = cmt("=".repeat(70));
|
|
251
|
+
const tests = cmp[arm].generated_tests;
|
|
252
|
+
const isKeep = arm === "grounded";
|
|
253
|
+
const lines = [];
|
|
254
|
+
lines.push(cmt(`OrangePro Local — A/B generated tests (${isKeep ? "Local KG / graph-grounded" : "prompt-only baseline"}, ${fw})`));
|
|
255
|
+
lines.push(cmt(`Generated ${createdAt} via ${cmp.model_provider}/${cmp.model_name}.`));
|
|
256
|
+
if (isKeep) {
|
|
257
|
+
lines.push(cmt("Local KG arm — KEEP these. Runnable framework code: write each test to its own"));
|
|
258
|
+
lines.push(cmt("file (see run hints in the report) and run it with your repo's test command."));
|
|
259
|
+
}
|
|
260
|
+
else {
|
|
261
|
+
lines.push(cmt("Prompt-only baseline arm — COMPARISON ONLY. Generated without the Local KG; kept"));
|
|
262
|
+
lines.push(cmt("so you can run both arms side by side. Do not commit these."));
|
|
263
|
+
}
|
|
264
|
+
lines.push("");
|
|
265
|
+
lines.push(bar);
|
|
266
|
+
lines.push(cmt(isKeep ? "Local KG (graph-grounded) — keep these" : "Prompt-only baseline — comparison only; delete before committing"));
|
|
267
|
+
lines.push(bar);
|
|
268
|
+
lines.push("");
|
|
269
|
+
if (!tests.length) {
|
|
270
|
+
lines.push(cmt("(no tests generated)"));
|
|
271
|
+
return lines.join("\n") + "\n";
|
|
272
|
+
}
|
|
273
|
+
if (ext === "go") {
|
|
274
|
+
const partitionedGo = tests.map((t) => partitionGoTestBody(cleanDraftTestBody(t.body)));
|
|
275
|
+
const packages = [...new Set(partitionedGo.map((p) => p.packageName).filter((p) => Boolean(p)))].sort();
|
|
276
|
+
const packageName = packages[0] ?? "main";
|
|
277
|
+
const packageMismatch = packages.length > 1;
|
|
278
|
+
lines.push(`package ${packageName}`);
|
|
279
|
+
lines.push("");
|
|
280
|
+
lines.push(...renderGoImportBlock(partitionedGo.flatMap((p) => p.imports)));
|
|
281
|
+
lines.push("");
|
|
282
|
+
tests.forEach((t, i) => {
|
|
283
|
+
lines.push(bar);
|
|
284
|
+
lines.push(cmt(`TEST ${i + 1}/${tests.length}: ${t.title} [${t.test_type}/${t.framework_hint}]${t.bucket ? ` {${t.bucket}}` : ""}`));
|
|
285
|
+
lines.push(bar);
|
|
286
|
+
if (t.grounding.entity_ids.length)
|
|
287
|
+
lines.push(cmt(`grounded by: ${t.grounding.entity_ids.join(", ")}`));
|
|
288
|
+
if (t.grounding.source_refs.length)
|
|
289
|
+
lines.push(cmt(`source refs: ${t.grounding.source_refs.join(", ")}`));
|
|
290
|
+
if (t.weak_evidence_used)
|
|
291
|
+
lines.push(cmt("uses weak/candidate evidence — verify before trusting"));
|
|
292
|
+
if (packageMismatch && partitionedGo[i].packageName && partitionedGo[i].packageName !== packageName) {
|
|
293
|
+
lines.push(cmt(`MANUAL SPLIT REQUIRED — this test belongs to package ${partitionedGo[i].packageName}.`));
|
|
294
|
+
lines.push(cmt("Write it to its own _test.go file using the run hint instead of this combined file."));
|
|
295
|
+
}
|
|
296
|
+
lines.push("");
|
|
297
|
+
lines.push(partitionedGo[i].rest);
|
|
298
|
+
lines.push("");
|
|
299
|
+
});
|
|
300
|
+
return lines.join("\n") + "\n";
|
|
301
|
+
}
|
|
302
|
+
if (ext === "java") {
|
|
303
|
+
const partitionedJava = tests.map((t) => partitionJavaTestBody(cleanDraftTestBody(t.body)));
|
|
304
|
+
const packages = [...new Set(partitionedJava.map((p) => p.packageName).filter((p) => Boolean(p)))].sort();
|
|
305
|
+
const packageName = packages[0];
|
|
306
|
+
const packageMismatch = packages.length > 1;
|
|
307
|
+
if (packageName) {
|
|
308
|
+
lines.push(`package ${packageName};`);
|
|
309
|
+
lines.push("");
|
|
310
|
+
}
|
|
311
|
+
const imports = [...new Set(partitionedJava.flatMap((p) => p.imports))].sort();
|
|
312
|
+
if (imports.length) {
|
|
313
|
+
lines.push(...imports);
|
|
314
|
+
lines.push("");
|
|
315
|
+
}
|
|
316
|
+
tests.forEach((t, i) => {
|
|
317
|
+
lines.push(bar);
|
|
318
|
+
lines.push(cmt(`TEST ${i + 1}/${tests.length}: ${t.title} [${t.test_type}/${t.framework_hint}]${t.bucket ? ` {${t.bucket}}` : ""}`));
|
|
319
|
+
lines.push(bar);
|
|
320
|
+
if (t.grounding.entity_ids.length)
|
|
321
|
+
lines.push(cmt(`grounded by: ${t.grounding.entity_ids.join(", ")}`));
|
|
322
|
+
if (t.grounding.source_refs.length)
|
|
323
|
+
lines.push(cmt(`source refs: ${t.grounding.source_refs.join(", ")}`));
|
|
324
|
+
if (t.weak_evidence_used)
|
|
325
|
+
lines.push(cmt("uses weak/candidate evidence — verify before trusting"));
|
|
326
|
+
if (packageMismatch && partitionedJava[i].packageName && partitionedJava[i].packageName !== packageName) {
|
|
327
|
+
lines.push(cmt(`MANUAL SPLIT REQUIRED — this test belongs to package ${partitionedJava[i].packageName}.`));
|
|
328
|
+
lines.push(cmt("Write it to its own Java file using the run hint instead of this combined file."));
|
|
329
|
+
}
|
|
330
|
+
lines.push("");
|
|
331
|
+
lines.push(partitionedJava[i].rest);
|
|
332
|
+
lines.push("");
|
|
333
|
+
});
|
|
334
|
+
return lines.join("\n") + "\n";
|
|
335
|
+
}
|
|
336
|
+
// TS/JS: hoist + de-duplicate imports across the concatenated test bodies so
|
|
337
|
+
// the combined file is one valid module instead of N duplicate import blocks.
|
|
338
|
+
const isCode = ext !== "py" && ext !== "go" && !ext.startsWith("testcases");
|
|
339
|
+
const partitioned = tests.map((t) => isCode ? partitionImports(cleanDraftTestBody(t.body)) : { imports: [], rest: cleanDraftTestBody(t.body) });
|
|
340
|
+
// De-duplicate on whitespace-NORMALIZED text plus parsed binding identity —
|
|
341
|
+
// the same import re-emitted with different spacing OR token layout must not
|
|
342
|
+
// survive as a duplicate declaration. An import whose LOCAL names collide
|
|
343
|
+
// with one already hoisted cannot live in this combined file at all: hoisting
|
|
344
|
+
// it is invalid TS, and silently dropping it would make its test run against
|
|
345
|
+
// the earlier binding. Keep it with its own test below as a commented
|
|
346
|
+
// "manual split required" block — and REPEAT the warning for every later test
|
|
347
|
+
// that carries the same conflicting line (a dedupe hit on a conflicted line is
|
|
348
|
+
// still a conflict for that test, not a resolved duplicate).
|
|
349
|
+
const hoisted = [];
|
|
350
|
+
const seenNormalized = new Set();
|
|
351
|
+
const conflictNormalized = new Set();
|
|
352
|
+
const boundIdentityByLocal = new Map();
|
|
353
|
+
const conflicts = partitioned.map(() => []);
|
|
354
|
+
partitioned.forEach((part, i) => {
|
|
355
|
+
for (const imp of part.imports) {
|
|
356
|
+
const normalized = imp.replace(/\s+/g, " ").trim();
|
|
357
|
+
if (seenNormalized.has(normalized)) {
|
|
358
|
+
if (conflictNormalized.has(normalized))
|
|
359
|
+
conflicts[i].push(normalized);
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
362
|
+
seenNormalized.add(normalized);
|
|
363
|
+
const bindings = importBindingIdentities(imp);
|
|
364
|
+
const locals = [...bindings.keys()];
|
|
365
|
+
const colliding = locals.filter((n) => boundIdentityByLocal.has(n));
|
|
366
|
+
if (colliding.length) {
|
|
367
|
+
// A duplicate (not a conflict) ONLY when every local resolves to the
|
|
368
|
+
// identical binding — same module AND same imported symbol/form — and
|
|
369
|
+
// the line adds nothing new. `saveCard as subject` vs `deleteCard as
|
|
370
|
+
// subject` from one module must NOT pass as a duplicate.
|
|
371
|
+
const sameBindingDup = colliding.length === locals.length && colliding.every((n) => boundIdentityByLocal.get(n) === bindings.get(n));
|
|
372
|
+
if (!sameBindingDup) {
|
|
373
|
+
conflicts[i].push(normalized);
|
|
374
|
+
conflictNormalized.add(normalized);
|
|
375
|
+
}
|
|
376
|
+
continue;
|
|
377
|
+
}
|
|
378
|
+
for (const [n, id] of bindings)
|
|
379
|
+
boundIdentityByLocal.set(n, id);
|
|
380
|
+
hoisted.push(imp);
|
|
381
|
+
}
|
|
382
|
+
});
|
|
383
|
+
if (hoisted.length) {
|
|
384
|
+
lines.push(cmt("Imports — hoisted and de-duplicated across the tests below:"));
|
|
385
|
+
lines.push(...hoisted);
|
|
386
|
+
lines.push("");
|
|
387
|
+
}
|
|
388
|
+
tests.forEach((t, i) => {
|
|
389
|
+
lines.push(bar);
|
|
390
|
+
lines.push(cmt(`TEST ${i + 1}/${tests.length}: ${t.title} [${t.test_type}/${t.framework_hint}]${t.bucket ? ` {${t.bucket}}` : ""}`));
|
|
391
|
+
lines.push(bar);
|
|
392
|
+
if (t.grounding.entity_ids.length)
|
|
393
|
+
lines.push(cmt(`grounded by: ${t.grounding.entity_ids.join(", ")}`));
|
|
394
|
+
if (t.grounding.source_refs.length)
|
|
395
|
+
lines.push(cmt(`source refs: ${t.grounding.source_refs.join(", ")}`));
|
|
396
|
+
if (t.weak_evidence_used)
|
|
397
|
+
lines.push(cmt("uses weak/candidate evidence — verify before trusting"));
|
|
398
|
+
if (conflicts[i].length) {
|
|
399
|
+
lines.push(cmt("⚠ MANUAL SPLIT REQUIRED — this test's import(s) collide with name(s) already"));
|
|
400
|
+
lines.push(cmt("bound earlier in this combined file and could not be hoisted. Below, those"));
|
|
401
|
+
lines.push(cmt("names may resolve to a different binding than this test intends (or stay"));
|
|
402
|
+
lines.push(cmt("unbound). Move this test to its own file with its own imports:"));
|
|
403
|
+
for (const c of conflicts[i])
|
|
404
|
+
lines.push(cmt(` ${c}`));
|
|
405
|
+
}
|
|
406
|
+
lines.push("");
|
|
407
|
+
lines.push(partitioned[i].rest);
|
|
408
|
+
lines.push("");
|
|
409
|
+
});
|
|
410
|
+
return lines.join("\n") + "\n";
|
|
411
|
+
}
|
|
412
|
+
/** One valid pretty-printed JSON document with ONE arm's structured test cases + grounding. */
|
|
413
|
+
export function renderArmTestsJson(cmp, arm, createdAt) {
|
|
414
|
+
const tests = cmp[arm].generated_tests.map((t) => ({
|
|
415
|
+
title: t.title,
|
|
416
|
+
bucket: t.bucket,
|
|
417
|
+
test_type: t.test_type,
|
|
418
|
+
framework: t.framework_hint,
|
|
419
|
+
grounding: {
|
|
420
|
+
entity_ids: t.grounding.entity_ids,
|
|
421
|
+
source_refs: t.grounding.source_refs,
|
|
422
|
+
weak_relationships_used: t.grounding.weak_relationships_used
|
|
423
|
+
},
|
|
424
|
+
weak_evidence_used: t.weak_evidence_used,
|
|
425
|
+
test_code: t.body
|
|
426
|
+
}));
|
|
427
|
+
return (JSON.stringify({
|
|
428
|
+
generated_at: createdAt,
|
|
429
|
+
model: `${cmp.model_provider}/${cmp.model_name}`,
|
|
430
|
+
arm: arm === "grounded" ? "local_kg" : "prompt_only_baseline",
|
|
431
|
+
count: tests.length,
|
|
432
|
+
tests
|
|
433
|
+
}, null, 2) + "\n");
|
|
434
|
+
}
|
|
435
|
+
export function renderCompareReportMarkdown(cmp, createdAt, files) {
|
|
436
|
+
const lines = [];
|
|
437
|
+
lines.push("# OrangePro Local — A/B comparison report");
|
|
438
|
+
lines.push("");
|
|
439
|
+
lines.push(`- Generated at: ${createdAt}`);
|
|
440
|
+
lines.push(`- Model: ${cmp.model_provider}/${cmp.model_name}`);
|
|
441
|
+
lines.push(`- System prompt (shared by both arms): ${cmp.system_prompt_source}`);
|
|
442
|
+
lines.push(`- Scored by: ${cmp.scoring_method}`);
|
|
443
|
+
if (cmp.rationale)
|
|
444
|
+
lines.push(`- Judge rationale: ${cmp.rationale}`);
|
|
445
|
+
lines.push("");
|
|
446
|
+
if (cmp.model_provider === "none") {
|
|
447
|
+
lines.push("No comparison was produced — no model provider configured.");
|
|
448
|
+
for (const w of cmp.warnings)
|
|
449
|
+
lines.push(`- ${w}`);
|
|
450
|
+
return lines.join("\n") + "\n";
|
|
451
|
+
}
|
|
452
|
+
if (files) {
|
|
453
|
+
lines.push("## Test cases");
|
|
454
|
+
lines.push("");
|
|
455
|
+
lines.push("Per-arm files (run each arm and compare side by side):");
|
|
456
|
+
lines.push("");
|
|
457
|
+
lines.push(`- **Local KG (graph-grounded) — keep these:** runnable code [\`${files.localKgTests}\`](./${files.localKgTests}), ` +
|
|
458
|
+
`structured cases [\`${files.localKgJson}\`](./${files.localKgJson}).`);
|
|
459
|
+
lines.push(`- **Prompt-only baseline — comparison only:** runnable code [\`${files.baselineTests}\`](./${files.baselineTests}), ` +
|
|
460
|
+
`structured cases [\`${files.baselineJson}\`](./${files.baselineJson}).`);
|
|
461
|
+
lines.push("");
|
|
462
|
+
lines.push("Write the **Local KG** tests to their own files and run them (those are the keep-arm); the " +
|
|
463
|
+
"prompt-only baseline is included only for comparison (it may need its own imports to run).");
|
|
464
|
+
lines.push("");
|
|
465
|
+
}
|
|
466
|
+
const s = cmp.scores;
|
|
467
|
+
lines.push("## Scores (0–100)");
|
|
468
|
+
lines.push("");
|
|
469
|
+
lines.push("| Dimension | Prompt-only | Local KG |");
|
|
470
|
+
lines.push("|---|---:|---:|");
|
|
471
|
+
lines.push(`| Completeness | ${s.baseline.completeness} | ${s.grounded.completeness} |`);
|
|
472
|
+
lines.push(`| Context awareness | ${s.baseline.context_awareness} | ${s.grounded.context_awareness} |`);
|
|
473
|
+
lines.push(`| Accuracy | ${s.baseline.accuracy} | ${s.grounded.accuracy} |`);
|
|
474
|
+
lines.push(`| Domain specificity | ${s.baseline.domain_specificity} | ${s.grounded.domain_specificity} |`);
|
|
475
|
+
lines.push("");
|
|
476
|
+
const mx = cmp.matrix;
|
|
477
|
+
lines.push("## Comparison matrix");
|
|
478
|
+
lines.push("");
|
|
479
|
+
lines.push("| Metric | Prompt-only | Local KG |");
|
|
480
|
+
lines.push("|---|---:|---:|");
|
|
481
|
+
lines.push(`| Tests | ${mx.baseline.tests} | ${mx.grounded.tests} |`);
|
|
482
|
+
lines.push(`| Concrete assertions (avg) | ${mx.baseline.concrete_assertions_avg} | ${mx.grounded.concrete_assertions_avg} |`);
|
|
483
|
+
lines.push(`| Traceability (source refs) | ${mx.baseline.traceability_refs} | ${mx.grounded.traceability_refs} |`);
|
|
484
|
+
lines.push(`| Weak evidence disclosed | ${mx.baseline.weak_evidence_disclosed} | ${mx.grounded.weak_evidence_disclosed} |`);
|
|
485
|
+
lines.push(`| Smoke-only | ${mx.baseline.smoke_only} | ${mx.grounded.smoke_only} |`);
|
|
486
|
+
lines.push("");
|
|
487
|
+
// Compact index — one line per test — instead of full body dumps (those live
|
|
488
|
+
// in the test-cases file).
|
|
489
|
+
const indexArm = (label, tests) => {
|
|
490
|
+
for (const t of tests) {
|
|
491
|
+
const refs = t.grounding.source_refs.length;
|
|
492
|
+
lines.push(`- [${label}] ${t.title}${t.bucket ? ` {${t.bucket}}` : ""} — ${t.test_type}/${t.framework_hint}, ` +
|
|
493
|
+
`${refs} source ref${refs === 1 ? "" : "s"}${t.weak_evidence_used ? ", weak evidence" : ""}`);
|
|
494
|
+
}
|
|
495
|
+
};
|
|
496
|
+
lines.push("## Generated tests (index)");
|
|
497
|
+
lines.push("");
|
|
498
|
+
if (!cmp.grounded.generated_tests.length && !cmp.baseline.generated_tests.length) {
|
|
499
|
+
lines.push("_(no tests generated)_");
|
|
500
|
+
}
|
|
501
|
+
else {
|
|
502
|
+
indexArm("Local KG", cmp.grounded.generated_tests);
|
|
503
|
+
indexArm("Baseline", cmp.baseline.generated_tests);
|
|
504
|
+
if (files) {
|
|
505
|
+
lines.push("");
|
|
506
|
+
lines.push(`Full bodies: Local KG [\`${files.localKgTests}\`](./${files.localKgTests}), ` +
|
|
507
|
+
`baseline [\`${files.baselineTests}\`](./${files.baselineTests}).`);
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
lines.push("");
|
|
511
|
+
// Agent run hints (Local KG, runnable tests only). The agent writes each test
|
|
512
|
+
// to its path and runs it — OrangePro never writes to or runs the repo.
|
|
513
|
+
if (cmp.grounded.run_hints?.length) {
|
|
514
|
+
lines.push("## Agent run hints (Local KG)");
|
|
515
|
+
lines.push("");
|
|
516
|
+
lines.push("Write each test to its path and run it (the agent does this for you):");
|
|
517
|
+
lines.push("");
|
|
518
|
+
for (const h of cmp.grounded.run_hints) {
|
|
519
|
+
lines.push(`- \`${h.suggested_path}\` — run: \`${h.run_command}\``);
|
|
520
|
+
}
|
|
521
|
+
lines.push("");
|
|
522
|
+
}
|
|
523
|
+
else if (cmp.grounded.generated_tests.length) {
|
|
524
|
+
lines.push("## Review before running");
|
|
525
|
+
lines.push("");
|
|
526
|
+
lines.push("The Local KG tests are in the per-arm files above. Review each, write it to its own file, " +
|
|
527
|
+
"and run it with your repo's test command.");
|
|
528
|
+
lines.push("");
|
|
529
|
+
}
|
|
530
|
+
if (cmp.warnings.length) {
|
|
531
|
+
lines.push("## Warnings");
|
|
532
|
+
lines.push("");
|
|
533
|
+
for (const w of cmp.warnings)
|
|
534
|
+
lines.push(`- ${w}`);
|
|
535
|
+
lines.push("");
|
|
536
|
+
}
|
|
537
|
+
return lines.join("\n") + "\n";
|
|
538
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
const norm = (s) => s.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
2
|
+
const stemOf = (p) => norm(String(p).split("/").pop().replace(/\.(test|spec)\.[a-z0-9]+$/i, "").replace(/\.[a-z0-9]+$/i, ""));
|
|
3
|
+
// JS/TS + Python assertion shapes (concrete, not smoke/existence-only).
|
|
4
|
+
const CONCRETE_ASSERTION = /\.(toBe|toEqual|toContain|toBeCloseTo|toMatch|toHaveLength|toStrictEqual|toThrow|toHaveBeenCalled|toHaveBeenCalledWith|toMatchObject)\(|assert(Equal|True|False|Raises|In|Is|Almost)?\s*\(/g;
|
|
5
|
+
const importsOf = (body) => [
|
|
6
|
+
...[...body.matchAll(/from\s+['"]([^'"]+)['"]/g)].map((m) => m[1]),
|
|
7
|
+
...[...body.matchAll(/require\(\s*['"]([^'"]+)['"]\s*\)/g)].map((m) => m[1])
|
|
8
|
+
];
|
|
9
|
+
const isFramework = (s) => /vitest|vue|jest|mocha|chai|playwright|cypress|pytest|testing-library|unittest/i.test(s);
|
|
10
|
+
/** Build the "real things exist" oracle from the graph: file stems + domain tokens. */
|
|
11
|
+
export function buildOracle(graph) {
|
|
12
|
+
const fileStems = new Set();
|
|
13
|
+
const tokens = new Set();
|
|
14
|
+
const add = (raw) => {
|
|
15
|
+
const t = norm(String(raw ?? ""));
|
|
16
|
+
if (t.length >= 5)
|
|
17
|
+
tokens.add(t);
|
|
18
|
+
};
|
|
19
|
+
for (const n of graph.nodes) {
|
|
20
|
+
if (n.kind === "File") {
|
|
21
|
+
const s = stemOf(n.external_id);
|
|
22
|
+
if (s) {
|
|
23
|
+
fileStems.add(s);
|
|
24
|
+
if (s.length >= 5)
|
|
25
|
+
tokens.add(s);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
if (n.kind === "CodeSymbol")
|
|
29
|
+
add(n.title);
|
|
30
|
+
if (n.title)
|
|
31
|
+
add(n.title);
|
|
32
|
+
if (n.properties && n.properties.feature) {
|
|
33
|
+
const f = norm(String(n.properties.feature));
|
|
34
|
+
if (f.length >= 4) {
|
|
35
|
+
tokens.add(f);
|
|
36
|
+
fileStems.add(f);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return { fileStems, tokens };
|
|
41
|
+
}
|
|
42
|
+
/** Single per-test pass that feeds both the dimension scores and the matrix. */
|
|
43
|
+
function analyzeArm(tests, oracle) {
|
|
44
|
+
const a = {
|
|
45
|
+
n: tests.length,
|
|
46
|
+
assertionsSum: 0,
|
|
47
|
+
groundedCount: 0,
|
|
48
|
+
realApiCount: 0,
|
|
49
|
+
inventedSum: 0,
|
|
50
|
+
domainCount: 0,
|
|
51
|
+
smokeCount: 0,
|
|
52
|
+
traceRefs: 0,
|
|
53
|
+
weakCount: 0
|
|
54
|
+
};
|
|
55
|
+
for (const t of tests) {
|
|
56
|
+
const body = t.body || "";
|
|
57
|
+
const nb = norm(body);
|
|
58
|
+
const assertions = (body.match(CONCRETE_ASSERTION) || []).length;
|
|
59
|
+
a.assertionsSum += assertions;
|
|
60
|
+
if (assertions === 0)
|
|
61
|
+
a.smokeCount++;
|
|
62
|
+
const refs = t.grounding.source_refs?.length || 0;
|
|
63
|
+
a.traceRefs += refs;
|
|
64
|
+
if (refs > 0)
|
|
65
|
+
a.groundedCount++;
|
|
66
|
+
if (t.weak_evidence_used)
|
|
67
|
+
a.weakCount++;
|
|
68
|
+
const nonFw = importsOf(body).filter((s) => !isFramework(s));
|
|
69
|
+
const importsReal = nonFw.some((s) => oracle.fileStems.has(stemOf(s)));
|
|
70
|
+
a.inventedSum += nonFw.filter((s) => !oracle.fileStems.has(stemOf(s))).length;
|
|
71
|
+
const mentionsDomain = [...oracle.tokens].some((tok) => nb.includes(tok));
|
|
72
|
+
if (mentionsDomain)
|
|
73
|
+
a.domainCount++;
|
|
74
|
+
if (importsReal || mentionsDomain)
|
|
75
|
+
a.realApiCount++;
|
|
76
|
+
}
|
|
77
|
+
return a;
|
|
78
|
+
}
|
|
79
|
+
/** Score one arm's tests across the four dimensions (0-100 each). */
|
|
80
|
+
export function scoreArm(tests, oracle) {
|
|
81
|
+
const a = analyzeArm(tests, oracle);
|
|
82
|
+
if (a.n === 0)
|
|
83
|
+
return { completeness: 0, context_awareness: 0, accuracy: 0, domain_specificity: 0 };
|
|
84
|
+
const assertionsAvg = a.assertionsSum / a.n;
|
|
85
|
+
const inventedAvg = a.inventedSum / a.n;
|
|
86
|
+
const pct = (x) => Math.max(0, Math.min(100, Math.round(100 * x)));
|
|
87
|
+
return {
|
|
88
|
+
// depth (assertions, target ~3) blended with breadth (scenario count, target ~3)
|
|
89
|
+
completeness: pct(0.6 * Math.min(1, assertionsAvg / 3) + 0.4 * Math.min(1, a.n / 3)),
|
|
90
|
+
context_awareness: pct(a.groundedCount / a.n),
|
|
91
|
+
accuracy: pct(Math.max(0, a.realApiCount / a.n - 0.5 * Math.min(1, inventedAvg / 2))),
|
|
92
|
+
domain_specificity: pct(a.domainCount / a.n)
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
/** Objective comparison matrix (incl. traceability) for one arm. */
|
|
96
|
+
export function armMetrics(tests, oracle) {
|
|
97
|
+
const a = analyzeArm(tests, oracle);
|
|
98
|
+
return {
|
|
99
|
+
tests: a.n,
|
|
100
|
+
concrete_assertions_avg: a.n ? Math.round((a.assertionsSum / a.n) * 10) / 10 : 0,
|
|
101
|
+
traceability_refs: a.traceRefs,
|
|
102
|
+
weak_evidence_disclosed: a.weakCount,
|
|
103
|
+
smoke_only: a.smokeCount
|
|
104
|
+
};
|
|
105
|
+
}
|