claude-dev-env 1.78.0 → 1.80.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 (63) hide show
  1. package/_shared/pr-loop/scripts/CLAUDE.md +1 -0
  2. package/_shared/pr-loop/scripts/copilot_quota.py +360 -0
  3. package/_shared/pr-loop/scripts/pr_loop_shared_constants/CLAUDE.md +1 -0
  4. package/_shared/pr-loop/scripts/pr_loop_shared_constants/copilot_quota_constants.py +24 -0
  5. package/_shared/pr-loop/scripts/tests/CLAUDE.md +7 -0
  6. package/_shared/pr-loop/scripts/tests/fixtures/copilot_internal_user_jonecho.json +76 -0
  7. package/_shared/pr-loop/scripts/tests/test_copilot_quota.py +242 -0
  8. package/_shared/pr-loop/scripts/tests/test_copilot_quota_constants.py +63 -0
  9. package/audit-rubrics/category_rubrics/category-k-codebase-conflicts.md +1 -0
  10. package/bin/install.mjs +1 -0
  11. package/bin/install.test.mjs +3 -2
  12. package/hooks/blocking/CLAUDE.md +3 -2
  13. package/hooks/blocking/code_rules_docstrings.py +609 -16
  14. package/hooks/blocking/code_rules_enforcer.py +41 -0
  15. package/hooks/blocking/code_rules_imports_logging.py +136 -1
  16. package/hooks/blocking/code_rules_naming_collection.py +76 -1
  17. package/hooks/blocking/code_rules_paired_test.py +240 -22
  18. package/hooks/blocking/code_rules_test_assertions.py +159 -1
  19. package/hooks/blocking/code_rules_unused_imports.py +7 -63
  20. package/hooks/blocking/env_var_table_code_drift_blocker.py +475 -0
  21. package/hooks/blocking/package_inventory_stale_blocker.py +54 -15
  22. package/hooks/blocking/test_code_rules_enforcer_docstring_field_runmode_outcome.py +129 -0
  23. package/hooks/blocking/test_code_rules_enforcer_docstring_length_constant_superlative.py +198 -0
  24. package/hooks/blocking/test_code_rules_enforcer_docstring_no_network.py +115 -0
  25. package/hooks/blocking/test_code_rules_enforcer_docstring_unreferenced_param.py +160 -0
  26. package/hooks/blocking/test_code_rules_enforcer_js_returns_object.py +72 -0
  27. package/hooks/blocking/test_code_rules_enforcer_module_docstring_data_schema_scope.py +82 -0
  28. package/hooks/blocking/test_code_rules_enforcer_paired_test.py +190 -0
  29. package/hooks/blocking/test_code_rules_enforcer_polarity_name_contradiction.py +76 -0
  30. package/hooks/blocking/test_code_rules_enforcer_unused_imports.py +96 -15
  31. package/hooks/blocking/test_code_rules_enforcer_vacuous_cleanup_assertion.py +132 -0
  32. package/hooks/blocking/test_code_rules_js_returns_object_schemaless.py +167 -0
  33. package/hooks/blocking/test_env_var_table_code_drift_blocker.py +94 -0
  34. package/hooks/blocking/test_package_inventory_stale_blocker.py +46 -0
  35. package/hooks/blocking/test_pre_tool_use_dispatcher.py +7 -7
  36. package/hooks/hooks_constants/CLAUDE.md +1 -0
  37. package/hooks/hooks_constants/blocking_check_limits.py +79 -0
  38. package/hooks/hooks_constants/code_rules_enforcer_constants.py +28 -0
  39. package/hooks/hooks_constants/env_var_table_code_drift_constants.py +64 -0
  40. package/hooks/hooks_constants/package_inventory_stale_blocker_constants.py +20 -9
  41. package/hooks/hooks_constants/paired_test_coverage_constants.py +11 -3
  42. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +4 -0
  43. package/hooks/hooks_constants/test_code_rules_enforcer_constants.py +93 -0
  44. package/hooks/hooks_constants/unused_module_import_constants.py +0 -1
  45. package/package.json +1 -1
  46. package/rules/CLAUDE.md +1 -0
  47. package/rules/docstring-prose-matches-implementation.md +57 -54
  48. package/rules/env-var-table-code-drift.md +24 -0
  49. package/rules/package-inventory-stale-entry.md +4 -4
  50. package/rules/paired-test-coverage.md +12 -5
  51. package/skills/autoconverge/SKILL.md +26 -5
  52. package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +4 -4
  53. package/skills/autoconverge/workflow/converge.contract.test.mjs +56 -120
  54. package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +45 -5
  55. package/skills/autoconverge/workflow/converge.fix-recovery.test.mjs +50 -46
  56. package/skills/autoconverge/workflow/converge.merge-conflict.test.mjs +6 -6
  57. package/skills/autoconverge/workflow/converge.mjs +110 -228
  58. package/skills/autoconverge/workflow/converge.run-input.test.mjs +11 -0
  59. package/skills/autoconverge/workflow/converge_multi.mjs +23 -7
  60. package/skills/autoconverge/workflow/converge_multi.run-input.test.mjs +28 -2
  61. package/skills/pr-converge/SKILL.md +28 -2
  62. package/skills/pr-converge/reference/convergence-gates.md +13 -1
  63. package/skills/pr-converge/reference/state-schema.md +11 -0
