okstra 0.153.0 → 0.154.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.
@@ -243,7 +243,7 @@ Important modules:
243
243
  | `build_tools.py` | allowlist SSOT for deciding whether a plan's command cell invokes the project build toolchain (`npm`/`pytest`/`cargo`/`gradle`/… behind transparent leaders like `sudo`/`env`). The planning worktree has no dependencies installed, so `validators/validate-run.py` uses this to warn (advisory) when a toolchain stage declares no install precondition. Intentionally an allowlist, not a denylist, so unknown tokens go undetected rather than firing on `grep`/`sed` in every plan |
244
244
  | `stage_citations.py` | shared grammar SSOT for reading the Stage Map stage numbers a prose cell cites (`Stages 1, 2, and 3`, ranges, etc.). One definition serves two readers that must not drift — the coverage check in `validators/validate-run.py` proving every stage traces to a requirement, and `incremental_scope.py`'s back-trace resolving which stages an answered clarification touches |
245
245
  | `self_mock_signals.py` | self-mock signal SSOT — language-keyed regexes (`SIGNALS`), the `EXT_TO_LANG` extension map, and the waiver-matching mechanics both gates share — `selfmock_path_key` (the one path-normalization), `waiver_entry_key` (the `(file, line, <discriminator>)` triple, with the hand-typed line coerced to `int`) and `partition_waived_entries` (the split into still-failing vs waived). Gate A passes the discriminator `signal`, gate B `mutant`; one definition means the two cannot disagree about whether a waiver matches a finding. The signals are each ported from a `prompts/coding-preflight/languages/<lang>.md` "Self-mock signals to refuse" bullet with the source `doc_keyword` retained so a drift guard fails when doc and module diverge. Patterns stay deliberately narrow (only the "stub the subject's own method, then assert the stub" shape and reaching into the subject's privates; subject identity is never inferred beyond the literal `sut` token). The static detector `validators/detect_self_mock.py`, the drift guard and `mutation_probe.py` MUST import from here; four documented shapes needing subject identity no regex has are left to the mutation gate (`mutation_probe.py`) |
246
- | `mutation_probe.py` | gate B of the self-mock gate — the tool-agnostic mutation probe. `ADAPTERS` maps an `EXT_TO_LANG` language key to an adapter (`ts_js` → Stryker, `rust` → cargo-mutants, `java`/`kotlin` → PIT, which reports `unsupported` because its SCM scoping is a Maven-only goal and the report↔path mapping is unverified). `run_probe` owns everything that must not differ between tools: production-source selection, the refusal to run on an empty target set, the requirement that the diff name EVERY changed source, the adapter result-shape check and the user-acknowledged waiver application; adapters only parse. `evaluate` counts a mutant only when it covers a line the diff added or modified, and records the pre-cap `survivedTotal` so a trimmed report cannot be fully waived to PASS. Anything that stops a real inspection — no adapter, tool not installed, unreadable report, unknown outcome word, no conclusive trial, a diff that misses a changed source — answers `unsupported(<reason>)`, never `PASS`. `classify_reason` is the 3-class SSOT (capability-gap / nothing-to-verify / integrity-inspection, unknown → integrity) read by BOTH the cross-language merge here and the blocking decision in `validators/validate-run.py` |
246
+ | `mutation_probe.py` | gate B of the self-mock gate — the tool-agnostic mutation probe. `ADAPTERS` maps an `EXT_TO_LANG` language key to an adapter (`ts_js` → Stryker, `python` → Cosmic Ray, `rust` → cargo-mutants, `java`/`kotlin` → PIT, which reports `unsupported` because its SCM scoping is a Maven-only goal and the report↔path mapping is unverified). The Python adapter activates only when the worktree has both an installed `cosmic-ray` executable and `cosmic-ray.toml`; Okstra never installs it. It verifies that `module-path` covers every changed Python target and that no target is excluded, snapshots the configured Python source bytes and modes, restores them after every run, and filters the JSONL dump to diff-added lines. A completed session with no applicable operator on a changed line is `nothing-to-verify`; malformed configuration, command/report failures, incomplete trials, and failed source restoration are blocking `integrity-inspection` results. `run_probe` owns everything that must not differ between tools: production-source selection, the refusal to run on an empty target set, the requirement that the diff name EVERY changed source, the adapter result-shape check and the user-acknowledged waiver application; adapters only parse. `evaluate` counts a mutant only when it covers a line the diff added or modified, and records the pre-cap `survivedTotal` so a trimmed report cannot be fully waived to PASS. Anything that stops a real inspection — no adapter, tool not installed, unreadable report, unknown outcome word, no conclusive trial, a diff that misses a changed source — answers `unsupported(<reason>)`, never `PASS`. `classify_reason` is the 3-class SSOT (capability-gap / nothing-to-verify / integrity-inspection, unknown → integrity) read by BOTH the cross-language merge here and the blocking decision in `validators/validate-run.py` |
247
247
  | `run_context.py` | Per-task mutex, run context and run-input persistence; `consumers_mutex` helper for atomic `consumers.jsonl` writes |
248
248
  | `path_hints.py` | Compact path-hint persistence + legacy context hydration — stores `run-context` / `active-run-context` in the schemaVersion `2.0` `identity` + `pathHints` compact schema, and hydrates the legacy flat path keys (`RUN_MANIFEST_RELATIVE_PATH`, `TEAM_STATE_PATH`, etc.) in memory the moment the host-side reader reads them |
249
249
  | `consumers.py` | Append-only `consumers.jsonl` writer + reader — records which `implementation` runs consumed which `implementation-planning` stage |
@@ -386,7 +386,7 @@ Optional (v1.0 backward-compatible) top-level keys:
386
386
  | `validate-schedule.py` | Schedule section/order/code validation |
387
387
  | `validate-implementation-plan-stages.py` | enforces the Stage Map structure — checks the S1–S8 rules (`## 5.5 Stage Map` + `## 5.5.<i> Stage <i>` sections, ≤ 8 steps per stage, etc.) |
388
388
  | `validate_improvement_report.py` | enforces the 11-item contract of the improvement-discovery final-report. Automatically invoked by `validate-run.py` when `task_type == "improvement-discovery"` |
389
- | `detect_self_mock.py` | self-mock detector — runs BOTH gates and writes the run's sidecar. Gate A (static) scans the changed TEST files for SUT-stub signals (patterns imported from the SSOT `scripts/okstra_ctl/self_mock_signals.py`, never redefined here), matching each file as one whole-file string so multi-line signals are caught. Writes a `qa/self-mock[-stage-<N>].json` sidecar and prints `QA-RESULT: PASS|FAIL` as its last line (exit 0 = no hits, exit 1 = at least one hit). The sidecar records `scannedFiles`/`skippedFiles` so the gate can prove every changed test file was actually scanned (a run that skips them cannot pass on empty input). An optional `--waivers <path>` moves hits matching `(file,line,signal)` from `staticDetect.hits` to `staticDetect.waived` (each carrying the user's `reason`/`acknowledgedBy`) and records the file as `waiverSource`. Gate B (mutation) runs in the same call: `--changed-file` takes the stage's WHOLE changed set (each adapter selects its own production sources out of it), `--diff` and `--worktree` scope it, and `scripts/okstra_ctl/mutation_probe.py` writes the result into the sidecar's `mutation` block; the received set is recorded as `changedFiles` so the gate can prove gate B was not handed an empty input. `overall` and the exit code follow BOTH gates — a mutation FAIL with a clean static scan still exits 1. The same `--waivers` file feeds both (gate A reads its `signal` entries, gate B its `mutant` ones). Its verdict feeds the fail-closed `_validate_selfmock` gate in `validate-run.py` (implementation / final-verification): a diff that touches test files with no readable PASS sidecar blocks the run; a `waived` entry missing `reason`/`acknowledgedBy`, or a `waiverSource` that is not the task's own `qa/self-mock-waivers.json`, also blocks |
389
+ | `detect_self_mock.py` | self-mock detector — runs BOTH gates and writes the run's sidecar. Gate A (static) scans the changed TEST files for SUT-stub signals (patterns imported from the SSOT `scripts/okstra_ctl/self_mock_signals.py`, never redefined here), matching each file as one whole-file string so multi-line signals are caught. Python strings and comments are token-masked without changing line positions before those regexes run, so examples in docstrings and comments do not become findings while executable `patch.object(self, ...)` and `sut._private` accesses remain detectable. Writes a `qa/self-mock[-stage-<N>].json` sidecar and prints `QA-RESULT: PASS|FAIL` as its last line (exit 0 = no hits, exit 1 = at least one hit). The sidecar records `scannedFiles`/`skippedFiles` so the gate can prove every changed test file was actually scanned (a run that skips them cannot pass on empty input). An optional `--waivers <path>` moves hits matching `(file,line,signal)` from `staticDetect.hits` to `staticDetect.waived` (each carrying the user's `reason`/`acknowledgedBy`) and records the file as `waiverSource`. Gate B (mutation) runs in the same call: `--changed-file` takes the stage's WHOLE changed set (each adapter selects its own production sources out of it), `--diff` and `--worktree` scope it, and `scripts/okstra_ctl/mutation_probe.py` writes the result into the sidecar's `mutation` block; the received set is recorded as `changedFiles` so the gate can prove gate B was not handed an empty input. `overall` and the exit code follow BOTH gates — a mutation FAIL with a clean static scan still exits 1. The same `--waivers` file feeds both (gate A reads its `signal` entries, gate B its `mutant` ones). Its verdict feeds the fail-closed `_validate_selfmock` gate in `validate-run.py` (implementation / final-verification): a diff that touches test files with no readable PASS sidecar blocks the run; a `waived` entry missing `reason`/`acknowledgedBy`, or a `waiverSource` that is not the task's own `qa/self-mock-waivers.json`, also blocks |
390
390
  | `validate-workflow.sh` | End-to-end fixture workflow validation |
