claude-dev-env 1.36.1 → 1.37.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 (101) hide show
  1. package/_shared/pr-loop/audit-contract.md +159 -0
  2. package/_shared/pr-loop/code-rules-gate.md +64 -0
  3. package/_shared/pr-loop/fix-protocol.md +37 -0
  4. package/_shared/pr-loop/gh-payloads.md +85 -0
  5. package/_shared/pr-loop/scripts/README.md +20 -0
  6. package/_shared/pr-loop/scripts/_claude_permissions_common.py +234 -0
  7. package/_shared/pr-loop/scripts/code_rules_gate.py +975 -0
  8. package/_shared/pr-loop/scripts/config/__init__.py +0 -0
  9. package/_shared/pr-loop/scripts/config/claude_permissions_constants.py +36 -0
  10. package/_shared/pr-loop/scripts/config/claude_settings_keys_constants.py +11 -0
  11. package/_shared/pr-loop/scripts/config/code_rules_gate_constants.py +56 -0
  12. package/_shared/pr-loop/scripts/config/fix_hookspath_constants.py +25 -0
  13. package/_shared/pr-loop/scripts/config/gh_util_constants.py +31 -0
  14. package/_shared/pr-loop/scripts/config/preflight_constants.py +68 -0
  15. package/_shared/pr-loop/scripts/fix_hookspath.py +260 -0
  16. package/_shared/pr-loop/scripts/gh_util.py +193 -0
  17. package/_shared/pr-loop/scripts/grant_project_claude_permissions.py +130 -0
  18. package/_shared/pr-loop/scripts/preflight.py +449 -0
  19. package/_shared/pr-loop/scripts/revoke_project_claude_permissions.py +156 -0
  20. package/_shared/pr-loop/scripts/tests/conftest.py +51 -0
  21. package/_shared/pr-loop/scripts/tests/test__claude_permissions_common.py +135 -0
  22. package/_shared/pr-loop/scripts/tests/test_claude_permissions_common.py +169 -0
  23. package/_shared/pr-loop/scripts/tests/test_claude_permissions_constants.py +58 -0
  24. package/_shared/pr-loop/scripts/tests/test_claude_settings_keys_constants.py +50 -0
  25. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +917 -0
  26. package/_shared/pr-loop/scripts/tests/test_code_rules_gate_constants.py +102 -0
  27. package/_shared/pr-loop/scripts/tests/test_fix_hookspath.py +374 -0
  28. package/_shared/pr-loop/scripts/tests/test_fix_hookspath_constants.py +47 -0
  29. package/_shared/pr-loop/scripts/tests/test_gh_util.py +257 -0
  30. package/_shared/pr-loop/scripts/tests/test_gh_util_constants.py +61 -0
  31. package/_shared/pr-loop/scripts/tests/test_grant_project_claude_permissions.py +49 -0
  32. package/_shared/pr-loop/scripts/tests/test_preflight.py +670 -0
  33. package/_shared/pr-loop/scripts/tests/test_preflight_constants.py +77 -0
  34. package/_shared/pr-loop/scripts/tests/test_revoke_project_claude_permissions.py +49 -0
  35. package/_shared/pr-loop/state-schema.md +81 -0
  36. package/hooks/blocking/code_rules_enforcer.py +269 -23
  37. package/hooks/blocking/test_code_rules_enforcer_unused_imports.py +157 -1
  38. package/hooks/config/test_unused_module_import_constants.py +48 -0
  39. package/hooks/config/unused_module_import_constants.py +41 -0
  40. package/package.json +2 -1
  41. package/skills/bg-agent/SKILL.md +69 -0
  42. package/skills/bugteam/CONSTRAINTS.md +10 -19
  43. package/skills/bugteam/PROMPTS.md +3 -3
  44. package/skills/bugteam/SKILL.md +103 -202
  45. package/skills/bugteam/SKILL_EVALS.md +75 -114
  46. package/skills/bugteam/reference/README.md +2 -4
  47. package/skills/bugteam/reference/design-rationale.md +3 -8
  48. package/skills/bugteam/reference/team-setup.md +11 -19
  49. package/skills/bugteam/reference/teardown-publish-permissions.md +2 -14
  50. package/skills/bugteam/scripts/config/__init__.py +0 -0
  51. package/skills/bugteam/scripts/config/reflow_skill_md_constants.py +12 -0
  52. package/skills/bugteam/scripts/reflow_skill_md.py +51 -47
  53. package/skills/bugteam/sources.md +1 -25
  54. package/skills/bugteam/test_skill_additions.py +4 -13
  55. package/skills/fresh-branch/SKILL.md +71 -0
  56. package/skills/gotcha/SKILL.md +73 -0
  57. package/skills/monitor-open-prs/SKILL.md +4 -37
  58. package/skills/monitor-open-prs/test_skill_contract.py +0 -5
  59. package/skills/pr-converge/SKILL.md +60 -1298
  60. package/skills/pr-converge/reference/convergence-gates.md +118 -0
  61. package/skills/pr-converge/reference/examples.md +76 -0
  62. package/skills/pr-converge/reference/fix-protocol.md +54 -0
  63. package/skills/pr-converge/reference/ground-rules.md +13 -0
  64. package/skills/pr-converge/reference/multi-pr-orchestration.md +204 -0
  65. package/skills/pr-converge/reference/per-tick.md +201 -0
  66. package/skills/pr-converge/reference/state-schema.md +19 -0
  67. package/skills/pr-converge/reference/stop-conditions.md +26 -0
  68. package/skills/pr-converge/scripts/README.md +36 -9
  69. package/skills/pr-converge/scripts/check_pr_mergeability.py +1 -2
  70. package/skills/pr-converge/scripts/config/pr_converge_constants.py +58 -5
  71. package/skills/pr-converge/scripts/config/reflow_skill_md_constants.py +13 -0
  72. package/skills/pr-converge/scripts/config/test_pr_converge_constants.py +0 -24
  73. package/skills/pr-converge/scripts/cursor-agents-continue.ahk +22 -2
  74. package/skills/pr-converge/scripts/fetch_bugbot_inline_comments.py +19 -59
  75. package/skills/pr-converge/scripts/fetch_bugbot_reviews.py +15 -61
  76. package/skills/pr-converge/scripts/fetch_claude_inline_comments.py +70 -0
  77. package/skills/pr-converge/scripts/fetch_claude_reviews.py +61 -0
  78. package/skills/pr-converge/scripts/fetch_copilot_inline_comments.py +19 -61
  79. package/skills/pr-converge/scripts/fetch_copilot_reviews.py +14 -74
  80. package/skills/pr-converge/scripts/reflow_skill_md.py +71 -50
  81. package/skills/pr-converge/scripts/reviewer_fetch_core.py +153 -0
  82. package/skills/pr-converge/scripts/reviewer_specs.py +98 -0
  83. package/skills/pr-converge/scripts/test_cursor_agents_continue.py +65 -0
  84. package/skills/pr-converge/scripts/test_fetch_bugbot_inline_comments.py +107 -6
  85. package/skills/pr-converge/scripts/test_fetch_bugbot_reviews.py +85 -6
  86. package/skills/pr-converge/scripts/test_fetch_claude_inline_comments.py +485 -0
  87. package/skills/pr-converge/scripts/test_fetch_claude_reviews.py +368 -0
  88. package/skills/pr-converge/scripts/test_fetch_copilot_inline_comments.py +74 -6
  89. package/skills/pr-converge/scripts/test_fetch_copilot_reviews.py +94 -8
  90. package/skills/pr-converge/scripts/test_reflow_skill_md.py +162 -0
  91. package/skills/pr-converge/scripts/test_reviewer_fetch_core.py +448 -0
  92. package/skills/pr-converge/scripts/test_reviewer_specs.py +107 -0
  93. package/skills/pr-converge/workflows/schedule-wakeup-loop.md +24 -22
  94. package/skills/bugteam/reference/workflow-path-a-orchestrated-teams.md +0 -113
  95. package/skills/bugteam/reference/workflow-path-b-task-harness.md +0 -48
  96. package/skills/bugteam/test_team_lifecycle.py +0 -103
  97. package/skills/monitor-open-prs/test_team_lifecycle.py +0 -46
  98. package/skills/pr-converge/scripts/open_followup_copilot_pr.py +0 -136
  99. package/skills/pr-converge/scripts/test_open_followup_copilot_pr.py +0 -236
  100. package/skills/pr-converge/test_team_lifecycle.py +0 -56
  101. package/skills/pr-converge/workflows/ahk-auto-continue-loop.md +0 -108