@@ -0,0 +1,167 @@
1
+ """Tests for check_js_returns_object_schemaless_branch.
2
+
3
+ The check catches the JS/.mjs Category O6 docstring-prose-vs-implementation
4
+ drift PR #807 surfaced: a ``function`` whose JSDoc ``@returns {Promise<object>}``
5
+ promises a structured object, while one branch returns the agent-spawn helper
6
+ with an options object that omits ``schema``. A schema-less agent call resolves
7
+ to a transcript string, not the object the JSDoc claims, so the contract drifts
8
+ for that branch while a sibling branch that does pass ``schema`` returns a real
9
+ object.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import importlib.util
15
+ import pathlib
16
+ import sys
17
+
18
+ _HOOK_DIRECTORY = pathlib.Path(__file__).parent
19
+ if str(_HOOK_DIRECTORY) not in sys.path:
20
+ sys.path.insert(0, str(_HOOK_DIRECTORY))
21
+
22
+ _module_spec = importlib.util.spec_from_file_location(
23
+ "code_rules_imports_logging",
24
+ _HOOK_DIRECTORY / "code_rules_imports_logging.py",
25
+ )
26
+ assert _module_spec is not None
27
+ assert _module_spec.loader is not None
28
+ _imports_logging_module = importlib.util.module_from_spec(_module_spec)
29
+ _module_spec.loader.exec_module(_imports_logging_module)
30
+
31
+ check_js_returns_object_schemaless_branch = (
32
+ _imports_logging_module.check_js_returns_object_schemaless_branch
33
+ )
34
+
35
+ _MJS_PATH = "skills/autoconverge/workflow/converge.mjs"
36
+
37
+ _SHIPPED_CONVERGE_MJS = (
38
+ _HOOK_DIRECTORY.parents[1] / "skills" / "autoconverge" / "workflow" / "converge.mjs"
39
+ )
40
+
41
+
42
+ def _mixed_schema_source() -> str:
43
+ return (
44
+ "/**\n"
45
+ " * Spawn a git-utility agent for a task.\n"
46
+ " * @returns {Promise<object>} the structured output\n"
47
+ " */\n"
48
+ "function runGitTask(task, head) {\n"
49
+ " if (task === 'resolve-head') {\n"
50
+ " return convergeAgent(\n"
51
+ " `Print the HEAD SHA of ${prCoordinates}.`,\n"
52
+ " { label: 'git-utility', phase: 'Converge', schema: HEAD_SCHEMA, agentType: 'Explore' },\n"
53
+ " )\n"
54
+ " }\n"
55
+ " if (task === 'prefetch-main') {\n"
56
+ " return convergeAgent(\n"
57
+ " `Refresh the base ref for ${prCoordinates}.`,\n"
58
+ " { label: 'git-utility', phase: 'Converge', agentType: 'Explore' },\n"
59
+ " )\n"
60
+ " }\n"
61
+ " return convergeAgent(\n"
62
+ " `Report conflicts for ${prCoordinates}.`,\n"
63
+ " { label: 'git-utility', phase: 'Converge', schema: MERGE_CONFLICT_SCHEMA, agentType: 'Explore' },\n"
64
+ " )\n"
65
+ "}\n"
66
+ )
67
+
68
+
69
+ def _all_schema_source() -> str:
70
+ return _mixed_schema_source().replace(
71
+ " { label: 'git-utility', phase: 'Converge', agentType: 'Explore' },\n",
72
+ " { label: 'git-utility', phase: 'Converge', schema: FETCH_SCHEMA, agentType: 'Explore' },\n",
73
+ )
74
+
75
+
76
+ def _plain_object_return_source() -> str:
77
+ return (
78
+ "/**\n"
79
+ " * Joined fixer recovery loop.\n"
80
+ " * @returns {Promise<object>} FIX_SCHEMA result\n"
81
+ " */\n"
82
+ "async function fixerWithRecovery(head, findings, sourceLabel) {\n"
83
+ " if (!verdictPassed) {\n"
84
+ " return {\n"
85
+ " newSha: head,\n"
86
+ " pushed: false,\n"
87
+ " summary: `verify step did not pass ${sourceLabel}`,\n"
88
+ " }\n"
89
+ " }\n"
90
+ " return commitWithRecovery({\n"
91
+ " runCommit: () => runFixerTask('commit', { head }),\n"
92
+ " runVerify: () => runVerifierTask('fix-verify', { head }),\n"
93
+ " })\n"
94
+ "}\n"
95
+ )
96
+
97
+
98
+ def _transcript_typed_source() -> str:
99
+ return _mixed_schema_source().replace(
100
+ " * @returns {Promise<object>} the structured output\n",
101
+ " * @returns {Promise<*>} the agent() result\n",
102
+ )
103
+
104
+
105
+ def _destructured_parameter_mixed_schema_source() -> str:
106
+ return (
107
+ "/**\n"
108
+ " * Commit and verify with recovery.\n"
109
+ " * @returns {Promise<object>} the structured verify output\n"
110
+ " */\n"
111
+ "async function commitWithRecovery({ runCommit, runVerify, head }) {\n"
112
+ " if (runVerify) {\n"
113
+ " return convergeAgent(\n"
114
+ " `Verify the commit for ${head}.`,\n"
115
+ " { label: 'commit-recovery', phase: 'Converge', schema: VERIFY_SCHEMA, agentType: 'Explore' },\n"
116
+ " )\n"
117
+ " }\n"
118
+ " return convergeAgent(\n"
119
+ " `Commit ${head} without verification.`,\n"
120
+ " { label: 'commit-recovery', phase: 'Converge', agentType: 'Explore' },\n"
121
+ " )\n"
122
+ "}\n"
123
+ )
124
+
125
+
126
+ def test_flags_schema_less_branch_under_returns_object_claim() -> None:
127
+ issues = check_js_returns_object_schemaless_branch(_mixed_schema_source(), _MJS_PATH)
128
+ assert len(issues) == 1
129
+ assert "runGitTask" in issues[0]
130
+ assert "convergeAgent" in issues[0]
131
+
132
+
133
+ def test_flags_schema_less_branch_with_destructured_parameter() -> None:
134
+ issues = check_js_returns_object_schemaless_branch(
135
+ _destructured_parameter_mixed_schema_source(), _MJS_PATH
136
+ )
137
+ assert len(issues) == 1
138
+ assert "commitWithRecovery" in issues[0]
139
+ assert "convergeAgent" in issues[0]
140
+
141
+
142
+ def test_accepts_function_when_every_branch_passes_a_schema() -> None:
143
+ issues = check_js_returns_object_schemaless_branch(_all_schema_source(), _MJS_PATH)
144
+ assert issues == []
145
+
146
+
147
+ def test_accepts_plain_object_returns_with_no_schema_driven_helper() -> None:
148
+ issues = check_js_returns_object_schemaless_branch(_plain_object_return_source(), _MJS_PATH)
149
+ assert issues == []
150
+
151
+
152
+ def test_accepts_transcript_typed_returns_clause() -> None:
153
+ issues = check_js_returns_object_schemaless_branch(_transcript_typed_source(), _MJS_PATH)
154
+ assert issues == []
155
+
156
+
157
+ def test_skips_python_files() -> None:
158
+ issues = check_js_returns_object_schemaless_branch(
159
+ _mixed_schema_source(), "workflow/converge.py"
160
+ )
161
+ assert issues == []
162
+
163
+
164
+ def test_shipped_converge_mjs_passes_its_own_check() -> None:
165
+ shipped_source = _SHIPPED_CONVERGE_MJS.read_text(encoding="utf-8")
166
+ issues = check_js_returns_object_schemaless_branch(shipped_source, _MJS_PATH)
167
+ assert issues == []
@@ -0,0 +1,94 @@
1
+ """Tests for the env-var-table code-drift blocker.
2
+
3
+ Each test builds a real on-disk directory holding a code file and a markdown
4
+ doc, then drives the blocker's detection function against that tree. The drift
5
+ the blocker catches: a markdown env-var summary table row attributes an
6
+ environment variable to a code file whose source never references that variable.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import sys
12
+ from pathlib import Path
13
+
14
+ _blocking_dir = str(Path(__file__).resolve().parent)
15
+ if _blocking_dir not in sys.path:
16
+ sys.path.insert(0, _blocking_dir)
17
+
18
+ from env_var_table_code_drift_blocker import ( # noqa: E402
19
+ find_drift_rows,
20
+ is_markdown_file,
21
+ )
22
+
23
+
24
+ def _write(file_path: Path, content: str) -> None:
25
+ file_path.parent.mkdir(parents=True, exist_ok=True)
26
+ file_path.write_text(content, encoding="utf-8")
27
+
28
+
29
+ def _anchor_repo_root(repo_root: Path) -> None:
30
+ (repo_root / ".git").mkdir(parents=True, exist_ok=True)
31
+
32
+
33
+ def test_is_markdown_file_accepts_md_and_rejects_python() -> None:
34
+ assert is_markdown_file("docs/configuration.md") is True
35
+ assert is_markdown_file("auth/google_auth.py") is False
36
+
37
+
38
+ def test_flags_variable_absent_from_named_code_file(tmp_path: Path) -> None:
39
+ _anchor_repo_root(tmp_path)
40
+ _write(tmp_path / "auth" / "google_auth.py", "def load():\n return read_bws_secret()\n")
41
+ doc_path = tmp_path / "docs" / "configuration.md"
42
+ content = (
43
+ "## Summary: Environment Variables\n\n"
44
+ "| Variable | Used By | Purpose |\n"
45
+ "|----------|---------|---------|\n"
46
+ "| `GOOGLE_APPLICATION_CREDENTIALS` | `auth/google_auth.py` | Path to JSON |\n"
47
+ )
48
+ drift_rows = find_drift_rows(content, doc_path.parent)
49
+ assert drift_rows == ["GOOGLE_APPLICATION_CREDENTIALS -> auth/google_auth.py"]
50
+
51
+
52
+ def test_passes_when_variable_present_in_code_file(tmp_path: Path) -> None:
53
+ _anchor_repo_root(tmp_path)
54
+ _write(
55
+ tmp_path / "automation_logging" / "__init__.py",
56
+ 'topic = os.environ["NTFY_TOPIC"]\n',
57
+ )
58
+ doc_path = tmp_path / "docs" / "configuration.md"
59
+ content = (
60
+ "| Variable | Used By | Purpose |\n"
61
+ "|----------|---------|---------|\n"
62
+ "| `NTFY_TOPIC` | `automation_logging/__init__.py` | ntfy topic |\n"
63
+ )
64
+ assert find_drift_rows(content, doc_path.parent) == []
65
+
66
+
67
+ def test_skips_row_whose_code_file_is_absent(tmp_path: Path) -> None:
68
+ _anchor_repo_root(tmp_path)
69
+ doc_path = tmp_path / "docs" / "configuration.md"
70
+ content = (
71
+ "| Variable | Used By | Purpose |\n"
72
+ "|----------|---------|---------|\n"
73
+ "| `MISSING_VAR` | `nowhere/ghost.py` | unresolved |\n"
74
+ )
75
+ assert find_drift_rows(content, doc_path.parent) == []
76
+
77
+
78
+ def test_ignores_rows_inside_a_fenced_code_block(tmp_path: Path) -> None:
79
+ _anchor_repo_root(tmp_path)
80
+ _write(tmp_path / "auth" / "google_auth.py", "no variable here\n")
81
+ doc_path = tmp_path / "docs" / "configuration.md"
82
+ content = "```\n| `GOOGLE_APPLICATION_CREDENTIALS` | `auth/google_auth.py` | sample |\n```\n"
83
+ assert find_drift_rows(content, doc_path.parent) == []
84
+
85
+
86
+ def test_ignores_row_whose_second_cell_is_not_a_code_file(tmp_path: Path) -> None:
87
+ _anchor_repo_root(tmp_path)
88
+ doc_path = tmp_path / "docs" / "configuration.md"
89
+ content = (
90
+ "| Variable | Purpose | Default |\n"
91
+ "|----------|---------|---------|\n"
92
+ "| `SOME_FLAG` | `enables the thing` | off |\n"
93
+ )
94
+ assert find_drift_rows(content, doc_path.parent) == []
@@ -311,6 +311,52 @@ def test_is_inventoried_production_file_accepts_production_file(tmp_path: Path):
311
311
  assert is_inventoried_production_file(str(production_file_path)) is True
