claude-dev-env 2.29.0 → 2.30.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 (48) hide show
  1. package/.agents/agents/pr-description-writer.md +21 -6
  2. package/.agents/agents/reference/README.md +6 -0
  3. package/.agents/agents/reference/pr-description-illustrative-voice.md +45 -0
  4. package/.agents/agents/reference/pr-description-verification.md +19 -0
  5. package/.agents/agents/test_pr_description_writer_style.py +47 -0
  6. package/_shared/pr-loop/code-rules-gate.md +1 -1
  7. package/_shared/pr-loop/scripts/code_rules_gate.py +2 -2
  8. package/_shared/pr-loop/scripts/code_rules_gate_parts/enforcer_loading.py +8 -8
  9. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_enforcer_loading.py +2 -2
  10. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_gate_running.py +1 -1
  11. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +15 -15
  12. package/docs/CODE_RULES.md +10 -0
  13. package/hooks/blocking/code_rules_enforcer.py +433 -313
  14. package/hooks/blocking/test_code_rules_enforcer_banned_noun_word.py +1 -1
  15. package/hooks/blocking/test_code_rules_enforcer_class_docstring_methods.py +1 -1
  16. package/hooks/blocking/test_code_rules_enforcer_dispatch_wiring.py +117 -22
  17. package/hooks/blocking/test_code_rules_enforcer_docstring_args_signature.py +1 -1
  18. package/hooks/blocking/test_code_rules_enforcer_docstring_prose_wall_illustration.py +1 -1
  19. package/hooks/blocking/test_code_rules_enforcer_docstring_runon_sentence.py +1 -1
  20. package/hooks/blocking/test_code_rules_enforcer_docstring_unreferenced_param.py +1 -1
  21. package/hooks/blocking/test_code_rules_enforcer_fstring_scan.py +1 -1
  22. package/hooks/blocking/test_code_rules_enforcer_ignored_must_check_return.py +1 -1
  23. package/hooks/blocking/test_code_rules_enforcer_inline_tuple_string_magic.py +1 -1
  24. package/hooks/blocking/test_code_rules_enforcer_join_separator_magic.py +2 -2
  25. package/hooks/blocking/test_code_rules_enforcer_js_returns_object.py +3 -3
  26. package/hooks/blocking/test_code_rules_enforcer_module_docstring_roster.py +1 -1
  27. package/hooks/blocking/test_code_rules_enforcer_naming_pattern.py +1 -1
  28. package/hooks/blocking/test_code_rules_enforcer_narrow_edit.py +117 -38
  29. package/hooks/blocking/test_code_rules_enforcer_output_snapshot.py +121 -0
  30. package/hooks/blocking/test_code_rules_enforcer_precheck_forecast.py +17 -15
  31. package/hooks/blocking/test_code_rules_enforcer_split_entry_1.py +4 -2
  32. package/hooks/blocking/test_code_rules_enforcer_split_entry_2.py +1 -1
  33. package/hooks/blocking/test_code_rules_enforcer_stage.py +1 -1
  34. package/hooks/blocking/test_code_rules_enforcer_string_magic.py +4 -4
  35. package/hooks/blocking/test_code_rules_enforcer_thin_wrapper_files.py +2 -2
  36. package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias.py +2 -2
  37. package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias_hook_routing.py +2 -2
  38. package/hooks/blocking/test_code_rules_validation_phase.py +281 -0
  39. package/hooks/blocking/test_fable_spawn_gate.py +1 -1
  40. package/hooks/blocking/test_pre_tool_use_dispatcher.py +26 -11
  41. package/hooks/hooks_constants/code_rules_enforcer_constants.py +8 -0
  42. package/hooks/hooks_constants/test_code_rules_enforcer_constants.py +21 -0
  43. package/hooks/hooks_constants/validation_phase_constants.py +34 -0
  44. package/hooks/validators/README.md +1 -1
  45. package/hooks/validators/config/hook_timing_harness_constants.py +16 -0
  46. package/hooks/validators/hook_timing_harness.py +287 -0
  47. package/hooks/validators/test_hook_timing_harness.py +251 -0
  48. package/package.json +1 -1
@@ -28,7 +28,7 @@ assert hook_spec.loader is not None
28
28
  hook_module = importlib.util.module_from_spec(hook_spec)
29
29
  hook_spec.loader.exec_module(hook_module)
30
30
  check_banned_noun_word_boundary = hook_module.check_banned_noun_word_boundary
31
- validate_content = hook_module.validate_content
31
+ validate_content = hook_module.validate_content_for_full_gate
32
32
 
