diff --git a/exo/stdlib/requirements.py b/exo/stdlib/requirements.py index 92e61f9..c1821f6 100644 --- a/exo/stdlib/requirements.py +++ b/exo/stdlib/requirements.py @@ -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 @@ -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() @@ -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 diff --git a/tests/test_requirements.py b/tests/test_requirements.py index 8a1f540..7119466 100644 --- a/tests/test_requirements.py +++ b/tests/test_requirements.py @@ -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, @@ -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"