claude-dev-env 8.17.0 → 8.18.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.
@@ -71,6 +71,8 @@ keep ownership of their native artifact rules while ELI5 supplies the global
71
71
  - Preserve an explicit user-supplied output path and an existing artifact path.
72
72
  - Keep one artifact path across all updates in the active task or conversation.
73
73
  - Keep every page self-contained and browser-ready.
74
+ - Give every source change a card in a digest. The reader reads less and still
75
+ knows every change, so a dropped change is a defect.
74
76
  - Preserve native wrapper, payload, evidence, and file-format contracts owned by
75
77
  the named capability skill.
76
78
  - Use human review for accuracy, terminology, safety, confidentiality, meaning,
@@ -92,7 +94,19 @@ Process steps in order.
92
94
  `~/.claude/rules/asd-ste100-language.md` to every sentence. Frame the topic
93
95
  for a beginner, use a large useful visual, keep the text minimal, and update
94
96
  the same artifact in place.
95
- 3. **Judgment — share the updated artifact with the user.**
97
+ 3. **Deterministic — run the digest check on a summary of many changes.** A
98
+ summary of a set of changes, such as the pull requests merged in a day, is a
99
+ digest. Give each change one card marked `data-digest-card="<id>"` that holds
100
+ its picture and its one-line change. Pass every source id to the check:
101
+
102
+ ```bash
103
+ python "$HOME/.claude/scripts/digest_check.py" <page.html> --expect <id> <id>
104
+ ```
105
+
106
+ The check reports each source id with no card, each card over 25 words, each
107
+ card without a picture, and more than 40 words outside the cards. Fix every
108
+ finding and run it again until it prints `CLEAN`.
109
+ 4. **Judgment — share the updated artifact with the user.**
96
110
 
97
111
  ### Examples
98
112
 
@@ -7,4 +7,6 @@ the start of the ELI5 process.
7
7
  2. Create the artifact when no active path exists and keep it self-contained.
8
8
  3. Apply the ASD-STE100 rule to page and response sentences.
9
9
  4. Add the concise beginner explanation and useful large visual in place.
