claude-dev-env 8.6.1 → 8.7.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 (102) hide show
  1. package/.agents/skills/privacy-hygiene/reference/sweep-procedure.md +1 -1
  2. package/_shared/advisor/scripts/config/advisor_scripts_constants/model_tier_run_validator_constants.py +1 -0
  3. package/_shared/advisor/scripts/model_tier_run_validator.py +3 -2
  4. package/_shared/advisor/scripts/tests/test_model_tier_run_validator.py +35 -0
  5. package/_shared/pr-loop/scripts/terminology_sweep.py +10 -9
  6. package/_shared/pr-loop/scripts/tests/test_terminology_sweep.py +13 -0
  7. package/bin/install.test.mjs +25 -0
  8. package/docs/high-trust-agent-delivery.md +132 -0
  9. package/hooks/advisory/refactor_guard.py +2 -2
  10. package/hooks/advisory/test_refactor_guard.py +21 -0
  11. package/hooks/blocking/code_rules_enforcer_test_support.py +10 -0
  12. package/hooks/blocking/code_rules_shared.py +4 -7
  13. package/hooks/blocking/conftest.py +22 -0
  14. package/hooks/blocking/state_description_blocker.py +10 -8
  15. package/hooks/blocking/test_code_rules_enforcer_duplicate_body.py +2 -11
  16. package/hooks/blocking/test_code_rules_enforcer_duplicate_body_hook_routing.py +4 -29
  17. package/hooks/blocking/test_code_rules_enforcer_test_support.py +21 -0
  18. package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias_hook_routing.py +3 -19
  19. package/hooks/blocking/test_code_rules_shared.py +18 -0
  20. package/hooks/blocking/test_conftest.py +35 -0
  21. package/hooks/blocking/test_state_description_blocker.py +16 -0
  22. package/hooks/git-hooks/git_hooks_constants/__init__.py +1 -14
  23. package/hooks/git-hooks/pull_request_handoff.py +2 -2
  24. package/hooks/git-hooks/test_config.py +10 -3
  25. package/hooks/git-hooks/test_pull_request_handoff.py +49 -0
  26. package/hooks/git-hooks/test_verification_notice_state.py +50 -0
  27. package/hooks/git-hooks/verification_notice_state.py +3 -1
  28. package/hooks/hooks_constants/code_rules_enforcer_constants.py +13 -5
  29. package/hooks/hooks_constants/refactor_guard_constants.py +2 -0
  30. package/hooks/hooks_constants/session_edit_stage_gate_constants.py +3 -4
  31. package/hooks/hooks_constants/state_description_blocker_constants.py +0 -3
  32. package/hooks/hooks_constants/test_state_description_blocker_constants.py +9 -7
  33. package/hooks/validators/exempt_paths.py +3 -18
  34. package/hooks/validators/test_exempt_paths.py +20 -0
  35. package/hooks/validators/test_python_antipattern_checks.py +14 -0
  36. package/package.json +1 -1
  37. package/rules/agent-merges-its-own-green-pull-request.md +54 -0
  38. package/rules/archiving-agent-config.md +10 -0
  39. package/rules/code-standards.md +1 -0
  40. package/rules/correction-lens.md +134 -0
  41. package/rules/flag-non-breaking-findings.md +11 -0
  42. package/rules/no-contrast-framing.md +68 -0
  43. package/rules/review-closure-is-a-check.md +44 -0
  44. package/scripts/Invoke-PesterSuites.ps1 +42 -0
  45. package/scripts/agent_merge_check.py +381 -0
  46. package/scripts/check.ps1 +12 -1
  47. package/scripts/ci/install-playtest.mjs +291 -0
  48. package/scripts/ci/install-playtest.test.mjs +84 -0
  49. package/scripts/ci/installer-lifecycle.mjs +11 -30
  50. package/scripts/ci/installer-lifecycle.test.mjs +14 -0
  51. package/scripts/ci/pstack-command-shim.test.mjs +33 -0
  52. package/scripts/ci/scratch-home-install.mjs +73 -0
  53. package/scripts/ci/scratch-home-install.test.mjs +49 -0
  54. package/scripts/codex_compat_materializer.py +33 -16
  55. package/scripts/contrast_framing.py +101 -0
  56. package/scripts/dev_env_scripts_constants/agent_merge_check_constants.py +121 -0
  57. package/scripts/dev_env_scripts_constants/contrast_framing_constants.py +82 -0
  58. package/scripts/dev_env_scripts_constants/merge_gate_check_constants.py +23 -0
  59. package/scripts/dev_env_scripts_constants/review_closure_constants.py +241 -0
  60. package/scripts/dev_env_scripts_constants/test_code_review_constants.py +12 -0
  61. package/scripts/dev_env_scripts_constants/test_contrast_framing_constants.py +60 -0
  62. package/scripts/durable_post_lint.py +30 -4
  63. package/scripts/followup_cli.py +6 -10
  64. package/scripts/merge_gate_checks.py +236 -0
  65. package/scripts/policy_lint/adapter_contrast_framing.py +59 -0
  66. package/scripts/policy_lint/adapters.py +30 -0
  67. package/scripts/policy_lint/config/constants.py +2 -0
  68. package/scripts/policy_lint/registry.py +5 -0
  69. package/scripts/repository_checks/config/constants.py +12 -0
  70. package/scripts/repository_checks/config/policy_document.py +28 -2
  71. package/scripts/repository_checks/followups.py +54 -0
  72. package/scripts/repository_checks/match_exemptions.py +67 -0
  73. package/scripts/repository_checks/models.py +46 -2
  74. package/scripts/repository_checks/reporting.py +23 -7
  75. package/scripts/repository_checks/tracked_secrets.py +2 -2
  76. package/scripts/repository_policy.py +4 -2
  77. package/scripts/review_closure.py +134 -0
  78. package/scripts/review_closure_github.py +345 -0
  79. package/scripts/review_closure_model.py +360 -0
  80. package/scripts/test_agent_merge_check.py +317 -0
  81. package/scripts/test_durable_post_lint.py +46 -0
  82. package/scripts/test_followup_cli.py +40 -0
  83. package/scripts/test_merge_gate_checks.py +92 -0
  84. package/scripts/tests/test_code_review_constants.py +78 -0
  85. package/scripts/tests/test_codex_compat_materializer.py +48 -4
  86. package/scripts/tests/test_contrast_framing.py +117 -0
  87. package/scripts/tests/test_followups.py +88 -0
  88. package/scripts/tests/test_match_exemptions.py +291 -0
  89. package/scripts/tests/test_models.py +67 -2
  90. package/scripts/tests/test_package_inventory.py +6 -4
  91. package/scripts/tests/test_pester_suites_run.py +53 -0
  92. package/scripts/tests/test_policy_document.py +39 -1
  93. package/scripts/tests/test_policy_lint_rules_contrast_framing.py +68 -0
  94. package/scripts/tests/test_reporting.py +18 -0
  95. package/scripts/tests/test_repository_policy.py +43 -1
  96. package/scripts/tests/test_review_closure.py +126 -0
  97. package/scripts/tests/test_review_closure_github.py +240 -0
  98. package/scripts/tests/test_review_closure_model.py +299 -0
  99. package/scripts/tests/test_tracked_secrets.py +42 -0
  100. package/hooks/hooks_constants/precommit_code_rules_gate_constants.py +0 -12
  101. package/scripts/repository_checks/email_exemptions.py +0 -39
  102. package/scripts/tests/test_email_exemptions.py +0 -158
