hermes-agent/tests/lints/test_npmrc_stale_exclude.py

382 lines
13 KiB
Python

"""Fixture tests for the npmrc-stale-exclude lint. No live network —
registry responses are injected via the ``fetch_times`` parameter."""
from __future__ import annotations
import json
import sys
from datetime import datetime, timedelta, timezone
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(REPO_ROOT / "scripts"))
from lints import npmrc_stale_exclude as mod # noqa: E402
NOW = datetime(2026, 8, 8, 12, 0, tzinfo=timezone.utc)
OLD = (NOW - timedelta(days=40)).isoformat() # far past 14d + grace
FRESH = (NOW - timedelta(days=3)).isoformat() # inside 14d
BOUNDARY = (NOW - timedelta(days=14, hours=12)).isoformat() # in the grace band
def _write_lockfile(path: Path, versions: dict[str, str]) -> None:
packages = {"": {"name": "fixture", "version": "0.0.0"}}
for name, version in versions.items():
packages[f"node_modules/{name}"] = {"version": version}
path.write_text(json.dumps({"lockfileVersion": 3, "packages": packages}), encoding="utf-8")
def _registry(times: dict[str, dict[str, str]]):
def fetch(name: str) -> dict[str, str]:
if name not in times:
raise RuntimeError(f"unexpected registry fetch for {name}")
return times[name]
return fetch
def _evaluate(tmp_path, npmrc_text, lock_versions, registry_times):
npmrc = tmp_path / ".npmrc"
npmrc.write_text(npmrc_text, encoding="utf-8")
lockfile = tmp_path / "package-lock.json"
_write_lockfile(lockfile, lock_versions)
return npmrc, mod.evaluate_file(npmrc, lockfile, _registry(registry_times), now=NOW)
# ── parse_npmrc ──────────────────────────────────────────────────────────
def test_parse_groups_comment_blocks_and_entries():
text = (
"engine-strict=true\n"
"\n"
"min-release-age=14\n"
"# vuln fix. remove when old\n"
"min-release-age-exclude[]=tar\n"
"\n"
"# lint: keep\n"
"# ink needs\n"
"min-release-age-exclude[]=lightningcss\n"
"min-release-age-exclude[]=postcss\n"
)
min_age, groups = mod.parse_npmrc(text)
assert min_age == 14
assert len(groups) == 2
assert [p for _, p in groups[0].entries] == ["tar"]
assert groups[0].keep is False
assert [p for _, p in groups[1].entries] == ["lightningcss", "postcss"]
assert groups[1].keep is True
def test_parse_real_repo_npmrc_files():
"""Both tracked .npmrc files must parse (invariant, not a snapshot)."""
for rel in (".npmrc", "website/.npmrc"):
text = (REPO_ROOT / rel).read_text(encoding="utf-8")
min_age, groups = mod.parse_npmrc(text)
assert min_age is not None, rel
assert groups, rel
for group in groups:
assert group.entries, rel
# ── locked_versions ──────────────────────────────────────────────────────
def test_locked_versions_handles_nested_and_scoped(tmp_path):
lockfile = tmp_path / "package-lock.json"
lockfile.write_text(
json.dumps(
{
"lockfileVersion": 3,
"packages": {
"": {"name": "root", "version": "1.0.0"},
"node_modules/@scope/pkg": {"version": "1.0.0"},
"node_modules/a": {"version": "2.0.0"},
"node_modules/a/node_modules/b": {"version": "3.0.0"},
"apps/x/node_modules/a": {"version": "2.5.0"},
},
}
),
encoding="utf-8",
)
locked = mod.locked_versions(lockfile)
assert locked["@scope/pkg"] == {"1.0.0"}
assert locked["a"] == {"2.0.0", "2.5.0"} # both install paths collected
assert locked["b"] == {"3.0.0"}
# ── evaluate_file ────────────────────────────────────────────────────────
BASE = "min-release-age=14\n# fix. remove when > 2wks old\nmin-release-age-exclude[]=tar\n"
def test_stale_when_all_locked_versions_old(tmp_path):
_, stale = _evaluate(tmp_path, BASE, {"tar": "7.5.21"}, {"tar": {"7.5.21": OLD}})
assert len(stale) == 1
assert stale[0][1] == "tar"
assert "older than min-release-age" in stale[0][2]
def test_fresh_version_keeps_exclude(tmp_path):
_, stale = _evaluate(tmp_path, BASE, {"tar": "7.5.21"}, {"tar": {"7.5.21": FRESH}})
assert stale == []
def test_boundary_grace_keeps_exclude(tmp_path):
"""Just past min-release-age but inside the 1-day grace: keep, to
avoid remove/re-add churn across runs."""
_, stale = _evaluate(tmp_path, BASE, {"tar": "7.5.21"}, {"tar": {"7.5.21": BOUNDARY}})
assert stale == []
def test_one_fresh_version_among_old_keeps_exclude(tmp_path):
"""A name locked at several versions (nested deps): any fresh one
means the exclude is still doing work."""
npmrc = "min-release-age=14\nmin-release-age-exclude[]=nanoid\n"
lockfile = tmp_path / "package-lock.json"
lockfile.write_text(
json.dumps(
{
"lockfileVersion": 3,
"packages": {
"node_modules/nanoid": {"version": "5.0.0"},
"node_modules/x/node_modules/nanoid": {"version": "3.3.17"},
},
}
),
encoding="utf-8",
)
path = tmp_path / ".npmrc"
path.write_text(npmrc, encoding="utf-8")
stale = mod.evaluate_file(
path,
lockfile,
_registry({"nanoid": {"5.0.0": OLD, "3.3.17": FRESH}}),
now=NOW,
)
assert stale == []
def test_wildcard_scope_expands_against_lockfile(tmp_path):
npmrc = "min-release-age=14\nmin-release-age-exclude[]=@radix-ui/*\n"
_, stale = _evaluate(
tmp_path,
npmrc,
{"@radix-ui/react-menu": "2.0.0", "@radix-ui/number": "1.1.3"},
{
"@radix-ui/react-menu": {"2.0.0": OLD},
"@radix-ui/number": {"1.1.3": OLD},
},
)
assert len(stale) == 1 and stale[0][1] == "@radix-ui/*"
def test_wildcard_with_one_fresh_member_kept(tmp_path):
npmrc = "min-release-age=14\nmin-release-age-exclude[]=@radix-ui/*\n"
_, stale = _evaluate(
tmp_path,
npmrc,
{"@radix-ui/react-menu": "2.0.0", "@radix-ui/number": "1.1.3"},
{
"@radix-ui/react-menu": {"2.0.0": OLD},
"@radix-ui/number": {"1.1.3": FRESH},
},
)
assert stale == []
def test_no_lockfile_match_is_stale(tmp_path):
npmrc = "min-release-age=14\nmin-release-age-exclude[]=left-pad\n"
_, stale = _evaluate(tmp_path, npmrc, {"tar": "7.5.21"}, {})
assert len(stale) == 1
assert "matches no package" in stale[0][2]
def test_keep_marker_exempts_group(tmp_path):
npmrc = (
"min-release-age=14\n"
"# lint: keep — they update a LOT\n"
"min-release-age-exclude[]=@assistant-ui/*\n"
)
_, stale = _evaluate(
tmp_path,
npmrc,
{"@assistant-ui/react": "1.0.0"},
{"@assistant-ui/react": {"1.0.0": OLD}},
)
assert stale == []
def test_registry_failure_fails_open(tmp_path):
def exploding_fetch(name):
raise OSError("registry down")
npmrc_path = tmp_path / ".npmrc"
npmrc_path.write_text(BASE, encoding="utf-8")
lockfile = tmp_path / "package-lock.json"
_write_lockfile(lockfile, {"tar": "7.5.21"})
stale = mod.evaluate_file(npmrc_path, lockfile, exploding_fetch, now=NOW)
assert stale == []
def test_unknown_publish_date_fails_open(tmp_path):
"""Version missing from the registry's time map counts as young."""
_, stale = _evaluate(tmp_path, BASE, {"tar": "7.5.21"}, {"tar": {}})
assert stale == []
def test_missing_lockfile_yields_nothing(tmp_path):
npmrc = tmp_path / ".npmrc"
npmrc.write_text(BASE, encoding="utf-8")
stale = mod.evaluate_file(
npmrc, tmp_path / "package-lock.json", _registry({}), now=NOW
)
assert stale == []
# ── remove_stale_lines ───────────────────────────────────────────────────
def test_removal_deletes_line_and_orphaned_comment_block():
text = (
"min-release-age=14\n"
"\n"
"# tar fix. remove when > 2wks old\n"
"min-release-age-exclude[]=tar\n"
"\n"
"# fresh fix, still needed\n"
"min-release-age-exclude[]=nanoid\n"
)
_, groups = mod.parse_npmrc(text)
tar_idx = next(i for g in groups for i, p in g.entries if p == "tar")
result = mod.remove_stale_lines(text, {tar_idx})
assert "tar" not in result
assert "# tar fix" not in result # orphaned comment went with it
assert "min-release-age-exclude[]=nanoid" in result
assert "# fresh fix, still needed" in result
assert "\n\n\n" not in result # no double-blank residue
def test_removal_keeps_comment_when_group_partially_survives():
text = (
"min-release-age=14\n"
"# vite chain — remove once vite is old\n"
"min-release-age-exclude[]=vite\n"
"min-release-age-exclude[]=rolldown\n"
)
_, groups = mod.parse_npmrc(text)
vite_idx = next(i for g in groups for i, p in g.entries if p == "vite")
result = mod.remove_stale_lines(text, {vite_idx})
assert "min-release-age-exclude[]=vite\n" not in result
assert "min-release-age-exclude[]=rolldown" in result
assert "# vite chain" in result # block still owns a live entry
def test_removal_preserves_unrelated_directives():
text = (
"engine-strict=true\n"
"\n"
"min-release-age=14\n"
"min-release-age-exclude[]=tar\n"
)
_, groups = mod.parse_npmrc(text)
tar_idx = groups[0].entries[0][0]
result = mod.remove_stale_lines(text, {tar_idx})
assert "engine-strict=true" in result
assert "min-release-age=14" in result
assert "exclude" not in result
# ── _verify_only_exclude_deletions ───────────────────────────────────────
def test_verify_accepts_pure_exclude_deletion():
old = "min-release-age=14\n# c\nmin-release-age-exclude[]=tar\nengine-strict=true\n"
new = "min-release-age=14\nengine-strict=true\n"
mod._verify_only_exclude_deletions(old, new) # no raise
def test_verify_rejects_deleting_other_directives():
old = "min-release-age=14\nengine-strict=true\n"
new = "min-release-age=14\n"
try:
mod._verify_only_exclude_deletions(old, new)
except RuntimeError as e:
assert "non-exclude directive" in str(e)
else:
raise AssertionError("should have refused to delete engine-strict")
def test_verify_rejects_edited_or_inserted_lines():
old = "min-release-age=14\n"
for new in ("min-release-age=7\n", "min-release-age=14\nregistry=https://evil\n"):
try:
mod._verify_only_exclude_deletions(old, new)
except RuntimeError as e:
assert "not present in the original" in str(e)
else:
raise AssertionError(f"should have refused: {new!r}")
# ── end-to-end: check()/fix() against a fixture repo ─────────────────────
def test_fix_roundtrip(tmp_path, monkeypatch):
npmrc = tmp_path / ".npmrc"
npmrc.write_text(
"min-release-age=14\n"
"# stale one\n"
"min-release-age-exclude[]=tar\n"
"# fresh one\n"
"min-release-age-exclude[]=nanoid\n",
encoding="utf-8",
)
_write_lockfile(tmp_path / "package-lock.json", {"tar": "7.5.21", "nanoid": "3.3.17"})
monkeypatch.setattr(mod, "REPO_ROOT", tmp_path)
monkeypatch.setattr(mod, "_tracked_npmrc_files", lambda: [npmrc])
monkeypatch.setattr(
mod,
"_fetch_times",
_registry({"tar": {"7.5.21": OLD}, "nanoid": {"3.3.17": FRESH}}),
)
# evaluate_file defaults now to real utcnow; OLD/FRESH are relative to
# NOW which is in the past — regenerate stamps relative to real now.
real_now = datetime.now(timezone.utc)
monkeypatch.setattr(
mod,
"_fetch_times",
_registry(
{
"tar": {"7.5.21": (real_now - timedelta(days=40)).isoformat()},
"nanoid": {"3.3.17": (real_now - timedelta(days=3)).isoformat()},
}
),
)
findings = mod.check()
assert [f.path for f in findings] == [".npmrc"]
assert all(f.fixable for f in findings)
assert "tar" in findings[0].message
changed = mod.fix()
assert changed == [".npmrc"]
text = npmrc.read_text(encoding="utf-8")
assert "tar" not in text
assert "min-release-age-exclude[]=nanoid" in text
# Idempotent: second pass finds nothing, changes nothing.
assert mod.check() == []
assert mod.fix() == []
def test_lint_metadata_contract():
"""The lint must be network-gated and autofix-bounded to .npmrc."""
lint = mod.LINT
assert lint.network is True # advisory on PRs by policy
assert lint.autofix is True
from fnmatch import fnmatch
for path in (".npmrc", "website/.npmrc"):
assert any(fnmatch(path, g) for g in lint.fix_touches), path