mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-08 11:46:35 +00:00
Strip every `req <NNN>`, `req-<NNN>`, `Requirement <NNN>`, `requirement-<NNN>`, and `docs/requirements/<NNN>-...md` mention from non-Markdown source files. Comments anchored to requirement numbers rot when requirements are renumbered or rewritten and conflate present-state behaviour (which the comment should describe) with the audit trail of how the code got there (which belongs in commit messages, PR descriptions, and the requirement file itself). * Document the rule in `docs/contributing/documentation.md` under "Comments". Code MUST describe the *why* in present-state language; contract enforcement lives in tests, not folklore. * Clean 254 source files across `roles/`, `tests/`, `plugins/`, `cli/`, `scripts/`, `utils/`, `group_vars/`, and the repo-root `eslint.config.js`. Pure comment / docstring / log-message edits; no behavioural change. * Add `tests/lint/repository/test_no_req_references_in_code.py` to enforce the rule going forward across `.js`, `.py`, `.yml`, `.yaml`, `.j2`, and `.sh` files. The legacy `test_no_stale_requirement_refs.py` is exempt as the only file whose purpose is to detect these very tokens. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
282 lines
7.9 KiB
Python
282 lines
7.9 KiB
Python
import shutil
|
||
import sys
|
||
import tempfile
|
||
import unittest
|
||
from pathlib import Path
|
||
from textwrap import dedent
|
||
|
||
from . import PROJECT_ROOT
|
||
|
||
if str(PROJECT_ROOT) not in sys.path:
|
||
sys.path.insert(0, str(PROJECT_ROOT))
|
||
|
||
from utils.roles.dependency_resolver import RoleDependencyResolver
|
||
from utils.roles.mapping import (
|
||
ROLE_FILE_META_MAIN,
|
||
ROLE_FILE_META_SERVICES,
|
||
ROLE_FILE_TASKS_MAIN,
|
||
)
|
||
|
||
|
||
def write(path: str, content: str):
|
||
Path(str(Path(path).parent)).mkdir(parents=True, exist_ok=True)
|
||
with Path(path).open("w", encoding="utf-8") as f:
|
||
f.write(dedent(content).lstrip())
|
||
|
||
|
||
def make_role(roles_dir: str, name: str):
|
||
path = str(Path(roles_dir) / name)
|
||
Path(path).mkdir(parents=True, exist_ok=True)
|
||
Path(str(Path(path) / "tasks")).mkdir(parents=True, exist_ok=True)
|
||
Path(str(Path(path) / "meta")).mkdir(parents=True, exist_ok=True)
|
||
return path
|
||
|
||
|
||
class TestRoleDependencyResolver(unittest.TestCase):
|
||
def setUp(self):
|
||
self.roles_dir = tempfile.mkdtemp(prefix="roles_")
|
||
|
||
def tearDown(self):
|
||
shutil.rmtree(self.roles_dir, ignore_errors=True)
|
||
|
||
# ----------------------------- TESTS -----------------------------
|
||
|
||
def test_include_and_import_literal(self):
|
||
"""
|
||
A/tasks/main.yml:
|
||
- include_role: { name: B }
|
||
- import_role: { name: C }
|
||
Expect: deps = {B, C}
|
||
"""
|
||
make_role(self.roles_dir, "A")
|
||
make_role(self.roles_dir, "B")
|
||
make_role(self.roles_dir, "C")
|
||
|
||
write(
|
||
str(Path(self.roles_dir) / "A" / ROLE_FILE_TASKS_MAIN),
|
||
"""
|
||
- name: include B
|
||
include_role:
|
||
name: B
|
||
|
||
- name: import C
|
||
import_role:
|
||
name: C
|
||
""",
|
||
)
|
||
|
||
r = RoleDependencyResolver(self.roles_dir)
|
||
deps = r.get_role_dependencies("A")
|
||
self.assertEqual(deps, {"B", "C"})
|
||
|
||
def test_loop_with_string_items_and_dict_items(self):
|
||
"""
|
||
A/tasks/main.yml uses loop with strings and dicts.
|
||
Expect: {D, E, F, G}
|
||
"""
|
||
make_role(self.roles_dir, "A")
|
||
for rn in ["D", "E", "F", "G"]:
|
||
make_role(self.roles_dir, rn)
|
||
|
||
write(
|
||
str(Path(self.roles_dir) / "A" / ROLE_FILE_TASKS_MAIN),
|
||
"""
|
||
- name: loop over strings → D, E
|
||
include_role:
|
||
name: "{{ item }}"
|
||
loop:
|
||
- D
|
||
- E
|
||
|
||
- name: loop over dicts → F, G
|
||
import_role:
|
||
name: "{{ item.role }}"
|
||
with_items:
|
||
- { role: "F" }
|
||
- { role: "G" }
|
||
""",
|
||
)
|
||
|
||
r = RoleDependencyResolver(self.roles_dir)
|
||
deps = r.get_role_dependencies("A")
|
||
self.assertEqual(deps, {"D", "E", "F", "G"})
|
||
|
||
def test_pure_jinja_ignored_without_loop(self):
|
||
"""
|
||
name: "{{ something }}" with no loop should be ignored.
|
||
"""
|
||
make_role(self.roles_dir, "A")
|
||
for rn in ["X", "Y"]:
|
||
make_role(self.roles_dir, rn)
|
||
|
||
write(
|
||
str(Path(self.roles_dir) / "A" / ROLE_FILE_TASKS_MAIN),
|
||
"""
|
||
- name: pure var ignored
|
||
include_role:
|
||
name: "{{ something }}"
|
||
""",
|
||
)
|
||
|
||
r = RoleDependencyResolver(self.roles_dir)
|
||
deps = r.get_role_dependencies("A")
|
||
self.assertEqual(deps, set())
|
||
|
||
def test_meta_dependencies_strings_and_dicts(self):
|
||
"""
|
||
meta/main.yml:
|
||
dependencies:
|
||
- H
|
||
- { role: I }
|
||
Expect: {H, I}
|
||
"""
|
||
make_role(self.roles_dir, "A")
|
||
make_role(self.roles_dir, "H")
|
||
make_role(self.roles_dir, "I")
|
||
|
||
write(
|
||
str(Path(self.roles_dir) / "A" / ROLE_FILE_META_MAIN),
|
||
"""
|
||
---
|
||
dependencies:
|
||
- H
|
||
- { role: I }
|
||
""",
|
||
)
|
||
|
||
r = RoleDependencyResolver(self.roles_dir)
|
||
deps = r.get_role_dependencies("A")
|
||
self.assertEqual(deps, {"H", "I"})
|
||
|
||
def test_run_after_extraction_and_toggle(self):
|
||
"""
|
||
galaxy_info.run_after is only included when resolve_run_after=True
|
||
"""
|
||
make_role(self.roles_dir, "A")
|
||
make_role(self.roles_dir, "J")
|
||
make_role(self.roles_dir, "K")
|
||
|
||
# Per run_after lives at
|
||
# meta/services.yml.<primary_entity>.run_after; for "A" the entity
|
||
# name equals the role name.
|
||
write(
|
||
str(Path(self.roles_dir) / "A" / ROLE_FILE_META_SERVICES),
|
||
"""
|
||
---
|
||
A:
|
||
run_after:
|
||
- J
|
||
- K
|
||
""",
|
||
)
|
||
write(
|
||
str(Path(self.roles_dir) / "A" / ROLE_FILE_META_MAIN),
|
||
"""
|
||
---
|
||
dependencies: []
|
||
""",
|
||
)
|
||
|
||
r = RoleDependencyResolver(self.roles_dir)
|
||
|
||
# Direkter Helper
|
||
ra = r._extract_meta_run_after(str(Path(self.roles_dir) / "A"))
|
||
self.assertEqual(ra, {"J", "K"})
|
||
|
||
# Transitiv – off by default
|
||
visited_off = r.resolve_transitively(["A"], resolve_run_after=False)
|
||
self.assertNotIn("J", visited_off)
|
||
self.assertNotIn("K", visited_off)
|
||
|
||
# Transitiv – enabled
|
||
visited_on = r.resolve_transitively(["A"], resolve_run_after=True)
|
||
self.assertTrue({"A", "J", "K"}.issubset(visited_on))
|
||
|
||
def test_cycle_and_max_depth(self):
|
||
"""
|
||
A → include B
|
||
B → import A
|
||
- Ensure cycle-safe traversal.
|
||
- max_depth=0 → only start
|
||
- max_depth=1 → start + direct deps
|
||
"""
|
||
make_role(self.roles_dir, "A")
|
||
make_role(self.roles_dir, "B")
|
||
|
||
write(
|
||
str(Path(self.roles_dir) / "A" / ROLE_FILE_TASKS_MAIN),
|
||
"""
|
||
- include_role:
|
||
name: B
|
||
""",
|
||
)
|
||
write(
|
||
str(Path(self.roles_dir) / "B" / ROLE_FILE_TASKS_MAIN),
|
||
"""
|
||
- import_role:
|
||
name: A
|
||
""",
|
||
)
|
||
|
||
r = RoleDependencyResolver(self.roles_dir)
|
||
|
||
visited = r.resolve_transitively(["A"])
|
||
self.assertTrue({"A", "B"}.issubset(visited))
|
||
|
||
only_start = r.resolve_transitively(["A"], max_depth=0)
|
||
self.assertEqual(only_start, {"A"})
|
||
|
||
depth_one = r.resolve_transitively(["A"], max_depth=1)
|
||
self.assertEqual(depth_one, {"A", "B"})
|
||
|
||
def test_resolve_transitively_combined_sources(self):
|
||
"""
|
||
Combined test: include/import + dependencies (+ optional run_after).
|
||
"""
|
||
for rn in ["ROOT", "C1", "C2", "D1", "D2", "RA1"]:
|
||
make_role(self.roles_dir, rn)
|
||
|
||
write(
|
||
str(Path(self.roles_dir) / "ROOT" / ROLE_FILE_TASKS_MAIN),
|
||
"""
|
||
- include_role: { name: C1 }
|
||
- import_role: { name: C2 }
|
||
""",
|
||
)
|
||
write(
|
||
str(Path(self.roles_dir) / "ROOT" / ROLE_FILE_META_MAIN),
|
||
"""
|
||
---
|
||
dependencies:
|
||
- D1
|
||
- { role: D2 }
|
||
""",
|
||
)
|
||
# Per run_after lives at
|
||
# meta/services.yml.<primary_entity>.run_after.
|
||
write(
|
||
str(Path(self.roles_dir) / "ROOT" / ROLE_FILE_META_SERVICES),
|
||
"""
|
||
---
|
||
ROOT:
|
||
run_after:
|
||
- RA1
|
||
""",
|
||
)
|
||
|
||
r = RoleDependencyResolver(self.roles_dir)
|
||
|
||
# Ohne run_after
|
||
visited = r.resolve_transitively(["ROOT"], resolve_run_after=False)
|
||
for expected in ["ROOT", "C1", "C2", "D1", "D2"]:
|
||
self.assertIn(expected, visited)
|
||
self.assertNotIn("RA1", visited)
|
||
|
||
# Mit run_after
|
||
visited_ra = r.resolve_transitively(["ROOT"], resolve_run_after=True)
|
||
self.assertIn("RA1", visited_ra)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
unittest.main()
|