391
391
  | `lib/*.sh` | Shared shell validator helpers and fixtures |
392
392
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "okstra",
3
- "version": "0.153.0",
3
+ "version": "0.154.0",
4
4
  "description": "Host-aware multi-provider cross-verification orchestrator runtime and agent skills.",
5
5
  "license": "MIT",
6
6
  "author": "devonshin",
@@ -1,5 +1,5 @@
1
1
  {
2
- "package": "0.153.0",
3
- "builtAt": "2026-08-05T13:40:55.050Z",
2
+ "package": "0.154.0",
3
+ "builtAt": "2026-08-05T16:08:35.394Z",
4
4
  "repoRoot": "/home/runner/work/okstra/okstra"
5
5
  }
@@ -61,16 +61,22 @@ branches and would pass by falling between them.
61
61
 
62
62
  `ADAPTERS` is keyed by the `self_mock_signals.EXT_TO_LANG` vocabulary — the same
63
63
  lang names gate A resolves a changed file to — so both gates answer to one set
64
- of language keys: `ts_js` (Stryker), `rust` (cargo-mutants), and `java`/`kotlin`
65
- (PIT, whose diff scoping is not wired up yet — see `PitAdapter`).
64
+ of language keys: `ts_js` (Stryker), `python` (Cosmic Ray), `rust`
65
+ (cargo-mutants), and `java`/`kotlin` (PIT, whose diff scoping is not wired up
66
+ yet — see `PitAdapter`).
66
67
  """
67
68
  from __future__ import annotations
68
69
 
70
+ import fnmatch
69
71
  import json
72
+ import os
70
73
  import re
71
74
  import shutil
75
+ import stat
72
76
  import subprocess
73
77
  import sys
78
+ import tempfile
79
+ import tomllib
74
80
  from pathlib import Path
75
81
  from typing import NamedTuple, Protocol
76
82
 
@@ -413,6 +419,414 @@ class StrykerAdapter:
413
419
  return None
414
420
 
415
421
 
422
+ COSMIC_RAY_CONFIG_PATH = Path("cosmic-ray.toml")
423
+
424
+ _COSMIC_WORKER_OUTCOMES = (
425
+ "normal",
426
+ "abnormal",
427
+ "exception",
428
+ "no-test",
429
+ "skipped",
430
+ )
431
+ _COSMIC_TEST_OUTCOMES = ("killed", "survived", "incompetent", None)
432
+ _COSMIC_CONCLUSIVE = ("killed", "survived")
433
+ _COSMIC_SESSION_FAILURES = (
434
+ "init-failed",
435
+ "baseline-failed",
436
+ "exec-failed",
437
+ "dump-failed",
438
+ )
439
+
440
+
441
+ class SourceState(NamedTuple):
442
+ """Bytes and permissions Cosmic Ray must leave unchanged."""
443
+
444
+ data: bytes
445
+ mode: int
446
+
447
+
448
+ class CosmicRayScope(NamedTuple):
449
+ """Validated configuration and every Python source it may mutate."""
450
+
451
+ config_path: Path
452
+ sources: tuple[Path, ...]
453
+
454
+
455
+ class CosmicRayConfig(NamedTuple):
456
+ """The source roots and exclusions declared by ``cosmic-ray.toml``."""
457
+
458
+ module_paths: tuple[Path, ...]
459
+ excluded_patterns: tuple[str, ...]
460
+
461
+
462
+ class CosmicRaySession(NamedTuple):
463
+ """The external CLI's dump, or the command that prevented one."""
464
+
465
+ dump_text: str | None
466
+ failure_reason: str | None
467
+
468
+
469
+ class CosmicRayScopeError(ValueError):
470
+ """A stable unsupported reason for an unsafe or incomplete config scope."""
471
+
472
+ def __init__(self, reason: str):
473
+ super().__init__(reason)
474
+ self.reason = reason
475
+
476
+
477
+ def _cosmic_ray_executable(worktree: Path | None) -> Path | None:
478
+ """Find an installed Cosmic Ray executable without starting a process."""
479
+ if worktree is None:
480
+ return None
481
+ root = Path(worktree)
482
+ local_paths = (
483
+ Path(".venv/bin/cosmic-ray"),
484
+ Path("venv/bin/cosmic-ray"),
485
+ Path(".venv/Scripts/cosmic-ray.exe"),
486
+ Path("venv/Scripts/cosmic-ray.exe"),
487
+ )
488
+ for relative in local_paths:
489
+ candidate = root / relative
490
+ if candidate.is_file() and os.access(candidate, os.X_OK):
491
+ return candidate
492
+ found = shutil.which("cosmic-ray")
493
+ return Path(found) if found else None
494
+
495
+
496
+ def _is_within(path: Path, parent: Path) -> bool:
497
+ try:
498
+ path.relative_to(parent)
499
+ return True
500
+ except ValueError:
501
+ return False
502
+
503
+
504
+ def _cosmic_config(config_path: Path, root: Path) -> CosmicRayConfig:
505
+ try:
506
+ data = tomllib.loads(config_path.read_text(encoding="utf-8"))
507
+ except (OSError, tomllib.TOMLDecodeError) as exc:
508
+ raise CosmicRayScopeError("config-unreadable") from exc
509
+ config = data.get("cosmic-ray")
510
+ raw = config.get("module-path") if isinstance(config, dict) else None
511
+ values = [raw] if isinstance(raw, str) else raw
512
+ if not isinstance(values, list) or not values:
513
+ raise CosmicRayScopeError("config-unreadable")
514
+ if any(not isinstance(value, str) or not value.strip() for value in values):
515
+ raise CosmicRayScopeError("config-unreadable")
516
+ paths = tuple(
517
+ (root / value).resolve()
518
+ if not Path(value).is_absolute()
519
+ else Path(value).resolve()
520
+ for value in values
521
+ )
522
+ if any(not _is_within(path, root) for path in paths):
523
+ raise CosmicRayScopeError("config-unreadable")
524
+ exclusions: list[str] = []
525
+ for key in ("excluded-modules", "exclude-modules"):
526
+ raw_exclusions = config.get(key, [])
527
+ if not isinstance(raw_exclusions, list) or any(
528
+ not isinstance(pattern, str) or not pattern.strip()
529
+ for pattern in raw_exclusions
530
+ ):
531
+ raise CosmicRayScopeError("config-unreadable")
532
+ exclusions.extend(raw_exclusions)
533
+ return CosmicRayConfig(paths, tuple(exclusions))
534
+
535
+
536
+ def _cosmic_target_is_excluded(
537
+ target: Path,
538
+ root: Path,
539
+ patterns: tuple[str, ...],
540
+ ) -> bool:
541
+ relative = target.relative_to(root).as_posix()
542
+ return any(
543
+ fnmatch.fnmatchcase(relative, pattern) or Path(relative).match(pattern)
544
+ for pattern in patterns
545
+ )
546
+
547
+
548
+ def _configured_cosmic_sources(
549
+ module_paths: tuple[Path, ...],
550
+ root: Path,
551
+ ) -> tuple[Path, ...]:
552
+ sources: set[Path] = set()
553
+ for module_path in module_paths:
554
+ if module_path.is_file() and module_path.suffix == ".py":
555
+ candidates = (module_path,)
556
+ elif module_path.is_dir():
557
+ candidates = tuple(module_path.rglob("*.py"))
558
+ else:
559
+ raise CosmicRayScopeError("config-unreadable")
560
+ for candidate in candidates:
561
+ if candidate.is_symlink():
562
+ raise CosmicRayScopeError("config-unreadable")
563
+ resolved = candidate.resolve()
564
+ if not _is_within(resolved, root) or not resolved.is_file():
565
+ raise CosmicRayScopeError("config-unreadable")
566
+ sources.add(resolved)
567
+ return tuple(sorted(sources))
568
+
569
+
570
+ def _read_cosmic_ray_scope(worktree: Path, targets: list[Path]) -> CosmicRayScope:
571
+ root = Path(worktree).resolve()
572
+ config_path = root / COSMIC_RAY_CONFIG_PATH
573
+ config = _cosmic_config(config_path, root)
574
+ resolved_targets = tuple(
575
+ (root / target).resolve() if not target.is_absolute() else target.resolve()
576
+ for target in targets
577
+ )
578
+ if any(
579
+ not _is_within(target, root)
580
+ or _cosmic_target_is_excluded(target, root, config.excluded_patterns)
581
+ for target in resolved_targets
582
+ ):
583
+ raise CosmicRayScopeError("config-target-mismatch")
584
+ if any(
585
+ not any(
586
+ target == module_path
587
+ or (module_path.is_dir() and _is_within(target, module_path))
588
+ for module_path in config.module_paths
589
+ )
590
+ for target in resolved_targets
591
+ ):
592
+ raise CosmicRayScopeError("config-target-mismatch")
593
+ sources = _configured_cosmic_sources(config.module_paths, root)
594
+ if any(target not in sources for target in resolved_targets):
595
+ raise CosmicRayScopeError("config-target-mismatch")
596
+ return CosmicRayScope(config_path, sources)
597
+
598
+
599
+ def _snapshot_source_state(sources: tuple[Path, ...]) -> dict[Path, SourceState]:
600
+ return {
601
+ path: SourceState(
602
+ path.read_bytes(),
603
+ stat.S_IMODE(path.stat(follow_symlinks=False).st_mode),
604
+ )
605
+ for path in sources
606
+ }
607
+
608
+
609
+ def _restore_source_state(states: dict[Path, SourceState]) -> bool:
610
+ restored = True
611
+ for path, expected in states.items():
612
+ try:
613
+ if path.is_symlink() or (path.exists() and not path.is_file()):
614
+ restored = False
615
+ continue
616
+ if not path.exists() or path.read_bytes() != expected.data:
617
+ path.write_bytes(expected.data)
618
+ path.chmod(expected.mode)
619
+ actual = SourceState(
620
+ path.read_bytes(),
621
+ stat.S_IMODE(path.stat(follow_symlinks=False).st_mode),
622
+ )
623
+ restored = restored and actual == expected
624
+ except OSError:
625
+ restored = False
626
+ return restored
627
+
628
+
629
+ def _run_cosmic_ray_session(
630
+ executable: Path,
631
+ config_path: Path,
632
+ session_path: Path,
633
+ worktree: Path,
634
+ ) -> CosmicRaySession:
635
+ root = Path(worktree)
636
+ try:
637
+ config_arg = str(config_path.relative_to(root))
638
+ except ValueError:
639
+ config_arg = str(config_path)
640
+ commands = (
641
+ ("init", [str(executable), "init", config_arg, str(session_path)]),
642
+ (
643
+ "baseline",
644
+ [str(executable), "baseline", "--report", config_arg, str(session_path)],
645
+ ),
646
+ ("exec", [str(executable), "exec", config_arg, str(session_path)]),
647
+ ("dump", [str(executable), "dump", str(session_path)]),
648
+ )
649
+ for verb, argv in commands:
650
+ try:
651
+ completed = subprocess.run(
652
+ argv,
653
+ cwd=str(root),
654
+ capture_output=True,
655
+ text=True,
656
+ check=False,
657
+ )
658
+ except OSError:
659
+ return CosmicRaySession(None, f"{verb}-failed")
660
+ if completed.returncode != 0:
661
+ return CosmicRaySession(None, f"{verb}-failed")
662
+ if verb == "dump":
663
+ return CosmicRaySession(completed.stdout, None)
664
+ return CosmicRaySession(None, "dump-failed")
665
+
666
+
667
+ def _cosmic_diff_file(diff: object, worktree: Path) -> str | None:
668
+ if not isinstance(diff, list) or any(not isinstance(line, str) for line in diff):
669
+ return None
670
+ new_paths = [line[4:].split("\t", 1)[0] for line in diff if line.startswith("+++ ")]
671
+ if len(new_paths) != 1 or new_paths[0] == "/dev/null":
672
+ return None
673
+ raw = new_paths[0]
674
+ if raw.startswith("b/"):
675
+ raw = raw[2:]
676
+ candidate = Path(raw)
677
+ root = Path(worktree).resolve()
678
+ if candidate.is_absolute():
679
+ resolved = candidate.resolve()
680
+ else:
681
+ if ".." in candidate.parts:
682
+ return None
683
+ absolute_style = (Path("/") / candidate).resolve()
684
+ resolved = (
685
+ absolute_style if _is_within(absolute_style, root) else root / candidate
686
+ )
687
+ if not _is_within(resolved, root):
688
+ return None
689
+ return str(resolved.relative_to(root))
690
+
691
+
692
+ def _parse_cosmic_ray_dump(
693
+ dump_text: str,
694
+ diff_path: Path | None,
695
+ worktree: Path,
696
+ ) -> ParsedReport | None:
697
+ scope = read_diff(diff_path)
698
+ if scope is None:
699
+ return None
700
+ survivors: list[dict] = []
701
+ observed = 0
702
+ conclusive = 0
703
+ for raw in dump_text.splitlines():
704
+ if not raw.strip():
705
+ continue
706
+ try:
707
+ row = json.loads(raw)
708
+ except json.JSONDecodeError:
709
+ return None
710
+ if not isinstance(row, dict):
711
+ return None
712
+ worker = row.get("worker_outcome")
713
+ outcome = row.get("test_outcome")
714
+ file = _cosmic_diff_file(row.get("diff"), worktree)
715
+ line = _coerce_line(row.get("line_number"))
716
+ operator = row.get("operator")
717
+ occurrence = row.get("occurrence")
718
+ if (
719
+ worker not in _COSMIC_WORKER_OUTCOMES
720
+ or outcome not in _COSMIC_TEST_OUTCOMES
721
+ ):
722
+ return None
723
+ if worker == "normal" and outcome is None:
724
+ return None
725
+ if file is None or line is None or line < 1:
726
+ return None
727
+ if not isinstance(operator, str) or not operator.strip():
728
+ return None
729
+ if not isinstance(occurrence, int) or isinstance(occurrence, bool):
730
+ return None
731
+ if line not in scope.touched.get(selfmock_path_key(file), ()):
732
+ continue
733
+ observed += 1
734
+ if worker == "normal" and outcome in _COSMIC_CONCLUSIVE:
735
+ conclusive += 1
736
+ if worker == "normal" and outcome == "survived":
737
+ survivors.append(
738
+ {
739
+ "file": file,
740
+ "line": line,
741
+ "mutant": f"{operator}#{occurrence}",
742
+ "status": outcome,
743
+ }
744
+ )
745
+ return ParsedReport(survivors, conclusive, observed)
746
+
747
+
748
+ def _cosmic_session_verdict(
749
+ session: CosmicRaySession,
750
+ diff_path: Path | None,
751
+ worktree: Path,
752
+ tool: str,
753
+ ) -> ProbeResult:
754
+ if not isinstance(session, CosmicRaySession):
755
+ return unsupported("report-unparsed", tool=tool)
756
+ if session.failure_reason is not None:
757
+ if session.failure_reason not in _COSMIC_SESSION_FAILURES:
758
+ return unsupported("report-unparsed", tool=tool)
759
+ return unsupported(session.failure_reason, tool=tool)
760
+ if not isinstance(session.dump_text, str):
761
+ return unsupported("report-unparsed", tool=tool)
762
+ parsed = _parse_cosmic_ray_dump(session.dump_text, diff_path, worktree)
763
+ if parsed is None:
764
+ return unsupported("report-unparsed", tool=tool)
765
+ if parsed.observed == 0:
766
+ return unsupported("no-mutable-changed-lines", tool=tool)
767
+ return _verdict_from_parsed(parsed, diff_path, worktree, tool)
768
+
769
+
770
+ class CosmicRayAdapter:
771
+ """Gate B for Python, wrapping an explicitly configured Cosmic Ray CLI."""
772
+
773
+ name = "cosmic-ray"
774
+
775
+ def __init__(self, runner=_run_cosmic_ray_session):
776
+ self._runner = runner
777
+
778
+ def is_declared(self, worktree: Path | None) -> bool:
779
+ if worktree is None:
780
+ return False
781
+ root = Path(worktree)
782
+ return (root / COSMIC_RAY_CONFIG_PATH).is_file() and (
783
+ _cosmic_ray_executable(root) is not None
784
+ )
785
+
786
+ def run(
787
+ self,
788
+ targets: list[Path],
789
+ diff_path: Path | None,
790
+ worktree: Path | None,
791
+ ) -> ProbeResult:
792
+ if worktree is None:
793
+ return unsupported("config-unreadable", tool=self.name)
794
+ root = Path(worktree)
795
+ executable = _cosmic_ray_executable(root)
796
+ if executable is None:
797
+ return unsupported("tool-not-declared", tool=self.name)
798
+ try:
799
+ scope = _read_cosmic_ray_scope(root, targets)
800
+ states = _snapshot_source_state(scope.sources)
801
+ except CosmicRayScopeError as exc:
802
+ return unsupported(exc.reason, tool=self.name)
803
+ except OSError:
804
+ return unsupported("source-integrity-failed", tool=self.name)
805
+ result: ProbeResult
806
+ try:
807
+ with tempfile.TemporaryDirectory(prefix="okstra-cosmic-ray-") as temp_dir:
808
+ session_path = Path(temp_dir) / "session.sqlite"
809
+ session = self._runner(
810
+ executable,
811
+ scope.config_path,
812
+ session_path,
813
+ root,
814
+ )
815
+ result = _cosmic_session_verdict(
816
+ session,
817
+ diff_path,
818
+ root,
819
+ self.name,
820
+ )
821
+ except OSError:
822
+ result = unsupported("exec-failed", tool=self.name)
823
+ finally:
824
+ sources_restored = _restore_source_state(states)
825
+ if not sources_restored:
826
+ return unsupported("source-integrity-failed", tool=self.name)
827
+ return result
828
+
829
+
416
830
  CARGO_OUTCOMES_PATH = Path("mutants.out/outcomes.json")
