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.
- package/.agents/skills/eli5/SKILL.md +15 -1
- package/.agents/skills/eli5/reference/task-seeds.md +3 -1
- package/bin/merge_managed_permissions.test.mjs +87 -0
- package/package.json +1 -1
- package/scripts/dev_env_scripts_constants/digest_check_constants.py +76 -0
- package/scripts/digest_check.py +227 -0
- package/scripts/policy_lint/adapter_callers.py +46 -2
- package/scripts/test_digest_check.py +147 -0
- package/scripts/tests/test_policy_lint_rules_callers.py +31 -2
|
@@ -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. **
|
|
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.
|
|
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
|
@@ -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
|
|
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
|
|
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 & 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(
|
|
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
|
-
|
|
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
|