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.
- package/.agents/skills/privacy-hygiene/reference/sweep-procedure.md +1 -1
- package/_shared/advisor/scripts/config/advisor_scripts_constants/model_tier_run_validator_constants.py +1 -0
- package/_shared/advisor/scripts/model_tier_run_validator.py +3 -2
- package/_shared/advisor/scripts/tests/test_model_tier_run_validator.py +35 -0
- package/_shared/pr-loop/scripts/terminology_sweep.py +10 -9
- package/_shared/pr-loop/scripts/tests/test_terminology_sweep.py +13 -0
- package/bin/install.test.mjs +25 -0
- package/docs/high-trust-agent-delivery.md +132 -0
- package/hooks/advisory/refactor_guard.py +2 -2
- package/hooks/advisory/test_refactor_guard.py +21 -0
- package/hooks/blocking/code_rules_enforcer_test_support.py +10 -0
- package/hooks/blocking/code_rules_shared.py +4 -7
- package/hooks/blocking/conftest.py +22 -0
- package/hooks/blocking/state_description_blocker.py +10 -8
- package/hooks/blocking/test_code_rules_enforcer_duplicate_body.py +2 -11
- package/hooks/blocking/test_code_rules_enforcer_duplicate_body_hook_routing.py +4 -29
- package/hooks/blocking/test_code_rules_enforcer_test_support.py +21 -0
- package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias_hook_routing.py +3 -19
- package/hooks/blocking/test_code_rules_shared.py +18 -0
- package/hooks/blocking/test_conftest.py +35 -0
- package/hooks/blocking/test_state_description_blocker.py +16 -0
- package/hooks/git-hooks/git_hooks_constants/__init__.py +1 -14
- package/hooks/git-hooks/pull_request_handoff.py +2 -2
- package/hooks/git-hooks/test_config.py +10 -3
- package/hooks/git-hooks/test_pull_request_handoff.py +49 -0
- package/hooks/git-hooks/test_verification_notice_state.py +50 -0
- package/hooks/git-hooks/verification_notice_state.py +3 -1
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +13 -5
- package/hooks/hooks_constants/refactor_guard_constants.py +2 -0
- package/hooks/hooks_constants/session_edit_stage_gate_constants.py +3 -4
- package/hooks/hooks_constants/state_description_blocker_constants.py +0 -3
- package/hooks/hooks_constants/test_state_description_blocker_constants.py +9 -7
- package/hooks/validators/exempt_paths.py +3 -18
- package/hooks/validators/test_exempt_paths.py +20 -0
- package/hooks/validators/test_python_antipattern_checks.py +14 -0
- package/package.json +1 -1
- package/rules/agent-merges-its-own-green-pull-request.md +54 -0
- package/rules/archiving-agent-config.md +10 -0
- package/rules/code-standards.md +1 -0
- package/rules/correction-lens.md +134 -0
- package/rules/flag-non-breaking-findings.md +11 -0
- package/rules/no-contrast-framing.md +68 -0
- package/rules/review-closure-is-a-check.md +44 -0
- package/scripts/Invoke-PesterSuites.ps1 +42 -0
- package/scripts/agent_merge_check.py +381 -0
- package/scripts/check.ps1 +12 -1
- package/scripts/ci/install-playtest.mjs +291 -0
- package/scripts/ci/install-playtest.test.mjs +84 -0
- package/scripts/ci/installer-lifecycle.mjs +11 -30
- package/scripts/ci/installer-lifecycle.test.mjs +14 -0
- package/scripts/ci/pstack-command-shim.test.mjs +33 -0
- package/scripts/ci/scratch-home-install.mjs +73 -0
- package/scripts/ci/scratch-home-install.test.mjs +49 -0
- package/scripts/codex_compat_materializer.py +33 -16
- package/scripts/contrast_framing.py +101 -0
- package/scripts/dev_env_scripts_constants/agent_merge_check_constants.py +121 -0
- package/scripts/dev_env_scripts_constants/contrast_framing_constants.py +82 -0
- package/scripts/dev_env_scripts_constants/merge_gate_check_constants.py +23 -0
- package/scripts/dev_env_scripts_constants/review_closure_constants.py +241 -0
- package/scripts/dev_env_scripts_constants/test_code_review_constants.py +12 -0
- package/scripts/dev_env_scripts_constants/test_contrast_framing_constants.py +60 -0
- package/scripts/durable_post_lint.py +30 -4
- package/scripts/followup_cli.py +6 -10
- package/scripts/merge_gate_checks.py +236 -0
- package/scripts/policy_lint/adapter_contrast_framing.py +59 -0
- package/scripts/policy_lint/adapters.py +30 -0
- package/scripts/policy_lint/config/constants.py +2 -0
- package/scripts/policy_lint/registry.py +5 -0
- package/scripts/repository_checks/config/constants.py +12 -0
- package/scripts/repository_checks/config/policy_document.py +28 -2
- package/scripts/repository_checks/followups.py +54 -0
- package/scripts/repository_checks/match_exemptions.py +67 -0
- package/scripts/repository_checks/models.py +46 -2
- package/scripts/repository_checks/reporting.py +23 -7
- package/scripts/repository_checks/tracked_secrets.py +2 -2
- package/scripts/repository_policy.py +4 -2
- package/scripts/review_closure.py +134 -0
- package/scripts/review_closure_github.py +345 -0
- package/scripts/review_closure_model.py +360 -0
- package/scripts/test_agent_merge_check.py +317 -0
- package/scripts/test_durable_post_lint.py +46 -0
- package/scripts/test_followup_cli.py +40 -0
- package/scripts/test_merge_gate_checks.py +92 -0
- package/scripts/tests/test_code_review_constants.py +78 -0
- package/scripts/tests/test_codex_compat_materializer.py +48 -4
- package/scripts/tests/test_contrast_framing.py +117 -0
- package/scripts/tests/test_followups.py +88 -0
- package/scripts/tests/test_match_exemptions.py +291 -0
- package/scripts/tests/test_models.py +67 -2
- package/scripts/tests/test_package_inventory.py +6 -4
- package/scripts/tests/test_pester_suites_run.py +53 -0
- package/scripts/tests/test_policy_document.py +39 -1
- package/scripts/tests/test_policy_lint_rules_contrast_framing.py +68 -0
- package/scripts/tests/test_reporting.py +18 -0
- package/scripts/tests/test_repository_policy.py +43 -1
- package/scripts/tests/test_review_closure.py +126 -0
- package/scripts/tests/test_review_closure_github.py +240 -0
- package/scripts/tests/test_review_closure_model.py +299 -0
- package/scripts/tests/test_tracked_secrets.py +42 -0
- package/hooks/hooks_constants/precommit_code_rules_gate_constants.py +0 -12
- package/scripts/repository_checks/email_exemptions.py +0 -39
- 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
|
|
157
|
+
if title is None:
|
|
139
158
|
return []
|
|
140
|
-
|
|
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
|
-
|
|
176
|
-
|
|
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(
|
package/scripts/followup_cli.py
CHANGED
|
@@ -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
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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
|
-
|
|
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, ...]:
|
|
@@ -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
|
+
)
|