claude-dev-env 8.72.2 → 8.73.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/build-eval/SKILL.md +4 -8
- package/.agents/skills/build-eval/reference/context-budget.md +49 -0
- package/.agents/skills/orchestrator/reference/oversee-delegated-work.md +1 -0
- package/.agents/skills/pr-lifecycle/SKILL.md +3 -10
- package/bin/codex-trimmed-sol.mjs +109 -0
- package/bin/codex-trimmed-sol.test.mjs +226 -0
- package/bin/install.mjs +18 -0
- package/bin/install.question-presentation.test.mjs +1 -1
- package/docs/codex-accounts.md +26 -0
- package/hooks/blocking/context_budget_blocker.py +177 -0
- package/hooks/blocking/test_context_budget_blocker.py +189 -0
- package/hooks/blocking/test_pre_tool_use_dispatcher.py +3 -1
- package/hooks/context_budget/__init__.py +1 -0
- package/hooks/context_budget/baseline.py +78 -0
- package/hooks/context_budget/findings.py +251 -0
- package/hooks/context_budget/hook_measure.py +178 -0
- package/hooks/context_budget/model.py +134 -0
- package/hooks/context_budget/policy_changes.py +214 -0
- package/hooks/context_budget/policy_file.py +222 -0
- package/hooks/context_budget/sections.py +213 -0
- package/hooks/context_budget/tests/budget_support.py +71 -0
- package/hooks/context_budget/tests/test_baseline.py +43 -0
- package/hooks/context_budget/tests/test_findings.py +120 -0
- package/hooks/context_budget/tests/test_hook_measure.py +57 -0
- package/hooks/context_budget/tests/test_policy_changes.py +175 -0
- package/hooks/context_budget/tests/test_policy_file.py +62 -0
- package/hooks/context_budget/tests/test_sections.py +112 -0
- package/hooks/features/write-edit-blocking.md +5 -1
- package/hooks/hooks_constants/context_budget_constants.py +154 -0
- package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +6 -1
- package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +6 -2
- package/hooks/test_context_budget_hooks.py +79 -0
- package/hooks/test_files/context_budget/advisor_config/settings.json +3 -0
- package/hooks/test_files/context_budget/empty_transcript.jsonl +0 -0
- package/hooks/test_files/context_budget/orchestrator_skill_3f2c3ef.txt +170 -0
- package/hooks/test_files/context_budget/orchestrator_skill_98f45b1.txt +158 -0
- package/package.json +1 -1
- package/scripts/_code_review_test_support.py +7 -12
- package/scripts/context_budget_check.py +243 -0
- package/scripts/dev_env_scripts_constants/context_budget_check_constants.py +26 -0
- package/scripts/policy_lint/adapter_context_budget.py +158 -0
- package/scripts/policy_lint/adapters.py +73 -0
- package/scripts/policy_lint/config/constants.py +17 -1
- package/scripts/policy_lint/registry.py +17 -0
- package/scripts/tests/test_context_budget_check.py +270 -0
- package/scripts/tests/test_policy_lint_rules_context_budget.py +218 -0
- package/scripts/tests/test_policy_lint_rules_pairing.py +26 -0
- package/system-prompts/codex-sol.md +170 -0
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""Report a policy file change that loosens the budget.
|
|
2
|
+
|
|
3
|
+
The over-budget list may only shrink: a new or larger baseline entry, a raised
|
|
4
|
+
limit, a section rule turned off, a deleted kind, or a deleted hook whose
|
|
5
|
+
command script is still in the tree is a finding. A hook deleted together
|
|
6
|
+
with its script passes.
|
|
7
|
+
Existing kind patterns and precedence stay fixed. A null line count that
|
|
8
|
+
becomes a number is larger. A policy file the change introduces passes.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from collections import Counter
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from context_budget.model import (
|
|
17
|
+
BaselineEntry,
|
|
18
|
+
BudgetFinding,
|
|
19
|
+
BudgetPolicy,
|
|
20
|
+
ContextKind,
|
|
21
|
+
HookBudget,
|
|
22
|
+
)
|
|
23
|
+
from hooks_constants.context_budget_constants import (
|
|
24
|
+
CHANGE_ADDS_FILE,
|
|
25
|
+
CHANGE_ADDS_HOOK,
|
|
26
|
+
CHANGE_ADDS_SECTION,
|
|
27
|
+
CHANGE_CHANGES_KIND_ORDER,
|
|
28
|
+
CHANGE_CHANGES_PATTERNS,
|
|
29
|
+
CHANGE_DELETES_HOOK,
|
|
30
|
+
CHANGE_DELETES_KIND,
|
|
31
|
+
CHANGE_DISABLES_SECTION_RULE,
|
|
32
|
+
CHANGE_RAISES_CHAR_LIMIT,
|
|
33
|
+
CHANGE_RAISES_FILE,
|
|
34
|
+
CHANGE_RAISES_HOOK,
|
|
35
|
+
CHANGE_RAISES_LINE_LIMIT,
|
|
36
|
+
CHANGE_RAISES_SECTION_LIMIT,
|
|
37
|
+
CHANGE_RECORDS_FILE_LINES,
|
|
38
|
+
FILE_SCOPE,
|
|
39
|
+
NO_LINE_LIMIT_TEXT,
|
|
40
|
+
SHRINK_ONLY_TEMPLATE,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _limit_text(line_limit: int | None) -> str:
|
|
45
|
+
return NO_LINE_LIMIT_TEXT if line_limit is None else str(line_limit)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _kind_changes(prior_kind: ContextKind, current_kind: ContextKind | None) -> list[str]:
|
|
49
|
+
if current_kind is None:
|
|
50
|
+
return [CHANGE_DELETES_KIND.format(subject=prior_kind.name)]
|
|
51
|
+
all_changes: list[str] = []
|
|
52
|
+
if current_kind.all_patterns != prior_kind.all_patterns:
|
|
53
|
+
all_changes.append(CHANGE_CHANGES_PATTERNS.format(subject=prior_kind.name))
|
|
54
|
+
prior_limit = prior_kind.line_limit
|
|
55
|
+
current_limit = current_kind.line_limit
|
|
56
|
+
if prior_limit is not None and (current_limit is None or current_limit > prior_limit):
|
|
57
|
+
all_changes.append(
|
|
58
|
+
CHANGE_RAISES_LINE_LIMIT.format(
|
|
59
|
+
subject=prior_kind.name,
|
|
60
|
+
prior=_limit_text(prior_limit),
|
|
61
|
+
current=_limit_text(current_limit),
|
|
62
|
+
)
|
|
63
|
+
)
|
|
64
|
+
if prior_kind.is_section_rule_on and not current_kind.is_section_rule_on:
|
|
65
|
+
all_changes.append(CHANGE_DISABLES_SECTION_RULE.format(subject=prior_kind.name))
|
|
66
|
+
return all_changes
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _limit_changes(
|
|
70
|
+
prior_policy: BudgetPolicy, current_policy: BudgetPolicy, repository_root: Path
|
|
71
|
+
) -> list[str]:
|
|
72
|
+
all_changes: list[str] = []
|
|
73
|
+
prior_section_limit = prior_policy.section_detail_line_limit
|
|
74
|
+
current_section_limit = current_policy.section_detail_line_limit
|
|
75
|
+
if current_section_limit > prior_section_limit:
|
|
76
|
+
all_changes.append(
|
|
77
|
+
CHANGE_RAISES_SECTION_LIMIT.format(
|
|
78
|
+
prior=prior_section_limit, current=current_section_limit
|
|
79
|
+
)
|
|
80
|
+
)
|
|
81
|
+
current_kind_by_name = {each.name: each for each in current_policy.all_kinds}
|
|
82
|
+
all_prior_kind_names = tuple(
|
|
83
|
+
each.name for each in prior_policy.all_kinds if each.name in current_kind_by_name
|
|
84
|
+
)
|
|
85
|
+
all_current_kind_names = tuple(each.name for each in current_policy.all_kinds)
|
|
86
|
+
if (
|
|
87
|
+
all_current_kind_names[:len(all_prior_kind_names)] != all_prior_kind_names
|
|
88
|
+
or len(set(all_current_kind_names)) != len(all_current_kind_names)
|
|
89
|
+
):
|
|
90
|
+
all_changes.append(CHANGE_CHANGES_KIND_ORDER)
|
|
91
|
+
for each_prior_kind in prior_policy.all_kinds:
|
|
92
|
+
all_changes.extend(
|
|
93
|
+
_kind_changes(each_prior_kind, current_kind_by_name.get(each_prior_kind.name))
|
|
94
|
+
)
|
|
95
|
+
return all_changes + _hook_limit_changes(prior_policy, current_policy, repository_root)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _has_script_in_tree(hook: HookBudget, repository_root: Path) -> bool:
|
|
99
|
+
return any(
|
|
100
|
+
not Path(each_part).is_absolute() and (repository_root / each_part).is_file()
|
|
101
|
+
for each_part in hook.all_command_parts
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _deleted_hook_changes(prior_hook: HookBudget, repository_root: Path) -> list[str]:
|
|
106
|
+
if not _has_script_in_tree(prior_hook, repository_root):
|
|
107
|
+
return []
|
|
108
|
+
return [CHANGE_DELETES_HOOK.format(subject=prior_hook.name)]
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _hook_limit_changes(
|
|
112
|
+
prior_policy: BudgetPolicy, current_policy: BudgetPolicy, repository_root: Path
|
|
113
|
+
) -> list[str]:
|
|
114
|
+
"""Preserve each measured hook whose script remains and hold its limit to the prior budget."""
|
|
115
|
+
all_changes: list[str] = []
|
|
116
|
+
current_hook_by_name = {each.name: each for each in current_policy.all_hooks}
|
|
117
|
+
for each_prior_hook in prior_policy.all_hooks:
|
|
118
|
+
current_hook = current_hook_by_name.get(each_prior_hook.name)
|
|
119
|
+
if current_hook is None:
|
|
120
|
+
all_changes.extend(_deleted_hook_changes(each_prior_hook, repository_root))
|
|
121
|
+
continue
|
|
122
|
+
current_limit = current_hook.char_limit
|
|
123
|
+
if current_limit > each_prior_hook.char_limit:
|
|
124
|
+
all_changes.append(
|
|
125
|
+
CHANGE_RAISES_CHAR_LIMIT.format(
|
|
126
|
+
subject=each_prior_hook.name,
|
|
127
|
+
prior=each_prior_hook.char_limit,
|
|
128
|
+
current=current_limit,
|
|
129
|
+
)
|
|
130
|
+
)
|
|
131
|
+
return all_changes
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _line_entry_change(
|
|
135
|
+
each_path: str, each_entry: BaselineEntry, prior_entry: BaselineEntry
|
|
136
|
+
) -> str | None:
|
|
137
|
+
if each_entry.lines is None:
|
|
138
|
+
return None
|
|
139
|
+
if prior_entry.lines is None:
|
|
140
|
+
return CHANGE_RECORDS_FILE_LINES.format(subject=each_path, current=each_entry.lines)
|
|
141
|
+
if each_entry.lines > prior_entry.lines:
|
|
142
|
+
return CHANGE_RAISES_FILE.format(
|
|
143
|
+
subject=each_path, prior=prior_entry.lines, current=each_entry.lines
|
|
144
|
+
)
|
|
145
|
+
return None
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _file_entry_changes(prior_policy: BudgetPolicy, current_policy: BudgetPolicy) -> list[str]:
|
|
149
|
+
all_changes: list[str] = []
|
|
150
|
+
for each_path, each_entry in sorted(current_policy.baseline_entry_by_path.items()):
|
|
151
|
+
prior_entry = prior_policy.baseline_entry_by_path.get(each_path)
|
|
152
|
+
if prior_entry is None:
|
|
153
|
+
all_changes.append(CHANGE_ADDS_FILE.format(subject=each_path))
|
|
154
|
+
continue
|
|
155
|
+
line_change = _line_entry_change(each_path, each_entry, prior_entry)
|
|
156
|
+
if line_change is not None:
|
|
157
|
+
all_changes.append(line_change)
|
|
158
|
+
all_added_headings = Counter(each_entry.all_section_headings) - Counter(
|
|
159
|
+
prior_entry.all_section_headings
|
|
160
|
+
)
|
|
161
|
+
all_changes.extend(
|
|
162
|
+
CHANGE_ADDS_SECTION.format(heading=each_heading, subject=each_path)
|
|
163
|
+
for each_heading in sorted(all_added_headings.elements())
|
|
164
|
+
)
|
|
165
|
+
return all_changes
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _hook_entry_change(each_name: str, each_count: int, prior_count: int | None) -> str | None:
|
|
169
|
+
if prior_count is None:
|
|
170
|
+
return CHANGE_ADDS_HOOK.format(subject=each_name)
|
|
171
|
+
if each_count > prior_count:
|
|
172
|
+
return CHANGE_RAISES_HOOK.format(subject=each_name, prior=prior_count, current=each_count)
|
|
173
|
+
return None
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _hook_entry_changes(prior_policy: BudgetPolicy, current_policy: BudgetPolicy) -> list[str]:
|
|
177
|
+
prior_count_by_name = prior_policy.baseline_characters_by_hook_name
|
|
178
|
+
all_candidate_changes = (
|
|
179
|
+
_hook_entry_change(each_name, each_count, prior_count_by_name.get(each_name))
|
|
180
|
+
for each_name, each_count in sorted(current_policy.baseline_characters_by_hook_name.items())
|
|
181
|
+
)
|
|
182
|
+
return [each for each in all_candidate_changes if each is not None]
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def policy_shrink_findings(
|
|
186
|
+
policy_path: str,
|
|
187
|
+
prior_policy: BudgetPolicy | None,
|
|
188
|
+
current_policy: BudgetPolicy,
|
|
189
|
+
repository_root: Path,
|
|
190
|
+
) -> tuple[BudgetFinding, ...]:
|
|
191
|
+
"""Report each change to the policy file that loosens the budget.
|
|
192
|
+
|
|
193
|
+
Args:
|
|
194
|
+
policy_path: Repository-relative path of the policy file.
|
|
195
|
+
prior_policy: Policy before the change, or None when the change adds it.
|
|
196
|
+
current_policy: Policy after the change.
|
|
197
|
+
repository_root: Working tree that holds each hook's command script.
|
|
198
|
+
|
|
199
|
+
Returns:
|
|
200
|
+
One finding per loosening change; empty when the file is new.
|
|
201
|
+
"""
|
|
202
|
+
if prior_policy is None:
|
|
203
|
+
return ()
|
|
204
|
+
all_changes = (
|
|
205
|
+
_limit_changes(prior_policy, current_policy, repository_root)
|
|
206
|
+
+ _file_entry_changes(prior_policy, current_policy)
|
|
207
|
+
+ _hook_entry_changes(prior_policy, current_policy)
|
|
208
|
+
)
|
|
209
|
+
return tuple(
|
|
210
|
+
BudgetFinding(
|
|
211
|
+
policy_path, FILE_SCOPE, SHRINK_ONLY_TEMPLATE.format(path=policy_path, change=each)
|
|
212
|
+
)
|
|
213
|
+
for each in all_changes
|
|
214
|
+
)
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""Parse ``.claude/context-budget.json`` and locate it from an edited file.
|
|
2
|
+
|
|
3
|
+
Unknown keys are ignored, so a policy file another tool extends still parses.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from context_budget.model import (
|
|
12
|
+
BaselineEntry,
|
|
13
|
+
BudgetPolicy,
|
|
14
|
+
ContextBudgetPolicyError,
|
|
15
|
+
ContextKind,
|
|
16
|
+
HookBudget,
|
|
17
|
+
)
|
|
18
|
+
from hooks_constants.context_budget_constants import (
|
|
19
|
+
ALL_DEFAULT_KIND_SPECS,
|
|
20
|
+
ALL_POLICY_PATH_PARTS,
|
|
21
|
+
BASELINE_FILES_KEY,
|
|
22
|
+
BASELINE_HOOKS_KEY,
|
|
23
|
+
BASELINE_KEY,
|
|
24
|
+
CHAR_LIMIT_KEY,
|
|
25
|
+
COMMAND_KEY,
|
|
26
|
+
DEFAULT_SECTION_DETAIL_LINE_LIMIT,
|
|
27
|
+
GIT_MARKER_NAME,
|
|
28
|
+
HOOKS_KEY,
|
|
29
|
+
KINDS_KEY,
|
|
30
|
+
LINE_LIMIT_KEY,
|
|
31
|
+
LINES_KEY,
|
|
32
|
+
NAME_KEY,
|
|
33
|
+
PATTERNS_KEY,
|
|
34
|
+
POLICY_ERROR_BAD_FIELD,
|
|
35
|
+
POLICY_ERROR_DUPLICATE_HOOK_NAME,
|
|
36
|
+
POLICY_ERROR_NOT_OBJECT,
|
|
37
|
+
SECTION_DETAIL_LINE_LIMIT_KEY,
|
|
38
|
+
SECTION_RULE_KEY,
|
|
39
|
+
SECTIONS_KEY,
|
|
40
|
+
STDIN_KEY,
|
|
41
|
+
UTF8_ENCODING,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def default_policy() -> BudgetPolicy:
|
|
46
|
+
"""Return the built-in policy for a repository with no policy file.
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
The default kinds with an empty baseline.
|
|
50
|
+
"""
|
|
51
|
+
return BudgetPolicy(
|
|
52
|
+
section_detail_line_limit=DEFAULT_SECTION_DETAIL_LINE_LIMIT,
|
|
53
|
+
all_kinds=tuple(
|
|
54
|
+
ContextKind(each_name, each_patterns, each_limit, each_rule)
|
|
55
|
+
for each_name, each_patterns, each_limit, each_rule in ALL_DEFAULT_KIND_SPECS
|
|
56
|
+
),
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _bad_field(field_name: str) -> ContextBudgetPolicyError:
|
|
61
|
+
return ContextBudgetPolicyError(POLICY_ERROR_BAD_FIELD.format(field=field_name))
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _integer(raw_value: object, field_name: str) -> int:
|
|
65
|
+
if isinstance(raw_value, bool) or not isinstance(raw_value, int):
|
|
66
|
+
raise _bad_field(field_name)
|
|
67
|
+
return raw_value
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _text(raw_value: object, field_name: str) -> str:
|
|
71
|
+
if not isinstance(raw_value, str):
|
|
72
|
+
raise _bad_field(field_name)
|
|
73
|
+
return raw_value
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _mapping(raw_value: object, field_name: str) -> dict:
|
|
77
|
+
if not isinstance(raw_value, dict):
|
|
78
|
+
raise _bad_field(field_name)
|
|
79
|
+
return raw_value
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _texts(raw_value: object, field_name: str) -> tuple[str, ...]:
|
|
83
|
+
if not isinstance(raw_value, list):
|
|
84
|
+
raise _bad_field(field_name)
|
|
85
|
+
return tuple(_text(each_item, field_name) for each_item in raw_value)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _items(raw_value: object, field_name: str) -> list:
|
|
89
|
+
if not isinstance(raw_value, list):
|
|
90
|
+
raise _bad_field(field_name)
|
|
91
|
+
return raw_value
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _parse_kind(raw_kind: object) -> ContextKind:
|
|
95
|
+
kind_by_key = _mapping(raw_kind, KINDS_KEY)
|
|
96
|
+
raw_limit = kind_by_key.get(LINE_LIMIT_KEY)
|
|
97
|
+
return ContextKind(
|
|
98
|
+
_text(kind_by_key.get(NAME_KEY), NAME_KEY),
|
|
99
|
+
_texts(kind_by_key.get(PATTERNS_KEY), PATTERNS_KEY),
|
|
100
|
+
None if raw_limit is None else _integer(raw_limit, LINE_LIMIT_KEY),
|
|
101
|
+
kind_by_key.get(SECTION_RULE_KEY) is True,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _parse_hook(raw_hook: object) -> HookBudget:
|
|
106
|
+
hook_by_key = _mapping(raw_hook, HOOKS_KEY)
|
|
107
|
+
return HookBudget(
|
|
108
|
+
_text(hook_by_key.get(NAME_KEY), NAME_KEY),
|
|
109
|
+
_texts(hook_by_key.get(COMMAND_KEY), COMMAND_KEY),
|
|
110
|
+
json.dumps(hook_by_key.get(STDIN_KEY, {})),
|
|
111
|
+
_integer(hook_by_key.get(CHAR_LIMIT_KEY), CHAR_LIMIT_KEY),
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _parse_hooks(raw_hooks: object) -> tuple[HookBudget, ...]:
|
|
116
|
+
all_hooks = tuple(_parse_hook(each) for each in _items(raw_hooks, HOOKS_KEY))
|
|
117
|
+
all_names = [each.name for each in all_hooks]
|
|
118
|
+
if len(set(all_names)) != len(all_names):
|
|
119
|
+
raise ContextBudgetPolicyError(POLICY_ERROR_DUPLICATE_HOOK_NAME)
|
|
120
|
+
return all_hooks
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _parse_baseline_entry(raw_entry: object) -> BaselineEntry:
|
|
124
|
+
entry_by_key = _mapping(raw_entry, BASELINE_FILES_KEY)
|
|
125
|
+
if LINES_KEY not in entry_by_key:
|
|
126
|
+
raise _bad_field(LINES_KEY)
|
|
127
|
+
raw_lines = entry_by_key[LINES_KEY]
|
|
128
|
+
return BaselineEntry(
|
|
129
|
+
None if raw_lines is None else _integer(raw_lines, LINES_KEY),
|
|
130
|
+
_texts(entry_by_key.get(SECTIONS_KEY, []), SECTIONS_KEY),
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _parse_baseline(raw_baseline: object) -> tuple[dict[str, BaselineEntry], dict[str, int]]:
|
|
135
|
+
baseline_by_key = _mapping(raw_baseline, BASELINE_KEY)
|
|
136
|
+
raw_files = _mapping(baseline_by_key.get(BASELINE_FILES_KEY, {}), BASELINE_FILES_KEY)
|
|
137
|
+
raw_hooks = _mapping(baseline_by_key.get(BASELINE_HOOKS_KEY, {}), BASELINE_HOOKS_KEY)
|
|
138
|
+
return (
|
|
139
|
+
{
|
|
140
|
+
str(each_path): _parse_baseline_entry(each_entry)
|
|
141
|
+
for each_path, each_entry in raw_files.items()
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
str(each_name): _integer(each_count, BASELINE_HOOKS_KEY)
|
|
145
|
+
for each_name, each_count in raw_hooks.items()
|
|
146
|
+
},
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _json_object(policy_text: str) -> dict:
|
|
151
|
+
try:
|
|
152
|
+
raw_policy = json.loads(policy_text)
|
|
153
|
+
except json.JSONDecodeError as error:
|
|
154
|
+
raise ContextBudgetPolicyError(str(error)) from error
|
|
155
|
+
if not isinstance(raw_policy, dict):
|
|
156
|
+
raise ContextBudgetPolicyError(POLICY_ERROR_NOT_OBJECT)
|
|
157
|
+
return raw_policy
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def parse_policy(policy_text: str) -> BudgetPolicy:
|
|
161
|
+
"""Parse policy JSON text; unknown keys are ignored.
|
|
162
|
+
|
|
163
|
+
Args:
|
|
164
|
+
policy_text: The text of ``.claude/context-budget.json``.
|
|
165
|
+
|
|
166
|
+
Returns:
|
|
167
|
+
The parsed policy.
|
|
168
|
+
|
|
169
|
+
Raises:
|
|
170
|
+
ContextBudgetPolicyError: When the text is not JSON of the documented shape.
|
|
171
|
+
"""
|
|
172
|
+
raw_policy = _json_object(policy_text)
|
|
173
|
+
entry_by_path, characters_by_hook_name = _parse_baseline(raw_policy.get(BASELINE_KEY, {}))
|
|
174
|
+
return BudgetPolicy(
|
|
175
|
+
section_detail_line_limit=_integer(
|
|
176
|
+
raw_policy.get(SECTION_DETAIL_LINE_LIMIT_KEY, DEFAULT_SECTION_DETAIL_LINE_LIMIT),
|
|
177
|
+
SECTION_DETAIL_LINE_LIMIT_KEY,
|
|
178
|
+
),
|
|
179
|
+
all_kinds=tuple(
|
|
180
|
+
_parse_kind(each) for each in _items(raw_policy.get(KINDS_KEY, []), KINDS_KEY)
|
|
181
|
+
),
|
|
182
|
+
all_hooks=_parse_hooks(raw_policy.get(HOOKS_KEY, [])),
|
|
183
|
+
baseline_entry_by_path=entry_by_path,
|
|
184
|
+
baseline_characters_by_hook_name=characters_by_hook_name,
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def find_repository_root(file_path: Path) -> Path | None:
|
|
189
|
+
"""Return the nearest ancestor holding a ``.git`` file or directory.
|
|
190
|
+
|
|
191
|
+
Args:
|
|
192
|
+
file_path: Absolute path of a file that may not exist yet.
|
|
193
|
+
|
|
194
|
+
Returns:
|
|
195
|
+
The repository root, or None outside any repository.
|
|
196
|
+
"""
|
|
197
|
+
for each_directory in file_path.parents:
|
|
198
|
+
if (each_directory / GIT_MARKER_NAME).exists():
|
|
199
|
+
return each_directory
|
|
200
|
+
return None
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def read_policy_file(repository_root: Path) -> BudgetPolicy | None:
|
|
204
|
+
"""Read and parse a repository's policy file.
|
|
205
|
+
|
|
206
|
+
Args:
|
|
207
|
+
repository_root: Repository root directory.
|
|
208
|
+
|
|
209
|
+
Returns:
|
|
210
|
+
The parsed policy, or None when the repository has no policy file.
|
|
211
|
+
|
|
212
|
+
Raises:
|
|
213
|
+
ContextBudgetPolicyError: When the file exists and does not parse.
|
|
214
|
+
"""
|
|
215
|
+
policy_path = repository_root.joinpath(*ALL_POLICY_PATH_PARTS)
|
|
216
|
+
try:
|
|
217
|
+
policy_text = policy_path.read_text(encoding=UTF8_ENCODING)
|
|
218
|
+
except FileNotFoundError:
|
|
219
|
+
return None
|
|
220
|
+
except (OSError, UnicodeDecodeError) as error:
|
|
221
|
+
raise ContextBudgetPolicyError(str(error)) from error
|
|
222
|
+
return parse_policy(policy_text)
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""Classify a path by the kind table and measure its Markdown sections.
|
|
2
|
+
|
|
3
|
+
::
|
|
4
|
+
|
|
5
|
+
## Plan heading starts a section
|
|
6
|
+
Step one. detail line
|
|
7
|
+
| a | b | table row, not detail
|
|
8
|
+
- [ref](reference/x.md) pure link item, not detail; relative link is a pointer
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from collections.abc import Sequence
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from fnmatch import fnmatchcase
|
|
16
|
+
|
|
17
|
+
from context_budget.model import (
|
|
18
|
+
BaselineEntry,
|
|
19
|
+
BudgetPolicy,
|
|
20
|
+
ContextKind,
|
|
21
|
+
FileMeasure,
|
|
22
|
+
SectionScan,
|
|
23
|
+
)
|
|
24
|
+
from hooks_constants.context_budget_constants import (
|
|
25
|
+
ALL_NON_RELATIVE_TARGET_PREFIXES,
|
|
26
|
+
ATX_HEADING_PATTERN,
|
|
27
|
+
FENCE_OPEN_PATTERN,
|
|
28
|
+
FRONTMATTER_DELIMITER,
|
|
29
|
+
LINK_TARGET_PATTERN,
|
|
30
|
+
PATH_SEPARATOR,
|
|
31
|
+
PURE_LINK_LIST_ITEM_PATTERN,
|
|
32
|
+
RECURSIVE_GLOB_SEGMENT,
|
|
33
|
+
TABLE_ROW_PREFIX,
|
|
34
|
+
TOP_SECTION_HEADING,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _segments_match(all_pattern_parts: Sequence[str], all_path_parts: Sequence[str]) -> bool:
|
|
39
|
+
if not all_pattern_parts:
|
|
40
|
+
return not all_path_parts
|
|
41
|
+
if all_pattern_parts[0] == RECURSIVE_GLOB_SEGMENT:
|
|
42
|
+
return any(
|
|
43
|
+
_segments_match(all_pattern_parts[1:], all_path_parts[each_start:])
|
|
44
|
+
for each_start in range(len(all_path_parts) + 1)
|
|
45
|
+
)
|
|
46
|
+
if not all_path_parts or not fnmatchcase(all_path_parts[0], all_pattern_parts[0]):
|
|
47
|
+
return False
|
|
48
|
+
return _segments_match(all_pattern_parts[1:], all_path_parts[1:])
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def path_matches_pattern(pattern: str, relative_path: str) -> bool:
|
|
52
|
+
"""Return whether a repository-relative POSIX path matches one glob.
|
|
53
|
+
|
|
54
|
+
::
|
|
55
|
+
|
|
56
|
+
"**/SKILL.md" "SKILL.md" -> True
|
|
57
|
+
"rules/*.md" "rules/deep/a.md" -> False
|
|
58
|
+
"**/archived/**" "x/archived/a/b.md" -> True
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
pattern: Glob where ``**`` spans zero or more directories.
|
|
62
|
+
relative_path: Repository-relative POSIX path.
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
True when the whole path matches.
|
|
66
|
+
"""
|
|
67
|
+
return _segments_match(pattern.split(PATH_SEPARATOR), relative_path.split(PATH_SEPARATOR))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def context_kind_for_path(policy: BudgetPolicy, relative_path: str) -> ContextKind | None:
|
|
71
|
+
"""Return the measured kind of a path; the first matching kind wins.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
policy: Budget policy holding the ordered kind table.
|
|
75
|
+
relative_path: Repository-relative POSIX path.
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
The matching kind, or None when no kind matches or the match is skipped.
|
|
79
|
+
"""
|
|
80
|
+
for each_kind in policy.all_kinds:
|
|
81
|
+
if any(path_matches_pattern(each, relative_path) for each in each_kind.all_patterns):
|
|
82
|
+
return each_kind if each_kind.line_limit is not None else None
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _body_lines(text: str) -> list[str]:
|
|
87
|
+
all_lines = text.splitlines()
|
|
88
|
+
if not all_lines or all_lines[0].strip() != FRONTMATTER_DELIMITER:
|
|
89
|
+
return all_lines
|
|
90
|
+
for each_index in range(1, len(all_lines)):
|
|
91
|
+
if all_lines[each_index].strip() == FRONTMATTER_DELIMITER:
|
|
92
|
+
return all_lines[each_index + 1 :]
|
|
93
|
+
return all_lines
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _line_has_pointer(line: str) -> bool:
|
|
97
|
+
return any(
|
|
98
|
+
not each_match.group("target").lower().startswith(ALL_NON_RELATIVE_TARGET_PREFIXES)
|
|
99
|
+
for each_match in LINK_TARGET_PATTERN.finditer(line)
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _is_detail_line(line: str) -> bool:
|
|
104
|
+
if not line.strip() or line.lstrip().startswith(TABLE_ROW_PREFIX):
|
|
105
|
+
return False
|
|
106
|
+
return PURE_LINK_LIST_ITEM_PATTERN.match(line) is None
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _closes_fence(line: str, opening_marker: str) -> bool:
|
|
110
|
+
match = FENCE_OPEN_PATTERN.match(line)
|
|
111
|
+
if match is None or line[match.end() :].strip():
|
|
112
|
+
return False
|
|
113
|
+
marker = match.group("marker")
|
|
114
|
+
return marker[0] == opening_marker[0] and len(marker) >= len(opening_marker)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@dataclass
|
|
118
|
+
class _SectionWalk:
|
|
119
|
+
"""Mutable state of one pass over a Markdown body."""
|
|
120
|
+
|
|
121
|
+
all_scans: list[SectionScan]
|
|
122
|
+
heading: str = TOP_SECTION_HEADING
|
|
123
|
+
detail_line_count: int = 0
|
|
124
|
+
has_pointer: bool = False
|
|
125
|
+
opening_marker: str | None = None
|
|
126
|
+
|
|
127
|
+
def close_section(self) -> None:
|
|
128
|
+
self.all_scans.append(SectionScan(self.heading, self.detail_line_count, self.has_pointer))
|
|
129
|
+
|
|
130
|
+
def read_fenced(self, line: str) -> None:
|
|
131
|
+
if line.strip():
|
|
132
|
+
self.detail_line_count += 1
|
|
133
|
+
if self.opening_marker is not None and _closes_fence(line, self.opening_marker):
|
|
134
|
+
self.opening_marker = None
|
|
135
|
+
|
|
136
|
+
def read_open(self, line: str) -> None:
|
|
137
|
+
fence_match = FENCE_OPEN_PATTERN.match(line)
|
|
138
|
+
heading_match = ATX_HEADING_PATTERN.match(line)
|
|
139
|
+
if fence_match is not None:
|
|
140
|
+
self.opening_marker = fence_match.group("marker")
|
|
141
|
+
self.detail_line_count += 1
|
|
142
|
+
elif heading_match is not None:
|
|
143
|
+
self.close_section()
|
|
144
|
+
self.heading = (heading_match.group("text") or "").strip()
|
|
145
|
+
self.detail_line_count = 0
|
|
146
|
+
self.has_pointer = False
|
|
147
|
+
else:
|
|
148
|
+
self.detail_line_count += int(_is_detail_line(line))
|
|
149
|
+
self.has_pointer = self.has_pointer or _line_has_pointer(line)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def scan_sections(text: str) -> tuple[SectionScan, ...]:
|
|
153
|
+
"""Split Markdown into sections and measure each one.
|
|
154
|
+
|
|
155
|
+
A heading inside a fenced block is not a heading. Fenced lines count as
|
|
156
|
+
detail and never supply a pointer.
|
|
157
|
+
|
|
158
|
+
Args:
|
|
159
|
+
text: Full file text; leading frontmatter is removed.
|
|
160
|
+
|
|
161
|
+
Returns:
|
|
162
|
+
One scan per section in document order, starting with ``(top)``.
|
|
163
|
+
"""
|
|
164
|
+
walk = _SectionWalk([])
|
|
165
|
+
for each_line in _body_lines(text):
|
|
166
|
+
if walk.opening_marker is not None:
|
|
167
|
+
walk.read_fenced(each_line)
|
|
168
|
+
else:
|
|
169
|
+
walk.read_open(each_line)
|
|
170
|
+
walk.close_section()
|
|
171
|
+
return tuple(walk.all_scans)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def measure_file(policy: BudgetPolicy, relative_path: str, text: str) -> FileMeasure | None:
|
|
175
|
+
"""Measure one file against the kind its path selects.
|
|
176
|
+
|
|
177
|
+
Args:
|
|
178
|
+
policy: Budget policy.
|
|
179
|
+
relative_path: Repository-relative POSIX path.
|
|
180
|
+
text: File text.
|
|
181
|
+
|
|
182
|
+
Returns:
|
|
183
|
+
The measurement, or None when the path is not measured context.
|
|
184
|
+
"""
|
|
185
|
+
kind = context_kind_for_path(policy, relative_path)
|
|
186
|
+
if kind is None:
|
|
187
|
+
return None
|
|
188
|
+
all_over_limit_sections: tuple[SectionScan, ...] = ()
|
|
189
|
+
if kind.is_section_rule_on:
|
|
190
|
+
all_over_limit_sections = tuple(
|
|
191
|
+
each_scan
|
|
192
|
+
for each_scan in scan_sections(text)
|
|
193
|
+
if each_scan.detail_line_count > policy.section_detail_line_limit
|
|
194
|
+
and not each_scan.has_pointer
|
|
195
|
+
)
|
|
196
|
+
return FileMeasure(kind, len(text.splitlines()), all_over_limit_sections)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def baseline_entry_for(measure: FileMeasure) -> BaselineEntry | None:
|
|
200
|
+
"""Return the baseline entry a measurement needs, or None when within budget.
|
|
201
|
+
|
|
202
|
+
Args:
|
|
203
|
+
measure: One file measurement.
|
|
204
|
+
|
|
205
|
+
Returns:
|
|
206
|
+
The entry recording the over-limit headings, and the line count only
|
|
207
|
+
when it exceeds the kind's line limit.
|
|
208
|
+
"""
|
|
209
|
+
all_headings = tuple(each_scan.heading for each_scan in measure.all_over_limit_sections)
|
|
210
|
+
is_over_line_limit = measure.line_count > (measure.kind.line_limit or 0)
|
|
211
|
+
if not is_over_line_limit and not all_headings:
|
|
212
|
+
return None
|
|
213
|
+
return BaselineEntry(measure.line_count if is_over_line_limit else None, all_headings)
|