Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
55 changes: 40 additions & 15 deletions exo/stdlib/requirements.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@

from __future__ import annotations

import io
import re
import tokenize
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Any
Expand Down Expand Up @@ -321,6 +323,31 @@ def _scan_test_files(repo: Path, globs: list[str] | None = None) -> list[Path]:
return sorted(found)


def _scan_acc_in_python(file_path: Path, source: str) -> list[AccTestRef]:
"""Scan a Python source for @acc: annotations using the tokenizer.

Only COMMENT tokens are checked so occurrences inside string literals
(e.g. test fixtures that quote the marker as data) are ignored.
TokenizeError is swallowed — the caller gets an empty list for that file.
"""
refs: list[AccTestRef] = []
try:
tokens = list(tokenize.generate_tokens(io.StringIO(source).readline))
except tokenize.TokenError:
return refs
for tok in tokens:
if tok.type != tokenize.COMMENT:
continue
m = ACC_TAG_PATTERN.search(tok.string)
if m:
raw_ids = m.group(1)
for aid in raw_ids.split(","):
aid = aid.strip()
if aid:
refs.append(AccTestRef(acc_id=aid, file=str(file_path), line=tok.start[0]))
return refs


def scan_acc_refs(repo: Path, *, globs: list[str] | None = None) -> list[AccTestRef]:
"""Scan test files for @acc: annotations."""
repo = Path(repo).resolve()
Expand All @@ -329,26 +356,24 @@ def scan_acc_refs(repo: Path, *, globs: list[str] | None = None) -> list[AccTest

for filepath in files:
try:
lines = filepath.read_text(encoding="utf-8", errors="replace").splitlines()
source = filepath.read_text(encoding="utf-8", errors="replace")
except OSError:
continue

rel = str(filepath.relative_to(repo))

for line_num, line in enumerate(lines, start=1):
match = ACC_TAG_PATTERN.search(line)
if match:
raw_ids = match.group(1)
for aid in raw_ids.split(","):
aid = aid.strip()
if aid:
refs.append(
AccTestRef(
acc_id=aid,
file=rel,
line=line_num,
)
)
if filepath.suffix == ".py":
for ref in _scan_acc_in_python(filepath, source):
refs.append(AccTestRef(acc_id=ref.acc_id, file=rel, line=ref.line))
else:
for line_num, line in enumerate(source.splitlines(), start=1):
match = ACC_TAG_PATTERN.search(line)
if match:
raw_ids = match.group(1)
for aid in raw_ids.split(","):
aid = aid.strip()
if aid:
refs.append(AccTestRef(acc_id=aid, file=rel, line=line_num))

return refs

Expand Down
46 changes: 46 additions & 0 deletions tests/test_requirements.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
REQ_TAG_PATTERN,
VALID_PRIORITIES,
VALID_STATUSES,
_scan_acc_in_python,
format_req_trace_human,
load_requirements,
req_trace_to_dict,
Expand Down Expand Up @@ -1409,3 +1410,48 @@ def test_without_check_tests_flag(self, tmp_path: Path) -> None:
data = json.loads(result.stdout)
assert data["data"]["passed"]
assert data["data"]["acc_total"] == 0


# ── ACC Scanner String-Literal Immunity ─────────────────────────────


class TestAccScannerStringLiteralImmunity:
"""Tokenize-based scanner must not match @acc: inside Python string literals."""

def test_string_literal_yields_no_refs(self, tmp_path: Path) -> None:
"""A @acc: marker embedded in a string literal must not be flagged."""
src = 'f = "# @acc: ACC-001"\n'
refs = _scan_acc_in_python(tmp_path / "test_fake.py", src)
assert refs == []

def test_real_comment_yields_one_ref(self, tmp_path: Path) -> None:
"""A genuine @acc: comment must produce exactly one ref."""
src = "# @acc: ACC-002\ndef test_something(): pass\n"
refs = _scan_acc_in_python(tmp_path / "test_real.py", src)
assert len(refs) == 1
assert refs[0].acc_id == "ACC-002"

def test_mixed_source_yields_only_comment_ref(self, tmp_path: Path) -> None:
"""Only the comment annotation counts; the string-literal one must be ignored."""
src = 'f = "# @acc: ACC-001"\n# @acc: ACC-002\ndef test_something(): pass\n'
refs = _scan_acc_in_python(tmp_path / "test_mixed.py", src)
assert len(refs) == 1
assert refs[0].acc_id == "ACC-002"

def test_tokenize_error_returns_empty(self, tmp_path: Path) -> None:
"""Syntactically broken Python must not raise; scanner returns empty list."""
src = '"""\n' # unterminated triple-quote forces tokenize.TokenError
refs = _scan_acc_in_python(tmp_path / "test_broken.py", src)
assert refs == []

def test_non_python_file_line_regex_preserved(self, tmp_path: Path) -> None:
"""Non-Python files (.ts) must still be scanned via the line-regex path."""
repo = _bootstrap_repo(tmp_path)
(repo / "tests").mkdir(parents=True, exist_ok=True)
(repo / "tests" / "test_real.ts").write_text(
"// @acc: ACC-003\ntest('login', () => {});\n",
encoding="utf-8",
)
refs = scan_acc_refs(repo)
assert len(refs) == 1
assert refs[0].acc_id == "ACC-003"
Loading