@dzhechkov/skills-idea2prd 0.1.5 → 0.1.7
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/.dz-manifest.json +6 -6
- package/README.md +9 -0
- package/package.json +1 -1
- package/sbom.json +5 -5
- package/templates/.claude/skills/goap-research-ed25519/SKILL.md +5 -2
- package/templates/.claude/skills/goap-research-ed25519/scripts/learning_bridge.py +77 -13
- package/templates/.claude/skills/goap-research-ed25519/scripts/test_evidence_provenance.py +86 -1
package/.dz-manifest.json
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
},
|
|
14
14
|
{
|
|
15
15
|
"path": "README.md",
|
|
16
|
-
"sha256": "
|
|
16
|
+
"sha256": "646b19adeddf1d63c0f07eabef2f4dcce49d65a259563dfc6ab6b4248d262191"
|
|
17
17
|
},
|
|
18
18
|
{
|
|
19
19
|
"path": "bin/cli.js",
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
},
|
|
22
22
|
{
|
|
23
23
|
"path": "package.json",
|
|
24
|
-
"sha256": "
|
|
24
|
+
"sha256": "4bfe6564c3745ab76783640b91973036f6687155111052f3e3e34a3eb4dcd874"
|
|
25
25
|
},
|
|
26
26
|
{
|
|
27
27
|
"path": "sources.json",
|
|
@@ -73,7 +73,7 @@
|
|
|
73
73
|
},
|
|
74
74
|
{
|
|
75
75
|
"path": "templates/.claude/skills/goap-research-ed25519/SKILL.md",
|
|
76
|
-
"sha256": "
|
|
76
|
+
"sha256": "95459a1566b2f88b6fb0c1d2f096f7631d698b95fa156afa26444a7c06e76451"
|
|
77
77
|
},
|
|
78
78
|
{
|
|
79
79
|
"path": "templates/.claude/skills/goap-research-ed25519/references/ed25519-verification.md",
|
|
@@ -109,7 +109,7 @@
|
|
|
109
109
|
},
|
|
110
110
|
{
|
|
111
111
|
"path": "templates/.claude/skills/goap-research-ed25519/scripts/learning_bridge.py",
|
|
112
|
-
"sha256": "
|
|
112
|
+
"sha256": "f6ec73a2ef239b8195a321b5f20b6ab390d9a7004b42226b2a861181cf9e2d2f"
|
|
113
113
|
},
|
|
114
114
|
{
|
|
115
115
|
"path": "templates/.claude/skills/goap-research-ed25519/scripts/source_tiers.py",
|
|
@@ -121,7 +121,7 @@
|
|
|
121
121
|
},
|
|
122
122
|
{
|
|
123
123
|
"path": "templates/.claude/skills/goap-research-ed25519/scripts/test_evidence_provenance.py",
|
|
124
|
-
"sha256": "
|
|
124
|
+
"sha256": "86216fb6a40fcf270eada41c4a534600d2bcb2799052cbacfd64b459229b5bfe"
|
|
125
125
|
},
|
|
126
126
|
{
|
|
127
127
|
"path": "templates/.claude/skills/idea2prd-manual/SKILL.md",
|
|
@@ -173,5 +173,5 @@
|
|
|
173
173
|
}
|
|
174
174
|
]
|
|
175
175
|
},
|
|
176
|
-
"signature": "
|
|
176
|
+
"signature": "Rc5O0UZz+GsMlKiMvr/E8f4SUUtkaHJnL30dpGPXNIxY7Wo5SEYaTnB+P0GRSi1AYJjihTlsAo7d34RrSfQpAQ=="
|
|
177
177
|
}
|
package/README.md
CHANGED
|
@@ -2,6 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
Idea2PRD Manual — composite skill for [Claude Code](https://claude.com/claude-code) that takes a **problem or an idea** all the way to **Vibe-Coding-ready documentation**, with a user checkpoint between every phase.
|
|
4
4
|
|
|
5
|
+
|
|
6
|
+
> **`goap-research-ed25519` — self-learning (optional, since this release).** When
|
|
7
|
+
> [`@dzhechkov/harness-cli`](https://www.npmjs.com/package/@dzhechkov/harness-cli) is on PATH, the
|
|
8
|
+
> bundled research skill recalls prior METHOD lessons at the start of an investigation and records new
|
|
9
|
+
> ones at four named moments. Without it the skill behaves exactly as before and says so once — it is
|
|
10
|
+
> detected, never required. A guard refuses to record anything shaped like personal data: a method
|
|
11
|
+
> lesson is written in lower case, carries no digits, and holds no identifier. See
|
|
12
|
+
> `skills/goap-research-ed25519/SKILL.md`.
|
|
13
|
+
|
|
5
14
|
## What It Does
|
|
6
15
|
|
|
7
16
|
Auto-detects the input (problem vs idea) and runs up to two pipelines with **9 checkpoints**:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dzhechkov/skills-idea2prd",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.7",
|
|
4
4
|
"description": "Idea2PRD Manual — composite skill for Claude Code: from problem/idea to Vibe-Coding-ready docs (PRD + ADR + DDD + C4 + Pseudocode + Test Scenarios + Completion) with 9 checkpoints",
|
|
5
5
|
"main": "src/cli.js",
|
|
6
6
|
"bin": {
|
package/sbom.json
CHANGED
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
"hashes": [
|
|
36
36
|
{
|
|
37
37
|
"alg": "SHA-256",
|
|
38
|
-
"content": "
|
|
38
|
+
"content": "646b19adeddf1d63c0f07eabef2f4dcce49d65a259563dfc6ab6b4248d262191"
|
|
39
39
|
}
|
|
40
40
|
]
|
|
41
41
|
},
|
|
@@ -55,7 +55,7 @@
|
|
|
55
55
|
"hashes": [
|
|
56
56
|
{
|
|
57
57
|
"alg": "SHA-256",
|
|
58
|
-
"content": "
|
|
58
|
+
"content": "4bfe6564c3745ab76783640b91973036f6687155111052f3e3e34a3eb4dcd874"
|
|
59
59
|
}
|
|
60
60
|
]
|
|
61
61
|
},
|
|
@@ -185,7 +185,7 @@
|
|
|
185
185
|
"hashes": [
|
|
186
186
|
{
|
|
187
187
|
"alg": "SHA-256",
|
|
188
|
-
"content": "
|
|
188
|
+
"content": "95459a1566b2f88b6fb0c1d2f096f7631d698b95fa156afa26444a7c06e76451"
|
|
189
189
|
}
|
|
190
190
|
]
|
|
191
191
|
},
|
|
@@ -275,7 +275,7 @@
|
|
|
275
275
|
"hashes": [
|
|
276
276
|
{
|
|
277
277
|
"alg": "SHA-256",
|
|
278
|
-
"content": "
|
|
278
|
+
"content": "f6ec73a2ef239b8195a321b5f20b6ab390d9a7004b42226b2a861181cf9e2d2f"
|
|
279
279
|
}
|
|
280
280
|
]
|
|
281
281
|
},
|
|
@@ -305,7 +305,7 @@
|
|
|
305
305
|
"hashes": [
|
|
306
306
|
{
|
|
307
307
|
"alg": "SHA-256",
|
|
308
|
-
"content": "
|
|
308
|
+
"content": "86216fb6a40fcf270eada41c4a534600d2bcb2799052cbacfd64b459229b5bfe"
|
|
309
309
|
}
|
|
310
310
|
]
|
|
311
311
|
},
|
|
@@ -97,8 +97,11 @@ The accepted form has three properties, all checkable: **no digits** (spelled-ou
|
|
|
97
97
|
values count — `eight point zero four` is a value), **no identifiers**, and **lower
|
|
98
98
|
case** after the first word. The last one replaced an attempt to detect names: a name
|
|
99
99
|
is any word, no list of them exists, and the attempt let `John Smith has HIV` through.
|
|
100
|
-
Lower case costs a keystroke and closes that hole.
|
|
101
|
-
(`SHBG`, `TSH
|
|
100
|
+
Lower case costs a keystroke and closes that hole. Acronyms are unaffected — a token
|
|
101
|
+
passes if it is fully upper (`SHBG`, `TSH`) or opens lower (`apoB`, `mmHg`); only Title
|
|
102
|
+
Case is refused, because Title Case is the shape of a name. A token mixing letters with
|
|
103
|
+
a long digit run (`ab1234567`, `NCT04368728`) is an identifier and is refused however it
|
|
104
|
+
is punctuated, while a short lab acronym carrying a digit (`hba1c`) is not.
|
|
102
105
|
|
|
103
106
|
Pass `--allow-numbers` when the number IS the knowledge (a guideline threshold) and
|
|
104
107
|
you take responsibility for it. It never waves through a person beside a number.
|
|
@@ -76,7 +76,13 @@ BOOST_NOTE_TAILS = (
|
|
|
76
76
|
def _boost_note_present(out: str, domain: str = LEARNING_DOMAIN) -> bool:
|
|
77
77
|
"""True when THIS dz printed a real domain-boost note (not a lesson quoting one)."""
|
|
78
78
|
head = f'domain "{domain}":'
|
|
79
|
-
|
|
79
|
+
# split("\n"), NOT splitlines(). Python's splitlines() also breaks on U+2028,
|
|
80
|
+
# U+0085 and friends, which the CLI does not treat as line breaks — so a lesson
|
|
81
|
+
# containing one produced a "line" the renderer never emitted, and that forged line
|
|
82
|
+
# could satisfy this probe. Splitting exactly the way the producer joins keeps a
|
|
83
|
+
# forged tail stuck on a line that starts with the hit prefix, where the anchor
|
|
84
|
+
# below rejects it.
|
|
85
|
+
for line in out.split("\n"):
|
|
80
86
|
stripped = line.strip()
|
|
81
87
|
if stripped.startswith(head) and any(tail in stripped for tail in BOOST_NOTE_TAILS):
|
|
82
88
|
return True
|
|
@@ -121,7 +127,12 @@ DZ_MISSING_NOTE = (
|
|
|
121
127
|
|
|
122
128
|
# Any digit. Yes, any: a threshold, a dose, a year and a lab value are the same
|
|
123
129
|
# character class, and no amount of context-sniffing reliably tells them apart.
|
|
124
|
-
|
|
130
|
+
# NOT the regex `\d`. Round 5: the text scan used `\d` while the token scan used
|
|
131
|
+
# str.isdigit(), and `²` satisfies the second but not the first — so it was skipped as
|
|
132
|
+
# "a number" by one half and never refused by the other. Two different definitions of
|
|
133
|
+
# "digit" in one guard is a gap by construction. One predicate, used everywhere.
|
|
134
|
+
def _has_digit(text: str) -> bool:
|
|
135
|
+
return any(ch.isdigit() or ch.isnumeric() for ch in text)
|
|
125
136
|
|
|
126
137
|
# A value SPELLED OUT is still a value. Round 2 walked `John's TSH was eight point
|
|
127
138
|
# zero four` through the digit rule untouched. Requiring two number-words in a row
|
|
@@ -151,6 +162,17 @@ _IDENTIFIER_PATTERNS: Sequence[Tuple[str, str, bool]] = (
|
|
|
151
162
|
(r"\+?\d[\d\s().-]{7,}\d", "something shaped like a phone number", True),
|
|
152
163
|
(r"\b(mrn|снилс|полис|истори\w* болезни|medical record (no|number)|record no)\b[:\s#]*\w*",
|
|
153
164
|
"a medical-record identifier", True),
|
|
165
|
+
# Round 4: the TOKEN check refused `ab1234567`, so the same identifier written
|
|
166
|
+
# `ab—1234567` or `ab/1234567` was split by the tokenizer into a lower-case word
|
|
167
|
+
# and a permitted number — laundered by punctuation. This reads the RAW text, where
|
|
168
|
+
# the joiner is still visible. A SPACE is deliberately not a joiner here, or every
|
|
169
|
+
# "the 2019 guideline" would be refused.
|
|
170
|
+
# Any letter in ANY script, any single non-space separator, a long digit run. Round 5
|
|
171
|
+
# walked `ab:1234567` (colon was not in the joiner list) and `αβ—1234567` (Greek was
|
|
172
|
+
# not in the letter class) straight through. Enumerating joiners and alphabets is the
|
|
173
|
+
# same losing game as enumerating names; "not a space" and "any letter" are not.
|
|
174
|
+
(r"[^\W\d_]{2,}[^\w\s]?\d{4,}",
|
|
175
|
+
"letters sitting against a long run of digits — that is an identifier, however it is punctuated", False),
|
|
154
176
|
)
|
|
155
177
|
|
|
156
178
|
# THE CAPITALISATION RULE — round 2 forced this rewrite.
|
|
@@ -190,8 +212,25 @@ _TOKEN_EDGE_PUNCT = " \t\n\r.,;:!?()[]{}\"'«»„“”‘’—–-/\\|*_`"
|
|
|
190
212
|
|
|
191
213
|
|
|
192
214
|
def _is_acronym(token: str) -> bool:
|
|
193
|
-
"""
|
|
194
|
-
|
|
215
|
+
"""An acronym, as opposed to a name.
|
|
216
|
+
|
|
217
|
+
Round 4 killed the previous rule ("two or more capitals"), which was wrong in BOTH
|
|
218
|
+
directions at once: `Anne-Marie` and `McDonald` carry two capitals and sailed
|
|
219
|
+
through as acronyms, while `apoB` carries one and was refused — contradicting the
|
|
220
|
+
documentation in the same repository that offered it as the example.
|
|
221
|
+
|
|
222
|
+
What actually separates the two is WHERE the first capital sits. A name is Title
|
|
223
|
+
Case: capital first, lower case after. An acronym is either fully upper (SHBG, HIV,
|
|
224
|
+
TSH) or starts lower and capitalises later (apoB, mmHg). So: accept a token whose
|
|
225
|
+
first letter is lower case, or one whose letters are ALL upper case. Refuse
|
|
226
|
+
anything that opens with a capital and then drops to lower case, whatever it is.
|
|
227
|
+
"""
|
|
228
|
+
letters = [ch for ch in token if ch.isalpha()]
|
|
229
|
+
if not letters:
|
|
230
|
+
return False
|
|
231
|
+
if not letters[0].isupper():
|
|
232
|
+
return True # apoB, mmHg — lower-case opening is never a name
|
|
233
|
+
return all(ch.isupper() for ch in letters) # SHBG, HIV, D
|
|
195
234
|
|
|
196
235
|
|
|
197
236
|
# Splitting on whitespace ALONE was the last hole round 3 found: `method—John has HIV`
|
|
@@ -212,6 +251,13 @@ def _tokens(raw: str) -> List[str]:
|
|
|
212
251
|
# not a measurement in any unit system; it is a label for one thing.
|
|
213
252
|
_NUMBER_TOKEN_RE = r"^[<>≥≤~±]?\d+([.,]\d+)?[a-zа-яё%°]{0,4}$"
|
|
214
253
|
|
|
254
|
+
# …but a few real lab acronyms carry a digit (`HbA1c`, `CYP2D6`). What separates them
|
|
255
|
+
# from an accession is SIZE: an identifier has to be long enough to be unique. So a
|
|
256
|
+
# mixed token is an identifier when it is long or digit-heavy, and a lab acronym
|
|
257
|
+
# otherwise. The bound is a judgement call and is stated rather than hidden.
|
|
258
|
+
_IDENTIFIER_MIN_LEN = 7
|
|
259
|
+
_IDENTIFIER_MIN_DIGITS = 4
|
|
260
|
+
|
|
215
261
|
# A reference to ONE person. Alone it is fine — a method sentence may discuss patients
|
|
216
262
|
# in the abstract. Beside a value it is load-bearing: a person plus a reading is a
|
|
217
263
|
# record, and no --allow-numbers waves that through.
|
|
@@ -227,11 +273,11 @@ _NUMBER_TOKEN_RE = r"^[<>≥≤~±]?\d+([.,]\d+)?[a-zа-яё%°]{0,4}$"
|
|
|
227
273
|
# runs when --allow-numbers was passed (digits are refused outright otherwise). It is
|
|
228
274
|
# the narrowest of the three clauses and the only one that is still an enumeration.
|
|
229
275
|
_PERSON_RE = (
|
|
230
|
-
r"\b(patient|subject|participant|client|donor|volunteer|"
|
|
276
|
+
r"\b(patient|subject|participant|client|donor|volunteer|person|"
|
|
231
277
|
r"woman|man|individual|child|infant|adult|"
|
|
232
278
|
r"he|she|him|his|her|hers|"
|
|
233
279
|
r"пациент(?!ы|ов|ам|ами|ах)\w*|больн(ой|ого|ому|ым|ом)|испытуем(ый|ого|ому|ым|ом)|"
|
|
234
|
-
r"мужчина|мужчины\b|женщина|женщины\b
|
|
280
|
+
r"мужчина|мужчины\b|женщина|женщины\b|ребёнок|ребенок|доброволец|человек|"
|
|
235
281
|
r"у него|у неё)\b"
|
|
236
282
|
)
|
|
237
283
|
|
|
@@ -239,6 +285,7 @@ _PERSON_RE = (
|
|
|
239
285
|
class LessonVerdict:
|
|
240
286
|
ok: bool
|
|
241
287
|
reasons: List[str]
|
|
288
|
+
numbers_allowed: bool = False
|
|
242
289
|
|
|
243
290
|
@property
|
|
244
291
|
def note(self) -> str:
|
|
@@ -246,6 +293,17 @@ class LessonVerdict:
|
|
|
246
293
|
# NOT "safe": a shape detector cannot certify safety, and saying so would
|
|
247
294
|
# transfer responsibility it does not have (Codex QE #2). It reports the
|
|
248
295
|
# absence of a detected shape — the author still owns the content.
|
|
296
|
+
# The note must describe what was actually checked. Round 5: it said "no
|
|
297
|
+
# digits, no figures" even on the --allow-numbers path, where digits were
|
|
298
|
+
# present and consciously allowed — a message that contradicts the flag the
|
|
299
|
+
# caller just passed teaches the reader to stop reading messages.
|
|
300
|
+
if self.numbers_allowed:
|
|
301
|
+
return (
|
|
302
|
+
"no personal-data SHAPE detected (identifiers, capitalisation, person-plus-value). "
|
|
303
|
+
"Digits were ALLOWED by --allow-numbers and are present — you asserted the number "
|
|
304
|
+
"is knowledge, not a reading, and you own that call. This is not a safety "
|
|
305
|
+
"certificate: the guard checks shapes, not meaning."
|
|
306
|
+
)
|
|
249
307
|
return (
|
|
250
308
|
"no personal-data SHAPE detected (no digits, no identifiers, lower case). This is "
|
|
251
309
|
"not a safety certificate: the guard checks shapes, not meaning — a lower-case "
|
|
@@ -285,28 +343,34 @@ def check_lesson(text: str, allow_numbers: bool = False) -> LessonVerdict:
|
|
|
285
343
|
for index, token in enumerate(tokens):
|
|
286
344
|
has_digit = any(ch.isdigit() for ch in token)
|
|
287
345
|
has_alpha = any(ch.isalpha() for ch in token)
|
|
288
|
-
|
|
346
|
+
digit_count = sum(1 for ch in token if ch.isdigit())
|
|
347
|
+
looks_like_id = len(token) >= _IDENTIFIER_MIN_LEN or digit_count >= _IDENTIFIER_MIN_DIGITS
|
|
348
|
+
if has_digit and has_alpha and looks_like_id and not re.match(_NUMBER_TOKEN_RE, token):
|
|
289
349
|
reasons.append(
|
|
290
350
|
f"{token!r} mixes letters and digits — that is the shape of an identifier "
|
|
291
351
|
"(a record number, an accession, a passport), not of a measurement, and no "
|
|
292
352
|
"flag accepts it"
|
|
293
353
|
)
|
|
294
354
|
break
|
|
295
|
-
if
|
|
296
|
-
continue #
|
|
355
|
+
if not has_alpha:
|
|
356
|
+
continue # a pure number is judged by the digit rule; punctuation carries nothing
|
|
297
357
|
if index == 0:
|
|
298
358
|
continue # an ordinary sentence start
|
|
299
|
-
|
|
359
|
+
# Capitalisation is checked for EVERY token carrying letters, digits or not.
|
|
360
|
+
# Round 5: digit-bearing tokens returned before this check, so `method Test2
|
|
361
|
+
# applies` passed — a Title Case name only had to carry a digit to become exempt.
|
|
362
|
+
if all(ch.islower() for ch in token if ch.isalpha()) or _is_acronym(token):
|
|
300
363
|
continue
|
|
301
364
|
reasons.append(
|
|
302
365
|
f"a capitalised word ({token!r}) after the first one — a method lesson is written "
|
|
303
366
|
"in lower case, because one capital is the shape of a name and names cannot be "
|
|
304
|
-
"listed. Lower-case it
|
|
367
|
+
"listed. Lower-case it — an acronym passes either fully upper (SHBG, TSH) or opening "
|
|
368
|
+
"lower (apoB); only Title Case is refused"
|
|
305
369
|
)
|
|
306
370
|
break
|
|
307
371
|
|
|
308
372
|
spelled = re.search(_SPELLED_NUMBER_RE, raw, flags=re.IGNORECASE | re.UNICODE)
|
|
309
|
-
has_digits =
|
|
373
|
+
has_digits = _has_digit(raw) or spelled is not None
|
|
310
374
|
if has_digits and not allow_numbers:
|
|
311
375
|
reasons.append(
|
|
312
376
|
"the lesson contains digits — a method lesson rarely needs them, and a number is where "
|
|
@@ -327,7 +391,7 @@ def check_lesson(text: str, allow_numbers: bool = False) -> LessonVerdict:
|
|
|
327
391
|
"a person is referred to alongside a number — that is a record about someone, "
|
|
328
392
|
"and no flag makes it a method"
|
|
329
393
|
)
|
|
330
|
-
return LessonVerdict(not reasons, reasons)
|
|
394
|
+
return LessonVerdict(not reasons, reasons, numbers_allowed=allow_numbers)
|
|
331
395
|
|
|
332
396
|
|
|
333
397
|
# ---------------------------------------------------------------- dz detection
|
|
@@ -676,7 +676,11 @@ class LearningBridgeTests(unittest.TestCase):
|
|
|
676
676
|
for ok_text in (
|
|
677
677
|
"total testosterone is uninterpretable without SHBG",
|
|
678
678
|
"a single ferritin cannot separate overload from inflammation, pair it with CRP",
|
|
679
|
-
|
|
679
|
+
# NOT first in the sentence — round 4 found this test placed `apoB` at the
|
|
680
|
+
# start, where the sentence-start exemption made it pass no matter what the
|
|
681
|
+
# acronym rule did. A test whose subject sits in the one exempt position
|
|
682
|
+
# proves nothing about the rule it claims to cover.
|
|
683
|
+
"measure apoB before interpreting LDL alone",
|
|
680
684
|
):
|
|
681
685
|
with self.subTest(text=ok_text):
|
|
682
686
|
self.assertTrue(self.lb.check_lesson(ok_text).ok, ok_text)
|
|
@@ -766,6 +770,87 @@ class LearningBridgeTests(unittest.TestCase):
|
|
|
766
770
|
allow_numbers=True).ok)
|
|
767
771
|
|
|
768
772
|
|
|
773
|
+
# ---------------------------------------------------------------- round 4
|
|
774
|
+
|
|
775
|
+
def test_round4_acronym_vs_name_is_decided_by_WHERE_the_capital_sits(self):
|
|
776
|
+
"""The previous predicate — "two or more capitals" — was wrong in both
|
|
777
|
+
directions at once: `Anne-Marie` and `McDonald` passed as acronyms while
|
|
778
|
+
`apoB` was refused, contradicting this repo's own documentation."""
|
|
779
|
+
for name in ("patient Anne-Marie has HIV", "patient McDonald has HIV"):
|
|
780
|
+
with self.subTest(name=name):
|
|
781
|
+
self.assertFalse(self.lb.check_lesson(name).ok, name)
|
|
782
|
+
for acronym in ("measure apoB before interpreting risk",
|
|
783
|
+
"total testosterone is uninterpretable without SHBG"):
|
|
784
|
+
with self.subTest(acronym=acronym):
|
|
785
|
+
self.assertTrue(self.lb.check_lesson(acronym).ok, acronym)
|
|
786
|
+
|
|
787
|
+
def test_round4_punctuation_cannot_launder_an_identifier(self):
|
|
788
|
+
"""`ab1234567` was refused, so the same identifier written `ab—1234567` was
|
|
789
|
+
split by the tokenizer into a lower-case word plus a permitted number. The raw
|
|
790
|
+
text still shows the joiner, so that is where this one is checked."""
|
|
791
|
+
for leak in ("passport ab—1234567 identifies the holder",
|
|
792
|
+
"passport ab/1234567 identifies the holder",
|
|
793
|
+
"trial NCT—04368728 enrolled a cohort"):
|
|
794
|
+
with self.subTest(leak=leak):
|
|
795
|
+
self.assertFalse(self.lb.check_lesson(leak, allow_numbers=True).ok, leak)
|
|
796
|
+
# A YEAR is not an identifier: a space is deliberately not a joiner.
|
|
797
|
+
self.assertTrue(self.lb.check_lesson("the 2019 guideline moved the threshold",
|
|
798
|
+
allow_numbers=True).ok)
|
|
799
|
+
# …and a short lab acronym carrying a digit still passes.
|
|
800
|
+
self.assertTrue(self.lb.check_lesson("hba1c drifts with red-cell turnover",
|
|
801
|
+
allow_numbers=True).ok)
|
|
802
|
+
|
|
803
|
+
def test_round4_the_probe_cannot_be_forged_by_an_exotic_line_separator(self):
|
|
804
|
+
"""`splitlines()` breaks on U+2028 and U+0085, which the renderer does not treat
|
|
805
|
+
as line breaks — so a lesson containing one produced a 'line' the CLI never
|
|
806
|
+
emitted, and that forged line satisfied the capability probe. Splitting exactly
|
|
807
|
+
the way the producer joins keeps a forged tail on the hit's own line."""
|
|
808
|
+
tail = 'domain "health-research": order unchanged, nothing was hidden'
|
|
809
|
+
self.assertFalse(self.lb._boost_note_present(" [0.90] (general) x\u2028" + tail))
|
|
810
|
+
self.assertFalse(self.lb._boost_note_present(" [0.90] (general) x " + tail))
|
|
811
|
+
# A REAL note — its own line — is still detected.
|
|
812
|
+
self.assertTrue(self.lb._boost_note_present(" [0.90] (general) x\n " + tail))
|
|
813
|
+
|
|
814
|
+
|
|
815
|
+
# ---------------------------------------------------------------- round 5
|
|
816
|
+
|
|
817
|
+
def test_round5_ONE_definition_of_digit_everywhere(self):
|
|
818
|
+
"""The text scan used the regex `\\d` and the token scan used str.isdigit().
|
|
819
|
+
`²` satisfies the second but not the first, so one half skipped it as a number
|
|
820
|
+
and the other never refused it. Two definitions of the same word inside one
|
|
821
|
+
guard is a gap by construction."""
|
|
822
|
+
self.assertFalse(self.lb.check_lesson("threshold is ²").ok)
|
|
823
|
+
|
|
824
|
+
def test_round5_capitalisation_is_checked_even_when_a_token_carries_a_digit(self):
|
|
825
|
+
"""Digit-bearing tokens returned before the capitalisation check, so a Title
|
|
826
|
+
Case name only had to carry a digit to become exempt."""
|
|
827
|
+
self.assertFalse(self.lb.check_lesson("method Test2 applies", allow_numbers=True).ok)
|
|
828
|
+
|
|
829
|
+
def test_round5_joiners_and_alphabets_are_not_enumerated(self):
|
|
830
|
+
"""`ab:1234567` passed because a colon was not in the joiner list, and
|
|
831
|
+
`αβ—1234567` because Greek was not in the letter class. Enumerating joiners and
|
|
832
|
+
alphabets is the same losing game as enumerating names."""
|
|
833
|
+
for leak in ("passport ab:1234567 identifies the holder",
|
|
834
|
+
"passport αβ—1234567 identifies the holder"):
|
|
835
|
+
with self.subTest(leak=leak):
|
|
836
|
+
self.assertFalse(self.lb.check_lesson(leak, allow_numbers=True).ok, leak)
|
|
837
|
+
|
|
838
|
+
def test_round5_the_word_person_is_a_person(self):
|
|
839
|
+
"""The singular list enumerated patient/subject/participant and omitted the
|
|
840
|
+
plainest word of all."""
|
|
841
|
+
self.assertFalse(self.lb.check_lesson("this person has tsh 8.04", allow_numbers=True).ok)
|
|
842
|
+
|
|
843
|
+
def test_round5_the_acceptance_note_does_not_contradict_the_flag(self):
|
|
844
|
+
"""It said "no digits, no figures" on the --allow-numbers path, where digits
|
|
845
|
+
were present and consciously allowed. A message that contradicts the flag the
|
|
846
|
+
caller just passed teaches the reader to stop reading messages."""
|
|
847
|
+
verdict = self.lb.check_lesson("a 56h fast lowers total testosterone by a third",
|
|
848
|
+
allow_numbers=True)
|
|
849
|
+
self.assertTrue(verdict.ok)
|
|
850
|
+
self.assertNotIn("no digits", verdict.note)
|
|
851
|
+
self.assertIn("ALLOWED by --allow-numbers", verdict.note)
|
|
852
|
+
|
|
853
|
+
|
|
769
854
|
def test_a_real_failure_is_not_disguised_as_an_old_cli(self):
|
|
770
855
|
"""A crash, timeout or corrupt store must read as a failure, not as 'upgrade'."""
|
|
771
856
|
original = self.lb._run_dz
|