@orangepro/orangepro-mcp 0.2.43 → 0.2.44

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.
@@ -1935,10 +1935,36 @@ export function analyzeRepo(root, opts = {}) {
1935
1935
  const targetRel = resolvePythonImport(testRel, binding.module) ?? undefined;
1936
1936
  return { targetRel, imported: binding.imported, kind: binding.kind };
1937
1937
  };
1938
- const resolvePythonProofTarget = (testRel, structure, qualifier, callee, shadowed) => {
1938
+ const resolvePythonClassTarget = (testRel, structure, classLocal) => {
1939
+ const binding = pythonProofImportBinding(testRel, structure, classLocal);
1940
+ if (!binding)
1941
+ return null;
1942
+ if (binding.kind !== "named" || !binding.targetRel || !binding.imported || isNonProductFile(binding.targetRel))
1943
+ return null;
1944
+ if (symbolKind(binding.targetRel, binding.imported) === "class") {
1945
+ return { targetRel: binding.targetRel, className: binding.imported };
1946
+ }
1947
+ // One explicit, unique package re-export hop (`pkg.__init__: from .impl import Cls`).
1948
+ // Ambiguity, wildcard exports, cycles, or a second hop fail closed.
1949
+ const exportStructure = nonTsStructureByFile.get(binding.targetRel)?.structure;
1950
+ const exports = (exportStructure?.imports ?? []).filter((candidate) => candidate.kind === "named" && candidate.local === binding.imported && candidate.imported === binding.imported);
1951
+ if (exports.length !== 1)
1952
+ return null;
1953
+ const targetRel = resolvePythonImport(binding.targetRel, exports[0].module);
1954
+ return targetRel && targetRel !== binding.targetRel && !isNonProductFile(targetRel) && symbolKind(targetRel, binding.imported) === "class"
1955
+ ? { targetRel, className: binding.imported }
1956
+ : null;
1957
+ };
1958
+ const resolvePythonProofTarget = (testRel, structure, qualifier, callee, shadowed, instanceClass) => {
1939
1959
  const conv = conventionSibling(testRel, "python", codeFileSet);
1940
1960
  const conventionTargetRel = conv?.relPath && !isNonProductFile(conv.relPath) ? conv.relPath : null;
1941
1961
  const hasWildcardImport = structure.imports.some((i) => i.imported === "*");
1962
+ if (instanceClass) {
1963
+ if (!qualifier || shadowed.has(instanceClass) || hasWildcardImport)
1964
+ return null;
1965
+ const resolved = resolvePythonClassTarget(testRel, structure, instanceClass);
1966
+ return resolved ? pythonQualifiedMemberId(resolved.targetRel, resolved.className, callee) : null;
1967
+ }
1942
1968
  if (!qualifier) {
1943
1969
  if (shadowed.has(callee))
1944
1970
  return null;
@@ -2377,7 +2403,7 @@ export function analyzeRepo(root, opts = {}) {
2377
2403
  const testExternalId = `test:${testRel}`;
2378
2404
  for (const proof of structure.pythonProofCalls ?? []) {
2379
2405
  pythonProofAttempted++;
2380
- const symId = resolvePythonProofTarget(testRel, structure, proof.qualifier, proof.callee, new Set(proof.shadowed));
2406
+ const symId = resolvePythonProofTarget(testRel, structure, proof.qualifier, proof.callee, new Set(proof.shadowed), proof.instanceClass);
2381
2407
  if (!symId)
2382
2408
  continue;
2383
2409
  const edgeKey = `${testExternalId}|${symId}`;
@@ -1856,19 +1856,195 @@ function singlePythonComparisonCall(comparison) {
1856
1856
  const calls = namedChildren(comparison).filter((child) => child.type === "call");
1857
1857
  return calls.length === 1 ? calls[0] ?? null : null;
1858
1858
  }
1859
+ function pythonDirectAssignment(stmt) {
1860
+ const assignment = stmt.type === "assignment" || stmt.type === "annotated_assignment"
1861
+ ? stmt
1862
+ : namedChildren(stmt).find((child) => child.type === "assignment" || child.type === "annotated_assignment");
1863
+ if (!assignment)
1864
+ return null;
1865
+ const target = assignment.childForFieldName("left");
1866
+ const value = assignment.childForFieldName("right");
1867
+ return target && value ? { target, value } : null;
1868
+ }
1869
+ function pythonSingleCall(node) {
1870
+ const calls = [...(node.type === "call" ? [node] : []), ...node.descendantsOfType("call").filter((call) => Boolean(call))];
1871
+ const unique = [...new Map(calls.map((call) => [`${call.startIndex}:${call.endIndex}`, call])).values()];
1872
+ return unique.length === 1 ? unique[0] ?? null : null;
1873
+ }
1874
+ function pythonExactCall(node) {
1875
+ if (!node)
1876
+ return null;
1877
+ if (node.type === "call")
1878
+ return node;
1879
+ if (node.type === "await" || node.type === "parenthesized_expression") {
1880
+ const children = namedChildren(node);
1881
+ return children.length === 1 ? pythonExactCall(children[0] ?? null) : null;
1882
+ }
1883
+ return null;
1884
+ }
1885
+ function pythonDirectAssertedResult(assertion, results) {
1886
+ const subject = namedChildren(assertion)[0];
1887
+ if (subject?.type !== "comparison_operator")
1888
+ return null;
1889
+ const operands = namedChildren(subject);
1890
+ if (operands.length !== 2)
1891
+ return null;
1892
+ const candidates = operands.filter((operand) => operand.type === "identifier" && results.has(operand.text));
1893
+ if (candidates.length !== 1)
1894
+ return null;
1895
+ const name = candidates[0].text;
1896
+ const occurrences = subject.descendantsOfType("identifier")
1897
+ .filter((identifier) => Boolean(identifier))
1898
+ .filter((identifier) => identifier.text === name);
1899
+ return occurrences.length === 1 ? name : null;
1900
+ }
1901
+ function pythonLexicalNodes(root) {
1902
+ const out = [];
1903
+ const visit = (node) => {
1904
+ for (const child of namedChildren(node)) {
1905
+ if (child.type === "function_definition" || child.type === "class_definition" || child.type === "lambda")
1906
+ continue;
1907
+ out.push(child);
1908
+ visit(child);
1909
+ }
1910
+ };
1911
+ visit(root);
1912
+ return out;
1913
+ }
1914
+ function pythonBindingNames(node) {
1915
+ if (node.type === "assignment" || node.type === "annotated_assignment" || node.type === "augmented_assignment" || node.type === "named_expression") {
1916
+ const target = node.childForFieldName("left") ?? node.childForFieldName("name");
1917
+ if (target?.type === "attribute") {
1918
+ const dotted = pythonDottedName(target);
1919
+ return dotted ? [dotted.split(".")[0] ?? dotted] : [];
1920
+ }
1921
+ return target ? pythonBindingTargetNames(target) : [];
1922
+ }
1923
+ if (node.type === "for_statement") {
1924
+ const target = node.childForFieldName("left");
1925
+ return target ? pythonBindingTargetNames(target) : [];
1926
+ }
1927
+ if (node.type === "delete_statement") {
1928
+ return namedChildren(node).flatMap((target) => {
1929
+ if (target.type === "attribute") {
1930
+ const dotted = pythonDottedName(target);
1931
+ return dotted ? [dotted.split(".")[0] ?? dotted] : [];
1932
+ }
1933
+ return pythonBindingTargetNames(target);
1934
+ });
1935
+ }
1936
+ return [];
1937
+ }
1938
+ function pythonAssignedInstanceProofCalls(block) {
1939
+ const testFunction = block.parent;
1940
+ const decorated = testFunction?.parent?.type === "decorated_definition" ? testFunction.parent : testFunction;
1941
+ const parameterNames = testFunction?.childForFieldName("parameters")?.descendantsOfType("identifier").map((node) => node?.text).filter(Boolean) ?? [];
1942
+ if (parameterNames.some((name) => name !== "self" && name !== "cls"))
1943
+ return [];
1944
+ if (/\b(?:monkeypatch|mocker|patch|Mock|MagicMock|AsyncMock|create_autospec)\b/.test(decorated?.text ?? ""))
1945
+ return [];
1946
+ const lexicalNodes = pythonLexicalNodes(block);
1947
+ const assignments = lexicalNodes.filter((node) => node.type === "assignment" || node.type === "annotated_assignment");
1948
+ const assertions = lexicalNodes.filter((node) => node.type === "assert_statement");
1949
+ if (assertions.length !== 1)
1950
+ return [];
1951
+ const mutationCounts = new Map();
1952
+ for (const node of lexicalNodes) {
1953
+ for (const name of pythonBindingNames(node))
1954
+ mutationCounts.set(name, (mutationCounts.get(name) ?? 0) + 1);
1955
+ }
1956
+ const instances = new Map();
1957
+ const orderedAssignments = assignments.map(pythonDirectAssignment).filter((assignment) => Boolean(assignment));
1958
+ for (const { target, value } of orderedAssignments) {
1959
+ if (target.type !== "identifier" || mutationCounts.get(target.text) !== 1)
1960
+ continue;
1961
+ const constructor = pythonSingleCall(value);
1962
+ const parts = constructor ? callParts(constructor, "python") : null;
1963
+ if (!parts || parts.qualifier || !/^[A-Za-z_]\w*$/.test(parts.callee))
1964
+ continue;
1965
+ if ((mutationCounts.get(parts.callee) ?? 0) > 0)
1966
+ continue;
1967
+ instances.set(target.text, { className: parts.callee, assignedAt: target.startIndex });
1968
+ }
1969
+ for (const { target, value } of orderedAssignments) {
1970
+ if (target.type === "identifier" && value.type === "identifier" && instances.has(value.text))
1971
+ return [];
1972
+ }
1973
+ const trackedNames = new Set([
1974
+ ...instances.keys(),
1975
+ ...[...instances.values()].map((instance) => instance.className)
1976
+ ]);
1977
+ for (const callNode of lexicalNodes.filter((node) => node.type === "call")) {
1978
+ const call = callParts(callNode, "python");
1979
+ const receiver = call?.qualifier?.split(".")[0] ?? "";
1980
+ if (instances.has(receiver))
1981
+ continue;
1982
+ const args = callNode.childForFieldName("arguments");
1983
+ const argumentNames = args
1984
+ ? [
1985
+ ...(args.type === "identifier" ? [args] : []),
1986
+ ...args.descendantsOfType("identifier").filter((identifier) => Boolean(identifier))
1987
+ ].map((identifier) => identifier.text)
1988
+ : [];
1989
+ if (argumentNames.some((name) => trackedNames.has(name)))
1990
+ return [];
1991
+ }
1992
+ const results = new Map();
1993
+ const instanceCalls = lexicalNodes.filter((node) => node.type === "call").filter((callNode) => {
1994
+ const call = callParts(callNode, "python");
1995
+ const receiver = call?.qualifier?.split(".")[0] ?? "";
1996
+ return instances.has(receiver);
1997
+ });
1998
+ if (instanceCalls.length !== 1)
1999
+ return [];
2000
+ for (const { target, value } of orderedAssignments) {
2001
+ if (target.type !== "identifier" || mutationCounts.get(target.text) !== 1)
2002
+ continue;
2003
+ const callNode = pythonExactCall(value);
2004
+ const call = callNode ? callParts(callNode, "python") : null;
2005
+ const receiver = call?.qualifier?.split(".")[0] ?? "";
2006
+ const instance = instances.get(receiver);
2007
+ if (call && instance && instance.assignedAt < value.startIndex) {
2008
+ results.set(target.text, { call, instanceClass: instance.className });
2009
+ }
2010
+ }
2011
+ const out = [];
2012
+ for (const stmt of assertions) {
2013
+ const direct = directPythonAssertCall(stmt);
2014
+ const directReceiver = direct?.qualifier?.split(".")[0] ?? "";
2015
+ const directInstance = instances.get(directReceiver);
2016
+ if (direct && directInstance && directInstance.assignedAt < stmt.startIndex) {
2017
+ out.push({ assertion: stmt, call: direct, instanceClass: directInstance.className });
2018
+ }
2019
+ const subject = namedChildren(stmt)[0];
2020
+ const assertionCalls = subject
2021
+ ? [...(subject.type === "call" ? [subject] : []), ...subject.descendantsOfType("call").filter((call) => Boolean(call))]
2022
+ : [];
2023
+ if (assertionCalls.length > 0)
2024
+ continue;
2025
+ const observed = pythonDirectAssertedResult(stmt, results);
2026
+ const result = observed ? results.get(observed) : undefined;
2027
+ if (result)
2028
+ out.push({ assertion: stmt, ...result });
2029
+ }
2030
+ return out;
2031
+ }
1859
2032
  function extractPythonProofCalls(root) {
1860
2033
  const out = [];
1861
2034
  const seen = new Set();
1862
- const add = (testName, shadowed, call) => {
2035
+ const add = (testName, shadowed, call, instanceClass) => {
1863
2036
  if (!call)
1864
2037
  return;
1865
- const key = `${testName}|${call.qualifier ?? ""}|${call.callee}`;
2038
+ const key = `${testName}|${instanceClass ?? ""}|${call.qualifier ?? ""}|${call.callee}`;
1866
2039
  if (seen.has(key))
1867
2040
  return;
1868
2041
  seen.add(key);
1869
- out.push({ caller: testName, testName, ...call, shadowed: [...shadowed], assertion: "pytest_assert" });
2042
+ out.push({ caller: testName, testName, ...call, shadowed: [...shadowed], assertion: "pytest_assert", ...(instanceClass ? { instanceClass } : {}) });
1870
2043
  };
1871
2044
  const processBlock = (block, testName, shadowed) => {
2045
+ for (const associated of pythonAssignedInstanceProofCalls(block)) {
2046
+ add(testName, shadowed, associated.call, associated.instanceClass);
2047
+ }
1872
2048
  for (const stmt of blockStatements(block)) {
1873
2049
  if (stmt.type === "assert_statement")
1874
2050
  add(testName, shadowed, directPythonAssertCall(stmt));
@@ -13,7 +13,7 @@
13
13
  * No key ⇒ writes NO files, mints NO proof, returns explicit guidance.
14
14
  */
15
15
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
16
- import { basename, dirname, posix, resolve, sep } from "node:path";
16
+ import { basename, dirname, join, posix, relative, resolve, sep } from "node:path";
17
17
  import { generateTests, readDeclaredDeps, unresolvedLocalImports } from "./generate/generator.js";
18
18
  import { GENERATED_DIR, runHintsFor } from "./generate/runHints.js";
19
19
  import { rankRiskGaps } from "./score/risk.js";
@@ -21,12 +21,12 @@ import { resolveProviderConfig } from "./localConfig.js";
21
21
  import { buildProvider, DeterministicProvider } from "./generate/providers.js";
22
22
  import { resolveContained } from "./reprove/paths.js";
23
23
  import { buildRtm } from "./rtm.js";
24
- import { loadLedger } from "./ledger.js";
24
+ import { loadLedger, targetLanguage } from "./ledger.js";
25
25
  import { reportProgress } from "./util/progress.js";
26
26
  import { loadGraph, workspacePaths } from "./workspace.js";
27
27
  import { systemClock } from "./util/time.js";
28
28
  import { redactSecrets } from "./util/redact.js";
29
- import { classifyBaselineFailure, EXPERIMENTAL_SQLITE_TEST_ENV, IMPORT_TIME_CATEGORIES, isNeedsSetupCategory, readEnginesNode, targetNeedsExperimentalSqlite } from "./proofRunnability.js";
29
+ import { classifyBaselineFailure, EXPERIMENTAL_SQLITE_TEST_ENV, isNeedsSetupCategory, readEnginesNode, targetNeedsExperimentalSqlite } from "./proofRunnability.js";
30
30
  /** Real dynamic proof is profile-gated; only wired runner targets are attemptable. */
31
31
  function isTsJsFile(file) {
32
32
  return /\.[cm]?[jt]sx?$/i.test(file);
@@ -372,7 +372,74 @@ const GEN_WINDOW = 5;
372
372
  // file (every eligible symbol × every importing test) cannot starve the shared budget before
373
373
  // a provable hard-edge symbol is tried. Small K; promote to a flag if a repo needs a wider sweep.
374
374
  const EXISTING_LANE_MAX_WEAK_PER_SYMBOL = 3;
375
+ const RUNNER_ROOT_MARKERS = {
376
+ typescript: ["package.json"],
377
+ javascript: ["package.json"],
378
+ python: ["pyproject.toml", "setup.cfg", "setup.py", "pytest.ini", ".pytest.ini", "tox.ini"],
379
+ go: ["go.mod"],
380
+ java: ["pom.xml", "build.gradle", "build.gradle.kts"],
381
+ rs: ["Cargo.toml"]
382
+ };
383
+ /**
384
+ * The nearest language runner root for a target, bounded by the analyzed source root.
385
+ * A nested root is accepted only when the selected existing test is inside it too;
386
+ * otherwise proof execution falls back to the analyzed root, matching the oracle's
387
+ * containment rule rather than inventing a cross-project plan.
388
+ */
389
+ export function proofRunnerRoot(sourceRoot, targetRel, testRel) {
390
+ const stop = resolve(sourceRoot);
391
+ const targetAbs = resolve(stop, targetRel);
392
+ if (targetAbs !== stop && !targetAbs.startsWith(stop + sep))
393
+ return ".";
394
+ const language = targetLanguage(`sym:${targetRel}#target`);
395
+ const markers = RUNNER_ROOT_MARKERS[language] ?? [];
396
+ let dir = dirname(targetAbs);
397
+ let found = stop;
398
+ for (;;) {
399
+ if (markers.some((marker) => existsSync(join(dir, marker)))) {
400
+ found = dir;
401
+ break;
402
+ }
403
+ if (dir === stop)
404
+ break;
405
+ const parent = dirname(dir);
406
+ if (parent === dir || (parent !== stop && !parent.startsWith(stop + sep)))
407
+ break;
408
+ dir = parent;
409
+ }
410
+ if (testRel && found !== stop) {
411
+ const testFile = testRel.split("::", 1)[0] ?? testRel;
412
+ const testAbs = resolve(stop, testFile);
413
+ if ((testAbs !== stop && !testAbs.startsWith(stop + sep))
414
+ || (testAbs !== found && !testAbs.startsWith(found + sep)))
415
+ found = stop;
416
+ }
417
+ const rel = relative(stop, found).split(sep).join("/");
418
+ return rel || ".";
419
+ }
420
+ /** Dominant-language order from all denominator-eligible code behaviors. */
421
+ export function proofLanguageOrder(graph) {
422
+ const counts = new Map();
423
+ const first = new Map();
424
+ for (const node of graph.nodes) {
425
+ if (node.kind !== "CodeSymbol" || node.denominator_eligible !== true)
426
+ continue;
427
+ const language = targetLanguage(node.external_id);
428
+ if (!first.has(language))
429
+ first.set(language, first.size);
430
+ counts.set(language, (counts.get(language) ?? 0) + 1);
431
+ }
432
+ return [...counts.keys()].sort((a, b) => (counts.get(b) - counts.get(a)) || (first.get(a) - first.get(b)));
433
+ }
375
434
  export const NO_KEY_MESSAGE = "No provider key; auto-prove skipped — add OPENAI_API_KEY / ANTHROPIC_API_KEY, or use the OrangePro MCP in your coding agent.";
435
+ /** Only failures proven to apply across a runner project may quarantine its siblings. */
436
+ const PROJECT_WIDE_BLOCKERS = new Set([
437
+ "engine_mismatch",
438
+ "tsconfig_missing",
439
+ "runner_missing",
440
+ "module_root_missing",
441
+ "go_package_build_failure"
442
+ ]);
376
443
  export function isRoastSurvivor(attempt) {
377
444
  return attempt.classification === "non_killing" && attempt.mutant_status === "associated_survived";
378
445
  }
@@ -434,7 +501,13 @@ function fileReaderFor(root) {
434
501
  function classifyProof(result, ctx) {
435
502
  if ("status" in result && result.status === "unrunnable") {
436
503
  // Setup did not run (env non-event) — nothing was minted, target needs setup.
437
- return { classification: "needs_setup", reason: result.reason };
504
+ const reason = result.reason;
505
+ const category = /runner binary not found|unsupported or unknown test runner/i.test(reason)
506
+ ? "runner_missing"
507
+ : /no go\.mod found|no pom\.xml|no maven or gradle|build\.gradle|no python project root/i.test(reason)
508
+ ? "module_root_missing"
509
+ : undefined;
510
+ return { classification: "needs_setup", reason, category };
438
511
  }
439
512
  const dyn = result;
440
513
  const record = dyn.record;
@@ -442,8 +515,16 @@ function classifyProof(result, ctx) {
442
515
  return { classification: "proven" };
443
516
  const cert = record.dynamic_proof;
444
517
  if (cert && cert.baseline_green === false) {
518
+ const failureSummary = dyn.oracle.baseline?.failureSummary;
519
+ if (ctx.targetFileRel.endsWith(".go") && /^#\s+\S+/m.test(failureSummary ?? "")) {
520
+ return {
521
+ classification: "needs_setup",
522
+ reason: "The Go package did not compile before the selected test could run.",
523
+ category: "go_package_build_failure"
524
+ };
525
+ }
445
526
  const { category, reason } = classifyBaselineFailure({
446
- failureSummary: dyn.oracle.baseline?.failureSummary,
527
+ failureSummary,
447
528
  enginesNode: readEnginesNode(ctx.sourceRoot, ctx.targetFileRel),
448
529
  runnerNode: ctx.runnerNode ?? process.version
449
530
  });
@@ -457,27 +538,30 @@ function mutantStatusOf(result) {
457
538
  return "unrunnable";
458
539
  return result.record.dynamic_proof?.mutant_status;
459
540
  }
460
- /**
461
- * R-1 sibling-dedup key: a baseline-red import-time failure is a deterministic property of
462
- * loading the TARGET FILE with a given runner, independent of which test runs it — so
463
- * same-file siblings share it. Keyed on the TARGET FILE only (every caller pins runner to
464
- * undefined): the sole deduped cause is engine_mismatch, a package-level fact both lanes
465
- * classify against the SAME process.version, so the runner must not be in the key. The old
466
- * {runner, target file} key split the cache across lanes (lane 1 wrote "auto <file>", the
467
- * generation lane read "<runner> <file>" -> never matched), silently disabling cross-lane
468
- * dedup. NEVER merges across different files or a different failure class.
469
- */
470
- function dedupKey(runner, targetFileRel) {
471
- return `${runner ?? "auto"}\u0000${targetFileRel}`;
541
+ function projectBlockKey(language, projectRoot) {
542
+ return `${language}\u0000${projectRoot}`;
472
543
  }
473
- /** A same-file sibling deduped WITHOUT re-running: shares the first attempt's redacted reason. */
474
- function dedupedAttempt(targetSymbol, testPath, targetFileRel, blocked) {
544
+ function projectBlockKeys(language, projectRoot, targetFileRel) {
545
+ return [
546
+ projectBlockKey(language, projectRoot),
547
+ `${projectBlockKey(language, projectRoot)}\u0000package:${dirname(targetFileRel).split(sep).join("/")}`
548
+ ];
549
+ }
550
+ function classifiedProjectBlockKey(category, language, projectRoot, targetFileRel) {
551
+ return category === "go_package_build_failure"
552
+ ? projectBlockKeys(language, projectRoot, targetFileRel)[1]
553
+ : projectBlockKey(language, projectRoot);
554
+ }
555
+ /** A same-project candidate skipped WITHOUT re-running after a classified project-wide failure. */
556
+ function dedupedAttempt(targetSymbol, testPath, projectRoot, blocked) {
475
557
  return {
476
558
  target_symbol: targetSymbol,
477
559
  test_path: testPath,
478
560
  classification: "needs_setup",
479
- reason: `${blocked.reason} (shared root cause with a sibling in ${targetFileRel}; not re-run).`,
561
+ reason: `${blocked.reason} (shared project/toolchain cause in ${blocked.scopeLabel}; not re-run).`,
480
562
  category: blocked.category,
563
+ project_root: projectRoot,
564
+ blocked_by: blocked.blockedBy,
481
565
  deduped: true
482
566
  };
483
567
  }
@@ -616,7 +700,7 @@ export function existingAssociatedTests(graph, nodeById) {
616
700
  * the budget. Order within each tier follows the Map's insertion order (graph node/edge order),
617
701
  * so the result is deterministic.
618
702
  */
619
- export function orderExistingAttempts(testsBySymbol, maxWeakPerSymbol = EXISTING_LANE_MAX_WEAK_PER_SYMBOL) {
703
+ export function orderExistingAttempts(testsBySymbol, maxWeakPerSymbol = EXISTING_LANE_MAX_WEAK_PER_SYMBOL, schedule) {
620
704
  const hard = [];
621
705
  const weak = [];
622
706
  for (const [symId, tests] of testsBySymbol) {
@@ -628,7 +712,60 @@ export function orderExistingAttempts(testsBySymbol, maxWeakPerSymbol = EXISTING
628
712
  weak.push({ symId, testRel: t.test, hard: false });
629
713
  }
630
714
  }
631
- return [...hard, ...weak];
715
+ if (!schedule)
716
+ return [...hard, ...weak];
717
+ const languageOrder = proofLanguageOrder(schedule.graph);
718
+ const languageRank = new Map(languageOrder.map((language, index) => [language, index]));
719
+ const decorate = (attempt, index) => {
720
+ const node = schedule.nodeById.get(attempt.symId);
721
+ const targetRel = node ? symbolFileOf(node) : attempt.symId.replace(/^sym:/, "").split("#")[0];
722
+ return {
723
+ ...attempt,
724
+ language: targetLanguage(attempt.symId),
725
+ projectRoot: proofRunnerRoot(schedule.sourceRoot, targetRel, attempt.testRel),
726
+ index
727
+ };
728
+ };
729
+ const stableProjectOrder = (attempts) => {
730
+ const decorated = attempts.map(decorate);
731
+ const firstProject = new Map();
732
+ for (const item of decorated) {
733
+ const key = `${item.language}\u0000${item.projectRoot}`;
734
+ if (!firstProject.has(key))
735
+ firstProject.set(key, firstProject.size);
736
+ }
737
+ return decorated
738
+ .sort((a, b) => ((languageRank.get(a.language) ?? Number.MAX_SAFE_INTEGER) - (languageRank.get(b.language) ?? Number.MAX_SAFE_INTEGER))
739
+ || ((firstProject.get(`${a.language}\u0000${a.projectRoot}`) ?? 0) - (firstProject.get(`${b.language}\u0000${b.projectRoot}`) ?? 0))
740
+ || (a.index - b.index))
741
+ .map(({ index: _index, ...attempt }) => attempt);
742
+ };
743
+ return [...stableProjectOrder(hard), ...stableProjectOrder(weak)];
744
+ }
745
+ /**
746
+ * Stable scheduling view over the existing ORS-ranked candidate list. This changes
747
+ * only which runner receives a scarce proof attempt first; it never changes scores,
748
+ * evidence tiers, or the persisted priority-gap order.
749
+ */
750
+ export function orderRankedProofCandidates(candidates, graph, sourceRoot) {
751
+ const languageOrder = proofLanguageOrder(graph);
752
+ if (languageOrder.length <= 1)
753
+ return candidates;
754
+ const languageRank = new Map(languageOrder.map((language, index) => [language, index]));
755
+ const firstProject = new Map();
756
+ const decorated = candidates.map((candidate, index) => {
757
+ const language = targetLanguage(candidate.id);
758
+ const projectRoot = proofRunnerRoot(sourceRoot, candidate.file);
759
+ const projectKey = `${language}\u0000${projectRoot}`;
760
+ if (!firstProject.has(projectKey))
761
+ firstProject.set(projectKey, firstProject.size);
762
+ return { candidate, index, language, projectKey };
763
+ });
764
+ return decorated
765
+ .sort((a, b) => ((languageRank.get(a.language) ?? Number.MAX_SAFE_INTEGER) - (languageRank.get(b.language) ?? Number.MAX_SAFE_INTEGER))
766
+ || ((firstProject.get(a.projectKey) ?? 0) - (firstProject.get(b.projectKey) ?? 0))
767
+ || (a.index - b.index))
768
+ .map(({ candidate }) => candidate);
632
769
  }
633
770
  /**
634
771
  * PR 1.5 lane — prove the repo's OWN existing tests, NO provider key. For each eligible
@@ -642,7 +779,7 @@ export function orderExistingAttempts(testsBySymbol, maxWeakPerSymbol = EXISTING
642
779
  * — the generation lane gets whatever this lane leaves unspent, so TOTAL attempts
643
780
  * (existing + generation) never exceed the budget.
644
781
  */
645
- function proveExistingAssociatedTests(root, graph, sourceRoot, nodeById, opts, proveLoop, proveDeps, alreadyProven, importTimeBlocked, budget) {
782
+ function proveExistingAssociatedTests(root, graph, sourceRoot, nodeById, opts, proveLoop, proveDeps, alreadyProven, projectBlocked, budget) {
646
783
  const attempts = [];
647
784
  const needsSetup = [];
648
785
  const provenSymbols = new Set();
@@ -651,8 +788,12 @@ function proveExistingAssociatedTests(root, graph, sourceRoot, nodeById, opts, p
651
788
  const changed = opts.changedFiles && opts.changedFiles.length > 0 ? new Set(opts.changedFiles) : null;
652
789
  const reader = fileReaderFor(sourceRoot); // R-2: source scan for the node:sqlite env profile
653
790
  // Fix 3: hard TESTED_BY/COVERS pairs first, weak MAY_* pairs after and capped per symbol.
654
- const queue = orderExistingAttempts(existingAssociatedTests(graph, nodeById));
655
- for (const { symId, testRel, hard, testName } of queue) {
791
+ const queue = orderExistingAttempts(existingAssociatedTests(graph, nodeById), EXISTING_LANE_MAX_WEAK_PER_SYMBOL, {
792
+ graph,
793
+ nodeById,
794
+ sourceRoot
795
+ });
796
+ for (const { symId, testRel, hard, testName, language, projectRoot } of queue) {
656
797
  if (attempted >= budget)
657
798
  break;
658
799
  const node = nodeById.get(symId);
@@ -671,10 +812,10 @@ function proveExistingAssociatedTests(root, graph, sourceRoot, nodeById, opts, p
671
812
  if (provenSymbols.has(symId))
672
813
  continue;
673
814
  const targetFileRel = symbolFileOf(node);
674
- // R-1 sibling dedup: a prior same-file attempt hit a package-level env root cause
675
- // (engine_mismatch: runner Node outside the declared engines range). Every sibling in this
676
- // file fails baseline identically → mark it
677
- // needs_setup WITHOUT re-running (and WITHOUT consuming the attempt budget).
815
+ const targetLanguageName = language ?? targetLanguage(symId);
816
+ const runnerRoot = projectRoot ?? proofRunnerRoot(sourceRoot, targetFileRel, testRel);
817
+ // A prior candidate in this exact runner project hit a classified project-wide
818
+ // toolchain/environment failure. Preserve the budget and explain the skip.
678
819
  const isPython = isPythonFile(targetFileRel);
679
820
  const candidateTestRels = isPython
680
821
  ? pytestNodeidsForTarget(sourceRoot, testRel, testName).filter((candidate) => isRunnableTestForTarget(node, candidate))
@@ -682,9 +823,10 @@ function proveExistingAssociatedTests(root, graph, sourceRoot, nodeById, opts, p
682
823
  if (candidateTestRels.length === 0)
683
824
  continue;
684
825
  const proofTestRel = candidateTestRels[0];
685
- const blocked = importTimeBlocked.get(dedupKey(undefined, targetFileRel));
826
+ const blockedKey = projectBlockKeys(targetLanguageName, runnerRoot, targetFileRel).find((key) => projectBlocked.has(key));
827
+ const blocked = blockedKey ? projectBlocked.get(blockedKey) : undefined;
686
828
  if (blocked) {
687
- const attempt = dedupedAttempt(symId, proofTestRel, targetFileRel, blocked);
829
+ const attempt = dedupedAttempt(symId, proofTestRel, runnerRoot, blocked);
688
830
  attempts.push(attempt);
689
831
  needsSetup.push(attempt);
690
832
  continue;
@@ -745,7 +887,8 @@ function proveExistingAssociatedTests(root, graph, sourceRoot, nodeById, opts, p
745
887
  target_symbol: symId,
746
888
  test_path: displayTest,
747
889
  classification: "needs_setup",
748
- reason: `Proof could not run: ${redactSecrets(errMsg(e))}`
890
+ reason: `Proof could not run: ${redactSecrets(errMsg(e))}`,
891
+ project_root: runnerRoot
749
892
  };
750
893
  attempts.push(attempt);
751
894
  needsSetup.push(attempt);
@@ -758,6 +901,7 @@ function proveExistingAssociatedTests(root, graph, sourceRoot, nodeById, opts, p
758
901
  classification,
759
902
  reason,
760
903
  category,
904
+ project_root: runnerRoot,
761
905
  mutant_status: mutantStatusOf(result)
762
906
  };
763
907
  attempts.push(attempt);
@@ -768,9 +912,18 @@ function proveExistingAssociatedTests(root, graph, sourceRoot, nodeById, opts, p
768
912
  }
769
913
  if (classification === "needs_setup") {
770
914
  needsSetup.push(attempt);
771
- // Cache an import-time root cause so same-file siblings dedup instead of re-running.
772
- if (category && IMPORT_TIME_CATEGORIES.has(category)) {
773
- importTimeBlocked.set(dedupKey(undefined, targetFileRel), { category, reason: reason ?? "" });
915
+ // Quarantine only a classified project-wide root cause. Target/test-specific
916
+ // red baselines and surviving mutants never suppress siblings.
917
+ if (category && PROJECT_WIDE_BLOCKERS.has(category)) {
918
+ const scopeLabel = category === "go_package_build_failure"
919
+ ? dirname(targetFileRel).split(sep).join("/") || "."
920
+ : runnerRoot;
921
+ projectBlocked.set(classifiedProjectBlockKey(category, targetLanguageName, runnerRoot, targetFileRel), {
922
+ category,
923
+ reason: reason ?? "",
924
+ blockedBy: symId,
925
+ scopeLabel
926
+ });
774
927
  }
775
928
  }
776
929
  // non_killing → keep trying this symbol's other associated tests, if any.
@@ -810,16 +963,17 @@ export async function autoProve(root, opts, deps) {
810
963
  .filter((r) => r.evidence_tier === "proven")
811
964
  .map((r) => r.code_symbol)
812
965
  .filter(Boolean));
813
- // R-1: shared sibling-dedup cache of import-time baseline failures. Spans BOTH lanes so a
814
- // node:sqlite-style root cause found once is never re-run across same-file siblings.
815
- const importTimeBlocked = new Map();
966
+ // Shared, run-local quarantine for classified project-wide toolchain failures.
967
+ // Keyed by language + bounded runner root; never populated by a test-specific red
968
+ // baseline, an unknown failure, or a surviving mutant.
969
+ const projectBlocked = new Map();
816
970
  // ONE unified dynamic-proof budget for the whole pass (existing-first → then generation).
817
971
  // Default 5 ("dynamically prove top 5"); `--auto-limit N` overrides it, clamped to
818
972
  // MAX_AUTO_LIMIT. The existing lane consumes from this budget and the generation lane gets
819
973
  // only the remainder, so TOTAL attempts (existing + generation) are ≤ budget.
820
974
  const autoLimit = Math.max(1, Math.min(MAX_AUTO_LIMIT, Math.floor(opts.autoLimit ?? DEFAULT_AUTO_LIMIT)));
821
975
  // ── Lane 1: existing associated tests — NO key required, runs FIRST (PR 1.5). ──
822
- const ex = proveExistingAssociatedTests(root, graph, sourceRoot, nodeById, opts, proveLoop, proveDeps, alreadyProven, importTimeBlocked, autoLimit);
976
+ const ex = proveExistingAssociatedTests(root, graph, sourceRoot, nodeById, opts, proveLoop, proveDeps, alreadyProven, projectBlocked, autoLimit);
823
977
  if (opts.existingOnly) {
824
978
  const status = ex.proven > 0 ? "proven-run" : ex.attempted > 0 ? "ran-no-proof" : "no-targets";
825
979
  return {
@@ -872,6 +1026,7 @@ export async function autoProve(root, opts, deps) {
872
1026
  const changed = new Set(opts.changedFiles);
873
1027
  candidates = candidates.filter((g) => changed.has(g.file));
874
1028
  }
1029
+ candidates = orderRankedProofCandidates(candidates, graph, sourceRoot);
875
1030
  const attempts = [];
876
1031
  const needsSetup = [];
877
1032
  const skipped = [];
@@ -917,14 +1072,24 @@ export async function autoProve(root, opts, deps) {
917
1072
  }
918
1073
  const { target_symbol, replacement, runner } = hint.prove_run.args;
919
1074
  const targetFileRel = symbolFile(target_symbol);
920
- // R-1 sibling dedup (cross-lane): a same-file target already hit an import-time env root
921
- // cause → a fresh generated test importing the same module fails identically. Skip it
922
- // WITHOUT generating/writing/running or consuming the attempt budget.
923
- const blocked = importTimeBlocked.get(dedupKey(undefined, targetFileRel));
1075
+ const language = targetLanguage(target_symbol);
1076
+ const projectRoot = proofRunnerRoot(sourceRoot, targetFileRel);
1077
+ // Cross-lane quarantine: a classified project-wide runner failure already
1078
+ // explains why this generated candidate cannot run. Skip without spending budget.
1079
+ const blockedKey = projectBlockKeys(language, projectRoot, targetFileRel).find((key) => projectBlocked.has(key));
1080
+ const blocked = blockedKey ? projectBlocked.get(blockedKey) : undefined;
924
1081
  if (blocked) {
925
- const attempt = dedupedAttempt(target_symbol, "", targetFileRel, blocked);
1082
+ const attempt = dedupedAttempt(target_symbol, "", projectRoot, blocked);
926
1083
  attempts.push(attempt);
927
1084
  needsSetup.push(attempt);
1085
+ skipped.push({
1086
+ target_symbol,
1087
+ title: test.title,
1088
+ reason: attempt.reason ?? "Skipped after a project-wide proof failure.",
1089
+ language,
1090
+ project_root: projectRoot,
1091
+ blocked_by: blocked.blockedBy
1092
+ });
928
1093
  continue;
929
1094
  }
930
1095
  const filename = basename(hint.prove_run.args.test_path);
@@ -984,7 +1149,8 @@ export async function autoProve(root, opts, deps) {
984
1149
  target_symbol,
985
1150
  test_path: writeRel,
986
1151
  classification: "needs_setup",
987
- reason: `Proof could not run: ${redactSecrets(errMsg(e))}`
1152
+ reason: `Proof could not run: ${redactSecrets(errMsg(e))}`,
1153
+ project_root: projectRoot
988
1154
  };
989
1155
  attempts.push(attempt);
990
1156
  needsSetup.push(attempt);
@@ -997,6 +1163,7 @@ export async function autoProve(root, opts, deps) {
997
1163
  classification,
998
1164
  reason,
999
1165
  category,
1166
+ project_root: projectRoot,
1000
1167
  mutant_status: mutantStatusOf(result)
1001
1168
  };
1002
1169
  attempts.push(attempt);
@@ -1004,9 +1171,16 @@ export async function autoProve(root, opts, deps) {
1004
1171
  proven++;
1005
1172
  else if (classification === "needs_setup") {
1006
1173
  needsSetup.push(attempt);
1007
- // Cache an import-time root cause so same-file siblings dedup instead of re-running.
1008
- if (category && IMPORT_TIME_CATEGORIES.has(category)) {
1009
- importTimeBlocked.set(dedupKey(undefined, targetFileRel), { category, reason: reason ?? "" });
1174
+ if (category && PROJECT_WIDE_BLOCKERS.has(category)) {
1175
+ const scopeLabel = category === "go_package_build_failure"
1176
+ ? dirname(targetFileRel).split(sep).join("/") || "."
1177
+ : projectRoot;
1178
+ projectBlocked.set(classifiedProjectBlockKey(category, language, projectRoot, targetFileRel), {
1179
+ category,
1180
+ reason: reason ?? "",
1181
+ blockedBy: target_symbol,
1182
+ scopeLabel
1183
+ });
1010
1184
  }
1011
1185
  }
1012
1186
  // non_killing stays in `attempts` only — an honest skip, never Proven.
@@ -23,7 +23,7 @@ import { workspacePaths } from "./workspace.js";
23
23
  import { redactSecrets } from "./util/redact.js";
24
24
  import { targetLanguage } from "./ledger.js";
25
25
  import { readEnginesNode, satisfiesNodeRange } from "./proofRunnability.js";
26
- export const PROOF_ATTEMPTS_SCHEMA_VERSION = "orangepro.proof_attempts.v1";
26
+ export const PROOF_ATTEMPTS_SCHEMA_VERSION = "orangepro.proof_attempts.v2";
27
27
  export const PROOF_ATTEMPTS_FILE = "proof-attempts.json";
28
28
  export const PROOF_DOCTOR_SCHEMA_VERSION = "orangepro.proof_doctor.v1";
29
29
  /** Languages with a shipped dynamic-proof profile. Everything else is honest "not yet". */
@@ -115,12 +115,17 @@ export function distillProofAttempts(auto, meta) {
115
115
  category: a.category,
116
116
  reason: a.reason ? redactSecrets(a.reason) : undefined,
117
117
  deduped: a.deduped,
118
- language: targetLanguage(a.target_symbol)
118
+ language: targetLanguage(a.target_symbol),
119
+ project_root: a.project_root,
120
+ blocked_by: a.blocked_by
119
121
  })),
120
122
  skipped: auto.skipped.map((s) => ({
121
123
  target_symbol: s.target_symbol,
122
124
  title: s.title,
123
- reason: redactSecrets(s.reason)
125
+ reason: redactSecrets(s.reason),
126
+ language: s.language,
127
+ project_root: s.project_root,
128
+ blocked_by: s.blocked_by
124
129
  }))
125
130
  };
126
131
  }
@@ -3,7 +3,7 @@ import { ORS_VERSION } from "./score/risk.js";
3
3
  import { ORANGEPRO_VERSION } from "./version.js";
4
4
  import { hashString } from "./util/hash.js";
5
5
  export const ARTIFACT_IDENTITY_VERSION = "orangepro.artifact_identity.v1";
6
- export const ANALYZER_VERSION = "orangepro.analyzer.v2";
6
+ export const ANALYZER_VERSION = "orangepro.analyzer.v3";
7
7
  export const PROOF_ORACLE_VERSION = "orangepro.targeted_mutation_oracle.v1";
8
8
  function stable(value) {
9
9
  if (value === null || typeof value !== "object")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orangepro/orangepro-mcp",
3
- "version": "0.2.43",
3
+ "version": "0.2.44",
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",