10
- 5. Share the updated artifact with the user.
10
+ 5. Run `digest_check.py` on a digest with every source id until it prints
11
+ `CLEAN`.
12
+ 6. Share the updated artifact with the user.
@@ -0,0 +1,87 @@
1
+ import { test } from 'node:test';
2
+ import { strict as assert } from 'node:assert';
3
+ import {
4
+ emptyManagedPermissions,
5
+ managedPermissionsFromPackageSettings,
6
+ countManagedPermissions,
7
+ retiredManagedPermissions,
8
+ mergeManagedPermissionsIntoSettings,
9
+ pruneManagedPermissionsFromSettings,
10
+ } from './merge_managed_permissions.mjs';
11
+
12
+ test('emptyManagedPermissions returns separate empty records', () => {
13
+ const first = emptyManagedPermissions();
14
+ const second = emptyManagedPermissions();
15
+ assert.deepEqual(first, { allow: [], deny: [] });
16
+ first.allow.push('Read(./managed/**)');
17
+ assert.deepEqual(second, { allow: [], deny: [] });
18
+ });
19
+
20
+ test('managedPermissionsFromPackageSettings keeps nonblank string rules', () => {
21
+ const settings = {
22
+ permissions: {
23
+ allow: ['Read(./managed/**)', '', ' ', 4],
24
+ deny: ['Write(./managed/**)', null],
25
+ ask: ['Write(./user/**)'],
26
+ },
27
+ };
28
+ assert.deepEqual(managedPermissionsFromPackageSettings(settings), {
29
+ allow: ['Read(./managed/**)'],
30
+ deny: ['Write(./managed/**)'],
31
+ });
32
+ assert.deepEqual(managedPermissionsFromPackageSettings(null), {
33
+ allow: [],
34
+ deny: [],
35
+ });
36
+ });
37
+
38
+ test('countManagedPermissions counts both owned lists', () => {
39
+ assert.equal(countManagedPermissions({ allow: ['read-a', 'read-b'], deny: ['write-a'] }), 3);
40
+ });
41
+
42
+ test('retiredManagedPermissions keeps only rules absent from the same current list', () => {
43
+ const prior = {
44
+ allow: ['Read(./old/**)', 'Read(./keep/**)'],
45
+ deny: ['Write(./keep/**)', 'Write(./old/**)'],
46
+ };
47
+ const current = {
48
+ allow: ['Read(./keep/**)'],
49
+ deny: ['Write(./keep/**)'],
50
+ };
51
+ assert.deepEqual(retiredManagedPermissions(prior, current), {
52
+ allow: ['Read(./old/**)'],
53
+ deny: ['Write(./old/**)'],
54
+ });
55
+ assert.equal(prior.allow.length, 2);
56
+ assert.equal(prior.deny.length, 2);
57
+ });
58
+
59
+ test('mergeManagedPermissionsIntoSettings adds owned rules once and keeps user rules', () => {
60
+ const settings = { permissions: { allow: ['Read(./user/**)'], ask: ['Write(./user/**)'] } };
61
+ const managed = { allow: ['Read(./managed/**)'], deny: ['Write(./managed/**)'] };
62
+ const first = mergeManagedPermissionsIntoSettings(settings, managed);
63
+ const second = mergeManagedPermissionsIntoSettings(settings, managed);
64
+ assert.deepEqual([first.addedCount, first.alreadyPresentCount], [2, 0]);
65
+ assert.deepEqual([second.addedCount, second.alreadyPresentCount], [0, 2]);
66
+ assert.deepEqual(settings.permissions, {
67
+ allow: ['Read(./user/**)', 'Read(./managed/**)'],
68
+ ask: ['Write(./user/**)'],
69
+ deny: ['Write(./managed/**)'],
70
+ });
71
+ });
72
+
73
+ test('pruneManagedPermissionsFromSettings removes owned rules and keeps user rules', () => {
74
+ const settings = {
75
+ permissions: {
76
+ allow: ['Read(./user/**)', 'Read(./managed/**)'],
77
+ ask: ['Write(./user/**)'],
78
+ deny: ['Write(./managed/**)'],
79
+ },
80
+ };
81
+ const managed = { allow: ['Read(./managed/**)'], deny: ['Write(./managed/**)'] };
82
+ assert.deepEqual(pruneManagedPermissionsFromSettings(settings, managed), { removedCount: 2 });
83
+ assert.deepEqual(settings.permissions, {
84
+ allow: ['Read(./user/**)'],
85
+ ask: ['Write(./user/**)'],
86
+ });
87
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "8.17.0",
3
+ "version": "8.18.0",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,76 @@
1
+ """Constants for the digest page check.
2
+
3
+ A digest page summarizes a set of changes for a reader who reads little and
4
+ needs to know every change. These values set the word budgets, the attribute
5
+ that marks one card, the elements that count as a picture, and the finding
6
+ messages.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+
13
+ UTF8_ENCODING: str = "utf-8"
14
+
15
+ CARD_WORD_BUDGET: int = 25
16
+
17
+ LEAD_WORD_BUDGET: int = 40
18
+
19
+ ALL_PICTURE_TAGS: frozenset[str] = frozenset({"img", "svg", "canvas", "picture"})
20
+
21
+ ALL_HIDDEN_TEXT_TAGS: frozenset[str] = frozenset(
22
+ {"head", "script", "style", "template", "noscript"}
23
+ )
24
+
25
+ ALL_RAW_TEXT_TAGS: frozenset[str] = frozenset({"script", "style"})
26
+
27
+ ALL_VOID_TAGS: frozenset[str] = frozenset(
28
+ {
29
+ "area",
30
+ "base",
31
+ "br",
32
+ "col",
33
+ "embed",
34
+ "hr",
35
+ "img",
36
+ "input",
37
+ "link",
38
+ "meta",
39
+ "source",
40
+ "track",
41
+ "wbr",
42
+ }
43
+ )
44
+
45
+ MARKUP_PATTERN: re.Pattern[str] = re.compile(
46
+ r"<!--.*?-->|<![^>]*>"
47
+ r"|<(?P<closing>/?)(?P<tag_name>[a-zA-Z][\w:-]*)"
48
+ r"(?P<attributes>(?:[^>\"']|\"[^\"]*\"|'[^']*')*?)(?P<self_closing>/?)>",
49
+ re.DOTALL,
50
+ )
51
+
52
+ CARD_ATTRIBUTE_PATTERN: re.Pattern[str] = re.compile(
53
+ r"\bdata-digest-card\s*=\s*(?:\"(?P<double>[^\"]*)\"|'(?P<single>[^']*)')"
54
+ )
55
+
56
+ WORD_PATTERN: re.Pattern[str] = re.compile(r"[^\W_][\w'’-]*")
57
+
58
+ MISSING_CARD_FINDING: str = "missing card {card_id}: the page leaves it out"
59
+
60
+ DUPLICATE_CARD_FINDING: str = "card {card_id} appears {count} times"
61
+
62
+ CARD_OVER_BUDGET_FINDING: str = (
63
+ "card {card_id} carries {word_count} words; the budget is {budget}"
64
+ )
65
+
66
+ CARD_WITHOUT_PICTURE_FINDING: str = "card {card_id} has no picture"
67
+
68
+ LEAD_OVER_BUDGET_FINDING: str = (
69
+ "text outside the cards carries {word_count} words; the budget is {budget}"
70
+ )
71
+
72
+ CLEAN_VERDICT: str = "CLEAN: {card_count} cards, every one present"
73
+
74
+ EXIT_CLEAN: int = 0
75
+
76
+ EXIT_FINDINGS: int = 1
@@ -0,0 +1,227 @@
1
+ #!/usr/bin/env python3
2
+ """Check that a digest page lets its reader read less and still know all.
3
+
4
+ A digest page summarizes a set of changes, such as the pull requests merged in
5
+ a day. Each change gets one card, marked with ``data-digest-card="<id>"``,
6
+ that holds its picture and its one-line change.
7
+
8
+ ::
9
+
10
+ python digest_check.py summary.html --expect 4701 4702 4715
11
+ ok: CLEAN: 3 cards, every one present
12
+ flag: missing card 4715: the page leaves it out
13
+ flag: card 4701 carries 31 words; the budget is 25
14
+ flag: card 4702 has no picture
15
+
16
+ The check also reports lead text over its own budget and a card marked twice.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import argparse
22
+ import html
23
+ import re
24
+ import sys
25
+ from collections import Counter
26
+ from dataclasses import dataclass, field
27
+ from pathlib import Path
28
+
29
+ from dev_env_scripts_constants.digest_check_constants import (
30
+ ALL_HIDDEN_TEXT_TAGS,
31
+ ALL_PICTURE_TAGS,
32
+ ALL_RAW_TEXT_TAGS,
33
+ ALL_VOID_TAGS,
34
+ CARD_ATTRIBUTE_PATTERN,
35
+ CARD_OVER_BUDGET_FINDING,
36
+ CARD_WITHOUT_PICTURE_FINDING,
37
+ CARD_WORD_BUDGET,
38
+ CLEAN_VERDICT,
39
+ DUPLICATE_CARD_FINDING,
40
+ EXIT_CLEAN,
41
+ EXIT_FINDINGS,
42
+ LEAD_OVER_BUDGET_FINDING,
43
+ LEAD_WORD_BUDGET,
44
+ MARKUP_PATTERN,
45
+ MISSING_CARD_FINDING,
46
+ UTF8_ENCODING,
47
+ WORD_PATTERN,
48
+ )
49
+
50
+
51
+ @dataclass
52
+ class DigestCard:
53
+ """One marked card on a digest page."""
54
+
55
+ card_id: str
56
+ word_count: int = 0
57
+ has_picture: bool = False
58
+
59
+
60
+ @dataclass
61
+ class DigestPage:
62
+ """Every marked card on a page, and the words outside them."""
63
+
64
+ all_cards: list[DigestCard] = field(default_factory=list)
65
+ lead_word_count: int = 0
66
+
67
+
68
+ @dataclass
69
+ class _OpenElement:
70
+ tag_name: str
71
+ card: DigestCard | None
72
+ is_hidden: bool
73
+
74
+
75
+ def _innermost_card(all_open: list[_OpenElement]) -> DigestCard | None:
76
+ for each_open in reversed(all_open):
77
+ if each_open.card is not None:
78
+ return each_open.card
79
+ return None
80
+
81
+
82
+ def _count_words(page: DigestPage, all_open: list[_OpenElement], text: str) -> None:
83
+ if any(each_open.is_hidden for each_open in all_open):
84
+ return
85
+ word_count = len(WORD_PATTERN.findall(html.unescape(text)))
86
+ enclosing_card = _innermost_card(all_open)
87
+ if enclosing_card is None:
88
+ page.lead_word_count += word_count
89
+ else:
90
+ enclosing_card.word_count += word_count
91
+
92
+
93
+ def _close_element(all_open: list[_OpenElement], tag_name: str) -> None:
94
+ for each_index in range(len(all_open) - 1, -1, -1):
95
+ if all_open[each_index].tag_name == tag_name:
96
+ del all_open[each_index:]
97
+ return
98
+
99
+
100
+ def _open_element(
101
+ page: DigestPage, all_open: list[_OpenElement], tag_match: re.Match[str]
102
+ ) -> None:
103
+ tag_name = tag_match.group("tag_name").lower()
104
+ if tag_name in ALL_PICTURE_TAGS:
105
+ enclosing_card = _innermost_card(all_open)
106
+ if enclosing_card is not None:
107
+ enclosing_card.has_picture = True
108
+ if tag_name in ALL_VOID_TAGS or tag_match.group("self_closing"):
109
+ return
110
+ new_card = None
111
+ card_match = CARD_ATTRIBUTE_PATTERN.search(tag_match.group("attributes"))
112
+ if card_match is not None:
113
+ card_id = card_match.group("double") or card_match.group("single") or ""
114
+ new_card = DigestCard(card_id=html.unescape(card_id).strip())
115
+ page.all_cards.append(new_card)
116
+ all_open.append(_OpenElement(tag_name, new_card, tag_name in ALL_HIDDEN_TEXT_TAGS))
117
+
118
+
119
+ def read_digest_page(page_html: str) -> DigestPage:
120
+ """Return the marked cards and lead word count of one page.
121
+
122
+ Args:
123
+ page_html: Full HTML source of the page.
124
+
125
+ Returns:
126
+ The parsed page, with words counted toward their innermost card.
127
+ """
128
+ page = DigestPage()
129
+ all_open: list[_OpenElement] = []
130
+ position = 0
131
+ while position < len(page_html):
132
+ tag_match = MARKUP_PATTERN.search(page_html, position)
133
+ text_end = len(page_html) if tag_match is None else tag_match.start()
134
+ _count_words(page, all_open, page_html[position:text_end])
135
+ if tag_match is None:
136
+ break
137
+ position = tag_match.end()
138
+ if tag_match.group("tag_name") is None:
139
+ continue
140
+ tag_name = tag_match.group("tag_name").lower()
141
+ if tag_match.group("closing"):
142
+ _close_element(all_open, tag_name)
143
+ continue
144
+ _open_element(page, all_open, tag_match)
145
+ if tag_name in ALL_RAW_TEXT_TAGS and not tag_match.group("self_closing"):
146
+ raw_end = page_html.lower().find(f"</{tag_name}", position)
147
+ position = len(page_html) if raw_end == -1 else raw_end
148
+ return page
149
+
150
+
151
+ def _coverage_findings(page: DigestPage, all_expected_ids: list[str]) -> list[str]:
152
+ card_counts = Counter(each_card.card_id for each_card in page.all_cards)
153
+ all_findings = [
154
+ MISSING_CARD_FINDING.format(card_id=each_expected_id)
155
+ for each_expected_id in all_expected_ids
156
+ if card_counts[each_expected_id] == 0
157
+ ]
158
+ all_findings.extend(
159
+ DUPLICATE_CARD_FINDING.format(card_id=each_card_id, count=each_count)
160
+ for each_card_id, each_count in card_counts.items()
161
+ if each_count > 1
162
+ )
163
+ return all_findings
164
+
165
+
166
+ def _budget_findings(page: DigestPage) -> list[str]:
167
+ all_findings: list[str] = []
168
+ for each_card in page.all_cards:
169
+ if each_card.word_count > CARD_WORD_BUDGET:
170
+ all_findings.append(
171
+ CARD_OVER_BUDGET_FINDING.format(
172
+ card_id=each_card.card_id,
173
+ word_count=each_card.word_count,
174
+ budget=CARD_WORD_BUDGET,
175
+ )
176
+ )
177
+ if not each_card.has_picture:
178
+ all_findings.append(
179
+ CARD_WITHOUT_PICTURE_FINDING.format(card_id=each_card.card_id)
180
+ )
181
+ if page.lead_word_count > LEAD_WORD_BUDGET:
182
+ all_findings.append(
183
+ LEAD_OVER_BUDGET_FINDING.format(
184
+ word_count=page.lead_word_count, budget=LEAD_WORD_BUDGET
185
+ )
186
+ )
187
+ return all_findings
188
+
189
+
190
+ def digest_findings(page: DigestPage, all_expected_ids: list[str]) -> list[str]:
191
+ """Return every way the page makes its reader read more or know less.
192
+
193
+ Args:
194
+ page: The parsed page.
195
+ all_expected_ids: Every source id the page must cover with a card.
196
+
197
+ Returns:
198
+ Coverage findings first, then budget findings; empty when the page passes.
199
+ """
200
+ return _coverage_findings(page, all_expected_ids) + _budget_findings(page)
201
+
202
+
203
+ def main(all_arguments: list[str]) -> int:
204
+ """Check one page against its source ids and print the verdict.
205
+
206
+ Args:
207
+ all_arguments: Command-line arguments after the program name.
208
+
209
+ Returns:
210
+ Zero when the page passes, one when any finding reports.
211
+ """
212
+ argument_parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
213
+ argument_parser.add_argument("page", type=Path)
214
+ argument_parser.add_argument("--expect", nargs="+", required=True)
215
+ parsed = argument_parser.parse_args(all_arguments)
216
+ page = read_digest_page(parsed.page.read_text(encoding=UTF8_ENCODING))
217
+ all_findings = digest_findings(page, parsed.expect)
218
+ for each_finding in all_findings:
219
+ print(each_finding)
220
+ if all_findings:
221
+ return EXIT_FINDINGS
222
+ print(CLEAN_VERDICT.format(card_count=len(parsed.expect)))
223
+ return EXIT_CLEAN
224
+
225
+
226
+ if __name__ == "__main__":
227
+ sys.exit(main(sys.argv[1:]))
@@ -83,6 +83,49 @@ def _has_caller(document: Document, all_caller_texts: tuple[str, ...]) -> bool:
83
83
  return any(name_pattern.search(each_text) for each_text in all_caller_texts)
84
84
 
85
85
 
86
+ def _reached_paths(
87
+ all_new_documents: tuple[Document, ...],
88
+ all_caller_texts: tuple[str, ...],
89
+ all_called: set[PurePosixPath],
90
+ ) -> set[PurePosixPath]:
91
+ return {
92
+ each_document.path
93
+ for each_document in all_new_documents
94
+ if each_document.path not in all_called
95
+ and _has_caller(each_document, all_caller_texts)
96
+ }
97
+
98
+
99
+ def _called_paths(
100
+ all_new_documents: tuple[Document, ...], all_caller_texts: tuple[str, ...]
101
+ ) -> frozenset[PurePosixPath]:
102
+ """Return each new file a caller reaches, directly or through a called new file.
103
+
104
+ ::
105
+
106
+ workflow step -> new_check.py -> new_check_constants.py both called
107
+ nothing -> lonely.py -> helper.py both uncalled
108
+
109
+ Args:
110
+ all_new_documents: Every new code file in the change.
111
+ all_caller_texts: Text of every existing file that may name one.
112
+
113
+ Returns:
114
+ Paths of the new files some caller chain reaches.
115
+ """
116
+ all_called: set[PurePosixPath] = set()
117
+ all_reached = _reached_paths(all_new_documents, all_caller_texts, all_called)
118
+ while all_reached:
119
+ all_called |= all_reached
120
+ all_reached_texts = tuple(
121
+ each_document.text
122
+ for each_document in all_new_documents
123
+ if each_document.path in all_reached
124
+ )
125
+ all_reached = _reached_paths(all_new_documents, all_reached_texts, all_called)
126
+ return frozenset(all_called)
127
+
128
+
86
129
  def _uncalled_diagnostic(document: Document) -> Diagnostic:
87
130
  return Diagnostic(
88
131
  constants.UNCALLED_NEW_FILE_RULE_ID,
@@ -102,7 +145,7 @@ def uncalled_new_file_diagnostics(
102
145
 
103
146
  Returns:
104
147
  One diagnostic for each added script, hook or skill script whose name
105
- appears only in tests and inventory files.
148
+ appears only in tests, inventory files, and other uncalled new files.
106
149
  """
107
150
  if document_set.selection not in {SelectionKind.STAGED, SelectionKind.BASE}:
108
151
  return ()
@@ -117,8 +160,9 @@ def uncalled_new_file_diagnostics(
117
160
  document_set.repository_root,
118
161
  frozenset(each_document.path for each_document in all_new_documents),
119
162
  )
163
+ all_called = _called_paths(all_new_documents, all_caller_texts)
120
164
  return tuple(
121
165
  _uncalled_diagnostic(each_document)
122
166
  for each_document in all_new_documents
123
- if not _has_caller(each_document, all_caller_texts)
167
+ if each_document.path not in all_called
124
168
  )
@@ -0,0 +1,147 @@
1
+ """Tests for the digest page check."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ import pytest
9
+
10
+ _SCRIPTS_DIRECTORY = Path(__file__).resolve().parent
11
+ if str(_SCRIPTS_DIRECTORY) not in sys.path:
12
+ sys.path.insert(0, str(_SCRIPTS_DIRECTORY))
13
+
14
+ import digest_check
15
+ from dev_env_scripts_constants.digest_check_constants import (
16
+ CARD_WORD_BUDGET,
17
+ LEAD_WORD_BUDGET,
18
+ )
19
+
20
+ PICTURE = '<svg viewBox="0 0 10 10"><rect width="10" height="10"/></svg>'
21
+
22
+
23
+ def _card(card_id: str, words: str, picture: str = PICTURE) -> str:
24
+ return f'<section data-digest-card="{card_id}">{picture}<p>{words}</p></section>'
25
+
26
+
27
+ def _page(*all_parts: str, lead: str = "Merged today") -> str:
28
+ body = "".join(all_parts)
29
+ return (
30
+ "<!doctype html><html><head><title>Digest</title>"
31
+ "<style>p{margin:0}</style></head>"
32
+ f"<body><h1>{lead}</h1>{body}"
33
+ "<script>const words = 'one two three four five';</script></body></html>"
34
+ )
35
+
36
+
37
+ def _findings(page_html: str, all_expected_ids: list[str]) -> list[str]:
38
+ page = digest_check.read_digest_page(page_html)
39
+ return digest_check.digest_findings(page, all_expected_ids)
40
+
41
+
42
+ def test_page_covering_every_card_briefly_with_pictures_passes() -> None:
43
+ page_html = _page(
44
+ _card("4701", "Search now keeps your filters."),
45
+ _card("4702", "Exports finish twice as fast."),
46
+ )
47
+ assert _findings(page_html, ["4701", "4702"]) == []
48
+
49
+
50
+ def test_page_leaving_out_an_expected_card_reports_it() -> None:
51
+ page_html = _page(_card("4701", "Search now keeps your filters."))
52
+ assert _findings(page_html, ["4701", "4715"]) == [
53
+ "missing card 4715: the page leaves it out"
54
+ ]
55
+
56
+
57
+ def test_card_over_the_word_budget_reports_its_count() -> None:
58
+ long_words = " ".join(["word"] * (CARD_WORD_BUDGET + 1))
59
+ page_html = _page(_card("4701", long_words))
60
+ assert _findings(page_html, ["4701"]) == [
61
+ f"card 4701 carries {CARD_WORD_BUDGET + 1} words; "
62
+ f"the budget is {CARD_WORD_BUDGET}"
63
+ ]
64
+
65
+
66
+ def test_card_at_the_word_budget_passes() -> None:
67
+ page_html = _page(_card("4701", " ".join(["word"] * CARD_WORD_BUDGET)))
68
+ assert _findings(page_html, ["4701"]) == []
69
+
70
+
71
+ def test_card_without_a_picture_reports_it() -> None:
72
+ page_html = _page(_card("4701", "Search now keeps your filters.", picture=""))
73
+ assert _findings(page_html, ["4701"]) == ["card 4701 has no picture"]
74
+
75
+
76
+ def test_self_closing_image_counts_as_a_picture() -> None:
77
+ page_html = _page(
78
+ _card("4701", "Search now keeps your filters.", picture='<img src="a.png"/>')
79
+ )
80
+ assert _findings(page_html, ["4701"]) == []
81
+
82
+
83
+ def test_lead_text_over_its_budget_reports_the_count() -> None:
84
+ long_lead = " ".join(["word"] * (LEAD_WORD_BUDGET + 1))
85
+ page_html = _page(_card("4701", "Search now keeps your filters."), lead=long_lead)
86
+ assert _findings(page_html, ["4701"]) == [
87
+ f"text outside the cards carries {LEAD_WORD_BUDGET + 1} words; "
88
+ f"the budget is {LEAD_WORD_BUDGET}"
89
+ ]
90
+
91
+
92
+ def test_head_script_and_style_text_stay_out_of_the_count() -> None:
93
+ page = digest_check.read_digest_page(_page(lead="Merged today"))
94
+ assert page.lead_word_count == 2
95
+
96
+
97
+ def test_picture_label_text_counts_toward_its_card() -> None:
98
+ labelled_picture = "<svg><text>before after</text></svg>"
99
+ page = digest_check.read_digest_page(
100
+ _page(_card("4701", "one two", picture=labelled_picture))
101
+ )
102
+ assert [each_card.word_count for each_card in page.all_cards] == [4]
103
+
104
+
105
+ def test_card_marked_twice_reports_the_duplicate() -> None:
106
+ page_html = _page(
107
+ _card("4701", "Search now keeps your filters."),
108
+ _card("4701", "Search now keeps your filters."),
109
+ )
110
+ assert _findings(page_html, ["4701"]) == ["card 4701 appears 2 times"]
111
+
112
+
113
+ def test_main_exits_one_and_prints_findings_for_a_missing_card(
114
+ tmp_path: Path, capsys: pytest.CaptureFixture[str]
115
+ ) -> None:
116
+ page_path = tmp_path / "digest.html"
117
+ page_path.write_text(_page(_card("4701", "Search keeps filters.")), encoding="utf-8")
118
+ exit_code = digest_check.main([str(page_path), "--expect", "4701", "4702"])
119
+ assert exit_code == 1
120
+ assert capsys.readouterr().out == "missing card 4702: the page leaves it out\n"
121
+
122
+
123
+ def test_main_exits_zero_and_names_the_count_for_a_clean_page(
124
+ tmp_path: Path, capsys: pytest.CaptureFixture[str]
125
+ ) -> None:
126
+ page_path = tmp_path / "digest.html"
127
+ page_path.write_text(_page(_card("4701", "Search keeps filters.")), encoding="utf-8")
128
+ exit_code = digest_check.main([str(page_path), "--expect", "4701"])
129
+ assert exit_code == 0
130
+ assert capsys.readouterr().out == "CLEAN: 1 cards, every one present\n"
131
+
132
+
133
+ def test_markup_inside_a_script_leaves_the_cards_intact() -> None:
134
+ page_html = _page(
135
+ "<script>if (a<b) { x = '<div data-digest-card=\"9\">'; }</script>",
136
+ _card("4701", "Search keeps filters."),
137
+ )
138
+ assert _findings(page_html, ["4701"]) == []
139
+
140
+
141
+ def test_single_quoted_card_marker_and_entities_parse() -> None:
142
+ page = digest_check.read_digest_page(
143
+ f"<div data-digest-card='4701'>{PICTURE}<p>Fish &amp; chips</p></div>"
144
+ )
145
+ assert [(each_card.card_id, each_card.word_count) for each_card in page.all_cards] == [
146
+ ("4701", 2)
147
+ ]
@@ -23,10 +23,12 @@ def _write(repository_root: Path, relative_path: str, text: str) -> None:
23
23
  target.write_text(text, "utf-8")
24
24
 
25
25
 
26
- def _added(relative_path: str, prior_text: str | None = None) -> Document:
26
+ def _added(
27
+ relative_path: str, prior_text: str | None = None, text: str = "print('hi')\n"
28
+ ) -> Document:
27
29
  return Document(
28
30
  PurePosixPath(relative_path),
29
- "print('hi')\n",
31
+ text,
30
32
  prior_text,
31
33
  None,
32
34
  ContentOrigin.REVISION_DIFF,
@@ -138,3 +140,30 @@ def test_should_report_new_ci_and_tools_helpers_only_tests_name(
138
140
  all_messages = _messages(tmp_path, (_added(ci_helper), _added(tools_helper)))
139
141
 
140
142
  assert len(all_messages) == 2
143
+
144
+
145
+ def test_should_pass_a_new_module_that_a_called_new_script_imports(
146
+ tmp_path: Path,
147
+ ) -> None:
148
+ constants_path = f"{_PACKAGE}/scripts/constants/lonely_watcher_constants.py"
149
+ script_text = "from constants.lonely_watcher_constants import LIMIT\n"
150
+ _write(tmp_path, _NEW_SCRIPT, script_text)
151
+ _write(tmp_path, constants_path, "LIMIT = 3\n")
152
+ _write(tmp_path, ".github/workflows/ci.yml", f"run: python {_NEW_SCRIPT}\n")
153
+
154
+ all_documents = (_added(_NEW_SCRIPT, text=script_text), _added(constants_path))
155
+
156
+ assert _messages(tmp_path, all_documents) == ()
157
+
158
+
159
+ def test_should_report_a_new_module_only_an_uncalled_new_script_imports(
160
+ tmp_path: Path,
161
+ ) -> None:
162
+ constants_path = f"{_PACKAGE}/scripts/constants/lonely_watcher_constants.py"
163
+ script_text = "from constants.lonely_watcher_constants import LIMIT\n"
164
+ _write(tmp_path, _NEW_SCRIPT, script_text)
165
+ _write(tmp_path, constants_path, "LIMIT = 3\n")
166
+
167
+ all_documents = (_added(_NEW_SCRIPT, text=script_text), _added(constants_path))
168
+
169
+ assert len(_messages(tmp_path, all_documents)) == 2