claude-dev-env 1.41.0 → 1.42.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/CLAUDE.md +8 -0
  2. package/_shared/pr-loop/scripts/_claude_permissions_common.py +231 -3
  3. package/_shared/pr-loop/scripts/config/claude_permissions_constants.py +56 -2
  4. package/_shared/pr-loop/scripts/config/claude_settings_keys_constants.py +2 -0
  5. package/_shared/pr-loop/scripts/grant_project_claude_permissions.py +121 -4
  6. package/_shared/pr-loop/scripts/revoke_project_claude_permissions.py +73 -17
  7. package/_shared/pr-loop/scripts/tests/test_agent_config_carveout.py +385 -0
  8. package/_shared/pr-loop/scripts/tests/test_claude_permissions_constants.py +33 -0
  9. package/_shared/pr-loop/scripts/tests/test_revoke_project_claude_permissions.py +3 -1
  10. package/package.json +1 -1
  11. package/skills/bugteam/scripts/_claude_permissions_common.py +109 -0
  12. package/skills/bugteam/scripts/config/claude_permissions_common_constants.py +51 -2
  13. package/skills/bugteam/scripts/grant_project_claude_permissions.py +115 -4
  14. package/skills/bugteam/scripts/revoke_project_claude_permissions.py +69 -17
  15. package/skills/bugteam/scripts/test_agent_config_carveout.py +356 -0
  16. package/skills/implement/SKILL.md +66 -0
  17. package/skills/implement/scripts/append_note.py +133 -0
  18. package/skills/implement/scripts/config/__init__.py +0 -0
  19. package/skills/implement/scripts/config/notes_constants.py +12 -0
  20. package/skills/implement/scripts/test_append_note.py +191 -0
  21. package/skills/pr-converge/config/constants.py +5 -0
  22. package/skills/pr-converge/scripts/check_bugbot_ci.py +1 -1
  23. package/skills/pr-converge/scripts/check_convergence.py +167 -28
  24. package/skills/pr-converge/scripts/check_pending_reviews.py +1 -1
  25. package/skills/pr-converge/scripts/conftest.py +60 -0
  26. package/skills/pr-converge/scripts/fetch_copilot_reviews.py +1 -1
  27. package/skills/pr-converge/scripts/post_fix_reply.py +1 -1
  28. package/skills/pr-converge/scripts/test_check_bugbot_ci.py +1 -1
  29. package/skills/pr-converge/scripts/test_check_convergence.py +306 -0
  30. package/skills/pr-converge/scripts/test_evict_cached_config_modules.py +1 -1
  31. package/skills/refine/SKILL.md +257 -0
  32. package/skills/refine/templates/implementation-notes-template.html +56 -0
  33. package/skills/refine/templates/plan-template.md +60 -0