417
831
 
418
832
  # cargo-mutants' own vocabulary: `caught` (a test failed, good), `missed` (no
@@ -665,6 +1079,7 @@ class PitAdapter:
665
1079
  _PIT = PitAdapter()
666
1080
  ADAPTERS: dict[str, Adapter] = {
667
1081
  "ts_js": StrykerAdapter(),
1082
+ "python": CosmicRayAdapter(),
668
1083
  "rust": CargoMutantsAdapter(),
669
1084
  "java": _PIT,
670
1085
  "kotlin": _PIT,
@@ -1107,6 +1522,7 @@ CAPABILITY_GAP_REASONS = frozenset(
1107
1522
  NOTHING_TO_VERIFY_REASONS = frozenset(
1108
1523
  {
1109
1524
  "no-mutants-generated",
1525
+ "no-mutable-changed-lines",
1110
1526
  "diff-adds-no-line",
1111
1527
  }
1112
1528
  )
@@ -1127,6 +1543,13 @@ INTEGRITY_INSPECTION_REASONS = frozenset(
1127
1543
  "report-unparsed",
1128
1544
  "adapter-malformed-status",
1129
1545
  "no-conclusive-mutants",
1546
+ "config-unreadable",
1547
+ "config-target-mismatch",
1548
+ "init-failed",
1549
+ "baseline-failed",
1550
+ "exec-failed",
1551
+ "dump-failed",
1552
+ "source-integrity-failed",
1130
1553
  }
1131
1554
  )
1132
1555
 
@@ -41,8 +41,10 @@ redirecting ``--waivers`` at a self-authored file blocks instead of passing.
41
41
  from __future__ import annotations
42
42
 
43
43
  import argparse
44
+ import io
44
45
  import json
45
46
  import sys
47
+ import tokenize
46
48
  from datetime import datetime, timezone
47
49
  from pathlib import Path
48
50
 
@@ -91,16 +93,39 @@ def scannable_files(paths: list[Path]) -> list[Path]:
91
93
  return [p for p in paths if is_scannable(p)]
92
94
 
93
95
 
96
+ def _mask_python_non_code(text: str) -> str:
97
+ """Blank Python strings and comments without moving any match positions."""
98
+ chars = list(text)
99
+ line_offsets = [0]
100
+ for line in text.splitlines(keepends=True):
101
+ line_offsets.append(line_offsets[-1] + len(line))
102
+ try:
103
+ tokens = tokenize.generate_tokens(io.StringIO(text).readline)
104
+ for token in tokens:
105
+ if token.type not in {tokenize.STRING, tokenize.COMMENT}:
106
+ continue
107
+ start = line_offsets[token.start[0] - 1] + token.start[1]
108
+ end = line_offsets[token.end[0] - 1] + token.end[1]
109
+ for index in range(start, end):
110
+ if chars[index] not in "\r\n":
111
+ chars[index] = " "
112
+ except (tokenize.TokenError, IndentationError, SyntaxError):
113
+ # Broken source is scanned conservatively so a syntax error cannot hide a hit.
114
+ return text
115
+ return "".join(chars)
116
+
117
+
94
118
  def scan_files(paths: list[Path]) -> list[dict]:
95
119
  """Return one hit dict ``{file, line, signal}`` per signal match."""
96
120
  hits: list[dict] = []
97
121
  for p in scannable_files(paths):
98
122
  lang = EXT_TO_LANG[p.suffix]
99
123
  text = p.read_text(encoding="utf-8", errors="replace")
124
+ scan_text = _mask_python_non_code(text) if lang == "python" else text
100
125
  file_hits: list[dict] = []
101
126
  for sig in SIGNALS.get(lang, []):
102
- for m in sig.pattern.finditer(text):
103
- line = text[: m.start()].count("\n") + 1
127
+ for m in sig.pattern.finditer(scan_text):
128
+ line = scan_text[: m.start()].count("\n") + 1
104
129
  file_hits.append({"file": str(p), "line": line, "signal": sig.name})
105
130
  hits.extend(sorted(file_hits, key=lambda h: (h["line"], h["signal"])))
106
131
  return hits