Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
"""Invariants for scripts/build_skills_index.py's health-check guard.
|
||||
|
||||
Regression context (June 2026): a GitHub API rate limit zeroed every
|
||||
api.github.com-backed source (github / well-known) at
|
||||
once during the docs deploy crawl. The build's health check fired and exited
|
||||
non-zero — but it had ALREADY written the degenerate index to disk, and
|
||||
deploy-site.yml swallowed the exit code with ``|| echo non-fatal``. The
|
||||
partial index (missing the OpenAI/Anthropic/HuggingFace/NVIDIA tabs) shipped
|
||||
to the live Skills Hub.
|
||||
|
||||
These tests pin the two contracts that prevent a recurrence:
|
||||
1. A degenerate crawl exits non-zero AND does NOT write the output file
|
||||
(so extract-skills.py falls back instead of reading a broken index).
|
||||
2. A healthy crawl exits zero AND writes the file with every source present.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import types
|
||||
|
||||
import pytest
|
||||
|
||||
import scripts.build_skills_index as build_mod
|
||||
|
||||
|
||||
def _meta(name, src):
|
||||
return build_mod.SkillMeta(
|
||||
name=name, description="d", source=src,
|
||||
identifier=f"{src}/{name}", trust_level="community",
|
||||
)
|
||||
|
||||
|
||||
class _FakeSource:
|
||||
def __init__(self, src, n, rate_limited=False):
|
||||
self._src = src
|
||||
self._n = n
|
||||
self.is_rate_limited = rate_limited
|
||||
|
||||
def search(self, query, limit=10):
|
||||
return [_meta(f"{self._src}-{i}", self._src) for i in range(self._n)]
|
||||
|
||||
def enrich_owners(self, skills, max_workers=30):
|
||||
# No-op: fake source doesn't need owner enrichment.
|
||||
return 0
|
||||
|
||||
|
||||
def _install_fake_sources(monkeypatch, *, github_count,
|
||||
well_known_count=10, github_rate_limited=False):
|
||||
monkeypatch.setattr(build_mod, "SkillsShSource", lambda auth: _FakeSource("skills.sh", 15000))
|
||||
monkeypatch.setattr(build_mod, "OptionalSkillSource", lambda: _FakeSource("official", 95))
|
||||
monkeypatch.setattr(build_mod, "WellKnownSkillSource", lambda: _FakeSource("well-known", well_known_count))
|
||||
monkeypatch.setattr(
|
||||
build_mod, "GitHubSource",
|
||||
lambda auth: _FakeSource("github", github_count, rate_limited=github_rate_limited),
|
||||
)
|
||||
monkeypatch.setattr(build_mod, "ClawHubSource", lambda: _FakeSource("clawhub", 69000))
|
||||
monkeypatch.setattr(build_mod, "LobeHubSource", lambda: _FakeSource("lobehub", 500))
|
||||
monkeypatch.setattr(build_mod, "BrowseShSource", lambda: _FakeSource("browse-sh", 380))
|
||||
monkeypatch.setattr(
|
||||
build_mod, "crawl_skills_sh",
|
||||
lambda source: [build_mod._meta_to_dict(m) for m in source.search("", 0)],
|
||||
)
|
||||
monkeypatch.setattr(build_mod, "batch_resolve_paths", lambda skills, auth: skills)
|
||||
monkeypatch.setattr(
|
||||
build_mod, "GitHubAuth",
|
||||
lambda: types.SimpleNamespace(auth_method=lambda: "token"),
|
||||
)
|
||||
|
||||
|
||||
def test_degenerate_crawl_exits_nonzero_and_writes_no_file(tmp_path, monkeypatch):
|
||||
"""A collapsed GitHub crawl must fail loud and leave OUTPUT_PATH unwritten."""
|
||||
out = tmp_path / "skills-index.json"
|
||||
monkeypatch.setattr(build_mod, "OUTPUT_PATH", str(out))
|
||||
_install_fake_sources(monkeypatch, github_count=0,
|
||||
well_known_count=0, github_rate_limited=True)
|
||||
|
||||
with pytest.raises(SystemExit) as exc:
|
||||
build_mod.main()
|
||||
|
||||
assert exc.value.code != 0
|
||||
# The degenerate index must NOT have been written — extract-skills.py
|
||||
# relies on the file's absence to fall back instead of reading garbage.
|
||||
assert not out.exists()
|
||||
|
||||
|
||||
def test_healthy_crawl_writes_index_with_all_sources(tmp_path, monkeypatch):
|
||||
out = tmp_path / "skills-index.json"
|
||||
monkeypatch.setattr(build_mod, "OUTPUT_PATH", str(out))
|
||||
_install_fake_sources(monkeypatch, github_count=200)
|
||||
|
||||
build_mod.main() # exit 0 (no SystemExit)
|
||||
|
||||
assert out.exists()
|
||||
import json
|
||||
data = json.loads(out.read_text())
|
||||
sources = {s["source"] for s in data["skills"]}
|
||||
# Every GitHub-API-backed source that vanished in the regression is present.
|
||||
assert {"github", "well-known"} <= sources
|
||||
assert data["skill_count"] == len(data["skills"])
|
||||
@@ -0,0 +1,118 @@
|
||||
"""Wrappers for scripts/check-case-collisions.py.
|
||||
|
||||
Same pattern as tests/scripts/test_windows_footguns_full_repo_scan.py: run
|
||||
the real checker and assert its outcomes, so a normal pytest run catches a
|
||||
regression — someone committing a case-colliding pair — without anyone
|
||||
having to remember to run the script by hand.
|
||||
|
||||
The collision cases are built with ``git update-index --cacheinfo`` (index
|
||||
only, never touching the working tree), so they exercise the same index the
|
||||
checker reads and work even on a case-insensitive filesystem, where the two
|
||||
spellings cannot coexist on disk.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
SCRIPT = REPO_ROOT / "scripts" / "check-case-collisions.py"
|
||||
|
||||
|
||||
def _git_blob_sha(data: bytes) -> str:
|
||||
"""The git object hash for a blob with ``data`` as its content."""
|
||||
header = f"blob {len(data)}\0".encode("ascii")
|
||||
return hashlib.sha1(header + data).hexdigest()
|
||||
|
||||
|
||||
def _run_check(*args, root=None):
|
||||
cmd = [sys.executable, str(SCRIPT)] + list(args)
|
||||
if root is not None:
|
||||
cmd.append(str(root))
|
||||
return subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
stdin=subprocess.DEVNULL,
|
||||
cwd=REPO_ROOT,
|
||||
)
|
||||
|
||||
|
||||
def _git_init(tmp_path) -> Path:
|
||||
repo = tmp_path / "repo"
|
||||
repo.mkdir()
|
||||
subprocess.run(["git", "init", "-q"], cwd=repo, check=True)
|
||||
return repo
|
||||
|
||||
|
||||
def test_full_repo_has_no_case_colliding_paths():
|
||||
"""The real checker against the whole tracked tree must exit clean."""
|
||||
result = _run_check()
|
||||
assert result.returncode == 0, (
|
||||
f"Case-collision check failed:\n{result.stdout}\n{result.stderr}"
|
||||
)
|
||||
|
||||
|
||||
def test_detects_case_colliding_paths(tmp_path):
|
||||
"""Same-directory Foo.txt + foo.txt must fail, naming both paths."""
|
||||
repo = _git_init(tmp_path)
|
||||
subprocess.run(
|
||||
[
|
||||
"git", "update-index", "--add", "--cacheinfo",
|
||||
f"100644,{_git_blob_sha(b'a')},Foo.txt",
|
||||
],
|
||||
cwd=repo, check=True,
|
||||
)
|
||||
subprocess.run(
|
||||
[
|
||||
"git", "update-index", "--add", "--cacheinfo",
|
||||
f"100644,{_git_blob_sha(b'b')},foo.txt",
|
||||
],
|
||||
cwd=repo, check=True,
|
||||
)
|
||||
|
||||
result = _run_check(root=repo)
|
||||
assert result.returncode == 1, f"expected failure, got:\n{result.stdout}"
|
||||
assert "Foo.txt" in result.stdout
|
||||
assert "foo.txt" in result.stdout
|
||||
|
||||
|
||||
def test_detects_directory_case_collisions(tmp_path):
|
||||
"""The comparison is on the FULL path — dir/Foo.txt vs DIR/foo.txt too."""
|
||||
repo = _git_init(tmp_path)
|
||||
subprocess.run(
|
||||
[
|
||||
"git", "update-index", "--add", "--cacheinfo",
|
||||
f"100644,{_git_blob_sha(b'a')},src/Helper.py",
|
||||
],
|
||||
cwd=repo, check=True,
|
||||
)
|
||||
subprocess.run(
|
||||
[
|
||||
"git", "update-index", "--add", "--cacheinfo",
|
||||
f"100644,{_git_blob_sha(b'b')},SRC/helper.py",
|
||||
],
|
||||
cwd=repo, check=True,
|
||||
)
|
||||
|
||||
result = _run_check(root=repo)
|
||||
assert result.returncode == 1, f"expected failure, got:\n{result.stdout}"
|
||||
assert "src/Helper.py" in result.stdout
|
||||
assert "SRC/helper.py" in result.stdout
|
||||
|
||||
|
||||
def test_same_name_in_different_dirs_is_not_a_collision(tmp_path):
|
||||
"""a/Readme.txt and b/readme.txt share a basename but not a path."""
|
||||
repo = _git_init(tmp_path)
|
||||
(repo / "a").mkdir()
|
||||
(repo / "b").mkdir()
|
||||
(repo / "a" / "Readme.txt").write_text("a", encoding="utf-8")
|
||||
(repo / "b" / "readme.txt").write_text("b", encoding="utf-8")
|
||||
subprocess.run(["git", "add", "-A"], cwd=repo, check=True)
|
||||
|
||||
result = _run_check(root=repo)
|
||||
assert result.returncode == 0, f"expected clean, got:\n{result.stdout}"
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Behavioral tests for the profile archive CI guard."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
SCRIPT = (
|
||||
Path(__file__).resolve().parents[2]
|
||||
/ "scripts"
|
||||
/ "ci"
|
||||
/ "check_profile_archive_boundary.py"
|
||||
)
|
||||
|
||||
|
||||
def _run(root: Path) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
[sys.executable, str(SCRIPT), "--root", str(root)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
|
||||
def test_clean_root_passes(tmp_path):
|
||||
result = _run(tmp_path)
|
||||
|
||||
assert result.returncode == 0
|
||||
assert "No profile export archives" in result.stdout
|
||||
|
||||
|
||||
def test_root_profile_archive_fails_without_printing_contents(tmp_path):
|
||||
default_archive = tmp_path / "default.tar.gz"
|
||||
alternate_archive = tmp_path / "backup.TGZ"
|
||||
default_archive.write_bytes(b"profile webhook secret must never be printed")
|
||||
alternate_archive.write_bytes(b"another archive")
|
||||
|
||||
result = _run(tmp_path)
|
||||
|
||||
assert result.returncode == 1
|
||||
assert "default.tar.gz" in result.stdout
|
||||
assert "backup.TGZ" in result.stdout
|
||||
assert "profile webhook secret" not in result.stdout
|
||||
assert "another archive" not in result.stdout
|
||||
|
||||
|
||||
def test_nested_profile_archive_is_also_rejected(tmp_path):
|
||||
nested = tmp_path / "fixtures"
|
||||
nested.mkdir()
|
||||
(nested / "fixture.tar.gz").write_bytes(b"test fixture")
|
||||
|
||||
result = _run(tmp_path)
|
||||
|
||||
assert result.returncode == 1
|
||||
assert "fixtures/fixture.tar.gz" in result.stdout
|
||||
@@ -0,0 +1,181 @@
|
||||
"""Tests for the conflict-free contributor mapping system.
|
||||
|
||||
New contributor email → GitHub login mappings live as one file per email
|
||||
under contributors/emails/ (additions never merge-conflict). The legacy
|
||||
AUTHOR_MAP dict in scripts/release.py is frozen; release.py merges both at
|
||||
import time with the directory winning on duplicates.
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
SCRIPTS_DIR = REPO_ROOT / "scripts"
|
||||
|
||||
sys.path.insert(0, str(SCRIPTS_DIR))
|
||||
|
||||
import release # noqa: E402
|
||||
from add_contributor import add_contributor, read_mapping_file # noqa: E402
|
||||
|
||||
|
||||
# ── directory loader behavior ─────────────────────────────────────────
|
||||
|
||||
|
||||
def test_loader_reads_login_from_first_noncomment_line(tmp_path):
|
||||
d = tmp_path / "emails"
|
||||
d.mkdir()
|
||||
(d / "jane@example.com").write_text("# salvage PR #1\njanedoe\n# trailing note\n")
|
||||
mapping = release._load_contributor_dir(d)
|
||||
assert mapping == {"jane@example.com": "janedoe"}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def test_effective_map_merges_legacy_and_directory():
|
||||
# Invariant: every legacy entry survives into the effective map unless
|
||||
# shadowed by a directory entry, and the directory contributes on top.
|
||||
assert set(release.LEGACY_AUTHOR_MAP) <= (
|
||||
set(release.AUTHOR_MAP) | set(release._load_contributor_dir())
|
||||
)
|
||||
for email, login in release._load_contributor_dir().items():
|
||||
assert release.AUTHOR_MAP[email] == login
|
||||
|
||||
|
||||
|
||||
|
||||
# ── add_contributor.py CLI behavior ───────────────────────────────────
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def emails_dir(tmp_path, monkeypatch):
|
||||
import add_contributor
|
||||
|
||||
d = tmp_path / "contributors" / "emails"
|
||||
monkeypatch.setattr(add_contributor, "EMAILS_DIR", d)
|
||||
return d
|
||||
|
||||
|
||||
def test_add_creates_mapping_file(emails_dir):
|
||||
rc = add_contributor("new@example.com", "newperson", "PR #999 salvage")
|
||||
assert rc == 0
|
||||
path = emails_dir / "new@example.com"
|
||||
assert path.is_file()
|
||||
assert read_mapping_file(path) == "newperson"
|
||||
assert "# PR #999 salvage" in path.read_text()
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def test_add_refuses_login_conflicting_with_legacy_map(emails_dir):
|
||||
email, login = next(iter(release.LEGACY_AUTHOR_MAP.items()))
|
||||
assert add_contributor(email, login + "x") == 1
|
||||
assert not (emails_dir / email).exists()
|
||||
|
||||
|
||||
|
||||
|
||||
def test_add_accepts_legacy_consecutive_hyphen_login(emails_dir):
|
||||
# Legacy GitHub accounts with consecutive hyphens are real (Roger--Han);
|
||||
# current signup rules forbid them but existing logins remain valid.
|
||||
assert add_contributor("roger.hanhong@gmail.com", "Roger--Han") == 0
|
||||
assert (emails_dir / "roger.hanhong@gmail.com").read_text(
|
||||
encoding="utf-8"
|
||||
).strip().endswith("Roger--Han")
|
||||
|
||||
|
||||
def test_add_strips_at_prefix(emails_dir):
|
||||
assert add_contributor("z@z.com", "@zeta") == 0
|
||||
assert read_mapping_file(emails_dir / "z@z.com") == "zeta"
|
||||
|
||||
|
||||
def test_cli_entrypoint_end_to_end(tmp_path):
|
||||
# Run the real script in a subprocess against a temp repo layout.
|
||||
scripts = tmp_path / "scripts"
|
||||
scripts.mkdir()
|
||||
for name in ("add_contributor.py",):
|
||||
# Explicit encoding: add_contributor.py contains UTF-8 multi-byte
|
||||
# characters (an em dash), so the locale-default read_text() raises
|
||||
# UnicodeDecodeError on non-UTF-8 Windows locales (e.g. cp950).
|
||||
(scripts / name).write_text(
|
||||
(SCRIPTS_DIR / name).read_text(encoding="utf-8"), encoding="utf-8"
|
||||
)
|
||||
# Minimal stub release.py so the legacy lookup import works
|
||||
(scripts / "release.py").write_text("LEGACY_AUTHOR_MAP = {}\n")
|
||||
proc = subprocess.run(
|
||||
[sys.executable, str(scripts / "add_contributor.py"),
|
||||
"cli@example.com", "cliperson", "via subprocess"],
|
||||
cwd=tmp_path, capture_output=True, text=True,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
out = (tmp_path / "contributors" / "emails" / "cli@example.com").read_text(encoding="utf-8")
|
||||
assert out.splitlines()[0] == "cliperson"
|
||||
|
||||
|
||||
# ── case-insensitive filename collisions ──────────────────────────────
|
||||
#
|
||||
# The mapping key IS the filename, so two emails differing only in case are the
|
||||
# same file on Windows and on default macOS. When both exist, git writes one and
|
||||
# then reports the other as modified in a FRESH clone, permanently: the repo can
|
||||
# never be checked out clean on those platforms.
|
||||
#
|
||||
# The historical agent@Agents-Mac-mini.local / agent@agents-Mac-mini.local pair
|
||||
# was removed from the tree (fcdae2cf0b), so there is no allowlist: any pair
|
||||
# is a regression. scripts/check-case-collisions.py enforces the same
|
||||
# invariant repo-wide in CI; this test keeps it visible next to the writer.
|
||||
EMAILS_DIR = REPO_ROOT / "contributors" / "emails"
|
||||
|
||||
|
||||
def test_no_case_insensitive_mapping_collisions():
|
||||
groups: dict[str, set[str]] = {}
|
||||
for entry in EMAILS_DIR.iterdir():
|
||||
if entry.is_file():
|
||||
groups.setdefault(entry.name.casefold(), set()).add(entry.name)
|
||||
|
||||
collisions = {frozenset(names) for names in groups.values() if len(names) > 1}
|
||||
|
||||
assert not collisions, (
|
||||
"contributor mappings differing only in case cannot coexist on "
|
||||
"case-insensitive filesystems (Windows, default macOS) — a fresh clone "
|
||||
f"there is permanently dirty: {sorted(sorted(c) for c in collisions)}"
|
||||
)
|
||||
|
||||
|
||||
def test_add_contributor_refuses_a_case_collision(tmp_path, monkeypatch):
|
||||
d = tmp_path / "emails"
|
||||
d.mkdir()
|
||||
(d / "agent@Example-Host.local").write_text("someone\n")
|
||||
|
||||
import add_contributor as mod
|
||||
|
||||
monkeypatch.setattr(mod, "EMAILS_DIR", d)
|
||||
|
||||
assert mod.add_contributor("agent@example-host.local", "otherperson") == 1
|
||||
assert not (d / "agent@example-host.local").exists()
|
||||
|
||||
|
||||
def test_add_contributor_refuses_case_collision_even_for_same_login(emails_dir, capsys):
|
||||
# Same login, different spelling: still refused — the problem is the
|
||||
# filename pair, not the login. The exact spelling is what's "present".
|
||||
emails_dir.mkdir(parents=True)
|
||||
(emails_dir / "Foo@Example.com").write_text("foouser\n")
|
||||
|
||||
assert add_contributor("foo@example.com", "foouser") == 1
|
||||
assert "Foo@Example.com" in capsys.readouterr().err
|
||||
assert sorted(p.name for p in emails_dir.iterdir()) == ["Foo@Example.com"]
|
||||
# Exact-case re-add is the ordinary idempotent path.
|
||||
assert add_contributor("Foo@Example.com", "foouser") == 0
|
||||
|
||||
|
||||
def test_case_collision_uses_casefold(emails_dir):
|
||||
# casefold, not lower: matches how macOS/Windows fold non-ASCII (ß ~ ss).
|
||||
emails_dir.mkdir(parents=True)
|
||||
(emails_dir / "strasse@example.com").write_text("someone\n")
|
||||
assert add_contributor("STRASSE@example.com", "someone") == 1
|
||||
assert add_contributor("straße@example.com", "someone") == 1
|
||||
@@ -0,0 +1,234 @@
|
||||
"""Tests for the ``subprocess text=True without explicit encoding=`` footgun
|
||||
rule in ``scripts/check-windows-footguns.py``.
|
||||
|
||||
This rule (added alongside PR #60741) catches ``subprocess.run/Popen/call/
|
||||
check_output/check_call(..., text=True, ...)`` calls that don't pass an
|
||||
explicit ``encoding=``. On Chinese Windows (cp936/GBK) and other non-UTF-8
|
||||
default codepages, ``text=True`` without ``encoding=`` decodes child output
|
||||
with ``locale.getpreferredencoding(False)`` and crashes ``_readerthread``
|
||||
with ``UnicodeDecodeError`` on non-default-codepage bytes.
|
||||
|
||||
See issues #47939, #53428, #57238.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
LINTER_PATH = REPO_ROOT / "scripts" / "check-windows-footguns.py"
|
||||
|
||||
|
||||
def _load_linter_module():
|
||||
"""Import the linter script as a module (it's not a package).
|
||||
|
||||
Register the module in sys.modules BEFORE exec_module so that
|
||||
``@dataclass`` can resolve ``cls.__module__`` via
|
||||
``sys.modules.get(cls.__module__).__dict__`` (CPython 3.11+ dataclass
|
||||
internals require this).
|
||||
"""
|
||||
spec = importlib.util.spec_from_file_location("check_windows_footguns", LINTER_PATH)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
sys.modules["check_windows_footguns"] = mod
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def linter():
|
||||
return _load_linter_module()
|
||||
|
||||
|
||||
def _find_footgun(linter, name: str):
|
||||
"""Locate a Footgun by name in the FOOTGUNS list."""
|
||||
for fg in linter.FOOTGUNS:
|
||||
if fg.name == name:
|
||||
return fg
|
||||
pytest.fail(f"Footgun rule '{name}' not found in FOOTGUNS")
|
||||
|
||||
|
||||
def _scan_line(linter, line: str, footgun_name: str) -> bool:
|
||||
"""Return True if the given line triggers the named footgun rule.
|
||||
|
||||
Uses the linter's own pattern + post_filter logic so the test exercises
|
||||
the real detection path (including guard-hint and suppression checks).
|
||||
"""
|
||||
fg = _find_footgun(linter, footgun_name)
|
||||
# Replicate the relevant checks from scan_file(): suppression marker,
|
||||
# guard hints, then pattern + post_filter.
|
||||
if linter.SUPPRESS_MARKER.search(line):
|
||||
return False
|
||||
if any(hint in line for hint in linter.GUARD_HINTS):
|
||||
return False
|
||||
code = linter._strip_code(line)
|
||||
if not code.strip():
|
||||
return False
|
||||
match = fg.pattern.search(code)
|
||||
if not match:
|
||||
return False
|
||||
if fg.post_filter is not None:
|
||||
try:
|
||||
if not fg.post_filter(match, line):
|
||||
return False
|
||||
except (IndexError, AttributeError):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
RULE_NAME = "subprocess text=True without explicit encoding="
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Detection — these SHOULD be flagged
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDetection:
|
||||
|
||||
|
||||
def test_flags_subprocess_check_output_text_true(self, linter):
|
||||
line = ' out = subprocess.check_output(["git", "status"], text=True)'
|
||||
assert _scan_line(linter, line, RULE_NAME)
|
||||
|
||||
|
||||
def test_flags_text_with_spaces_around_equals(self, linter):
|
||||
line = ' subprocess.run(cmd, text = True, timeout=10)'
|
||||
assert _scan_line(linter, line, RULE_NAME)
|
||||
|
||||
def test_flags_bare_run_call(self, linter):
|
||||
# .run( without explicit subprocess. prefix — still a subprocess call
|
||||
line = ' result = obj.run(cmd, text=True)'
|
||||
assert _scan_line(linter, line, RULE_NAME)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Suppression — these should NOT be flagged
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSuppression:
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def test_does_not_flag_comment_only_line(self, linter):
|
||||
line = ' # subprocess.run(cmd, text=True) — example'
|
||||
assert not _scan_line(linter, line, RULE_NAME)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helper functions — unit tests for _is_likely_subprocess_call and
|
||||
# _looks_like_string_literal
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestHelpers:
|
||||
def test_is_likely_subprocess_call_matches_subprocess_run(self, linter):
|
||||
assert linter._is_likely_subprocess_call("subprocess.run(cmd, text=True)")
|
||||
|
||||
|
||||
|
||||
def test_is_likely_subprocess_call_rejects_plain_assignment(self, linter):
|
||||
assert not linter._is_likely_subprocess_call("config.text = True")
|
||||
|
||||
def test_looks_like_string_literal_double_quotes(self, linter):
|
||||
import re
|
||||
line = ' msg = "use text=True carefully"'
|
||||
match = re.search(r"\btext\s*=\s*True\b", line)
|
||||
assert match is not None
|
||||
assert linter._looks_like_string_literal(line, match)
|
||||
|
||||
|
||||
def test_looks_like_string_literal_false_for_real_code(self, linter):
|
||||
import re
|
||||
line = ' subprocess.run(cmd, text=True)'
|
||||
match = re.search(r"\btext\s*=\s*True\b", line)
|
||||
assert match is not None
|
||||
assert not linter._looks_like_string_literal(line, match)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Full-repo scan — after PR #60741 merges, the new rule should find ZERO
|
||||
# unsuppressed violations in the whole tree (excluding the linter itself
|
||||
# and CONTRIBUTING docs). This test will FAIL until PR #60741 is merged;
|
||||
# mark it xfail when run on a branch that doesn't include PR #60741's fixes.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFullRepoScan:
|
||||
def test_new_rule_find_only_known_violations(self, linter, monkeypatch):
|
||||
"""Scan the full repo and assert the new rule's matches are exactly
|
||||
the set of call sites that PR #60741 fixes (or zero, if PR #60741
|
||||
is already merged into this branch).
|
||||
|
||||
This is a regression guard: if someone adds a new
|
||||
``subprocess.run(text=True)`` without ``encoding=``, this test
|
||||
catches it.
|
||||
"""
|
||||
# The 7 call sites that PR #60741 fixes. If PR #60741 is merged
|
||||
# into this branch, this set should be empty. If not, these are
|
||||
# the expected matches.
|
||||
pr_60741_sites = {
|
||||
"hermes_cli/main.py",
|
||||
"hermes_cli/onepassword_secrets_cli.py",
|
||||
"hermes_cli/setup.py",
|
||||
"tools/transcription_tools.py",
|
||||
"tools/tts_tool.py",
|
||||
}
|
||||
|
||||
# Run the full scan
|
||||
roots = [
|
||||
REPO_ROOT / "hermes_cli",
|
||||
REPO_ROOT / "gateway",
|
||||
REPO_ROOT / "tools",
|
||||
REPO_ROOT / "cron",
|
||||
REPO_ROOT / "agent",
|
||||
REPO_ROOT / "plugins",
|
||||
REPO_ROOT / "scripts",
|
||||
REPO_ROOT / "acp_adapter",
|
||||
REPO_ROOT / "acp_registry",
|
||||
]
|
||||
roots = [r for r in roots if r.exists()]
|
||||
|
||||
fg = _find_footgun(linter, RULE_NAME)
|
||||
new_rule_matches: dict[str, list[int]] = {}
|
||||
|
||||
for path in linter.iter_files(roots):
|
||||
matches = linter.scan_file(path, [fg]) # scan with ONLY the new rule
|
||||
if matches:
|
||||
rel = path.relative_to(REPO_ROOT).as_posix()
|
||||
new_rule_matches[rel] = [m[0] for m in matches]
|
||||
|
||||
# Determine which sites remain. PR #60741's fixes are on a separate
|
||||
# branch; if this branch doesn't include them, the 7 call sites
|
||||
# will still be flagged — that's expected, not a failure.
|
||||
if new_rule_matches:
|
||||
# Filter out the linter itself (it mentions text=True in its
|
||||
# own pattern/message, but EXCLUDED_FILES handles that for the
|
||||
# CLI entry point; the helper functions could trip it).
|
||||
new_rule_matches = {
|
||||
k: v for k, v in new_rule_matches.items()
|
||||
if k != "scripts/check-windows-footguns.py"
|
||||
}
|
||||
|
||||
if not new_rule_matches:
|
||||
# PR #60741 already merged — clean tree. This is the goal state.
|
||||
return
|
||||
|
||||
# Matches remain — they must be exactly the PR #60741 sites.
|
||||
matched_files = set(new_rule_matches.keys())
|
||||
unexpected = matched_files - pr_60741_sites
|
||||
if unexpected:
|
||||
pytest.fail(
|
||||
f"New footgun rule found UNEXPECTED matches in files not "
|
||||
f"covered by PR #60741: {sorted(unexpected)}.\n"
|
||||
f"These are either new regressions or call sites that need "
|
||||
f"a `# windows-footgun: ok` suppression."
|
||||
)
|
||||
# All matches are the expected PR #60741 sites — OK on this branch.
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Tests for the shared-metrics smoke artifact."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from scripts import smoke_nemo_relay_shared_metrics as smoke
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"relative_path",
|
||||
[
|
||||
Path(".venv") / "bin" / "hermes",
|
||||
Path(".venv") / "Scripts" / "hermes.exe",
|
||||
],
|
||||
)
|
||||
def test_resolve_hermes_executable_from_repository_venv(
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
relative_path,
|
||||
):
|
||||
executable = tmp_path / relative_path
|
||||
executable.parent.mkdir(parents=True)
|
||||
executable.touch()
|
||||
monkeypatch.setattr(smoke.shutil, "which", lambda _name: None)
|
||||
|
||||
assert smoke._resolve_hermes_executable(tmp_path) == executable
|
||||
|
||||
|
||||
def test_resolve_hermes_executable_falls_back_to_path(tmp_path, monkeypatch):
|
||||
executable = tmp_path / "bin" / "hermes"
|
||||
monkeypatch.setattr(smoke.shutil, "which", lambda _name: str(executable))
|
||||
|
||||
assert smoke._resolve_hermes_executable(tmp_path / "repo") == executable
|
||||
|
||||
|
||||
def test_resolve_hermes_executable_reports_missing_binary(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(smoke.shutil, "which", lambda _name: None)
|
||||
|
||||
with pytest.raises(SystemExit, match="or on PATH"):
|
||||
smoke._resolve_hermes_executable(tmp_path)
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Full-repo self-scan wrapper for scripts/check-windows-footguns.py.
|
||||
|
||||
scripts/check_subprocess_stdin.py has had a pytest wrapper (see
|
||||
tests/tools/test_subprocess_stdin_guard.py's test_all_tui_subprocess_calls_
|
||||
have_stdin) that runs the checker with its default full-scan behavior and
|
||||
asserts a clean exit — so a normal pytest run of that file catches
|
||||
regressions even when no one remembers to run the standalone script by hand.
|
||||
check-windows-footguns.py had no equivalent: only a narrow rule-level test
|
||||
(tests/scripts/test_footgun_subprocess_encoding.py, scoped to the
|
||||
text=True/encoding= rule) existed, so a bare ``os.killpg``/``signal.SIGKILL``
|
||||
regression (caught by CI running the real script with --all, not by any
|
||||
local pytest run) shipped in the T1-T3 npx-agent-browser hardening commit
|
||||
before anyone ran the script directly. This closes that gap the same way
|
||||
the stdin guard already closes its equivalent one.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
SCRIPT = REPO_ROOT / "scripts" / "check-windows-footguns.py"
|
||||
|
||||
|
||||
def test_full_repo_scan_has_no_unsuppressed_windows_footguns():
|
||||
"""Mirrors check_subprocess_stdin.py's wrapper: run the real checker
|
||||
against the whole repo (--all) and require a clean exit, so this test
|
||||
file — not just institutional memory — is what catches the next
|
||||
bare os.killpg/signal.SIGKILL-style regression."""
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(SCRIPT), "--all"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
stdin=subprocess.DEVNULL,
|
||||
)
|
||||
assert result.returncode == 0, (
|
||||
f"Windows footgun check failed:\n{result.stdout}\n{result.stderr}"
|
||||
)
|
||||
Reference in New Issue
Block a user