312
312
 
313
313
 
314
+ SKILL_MD_SCRIPTS_LAYOUT = (
315
+ "# base-theme-match\n\n"
316
+ "## Layout\n\n"
317
+ "| Path | Role |\n"
318
+ "|---|---|\n"
319
+ "| `scripts/discover_candidates.py` | Discovers candidates. |\n"
320
+ "| `scripts/resolve_stp.py` | Resolves the STP filename. |\n"
321
+ "| `scripts/select_stp.py` | Selects the STP filename. |\n"
322
+ )
323
+
324
+
325
+ def _skill_scripts_directory(tmp_path: Path) -> Path:
326
+ """Return a skill package's scripts/ directory under a SKILL.md Layout table."""
327
+ skill_directory = tmp_path / "base-theme-match"
328
+ skill_directory.mkdir()
329
+ (skill_directory / "SKILL.md").write_text(SKILL_MD_SCRIPTS_LAYOUT, encoding="utf-8")
330
+ scripts_directory = skill_directory / "scripts"
331
+ scripts_directory.mkdir()
332
+ _write_sibling_files(scripts_directory, ["discover_candidates.py", "resolve_stp.py"])
333
+ return scripts_directory
334
+
335
+
336
+ def test_blocks_new_script_absent_from_parent_skill_layout(tmp_path: Path):
337
+ scripts_directory = _skill_scripts_directory(tmp_path)
338
+ new_file_path = scripts_directory / "stp_selection.py"
339
+ result = _run_hook(
340
+ "Write",
341
+ {"file_path": str(new_file_path), "content": "x = 1\n"},
342
+ )
343
+ assert result.returncode == 0
344
+ payload = json.loads(result.stdout)
345
+ assert payload["hookSpecificOutput"]["permissionDecision"] == "deny"
346
+ assert "stp_selection.py" in payload["hookSpecificOutput"]["permissionDecisionReason"]
347
+
348
+
349
+ def test_allows_new_script_named_in_parent_skill_layout(tmp_path: Path):
350
+ scripts_directory = _skill_scripts_directory(tmp_path)
351
+ new_file_path = scripts_directory / "select_stp.py"
352
+ result = _run_hook(
353
+ "Write",
354
+ {"file_path": str(new_file_path), "content": "x = 1\n"},
355
+ )
356
+ assert result.returncode == 0
357
+ assert result.stdout.strip() == ""
358
+
359
+
314
360
  def test_find_stale_inventory_returns_survey_for_omission(tmp_path: Path):