@@ -0,0 +1,191 @@
1
+ """Tests for append_note.
2
+
3
+ Covers:
4
+ - _build_skeleton emits a four-section HTML document keyed by every slug
5
+ - _ensure_file creates a fresh file on first call and round-trips on subsequent calls
6
+ - _render_entry HTML-escapes the about label and the note body
7
+ - _insert_entry puts the first <li> on its own line and keeps a 6-space indent across entries
8
+ - _insert_entry raises a descriptive RuntimeError when the section block is missing
9
+ - _insert_entry raises a descriptive RuntimeError when the closing </ul> is missing
10
+ - main appends through the CLI surface against a real on-disk file
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import importlib.util
16
+ import subprocess
17
+ import sys
18
+ from pathlib import Path
19
+ from types import ModuleType
20
+
21
+ import pytest
22
+
23
+ _SCRIPTS_DIRECTORY = Path(__file__).resolve().parent
24
+
25
+
26
+ def _load_module() -> ModuleType:
27
+ if str(_SCRIPTS_DIRECTORY) not in sys.path:
28
+ sys.path.insert(0, str(_SCRIPTS_DIRECTORY))
29
+ module_path = _SCRIPTS_DIRECTORY / "append_note.py"
30
+ spec = importlib.util.spec_from_file_location("append_note", module_path)
31
+ assert spec is not None
32
+ assert spec.loader is not None
33
+ module = importlib.util.module_from_spec(spec)
34
+ spec.loader.exec_module(module)
35
+ return module
36
+
37
+
38
+ append_note_module = _load_module()
39
+ HEADING_BY_SLUG = append_note_module.HEADING_BY_SLUG
40
+
41
+
42
+ def test_should_build_skeleton_with_every_section_slug() -> None:
43
+ skeleton = append_note_module._build_skeleton()
44
+
45
+ for each_slug, each_heading in HEADING_BY_SLUG.items():
46
+ assert f'<section id="{each_slug}">' in skeleton
47
+ assert f"<h2>{each_heading}</h2>" in skeleton
48
+ assert skeleton.count("<ul></ul>") == len(HEADING_BY_SLUG)
49
+
50
+
51
+ def test_should_create_file_with_skeleton_on_first_ensure(tmp_path: Path) -> None:
52
+ target = tmp_path / "subdir" / "implementation-notes.html"
53
+
54
+ document = append_note_module._ensure_file(target)
55
+
56
+ assert target.exists()
57
+ assert document == target.read_text(encoding="utf-8")
58
+ assert '<section id="decisions">' in document
59
+
60
+
61
+ def test_should_return_existing_content_on_subsequent_ensure(tmp_path: Path) -> None:
62
+ target = tmp_path / "notes.html"
63
+ custom_content = "<!doctype html><html><body>existing</body></html>\n"
64
+ target.write_text(custom_content, encoding="utf-8")
65
+
66
+ returned = append_note_module._ensure_file(target)
67
+
68
+ assert returned == custom_content
69
+
70
+
71
+ def test_should_escape_html_metacharacters_in_about_and_note() -> None:
72
+ entry = append_note_module._render_entry("a<b & c>d", "<script>x</script>")
73
+
74
+ assert "<script>" not in entry
75
+ assert "&lt;script&gt;" in entry
76
+ assert "a&lt;b &amp; c&gt;d" in entry
77
+
78
+
79
+ def test_should_put_first_entry_on_its_own_line_inside_empty_ul() -> None:
80
+ skeleton = append_note_module._build_skeleton()
81
+ entry = append_note_module._render_entry("First", "alpha")
82
+
83
+ after_first = append_note_module._insert_entry(skeleton, "decisions", entry)
84
+
85
+ assert "<ul> <li>" not in after_first
86
+ assert "<ul>\n <li>" in after_first
87
+
88
+
89
+ def test_should_keep_uniform_six_space_indent_across_multiple_entries() -> None:
90
+ skeleton = append_note_module._build_skeleton()
91
+ first_entry = append_note_module._render_entry("First", "alpha")
92
+ second_entry = append_note_module._render_entry("Second", "beta")
93
+
94
+ after_first = append_note_module._insert_entry(skeleton, "decisions", first_entry)
95
+ after_second = append_note_module._insert_entry(after_first, "decisions", second_entry)
96
+
97
+ decisions_section_start = after_second.index('<section id="decisions">')
98
+ decisions_section_end = after_second.index("</section>", decisions_section_start)
99
+ decisions_section = after_second[decisions_section_start:decisions_section_end]
100
+
101
+ assert " <li>" not in decisions_section
102
+ assert decisions_section.count("\n <li>") == 2
103
+
104
+
105
+ def test_should_raise_when_requested_section_is_absent() -> None:
106
+ document_without_section = "<html><body></body></html>\n"
107
+ entry = append_note_module._render_entry("x", "y")
108
+
109
+ with pytest.raises(RuntimeError, match="section 'decisions' not found"):
110
+ append_note_module._insert_entry(document_without_section, "decisions", entry)
111
+
112
+
113
+ def test_should_raise_when_closing_ul_is_missing() -> None:
114
+ truncated_section = '<section id="decisions">\n <h2>Design decisions</h2>\n <ul>\n </section>\n'
115
+ entry = append_note_module._render_entry("x", "y")
116
+
117
+ with pytest.raises(RuntimeError, match="missing its closing </ul>"):
118
+ append_note_module._insert_entry(truncated_section, "decisions", entry)
119
+
120
+
121
+ def test_should_not_borrow_closing_ul_from_a_later_section() -> None:
122
+ malformed_first_with_intact_second = (
123
+ '<section id="decisions">\n'
124
+ ' <h2>Design decisions</h2>\n'
125
+ ' <ul>\n'
126
+ ' </section>\n'
127
+ ' <section id="deviations">\n'
128
+ ' <h2>Deviations</h2>\n'
129
+ ' <ul></ul>\n'
130
+ ' </section>\n'
131
+ )
132
+ entry = append_note_module._render_entry("x", "y")
133
+
134
+ with pytest.raises(RuntimeError, match="missing its closing </ul>"):
135
+ append_note_module._insert_entry(malformed_first_with_intact_second, "decisions", entry)
136
+
137
+
138
+ def test_should_raise_when_closing_section_is_missing() -> None:
139
+ section_without_close = '<section id="decisions">\n <h2>Design decisions</h2>\n <ul></ul>\n'
140
+ entry = append_note_module._render_entry("x", "y")
141
+
142
+ with pytest.raises(RuntimeError, match="missing its closing </section>"):
143
+ append_note_module._insert_entry(section_without_close, "decisions", entry)
144
+
145
+
146
+ def test_should_append_through_cli_against_real_file(tmp_path: Path) -> None:
147
+ target = tmp_path / "notes.html"
148
+ script_path = _SCRIPTS_DIRECTORY / "append_note.py"
149
+
150
+ first_run = subprocess.run(
151
+ [
152
+ sys.executable,
153
+ str(script_path),
154
+ "--section",
155
+ "decisions",
156
+ "--about",
157
+ "First",
158
+ "--note",
159
+ "alpha",
160
+ "--file",
161
+ str(target),
162
+ ],
163
+ cwd=str(_SCRIPTS_DIRECTORY),
164
+ capture_output=True,
165
+ text=True,
166
+ check=False,
167
+ )
168
+ second_run = subprocess.run(
169
+ [
170
+ sys.executable,
171
+ str(script_path),
172
+ "--section",
173
+ "questions",
174
+ "--about",
175
+ "Q1",
176
+ "--note",
177
+ "<beta & gamma>",
178
+ "--file",
179
+ str(target),
180
+ ],
181
+ cwd=str(_SCRIPTS_DIRECTORY),
182
+ capture_output=True,
183
+ text=True,
184
+ check=False,
185
+ )
186
+
187
+ assert first_run.returncode == 0, first_run.stderr
188
+ assert second_run.returncode == 0, second_run.stderr
189
+ output = target.read_text(encoding="utf-8")
190
+ assert "<li><strong>First:</strong> alpha</li>" in output
191
+ assert "<li><strong>Q1:</strong> &lt;beta &amp; gamma&gt;</li>" in output
@@ -33,6 +33,11 @@ ALL_BUGBOT_CHECK_RUN_COMPLETE_CONCLUSIONS = ("success", "neutral")
33
33
  BUGBOT_RUN_TRIGGER_PHRASE = "bugbot run\n"
34
34
  BUGBOT_RUN_TRIGGER_WAIT_SECONDS = 8
35
35
 
36
+ BUGTEAM_NEW_HEADER_PREFIX = "**Bugteam audit completed**"
37
+ BUGTEAM_LEGACY_HEADER_PREFIX = "## /bugteam loop "
38
+ BUGTEAM_NEW_CLEAN_LABEL = "Clean — no findings"
39
+ BUGTEAM_LEGACY_CLEAN_TOKEN = "→ clean"
40
+
36
41
  GH_INLINE_COMMENTS_PATH_TEMPLATE = "repos/{owner}/{repo}/pulls/{number}/comments"
37
42
  GH_REVIEW_COMMENTS_PATH_TEMPLATE = (
38
43
  "repos/{owner}/{repo}/pulls/{number}/reviews/{review_id}/comments"
@@ -29,7 +29,7 @@ import subprocess
29
29
  import sys
30
30
  from pathlib import Path
31
31
 
32
- _pr_converge_dir = Path(__file__).resolve().parent.parent
32
+ _pr_converge_dir = Path(__file__).absolute().parent.parent
33
33
  if str(_pr_converge_dir) not in sys.path:
34
34
  sys.path.insert(0, str(_pr_converge_dir))
35
35
 
@@ -2,9 +2,10 @@
2
2
 
3
3
  Usage:
4
4
  python scripts/check_convergence.py --owner <O> --repo <R> --pr-number <N>
5
+ [--bugbot-down]
5
6
 
6
7
  Exit codes:
7
- 0 — all seven pre-conditions met
8
+ 0 — all pre-conditions met
8
9
  1 — one or more conditions not met (FAIL lines printed to stdout)
9
10
  2 — gh CLI error
10
11
  """