@@ -0,0 +1,156 @@
1
+ """Revoke the permissions previously granted by grant_project_claude_permissions.
2
+
3
+ Run from the same project root you previously granted. Removes the matching
4
+ allow rules, the additionalDirectories entry, and the autoMode environment
5
+ entry from ~/.claude/settings.json. Safe to run when no prior grant exists.
6
+ After removals, prunes any newly empty lists and their parent permissions or
7
+ autoMode sections so repeated grant/revoke cycles leave no dead structure.
8
+ """
9
+
10
+ import sys
11
+ from pathlib import Path
12
+
13
+ sys.modules.pop("config", None)
14
+ if str(Path(__file__).resolve().parent) not in sys.path:
15
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
16
+
17
+ from _claude_permissions_common import ( # noqa: E402
18
+ build_permission_rules,
19
+ exit_with_error,
20
+ get_current_project_path,
21
+ is_valid_project_root,
22
+ load_settings,
23
+ prune_empty_list_then_empty_section,
24
+ save_settings,
25
+ )
26
+ from config.claude_permissions_constants import ( # noqa: E402
27
+ ALL_PERMISSION_ALLOW_TOOLS,
28
+ AUTO_MODE_ENVIRONMENT_ENTRY_TEMPLATE,
29
+ get_claude_user_settings_path,
30
+ )
31
+ from config.claude_settings_keys_constants import ( # noqa: E402
32
+ CLAUDE_SETTINGS_ADDITIONAL_DIRECTORIES_KEY,
33
+ CLAUDE_SETTINGS_ALLOW_KEY,
34
+ CLAUDE_SETTINGS_AUTO_MODE_KEY,
35
+ CLAUDE_SETTINGS_ENVIRONMENT_KEY,
36
+ CLAUDE_SETTINGS_PERMISSIONS_KEY,
37
+ )
38
+
39
+
40
+ def remove_values_from_list(
41
+ all_target_list: list[object], all_values_to_remove: set[str]
42
+ ) -> int:
43
+ original_length = len(all_target_list)
44
+ all_target_list[:] = [
45
+ each_value
46
+ for each_value in all_target_list
47
+ if not (isinstance(each_value, str) and each_value in all_values_to_remove)
48
+ ]
49
+ return original_length - len(all_target_list)
50
+
51
+
52
+ def remove_rules_from_allow_list(
53
+ all_settings: dict[str, object], all_rules_to_remove: list[str]
54
+ ) -> int:
55
+ permissions_section = all_settings.get(CLAUDE_SETTINGS_PERMISSIONS_KEY)
56
+ if not isinstance(permissions_section, dict):
57
+ return 0
58
+ existing_allow_list = permissions_section.get(CLAUDE_SETTINGS_ALLOW_KEY)
59
+ if not isinstance(existing_allow_list, list):
60
+ return 0
61
+ return remove_values_from_list(existing_allow_list, set(all_rules_to_remove))
62
+
63
+
64
+ def remove_directory_from_additional_directories(
65
+ all_settings: dict[str, object], directory_path: str
66
+ ) -> int:
67
+ permissions_section = all_settings.get(CLAUDE_SETTINGS_PERMISSIONS_KEY)
68
+ if not isinstance(permissions_section, dict):
69
+ return 0
70
+ existing_directories = permissions_section.get(
71
+ CLAUDE_SETTINGS_ADDITIONAL_DIRECTORIES_KEY
72
+ )
73
+ if not isinstance(existing_directories, list):
74
+ return 0
75
+ return remove_values_from_list(existing_directories, {directory_path})
76
+
77
+
78
+ def remove_auto_mode_environment_entry(
79
+ all_settings: dict[str, object], entry_text: str
80
+ ) -> int:
81
+ auto_mode_section = all_settings.get(CLAUDE_SETTINGS_AUTO_MODE_KEY)
82
+ if not isinstance(auto_mode_section, dict):
83
+ return 0
84
+ existing_environment = auto_mode_section.get(CLAUDE_SETTINGS_ENVIRONMENT_KEY)
85
+ if not isinstance(existing_environment, list):
86
+ return 0
87
+ return remove_values_from_list(existing_environment, {entry_text})
88
+
89
+
90
+ def prune_settings_after_revoke(all_settings: dict[str, object]) -> None:
91
+ prune_empty_list_then_empty_section(
92
+ all_settings,
93
+ CLAUDE_SETTINGS_PERMISSIONS_KEY,
94
+ CLAUDE_SETTINGS_ALLOW_KEY,
95
+ )
96
+ prune_empty_list_then_empty_section(
97
+ all_settings,
98
+ CLAUDE_SETTINGS_PERMISSIONS_KEY,
99
+ CLAUDE_SETTINGS_ADDITIONAL_DIRECTORIES_KEY,
100
+ )
101
+ prune_empty_list_then_empty_section(
102
+ all_settings,
103
+ CLAUDE_SETTINGS_AUTO_MODE_KEY,
104
+ CLAUDE_SETTINGS_ENVIRONMENT_KEY,
105
+ )
106
+
107
+
108
+ def revoke_permissions_for_current_directory() -> None:
109
+ claude_user_settings_path: Path = get_claude_user_settings_path()
110
+ project_root_path = Path.cwd()
111
+ if not is_valid_project_root(project_root_path):
112
+ print(
113
+ f"ERROR: cwd {project_root_path} is not a project root "
114
+ f"(no .git or .claude). Run from a project root.",
115
+ file=sys.stderr,
116
+ )
117
+ raise SystemExit(1)
118
+ project_path = get_current_project_path()
119
+ permission_rules = build_permission_rules(project_path, ALL_PERMISSION_ALLOW_TOOLS)
120
+ environment_entry = AUTO_MODE_ENVIRONMENT_ENTRY_TEMPLATE.format(
121
+ project_path=project_path
122
+ )
123
+ settings = load_settings(claude_user_settings_path)
124
+ rules_removed_count = remove_rules_from_allow_list(settings, permission_rules)
125
+ directories_removed_count = remove_directory_from_additional_directories(
126
+ settings, project_path
127
+ )
128
+ environment_entries_removed_count = remove_auto_mode_environment_entry(
129
+ settings, environment_entry
130
+ )
131
+ total_changes_count = (
132
+ rules_removed_count
133
+ + directories_removed_count
134
+ + environment_entries_removed_count
135
+ )
136
+ if total_changes_count == 0:
137
+ print(f"Project path: {project_path}")
138
+ print(f"Settings file: {claude_user_settings_path}")
139
+ print("No changes to revoke; settings file left untouched.")
140
+ return
141
+ prune_settings_after_revoke(settings)
142
+ save_settings(claude_user_settings_path, settings)
143
+ print(f"Project path: {project_path}")
144
+ print(f"Settings file: {claude_user_settings_path}")
145
+ print(f"Allow rules removed: {rules_removed_count} of {len(permission_rules)}")
146
+ print(f"Additional directories removed: {directories_removed_count}")
147
+ print(
148
+ f"Auto-mode environment entries removed: {environment_entries_removed_count}"
149
+ )
150
+
151
+
152
+ if __name__ == "__main__":
153
+ try:
154
+ revoke_permissions_for_current_directory()
155
+ except ValueError as path_error:
156
+ exit_with_error(str(path_error))
@@ -0,0 +1,51 @@
1
+ """Test fixtures for _shared/pr-loop/scripts/.
2
+
3
+ Two unrelated Python packages live under the name ``config`` in this repo:
4
+ - ``_shared/pr-loop/scripts/config/`` (constants for grant/revoke/gate/preflight scripts)
5
+ - ``hooks/config/`` (constants for the code-rules enforcer and other hooks)
6
+
7
+ When tests under this directory exercise the gate (which loads
8
+ ``hooks/blocking/code_rules_enforcer.py``) and also load the grant/revoke
9
+ scripts in the same pytest process, ``sys.modules['config']`` and
10
+ ``sys.modules['config.<submodule>']`` cache entries from one package leak
11
+ into the other. The next ``from config.<submodule> import ...`` then fails
12
+ with ``ModuleNotFoundError`` because the cached parent package does not
13
+ expose that submodule.
14
+
15
+ Independently, several scripts in this folder do
16
+ ``Path(__file__).resolve()`` then prepend the resulting directory to
17
+ ``sys.path``. On Windows when the working tree lives under a mapped drive
18
+ backed by a UNC share (``Y:`` -> ``\\\\server\\share\\...``), ``.resolve()``
19
+ returns the UNC form, and Python's import machinery on this host cannot
20
+ locate ``config`` packages from a UNC ``sys.path`` entry. The Y:-form entry
21
+ gets pushed to a later index by subsequent inserts, making ``from
22
+ config.<submodule> import ...`` fail.
23
+
24
+ This autouse fixture restores both invariants before each test:
25
+ 1. evict every ``config`` and ``config.*`` entry from ``sys.modules``
26
+ 2. prepend the drive-letter (``.absolute()``) form of the scripts
27
+ directory to ``sys.path`` so package resolution always has a
28
+ non-UNC path to search first
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import sys
34
+ from pathlib import Path
35
+
36
+ import pytest
37
+
38
+ SCRIPTS_DIRECTORY_DRIVE_LETTER_FORM = str(Path(__file__).absolute().parent.parent)
39
+
40
+
41
+ @pytest.fixture(autouse=True)
42
+ def _evict_config_namespace_between_tests() -> None:
43
+ for each_module_name in [
44
+ each_key
45
+ for each_key in list(sys.modules)
46
+ if each_key == "config" or each_key.startswith("config.")
47
+ ]:
48
+ sys.modules.pop(each_module_name, None)
49
+ if SCRIPTS_DIRECTORY_DRIVE_LETTER_FORM in sys.path:
50
+ sys.path.remove(SCRIPTS_DIRECTORY_DRIVE_LETTER_FORM)
51
+ sys.path.insert(0, SCRIPTS_DIRECTORY_DRIVE_LETTER_FORM)
@@ -0,0 +1,135 @@
1
+ """Regression tests for the leading-underscore _claude_permissions_common module.
2
+
3
+ The companion test_claude_permissions_common.py covers the public-name
4
+ matched suite. This file holds tests the TDD enforcer pairs to the
5
+ leading-underscore production filename.
6
+ """
7
+
8
+ import importlib.util
9
+ import json
10
+ import sys
11
+ from pathlib import Path
12
+ from types import ModuleType
13
+
14
+ import pytest
15
+
16
+
17
+ def _load_common_module() -> ModuleType:
18
+ module_path = Path(__file__).parent.parent / "_claude_permissions_common.py"
19
+ parent_directory = str(module_path.parent.resolve())
20
+ if parent_directory not in sys.path:
21
+ sys.path.insert(0, parent_directory)
22
+ spec = importlib.util.spec_from_file_location(
23
+ "_claude_permissions_common", module_path
24
+ )
25
+ assert spec is not None
26
+ assert spec.loader is not None
27
+ module = importlib.util.module_from_spec(spec)
28
+ spec.loader.exec_module(module)
29
+ return module
30
+
31
+
32
+ common = _load_common_module()
33
+
34
+
35
+ def test_save_settings_chmods_after_replace_to_defeat_umask(
36
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
37
+ ) -> None:
38
+ """Regression: POSIX umask masks the mode passed to os.open, so the
39
+ "preserve mode" intent is silently defeated unless save_settings
40
+ chmods the final file after os.replace.
41
+ """
42
+ settings_path = tmp_path / "settings.json"
43
+ settings_path.write_text(json.dumps({}), encoding="utf-8")
44
+ captured_mode_to_preserve = 0o600
45
+ monkeypatch.setattr(
46
+ common, "get_mode_to_preserve", lambda _path: captured_mode_to_preserve
47
+ )
48
+
49
+ ordered_calls: list[tuple[str, str, int | None]] = []
50
+ real_replace = common.os.replace
51
+ real_chmod = common.os.chmod
52
+
53
+ def recording_replace(source_path: str, destination_path: str) -> None:
54
+ ordered_calls.append(("replace", str(destination_path), None))
55
+ real_replace(source_path, destination_path)
56
+
57
+ def recording_chmod(target_path: str, file_mode: int) -> None:
58
+ ordered_calls.append(("chmod", str(target_path), file_mode))
59
+ real_chmod(target_path, file_mode)
60
+
61
+ monkeypatch.setattr(common.os, "replace", recording_replace)
62
+ monkeypatch.setattr(common.os, "chmod", recording_chmod)
63
+
64
+ common.save_settings(settings_path, {"writer": "only"})
65
+
66
+ final_replace_index = next(
67
+ each_index
68
+ for each_index, each_call in enumerate(ordered_calls)
69
+ if each_call[0] == "replace" and each_call[1] == str(settings_path)
70
+ )
71
+ final_chmod_index = next(
72
+ (
73
+ each_index
74
+ for each_index, each_call in enumerate(ordered_calls)
75
+ if each_call[0] == "chmod"
76
+ and each_call[1] == str(settings_path)
77
+ and each_call[2] == captured_mode_to_preserve
78
+ ),
79
+ None,
80
+ )
81
+ assert final_chmod_index is not None
82
+ assert final_replace_index < final_chmod_index
83
+
84
+
85
+ def test_path_contains_glob_metacharacters_rejects_true_metacharacters() -> None:
86
+ assert common.path_contains_glob_metacharacters("C:/some/path/*.py") is True
87
+ assert common.path_contains_glob_metacharacters("C:/some/path/[abc].py") is True
88
+ assert common.path_contains_glob_metacharacters("C:/some/{a,b}/file") is True
89
+
90
+
91
+
92
+
93
+ def test_write_atomically_with_mode_closes_descriptor_when_fdopen_raises(
94
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
95
+ ) -> None:
96
+ """Regression: if os.fdopen fails, the raw descriptor from os.open must close.
97
+
98
+ Cursor Bugbot: without a failure path, the descriptor
99
+ leaks when fdopen raises before the file object assumes ownership.
100
+ """
101
+ closed_descriptors: list[int] = []
102
+ sentinel_descriptor = 91
103
+
104
+ def fake_open(_path: str, _flags: int, _mode: int = 0o777) -> int:
105
+ return sentinel_descriptor
106
+
107
+ def fake_fdopen(_file_descriptor: int, *_args: object, **_kwargs: object) -> object:
108
+ raise OSError("simulated fdopen failure")
109
+
110
+ def recording_close(file_descriptor: int) -> None:
111
+ closed_descriptors.append(file_descriptor)
112
+
113
+ monkeypatch.setattr(common.os, "open", fake_open)
114
+ monkeypatch.setattr(common.os, "fdopen", fake_fdopen)
115
+ monkeypatch.setattr(common.os, "close", recording_close)
116
+
117
+ temporary_path = tmp_path / "tempfile.tmp"
118
+ with pytest.raises(OSError, match="simulated fdopen failure"):
119
+ common.write_atomically_with_mode(temporary_path, "{}", 0o600)
120
+
121
+ assert closed_descriptors == [sentinel_descriptor]
122
+
123
+
124
+ def test_path_contains_glob_metacharacters_accepts_windows_paths_with_parens() -> None:
125
+ """Regression: Windows paths like C:/Program Files (x86)/ must not raise ValueError.
126
+
127
+ `(`, `)`, and `,` are not glob metacharacters in Claude Code's permission
128
+ rule matching. Including them in the metacharacter set causes
129
+ get_current_project_path to raise ValueError for any user whose home
130
+ directory contains parentheses (e.g. `C:/Users/Jon (Admin)/...`).
131
+ """
132
+ assert common.path_contains_glob_metacharacters("C:/Program Files (x86)/app") is False
133
+ assert common.path_contains_glob_metacharacters("C:/Users/Jon (Admin)/project") is False
134
+ assert common.path_contains_glob_metacharacters("C:/Projects/a,b/file.py") is False
135
+
@@ -0,0 +1,169 @@
1
+ """Tests for _shared permission helpers extracted from skills/bugteam/scripts/."""
2
+
3
+ import importlib.util
4
+ import inspect
5
+ import json
6
+ import sys
7
+ from pathlib import Path
8
+ from types import ModuleType
9
+
10
+ import pytest
11
+
12
+
13
+ def _load_common_module() -> ModuleType:
14
+ module_path = Path(__file__).parent.parent / "_claude_permissions_common.py"
15
+ parent_directory = str(module_path.parent.resolve())
16
+ if parent_directory not in sys.path:
17
+ sys.path.insert(0, parent_directory)
18
+ spec = importlib.util.spec_from_file_location(
19
+ "_claude_permissions_common", module_path
20
+ )
21
+ assert spec is not None
22
+ assert spec.loader is not None
23
+ module = importlib.util.module_from_spec(spec)
24
+ spec.loader.exec_module(module)
25
+ return module
26
+
27
+
28
+ common = _load_common_module()
29
+
30
+
31
+ def test_return_normalized_path_when_cwd_contains_spaces(
32
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
33
+ ) -> None:
34
+ directory_with_spaces = tmp_path / "dir with spaces"
35
+ directory_with_spaces.mkdir()
36
+ monkeypatch.chdir(directory_with_spaces)
37
+ returned_project_path = common.get_current_project_path()
38
+ expected_suffix = "/dir with spaces"
39
+ assert returned_project_path.endswith(expected_suffix)
40
+ assert "\\" not in returned_project_path
41
+ built_rule = common.build_permission_rule("Edit", returned_project_path)
42
+ assert built_rule.startswith("Edit(")
43
+ assert built_rule.endswith("/.claude/**)")
44
+ assert "dir with spaces" in built_rule
45
+
46
+
47
+ def test_raise_when_cwd_contains_glob_metacharacters(
48
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
49
+ ) -> None:
50
+ directory_with_star = tmp_path / "weird[dir]"
51
+ directory_with_star.mkdir()
52
+ monkeypatch.chdir(directory_with_star)
53
+ with pytest.raises(ValueError, match="glob metacharacters"):
54
+ common.get_current_project_path()
55
+
56
+
57
+ def test_flag_glob_metacharacters_in_any_position() -> None:
58
+ assert common.path_contains_glob_metacharacters("/home/user/[dir]/project")
59
+ assert common.path_contains_glob_metacharacters("/home/user/project*")
60
+ assert not common.path_contains_glob_metacharacters("/home/user/dir with spaces")
61
+
62
+
63
+ def test_text_file_encoding_remains_local_constant() -> None:
64
+ assert common.TEXT_FILE_ENCODING == "utf-8"
65
+
66
+
67
+ def test_module_no_longer_redeclares_migrated_constants() -> None:
68
+ assert not hasattr(common, "ALL_PERMISSION_ALLOW_TOOLS")
69
+ assert not hasattr(common, "AUTO_MODE_ENVIRONMENT_ENTRY_TEMPLATE")
70
+
71
+
72
+ def test_save_settings_uses_unique_per_call_temp_suffix(
73
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
74
+ ) -> None:
75
+ """Regression for atomic-write race: each save_settings call must
76
+ derive its own unique temp path so concurrent writers do not collide
77
+ on the same `.tmp` filename.
78
+ """
79
+ settings_path = tmp_path / "settings.json"
80
+ settings_path.write_text(json.dumps({}), encoding="utf-8")
81
+
82
+ captured_temp_paths: list[Path] = []
83
+ real_write_atomically_with_mode = common.write_atomically_with_mode
84
+
85
+ def capturing_write(
86
+ temporary_path: Path, serialized_content: str, file_mode: int
87
+ ) -> None:
88
+ captured_temp_paths.append(Path(temporary_path))
89
+ real_write_atomically_with_mode(
90
+ temporary_path, serialized_content, file_mode
91
+ )
92
+
93
+ monkeypatch.setattr(common, "write_atomically_with_mode", capturing_write)
94
+
95
+ common.save_settings(settings_path, {"writer": "first"})
96
+ common.save_settings(settings_path, {"writer": "second"})
97
+
98
+ assert len(captured_temp_paths) == 2
99
+ assert captured_temp_paths[0] != captured_temp_paths[1]
100
+ for each_temp_path in captured_temp_paths:
101
+ assert ".tmp." in each_temp_path.name
102
+
103
+
104
+ def test_save_settings_temp_suffix_includes_pid_and_random_token(
105
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
106
+ ) -> None:
107
+ """The unique temp suffix must include the process id and a random
108
+ hex token so two processes cannot collide on the same temp filename.
109
+ """
110
+ settings_path = tmp_path / "settings.json"
111
+ settings_path.write_text(json.dumps({}), encoding="utf-8")
112
+
113
+ captured_temp_paths: list[Path] = []
114
+ real_write_atomically_with_mode = common.write_atomically_with_mode
115
+
116
+ def capturing_write(
117
+ temporary_path: Path, serialized_content: str, file_mode: int
118
+ ) -> None:
119
+ captured_temp_paths.append(Path(temporary_path))
120
+ real_write_atomically_with_mode(
121
+ temporary_path, serialized_content, file_mode
122
+ )
123
+
124
+ monkeypatch.setattr(common, "write_atomically_with_mode", capturing_write)
125
+
126
+ common.save_settings(settings_path, {"writer": "only"})
127
+
128
+ assert len(captured_temp_paths) == 1
129
+ suffix_token = captured_temp_paths[0].name.split(".tmp.", maxsplit=1)[1]
130
+ pid_text, random_token = suffix_token.split(".", maxsplit=1)
131
+ assert pid_text.isdigit()
132
+ minimum_random_token_hex_chars = 4
133
+ assert len(random_token) >= minimum_random_token_hex_chars
134
+ assert all(
135
+ each_character in "0123456789abcdef" for each_character in random_token
136
+ )
137
+
138
+
139
+ def test_text_file_encoding_sourced_from_config() -> None:
140
+ config_module_path = (
141
+ Path(__file__).parent.parent / "config" / "claude_permissions_constants.py"
142
+ )
143
+ specification = importlib.util.spec_from_file_location(
144
+ "config.claude_permissions_constants", config_module_path
145
+ )
146
+ assert specification is not None
147
+ assert specification.loader is not None
148
+ config_module = importlib.util.module_from_spec(specification)
149
+ specification.loader.exec_module(config_module)
150
+ assert common.TEXT_FILE_ENCODING == config_module.TEXT_FILE_ENCODING
151
+
152
+
153
+ def test_path_contains_glob_metacharacters_local_tuple_uses_all_collection_prefix() -> None:
154
+ source_text = inspect.getsource(common.path_contains_glob_metacharacters)
155
+ assert "all_glob_metacharacters_in_path" in source_text
156
+ assert "glob_metacharacters_in_path:" not in source_text.replace(
157
+ "all_glob_metacharacters_in_path", ""
158
+ )
159
+
160
+
161
+ def test_is_valid_project_root_uses_extracted_directory_marker_constants() -> None:
162
+ """is_valid_project_root must reference extracted constants, not inline string literals."""
163
+ source_text = inspect.getsource(common.is_valid_project_root)
164
+ assert "GIT_DIRECTORY_NAME" in source_text
165
+ assert "CLAUDE_SETTINGS_DIRECTORY_NAME" in source_text
166
+ assert "'.git'" not in source_text
167
+ assert '".git"' not in source_text
168
+ assert "'.claude'" not in source_text
169
+ assert '".claude"' not in source_text
@@ -0,0 +1,58 @@
1
+ """Tests for shared constants powering grant/revoke claude permissions."""
2
+
3
+ import importlib.util
4
+ from pathlib import Path
5
+ from types import ModuleType
6
+
7
+
8
+ def _load_constants_module() -> ModuleType:
9
+ module_path = (
10
+ Path(__file__).parent.parent / "config" / "claude_permissions_constants.py"
11
+ )
12
+ specification = importlib.util.spec_from_file_location(
13
+ "config.claude_permissions_constants", module_path
14
+ )
15
+ assert specification is not None
16
+ assert specification.loader is not None
17
+ module = importlib.util.module_from_spec(specification)
18
+ specification.loader.exec_module(module)
19
+ return module
20
+
21
+
22
+ constants_module = _load_constants_module()
23
+
24
+
25
+ def test_exposes_all_permission_allow_tools_tuple() -> None:
26
+ assert constants_module.ALL_PERMISSION_ALLOW_TOOLS == ("Edit", "Write", "Read")
27
+
28
+
29
+ def test_auto_mode_environment_entry_template_is_format_string() -> None:
30
+ rendered_template_text = (
31
+ constants_module.AUTO_MODE_ENVIRONMENT_ENTRY_TEMPLATE.format(
32
+ project_path="/tmp/x"
33
+ )
34
+ )
35
+ assert "/tmp/x" in rendered_template_text
36
+ assert ".claude/**" in rendered_template_text
37
+
38
+
39
+ def test_get_claude_user_settings_path_ends_in_settings_json() -> None:
40
+ resolved_settings_path = constants_module.get_claude_user_settings_path()
41
+ assert resolved_settings_path.name == constants_module.CLAUDE_SETTINGS_FILENAME
42
+ assert (
43
+ resolved_settings_path.parent.name
44
+ == constants_module.CLAUDE_SETTINGS_DIRECTORY_NAME
45
+ )
46
+
47
+
48
+ def test_text_file_encoding_lives_in_config() -> None:
49
+ assert constants_module.TEXT_FILE_ENCODING == "utf-8"
50
+
51
+
52
+ def test_unique_temporary_suffix_byte_length_is_positive_integer() -> None:
53
+ assert isinstance(constants_module.UNIQUE_TEMPORARY_SUFFIX_BYTE_LENGTH, int)
54
+ assert constants_module.UNIQUE_TEMPORARY_SUFFIX_BYTE_LENGTH > 0
55
+
56
+
57
+ def test_git_directory_name_lives_in_config() -> None:
58
+ assert constants_module.GIT_DIRECTORY_NAME == ".git"
@@ -0,0 +1,50 @@
1
+ """Tests for claude_settings_keys_constants.py extracted constant set."""
2
+
3
+ import importlib.util
4
+ from pathlib import Path
5
+ from types import ModuleType
6
+
7
+
8
+ def _load_constants_module() -> ModuleType:
9
+ module_path = (
10
+ Path(__file__).parent.parent / "config" / "claude_settings_keys_constants.py"
11
+ )
12
+ specification = importlib.util.spec_from_file_location(
13
+ "config.claude_settings_keys_constants", module_path
14
+ )
15
+ assert specification is not None
16
+ assert specification.loader is not None
17
+ module = importlib.util.module_from_spec(specification)
18
+ specification.loader.exec_module(module)
19
+ return module
20
+
21
+
22
+ constants_module = _load_constants_module()
23
+
24
+
25
+ def test_permissions_key_is_typed_string() -> None:
26
+ assert isinstance(constants_module.CLAUDE_SETTINGS_PERMISSIONS_KEY, str)
27
+ assert constants_module.CLAUDE_SETTINGS_PERMISSIONS_KEY == "permissions"
28
+
29
+
30
+ def test_allow_key_is_typed_string() -> None:
31
+ assert isinstance(constants_module.CLAUDE_SETTINGS_ALLOW_KEY, str)
32
+ assert constants_module.CLAUDE_SETTINGS_ALLOW_KEY == "allow"
33
+
34
+
35
+ def test_additional_directories_key_is_typed_string() -> None:
36
+ assert isinstance(constants_module.CLAUDE_SETTINGS_ADDITIONAL_DIRECTORIES_KEY, str)
37
+ assert (
38
+ constants_module.CLAUDE_SETTINGS_ADDITIONAL_DIRECTORIES_KEY
39
+ == "additionalDirectories"
40
+ )
41
+
42
+
43
+ def test_auto_mode_key_is_typed_string() -> None:
44
+ assert isinstance(constants_module.CLAUDE_SETTINGS_AUTO_MODE_KEY, str)
45
+ assert constants_module.CLAUDE_SETTINGS_AUTO_MODE_KEY == "autoMode"
46
+
47
+
48
+ def test_environment_key_is_typed_string() -> None:
49
+ assert isinstance(constants_module.CLAUDE_SETTINGS_ENVIRONMENT_KEY, str)
50
+ assert constants_module.CLAUDE_SETTINGS_ENVIRONMENT_KEY == "environment"