Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.d/20260930-strix-report-path-token-boundary.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
## Fixed

- Bind Strix changed-source report evidence to complete path tokens so backup or
child suffixes and same-named files under unrelated directories cannot satisfy
the central review-scope gate.
6 changes: 6 additions & 0 deletions docs/product-technical-gap-baseline.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@

이 문서는 제품·기술·운영 Gap을 현재 문서와 현재 GitHub 상태에 묶어 두는 기준선이다. 새 작업은 먼저 이 문서의 Gap ID를 PR 설명과 테스트 증거에 연결하고, PR의 정확한 exact HEAD·Checks·리뷰를 다시 수집한 뒤 구현한다. 표의 상태는 작성 시점의 관측값이므로, 병합 판단에는 재사용하지 않는다. 이 인벤토리는 스냅샷이며 merge authorization이 아니다.

### 2026-09-30 Strix report path-token false-positive delta

| Gap ID | 상태 | exact evidence | causal owner / next gate |
|---|---|---|---|
| CONTROL-STRIX-REPORT-PATH-TOKEN-02 | **Proposed — RED→GREEN source repair; hosted exact-head acceptance pending** | `.github#2504`의 이전 `names_changed_path`는 전체 경로의 `.bak`/child suffix와 `other/scripts/ci/` 아래 동명 파일을 changed source로 오인했다. RED `fcf03118df421be7eff73964e711aaf1cd8312e6`이 두 오탐을 executable regression으로 고정했고, GREEN `ff64cfaa607a413976a8a85c9cd5a003289ef292`은 direct path·directory·filename을 완전 token 경계로 검증한다. focused path-boundary 계약은 7/7 GREEN이며 protected `main@37b10243cec3d160ecc9c1be75c71428b160a703`를 ordinary merge `e8fd6123c1ff6f4fd89848d15f07cb6ae5eb35b4`로 통합했다. | Canonical owner는 중앙 `scripts/ci/strix_report_scope.py`다. exact-head hosted security/quality Checks와 fresh independent review가 terminal GREEN이 되기 전에는 Accepted·merge authority로 승격하지 않는다. |

### 2026-09-30 central coverage owner stack delta

| Gap ID | 상태 | exact-head evidence | causal owner / next gate |
Expand Down
34 changes: 31 additions & 3 deletions scripts/ci/strix_report_scope.py
100644 → 100755
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,35 @@ def _names_scoped_ancestor(report: str, changed_paths: list[str]) -> bool:
return False


def validate(output: Path, changed_paths: list[str]) -> None:
"""Validate that a completed Strix report covers changed source scope."""
def _token(text: str) -> re.Pattern[str]:
"""Match text only where it is not part of a longer name or path segment."""
return re.compile(rf"(?<![\w./-]){re.escape(text)}(?![\w-]|\.\w)")


def _file_token(text: str) -> re.Pattern[str]:
"""Match a file name only when no longer name or child path continues it."""
return re.compile(rf"(?<![\w./-]){re.escape(text)}(?![\w/-]|\.\w)")


def names_changed_path(report: str, path: str) -> bool:
"""Return whether the report names the path, or its file within a named directory.

The directory may be the file's own directory or any ancestor of at least two
segments; a lone top-level name such as ``crates`` is too generic to scope a file.
"""
if _file_token(path).search(report) is not None:
return True
directory, _, name = path.rpartition("/")
if not directory or _file_token(name).search(report) is None:
return False
if _token(directory).search(report) is not None:
return True
parts = directory.split("/")
return any(_token("/".join(parts[:depth])).search(report) for depth in range(2, len(parts) + 1))


def validate(output: Path, changed_paths: list[str]) -> None:
"""Raise ValueError unless one completed, unlinked report names a changed path."""
if not output.is_dir() or output.is_symlink():
raise ValueError("scan output directory is missing")
runs = [path for path in output.iterdir() if path.is_dir() and not path.is_symlink()]
Expand All @@ -44,7 +70,9 @@ def validate(output: Path, changed_paths: list[str]) -> None:
if metadata.get("status") != "completed" or results.get("scan_completed") is not True or results.get("success") is not True:
raise ValueError("scan report is incomplete")
report = report_path.read_text(encoding="utf-8")
if not any(path in report for path in changed_paths) and not _names_scoped_ancestor(report, changed_paths):
if not any(names_changed_path(report, path) for path in changed_paths) and not _names_scoped_ancestor(
report, changed_paths
):
raise ValueError("scan report does not identify a changed source file")


