@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 globs = Array.isArray(ws) ? ws : Array.isArray(ws?.packages) ? ws.packages : [];
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.",
@@ -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|capture|refund|charge|billing/, 10],
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
- const score = Math.round(pExact * iExact * d * 10) / 10;
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
- if (used < maxPerFile) {
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 sens = (risk.data_sensitivity ?? 1) >= 9
746
- ? " Include a negative case: invalid or expired credentials must fail closed."
747
- : (risk.data_sensitivity ?? 1) >= 7
748
- ? " Include a failure case: a rejected transaction must leave no partial state."
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.8",
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",