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.
- package/docs/project-structure-overview.md +2 -2
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/python/okstra_ctl/clarification_items.py +57 -14
- package/runtime/python/okstra_ctl/mutation_probe.py +425 -2
- package/runtime/python/okstra_ctl/user_response.py +5 -1
- package/runtime/templates/reports/html/i18n/en.json +3 -1
- package/runtime/templates/reports/html/i18n/ko.json +3 -1
- package/runtime/templates/reports/html/macros/forms.html +2 -2
- package/runtime/validators/detect_self_mock.py +27 -2
|
@@ -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
|
@@ -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
|
-
|
|
401
|
-
|
|
402
|
-
|
|
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
|
|
618
|
+
def sidecar_answers(source: Path) -> dict[str, str]:
|
|
591
619
|
"""`user-responses/` 사이드카들의 답변을 `{clarification-id: value}` 로 모은다.
|
|
592
620
|
|
|
593
|
-
|
|
594
|
-
|
|
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 =
|
|
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
|
|
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
|
|
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 표에서 사이드카 답이 있는 미해결 행의
|
|
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), `
|
|
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
|
|
|
@@ -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 }}
|
|
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 }}">
|
|
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(
|
|
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
|