Expand Down
82 changes: 82 additions & 0 deletions tests/test_strix_report_scope.py
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,88 @@ def test_bare_repository_directory_or_scope_root_is_rejected(
)


def test_validate_accepts_directory_scoped_file_name(tmp_path: Path) -> None:
"""A standalone file token within its reported directory is accepted."""
_write_report(
tmp_path,
report_text=(
"Scope: `/workspace/strix-pr-scope.v6Wilu/scripts/ci/`.\n"
"Reviewed `strix_quick_gate.sh`; no vulnerabilities found.\n"
),
)
report_scope.validate(tmp_path, ["scripts/ci/strix_quick_gate.sh"])


@pytest.mark.parametrize(
"report_text",
[
"Reviewed `strix_quick_gate.sh` without naming its directory.\n",
"Scope: scripts/ci/. Reviewed `my_strix_quick_gate.sh`.\n",
"Scope: scripts/ci/. Reviewed `strix_quick_gate.sh.bak`.\n",
"Scope: other/scripts/ci-tools/. Reviewed strix_quick_gate.sh.\n",
],
)
def test_validate_rejects_bare_or_partial_file_names(
tmp_path: Path, report_text: str
) -> None:
"""Bare, prefixed, suffixed, and wrong-directory identities fail closed."""
_write_report(tmp_path, report_text=report_text)
with pytest.raises(ValueError, match="does not identify a changed source file"):
report_scope.validate(tmp_path, ["scripts/ci/strix_quick_gate.sh"])


@pytest.mark.parametrize(
"report_text",
[
"Reviewed scripts/ci/strix_quick_gate.sh.bak.\n",
"Reviewed scripts/ci/strix_quick_gate.sh/notes.\n",
"Scope: other/scripts/ci/. Reviewed other/strix_quick_gate.sh.\n",
],
)
def test_names_changed_path_rejects_longer_or_unrelated_paths(
report_text: str,
) -> None:
"""A suffix or same-named file elsewhere is not the changed file."""
assert not report_scope.names_changed_path(
report_text, "scripts/ci/strix_quick_gate.sh"
)


def test_validate_accepts_file_within_reported_ancestor(tmp_path: Path) -> None:
"""A two-segment ancestor plus standalone file token binds the source."""
_write_report(
tmp_path,
report_text=(
"**Scope:** `/workspace/strix-pr-scope.4eyzTL` including "
"`crates/mlsirm-core`.\n"
"Reviewed `two_tier_recursion.rs`; no vulnerabilities found.\n"
),
)
report_scope.validate(
tmp_path, ["crates/mlsirm-core/src/two_tier_recursion.rs"]
)


@pytest.mark.parametrize(
"report_text",
[
"Scope: crates/ only. Reviewed two_tier_recursion.rs.\n",
"Scope: crates/mlsirm-core-extra. Reviewed two_tier_recursion.rs.\n",
"Scope: crates/mlsirm-core. Reviewed two_tier_recursion.rs/notes.\n",
Comment thread
coderabbitai[bot] marked this conversation as resolved.
"Scope: crates/mlsirm-core. Reviewed other_recursion.rs.\n",
],
)
def test_validate_rejects_generic_or_mismatched_ancestors(
tmp_path: Path, report_text: str
) -> None:
"""Generic, partial, child-suffixed, and wrong-file scopes are rejected."""
_write_report(tmp_path, report_text=report_text)
with pytest.raises(ValueError, match="does not identify a changed source file"):
report_scope.validate(
tmp_path, ["crates/mlsirm-core/src/two_tier_recursion.rs"]
)


def test_cli_reports_validation_error_and_accepts_scoped_success(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
Expand Down
Loading