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
package/runtime/BUILD.json
CHANGED
|
@@ -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), `
|
|
65
|
-
(PIT, whose diff scoping is not wired up
|
|
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(
|
|
103
|
-
line =
|
|
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
|