@@ -0,0 +1,60 @@
1
+ """The contrast-framing form list stays usable by both linters."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dev_env_scripts_constants.contrast_framing_constants import (
6
+ ALL_CONTRAST_FRAMING_FORMS,
7
+ CONTRAST_FRAMING_MESSAGE_TEMPLATE,
8
+ )
9
+
10
+
11
+ def test_every_form_carries_a_distinct_name() -> None:
12
+ all_names = [each_form.name for each_form in ALL_CONTRAST_FRAMING_FORMS]
13
+
14
+ assert len(set(all_names)) == len(all_names)
15
+
16
+
17
+ def test_no_form_matches_prose_that_states_what_is() -> None:
18
+ all_clean_lines = (
19
+ "The function runs more than 30 lines.",
20
+ "The reader reads more than 30 lines a minute.",
21
+ "The check names the failing line and the fix.",
22
+ "Push the branch and read the verdict.",
23
+ "Three attempts, then park the member.",
24
+ )
25
+ for each_form in ALL_CONTRAST_FRAMING_FORMS:
26
+ for each_line in all_clean_lines:
27
+ assert each_form.pattern.search(each_line) is None
28
+
29
+
30
+ def test_every_pattern_reports_the_words_that_carry_the_contrast() -> None:
31
+ all_expected_matches = {
32
+ "trailing-comma-not": (", not s", "a diff regression, not shared code"),
33
+ "corrective-it-is-not": (
34
+ "it is not a flake, it is",
35
+ "it is not a flake, it is a bug",
36
+ ),
37
+ "substitution-rather-than": ("rather than", "park it rather than fix it"),
38
+ "additive-not-just": ("not just", "not just for today"),
39
+ "comparative-ranking": (
40
+ "matters more than",
41
+ "precision matters more than coverage",
42
+ ),
43
+ "substitution-as-opposed-to": (
44
+ "as opposed to",
45
+ "a warning as opposed to a failure",
46
+ ),
47
+ }
48
+ for each_form in ALL_CONTRAST_FRAMING_FORMS:
49
+ expected_text, example_line = all_expected_matches[each_form.name]
50
+ assert each_form.pattern.search(example_line).group(0) == expected_text
51
+
52
+
53
+ def test_the_message_template_carries_the_three_parts() -> None:
54
+ message = CONTRAST_FRAMING_MESSAGE_TEMPLATE.format(
55
+ name="trailing-comma-not", text="a fix, not a workaround", guidance="State it."
56
+ )
57
+
58
+ assert "trailing-comma-not" in message
59
+ assert "a fix, not a workaround" in message
60
+ assert message.endswith("State it.")
@@ -8,6 +8,10 @@ from collections.abc import Sequence
8
8
  from dataclasses import dataclass
9
9
  from pathlib import Path
10
10
 
11
+ from contrast_framing import describe_contrast_framing, find_contrast_framing
12
+ from dev_env_scripts_constants.contrast_framing_constants import (
13
+ CONTRAST_FRAMING_FINDING_CODE,
14
+ )
11
15
  from durable_post_lint_config.config.constants import (
12
16
  ACTION_PR_CREATE,
13
17
  ACTION_PR_EDIT,
@@ -134,10 +138,30 @@ def _description_heading_findings(body_text: str) -> list[DurablePostFinding]:
134
138
  ]
135
139
 
136
140
 
141
+ def _contrast_framing_findings(text: str) -> list[DurablePostFinding]:
142
+ all_lines = text.splitlines()
143
+ return [
144
+ DurablePostFinding(
145
+ code=CONTRAST_FRAMING_FINDING_CODE,
146
+ message=describe_contrast_framing(
147
+ all_lines[each_line_number - 1], each_form_name
148
+ ),
149
+ )
150
+ for each_line_number, _each_column, each_form_name in find_contrast_framing(
151
+ text
152
+ )
153
+ ]
154
+
155
+
137
156
  def _title_findings(title: str | None) -> list[DurablePostFinding]:
138
- if title is None or CONVENTIONAL_TITLE_PATTERN.fullmatch(title) is not None:
157
+ if title is None:
139
158
  return []
140
- return [DurablePostFinding(code=INVALID_TITLE_CODE, message=INVALID_TITLE_MESSAGE)]
159
+ all_findings = _contrast_framing_findings(title)
160
+ if CONVENTIONAL_TITLE_PATTERN.fullmatch(title) is None:
161
+ all_findings.append(
162
+ DurablePostFinding(code=INVALID_TITLE_CODE, message=INVALID_TITLE_MESSAGE)
163
+ )
164
+ return all_findings
141
165
 
142
166
 
143
167
  def _is_release_automation_branch(head_branch: str | None) -> bool:
@@ -172,8 +196,10 @@ def _body_findings(
172
196
  )
173
197
  if _is_release_automation_branch(head_branch):
174
198
  all_findings.extend(_release_body_findings(body_text))
175
- elif action in ALL_PR_DESCRIPTION_ACTIONS:
176
- all_findings.extend(_description_heading_findings(body_text))
199
+ else:
200
+ if action in ALL_PR_DESCRIPTION_ACTIONS:
201
+ all_findings.extend(_description_heading_findings(body_text))
202
+ all_findings.extend(_contrast_framing_findings(body_text))
177
203
  if find_volatile_path_marker(body_text) is not None:
178
204
  all_findings.append(
179
205
  DurablePostFinding(
@@ -281,21 +281,17 @@ def _run_count(repository_root: Path, stdout: TextIO) -> int:
281
281
  and BACKLOG_EXCEEDED_EXIT_CODE once it passes it.
282
282
  """
