okstra 0.153.0 → 0.154.1

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.1",
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.1",
3
+ "builtAt": "2026-08-05T18:07:27.600Z",
4
4
  "repoRoot": "/home/runner/work/okstra/okstra"
5
5
  }
@@ -395,11 +395,39 @@ def scan_clarification_blockers(
395
395
  report_path: Path, blocking_values: frozenset[str]
396
396
  ) -> ClarificationScan:
397
397
  """Shared fail-closed clarification walk for both gates above — schema-v2
398
- reads its rows from the data sibling, schema-v1 from the §1 table."""
398
+ reads its rows from the data sibling, schema-v1 from the §1 table, and the
399
+ user's `user-responses/` sidecar outranks whatever the report says about
400
+ those rows."""
399
401
  v2_scan = _scan_v2_blockers(report_path, blocking_values)
400
- if v2_scan is not None:
401
- return v2_scan
402
- return scan_section_1_blockers(_read_report_text(report_path), blocking_values)
402
+ scan = (
403
+ v2_scan if v2_scan is not None
404
+ else scan_section_1_blockers(_read_report_text(report_path), blocking_values)
405
+ )
406
+ return _resolve_blockers_answered_by_user(report_path, scan)
407
+
408
+
409
+ def _resolve_blockers_answered_by_user(
410
+ report_path: Path, scan: ClarificationScan
411
+ ) -> ClarificationScan:
412
+ """사용자가 사이드카로 답한 행을 blocker 에서 뺀 스캔.
413
+
414
+ 답의 정본은 사용자의 `user-responses/` 사이드카다. 리포트의 `Status` 는 그
415
+ run 이 스스로 적어둔 값이고, 답이 사이드카로만 들어오는 경로(HTML 뷰의
416
+ `Export user response`, `okstra user-response write`)에서는 갱신되지
417
+ 않는다 — 게이트가 리포트만 보면 사용자가 답을 다 채운 뒤에도 같은 항목이
418
+ 영원히 미해결로 남아 다음 phase 를 막는다.
419
+
420
+ fail-closed 는 그대로다: 행 자체를 못 읽은 스캔(`unreadable_reason`)은
421
+ 어떤 id 가 blocker 인지 모르는 상태이므로 사이드카로 덮지 않는다.
422
+ """
423
+ if scan.unreadable_reason is not None or not scan.blockers:
424
+ return scan
425
+ answers = sidecar_answers(report_path)
426
+ if not answers:
427
+ return scan
428
+ return ClarificationScan(
429
+ [b for b in scan.blockers if b.row_id not in answers], None
430
+ )
403
431
 
404
432
 
405
433
  def scan_section_1_blockers(
@@ -587,11 +615,20 @@ def _sidecars_for_attachment(source: Path) -> list[Path]:
587
615
  return sorted([*ordinary, *selected])
588
616
 
589
617
 
590
- def _sidecar_answers(source: Path) -> dict[str, str]:
618
+ def sidecar_answers(source: Path) -> dict[str, str]:
591
619
  """`user-responses/` 사이드카들의 답변을 `{clarification-id: value}` 로 모은다.
592
620
 
593
- 같은 id 여러 사이드카에 나오면 이름순 마지막(최신 seq)이 이긴다. `value`
594
- 항목은 담지 않는다. `user_response` 지연 import 해 순환 참조를 피한다.
621
+ 사용자가 답한 항목이 무엇인지 아는 단일 참조점 carry-in 병합도, 승인
622
+ 게이트도, 스킬의 열린 항목 목록도 전부 곳을 본다.
623
+
624
+ `disposition` 이 `answer` 인 항목만 답으로 센다. `reframe` 은 "다음 run 에서
625
+ 다시 물어달라" 이지 답이 아니므로(`skills/okstra-user-response/SKILL.md` 의
626
+ "A reframe is not an answer") 답 집합에서 빠져야 게이트가 그 항목을 계속
627
+ 미해결로 잡는다.
628
+
629
+ 같은 id 가 여러 사이드카에 나오면 이름순 마지막(최신 seq)이 이긴다 — 최신이
630
+ reframe 이거나 값이 비면 앞선 답을 지운다. 그래야 답을 물렀을 때 그 항목이
631
+ 미해결로 돌아온다. `user_response` 를 지연 import 해 순환 참조를 피한다.
595
632
  """
596
633
  from okstra_ctl.user_response import parse_user_response_entries
597
634
 
@@ -600,8 +637,10 @@ def _sidecar_answers(source: Path) -> dict[str, str]:
600
637
  for entry in parse_user_response_entries(
601
638
  sidecar.read_text(encoding="utf-8")
602
639
  ):
603
- if entry.value:
640
+ if entry.value and entry.disposition == "answer":
604
641
  answers[entry.response_id] = entry.value
642
+ else:
643
+ answers.pop(entry.response_id, None)
605
644
  return answers
606
645
 
607
646
 
@@ -644,7 +683,7 @@ def clarification_response_with_sidecars(source: Path) -> str:
644
683
  """
