@orangepro/orangepro-mcp 0.2.8 → 0.2.9
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.
|
@@ -47,7 +47,7 @@ function extractionBackend(language) {
|
|
|
47
47
|
return "tsc"; // TS/JS compiler path (or unextracted)
|
|
48
48
|
return treeSitterReady(language) ? "ts" : "rx"; // tree-sitter AST vs regex fallback
|
|
49
49
|
}
|
|
50
|
-
import { runConfirmer } from "./confirm.js";
|
|
50
|
+
import { repoRootOf, resolveSpecifier, workspacePackages, runConfirmer } from "./confirm.js";
|
|
51
51
|
const DETECTOR = "repo_analyzer";
|
|
52
52
|
// Global ceiling on extracted code symbols. A SINGLE counter shared across the walk,
|
|
53
53
|
// so a low value lets whichever language is walked first (e.g. a Go `server/`) eat the
|
|
@@ -1972,6 +1972,46 @@ export function analyzeRepo(root, opts = {}) {
|
|
|
1972
1972
|
seenPair.add(key);
|
|
1973
1973
|
candidates.push({ testRel, testAbs, implRel, implAbs });
|
|
1974
1974
|
}
|
|
1975
|
+
// Import-derived pairing: a test file is a candidate for every in-repo
|
|
1976
|
+
// impl file it imports — an import IS the relationship; resolution
|
|
1977
|
+
// (relative, tsconfig paths, npm/pnpm workspace names) decides
|
|
1978
|
+
// membership. Adds PAIRS only; the assertion-aware confirmer remains
|
|
1979
|
+
// the sole judge.
|
|
1980
|
+
{
|
|
1981
|
+
const pairRoot = repoRootOf(root);
|
|
1982
|
+
const wsPkgs = workspacePackages(pairRoot);
|
|
1983
|
+
const relByAbs = new Map(resolveFiles.map((f) => [f.abs, f.rel]));
|
|
1984
|
+
const IMPORT_SPEC_RE = /(?:import|export)[^'"\n]*from\s*['"]([^'"]+)['"]|require\(\s*['"]([^'"]+)['"]\s*\)/g;
|
|
1985
|
+
for (const tf of resolveFiles) {
|
|
1986
|
+
if (tf.role !== "test")
|
|
1987
|
+
continue;
|
|
1988
|
+
let srcTxt = "";
|
|
1989
|
+
try {
|
|
1990
|
+
srcTxt = readFileSync(tf.abs, "utf8");
|
|
1991
|
+
}
|
|
1992
|
+
catch {
|
|
1993
|
+
continue;
|
|
1994
|
+
}
|
|
1995
|
+
let m;
|
|
1996
|
+
IMPORT_SPEC_RE.lastIndex = 0;
|
|
1997
|
+
while ((m = IMPORT_SPEC_RE.exec(srcTxt)) !== null) {
|
|
1998
|
+
const spec = m[1] ?? m[2];
|
|
1999
|
+
if (!spec)
|
|
2000
|
+
continue;
|
|
2001
|
+
const resolvedAbs = resolveSpecifier(spec, tf.abs, pairRoot, wsPkgs);
|
|
2002
|
+
if (!resolvedAbs)
|
|
2003
|
+
continue;
|
|
2004
|
+
const implRel = relByAbs.get(resolvedAbs);
|
|
2005
|
+
if (!implRel || !eligibleSymbolsByFile.has(implRel))
|
|
2006
|
+
continue;
|
|
2007
|
+
const key = `${tf.rel}|${implRel}`;
|
|
2008
|
+
if (seenPair.has(key))
|
|
2009
|
+
continue;
|
|
2010
|
+
seenPair.add(key);
|
|
2011
|
+
candidates.push({ testRel: tf.rel, testAbs: tf.abs, implRel, implAbs: resolvedAbs });
|
|
2012
|
+
}
|
|
2013
|
+
}
|
|
2014
|
+
}
|
|
1975
2015
|
const confirmBudget = Math.max(1, Number(process.env.ORANGEPRO_MAX_CONFIRM_FILES) || 1500);
|
|
1976
2016
|
const riskSymbolLimit = Math.max(1, Number(process.env.ORANGEPRO_CONFIRM_RISK_SYMBOLS) || DEFAULT_CONFIRM_RISK_SYMBOLS);
|
|
1977
2017
|
const involved = new Set();
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
// is the SOLE producer of hard TESTED_BY/COVERS edges.
|
|
23
23
|
import ts from "typescript";
|
|
24
24
|
import path from "node:path";
|
|
25
|
+
import { readFileSync as fsRead, existsSync as fsExists } from "node:fs";
|
|
25
26
|
import { loadTsConfigFor, resolveImport } from "../resolve/resolver.js";
|
|
26
27
|
import { walkBarrel } from "../resolve/barrelWalker.js";
|
|
27
28
|
import { isSelfAssertingCallee } from "./selfAssert.js";
|
|
@@ -33,10 +34,45 @@ const norm = (p) => path.resolve(p);
|
|
|
33
34
|
* the target repo. JSX is preserved and emit/lib-checks are off — we only read
|
|
34
35
|
* symbols, never compile.
|
|
35
36
|
*/
|
|
37
|
+
export function repoRootOf(anchor) {
|
|
38
|
+
let dir = anchor;
|
|
39
|
+
for (let i = 0; i < 15; i++) {
|
|
40
|
+
try {
|
|
41
|
+
const pj = JSON.parse(fsRead(path.join(dir, "package.json"), "utf8"));
|
|
42
|
+
if (pj && pj.workspaces)
|
|
43
|
+
return dir;
|
|
44
|
+
}
|
|
45
|
+
catch { /* not here; walk up */ }
|
|
46
|
+
if (fsExists(path.join(dir, "pnpm-workspace.yaml")))
|
|
47
|
+
return dir;
|
|
48
|
+
if (fsExists(path.join(dir, ".git")) || fsExists(path.join(dir, "go.mod")))
|
|
49
|
+
return dir;
|
|
50
|
+
const parent = path.dirname(dir);
|
|
51
|
+
if (parent === dir)
|
|
52
|
+
return dir;
|
|
53
|
+
dir = parent;
|
|
54
|
+
}
|
|
55
|
+
return dir;
|
|
56
|
+
}
|
|
36
57
|
export function buildConfirmProgram(absFiles, anchorFile) {
|
|
37
58
|
const base = loadTsConfigFor(anchorFile).options;
|
|
59
|
+
// Workspace-aware resolution: synthesize compilerOptions.paths from the
|
|
60
|
+
// repo's workspaces manifest so the STANDARD compiler resolves
|
|
61
|
+
// "@scope/pkg" and "@scope/pkg/lib/x" to source. Adds no confirmation
|
|
62
|
+
// path — only lets the existing assertion-aware bar evaluate tests it
|
|
63
|
+
// previously could not resolve at all.
|
|
64
|
+
const wsRoot = repoRootOf(anchorFile);
|
|
65
|
+
const wsPaths = {};
|
|
66
|
+
for (const [name, dir] of workspacePackages(wsRoot)) {
|
|
67
|
+
wsPaths[name] = [dir + "/src/index", dir + "/index", dir + "/src"];
|
|
68
|
+
wsPaths[name + "/lib/*"] = [dir + "/src/*"];
|
|
69
|
+
wsPaths[name + "/dist/*"] = [dir + "/src/*"];
|
|
70
|
+
wsPaths[name + "/*"] = [dir + "/src/*", dir + "/*"];
|
|
71
|
+
}
|
|
38
72
|
const options = {
|
|
39
73
|
...base,
|
|
74
|
+
baseUrl: base.baseUrl ?? wsRoot,
|
|
75
|
+
paths: { ...wsPaths, ...base.paths },
|
|
40
76
|
noEmit: true,
|
|
41
77
|
allowJs: true,
|
|
42
78
|
checkJs: false,
|
|
@@ -2423,11 +2459,22 @@ function tsconfigPathsFor(fileAbs, stopDir) {
|
|
|
2423
2459
|
return null;
|
|
2424
2460
|
}
|
|
2425
2461
|
/** Workspace member name → package dir, from the root package.json workspaces globs. */
|
|
2426
|
-
function workspacePackages(repoRoot) {
|
|
2462
|
+
export function workspacePackages(repoRoot) {
|
|
2463
|
+
const pnpmGlobs = [];
|
|
2464
|
+
try {
|
|
2465
|
+
const y = fsRead(path.join(repoRoot, "pnpm-workspace.yaml"), "utf8");
|
|
2466
|
+
for (const line of y.split("\n")) {
|
|
2467
|
+
const m = /^\s*-\s*['"]?([^'"#\n]+?)['"]?\s*$/.exec(line);
|
|
2468
|
+
if (m)
|
|
2469
|
+
pnpmGlobs.push(m[1].trim());
|
|
2470
|
+
}
|
|
2471
|
+
}
|
|
2472
|
+
catch { /* not a pnpm workspace */ }
|
|
2427
2473
|
const out = new Map();
|
|
2428
2474
|
const rootPkg = readJsonSafe(_jn(repoRoot, "package.json"));
|
|
2429
2475
|
const ws = rootPkg?.workspaces;
|
|
2430
|
-
const
|
|
2476
|
+
const npmGlobs = Array.isArray(ws) ? ws : Array.isArray(ws?.packages) ? ws.packages : [];
|
|
2477
|
+
const globs = [...npmGlobs, ...pnpmGlobs];
|
|
2431
2478
|
const dirs = [];
|
|
2432
2479
|
for (const g of globs) {
|
|
2433
2480
|
if (g.endsWith("/*")) {
|
|
@@ -159,6 +159,8 @@ export function buildBatchGenerationSystemPromptV5() {
|
|
|
159
159
|
"- Never mock, stub, or spy on the behavior-under-test itself. The subject must execute for real. Mock only true external I/O boundaries — network calls, the system clock, third-party SDKs, outbound HTTP. If the behavior calls internal services in the same codebase, let them run (or use real test doubles at the I/O edge, never at the subject). A test that mocks the subject proves nothing and will be rejected.",
|
|
160
160
|
"- Each test is complete and runnable (all imports, setup, assertions, cleanup).",
|
|
161
161
|
"- Start each test with: // Concern: <concern> | Technique: <technique>",
|
|
162
|
+
"- When asserting an exact return value (string, number, constant), copy the expected value VERBATIM from the provided source code. Never invent an expected value.",
|
|
163
|
+
"- If the exact value is not visible in the provided source, assert structure instead (non-nil, error vs no-error, type, boolean outcome) — never a guessed literal.",
|
|
162
164
|
"- Assert all targets listed in each scenario.",
|
|
163
165
|
"- Do not copy source excerpts verbatim. Use them to understand, then write original code.",
|
|
164
166
|
"- Reuse SUBJECT IMPORTS. Do not invent module paths.",
|
package/dist/local/score/risk.js
CHANGED
|
@@ -139,8 +139,8 @@ function deriveRouteWeight(node) {
|
|
|
139
139
|
function deriveDataSensitivity(node) {
|
|
140
140
|
const text = `${node.external_id} ${symbolFile(node)} ${symbolTitle(node)}`.toLowerCase();
|
|
141
141
|
const tiers = [
|
|
142
|
-
[/payment|stripe|
|
|
143
|
-
[/auth|token|session|password|credential|jwt|oauth/, 9],
|
|
142
|
+
[/payment|stripe|refund|charge(?!r)|billing|payout|chargeback/, 10],
|
|
143
|
+
[/auth(?!or\b)|token(?!iz)|session|password|credential|jwt|oauth/, 9],
|
|
144
144
|
[/order|cart|checkout|invoice|transaction/, 7],
|
|
145
145
|
[/customer|user|account|profile|pii|gdpr/, 6],
|
|
146
146
|
[/notification|email|sms|webhook|push/, 3]
|
|
@@ -389,7 +389,10 @@ export function rankRiskGaps(graph, opts = {}) {
|
|
|
389
389
|
const i = Math.round(iExact);
|
|
390
390
|
const d = rawScores[idx].d;
|
|
391
391
|
const detectionTier = detectionFor(s.external_id);
|
|
392
|
-
|
|
392
|
+
let score = Math.round(pExact * iExact * d * 10) / 10;
|
|
393
|
+
const disconnected = (incoming.get(s.external_id) ?? 0) === 0 && fan_out === 0;
|
|
394
|
+
if (disconnected)
|
|
395
|
+
score = Math.round(score * 0.25 * 10) / 10;
|
|
393
396
|
const reasons = [
|
|
394
397
|
`ORS ${score} ≈ P${p} × I${i} × D${d}`,
|
|
395
398
|
`${incoming_refs} incoming structural reference${incoming_refs === 1 ? "" : "s"} (method-attributed)`,
|
|
@@ -400,6 +403,8 @@ export function rankRiskGaps(graph, opts = {}) {
|
|
|
400
403
|
reasons.push("near an API/route/handler entry point");
|
|
401
404
|
if (is_new_code)
|
|
402
405
|
reasons.push("new code (< 30 days)");
|
|
406
|
+
if (disconnected)
|
|
407
|
+
reasons.push("no callers and no callees — structurally disconnected, score dampened");
|
|
403
408
|
if (detectionTier === "candidate")
|
|
404
409
|
reasons.push("lexical candidate test match only — unconfirmed");
|
|
405
410
|
return {
|
|
@@ -431,11 +436,18 @@ export function rankRiskGaps(graph, opts = {}) {
|
|
|
431
436
|
return ranked.slice(0, limit);
|
|
432
437
|
const maxPerFile = Math.max(1, opts.maxPerFile);
|
|
433
438
|
const perFile = new Map();
|
|
439
|
+
// Multi-program repos flood identical titles (76 x main) across files; the
|
|
440
|
+
// per-FILE cap cannot see it. Same diversity principle, second axis.
|
|
441
|
+
const maxPerTitle = 2;
|
|
442
|
+
const perTitle = new Map();
|
|
434
443
|
const surfaced = [];
|
|
435
444
|
const overflow = [];
|
|
436
445
|
for (const gap of ranked) {
|
|
437
446
|
const used = perFile.get(gap.file) ?? 0;
|
|
438
|
-
|
|
447
|
+
const tKey = (gap.title || "").split("(")[0].trim();
|
|
448
|
+
const tUsed = perTitle.get(tKey) ?? 0;
|
|
449
|
+
if (used < maxPerFile && tUsed < maxPerTitle) {
|
|
450
|
+
perTitle.set(tKey, tUsed + 1);
|
|
439
451
|
perFile.set(gap.file, used + 1);
|
|
440
452
|
surfaced.push(gap);
|
|
441
453
|
}
|
|
@@ -409,6 +409,12 @@ function fmtRefs(n) {
|
|
|
409
409
|
return "<1";
|
|
410
410
|
return String(Math.round(n));
|
|
411
411
|
}
|
|
412
|
+
function displayTitle(title, file) {
|
|
413
|
+
if (title.includes("."))
|
|
414
|
+
return title;
|
|
415
|
+
const pkg = file && file.includes("/") ? file.split("/").slice(-2, -1)[0] : "";
|
|
416
|
+
return pkg ? `${pkg}.${title}` : title;
|
|
417
|
+
}
|
|
412
418
|
/** Deterministic 1–2 line behavior context from graph facts only — no LLM.
|
|
413
419
|
* Sensitivity label mirrors deriveDataSensitivity's tiers. */
|
|
414
420
|
function riskContext(risk) {
|
|
@@ -740,13 +746,16 @@ function riskTodo(risk, verb, path, generatedTests) {
|
|
|
740
746
|
const call = verb !== "BEHAVIOR"
|
|
741
747
|
? `issues ${verb} ${path}`
|
|
742
748
|
: risk.entry_point
|
|
743
|
-
? `invokes ${risk.title} through its entry point`
|
|
744
|
-
: `calls ${risk.title} directly`;
|
|
745
|
-
const
|
|
746
|
-
|
|
747
|
-
:
|
|
748
|
-
|
|
749
|
-
: "
|
|
749
|
+
? `invokes ${displayTitle(risk.title, risk.file)} through its entry point`
|
|
750
|
+
: `calls ${displayTitle(risk.title, risk.file)} directly`;
|
|
751
|
+
const s = risk.data_sensitivity ?? 1;
|
|
752
|
+
const sens = s >= 10
|
|
753
|
+
? " Include a failure case: a rejected transaction must leave no partial state."
|
|
754
|
+
: s >= 9
|
|
755
|
+
? " Include a negative case: invalid or expired credentials must fail closed."
|
|
756
|
+
: s >= 7
|
|
757
|
+
? " Include a failure case: a rejected transaction must leave no partial state."
|
|
758
|
+
: "";
|
|
750
759
|
if (risk.integration_signal === "candidate") {
|
|
751
760
|
return `A similarly named test exists but nothing links it. Write a test that imports and ${call}, asserting the observable outcome — that upgrades this from unconfirmed candidate to a hard link.${sens}`;
|
|
752
761
|
}
|
|
@@ -790,7 +799,7 @@ function riskRows(risks, graph) {
|
|
|
790
799
|
...(() => {
|
|
791
800
|
const generatedTests = riskGeneratedTests(graph, risk, riskIds, firstRowForFile.get(risk.file) === risk.id);
|
|
792
801
|
const verb = methodMatch?.[1]?.toUpperCase() ?? "BEHAVIOR";
|
|
793
|
-
const path = qualify(risk, methodMatch?.[2] ?? risk.title);
|
|
802
|
+
const path = qualify(risk, methodMatch?.[2] ?? displayTitle(risk.title, risk.file));
|
|
794
803
|
const generatedCategories = [...new Set([
|
|
795
804
|
...generatedTests.map((t) => (t.bucket ? BUCKET_TO_CONCERN[t.bucket] : undefined)),
|
|
796
805
|
// An integration/api/e2e-layer draft targets integration_flow. This
|
|
@@ -824,7 +833,7 @@ export function buildBehaviorReportData(graph, ledger, opts = {}) {
|
|
|
824
833
|
const flowIds = flowSymbolIds(graph);
|
|
825
834
|
const summary = summaryFromRows(rows, flowIds);
|
|
826
835
|
const repoRoot = opts.repoRoot ?? graph.workspace.root;
|
|
827
|
-
const riskGaps = rankRiskGaps(graph, { repoRoot, limit: opts.riskLimit ?? 20 });
|
|
836
|
+
const riskGaps = rankRiskGaps(graph, { repoRoot, limit: opts.riskLimit ?? 20, maxPerFile: 3 });
|
|
828
837
|
const lists = behaviorLists(rows, flowIds);
|
|
829
838
|
const risks = riskRows(riskGaps, graph);
|
|
830
839
|
const sortedBehaviors = [...lists.behaviors].sort((a, b) => tierRank(a) - tierRank(b));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@orangepro/orangepro-mcp",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.9",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "OrangePro (`opro`) — a local-first, BYOK CLI + MCP server that builds an evidence graph from a local checkout, ingests runtime coverage, and generates grounded tests. Metadata-only exports; no source upload; generated tests stay local.",
|
|
6
6
|
"license": "MIT",
|