283
283
  finding_count = len(all_recorded_findings(repository_root))
284
- if finding_count <= FOLLOWUP_BACKLOG_THRESHOLD:
285
- stdout.write(
286
- BACKLOG_WITHIN_TEMPLATE.format(
287
- finding_count=finding_count, threshold=FOLLOWUP_BACKLOG_THRESHOLD
288
- )
289
- + LINE_SEPARATOR
290
- )
291
- return SUCCESS_EXIT_CODE
284
+ within_threshold = finding_count <= FOLLOWUP_BACKLOG_THRESHOLD
285
+ finding_template = (
286
+ BACKLOG_WITHIN_TEMPLATE if within_threshold else BACKLOG_EXCEEDED_TEMPLATE
287
+ )
292
288
  stdout.write(
293
- BACKLOG_EXCEEDED_TEMPLATE.format(
289
+ finding_template.format(
294
290
  finding_count=finding_count, threshold=FOLLOWUP_BACKLOG_THRESHOLD
295
291
  )
296
292
  + LINE_SEPARATOR
297
293
  )
298
- return BACKLOG_EXCEEDED_EXIT_CODE
294
+ return SUCCESS_EXIT_CODE if within_threshold else BACKLOG_EXCEEDED_EXIT_CODE
299
295
 
300
296
 
301
297
  def _run_clear(repository_root: Path) -> int:
@@ -0,0 +1,236 @@
1
+ #!/usr/bin/env python3
2
+ """Hold a merge policy document to the branch ruleset it describes.
3
+
4
+ A policy document names the checks a pull request passes before it merges.
5
+ Those names go stale the moment the branch ruleset stops requiring one of
6
+ them, and the document then promises a gate that never runs.
7
+
8
+ The document carries one fenced block whose info string is
9
+ ``required-status-checks``. Each line inside it is one check context, spelled
10
+ the way the ruleset spells it.
11
+
12
+ ::
13
+
14
+ ```required-status-checks
15
+ instruction-pairs / instruction-pairs
16
+ ```
17
+ ok: the ruleset requires that context -> exit 0
18
+ flag: the ruleset dropped that context -> exit 1, one line naming it
19
+
20
+ The ruleset arrives two ways. A checked-in snapshot keeps the comparison
21
+ offline, which is what the paired test suite reads. ``--live`` reads the
22
+ ruleset from the GitHub API, and ``--refresh`` writes the live answer back to
23
+ the snapshot file.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import argparse
29
+ import json
30
+ import os
31
+ import sys
32
+ import urllib.request
33
+ from collections.abc import Iterator, Sequence
34
+ from pathlib import Path
35
+ from typing import TextIO
36
+
37
+ from dev_env_scripts_constants.merge_gate_check_constants import (
38
+ ALL_GITHUB_TOKEN_VARIABLES,
39
+ FENCE_MARKER,
40
+ JSON_INDENT_WIDTH,
41
+ MERGE_GATE_CLEAN_EXIT_CODE,
42
+ MERGE_GATE_MISSING_CHECK_EXIT_CODE,
43
+ REQUIRED_CHECK_BLOCK_INFO_STRING,
44
+ REQUIRED_STATUS_CHECKS_RULE_TYPE,
45
+ RULE_CONTEXT_KEY,
46
+ RULE_PARAMETERS_KEY,
47
+ RULE_TYPE_KEY,
48
+ RULESET_API_ACCEPT_HEADER,
49
+ RULESET_API_TEMPLATE,
50
+ RULESET_REQUEST_TIMEOUT_SECONDS,
51
+ UTF8_ENCODING,
52
+ )
53
+
54
+
55
+ def parse_declared_contexts(document_text: str) -> list[str]:
56
+ """Read the check contexts a policy document declares, in document order.
57
+
58
+ ::
59
+
60
+ parse_declared_contexts("```required-status-checks\\nRuff\\n```\\n")
61
+ ok: ["Ruff"]
62
+
63
+ A document with no such block declares nothing.
64
+
65
+ Args:
66
+ document_text: The whole policy document.
67
+
68
+ Returns:
69
+ The contexts inside the ``required-status-checks`` block, in the order
70
+ the document lists them, and an empty list when the block is absent.
71
+ """
72
+ all_stripped_lines = [each_line.strip() for each_line in document_text.splitlines()]
73
+ opening_fence = FENCE_MARKER + REQUIRED_CHECK_BLOCK_INFO_STRING
74
+ if opening_fence not in all_stripped_lines:
75
+ return []
76
+ block_body_index = all_stripped_lines.index(opening_fence) + 1
77
+ all_declared_contexts: list[str] = []
78
+ for each_line in all_stripped_lines[block_body_index:]:
79
+ if each_line == FENCE_MARKER:
80
+ break
81
+ if each_line:
82
+ all_declared_contexts.append(each_line)
83
+ return all_declared_contexts
84
+
85
+
86
+ def ruleset_contexts(ruleset_payload: object) -> set[str]:
87
+ """Read every status-check context a branch ruleset requires.
88
+
89
+ A rule of another type carries no context and is passed over.
90
+
91
+ Args:
92
+ ruleset_payload: The list of rules the branch-rules endpoint returns.
93
+
94
+ Returns:
95
+ Every context named by a ``required_status_checks`` rule.
96
+ """
97
+ all_required_contexts: set[str] = set()
98
+ for each_rule in _required_status_check_rules(ruleset_payload):
99
+ all_required_contexts.update(_rule_contexts(each_rule))
100
+ return all_required_contexts
101
+
102
+
103
+ def missing_contexts(document_text: str, ruleset_payload: object) -> list[str]:
104
+ """Name the declared contexts the ruleset stopped requiring.
105
+
106
+ An empty list means the ruleset requires every check the document names,
107
+ so the document's promise holds.
108
+
109
+ Args:
110
+ document_text: The whole policy document.
111
+ ruleset_payload: The list of rules the branch-rules endpoint returns.
112
+
113
+ Returns:
114
+ The declared contexts the ruleset leaves out, in document order.
115
+ """
116
+ all_required_contexts = ruleset_contexts(ruleset_payload)
117
+ return [
118
+ each_context
119
+ for each_context in parse_declared_contexts(document_text)
120
+ if each_context not in all_required_contexts
121
+ ]
122
+
123
+
124
+ def main(all_arguments: Sequence[str]) -> int:
125
+ """Compare the document against the snapshot, or against the live ruleset.
126
+
127
+ Args:
128
+ all_arguments: The command-line arguments after the program name.
129
+
130
+ Returns:
131
+ ``0`` when the ruleset requires every declared context, and ``1`` with
132
+ one line per context the ruleset leaves out.
133
+ """
134
+ parser = _build_parser()
135
+ parsed_arguments = parser.parse_args(all_arguments)
136
+ wants_live_ruleset = parsed_arguments.live or parsed_arguments.refresh
137
+ if wants_live_ruleset and not parsed_arguments.repository:
138
+ parser.error("--live and --refresh need --repository")
139
+ ruleset_payload = _resolve_ruleset(parsed_arguments)
140
+ document_text = parsed_arguments.document.read_text(encoding=UTF8_ENCODING)
141
+ return _report_missing(
142
+ parsed_arguments.document,
143
+ missing_contexts(document_text, ruleset_payload),
144
+ sys.stdout,
145
+ )
146
+
147
+
148
+ def _required_status_check_rules(
149
+ ruleset_payload: object,
150
+ ) -> Iterator[dict[str, object]]:
151
+ if not isinstance(ruleset_payload, list):
152
+ return
153
+ for each_rule in ruleset_payload:
154
+ if isinstance(each_rule, dict) and (
155
+ each_rule.get(RULE_TYPE_KEY) == REQUIRED_STATUS_CHECKS_RULE_TYPE
156
+ ):
157
+ yield each_rule
158
+
159
+
160
+ def _rule_contexts(all_rule_fields: dict[str, object]) -> set[str]:
161
+ parameters = all_rule_fields.get(RULE_PARAMETERS_KEY)
162
+ if not isinstance(parameters, dict):
163
+ return set()
164
+ all_declared_checks = parameters.get(REQUIRED_STATUS_CHECKS_RULE_TYPE)
165
+ if not isinstance(all_declared_checks, list):
166
+ return set()
167
+ return {
168
+ each_check[RULE_CONTEXT_KEY]
169
+ for each_check in all_declared_checks
170
+ if isinstance(each_check, dict)
171
+ and isinstance(each_check.get(RULE_CONTEXT_KEY), str)
172
+ }
173
+
174
+
175
+ def _read_github_token() -> str | None:
176
+ for each_variable_name in ALL_GITHUB_TOKEN_VARIABLES:
177
+ token = os.environ.get(each_variable_name)
178
+ if token:
179
+ return token
180
+ return None
181
+
182
+
183
+ def _fetch_ruleset(repository: str, branch: str) -> object:
184
+ request = urllib.request.Request(
185
+ RULESET_API_TEMPLATE.format(repository=repository, branch=branch),
186
+ headers={"Accept": RULESET_API_ACCEPT_HEADER},
187
+ )
188
+ token = _read_github_token()
189
+ if token:
190
+ request.add_header("Authorization", f"Bearer {token}")
191
+ with urllib.request.urlopen(
192
+ request, timeout=RULESET_REQUEST_TIMEOUT_SECONDS
193
+ ) as api_reply:
194
+ return json.loads(api_reply.read().decode(UTF8_ENCODING))
195
+
196
+
197
+ def _resolve_ruleset(parsed_arguments: argparse.Namespace) -> object:
198
+ if not (parsed_arguments.live or parsed_arguments.refresh):
199
+ return json.loads(parsed_arguments.ruleset.read_text(encoding=UTF8_ENCODING))
200
+ ruleset_payload = _fetch_ruleset(
201
+ parsed_arguments.repository, parsed_arguments.branch
202
+ )
203
+ if parsed_arguments.refresh:
204
+ parsed_arguments.ruleset.write_text(
205
+ json.dumps(ruleset_payload, indent=JSON_INDENT_WIDTH) + "\n",
206
+ encoding=UTF8_ENCODING,
207
+ )
208
+ return ruleset_payload
209
+
210
+
211
+ def _report_missing(
212
+ document_path: Path, all_absent_contexts: Sequence[str], stream: TextIO
213
+ ) -> int:
214
+ if not all_absent_contexts:
215
+ stream.write(f"{document_path}: the ruleset requires every named check\n")
216
+ return MERGE_GATE_CLEAN_EXIT_CODE
217
+ stream.writelines(
218
+ f"{document_path}: the branch ruleset does not require {each_context!r}\n"
219
+ for each_context in all_absent_contexts
220
+ )
221
+ return MERGE_GATE_MISSING_CHECK_EXIT_CODE
222
+
223
+
224
+ def _build_parser() -> argparse.ArgumentParser:
225
+ parser = argparse.ArgumentParser(description="Check a merge policy document.")
226
+ parser.add_argument("--document", required=True, type=Path)
227
+ parser.add_argument("--ruleset", required=True, type=Path)
228
+ parser.add_argument("--repository")
229
+ parser.add_argument("--branch", default="main")
230
+ parser.add_argument("--live", action="store_true")
231
+ parser.add_argument("--refresh", action="store_true")
232
+ return parser
233
+
234
+
235
+ if __name__ == "__main__":
236
+ raise SystemExit(main(sys.argv[1:]))
@@ -0,0 +1,59 @@
1
+ """Report authored prose that defines its subject against a rejected reading.
2
+
3
+ A contrast leaves the reader holding two readings where one would do. The
4
+ detector lives in ``scripts/contrast_framing.py`` and reads its pattern list
5
+ from ``scripts/dev_env_scripts_constants/contrast_framing_constants.py``, the
6
+ same list the durable post linter reads.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from pathlib import Path
12
+
13
+ from contrast_framing import describe_contrast_framing, find_contrast_framing
14
+
15
+ from .config import constants
16
+ from .model import Diagnostic, Document, Location, Severity
17
+
18
+
19
+ def accepts_authored_markdown(document: Document) -> bool:
20
+ """Return whether the document is Markdown an author wrote.
21
+
22
+ Args:
23
+ document: Candidate document.
24
+
25
+ Returns:
26
+ True for Markdown outside the generated documents.
27
+ """
28
+ if document.path.suffix.lower() not in constants.ALL_MARKDOWN_SUFFIXES:
29
+ return False
30
+ return document.path.name.lower() not in constants.ALL_GENERATED_DOCUMENT_NAMES
31
+
32
+
33
+ def contrast_framing_diagnostics(
34
+ document: Document, repository_root: Path
35
+ ) -> tuple[Diagnostic, ...]:
36
+ """Report each contrast-framing occurrence in one document.
37
+
38
+ Args:
39
+ document: Current document text and path.
40
+ repository_root: Request repository root, unused by this detector.
41
+
42
+ Returns:
43
+ Contrast-framing diagnostics in document order.
44
+ """
45
+ del repository_root
46
+ all_lines = document.text.splitlines()
47
+ return tuple(
48
+ Diagnostic(
49
+ constants.CONTRAST_FRAMING_RULE_ID,
50
+ Severity.ERROR,
51
+ describe_contrast_framing(
52
+ all_lines[each_line_number - 1], each_form_name
53
+ ),
54
+ Location(document.path, each_line_number, each_column),
55
+ )
56
+ for each_line_number, each_column, each_form_name in find_contrast_framing(
57
+ document.text
58
+ )
59
+ )
@@ -10,6 +10,7 @@ from shared_tree_paths import resolve_shared_scripts_directory
10
10
 
11
11
  from . import (
12
12
  adapter_configuration,
13
+ adapter_contrast_framing,
13
14
  adapter_detectors,
14
15
  adapter_pairing,
15
16
  adapter_retired_hook_prose,
@@ -438,6 +439,35 @@ def accepts_instruction_markdown(document: Document) -> bool:
438
439
  return adapter_retired_hook_prose.accepts_instruction_markdown(document)
439
440
 
440
441
 
442
+ def accepts_authored_markdown(document: Document) -> bool:
443
+ """Return whether the document is Markdown an author wrote.
444
+
445
+ Args:
446
+ document: Candidate document.
447
+
448
+ Returns:
449
+ True for Markdown outside the generated documents.
450
+ """
451
+ return adapter_contrast_framing.accepts_authored_markdown(document)
452
+
453
+
454
+ def contrast_framing_diagnostics(
455
+ document: Document, repository_root: Path
456
+ ) -> tuple[Diagnostic, ...]:
457
+ """Report prose that defines its subject against a rejected reading.
458
+
459
+ Args:
460
+ document: Current Markdown text and path.
461
+ repository_root: Request repository root.
462
+
463
+ Returns:
464
+ Contrast-framing diagnostics.
465
+ """
466
+ return adapter_contrast_framing.contrast_framing_diagnostics(
467
+ document, repository_root
468
+ )
469
+
470
+
441
471
  def retired_hook_prose_diagnostics(
442
472
  document: Document, repository_root: Path
443
473
  ) -> tuple[Diagnostic, ...]:
@@ -168,3 +168,5 @@ RETIRED_HOOK_PROSE_MESSAGE = (
168
168
  "reaches no tool call. Say what the reader faces now, or drop the "
169
169
  "present-tense action."
170
170
  )
171
+
172
+ CONTRAST_FRAMING_RULE_ID = "contrast-framing"
@@ -104,6 +104,11 @@ def _text_document_rules() -> tuple[model.Rule, ...]:
104
104
  adapters.accepts_workflow,
105
105
  adapters.workflow_substitution_diagnostics,
106
106
  ),
107
+ build_document_rule(
108
+ "contrast-framing",
109
+ adapters.accepts_authored_markdown,
110
+ adapters.contrast_framing_diagnostics,
111
+ ),
107
112
  build_document_rule(
108
113
  "retired-hook-prose",
109
114
  adapters.accepts_instruction_markdown,
@@ -21,6 +21,16 @@ ALL_CHECK_IDS = (
21
21
  CHECK_ID_TRACKED_PERSONAL_DATA,
22
22
  )
23
23
 
24
+ SEVERITY_BREAKING = "breaking"
25
+ SEVERITY_SMELL = "smell"
26
+ SEVERITY_BY_CHECK_ID: dict[str, str] = {
27
+ CHECK_ID_CLAUDE_MD_ORPHANS: SEVERITY_BREAKING,
28
+ CHECK_ID_ENV_VAR_DOCUMENTATION: SEVERITY_BREAKING,
29
+ CHECK_ID_PACKAGE_INVENTORY: SEVERITY_SMELL,
30
+ CHECK_ID_PYTEST_TESTPATHS: SEVERITY_BREAKING,
31
+ CHECK_ID_TRACKED_PERSONAL_DATA: SEVERITY_BREAKING,
32
+ }
33
+
24
34
  SUCCESS_EXIT_CODE = 0
25
35
  FINDINGS_EXIT_CODE = 1
26
36
  FAILED_CHECK_EXIT_CODE = INCOMPLETE_EXIT_CODE
@@ -28,6 +38,7 @@ USAGE_EXIT_CODE = INVALID_INPUT_EXIT_CODE
28
38
 
29
39
  REPOSITORY_ROOT_FLAG = "--repository-root"
30
40
  FINDING_LINE_TEMPLATE = "{check_id}: {relative_path}: {message}"
41
+ ADVISORY_FINDING_LINE_TEMPLATE = "advisory: {check_id}: {relative_path}: {message}"
31
42
  RULE_FAILED_LINE_TEMPLATE = "error: rule failed: {check_id}"
32
43
  REPORT_LINE_SEPARATOR = "\n"
33
44
  EMPTY_REPORT_TEXT = ""
@@ -57,6 +68,7 @@ PACKAGE_INVENTORY_CONSTANTS_MODULE_NAME = (
57
68
  )
58
69
  PYTEST_TESTPATHS_MODULE_NAME = "blocking.pytest_testpaths_orphan_blocker"
59
70
  PII_SCANNER_MODULE_NAME = "blocking.pii_scanner"
71
+ FOLLOWUP_LEDGER_MODULE_NAME = "followup_ledger"
60
72
  REPOSITORY_EXEMPTION_MODULE_NAME = (
61
73
  "blocking.pii_prevention_blocker_parts.repository_exemption"
62
74
  )
@@ -1,6 +1,7 @@
1
1
  """Repository policy document and exception-entry schema."""
2
2
 
3
3
  import re
4
+ from typing import NamedTuple
4
5
 
5
6
  CONFIG_RELATIVE_PATH = "config/repository-policy.json"
6
7
  DIGEST_PATTERN = re.compile(r"[0-9a-f]{64}")
@@ -11,9 +12,34 @@ FIRST_PRINTABLE_CODEPOINT = 32
11
12
  VERSION_FIELD = "version"
12
13
  SUPPORTED_DOCUMENT_VERSION = 1
13
14
  EMAIL_EXEMPTIONS_FIELD = "email_exemptions"
15
+ PRIVATE_IP_EXEMPTIONS_FIELD = "private_ip_exemptions"
14
16
  PATH_EXEMPTIONS_FIELD = "path_exemptions"
15
- ALL_EXEMPTION_FIELDS = frozenset({EMAIL_EXEMPTIONS_FIELD, PATH_EXEMPTIONS_FIELD})
16
- ALL_DOCUMENT_FIELDS = ALL_EXEMPTION_FIELDS | {VERSION_FIELD}
17
17
  EMAIL_EXEMPTION_SUBJECT = "Email exemption"
18
+ PRIVATE_IP_EXEMPTION_SUBJECT = "Private IP exemption"
18
19
  PATH_EXEMPTION_SUBJECT = "Path exemption"
19
20
  EMAIL_CATEGORY = "email"
21
+ PRIVATE_IP_CATEGORY = "private-ip"
22
+
23
+
24
+ class MatchExemptionFamily(NamedTuple):
25
+ """One scanner category cleared by a committed path and match digest."""
26
+
27
+ field_name: str
28
+ category: str
29
+ subject: str
30
+
31
+
32
+ EMAIL_EXEMPTION_FAMILY = MatchExemptionFamily(
33
+ EMAIL_EXEMPTIONS_FIELD, EMAIL_CATEGORY, EMAIL_EXEMPTION_SUBJECT
34
+ )
35
+ PRIVATE_IP_EXEMPTION_FAMILY = MatchExemptionFamily(
36
+ PRIVATE_IP_EXEMPTIONS_FIELD, PRIVATE_IP_CATEGORY, PRIVATE_IP_EXEMPTION_SUBJECT
37
+ )
38
+ ALL_MATCH_EXEMPTION_FAMILIES = (
39
+ EMAIL_EXEMPTION_FAMILY,
40
+ PRIVATE_IP_EXEMPTION_FAMILY,
41
+ )
42
+ ALL_EXEMPTION_FIELDS = frozenset(
43
+ each_family.field_name for each_family in ALL_MATCH_EXEMPTION_FAMILIES
44
+ ) | {PATH_EXEMPTIONS_FIELD}
45
+ ALL_DOCUMENT_FIELDS = ALL_EXEMPTION_FIELDS | {VERSION_FIELD}
@@ -0,0 +1,54 @@
1
+ """Record advisory committed-tree findings in the follow-up ledger."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from types import ModuleType
7
+
8
+ from repository_checks.config.constants import (
9
+ ALL_FAIL_CLOSED_EXCEPTION_TYPES,
10
+ FOLLOWUP_LEDGER_MODULE_NAME,
11
+ SEVERITY_SMELL,
12
+ )
13
+ from repository_checks.hook_modules import load_hooks_module
14
+ from repository_checks.models import RepositoryCheckReport, RepositoryFinding
15
+
16
+
17
+ def record_smell_findings(repository_root: Path, report: RepositoryCheckReport) -> None:
18
+ """Append every advisory finding to the repository's follow-up ledger.
19
+
20
+ The ledger keeps the gate's decision unchanged, so a ledger that cannot be
21
+ loaded or written leaves the report's exit code as it stands.
22
+
23
+ Args:
24
+ repository_root: The repository whose ledger receives the findings.
25
+ report: The findings this run produced.
26
+ """
27
+ if not report.all_smell_findings:
28
+ return
29
+ try:
30
+ ledger = load_hooks_module(FOLLOWUP_LEDGER_MODULE_NAME)
31
+ origin_commit = ledger.head_commit(repository_root)
32
+ for each_finding in report.all_smell_findings:
33
+ _append_finding(ledger, repository_root, each_finding, origin_commit)
34
+ except ALL_FAIL_CLOSED_EXCEPTION_TYPES:
35
+ return
36
+
37
+
38
+ def _append_finding(
39
+ ledger: ModuleType,
40
+ repository_root: Path,
41
+ finding: RepositoryFinding,
42
+ origin_commit: str,
43
+ ) -> None:
44
+ ledger.record_followup_finding(
45
+ repository_root,
46
+ ledger.FollowupFinding(
47
+ finding.check_id,
48
+ finding.relative_path,
49
+ finding.message,
50
+ finding.check_id,
51
+ SEVERITY_SMELL,
52
+ origin_commit,
53
+ ),
54
+ )