645
684
  text = source.read_text(encoding="utf-8")
646
685
  section = attached_user_responses_section(source)
647
- answers = _sidecar_answers(source)
686
+ answers = sidecar_answers(source)
648
687
  body = _clarification_carry_body(source, text, answers)
649
688
  if not section:
650
689
  return body
@@ -750,9 +789,13 @@ def _locate_user_input_column(lines: list[str]) -> tuple[int, int]:
750
789
 
751
790
 
752
791
  def _reconcile_row(line: str, ui_col: int, answers: dict[str, str]) -> str:
753
- """답이 있고 open/answered 이며 `User input` 칸이 행이면 답을 채우고 Status 를
754
- resolved 로 바꾼 줄을, 그 외에는 원본 줄을 그대로 돌려준다. 이미 채워진 칸은
755
- 표에 든 값이 정본이므로 덮어쓰지 않는다.
792
+ """답이 있고 open/answered 행이면 `User input` 칸을 답으로 채우고 Status 를
793
+ resolved 로 바꾼 줄을, 그 외에는 원본 줄을 그대로 돌려준다.
794
+
795
+ 칸에 이미 값이 있어도 사용자의 사이드카 답이 이긴다. 그 칸을 채우는 것은
796
+ run 자신(직전 렌더가 옮겨 적은 값)이고, 사용자가 나중에 답을 바꾸면 둘이
797
+ 갈라진다 — 사용자가 쓴 쪽을 정본으로 삼지 않으면 run 이 자기가 적어둔 값으로
798
+ 계속 되돌아간다.
756
799
 
757
800
  판정은 앵커/백틱을 벗긴 셀(`_split_pipe_row`)로 — 그래야 `_meta_id` 가 스크롤
758
801
  앵커의 소문자 slug 대신 진짜 대문자 ID 를 읽는다. 재조립은 원본 셀
@@ -764,7 +807,7 @@ def _reconcile_row(line: str, ui_col: int, answers: dict[str, str]) -> str:
764
807
  if item.status not in UNRESOLVED_STATUSES:
765
808
  return line
766
809
  raw = split_pipe_row(line)
767
- if not (0 <= ui_col < len(raw) and raw[ui_col] == ""):
810
+ if not 0 <= ui_col < len(raw):
768
811
  return line
769
812
  raw[ui_col] = answers[item.row_id]
770
813
  raw[0] = _STATUS_RESOLVE_RE.sub(r"\1resolved", raw[0])
@@ -772,7 +815,7 @@ def _reconcile_row(line: str, ui_col: int, answers: dict[str, str]) -> str:
772
815
 
773
816
 
774
817
  def _reconcile_user_input(section: str, answers: dict[str, str]) -> str:
775
- """§1 표에서 사이드카 답이 있는 미해결 행의 `User input` 칸을 답으로 채우고
818
+ """§1 표에서 사이드카 답이 있는 미해결 행의 `User input` 칸을 답으로 채우고
776
819
  Status 를 resolved 로 바꾼 §1 본문을 돌려준다.
777
820
 