315
361
  package_directory = _package_directory_with_readme(tmp_path, README_LISTING_TWO_FILES)
316
362
  _write_sibling_files(package_directory, ["dialer_compose.py", "compose_dialer_cli.py"])
@@ -632,24 +632,24 @@ def test_dispatcher_write_applies_both_groups() -> None:
632
632
  assert "blocking/plain_language_blocker.py" in all_write_script_paths, (
633
633
  "plain_language_blocker (Group B) must be in Write applicable set"
634
634
  )
635
- assert len(all_write_entries) == 18, (
636
- f"Write tool must apply to all 18 hosted hooks, got {len(all_write_entries)}"
635
+ assert len(all_write_entries) == 19, (
636
+ f"Write tool must apply to all 19 hosted hooks, got {len(all_write_entries)}"
637
637
  )
638
638
 
639
639
 
640
640
  def test_dispatcher_edit_applies_both_groups() -> None:
641
641
  """Edit tool triggers both Group A and Group B hooks through the dispatcher."""
642
642
  all_edit_entries = _applicable_entries_for_tool(EDIT_TOOL_NAME)
643
- assert len(all_edit_entries) == 18, (
644
- f"Edit tool must apply to all 18 hosted hooks, got {len(all_edit_entries)}"
643
+ assert len(all_edit_entries) == 19, (
644
+ f"Edit tool must apply to all 19 hosted hooks, got {len(all_edit_entries)}"
645
645
  )
646
646
 
647
647
 
648
648
  def test_dispatcher_multi_edit_applies_only_group_b() -> None:
649
- """MultiEdit tool triggers only Group B (7 hooks), not Group A."""
649
+ """MultiEdit tool triggers only Group B (8 hooks), not Group A."""
650
650
  all_multi_edit_entries = _applicable_entries_for_tool(MULTI_EDIT_TOOL_NAME)
651
- assert len(all_multi_edit_entries) == 7, (
652
- f"MultiEdit tool must apply to exactly 7 Group-B hooks, got {len(all_multi_edit_entries)}"
651
+ assert len(all_multi_edit_entries) == 8, (
652
+ f"MultiEdit tool must apply to exactly 8 Group-B hooks, got {len(all_multi_edit_entries)}"
653
653
  )
654
654
 
655
655
 
@@ -13,6 +13,7 @@ Shared constant modules imported by hooks throughout the `hooks/` tree. Each fil
13
13
  | `bot_mention_comment_blocker_constants.py` | Patterns for detecting bot @-mentions in PR comments |
14
14
  | `claude_md_orphan_file_blocker_constants.py` | Table patterns, file extensions, scan budget, and block-message text for the CLAUDE.md orphan-file blocker |
15
15
  | `code_rules_enforcer_constants.py` | File-extension sets, test-path patterns, advisory line thresholds, boolean-name prefixes |
16
+ | `env_var_table_code_drift_constants.py` | Table patterns, env-var-name and code-file recognizers, scan budget, and block-message text for the env-var-table code-drift blocker |
16
17
  | `code_rules_path_utils_constants.py` | Path-matching helpers used by the code-rules check modules |
17
18
  | `code_verifier_spawn_preflight_gate_constants.py` | Subagent type, merge-tree command flags, timeouts, and deny-message text for the code-verifier spawn pre-flight gate |
18
19
  | `convergence_branch_constants.py` | Branch and worktree naming patterns for the convergence gate |
@@ -45,12 +45,14 @@ ALL_IMPORT_BLOCK_SORT_RUFF_COMMAND_PREFIX: tuple[str, ...] = (
45
45
  "json",
46
46
  )
47
47
  MAX_JS_RESUME_TASK_ENUMERATION_ISSUES: int = 5
48
+ MAX_JS_RETURNS_OBJECT_SCHEMALESS_ISSUES: int = 5
48
49
  MINIMUM_RESUME_TASK_ENUMERATION_ITEMS: int = 2
49
50
  DOCSTRING_TRIVIAL_FUNCTION_BODY_LINE_LIMIT: int = 3
50
51
  MAX_DOCSTRING_FALLBACK_BRANCH_ISSUES: int = 3
51
52
  DOCSTRING_FALLBACK_BRANCH_MINIMUM_ROUTE_COUNT: int = 2
52
53
  MAX_DOCSTRING_NO_CONSUMER_CLAIM_ISSUES: int = 3
53
54
  MAX_DOCSTRING_UNGUARDED_PAYLOAD_CLAIM_ISSUES: int = 3
55
+ MAX_DOCSTRING_NO_NETWORK_CLAIM_ISSUES: int = 3
54
56
  MAX_STALE_TEST_NAME_TARGET_ISSUES: int = 3
55
57
  STALE_TEST_NAME_MINIMUM_SHARED_TOKEN_COUNT: int = 2
56
58
  MAX_MODULE_DOCSTRING_CHECK_ROSTER_ISSUES: int = 5
@@ -79,6 +81,36 @@ MINIMUM_TOKENS_FOR_DISPATCH_CALLEE: int = 2
79
81
  MAX_DOCSTRING_UNDEFINED_CONSTANT_ISSUES: int = 3
80
82
  MAX_DOCSTRING_RETURNS_PLURAL_CARDINALITY_ISSUES: int = 5
81
83
  SINGLE_DICT_KEY_COUNT_FOR_PLURAL_CARDINALITY_DRIFT: int = 1
84
+ MAX_LENGTH_CONSTANT_SUPERLATIVE_ISSUES: int = 3
85
+ LENGTH_GATE_PACKAGE_SCAN_FILE_LIMIT: int = 400
86
+ LENGTH_CONFIG_SUBDIRECTORY_NAME: str = "config"
87
+ ALL_LENGTH_CONSTANT_NAME_SUFFIXES: tuple[str, ...] = ("_LENGTH", "_LEN")
88
+ ALL_LENGTH_SUPERLATIVE_RANGE_PHRASES: tuple[str, ...] = (
89
+ "longest",
90
+ "maximum length",
91
+ "no longer than",
92
+ )
93
+ MAX_DOCSTRING_FIELD_RUNMODE_OUTCOME_ISSUES: int = 5
94
+ ALL_DOCSTRING_RUNMODE_FLAG_FIELD_NAME_TOKENS: tuple[str, ...] = ("dry_run",)
95
+ ALL_DOCSTRING_PER_RECORD_WRITE_OUTCOME_PHRASES: tuple[str, ...] = (
96
+ "was written",
97
+ "is written",
98
+ "were written",
99
+ "nothing was written",
100
+ "no file was written",
101
+ "wrote",
102
+ "written to disk",
103
+ "written to the file",
104
+ )
105
+ ALL_DOCSTRING_RUN_MODE_PHRASES: tuple[str, ...] = (
106
+ "dry run",
107
+ "dry-run",
108
+ "execute run",
109
+ "--execute",
110
+ "run mode",
111
+ "live run",
112
+ "not an execute",
113
+ )
82
114
  MAX_DOCSTRING_RAISES_LARGEZIPFILE_ISSUES: int = 5
83
115
  DOCSTRING_LARGE_ZIP_FILE_EXCEPTION_NAME: str = "LargeZipFile"
84
116
  ZIPFILE_WRITER_CLASS_NAME: str = "ZipFile"
@@ -187,6 +219,26 @@ ALL_DOCSTRING_GUARDED_FAILURE_CLAIM_PHRASES: tuple[str, ...] = (
187
219
  "malformed payload yields none",
188
220
  )
189
221
 
222
+ ALL_DOCSTRING_NO_NETWORK_CLAIM_PHRASES: tuple[str, ...] = (
223
+ "without touching the network",
224
+ "without touching the share",
225
+ "without touching the network share",
226
+ "without hitting the network",
227
+ "without hitting the share",
228
+ "without a network call",
229
+ "without any network call",
230
+ "without network access",
231
+ "without a network round-trip",
232
+ "without a network round trip",
233
+ "no network access",
234
+ "no network round-trip",
235
+ "no network round trip",
236
+ )
237
+
238
+ ALL_PATH_METADATA_ACCESS_METHOD_NAMES: frozenset[str] = frozenset(
239
+ {"is_file", "is_dir", "exists", "stat", "lstat"}
240
+ )
241
+
190
242
  MAX_DOCSTRING_INLINE_LITERAL_CLAIM_ISSUES: int = 3
191
243
  ALL_DOCSTRING_NO_INLINE_LITERAL_CLAIM_PHRASES: tuple[str, ...] = (
192
244
  "no literals appear inline",
@@ -228,3 +280,30 @@ ALL_TEST_INDICATING_ENVIRONMENT_VARIABLE_NAMES: frozenset[str] = frozenset(
228
280
  "UNIT_TEST",
229
281
  }
230
282
  )
283
+
284
+ MAX_MODULE_DOCSTRING_DATA_SCHEMA_SCOPE_ISSUES: int = 1
285
+ MODULE_DOCSTRING_DATA_SCHEMA_CONSTANT_SAMPLE_LIMIT: int = 6
286
+ ALL_USER_FACING_TEXT_SCOPE_DOCSTRING_PHRASES: tuple[str, ...] = (
287
+ "user-facing",
288
+ "user facing",
289
+ )
290
+ ALL_DATA_SCHEMA_CONSTANT_NAME_MARKERS: tuple[str, ...] = (
291
+ "_FIELD_",
292
+ "_KEY_",
293
+ "_SCHEMA_",
294
+ "_ENCODING",
295
+ "_FORMAT_STRING",
296
+ )
297
+ ALL_DATA_SCHEMA_DOCSTRING_ACKNOWLEDGEMENT_PHRASES: tuple[str, ...] = (
298
+ "field key",
299
+ "schema key",
300
+ "schema",
301
+ "metadata",
302
+ "encoding",
303
+ "format string",
304
+ "runtime config",
305
+ "runtime-config",
306
+ "data schema",
307
+ "data-schema",
308
+ "config constant",
309
+ )
@@ -39,6 +39,20 @@ DENY_REASON_ISSUE_PREVIEW_COUNT = 10
39
39
  ALL_BOOLEAN_NAME_PREFIXES: tuple[str, ...] = ("is_", "has_", "should_", "can_", "was_", "did_")
40
40
  UPPER_SNAKE_CONSTANT_PATTERN = re.compile(r"^[A-Z][A-Z0-9_]*$")
41
41
 
42
+ ALL_POLARITY_ANTONYM_TOKEN_PAIRS: tuple[tuple[str, str], ...] = (
43
+ ("allowed", "forbidden"),
44
+ ("allowed", "denied"),
45
+ ("allowed", "blocked"),
46
+ ("permitted", "forbidden"),
47
+ ("permitted", "denied"),
48
+ ("included", "excluded"),
49
+ ("valid", "invalid"),
50
+ ("enabled", "disabled"),
51
+ ("visible", "hidden"),
52
+ ("present", "missing"),
53
+ )
54
+ POLARITY_TOKEN_BOUNDARY_PATTERN: str = r"(?:^|_)%s(?:_|$)"
55
+
42
56
  ALL_MUST_CHECK_RETURN_FUNCTION_NAMES: frozenset[str] = frozenset({"find_and_click", "write_outcome"})
43
57
 
44
58
  DOCSTRING_ARG_ENTRY_PATTERN: re.Pattern[str] = re.compile(r"^([A-Za-z_][A-Za-z0-9_]*)\s*[:(]")
@@ -208,6 +222,20 @@ ENUMERATION_TASK_ITEM_PATTERN: re.Pattern[str] = re.compile(
208
222
  HYPHENATED_TASK_ITEM_PATTERN: re.Pattern[str] = re.compile(
209
223
  r"^[a-z0-9]+(?:-[a-z0-9]+)+$"
210
224
  )
225
+ FUNCTION_WITH_JSDOC_PATTERN: re.Pattern[str] = re.compile(
226
+ r"/\*\*(?P<jsdoc>(?:(?!\*/).)*?)\*/\s*"
227
+ r"(?:async\s+)?function\s+(?P<name>\w+)\s*\(",
228
+ re.DOTALL,
229
+ )
230
+ JSDOC_RETURNS_STRUCTURED_OBJECT_PROMISE_PATTERN: re.Pattern[str] = re.compile(
231
+ r"@returns?\s*\{[^}]*Promise\s*<\s*[Oo]bject\s*>[^}]*\}"
232
+ )
233
+ RETURN_CALL_OPENING_PARENTHESIS_PATTERN: re.Pattern[str] = re.compile(
234
+ r"\breturn\s+(?:await\s+)?(?P<callee>\w+)\s*\("
235
+ )
236
+ SCHEMA_OPTIONS_PROPERTY_KEY_PATTERN: re.Pattern[str] = re.compile(
237
+ r"(?<![A-Za-z0-9_])schema\s*:"
238
+ )
211
239
  ALL_BUILTIN_DICT_METHOD_NAMES: frozenset[str] = frozenset({
212
240
  "get", "items", "keys", "values", "update", "pop",
213
241
  "setdefault", "copy", "clear",
@@ -0,0 +1,64 @@
1
+ """Constants for the env-var-table code-drift blocker.
2
+
3
+ Holds the markdown filename matcher, the table-row and cell patterns, the
4
+ env-var-name and code-file-extension recognizers, the bounded-scan budgets, and
5
+ the block-message strings. The blocker imports each of these by name.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+
12
+ __all__ = [
13
+ "MARKDOWN_FILE_EXTENSION",
14
+ "TABLE_ROW_PATTERN",
15
+ "CODE_FENCE_PATTERN",
16
+ "SEPARATOR_CELL_PATTERN",
17
+ "BACKTICK_TOKEN_PATTERN",
18
+ "ENV_VAR_NAME_PATTERN",
19
+ "ALL_CODE_FILE_EXTENSIONS",
20
+ "ALL_NOISE_DIRECTORY_NAMES",
21
+ "GIT_DIRECTORY_NAME",
22
+ "MINIMUM_ENV_VAR_ROW_CELL_COUNT",
23
+ "MAX_SUBTREE_FILES_SCANNED",
24
+ "MAX_DRIFT_ISSUES",
25
+ "DRIFT_MESSAGE_TEMPLATE",
26
+ "DRIFT_ADDITIONAL_CONTEXT",
27
+ "DRIFT_SYSTEM_MESSAGE",
28
+ ]
29
+
30
+ MARKDOWN_FILE_EXTENSION = ".md"
31
+
32
+ TABLE_ROW_PATTERN = re.compile(r"^\s*\|")
33
+ CODE_FENCE_PATTERN = re.compile(r"^\s*(```|~~~)")
34
+ SEPARATOR_CELL_PATTERN = re.compile(r"^[:\-\s]+$")
35
+ BACKTICK_TOKEN_PATTERN = re.compile(r"`([^`]+)`")
36
+ ENV_VAR_NAME_PATTERN = re.compile(r"^[A-Z][A-Z0-9_]{1,}$")
37
+
38
+ ALL_CODE_FILE_EXTENSIONS: frozenset[str] = frozenset(
39
+ {".py", ".mjs", ".js", ".ts", ".ps1", ".sh"}
40
+ )
41
+ ALL_NOISE_DIRECTORY_NAMES: frozenset[str] = frozenset(
42
+ {".git", "__pycache__", "node_modules", ".pytest_cache", ".mypy_cache", ".ruff_cache"}
43
+ )
44
+
45
+ GIT_DIRECTORY_NAME = ".git"
46
+ MINIMUM_ENV_VAR_ROW_CELL_COUNT = 2
47
+
48
+ MAX_SUBTREE_FILES_SCANNED = 20000
49
+ MAX_DRIFT_ISSUES = 20
50
+
51
+ DRIFT_MESSAGE_TEMPLATE = (
52
+ "Env-var summary table in {file} attributes an environment variable to a "
53
+ "code file that does not read it: {drift}. The code file no longer "
54
+ "references the variable, so the table points a reader at a consumer "
55
+ "relationship the code does not have. Remove or correct the row so the "
56
+ "summary matches the code."
57
+ )
58
+ DRIFT_ADDITIONAL_CONTEXT = (
59
+ "Each `VARIABLE` | `code/file.py` row in a markdown env-var summary table "
60
+ "names a code file that reads that variable. When the code file exists but "
61
+ "its source never references the variable name, the row is stale. Drop the "
62
+ "row, or point it at the variable the file actually reads."
63
+ )
64
+ DRIFT_SYSTEM_MESSAGE = "Blocked: env-var summary table attributes a variable to a code file that does not read it."
@@ -1,7 +1,8 @@
1
1
  """Constants for the package-inventory stale-entry blocker.
2
2
 
3
3
  A package directory documents its own files in a sibling inventory document —
4
- a ``README.md`` Layout table or a ``CLAUDE.md`` "Key files" list whose entries
4
+ a ``README.md`` Layout table, a ``CLAUDE.md`` "Key files" list, or a skill
5
+ ``SKILL.md`` Layout table that maps the ``scripts/`` subdirectory — whose entries
5
6
  name each file in backticks. When a new production code file lands in that
6
7
  directory and the inventory carries no entry naming it, the inventory disagrees
7
8
  with the directory on the package's file set, and a reader trusting the
@@ -19,6 +20,8 @@ import re
19
20
 
20
21
  __all__ = [
21
22
  "ALL_INVENTORY_DOCUMENT_NAMES",
23
+ "SKILL_INVENTORY_DOCUMENT_NAME",
24
+ "SCRIPTS_SUBDIRECTORY_NAME",
22
25
  "ALL_PRODUCTION_CODE_EXTENSIONS",
23
26
  "PYTHON_FILE_EXTENSION",
24
27
  "ALL_TEST_FILE_MARKERS",
@@ -35,7 +38,13 @@ __all__ = [
35
38
  "STALE_INVENTORY_ADDITIONAL_CONTEXT",
36
39
  ]
37
40
 
38
- ALL_INVENTORY_DOCUMENT_NAMES: frozenset[str] = frozenset({"README.md", "CLAUDE.md"})
41
+ SKILL_INVENTORY_DOCUMENT_NAME: str = "SKILL.md"
42
+
43
+ SCRIPTS_SUBDIRECTORY_NAME: str = "scripts"
44
+
45
+ ALL_INVENTORY_DOCUMENT_NAMES: frozenset[str] = frozenset(
46
+ {"README.md", "CLAUDE.md", SKILL_INVENTORY_DOCUMENT_NAME}
47
+ )
39
48
 
40
49
  PYTHON_FILE_EXTENSION: str = ".py"
41
50
 
@@ -96,16 +105,18 @@ STALE_INVENTORY_MESSAGE_TEMPLATE: str = (
96
105
 
97
106
  STALE_INVENTORY_SYSTEM_MESSAGE: str = (
98
107
  "New production file is absent from its package inventory (README.md / "
99
- "CLAUDE.md) - add the inventory entry in this same change"
108
+ "CLAUDE.md / SKILL.md) - add the inventory entry in this same change"
100
109
  )
101
110
 
102
111
  STALE_INVENTORY_ADDITIONAL_CONTEXT: str = (
103
- "A package directory whose README.md or CLAUDE.md lists its files in "
104
- "backticks is a maintained inventory of the package's file set. A new "
105
- "production code file (.py, .mjs, .js, .ts, .ps1, .sh) in that directory "
106
- "carries one inventory entry naming it. Add a row to the README.md table or "
107
- "a bullet to the CLAUDE.md list naming this file, describing what it does, "
108
- "in the same change that creates the file. Exempt files (no entry needed): "
112
+ "A package directory whose README.md, CLAUDE.md, or SKILL.md lists its files "
113
+ "in backticks is a maintained inventory of the package's file set. A skill "
114
+ "SKILL.md Layout table that maps the scripts/ subdirectory counts as the "
115
+ "inventory for files in that subdirectory. A new production code file (.py, "
116
+ ".mjs, .js, .ts, .ps1, .sh) in an inventoried directory carries one entry "
117
+ "naming it. Add a row to the README.md or SKILL.md table or a bullet to the "
118
+ "CLAUDE.md list naming this file, describing what it does, in the same change "
119
+ "that creates the file. Exempt files (no entry needed): "
109
120
  "__init__.py, conftest.py, setup.py, _path_setup.py, files under config/ or "
110
121
  "tests/, and test files (test_*.py, *_test.py, *.spec.*, *.test.*)."
111
122
  )
@@ -21,7 +21,15 @@ MINIMUM_COVERED_PUBLIC_FUNCTIONS: int = 1
21
21
  MAX_PAIRED_TEST_COVERAGE_ISSUES: int = 25
22
22
  MISSING_PAIRED_TEST_GUIDANCE: str = (
23
23
  "is exercised by no test in the module's paired test suite, though that"
24
- " suite already covers another public function in this module - add a"
25
- " behavioral test that calls this function and asserts on its return value"
26
- " or side effect (CODE_RULES TDD paired-test rule)"
24
+ " suite already exercises this module (covering another public function or"
25
+ " referencing a private helper) - add a behavioral test that calls this"
26
+ " function and asserts on its return value or side effect (CODE_RULES TDD"
27
+ " paired-test rule)"
28
+ )
29
+ TEST_SUITE_OMITS_FUNCTION_GUIDANCE: str = (
30
+ "is a public function this stem-matched test suite defines its module for"
31
+ " but exercises nowhere, though the suite already covers another public"
32
+ " function in that module - add a behavioral test that calls this function"
33
+ " and asserts on its return value or side effect, or remove the function"
34
+ " (CODE_RULES TDD paired-test rule)"
27
35
  )
@@ -129,6 +129,10 @@ ALL_HOSTED_HOOK_ENTRIES: tuple[HostedHookEntry, ...] = (
129
129
  script_relative_path="blocking/package_inventory_stale_blocker.py",
130
130
  applicable_tool_names=ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES,
131
131
  ),
132
+ HostedHookEntry(
133
+ script_relative_path="blocking/env_var_table_code_drift_blocker.py",
134
+ applicable_tool_names=ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES,
135
+ ),
132
136
  HostedHookEntry(
133
137
  script_relative_path="blocking/pytest_testpaths_orphan_blocker.py",
134
138
  applicable_tool_names=ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES,