okstra 0.152.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.
- package/README.md +1 -1
- package/bin/okstra +7 -0
- package/docs/cli.md +5 -1
- package/docs/for-ai/skills/okstra-schedule-gen.md +152 -232
- package/docs/project-structure-overview.md +2 -2
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/bin/okstra-antigravity-exec.sh +11 -6
- package/runtime/bin/okstra-wrapper-agy-stream.py +61 -0
- package/runtime/prompts/lead/convergence.md +3 -2
- package/runtime/prompts/lead/plan-body-verification.md +30 -1
- package/runtime/python/okstra_ctl/container.py +9 -10
- package/runtime/python/okstra_ctl/convergence_engine.py +2 -1
- package/runtime/python/okstra_ctl/handoff.py +4 -8
- package/runtime/python/okstra_ctl/implementation_outcome.py +10 -56
- package/runtime/python/okstra_ctl/model_discovery.py +22 -1
- package/runtime/python/okstra_ctl/mutation_probe.py +425 -2
- package/runtime/python/okstra_ctl/plan_run_root.py +15 -8
- package/runtime/python/okstra_ctl/run.py +8 -54
- package/runtime/python/okstra_ctl/schedule_semantics.py +1249 -0
- package/runtime/python/okstra_ctl/stage_map.py +288 -0
- package/runtime/python/okstra_ctl/wizard.py +24 -35
- package/runtime/python/okstra_project/state.py +19 -5
- package/runtime/skills/okstra-schedule-gen/SKILL.md +75 -35
- package/runtime/templates/reports/schedule.template.md +9 -9
- package/runtime/validators/detect_self_mock.py +27 -2
- package/runtime/validators/validate-implementation-plan-stages.py +24 -63
- package/runtime/validators/validate-run.py +110 -0
- package/runtime/validators/validate-schedule.py +78 -10
- package/src/commands/inspect/stage-map.mjs +1 -1
|
@@ -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
|
|
|
@@ -26,6 +26,17 @@ class PlanRun(NamedTuple):
|
|
|
26
26
|
approved_plan_path: str
|
|
27
27
|
|
|
28
28
|
|
|
29
|
+
def list_implementation_planning_reports(reports_dir: Path) -> list[Path]:
|
|
30
|
+
"""Return numbered implementation-planning reports in latest-first order."""
|
|
31
|
+
numbered: list[tuple[int, Path]] = []
|
|
32
|
+
for report in reports_dir.glob("final-report-implementation-planning-*.md"):
|
|
33
|
+
match = _FINAL_REPORT_RE.fullmatch(report.name)
|
|
34
|
+
if match:
|
|
35
|
+
numbered.append((int(match.group(1)), report))
|
|
36
|
+
numbered.sort(key=lambda item: item[0], reverse=True)
|
|
37
|
+
return [report for _, report in numbered]
|
|
38
|
+
|
|
39
|
+
|
|
29
40
|
def plan_run_root_from_approved_plan(approved_plan_path: str | Path) -> Path:
|
|
30
41
|
"""approved-plan(final-report) 경로에서 plan_run_root 를 역산한다.
|
|
31
42
|
|
|
@@ -58,21 +69,17 @@ def resolve_plan_run_root_by_task_key(
|
|
|
58
69
|
f"({reports_dir} 없음). 먼저 implementation-planning 을 완료하거나 "
|
|
59
70
|
"approved-plan 경로를 직접 지정하세요."
|
|
60
71
|
)
|
|
61
|
-
candidates: list[tuple[
|
|
62
|
-
for report in reports_dir
|
|
63
|
-
m = _FINAL_REPORT_RE.search(report.name)
|
|
64
|
-
if not m:
|
|
65
|
-
continue
|
|
72
|
+
candidates: list[tuple[Path, Path]] = []
|
|
73
|
+
for report in list_implementation_planning_reports(reports_dir):
|
|
66
74
|
run_root = plan_run_root_from_approved_plan(report)
|
|
67
75
|
done = [r for r in read_consumers(run_root) if r.get("status") == "done"]
|
|
68
76
|
if done:
|
|
69
|
-
candidates.append((
|
|
77
|
+
candidates.append((run_root, report))
|
|
70
78
|
if not candidates:
|
|
71
79
|
raise PrepareError(
|
|
72
80
|
"container up: done 상태의 implementation-planning run 이 없습니다. "
|
|
73
81
|
"stage 를 완료(implementation done)한 뒤 다시 시도하거나 approved-plan "
|
|
74
82
|
"경로를 직접 지정하세요."
|
|
75
83
|
)
|
|
76
|
-
|
|
77
|
-
_, run_root, report = candidates[-1]
|
|
84
|
+
run_root, report = candidates[0]
|
|
78
85
|
return PlanRun(run_root=run_root, approved_plan_path=str(report))
|
|
@@ -15,7 +15,6 @@ state passing, and are read once at the start.
|
|
|
15
15
|
"""
|
|
16
16
|
from __future__ import annotations
|
|
17
17
|
|
|
18
|
-
import importlib.util
|
|
19
18
|
import hashlib
|
|
20
19
|
import json
|
|
21
20
|
import os
|
|
@@ -475,62 +474,17 @@ def _validate_stage_structure(plan_path: str) -> None:
|
|
|
475
474
|
RUN_STEP_BUDGET = _stage_targets.RUN_STEP_BUDGET
|
|
476
475
|
|
|
477
476
|
|
|
478
|
-
def
|
|
479
|
-
"""
|
|
480
|
-
|
|
481
|
-
빈/손상 Stage Map 을 흘려보내면 whole-task 완료 게이트
|
|
482
|
-
(`_resolve_whole_task_target`의 `for stage in stage_map`)가 0회 순회로 vacuous
|
|
483
|
-
통과해 미완 task 를 '완성'으로 배포·검증한다. 빈 파싱(heading rename·표 손상)과
|
|
484
|
-
stage 번호 비단조(중간 행 누락 → S2)의 판정을 한 곳에 모아, run.py 의 prepare
|
|
485
|
-
게이트와 wizard 의 stage picker 가 같은 기준을 상속하게 한다(single-reference)."""
|
|
486
|
-
if stages and not errs:
|
|
487
|
-
return None
|
|
488
|
-
return (
|
|
489
|
-
"; ".join(e.message for e in errs) if errs
|
|
490
|
-
else "'## 5.5 Stage Map' heading 이 없거나 표가 비었습니다")
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
def _load_parsed_stage_map(text: str):
|
|
494
|
-
"""validator 모듈을 로드해 `_parse_stage_map(text)` 의 (stages, errs) 를 돌려준다.
|
|
477
|
+
def _parse_stage_map_into_ctx(plan_path: str) -> list:
|
|
478
|
+
"""Parse the approved plan into context records for execution consumers."""
|
|
479
|
+
from .stage_map import StageMapError, parse_stage_map_file, stage_map_records
|
|
495
480
|
|
|
496
|
-
run.py 와 wizard 가 공유하는 단일 validator-load 경로 — 둘 다 `_STAGE_VALIDATOR_PATH`
|
|
497
|
-
(설치 시 `~/.okstra/lib/validators` 로 배치돼 `parents[2]/validators` 로 해소)로
|
|
498
|
-
수렴해 경로 해소가 갈라지지 않게 한다."""
|
|
499
|
-
spec = importlib.util.spec_from_file_location(
|
|
500
|
-
"_ip_stage_validator", _STAGE_VALIDATOR_PATH
|
|
501
|
-
)
|
|
502
|
-
if spec is None or spec.loader is None:
|
|
503
|
-
raise PrepareError(f"cannot load stage validator at {_STAGE_VALIDATOR_PATH}")
|
|
504
|
-
mod = importlib.util.module_from_spec(spec)
|
|
505
|
-
# Register before exec_module so dataclass field-type resolution can find
|
|
506
|
-
# the module in sys.modules (required on Python 3.9).
|
|
507
|
-
sys.modules["_ip_stage_validator"] = mod
|
|
508
481
|
try:
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
finally:
|
|
512
|
-
sys.modules.pop("_ip_stage_validator", None)
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
def _parse_stage_map_into_ctx(plan_path: str) -> list:
|
|
516
|
-
"""Reuse the validator's parser to extract StageMeta dicts for the ctx."""
|
|
517
|
-
text = Path(plan_path).read_text(encoding="utf-8")
|
|
518
|
-
stages, errs = _load_parsed_stage_map(text)
|
|
519
|
-
detail = _stage_map_reject_detail(stages, errs)
|
|
520
|
-
if detail is not None:
|
|
482
|
+
return stage_map_records(parse_stage_map_file(Path(plan_path)))
|
|
483
|
+
except StageMapError as exc:
|
|
521
484
|
raise PrepareError(
|
|
522
|
-
|
|
523
|
-
f"{
|
|
524
|
-
|
|
525
|
-
{
|
|
526
|
-
"stage_number": s.stage_number,
|
|
527
|
-
"title": s.title,
|
|
528
|
-
"depends_on": list(s.depends_on),
|
|
529
|
-
"step_count": s.step_count,
|
|
530
|
-
"exit_contract_summary": s.exit_contract_summary,
|
|
531
|
-
}
|
|
532
|
-
for s in stages
|
|
533
|
-
]
|
|
485
|
+
"approved-plan 의 Stage Map 을 신뢰할 수 없어 거부합니다 "
|
|
486
|
+
f"({plan_path}): {exc.reason}. plan 의 Stage Map 을 점검하세요."
|
|
487
|
+
) from exc
|
|
534
488
|
|
|
535
489
|
|
|
536
490
|
def _apply_cli_approval(path: str) -> str:
|