claude-dev-env 8.62.1 → 8.63.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.
@@ -0,0 +1,252 @@
1
+ """Read checkout files and measure them as context rows.
2
+
3
+ A row records one loaded file: its size, its links out, and its depth mode.
4
+ The helpers here read text, parse frontmatter, and follow links and imports.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import re
11
+ import subprocess
12
+ from dataclasses import dataclass
13
+ from pathlib import Path
14
+
15
+ from context_audit_constants.config import constants
16
+ from context_audit_constants.config.constants import DepthMode, RowKind, Trigger
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class ContextRow:
21
+ """One file an agent loads, with its size and how it loads."""
22
+
23
+ path: str
24
+ kind: RowKind
25
+ loader: str
26
+ trigger: Trigger
27
+ bytes: int
28
+ lines: int
29
+ est_tokens: int
30
+ links_out: int
31
+ depth_mode: DepthMode
32
+ cap: int | None
33
+ note: str
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class RowSource:
38
+ """Where a row's file sits and how the agent reaches it."""
39
+
40
+ path: str
41
+ kind: RowKind
42
+ loader: str
43
+ trigger: Trigger
44
+ note: str
45
+
46
+
47
+ def list_tracked_files(root: Path) -> list[Path]:
48
+ """Return the tracked files under *root*, minus caches and vendored folders.
49
+
50
+ Args:
51
+ root: A git checkout.
52
+
53
+ Returns:
54
+ Absolute paths of tracked files that exist on disk.
55
+ """
56
+ listing = subprocess.run(
57
+ constants.ALL_GIT_LIST_FILES_ARGUMENTS,
58
+ cwd=root,
59
+ capture_output=True,
60
+ check=True,
61
+ ).stdout
62
+ all_paths = [
63
+ root / os.fsdecode(each_entry)
64
+ for each_entry in listing.split(constants.GIT_LIST_FILES_SEPARATOR)
65
+ if each_entry
66
+ ]
67
+ return [
68
+ each_path
69
+ for each_path in all_paths
70
+ if not constants.ALL_SKIPPED_PATH_PARTS.intersection(each_path.parts)
71
+ and each_path.is_file()
72
+ ]
73
+
74
+
75
+ def read_text(path: Path) -> str:
76
+ """Return the file text, or an empty string when it cannot be read.
77
+
78
+ Args:
79
+ path: The file to read.
80
+
81
+ Returns:
82
+ The decoded text.
83
+ """
84
+ try:
85
+ return path.read_text(
86
+ encoding=constants.TEXT_ENCODING, errors=constants.TEXT_DECODE_ERRORS
87
+ )
88
+ except OSError:
89
+ return ""
90
+
91
+
92
+ def parse_frontmatter(text: str) -> dict[str, str]:
93
+ """Return the frontmatter fields of a markdown file as flat strings.
94
+
95
+ ::
96
+
97
+ ---
98
+ name: demo
99
+ paths:
100
+ - src/**
101
+ ---
102
+ -> {"name": "demo", "paths": "- src/**"}
103
+
104
+ Args:
105
+ text: The file text.
106
+
107
+ Returns:
108
+ Field text by field name; empty when the file has no frontmatter.
109
+ """
110
+ fence_length = len(constants.FRONTMATTER_FENCE)
111
+ closing_index = text.find(constants.FRONTMATTER_CLOSING_FENCE, fence_length)
112
+ if not text.startswith(constants.FRONTMATTER_FENCE) or closing_index == -1:
113
+ return {}
114
+ field_by_name: dict[str, str] = {}
115
+ current_name = ""
116
+ for each_line in text[fence_length:closing_index].splitlines():
117
+ current_name = _merge_frontmatter_line(field_by_name, current_name, each_line)
118
+ return field_by_name
119
+
120
+
121
+ def _merge_frontmatter_line(
122
+ field_by_name: dict[str, str], current_name: str, line: str
123
+ ) -> str:
124
+ field_match = re.match(constants.FRONTMATTER_FIELD_PATTERN, line)
125
+ if field_match:
126
+ field_by_name[field_match.group(1)] = field_match.group(2).strip()
127
+ return field_match.group(1)
128
+ if current_name and line.startswith(
129
+ constants.ALL_FRONTMATTER_CONTINUATION_PREFIXES
130
+ ):
131
+ field_by_name[current_name] = (
132
+ f"{field_by_name[current_name]} {line.strip()}".strip()
133
+ )
134
+ return current_name
135
+
136
+
137
+ def find_relative_links(source: Path, text: str, root: Path) -> list[Path]:
138
+ """Return the files inside *root* that markdown links in *text* point to.
139
+
140
+ Args:
141
+ source: The file the text came from.
142
+ text: Markdown text.
143
+ root: The checkout root.
144
+
145
+ Returns:
146
+ Resolved link targets that exist inside the checkout.
147
+ """
148
+ all_targets = [
149
+ each_target
150
+ for each_target in re.findall(constants.LINK_PATTERN, text)
151
+ if not re.match(constants.URL_SCHEME_PATTERN, each_target)
152
+ ]
153
+ return _existing_inside(source, all_targets, root)
154
+
155
+
156
+ def find_import_targets(source: Path, text: str, root: Path) -> list[Path]:
157
+ """Return the files inside *root* that ``@path`` import lines name.
158
+
159
+ Args:
160
+ source: The instruction file the text came from.
161
+ text: The instruction text.
162
+ root: The checkout root.
163
+
164
+ Returns:
165
+ Resolved import targets that exist inside the checkout.
166
+ """
167
+ return _existing_inside(source, re.findall(constants.IMPORT_PATTERN, text), root)
168
+
169
+
170
+ def _existing_inside(source: Path, all_targets: list[str], root: Path) -> list[Path]:
171
+ all_candidates = [
172
+ (source.parent / each_target).resolve() for each_target in all_targets
173
+ ]
174
+ return [
175
+ each_candidate
176
+ for each_candidate in all_candidates
177
+ if each_candidate.is_file() and root in each_candidate.parents
178
+ ]
179
+
180
+
181
+ def classify_depth(
182
+ cap: int | None, byte_count: int, line_count: int, link_count: int
183
+ ) -> DepthMode:
184
+ """Return how much depth a file carries against its cap.
185
+
186
+ ::
187
+
188
+ classify_depth(20, 0, 0, 0) -> empty
189
+ classify_depth(None, 900, 30, 0) -> reference
190
+ classify_depth(20, 4000, 64, 2) -> carries
191
+ classify_depth(20, 600, 12, 3) -> points
192
+
193
+ Args:
194
+ cap: The line cap, or None for on-link depth.
195
+ byte_count: File size in bytes.
196
+ line_count: File length in lines.
197
+ link_count: Relative links out of the file.
198
+
199
+ Returns:
200
+ The depth mode; a file over cap lines, or over cap times 120 bytes, carries.
201
+ """
202
+ if byte_count <= constants.EMPTY_FILE_BYTE_LIMIT:
203
+ return DepthMode.EMPTY
204
+ if cap is None:
205
+ return DepthMode.REFERENCE
206
+ if line_count > cap or byte_count > cap * constants.CHARACTERS_PER_CAPPED_LINE:
207
+ return DepthMode.CARRIES
208
+ return DepthMode.POINTS if link_count else DepthMode.LEAN
209
+
210
+
211
+ def count_lines(text: str) -> int:
212
+ """Return the line count of *text*, counting a final unterminated line.
213
+
214
+ Args:
215
+ text: Any text.
216
+
217
+ Returns:
218
+ The number of lines.
219
+ """
220
+ has_unterminated_tail = bool(text) and not text.endswith(constants.LINE_BREAK)
221
+ return text.count(constants.LINE_BREAK) + int(has_unterminated_tail)
222
+
223
+
224
+ def measure_row(
225
+ source: RowSource, text: str, cap: int | None, link_count: int
226
+ ) -> ContextRow:
227
+ """Return the row for *text* loaded from *source*.
228
+
229
+ Args:
230
+ source: Where the file sits and how it loads.
231
+ text: The text the agent receives.
232
+ cap: The line cap, or None for on-link depth.
233
+ link_count: Relative links out of the text.
234
+
235
+ Returns:
236
+ The measured row.
237
+ """
238
+ byte_count = len(text.encode(constants.TEXT_ENCODING))
239
+ line_count = count_lines(text)
240
+ return ContextRow(
241
+ path=source.path,
242
+ kind=source.kind,
243
+ loader=source.loader,
244
+ trigger=source.trigger,
245
+ bytes=byte_count,
246
+ lines=line_count,
247
+ est_tokens=round(byte_count / constants.BYTES_PER_ESTIMATED_TOKEN),
248
+ links_out=link_count,
249
+ depth_mode=classify_depth(cap, byte_count, line_count, link_count),
250
+ cap=cap,
251
+ note=source.note,
252
+ )
@@ -0,0 +1,217 @@
1
+ """Behavior tests for the context audit, run as a command on a temporary git checkout."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import subprocess
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ import pytest
11
+
12
+ SCRIPT_PATH = Path(__file__).resolve().parent / "context_audit.py"
13
+
14
+
15
+ def write_file(root: Path, relative_path: str, text: str) -> None:
16
+ target = root / relative_path
17
+ target.parent.mkdir(parents=True, exist_ok=True)
18
+ target.write_text(text, encoding="utf-8")
19
+
20
+
21
+ def numbered_lines(count: int) -> str:
22
+ return "".join(f"line {each_index}\n" for each_index in range(count))
23
+
24
+
25
+ @pytest.fixture
26
+ def checkout(tmp_path: Path) -> Path:
27
+ root = tmp_path / "sample-repo"
28
+ write_file(root, "CLAUDE.md", "@AGENTS.md\n@docs/shared.md\n")
29
+ write_file(root, "AGENTS.md", numbered_lines(30))
30
+ write_file(root, "docs/shared.md", "Shared steps. See [deep](deep.md).\n")
31
+ write_file(root, "docs/deep.md", "Deep notes. See [deeper](deeper.md).\n")
32
+ write_file(root, "docs/deeper.md", "Deeper notes.\n")
33
+ write_file(root, "nested/CLAUDE.md", "")
34
+ write_file(
35
+ root,
36
+ ".claude/rules/api.md",
37
+ "---\npaths:\n - src/api/**\n---\nUse the client.\n",
38
+ )
39
+ write_file(root, ".claude/rules/general.md", "Keep diffs small.\n")
40
+ write_file(
41
+ root,
42
+ ".claude/skills/demo/SKILL.md",
43
+ "---\nname: demo\ndescription: Run the demo. Use when asked to demo.\n---\n"
44
+ + numbered_lines(250),
45
+ )
46
+ write_file(root, ".claude/skills/demo/references/guide.md", "Guide body.\n")
47
+ write_file(
48
+ root,
49
+ ".claude/skills/quiet/SKILL.md",
50
+ "---\nname: quiet\ndescription: Hidden.\ndisable-model-invocation: true\n---\nBody.\n",
51
+ )
52
+ write_file(
53
+ root, "tests/fixture/.claude/skills/ghost/SKILL.md", "---\nname: ghost\n---\n"
54
+ )
55
+ write_file(root, "tool.py", '"""' + numbered_lines(20) + '"""\n')
56
+ subprocess.run(["git", "init", "-q"], cwd=root, check=True)
57
+ subprocess.run(["git", "add", "-A"], cwd=root, check=True)
58
+ return root
59
+
60
+
61
+ def run_audit(*all_arguments: str) -> subprocess.CompletedProcess[str]:
62
+ return subprocess.run(
63
+ [sys.executable, str(SCRIPT_PATH), *all_arguments],
64
+ capture_output=True,
65
+ text=True,
66
+ check=False,
67
+ )
68
+
69
+
70
+ def audit_rows(
71
+ checkout: Path, tmp_path: Path, *all_extra_arguments: str
72
+ ) -> dict[tuple[str, str], dict]:
73
+ rows_path = tmp_path / "rows.jsonl"
74
+ completed = run_audit(str(checkout), "--rows", str(rows_path), *all_extra_arguments)
75
+ assert completed.returncode == 0, completed.stderr
76
+ all_rows = [
77
+ json.loads(each_line) for each_line in rows_path.read_text().splitlines()
78
+ ]
79
+ return {(each_row["path"], each_row["kind"]): each_row for each_row in all_rows}
80
+
81
+
82
+ def test_root_instructions_load_at_session_start_and_carry_when_over_cap(
83
+ checkout: Path, tmp_path: Path
84
+ ) -> None:
85
+ row_by_key = audit_rows(checkout, tmp_path)
86
+ agents_row = row_by_key[("AGENTS.md", "instructions")]
87
+ assert agents_row["trigger"] == "session-start"
88
+ assert agents_row["loader"] == "@import from CLAUDE.md"
89
+ assert agents_row["lines"] == 30
90
+ assert agents_row["depth_mode"] == "carries"
91
+ assert row_by_key[("CLAUDE.md", "instructions")]["depth_mode"] == "lean"
92
+
93
+
94
+ def test_nested_empty_instructions_load_on_folder_enter_as_empty(
95
+ checkout: Path, tmp_path: Path
96
+ ) -> None:
97
+ nested_row = audit_rows(checkout, tmp_path)[("nested/CLAUDE.md", "instructions")]
98
+ assert nested_row["trigger"] == "folder-enter"
99
+ assert nested_row["depth_mode"] == "empty"
100
+
101
+
102
+ def test_import_loads_with_importer_and_its_links_follow_on_demand(
103
+ checkout: Path, tmp_path: Path
104
+ ) -> None:
105
+ row_by_key = audit_rows(checkout, tmp_path)
106
+ assert row_by_key[("docs/shared.md", "import")]["trigger"] == "session-start"
107
+ assert row_by_key[("docs/shared.md", "import")]["depth_mode"] == "points"
108
+ deep_row = row_by_key[("docs/deep.md", "linked-doc")]
109
+ deeper_row = row_by_key[("docs/deeper.md", "linked-doc")]
110
+ assert (deep_row["trigger"], deep_row["depth_mode"], deep_row["note"]) == (
111
+ "on-link",
112
+ "reference",
113
+ "link depth 1",
114
+ )
115
+ assert deeper_row["note"] == "link depth 2"
116
+
117
+
118
+ def test_rule_with_paths_loads_on_path_match_and_plain_rule_at_session_start(
119
+ checkout: Path, tmp_path: Path
120
+ ) -> None:
121
+ row_by_key = audit_rows(checkout, tmp_path)
122
+ assert row_by_key[(".claude/rules/api.md", "rule")]["trigger"] == "path-match"
123
+ assert (
124
+ row_by_key[(".claude/rules/general.md", "rule")]["trigger"] == "session-start"
125
+ )
126
+
127
+
128
+ def test_skill_yields_listing_body_and_reference_rows(
129
+ checkout: Path, tmp_path: Path
130
+ ) -> None:
131
+ row_by_key = audit_rows(checkout, tmp_path)
132
+ description_row = row_by_key[(".claude/skills/demo/SKILL.md", "skill-description")]
133
+ body_row = row_by_key[(".claude/skills/demo/SKILL.md", "skill-body")]
134
+ reference_row = row_by_key[
135
+ (".claude/skills/demo/references/guide.md", "skill-reference")
136
+ ]
137
+ assert (description_row["trigger"], description_row["depth_mode"]) == (
138
+ "session-start",
139
+ "points",
140
+ )
141
+ assert (body_row["trigger"], body_row["depth_mode"]) == ("skill-invoke", "carries")
142
+ assert reference_row["trigger"] == "on-link"
143
+
144
+
145
+ def test_disabled_skill_has_no_listing_and_fixture_skill_is_skipped(
146
+ checkout: Path, tmp_path: Path
147
+ ) -> None:
148
+ row_by_key = audit_rows(checkout, tmp_path)
149
+ assert (".claude/skills/quiet/SKILL.md", "skill-description") not in row_by_key
150
+ assert (".claude/skills/quiet/SKILL.md", "skill-body") in row_by_key
151
+ assert not any(
152
+ each_path.startswith("tests/") and each_kind.startswith("skill")
153
+ for each_path, each_kind in row_by_key
154
+ )
155
+
156
+
157
+ def test_module_docstring_loads_on_file_open_and_carries_over_cap(
158
+ checkout: Path, tmp_path: Path
159
+ ) -> None:
160
+ docstring_row = audit_rows(checkout, tmp_path)[("tool.py", "docstring")]
161
+ assert (
162
+ docstring_row["trigger"],
163
+ docstring_row["depth_mode"],
164
+ docstring_row["cap"],
165
+ ) == ("file-open", "carries", 15)
166
+
167
+
168
+ def test_saved_hook_text_counts_at_session_start(
169
+ checkout: Path, tmp_path: Path
170
+ ) -> None:
171
+ hook_text_file = tmp_path / "hook-banner.txt"
172
+ hook_text_file.write_text(numbered_lines(3), encoding="utf-8")
173
+ row_by_key = audit_rows(
174
+ checkout, tmp_path, "--session-start-text", str(hook_text_file)
175
+ )
176
+ hook_row = row_by_key[("hook-banner.txt", "hook-text")]
177
+ assert (hook_row["trigger"], hook_row["lines"], hook_row["depth_mode"]) == (
178
+ "session-start",
179
+ 3,
180
+ "lean",
181
+ )
182
+
183
+
184
+ def test_report_lists_each_cleanup_section(checkout: Path) -> None:
185
+ completed = run_audit(str(checkout))
186
+ assert completed.returncode == 0, completed.stderr
187
+ carries_section, _, after_carries = completed.stdout.partition(
188
+ "## Empty instruction stubs"
189
+ )
190
+ stubs_section, _, split_section = after_carries.partition("## Long skills to split")
191
+ assert "| AGENTS.md | instructions | session-start | 30 | 20 |" in carries_section
192
+ assert "| tool.py | docstring | file-open | 20 | 15 |" in carries_section
193
+ assert "SKILL.md" not in carries_section
194
+ assert stubs_section.strip() == "- nested/CLAUDE.md (instructions)"
195
+ assert (
196
+ "| .claude/skills/demo/SKILL.md | 254 | 200 |"
197
+ in split_section.split("\n", 3)[3]
198
+ )
199
+
200
+
201
+ def test_command_option_is_gone_and_text_file_must_exist(
202
+ checkout: Path, tmp_path: Path
203
+ ) -> None:
204
+ rejected = run_audit(str(checkout), "--session-start-command", "echo hi")
205
+ assert rejected.returncode == 2
206
+ assert "unrecognized arguments" in rejected.stderr
207
+ missing = run_audit(
208
+ str(checkout), "--session-start-text", str(tmp_path / "absent.txt")
209
+ )
210
+ assert missing.returncode == 2
211
+ assert "file not found" in missing.stderr
212
+
213
+
214
+ def test_folder_outside_git_is_refused(tmp_path: Path) -> None:
215
+ completed = run_audit(str(tmp_path))
216
+ assert completed.returncode == 2
217
+ assert "not a git checkout" in completed.stderr
@@ -18,6 +18,7 @@ export const EVER_SHIPPED_SKILL_NAMES = new Set([
18
18
  'condensing-instructions',
19
19
  'codex-review',
20
20
  'codex-cleanse',
21
+ 'context-audit',
21
22
  'context7-mcp',
22
23
  'copilot-finding-triage',
23
24
  'copilot-review',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "8.62.1",
3
+ "version": "8.63.0",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {