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.
Files changed (48) hide show
  1. package/.agents/skills/build-eval/SKILL.md +4 -8
  2. package/.agents/skills/build-eval/reference/context-budget.md +49 -0
  3. package/.agents/skills/orchestrator/reference/oversee-delegated-work.md +1 -0
  4. package/.agents/skills/pr-lifecycle/SKILL.md +3 -10
  5. package/bin/codex-trimmed-sol.mjs +109 -0
  6. package/bin/codex-trimmed-sol.test.mjs +226 -0
  7. package/bin/install.mjs +18 -0
  8. package/bin/install.question-presentation.test.mjs +1 -1
  9. package/docs/codex-accounts.md +26 -0
  10. package/hooks/blocking/context_budget_blocker.py +177 -0
  11. package/hooks/blocking/test_context_budget_blocker.py +189 -0
  12. package/hooks/blocking/test_pre_tool_use_dispatcher.py +3 -1
  13. package/hooks/context_budget/__init__.py +1 -0
  14. package/hooks/context_budget/baseline.py +78 -0
  15. package/hooks/context_budget/findings.py +251 -0
  16. package/hooks/context_budget/hook_measure.py +178 -0
  17. package/hooks/context_budget/model.py +134 -0
  18. package/hooks/context_budget/policy_changes.py +214 -0
  19. package/hooks/context_budget/policy_file.py +222 -0
  20. package/hooks/context_budget/sections.py +213 -0
  21. package/hooks/context_budget/tests/budget_support.py +71 -0
  22. package/hooks/context_budget/tests/test_baseline.py +43 -0
  23. package/hooks/context_budget/tests/test_findings.py +120 -0
  24. package/hooks/context_budget/tests/test_hook_measure.py +57 -0
  25. package/hooks/context_budget/tests/test_policy_changes.py +175 -0
  26. package/hooks/context_budget/tests/test_policy_file.py +62 -0
  27. package/hooks/context_budget/tests/test_sections.py +112 -0
  28. package/hooks/features/write-edit-blocking.md +5 -1
  29. package/hooks/hooks_constants/context_budget_constants.py +154 -0
  30. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +6 -1
  31. package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +6 -2
  32. package/hooks/test_context_budget_hooks.py +79 -0
  33. package/hooks/test_files/context_budget/advisor_config/settings.json +3 -0
  34. package/hooks/test_files/context_budget/empty_transcript.jsonl +0 -0
  35. package/hooks/test_files/context_budget/orchestrator_skill_3f2c3ef.txt +170 -0
  36. package/hooks/test_files/context_budget/orchestrator_skill_98f45b1.txt +158 -0
  37. package/package.json +1 -1
  38. package/scripts/_code_review_test_support.py +7 -12
  39. package/scripts/context_budget_check.py +243 -0
  40. package/scripts/dev_env_scripts_constants/context_budget_check_constants.py +26 -0
  41. package/scripts/policy_lint/adapter_context_budget.py +158 -0
  42. package/scripts/policy_lint/adapters.py +73 -0
  43. package/scripts/policy_lint/config/constants.py +17 -1
  44. package/scripts/policy_lint/registry.py +17 -0
  45. package/scripts/tests/test_context_budget_check.py +270 -0
  46. package/scripts/tests/test_policy_lint_rules_context_budget.py +218 -0
  47. package/scripts/tests/test_policy_lint_rules_pairing.py +26 -0
  48. 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)