claude-dev-env 8.62.0 → 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.
- package/.agents/skills/context-audit/SKILL.md +48 -0
- package/.agents/skills/context-audit/references/depth-modes.md +67 -0
- package/.agents/skills/context-audit/scripts/context_audit.py +109 -0
- package/.agents/skills/context-audit/scripts/context_audit_constants/__init__.py +1 -0
- package/.agents/skills/context-audit/scripts/context_audit_constants/config/__init__.py +1 -0
- package/.agents/skills/context-audit/scripts/context_audit_constants/config/constants.py +179 -0
- package/.agents/skills/context-audit/scripts/context_inventory.py +362 -0
- package/.agents/skills/context-audit/scripts/context_report.py +176 -0
- package/.agents/skills/context-audit/scripts/context_sources.py +252 -0
- package/.agents/skills/context-audit/scripts/test_context_audit.py +217 -0
- package/bin/ever-shipped-skills.mjs +1 -0
- package/package.json +1 -1
|
@@ -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
|