@@ -18,18 +19,20 @@ import subprocess
18
19
  import sys
19
20
  from pathlib import Path
20
21
 
21
- _pr_converge_dir = Path(__file__).resolve().parent.parent
22
+ _pr_converge_dir = Path(__file__).absolute().parent.parent
22
23
  if str(_pr_converge_dir) not in sys.path:
23
24
  sys.path.insert(0, str(_pr_converge_dir))
24
25
 
25
26
  from config.constants import (
26
- ALL_CLAUDE_DIRTY_REVIEW_STATES,
27
27
  ALL_COPILOT_DIRTY_REVIEW_STATES,
28
28
  ALL_BUGBOT_CHECK_RUN_COMPLETE_CONCLUSIONS,
29
29
  BUGBOT_CHECK_RUN_NAME_SUBSTRING,
30
30
  BUGBOT_DIRTY_BODY_REGEX,
31
+ BUGTEAM_LEGACY_CLEAN_TOKEN,
32
+ BUGTEAM_LEGACY_HEADER_PREFIX,
33
+ BUGTEAM_NEW_CLEAN_LABEL,
34
+ BUGTEAM_NEW_HEADER_PREFIX,
31
35
  CHECK_RUNS_PER_PAGE,
32
- ALL_CLAUDE_CLEAN_REVIEW_STATES,
33
36
  CLAUDE_LOGIN_FILTER_SUBSTRING,
34
37
  ALL_COPILOT_CLEAN_REVIEW_STATES,
35
38
  COPILOT_LOGIN_FILTER_SUBSTRING,
@@ -46,6 +49,93 @@ from config.constants import (
46
49
  )
47
50
 
48
51
 
52
+ def _is_bugteam_review(review_body: str) -> bool:
53
+ """Return True when a review body opens with a bugteam audit header.
54
+
55
+ Args:
56
+ review_body: Full body text of a PR review.
57
+
58
+ Returns:
59
+ True when the body opens with either the new audit-template header
60
+ prefix or the legacy bugteam loop header prefix; False otherwise.
61
+ Used to identify bugteam audit reviews by body content rather than
62
+ by the posting user's GitHub login (the underlying ``gh`` token is
63
+ typically the PR-owner or reviewer identity, not ``claude[bot]``).
64
+ """
65
+ return (
66
+ review_body.startswith(BUGTEAM_NEW_HEADER_PREFIX)
67
+ or review_body.startswith(BUGTEAM_LEGACY_HEADER_PREFIX)
68
+ )
69
+
70
+
71
+ def _is_clean_bugteam_review(review_body: str) -> bool:
72
+ """Return True when a bugteam audit review body declares a clean pass.
73
+
74
+ Args:
75
+ review_body: Body text of a review that has already satisfied
76
+ :func:`_is_bugteam_review`.
77
+
78
+ Returns:
79
+ True when the new-shape body's first line carries the clean state
80
+ label, or the legacy-shape body ends with the legacy clean token.
81
+ False for any other shape, including dirty audit reviews and
82
+ bodies that do not match the bugteam header signature.
83
+ """
84
+ if review_body.startswith(BUGTEAM_NEW_HEADER_PREFIX):
85
+ first_line = review_body.splitlines()[0]
86
+ return BUGTEAM_NEW_CLEAN_LABEL in first_line
87
+ if review_body.startswith(BUGTEAM_LEGACY_HEADER_PREFIX):
88
+ return review_body.rstrip().endswith(BUGTEAM_LEGACY_CLEAN_TOKEN)
89
+ return False
90
+
91
+
92
+ def _check_bugteam_clean(
93
+ *, owner: str, repo: str, number: int, head_sha: str
94
+ ) -> tuple[bool, str]:
95
+ endpoint = GH_REVIEWS_PATH_TEMPLATE.format(owner=owner, repo=repo, number=number)
96
+ returncode, stdout = _gh_api_paginated(f"{endpoint}?per_page={REVIEWS_PER_PAGE}")
97
+ if returncode != 0:
98
+ return False, f"gh api error: {stdout}"
99
+ try:
100
+ raw_output = json.loads(stdout)
101
+ except json.JSONDecodeError:
102
+ return False, "gh api response not valid JSON"
103
+ if not isinstance(raw_output, list):
104
+ return False, "unexpected gh api response shape (expected list)"
105
+ all_pages = [p for p in raw_output if isinstance(p, list)]
106
+ all_flat: list[dict[str, object]] = [
107
+ each_entry
108
+ for page in all_pages
109
+ for each_entry in page
110
+ if isinstance(each_entry, dict)
111
+ ]
112
+ all_flat.sort(
113
+ key=lambda each_review: str(each_review.get("submitted_at", "")),
114
+ reverse=True,
115
+ )
116
+ for each_review in all_flat:
117
+ body = each_review.get("body", "")
118
+ if not isinstance(body, str):
119
+ continue
120
+ if not _is_bugteam_review(body):
121
+ continue
122
+ commit_id = each_review.get("commit_id", "")
123
+ if not isinstance(commit_id, str) or not commit_id.startswith(head_sha):
124
+ continue
125
+ review_id = each_review.get("id", "?")
126
+ short_commit = commit_id[:7]
127
+ if _is_clean_bugteam_review(body):
128
+ return (
129
+ True,
130
+ f"review #{review_id}, clean bugteam audit, commit: {short_commit}",
131
+ )
132
+ return (
133
+ False,
134
+ f"review #{review_id}, dirty bugteam audit, commit: {short_commit}",
135
+ )
136
+ return False, f"no bugteam review found on {head_sha[:7]}"
137
+
138
+
49
139
  def _gh_api(endpoint_path: str) -> tuple[int, str]:
50
140
  completed_process = subprocess.run(
51
141
  ["gh", "api", endpoint_path],
@@ -387,38 +477,60 @@ def _check_no_pending_reviews(
387
477
  return True, "no pending reviewers"
388
478
 
389
479
 
390
- def check_all(*, owner: str, repo: str, number: int) -> int:
480
+ def check_all(*, owner: str, repo: str, number: int, bugbot_down: bool) -> int:
481
+ """Run every convergence gate and print one PASS/FAIL line per condition.
482
+
483
+ Args:
484
+ owner: GitHub repository owner login.
485
+ repo: GitHub repository name.
486
+ number: Pull request number to inspect.
487
+ bugbot_down: When True, bypass both the Cursor Bugbot check-run
488
+ presence gate and the bugbot review-body content gate. The
489
+ check-run gate appears in the condition list with a
490
+ ``bypassed (bugbot_down)`` note; the review-body gate is
491
+ omitted entirely. Callers pass True when the lead has
492
+ declared Cursor Bugbot unreachable on the current HEAD so the
493
+ broader convergence gate can still close on the remaining
494
+ signals.
495
+
496
+ Returns:
497
+ ``0`` when every gate reports PASS, ``1`` when at least one gate
498
+ reports FAIL. The function never raises for gate-level failures;
499
+ gh-api transport failures surface as gate FAILs in the printed
500
+ output and contribute to the ``1`` exit code.
501
+ """
391
502
  head_sha = _get_pr_head_sha(owner=owner, repo=repo, number=number)
392
503
  print(f"HEAD: {head_sha[:7]}\n")
393
504
 
394
505
  conditions: list[tuple[str, tuple[bool, str]]] = []
395
506
 
396
- conditions.append(
397
- (
398
- "bugbot_clean_at == current_head",
399
- _check_bugbot(owner=owner, repo=repo, sha=head_sha),
507
+ if bugbot_down:
508
+ conditions.append(
509
+ (
510
+ "bugbot_clean_at == current_head",
511
+ (True, "bypassed (bugbot_down)"),
512
+ )
400
513
  )
401
- )
402
- if conditions[-1][1][0]:
514
+ else:
403
515
  conditions.append(
404
516
  (
405
- "bugbot review body clean",
406
- _check_bugbot_not_dirty(owner=owner, repo=repo, number=number, head_sha=head_sha),
517
+ "bugbot_clean_at == current_head",
518
+ _check_bugbot(owner=owner, repo=repo, sha=head_sha),
407
519
  )
408
520
  )
521
+ if conditions[-1][1][0]:
522
+ conditions.append(
523
+ (
524
+ "bugbot review body clean",
525
+ _check_bugbot_not_dirty(owner=owner, repo=repo, number=number, head_sha=head_sha),
526
+ )
527
+ )
409
528
 
410
529
  conditions.append(
411
530
  (
412
531
  "bugteam_clean_at == current_head",
413
- _check_bot_review(
414
- owner=owner,
415
- repo=repo,
416
- number=number,
417
- head_sha=head_sha,
418
- login_substring=CLAUDE_LOGIN_FILTER_SUBSTRING,
419
- clean_states=ALL_CLAUDE_CLEAN_REVIEW_STATES,
420
- dirty_states=ALL_CLAUDE_DIRTY_REVIEW_STATES,
421
- label="claude[bot]",
532
+ _check_bugteam_clean(
533
+ owner=owner, repo=repo, number=number, head_sha=head_sha
422
534
  ),
423
535
  )
424
536
  )
@@ -458,13 +570,11 @@ def check_all(*, owner: str, repo: str, number: int) -> int:
458
570
  )
459
571
 
460
572
  is_all_passed = True
461
- index = 1
462
- for label, (passed, detail) in conditions:
463
- status = "PASS" if passed else "FAIL"
464
- print(f"{index}. {label}: {status} — {detail}")
465
- if not passed:
573
+ for each_index, (each_label, (each_passed, each_detail)) in enumerate(conditions, start=1):
574
+ status = "PASS" if each_passed else "FAIL"
575
+ print(f"{each_index}. {each_label}: {status} {each_detail}")
576
+ if not each_passed:
466
577
  is_all_passed = False
467
- index += 1
468
578
 
469
579
  print()
470
580
  if is_all_passed:
@@ -475,21 +585,50 @@ def check_all(*, owner: str, repo: str, number: int) -> int:
475
585
 
476
586
 
477
587
  def parse_arguments(all_argv: list[str]) -> argparse.Namespace:
588
+ """Parse command-line arguments for the convergence checker.
589
+
590
+ Args:
591
+ all_argv: Argument list excluding the program name, typically
592
+ ``sys.argv[1:]``.
593
+
594
+ Returns:
595
+ Namespace exposing ``owner``, ``repo``, ``pr_number``, and
596
+ ``bugbot_down`` attributes. ``bugbot_down`` defaults to False so
597
+ the unmodified hook contract (``--owner X --repo Y --pr-number N``)
598
+ still picks up the full gate set.
599
+ """
478
600
  parser = argparse.ArgumentParser(description=__doc__)
479
601
  parser.add_argument("--owner", required=True, help="GitHub repository owner")
480
602
  parser.add_argument("--repo", required=True, help="GitHub repository name")
481
603
  parser.add_argument(
482
604
  "--pr-number", required=True, type=int, help="Pull request number"
483
605
  )
606
+ parser.add_argument(
607
+ "--bugbot-down",
608
+ action="store_true",
609
+ help=(
610
+ "Bypass the bugbot check-run gate (gate 1) when the lead has "
611
+ "declared Cursor Bugbot unreachable on the current HEAD."
612
+ ),
613
+ )
484
614
  return parser.parse_args(all_argv)
485
615
 
486
616
 
487
617
  def main(all_arguments: list[str]) -> int:
618
+ """Run the script end-to-end against parsed CLI arguments.
619
+
620
+ Args:
621
+ all_arguments: Argument list excluding the program name.
622
+
623
+ Returns:
624
+ ``0`` on full convergence, ``1`` on one or more gate failures.
625
+ """
488
626
  arguments = parse_arguments(all_arguments)
489
627
  return check_all(
490
628
  owner=arguments.owner,
491
629
  repo=arguments.repo,
492
630
  number=getattr(arguments, "pr_number"),
631
+ bugbot_down=arguments.bugbot_down,
493
632
  )
494
633
 
495
634
 
@@ -17,7 +17,7 @@ import subprocess
17
17
  import sys
18
18
  from pathlib import Path
19
19
 
20
- _pr_converge_dir = Path(__file__).resolve().parent.parent
20
+ _pr_converge_dir = Path(__file__).absolute().parent.parent
21
21
  if str(_pr_converge_dir) not in sys.path:
22
22
  sys.path.insert(0, str(_pr_converge_dir))
23
23
 
@@ -0,0 +1,60 @@
1
+ """Test fixtures for skills/pr-converge/scripts/.
2
+
3
+ Two unrelated Python packages live under the name ``config`` in this repo:
4
+ - ``skills/pr-converge/config/`` (constants for the pr-converge scripts)
5
+ - ``hooks/config/`` (constants for the code-rules enforcer and other hooks)
6
+
7
+ When tests under this directory exercise pr-converge scripts that load
8
+ ``from config.constants import ...`` and other code paths in the same
9
+ pytest process also load a different ``config`` package,
10
+ ``sys.modules['config']`` and ``sys.modules['config.<submodule>']`` cache
11
+ entries from one package leak into the other. The next
12
+ ``from config.<submodule> import ...`` then fails with
13
+ ``ModuleNotFoundError`` because the cached parent package does not
14
+ expose that submodule.
15
+
16
+ Independently, several scripts in this folder do
17
+ ``Path(__file__).resolve()`` then prepend the resulting directory to
18
+ ``sys.path``. On Windows when the working tree lives under a mapped drive
19
+ backed by a UNC share (``Y:`` -> ``\\\\server\\share\\...``), ``.resolve()``
20
+ returns the UNC form, and Python's import machinery on this host cannot
21
+ locate ``config`` packages from a UNC ``sys.path`` entry. The Y:-form entry
22
+ gets pushed to a later index by subsequent inserts, making
23
+ ``from config.<submodule> import ...`` fail.
24
+
25
+ This autouse fixture restores both invariants once per pytest session,
26
+ immediately before the first test executes (after collection and module
27
+ imports have completed; session-scoped fixtures run after import, not
28
+ before, so test-module-level ``import`` of pr-converge scripts is
29
+ isolated by each module's own ``_load_module()`` helper rather than by
30
+ this fixture):
31
+ 1. evict every ``config`` and ``config.*`` entry from ``sys.modules``
32
+ 2. prepend the drive-letter (``.absolute()``) form of the pr-converge
33
+ directory to ``sys.path`` so package resolution always has a
34
+ non-UNC path to search first
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ import sys
40
+ from pathlib import Path
41
+
42
+ import pytest
43
+
44
+ PR_CONVERGE_DIRECTORY_DRIVE_LETTER_FORM = str(Path(__file__).absolute().parent.parent)
45
+
46
+ if PR_CONVERGE_DIRECTORY_DRIVE_LETTER_FORM not in sys.path:
47
+ sys.path.insert(0, PR_CONVERGE_DIRECTORY_DRIVE_LETTER_FORM)
48
+
49
+
50
+ @pytest.fixture(scope="session", autouse=True)
51
+ def _evict_config_namespace_at_session_start() -> None:
52
+ for each_module_name in [
53
+ each_key
54
+ for each_key in list(sys.modules)
55
+ if each_key == "config" or each_key.startswith("config.")
56
+ ]:
57
+ sys.modules.pop(each_module_name, None)
58
+ if PR_CONVERGE_DIRECTORY_DRIVE_LETTER_FORM in sys.path:
59
+ sys.path.remove(PR_CONVERGE_DIRECTORY_DRIVE_LETTER_FORM)
60
+ sys.path.insert(0, PR_CONVERGE_DIRECTORY_DRIVE_LETTER_FORM)
@@ -15,7 +15,7 @@ import subprocess
15
15
  import sys
16
16
  from pathlib import Path
17
17
 
18
- _pr_converge_dir = Path(__file__).resolve().parent.parent
18
+ _pr_converge_dir = Path(__file__).absolute().parent.parent
19
19
  if str(_pr_converge_dir) not in sys.path:
20
20
  sys.path.insert(0, str(_pr_converge_dir))
21
21
 
@@ -20,7 +20,7 @@ import subprocess
20
20
  import sys
21
21
  from pathlib import Path
22
22
 
23
- _pr_converge_dir = Path(__file__).resolve().parent.parent
23
+ _pr_converge_dir = Path(__file__).absolute().parent.parent
24
24
  if str(_pr_converge_dir) not in sys.path:
25
25
  sys.path.insert(0, str(_pr_converge_dir))
26
26
 
@@ -21,7 +21,7 @@ from unittest.mock import MagicMock, patch
21
21
 
22
22
  import pytest
23
23
 
24
- _SCRIPTS_DIRECTORY = Path(__file__).resolve().parent
24
+ _SCRIPTS_DIRECTORY = Path(__file__).absolute().parent
25
25
 
26
26
 
27
27
  @pytest.fixture(scope="session")