33
33
  from code_rules_banned_identifiers import ( # noqa: E402
34
34
  _find_banned_noun_word,
@@ -31,7 +31,7 @@ def check_class_docstring_names_public_methods(content: str, file_path: str) ->
31
31
 
32
32
 
33
33
  def validate_content(content: str, file_path: str, old_content: str) -> list[str]:
34
- return code_rules_enforcer.validate_content(content, file_path, old_content)
34
+ return code_rules_enforcer.validate_content_for_full_gate(content, file_path, old_content)
35
35
 
36
36
 
37
37
  PRODUCTION_FILE_PATH = "/project/src/break_reporter.py"
@@ -1,25 +1,49 @@
1
- """Meta-test asserting every check_* function is dispatched from validate_content.
1
+ """Meta-test asserting every check_* function is reachable from the full gate.
2
2
 
3
3
  The per-check test modules each prove one ``check_*`` function flags the right
4
4
  violation, but none proves the enforcer actually calls that function. A refactor
5
- that drops a dispatch line from ``validate_content`` leaves every per-check test
6
- green while the check stops firing at Write/Edit time — the precise failure mode
5
+ that drops a dispatch line from ``validate_content_for_full_gate`` or from one
6
+ of its extension-issue helper functions leaves every per-check test green
7
+ while the check stops firing at Write/Edit time — the precise failure mode
7
8
  that would let a dead module-level constant (the ``MEDIUM_TEXT`` class) or an
8
9
  orphan CSS class slip past the gate again.
9
10
 
10
- This module reads ``validate_content``'s source and asserts every ``check_*``
11
- attribute on the enforcer module appears in it. A check that is intentionally
12
- not wired must be listed in ``KNOWN_UNDISPATCHED_CHECKS`` with a reason in this
13
- docstring. ``check_unanchored_command_dispatch`` is listed there: it guards a
14
- ``hooks/blocking`` command classifier, and the whole ``validate_content`` verdict
15
- stays off hook-infrastructure files, so the enforcer dispatches it from
11
+ ::
12
+
13
+ validate_content_for_full_gate -> _python_extension_issues -> _python_magic_value_and_constant_issues
14
+ |
15
+ v
16
+ references check_magic_values
17
+ -> check_magic_values counts as reachable
18
+
19
+ check_unanchored_command_dispatch: called only from
20
+ _hook_infrastructure_blocking_issues, which the full gate's call graph
21
+ never reaches -> stays in KNOWN_UNDISPATCHED_CHECKS
22
+
23
+ This module walks the enforcer's real call graph from
24
+ ``validate_content_for_full_gate``, following every direct function call into
25
+ the functions the enforcer module defines, and collects every
26
+ ``check_*``/``advise_*`` name each reached function body references — a direct
27
+ call or a bare reference passed on as a callback (the
28
+ ``_fragment_or_deferred_check(check_magic_values, ...)`` shape). A name that
29
+ surfaces only in an import statement or a docstring is not a reference inside
30
+ a reached function body, so it does not count; this is what keeps the check
31
+ from going vacuous the way a whole-module substring scan would.
32
+
33
+ A check that is intentionally not wired must be listed in
34
+ ``KNOWN_UNDISPATCHED_CHECKS`` with a reason in this docstring.
35
+ ``check_unanchored_command_dispatch`` is listed there: it guards a
36
+ ``hooks/blocking`` command classifier, and the whole
37
+ ``validate_content_for_full_gate`` verdict stays off hook-infrastructure
38
+ files, so the enforcer dispatches it from
16
39
  ``_hook_infrastructure_blocking_issues`` instead. The companion
17
- ``test_code_rules_enforcer_cap_meta.py`` guards the payload-cap convention; this
18
- module guards the wiring.
40
+ ``test_code_rules_enforcer_cap_meta.py`` guards the payload-cap convention;
41
+ this module guards the wiring.
19
42
  """
20
43
 
21
44
  from __future__ import annotations
22
45
 
46
+ import ast
23
47
  import importlib.util
24
48
  import inspect
25
49
  import pathlib
@@ -42,6 +66,8 @@ KNOWN_UNDISPATCHED_CHECKS: frozenset[str] = frozenset(
42
66
  {"check_unanchored_command_dispatch"}
43
67
  )
44
68
 
69
+ DISPATCH_ENTRY_POINT_NAME = "validate_content_for_full_gate"
70
+
45
71
 
46
72
  def _all_check_function_names() -> list[str]:
47
73
  return [
@@ -52,22 +78,91 @@ def _all_check_function_names() -> list[str]:
52
78
  ]
53
79
 
54
80
 
55
- def _validate_content_source() -> str:
56
- return inspect.getsource(_hook_module.validate_content)
81
+ def _module_level_function_defs(module_tree: ast.Module) -> dict[str, ast.FunctionDef]:
82
+ """Map each module-level function's name to its definition node."""
83
+ return {
84
+ each_node.name: each_node
85
+ for each_node in module_tree.body
86
+ if isinstance(each_node, ast.FunctionDef)
87
+ }
57
88
 
58
89
 
59
- def test_every_check_function_is_called_in_validate_content() -> None:
60
- all_check_names = set(_all_check_function_names())
61
- validate_content_source = _validate_content_source()
62
- undispatched_check_names = {
63
- each_name for each_name in all_check_names if each_name not in validate_content_source
90
+ def _local_call_targets(function_node: ast.FunctionDef) -> set[str]:
91
+ """Return the plain-name targets one function directly calls."""
92
+ return {
93
+ each_call.func.id
94
+ for each_call in ast.walk(function_node)
95
+ if isinstance(each_call, ast.Call) and isinstance(each_call.func, ast.Name)
96
+ }
97
+
98
+
99
+ def _unvisited_local_calls(
100
+ function_name: str,
101
+ function_defs_by_name: dict[str, ast.FunctionDef],
102
+ visited: set[str],
103
+ ) -> list[str]:
104
+ """Return *function_name*'s call targets not already visited by the walk."""
105
+ function_node = function_defs_by_name.get(function_name)
106
+ if function_node is None:
107
+ return []
108
+ return [each for each in _local_call_targets(function_node) if each not in visited]
109
+
110
+
111
+ def _reachable_function_names(
112
+ start_name: str, function_defs_by_name: dict[str, ast.FunctionDef]
113
+ ) -> set[str]:
114
+ """Return every function name the call graph reaches, starting from start_name."""
115
+ visited: set[str] = set()
116
+ pending = [start_name]
117
+ while pending:
118
+ each_pending_name = pending.pop()
119
+ if each_pending_name in visited:
120
+ continue
121
+ visited.add(each_pending_name)
122
+ pending.extend(_unvisited_local_calls(each_pending_name, function_defs_by_name, visited))
123
+ return visited
124
+
125
+
126
+ def _referenced_names(function_node: ast.FunctionDef) -> set[str]:
127
+ """Return every loaded identifier one function body references.
128
+
129
+ A callback-style dispatch (``_fragment_or_deferred_check(check_magic_values,
130
+ ...)``) passes the check function by bare name rather than calling it
131
+ directly, so this collects every loaded ``Name``, not only call targets.
132
+ """
133
+ return {
134
+ each_name.id
135
+ for each_name in ast.walk(function_node)
136
+ if isinstance(each_name, ast.Name) and isinstance(each_name.ctx, ast.Load)
64
137
  }
138
+
139
+
140
+ def _reachable_referenced_names(
141
+ start_name: str, function_defs_by_name: dict[str, ast.FunctionDef]
142
+ ) -> set[str]:
143
+ """Return every identifier referenced by a function the call graph reaches."""
144
+ reachable_function_names = _reachable_function_names(start_name, function_defs_by_name)
145
+ referenced_names: set[str] = set()
146
+ for each_function_name in reachable_function_names:
147
+ function_node = function_defs_by_name.get(each_function_name)
148
+ if function_node is not None:
149
+ referenced_names.update(_referenced_names(function_node))
150
+ return referenced_names
151
+
152
+
153
+ def test_every_check_function_is_reachable_from_the_full_gate() -> None:
154
+ module_tree = ast.parse(inspect.getsource(_hook_module))
155
+ function_defs_by_name = _module_level_function_defs(module_tree)
156
+ reachable_names = _reachable_referenced_names(DISPATCH_ENTRY_POINT_NAME, function_defs_by_name)
157
+ all_check_names = set(_all_check_function_names())
158
+ undispatched_check_names = all_check_names - reachable_names
65
159
  unexpected_undispatched = undispatched_check_names - KNOWN_UNDISPATCHED_CHECKS
66
160
  assert unexpected_undispatched == set(), (
67
- f"check_* functions are imported but never called in validate_content: "
68
- f"{sorted(unexpected_undispatched)}. Wire each into validate_content so the "
69
- f"check fires at Write/Edit time, or list it in KNOWN_UNDISPATCHED_CHECKS "
70
- f"with a reason in the test header docstring."
161
+ f"check_* functions are imported but never reachable from "
162
+ f"validate_content_for_full_gate's call graph: {sorted(unexpected_undispatched)}. "
163
+ f"Wire each into validate_content_for_phase or one of its extension-issue "
164
+ f"helpers so the check fires at Write/Edit time, or list it in "
165
+ f"KNOWN_UNDISPATCHED_CHECKS with a reason in the test header docstring."
71
166
  )
72
167
 
73
168
 
@@ -30,7 +30,7 @@ def check_docstring_args_match_signature(content: str, file_path: str) -> list[s
30
30
 
31
31
 
32
32
  def validate_content(content: str, file_path: str, old_content: str) -> list[str]:
33
- return code_rules_enforcer.validate_content(content, file_path, old_content)
33
+ return code_rules_enforcer.validate_content_for_full_gate(content, file_path, old_content)
34
34
 
35
35
 
36
36
  PRODUCTION_FILE_PATH = "/project/src/services.py"
@@ -39,7 +39,7 @@ def check_docstring_prose_wall_without_illustration(content: str, file_path: str
39
39
 
40
40
 
41
41
  def validate_content(content: str, file_path: str, old_content: str) -> list[str]:
42
- return code_rules_enforcer.validate_content(content, file_path, old_content)
42
+ return code_rules_enforcer.validate_content_for_full_gate(content, file_path, old_content)
43
43
 
44
44
 
45
45
  PRODUCTION_FILE_PATH = "/project/src/tally.py"
@@ -42,7 +42,7 @@ def check_docstring_runon_sentence(content: str, file_path: str) -> list[str]:
42
42
 
43
43
 
44
44
  def validate_content(content: str, file_path: str, old_content: str) -> list[str]:
45
- return code_rules_enforcer.validate_content(content, file_path, old_content)
45
+ return code_rules_enforcer.validate_content_for_full_gate(content, file_path, old_content)
46
46
 
47
47
 
48
48
  PRODUCTION_FILE_PATH = "/project/src/run_lifecycle.py"
@@ -32,7 +32,7 @@ def check_documents_unreferenced_parameter(content: str, file_path: str) -> list
32
32
 
33
33
 
34
34
  def validate_content(content: str, file_path: str, old_content: str) -> list[str]:
35
- return code_rules_enforcer.validate_content(content, file_path, old_content)
35
+ return code_rules_enforcer.validate_content_for_full_gate(content, file_path, old_content)
36
36
 
37
37
 
38
38
  PRODUCTION_FILE_PATH = "/project/src/per_theme_loop.py"
@@ -17,7 +17,7 @@ assert hook_spec.loader is not None
17
17
  hook_module = importlib.util.module_from_spec(hook_spec)
18
18
  hook_spec.loader.exec_module(hook_module)
19
19
  check_fstring_structural_literals = hook_module.check_fstring_structural_literals
20
- validate_content = hook_module.validate_content
20
+ validate_content = hook_module.validate_content_for_full_gate
21
21
 
22
22
  PRODUCTION_FILE_PATH = "packages/claude-dev-env/example.py"
23
23
  TEST_FILE_PATH = "packages/claude-dev-env/test_example.py"
@@ -25,7 +25,7 @@ from code_rules_boolean_mustcheck import ( # noqa: E402
25
25
  check_ignored_must_check_return as _check_ignored_must_check_return,
26
26
  )
27
27
  from code_rules_enforcer import ( # noqa: E402
28
- validate_content as _validate_content,
28
+ validate_content_for_full_gate as _validate_content,
29
29
  )
30
30
 
31
31
  code_rules_enforcer = SimpleNamespace(
@@ -23,7 +23,7 @@ assert hook_spec.loader is not None
23
23
  hook_module = importlib.util.module_from_spec(hook_spec)
24
24
  hook_spec.loader.exec_module(hook_module)
25
25
  check_inline_tuple_string_magic = hook_module.check_inline_tuple_string_magic
26
- validate_content = hook_module.validate_content
26
+ validate_content = hook_module.validate_content_for_full_gate
27
27
 
28
28
  PRODUCTION_FILE_PATH = "packages/app/services/loader.py"
29
29
  TEST_FILE_PATH = "packages/app/services/test_loader.py"
@@ -77,7 +77,7 @@ def test_should_grandfather_unchanged_join_separator_in_wide_edit() -> None:
77
77
  " # keep the join separator the same\n"
78
78
  " return ', '.join(str(each_path) for each_path in all_paths)\n"
79
79
  )
80
- issues = code_rules_enforcer.validate_content(
80
+ issues = code_rules_enforcer.validate_content_for_full_gate(
81
81
  proposed_fragment,
82
82
  PRODUCTION_FILE_PATH,
83
83
  prior_fragment,
@@ -97,7 +97,7 @@ def test_should_block_new_join_separator_in_wide_edit() -> None:
97
97
  "def render(all_paths: list) -> str:\n"
98
98
  " return ', '.join(all_paths)\n"
99
99
  )
100
- issues = code_rules_enforcer.validate_content(
100
+ issues = code_rules_enforcer.validate_content_for_full_gate(
101
101
  proposed_fragment,
102
102
  PRODUCTION_FILE_PATH,
103
103
  prior_fragment,
@@ -1,6 +1,6 @@
1
1
  """Enforcer-dispatch tests for the JS returns-object schema-less branch check.
2
2
 
3
- These drive ``validate_content`` end-to-end on a ``.mjs`` payload, so they prove
3
+ These drive ``validate_content_for_full_gate`` end-to-end on a ``.mjs`` payload, so they prove
4
4
  the check is wired into the JavaScript branch of the enforcer, not just callable
5
5
  in isolation. The drift: a ``function`` whose JSDoc ``@returns {Promise<object>}``
6
6
  promises a structured object while one branch returns the agent helper with an
@@ -62,11 +62,11 @@ def _has_returns_object_finding(all_issues: list[str]) -> bool:
62
62
 
63
63
  def test_enforcer_reports_schema_less_branch_under_returns_object() -> None:
64
64
  drift_source = _mixed_schema_mjs()
65
- all_issues = code_rules_enforcer.validate_content(drift_source, _MJS_PATH, drift_source)
65
+ all_issues = code_rules_enforcer.validate_content_for_full_gate(drift_source, _MJS_PATH, drift_source)
66
66
  assert _has_returns_object_finding(all_issues)
67
67
 
68
68
 
69
69
  def test_enforcer_accepts_every_branch_with_a_schema() -> None:
70
70
  clean_source = _all_schema_mjs()
71
- all_issues = code_rules_enforcer.validate_content(clean_source, _MJS_PATH, clean_source)
71
+ all_issues = code_rules_enforcer.validate_content_for_full_gate(clean_source, _MJS_PATH, clean_source)
72
72
  assert not _has_returns_object_finding(all_issues)
@@ -31,7 +31,7 @@ def check_module_docstring_names_public_checks(content: str, file_path: str) ->
31
31
 
32
32
 
33
33
  def validate_content(content: str, file_path: str, old_content: str) -> list[str]:
34
- return code_rules_enforcer.validate_content(content, file_path, old_content)
34
+ return code_rules_enforcer.validate_content_for_full_gate(content, file_path, old_content)
35
35
 
36
36
 
37
37
  HOOK_INFRASTRUCTURE_PATH = "/home/user/.claude/hooks/blocking/code_rules_test_assertions.py"
@@ -18,7 +18,7 @@ assert _hook_spec.loader is not None
18
18
  _hook_module = importlib.util.module_from_spec(_hook_spec)
19
19
  _hook_spec.loader.exec_module(_hook_module)
20
20
  check_boolean_naming = _hook_module.check_boolean_naming
21
- validate_content = _hook_module.validate_content
21
+ validate_content = _hook_module.validate_content_for_full_gate
22
22
 
23
23
 
24
24
  PRODUCTION_FILE_PATH = "src/app/feature.py"
@@ -20,7 +20,7 @@ if _HOOKS_DIRECTORY not in sys.path:
20
20
 
21
21
  from code_rules_enforcer import ( # noqa: E402
22
22
  main,
23
- validate_content,
23
+ validate_content_for_full_gate as validate_content,
24
24
  )
25
25
 
26
26
 
@@ -165,47 +165,126 @@ def _validate_narrow_edit(narrow_edit_fixture: NarrowEditFixture) -> list[str]:
165
165
  )
166
166
 
167
167
 
168
- def _scope_aware_rule_names_from_source() -> frozenset[str]:
169
- source_tree = ast.parse(inspect.getsource(validate_content))
170
- all_rule_names: set[str] = set()
171
- for each_call in ast.walk(source_tree):
172
- if not isinstance(each_call, ast.Call):
168
+ _SCOPE_CARRYING_ATTRIBUTE_NAMES = frozenset({"all_changed_lines", "defer_scope_to_caller"})
169
+
170
+
171
+ def _assignment_target_names(target: ast.expr) -> list[str]:
172
+ """Return the plain names one assignment target binds, Name or Tuple-of-Name."""
173
+ if isinstance(target, ast.Name):
174
+ return [target.id]
175
+ if isinstance(target, ast.Tuple):
176
+ return [each.id for each in target.elts if isinstance(each, ast.Name)]
177
+ return []
178
+
179
+
180
+ def _scope_aliases_for_pair(target_names: list[str], all_values: list[ast.expr]) -> dict[str, str]:
181
+ """Return the local-name-to-scope-attribute map one assignment pair contributes."""
182
+ aliases: dict[str, str] = {}
183
+ for each_name, each_value in zip(target_names, all_values):
184
+ if not isinstance(each_value, ast.Attribute):
173
185
  continue
174
- if not isinstance(each_call.func, ast.Name):
186
+ if each_value.attr not in _SCOPE_CARRYING_ATTRIBUTE_NAMES:
175
187
  continue
176
- if each_call.func.id == "_fragment_or_deferred_check":
177
- all_argument_names = {
178
- each_name.id
179
- for each_argument in each_call.args[1:]
180
- for each_name in ast.walk(each_argument)
181
- if isinstance(each_name, ast.Name)
182
- }
183
- if (
184
- "defer_scope_to_caller" in all_argument_names
185
- and each_call.args
186
- and isinstance(each_call.args[0], ast.Name)
187
- ):
188
- all_rule_names.add(each_call.args[0].id)
188
+ aliases[each_name] = each_value.attr
189
+ return aliases
190
+
191
+
192
+ def _context_alias_map(function_node: ast.AST) -> dict[str, str]:
193
+ """Map each local name one function binds from ``context.<scope attribute>``.
194
+
195
+ A dispatch helper unpacks ``context.all_changed_lines`` and
196
+ ``context.defer_scope_to_caller`` into a short local name (``changed``,
197
+ ``defer``) before passing it on, so a call carrying that local name still
198
+ carries scope, even though the local name spells out none of it.
199
+ """
200
+ aliases: dict[str, str] = {}
201
+ for each_node in ast.walk(function_node):
202
+ if not isinstance(each_node, ast.Assign) or len(each_node.targets) != 1:
189
203
  continue
190
- if not each_call.func.id.startswith("check_"):
204
+ target_names = _assignment_target_names(each_node.targets[0])
205
+ if not target_names:
191
206
  continue
192
- all_argument_names = {
193
- each_name.id
194
- for each_argument in each_call.args
195
- for each_name in ast.walk(each_argument)
196
- if isinstance(each_name, ast.Name)
197
- }
198
- all_argument_names.update(
199
- each_name.id
200
- for each_keyword in each_call.keywords
201
- for each_name in ast.walk(each_keyword.value)
202
- if isinstance(each_name, ast.Name)
203
- )
204
- if (
205
- "all_changed_lines" in all_argument_names
206
- or "defer_scope_to_caller" in all_argument_names
207
- ):
208
- all_rule_names.add(each_call.func.id)
207
+ value = each_node.value
208
+ all_values = value.elts if isinstance(value, ast.Tuple) else [value]
209
+ aliases.update(_scope_aliases_for_pair(target_names, all_values))
210
+ return aliases
211
+
212
+
213
+ def _scope_names_for_reference(reference: ast.AST, aliases: dict[str, str]) -> set[str]:
214
+ """Return the scope-carrying attribute name one AST reference resolves to, if any."""
215
+ if isinstance(reference, ast.Name) and reference.id in aliases:
216
+ return {aliases[reference.id]}
217
+ if isinstance(reference, ast.Attribute) and reference.attr in _SCOPE_CARRYING_ATTRIBUTE_NAMES:
218
+ return {reference.attr}
219
+ return set()
220
+
221
+
222
+ def _referenced_scope_attribute_names(node: ast.AST, aliases: dict[str, str]) -> set[str]:
223
+ """Return every scope-carrying attribute name *node* references, direct or aliased."""
224
+ found: set[str] = set()
225
+ for each_reference in ast.walk(node):
226
+ found.update(_scope_names_for_reference(each_reference, aliases))
227
+ return found
228
+
229
+
230
+ def _fragment_deferred_rule_name(call_node: ast.Call, aliases: dict[str, str]) -> str | None:
231
+ """Return the rule name one ``_fragment_or_deferred_check`` call selects, if scoped."""
232
+ argument_scope_names = {
233
+ each_scope_name
234
+ for each_argument in call_node.args[1:]
235
+ for each_scope_name in _referenced_scope_attribute_names(each_argument, aliases)
236
+ }
237
+ if "defer_scope_to_caller" not in argument_scope_names:
238
+ return None
239
+ if not call_node.args or not isinstance(call_node.args[0], ast.Name):
240
+ return None
241
+ return call_node.args[0].id
242
+
243
+
244
+ def _check_call_rule_name(call_node: ast.Call, aliases: dict[str, str]) -> str | None:
245
+ """Return the rule name one direct ``check_*`` call selects, if it carries scope."""
246
+ func_name = call_node.func.id # type: ignore[attr-defined] # caller already narrowed func to ast.Name
247
+ if not func_name.startswith("check_"):
248
+ return None
249
+ all_call_arguments = (*call_node.args, *(each_keyword.value for each_keyword in call_node.keywords))
250
+ argument_scope_names = {
251
+ each_scope_name
252
+ for each_argument in all_call_arguments
253
+ for each_scope_name in _referenced_scope_attribute_names(each_argument, aliases)
254
+ }
255
+ return func_name if argument_scope_names else None
256
+
257
+
258
+ def _rule_names_from_function(function_node: ast.FunctionDef) -> set[str]:
259
+ """Return the check_* names *function_node* calls with scope-carrying arguments."""
260
+ aliases = _context_alias_map(function_node)
261
+ rule_names: set[str] = set()
262
+ for each_call in ast.walk(function_node):
263
+ if not isinstance(each_call, ast.Call) or not isinstance(each_call.func, ast.Name):
264
+ continue
265
+ if each_call.func.id == "_fragment_or_deferred_check":
266
+ rule_name = _fragment_deferred_rule_name(each_call, aliases)
267
+ else:
268
+ rule_name = _check_call_rule_name(each_call, aliases)
269
+ if rule_name is not None:
270
+ rule_names.add(rule_name)
271
+ return rule_names
272
+
273
+
274
+ def _scope_aware_rule_names_from_source() -> frozenset[str]:
275
+ """Return every check_* name dispatched anywhere in the enforcer with scope.
276
+
277
+ Walks every function in the enforcer module rather than only
278
+ ``validate_content``, since the extension-issue helper functions hold the
279
+ actual dispatch calls today.
280
+ """
281
+ enforcer_module = inspect.getmodule(validate_content)
282
+ assert enforcer_module is not None
283
+ module_tree = ast.parse(inspect.getsource(enforcer_module))
284
+ all_rule_names: set[str] = set()
285
+ for each_function in ast.walk(module_tree):
286
+ if isinstance(each_function, ast.FunctionDef):
287
+ all_rule_names.update(_rule_names_from_function(each_function))
209
288
  return frozenset(all_rule_names)
210
289
 
211
290
 
@@ -0,0 +1,121 @@
1
+ """Golden-output snapshot for the phase-split validators, guarding both phases.
2
+
3
+ Records the full-gate phase's ordered issue list for three representative
4
+ fixtures — a Python cross-file duplicate-body hit, a JavaScript boolean-naming
5
+ hit, and a Python-built-HTML orphan-CSS-class hit — so a decomposition that
6
+ reorders or drops a dispatch line shows up as a changed list, not a
7
+ still-green suite. Each fixture also records the edit-lane phase's list, which
8
+ matches the full-gate list except for the cross-file finding the edit lane
9
+ skips.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import importlib.util
15
+ from pathlib import Path
16
+ from types import ModuleType
17
+
18
+
19
+ def _load_module_from_path(module_name: str, module_path: Path) -> ModuleType:
20
+ """Load and execute the module found at module_path, under module_name."""
21
+ specification = importlib.util.spec_from_file_location(module_name, module_path)
22
+ assert specification is not None
23
+ assert specification.loader is not None
24
+ module = importlib.util.module_from_spec(specification)
25
+ specification.loader.exec_module(module)
26
+ return module
27
+
28
+
29
+ _BLOCKING_DIRECTORY = Path(__file__).resolve().parent
30
+ code_rules_enforcer = _load_module_from_path(
31
+ "code_rules_enforcer", _BLOCKING_DIRECTORY / "code_rules_enforcer.py"
32
+ )
33
+
34
+ _DUPLICATE_FUNCTION_SOURCE = (
35
+ "def compute_shipping_total(order_total: float, tax_rate: float) -> float:\n"
36
+ " taxed_total = order_total * (1 + tax_rate)\n"
37
+ " rounded_total = round(taxed_total, 2)\n"
38
+ " return rounded_total\n"
39
+ )
40
+ _JS_BOOLEAN_SOURCE = "const active = true;\n"
41
+ _CSS_ORPHAN_CLASS_SOURCE = (
42
+ '"""Render a status badge."""\n\n'
43
+ "STATUS_MARKUP = '<div class=\"status-badge\">Active</div>'\n"
44
+ "STYLE_BLOCK = '<style>.status-pill { color: green; }</style>'\n"
45
+ )
46
+
47
+ EXPECTED_PYTHON_CROSS_FILE_ISSUES = [
48
+ "Line 3: Magic value 2 - extract to named constant",
49
+ "Function 'compute_shipping_total' duplicates shipping_helper.py::compute_shipping_total"
50
+ " — this function body is identical to one in a sibling module; extract a single shared"
51
+ " helper (for example in hooks_constants/) and import it from both modules instead of"
52
+ " copying it (Reuse before create / DRY) (duplicate body span at line 1, spanning 4 lines)",
53
+ ]
54
+ EXPECTED_PYTHON_CROSS_FILE_EDIT_LANE_ISSUES = [
55
+ "Line 3: Magic value 2 - extract to named constant",
56
+ ]
57
+ EXPECTED_JS_ISSUES = [
58
+ "Line 1: Boolean active - prefix with is/has/should/can/was/did",
59
+ ]
60
+ EXPECTED_CSS_ISSUES = [
61
+ "Line 3: Constant STATUS_MARKUP - move to config/",
62
+ "Line 4: Constant STYLE_BLOCK - move to config/",
63
+ "Line 3: CSS class 'status-badge' used in markup has no matching '.status-badge' selector"
64
+ " - add a matching '.<class>' selector to the <style> block, or drop the unused class"
65
+ " attribute (CODE_RULES self-documenting markup)",
66
+ ]
67
+ EXPECTED_CSS_EDIT_LANE_ISSUES = [
68
+ "Line 3: Constant STATUS_MARKUP - move to config/",
69
+ "Line 4: Constant STYLE_BLOCK - move to config/",
70
+ ]
71
+
72
+
73
+ def test_python_cross_file_duplicate_snapshot_is_unchanged(tmp_path: Path) -> None:
74
+ """Both phases' ordered output for a cross-file duplicate stays fixed.
75
+
76
+ ``file_path`` is a fixed classification-only path, never opened, so the
77
+ real ``tmp_path`` sibling scan (which embeds this test's own name, a
78
+ ``test_`` match) never misclassifies the fixture as a test file.
79
+ """
80
+ sibling_dir = tmp_path / "sibling_dir"
81
+ sibling_dir.mkdir()
82
+ (sibling_dir / "shipping_helper.py").write_text(_DUPLICATE_FUNCTION_SOURCE, encoding="utf-8")
83
+ classification_only_file_path = "/repo/packages/demo/shipping/shipping_target.py"
84
+
85
+ full_gate_issues = code_rules_enforcer.validate_content_for_full_gate(
86
+ _DUPLICATE_FUNCTION_SOURCE,
87
+ classification_only_file_path,
88
+ "",
89
+ sibling_directory=sibling_dir,
90
+ )
91
+ edit_lane_issues = code_rules_enforcer.validate_content_for_edit_lane(
92
+ _DUPLICATE_FUNCTION_SOURCE,
93
+ classification_only_file_path,
94
+ "",
95
+ sibling_directory=sibling_dir,
96
+ )
97
+
98
+ assert full_gate_issues == EXPECTED_PYTHON_CROSS_FILE_ISSUES
99
+ assert edit_lane_issues == EXPECTED_PYTHON_CROSS_FILE_EDIT_LANE_ISSUES
100
+
101
+
102
+ def test_javascript_boolean_naming_snapshot_is_unchanged() -> None:
103
+ full_gate_issues = code_rules_enforcer.validate_content_for_full_gate(
104
+ _JS_BOOLEAN_SOURCE, "packages/app/frontend/status.js", ""
105
+ )
106
+ edit_lane_issues = code_rules_enforcer.validate_content_for_edit_lane(
107
+ _JS_BOOLEAN_SOURCE, "packages/app/frontend/status.js", ""
108
+ )
109
+ assert full_gate_issues == EXPECTED_JS_ISSUES
110
+ assert edit_lane_issues == EXPECTED_JS_ISSUES
111
+
112
+
113
+ def test_python_orphan_css_class_snapshot_is_unchanged() -> None:
114
+ full_gate_issues = code_rules_enforcer.validate_content_for_full_gate(
115
+ _CSS_ORPHAN_CLASS_SOURCE, "packages/app/widgets/status_widget.py", ""
116
+ )
117
+ edit_lane_issues = code_rules_enforcer.validate_content_for_edit_lane(
118
+ _CSS_ORPHAN_CLASS_SOURCE, "packages/app/widgets/status_widget.py", ""
119
+ )
120
+ assert full_gate_issues == EXPECTED_CSS_ISSUES
121
+ assert edit_lane_issues == EXPECTED_CSS_EDIT_LANE_ISSUES