778
821
  답의 정본 위치를 §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), `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
 
@@ -27,6 +27,7 @@ from okstra_ctl.clarification_items import (
27
27
  read_clarification_rows,
28
28
  scan_open_user_input,
29
29
  section_1_present_but_unparsed,
30
+ sidecar_answers,
30
31
  _section_1_slice,
31
32
  )
32
33
 
@@ -545,10 +546,13 @@ def resolve_refs(report_text: str, refs: list[str]) -> list[dict]:
545
546
 
546
547
  def show_open_rows(report_path: Path) -> dict:
547
548
  text = report_path.read_text(encoding="utf-8")
549
+ # 사이드카에 답이 있는 행은 사용자가 이미 답한 것이다. 리포트의 `Status` 는
550
+ # 그 답을 반영하지 않으므로, 이걸 빼지 않으면 스킬이 같은 질문을 다시 묻는다.
551
+ answered = sidecar_answers(report_path)
548
552
  rows = []
549
553
  for r in read_clarification_rows(report_path):
550
554
  it = r["item"]
551
- if it.status not in ("open", "answered"):
555
+ if it.status not in ("open", "answered") or it.row_id in answered:
552
556
  continue
553
557
  statement, expected = r["statement"], r["expected_form"]
554
558
  refs = sorted(set(_SECTION_REF_RE.findall(statement + " " + expected)))
@@ -104,7 +104,9 @@
104
104
  "export-my-answers": "Export my answers",
105
105
  "at-the-foot-of-the-page": "at the foot of the page.",
106
106
  "answer-as": "Answer as",
107
- "questions-waiting-on-you": "Questions waiting on you"
107
+ "questions-waiting-on-you": "Questions waiting on you",
108
+ "count-questions-waiting-on-you": "{count} questions waiting on you",
109
+ "your-answer-to-id": "Your answer to {id}"
108
110
  },
109
111
  "visualizations": {
110
112
  "component": "Component",
@@ -104,7 +104,9 @@
104
104
  "export-my-answers": "내 답변 내보내기",
105
105
  "at-the-foot-of-the-page": "버튼을 누르세요.",
106
106
  "answer-as": "답변 형식",
107
- "questions-waiting-on-you": "답변을 기다리는 질문"
107
+ "questions-waiting-on-you": "답변을 기다리는 질문",
108
+ "count-questions-waiting-on-you": "답변을 기다리는 질문 {count}건",
109
+ "your-answer-to-id": "{id}에 대한 답변"
108
110
  },
109
111
  "visualizations": {
110
112
  "component": "구성 요소",
@@ -33,14 +33,14 @@
33
33
  {% macro clarification_responses(items) -%}
34
34
  {% if items %}
35
35
  <section class="clarification-responses" aria-label="{{ t('macros.forms.questions-waiting-on-you') }}">
36
- <h2>{{ items | length }} questions waiting on you</h2>
36
+ <h2>{{ t('macros.forms.count-questions-waiting-on-you') | replace('{count}', items | length) }}</h2>
37
37
  <p class="clarification-lede">{{ t('macros.forms.the-code-alone-could-not-settle-these-fill-i') }} <strong>{{ t('macros.forms.export-my-answers') }}</strong> {{ t('macros.forms.at-the-foot-of-the-page') }}</p>
38
38
  {% for row in items %}
39
39
  <article class="clarification-item" id="id-{{ row.id }}" data-response-id="{{ row.id }}" data-kind="{{ row.kind }}">
40
40
  <p class="eyebrow">{{ row.id }} · {{ row.kind }}</p>
41
41
  {{ row.statement | paragraphs }}
42
42
  <dl class="clarification-expected"><dt>{{ t('macros.forms.answer-as') }}</dt><dd>{{ row.expectedForm | inline_code }}</dd></dl>
43
- <label for="response-{{ row.id }}">Your answer to {{ row.id }}</label>
43
+ <label for="response-{{ row.id }}">{{ t('macros.forms.your-answer-to-id') | replace('{id}', row.id) }}</label>
44
44
  <textarea id="response-{{ row.id }}" data-response-id="{{ row.id }}" rows="4">{{ row.userInput | default('') }}</textarea>
45
45
  </article>
46
46
  {% endfor %}
@@ -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