@1aboveio/skills 0.20.2 → 0.20.3

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.
Files changed (85) hide show
  1. package/README.md +12 -5
  2. package/package.json +1 -1
  3. package/runtime/skills/distribution/generated/recipes.json +220 -25
  4. package/runtime/skills/distribution/scripts/bundles.mjs +49 -5
  5. package/runtime/skills/engineering/engineering-runtime/scripts/workflow-coherence.mjs +4 -0
  6. package/runtime/skills/engineering/engineering-runtime/scripts/workflow-policy.mjs +1 -1
  7. package/skills/compliance/compliance-audit/LICENSE +3 -0
  8. package/skills/compliance/compliance-audit/SKILL.md +466 -0
  9. package/skills/compliance/compliance-audit/references/findings-schema.md +290 -0
  10. package/skills/compliance/compliance-audit/references/vars-checklist.md +1068 -0
  11. package/skills/compliance/compliance-audit/references/vars-findings-schema.md +395 -0
  12. package/skills/compliance/compliance-audit/scripts/reconcile_tracker.py +454 -0
  13. package/skills/compliance/compliance-audit/scripts/test_validate_tracker.py +90 -0
  14. package/skills/compliance/compliance-audit/scripts/validate_tracker.py +752 -0
  15. package/skills/compliance/doc-authenticity/LICENSE +3 -0
  16. package/skills/compliance/doc-authenticity/SKILL.md +158 -0
  17. package/skills/compliance/doc-authenticity/references/agreement.md +66 -0
  18. package/skills/compliance/doc-authenticity/references/bank-statement.md +150 -0
  19. package/skills/compliance/doc-authenticity/references/forensic-signals.md +157 -0
  20. package/skills/compliance/doc-authenticity/references/image-signals.md +114 -0
  21. package/skills/compliance/doc-authenticity/references/invoice.md +66 -0
  22. package/skills/compliance/doc-authenticity/references/issuer-profiles.md +86 -0
  23. package/skills/compliance/doc-authenticity/references/verdict-and-report.md +95 -0
  24. package/skills/compliance/doc-authenticity/references/verdict.schema.json +81 -0
  25. package/skills/compliance/doc-authenticity/scripts/image_forensics.py +294 -0
  26. package/skills/compliance/doc-authenticity/scripts/pdf_forensics.py +401 -0
  27. package/skills/compliance/doc-authenticity/scripts/validate_codes.py +224 -0
  28. package/skills/compliance/doc-authenticity/scripts/validate_verdict.py +308 -0
  29. package/skills/compliance/report-writing/LICENSE +3 -0
  30. package/skills/compliance/report-writing/SKILL.md +206 -0
  31. package/skills/compliance/report-writing/references/audit-report-template.md +121 -0
  32. package/skills/compliance/report-writing/references/external-assessment-template.md +167 -0
  33. package/skills/compliance/report-writing/references/management-report-template.md +145 -0
  34. package/skills/compliance/report-writing/references/vars-report-template.md +224 -0
  35. package/skills/document-utils/doc-to-md/LICENSE +3 -0
  36. package/skills/document-utils/doc-to-md/SKILL.md +77 -0
  37. package/skills/document-utils/doc-to-md/scripts/convert.py +157 -0
  38. package/skills/document-utils/reg-parser/LICENSE +3 -0
  39. package/skills/document-utils/reg-parser/SKILL.md +201 -0
  40. package/skills/document-utils/reg-parser/references/checklist-schema.md +223 -0
  41. package/skills/document-utils/reg-parser/references/checklists/hk-amlo-checklist.json +1306 -0
  42. package/skills/document-utils/reg-parser/references/checklists/hk-amlo-checklist.md +469 -0
  43. package/skills/document-utils/reg-parser/references/checklists/hk-fi-corporate-onboarding-cdd-checklist.json +634 -0
  44. package/skills/document-utils/reg-parser/references/checklists/hk-fi-corporate-onboarding-cdd-checklist.md +233 -0
  45. package/skills/document-utils/reg-parser/references/checklists/hk-mso-guideline-checklist.json +2194 -0
  46. package/skills/document-utils/reg-parser/references/checklists/hk-mso-guideline-checklist.md +771 -0
  47. package/skills/document-utils/reg-parser/references/checklists/kyc-aml-checklist.md +258 -0
  48. package/skills/document-utils/reg-parser/references/checklists/visa-vars-checklist.md +1058 -0
  49. package/skills/document-utils/reg-parser/references/mandates/hk-amlo-cap615.md +389 -0
  50. package/skills/document-utils/reg-parser/references/mandates/visa-vars.md +205 -0
  51. package/skills/document-utils/reg-parser/scripts/compare_checklist.py +228 -0
  52. package/skills/document-utils/reg-parser/scripts/dump_checklist.py +348 -0
  53. package/skills/document-utils/reg-parser/scripts/preprocess-mandate.sh +171 -0
  54. package/skills/document-utils/reg-parser/scripts/test_quarantine.sh +102 -0
  55. package/skills/document-utils/reg-parser/scripts/validate_checklist.py +532 -0
  56. package/skills/document-utils/reg-parser/scripts/validate_mandate.py +242 -0
  57. package/skills/engineering/engineering-runtime/coherence/workflow.json +16 -16
  58. package/skills/engineering/engineering-runtime/scripts/workflow-coherence.mjs +4 -0
  59. package/skills/engineering/engineering-runtime/scripts/workflow-policy.mjs +1 -1
  60. package/skills/payment/similar-domain/LICENSE +3 -0
  61. package/skills/payment/similar-domain/SKILL.md +178 -0
  62. package/skills/payment/similar-domain/references/entities.md +58 -0
  63. package/skills/payment/similar-domain/references/fingerprints.md +82 -0
  64. package/skills/payment/similar-domain/references/pivots.md +66 -0
  65. package/skills/payment/similar-domain/references/report-template.md +76 -0
  66. package/skills/payment/similar-domain/references/scoring.md +61 -0
  67. package/skills/payment/similar-domain/references/terminology.md +37 -0
  68. package/skills/payment/similar-domain/references/workflow.md +79 -0
  69. package/skills/payment/tld-detection/SKILL.md +76 -21
  70. package/skills/payment/tld-detection/references/clean.md +1 -1
  71. package/skills/payment/tld-detection/references/clustering.md +21 -6
  72. package/skills/payment/tld-detection/references/decision.md +7 -2
  73. package/skills/payment/tld-detection/references/fx-conversion.md +229 -0
  74. package/skills/payment/tld-detection/references/report-template.md +63 -27
  75. package/skills/payment/tld-detection/references/streams.md +90 -0
  76. package/skills/payment/tld-detection/references/terminology.md +15 -7
  77. package/skills/payment/tld-detection/references/workflow.md +1 -1
  78. package/skills/payment/tld-detection/scripts/detect_streams.py +542 -0
  79. package/skills/engineering/ensure-coverage/evals/evals.json +0 -125
  80. package/skills/engineering/rush-issues/evals/evals.json +0 -65
  81. package/skills/engineering/rush-release/evals/evals.json +0 -58
  82. package/skills/fullstack/shadcn/evals/evals.json +0 -90
  83. package/skills/payment/fraud-analysis/evals/evals.json +0 -40
  84. package/skills/payment/tld-detection/evals/evals.json +0 -107
  85. package/skills/payment/tld-detection/tests/test_detect_ladders.py +0 -132
@@ -0,0 +1,532 @@
1
+ #!/usr/bin/env python3
2
+ """Validate a compliance checklist Markdown file against the checklist schema.
3
+
4
+ Usage:
5
+ python3 validate_checklist.py <checklist.md> [--source <converted-md>]
6
+
7
+ Without --source, structural checks plus a flattening-detector warning run.
8
+
9
+ With --source <converted-md>, three additional semantic checks run as warnings:
10
+ 1. Source citations: each item's `Source:` field must have at least
11
+ one anchor (digit-bearing token) that appears in the source markdown.
12
+ 2. Modal verbs: each Requirement should contain mandatory language
13
+ (must / shall / required / prohibited / mandatory).
14
+ 3. Verification independence: Verification steps should not be a
15
+ near-paraphrase of the Requirement (Jaccard overlap > 0.5).
16
+
17
+ Always-on flattening detectors (source-native scheme):
18
+ - Marker-based: warns when a top-level Verification bullet contains
19
+ lettered or roman enumerator markers in prose (e.g. "(a)…(b)…(c)")
20
+ with no nested checkboxes — the agent likely flattened source
21
+ sub-bullets that should have been preserved as nested checkboxes
22
+ for auditor traceability.
23
+ - Comma-list: warns when a top-level Verification bullet is long
24
+ (>40 words) and contains many list joiners (4+ commas/semicolons)
25
+ with no nested children. Catches the case where the agent
26
+ paraphrased multiple source provisions into a comma-separated
27
+ summary, dropping the enumerator markers along the way.
28
+
29
+ Exit code 0 = valid. Non-zero = errors found (printed to stderr).
30
+ Warnings never cause non-zero exit.
31
+ """
32
+
33
+ import argparse
34
+ import re
35
+ import sys
36
+ import os
37
+
38
+ REQUIRED_METADATA = {"name", "regulation", "version", "scope", "date"}
39
+ REQUIRED_ITEM_FIELDS = {"Requirement", "Source", "Severity", "Verification", "Evidence"}
40
+
41
+ # Scheme-dependent rules. id_scheme metadata field selects between them;
42
+ # default is "cat".
43
+ SCHEMES = {
44
+ "cat": {
45
+ "cat_re": re.compile(r"^##\s+(CAT-\d{2}):\s+.+"),
46
+ "item_re": re.compile(r"^###\s+(CAT-\d{2}-\d{3}):\s+.+"),
47
+ "severities": ("critical", "major", "minor"),
48
+ "cat_hint": "## CAT-XX: Title",
49
+ "item_hint": "### CAT-XX-NNN: Title",
50
+ },
51
+ "source-native": {
52
+ # Category: uppercase token (letters/digits), e.g. AACQ, ATPA, PCIDSS.
53
+ "cat_re": re.compile(r"^##\s+([A-Z][A-Z0-9]*):\s+.+"),
54
+ # Item: <CAT>.<segment>(.<segment>)+ — preserves source punctuation.
55
+ "item_re": re.compile(
56
+ r"^###\s+([A-Z][A-Z0-9]*\.[A-Za-z0-9]+(?:\.[A-Za-z0-9]+)+):\s+.+"
57
+ ),
58
+ "severities": ("mandatory", "recommended"),
59
+ "cat_hint": "## <UPPERCASE-TOKEN>: Title",
60
+ "item_hint": "### <CAT>.<seg>.<seg>: Title",
61
+ },
62
+ }
63
+
64
+ YAML_BLOCK_RE = re.compile(r"^```yaml\s*$")
65
+ YAML_END_RE = re.compile(r"^```\s*$")
66
+ DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
67
+
68
+
69
+ def category_prefix(item_id: str, scheme: str) -> str:
70
+ """Return the category-ID prefix of an item ID under the given scheme."""
71
+ if scheme == "cat":
72
+ return item_id[:6] # 'CAT-01-001' -> 'CAT-01'
73
+ # source-native: everything before the first dot
74
+ return item_id.split(".", 1)[0]
75
+
76
+ MANDATORY_RE = re.compile(
77
+ r"\b(must|shall|required|requires|require|prohibited|prohibit|mandatory|"
78
+ r"obligated|forbidden|cannot|shall not|may not)\b",
79
+ re.IGNORECASE,
80
+ )
81
+ # Hedges that indicate a non-binding obligation. Present without an
82
+ # overriding mandatory anchor → likely the agent pulled in "should"/"may"
83
+ # guidance and treated it as a requirement.
84
+ WEAK_RE = re.compile(
85
+ r"\b(should|may|encouraged|recommended|best practice|where possible|"
86
+ r"as appropriate|if practicable|where practicable)\b",
87
+ re.IGNORECASE,
88
+ )
89
+ # Anchor token = a substring containing at least one digit, ≥2 chars.
90
+ # Catches: 13(1)(a), 2015/849, 615, 5.2, 76E, AACQ.C2.1.
91
+ ANCHOR_RE = re.compile(r"[\w./()\-]*\d[\w./()\-]*")
92
+ TOKEN_RE = re.compile(r"\b\w+\b")
93
+ STOPWORDS = {
94
+ "the", "a", "an", "of", "to", "and", "or", "in", "on", "for", "with",
95
+ "be", "is", "are", "as", "at", "by", "must", "shall", "all", "any",
96
+ "each", "from", "that", "this", "their", "its", "such", "where", "when",
97
+ "before", "after", "into", "out", "if", "not", "no", "than", "then",
98
+ "have", "has", "been", "being", "must", "may",
99
+ }
100
+ JACCARD_THRESHOLD = 0.5
101
+ JACCARD_MIN_TOKENS = 4 # avoid false positives on tiny items
102
+
103
+ # Flattening detection (source-native only). A top-level Verification
104
+ # bullet that contains 2+ parenthesized lowercase letters or 2+ roman
105
+ # numerals in its text — and has no nested checkboxes under it — is
106
+ # almost certainly a flattened source list (e.g. "Acquirer (a) collects
107
+ # X, (b) verifies Y, (c) reports Z" should have been three nested
108
+ # checkboxes). One match is tolerated (often a citation like §13(1)(a)).
109
+ LETTER_ENUM_RE = re.compile(r"\([a-z]\)")
110
+ ROMAN_ENUM_RE = re.compile(r"\([ivx]{1,4}\)")
111
+ CHECKBOX_LINE_RE = re.compile(r"^(\s*)-\s*\[[ xX]\]\s*(.+)$")
112
+ FIELD_LABEL_LINE_RE = re.compile(r"^-?\s*\*\*[A-Z][\w\s/-]*:\*\*")
113
+
114
+
115
+ def verification_flattening_warnings(
116
+ raw_lines: list[str],
117
+ item_re: re.Pattern,
118
+ ) -> list[str]:
119
+ """Detect Verification bullets that flatten source sub-bullets into prose.
120
+
121
+ Walks the raw file, tracking which item we're inside and whether we're
122
+ inside that item's Verification field. For each item, collects the
123
+ indentation + text of every checkbox bullet, then flags top-level
124
+ bullets (minimum indent within the item's Verification block) that
125
+ have no children but contain 2+ lettered/roman enumerator markers in
126
+ their text — the textbook flattening pattern.
127
+ """
128
+ warnings: list[str] = []
129
+ # item_id -> list of (indent, text)
130
+ blocks: dict[str, list[tuple[int, str]]] = {}
131
+ current_item: str | None = None
132
+ in_verif = False
133
+
134
+ for line in raw_lines:
135
+ stripped = line.strip()
136
+ m = item_re.match(stripped)
137
+ if m:
138
+ current_item = m.group(1)
139
+ blocks.setdefault(current_item, [])
140
+ in_verif = False
141
+ continue
142
+ if not current_item:
143
+ continue
144
+ # Verification field start
145
+ if stripped.startswith("**Verification:**") or stripped.startswith(
146
+ "- **Verification:**"
147
+ ):
148
+ in_verif = True
149
+ continue
150
+ if not in_verif:
151
+ continue
152
+ # Field-label line that is NOT a checkbox ends the Verification block
153
+ cb_match = CHECKBOX_LINE_RE.match(line.rstrip())
154
+ if not cb_match:
155
+ if FIELD_LABEL_LINE_RE.match(stripped) and not stripped.startswith(
156
+ "- [ ]"
157
+ ) and not stripped.startswith("- [x]"):
158
+ in_verif = False
159
+ continue
160
+ indent = len(cb_match.group(1))
161
+ text = cb_match.group(2)
162
+ blocks[current_item].append((indent, text))
163
+
164
+ for item_id, bullets in blocks.items():
165
+ if not bullets:
166
+ continue
167
+ min_indent = min(b[0] for b in bullets)
168
+ for idx, (indent, text) in enumerate(bullets):
169
+ if indent != min_indent:
170
+ continue
171
+ has_nested = idx + 1 < len(bullets) and bullets[idx + 1][0] > indent
172
+ if has_nested:
173
+ continue
174
+ letter_hits = len(LETTER_ENUM_RE.findall(text))
175
+ roman_hits = len(ROMAN_ENUM_RE.findall(text))
176
+ if letter_hits >= 2 or roman_hits >= 2:
177
+ kind = "lettered" if letter_hits >= 2 else "roman"
178
+ warnings.append(
179
+ f"{item_id}: Verification bullet contains {kind} enumerator "
180
+ f"markers in prose with no nested checkboxes — likely "
181
+ f"flattened source sub-bullets. Preserve source nesting depth "
182
+ f"(see checklist-schema.md 'Verification step prefixes')."
183
+ )
184
+ continue
185
+ # Comma-list flattening: long bullet packed with joiners but no
186
+ # children. Typically the agent paraphrased multiple source
187
+ # provisions into a single summary and stripped the markers.
188
+ # Strip the bold prefix (e.g. "**(1)**") so it doesn't inflate
189
+ # the word count for short, prefix-heavy bullets.
190
+ stripped_text = re.sub(r"^\*\*\([^)]*\)\*\*\s*", "", text)
191
+ words = stripped_text.split()
192
+ wc = len(words)
193
+ joiner_count = stripped_text.count(",") + stripped_text.count(";")
194
+ if wc > 40 and joiner_count >= 4:
195
+ warnings.append(
196
+ f"{item_id}: Verification bullet is {wc} words with "
197
+ f"{joiner_count} comma/semicolon joiners and no nested "
198
+ f"checkboxes — possibly multiple source provisions "
199
+ f"collapsed into a single summary. Compare against source "
200
+ f"and split if each clause is a discrete obligation."
201
+ )
202
+ return warnings
203
+
204
+
205
+ def parse_yaml_block(lines: list[str]) -> tuple[dict[str, str], dict[str, int], list[str]]:
206
+ """Extract key-value pairs and summary block from the first YAML fenced block."""
207
+ errors = []
208
+ meta: dict[str, str] = {}
209
+ summary: dict[str, int] = {}
210
+ in_yaml = False
211
+ in_summary = False
212
+ found = False
213
+
214
+ for line in lines:
215
+ stripped = line.strip()
216
+ if not in_yaml and YAML_BLOCK_RE.match(stripped):
217
+ in_yaml = True
218
+ found = True
219
+ continue
220
+ if in_yaml:
221
+ if YAML_END_RE.match(stripped):
222
+ break
223
+ # Detect summary block start
224
+ if stripped == "summary:":
225
+ in_summary = True
226
+ continue
227
+ # Parse summary sub-fields (indented)
228
+ if in_summary:
229
+ sm = re.match(r'^(\w+):\s*(\d+)\s*$', stripped)
230
+ if sm:
231
+ summary[sm.group(1)] = int(sm.group(2))
232
+ continue
233
+ else:
234
+ in_summary = False # end of summary block
235
+ # Simple key: "value" or key: value parsing
236
+ m = re.match(r'^(\w+):\s*"?(.+?)"?\s*$', stripped)
237
+ if m:
238
+ meta[m.group(1)] = m.group(2)
239
+
240
+ if not found:
241
+ errors.append("Missing YAML metadata block (```yaml ... ```)")
242
+ return meta, summary, errors
243
+
244
+
245
+ def tokenize(text: str) -> set[str]:
246
+ """Lowercase content tokens, stopwords removed, length >= 3."""
247
+ return {
248
+ w.lower()
249
+ for w in TOKEN_RE.findall(text)
250
+ if len(w) >= 3 and w.lower() not in STOPWORDS
251
+ }
252
+
253
+
254
+ def jaccard(a: set[str], b: set[str]) -> float:
255
+ if not a and not b:
256
+ return 0.0
257
+ return len(a & b) / len(a | b)
258
+
259
+
260
+ def fuzzy_present(token: str, haystack_lower: str) -> bool:
261
+ """Token appears in haystack literally or after punctuation strip."""
262
+ if token.lower() in haystack_lower:
263
+ return True
264
+ norm = re.sub(r"[^a-z0-9]", "", token.lower())
265
+ if len(norm) < 2:
266
+ return False
267
+ return norm in re.sub(r"[^a-z0-9]", "", haystack_lower)
268
+
269
+
270
+ def validate(filepath: str, source_path: str | None = None) -> tuple[list[str], list[str]]:
271
+ """Return (errors, warnings)."""
272
+ errors: list[str] = []
273
+ warnings: list[str] = []
274
+
275
+ if not os.path.isfile(filepath):
276
+ return [f"File not found: {filepath}"], []
277
+
278
+ with open(filepath, "r", encoding="utf-8") as f:
279
+ lines = f.readlines()
280
+
281
+ raw_lines = [l.rstrip("\n") for l in lines]
282
+
283
+ # --- Metadata ---
284
+ meta, summary, meta_errors = parse_yaml_block(raw_lines)
285
+ errors.extend(meta_errors)
286
+
287
+ for field in REQUIRED_METADATA:
288
+ if field not in meta:
289
+ errors.append(f"Missing required metadata field: {field}")
290
+
291
+ if "date" in meta and not DATE_RE.match(meta["date"]):
292
+ errors.append(f"Invalid date format: '{meta['date']}' (expected YYYY-MM-DD)")
293
+
294
+ # Resolve id_scheme; default = cat. Unknown values are an error.
295
+ scheme_name = meta.get("id_scheme", "cat")
296
+ if scheme_name not in SCHEMES:
297
+ errors.append(
298
+ f"Invalid id_scheme: '{scheme_name}' (must be 'cat' or 'source-native')"
299
+ )
300
+ scheme_name = "cat"
301
+ scheme = SCHEMES[scheme_name]
302
+ cat_re = scheme["cat_re"]
303
+ item_re = scheme["item_re"]
304
+ valid_severities = set(scheme["severities"])
305
+ summary_count_fields = ("categories", "total_items", *scheme["severities"])
306
+
307
+ if not summary:
308
+ errors.append(
309
+ "Missing required metadata field: summary "
310
+ f"({', '.join(summary_count_fields)})"
311
+ )
312
+ else:
313
+ for sf in summary_count_fields:
314
+ if sf not in summary:
315
+ errors.append(f"Missing summary field: {sf}")
316
+
317
+ # Check extends target exists
318
+ if "extends" in meta:
319
+ base_path = meta["extends"]
320
+ # Resolve relative to the checklist file's directory
321
+ checklist_dir = os.path.dirname(os.path.abspath(filepath))
322
+ resolved = os.path.normpath(os.path.join(checklist_dir, base_path))
323
+ if not os.path.isfile(resolved):
324
+ errors.append(f"extends target not found: {base_path} (resolved: {resolved})")
325
+
326
+ # --- Categories and Items ---
327
+ categories: set[str] = set()
328
+ item_ids: list[str] = []
329
+ current_cat: str | None = None
330
+ current_item_id: str | None = None
331
+ item_fields_found: dict[str, set[str]] = {} # item_id -> set of field names
332
+ # Per-item captured text for semantic checks
333
+ item_text: dict[str, dict[str, list[str]]] = {}
334
+ current_field: str | None = None # field currently being captured (multiline)
335
+
336
+ field_label_re = re.compile(r"^-\s+\*\*([\w\s\-]+?):\*\*\s*(.*)$")
337
+
338
+ for line_num, line in enumerate(raw_lines, 1):
339
+ stripped = line.strip()
340
+
341
+ # Category heading
342
+ cat_match = cat_re.match(stripped)
343
+ if cat_match:
344
+ current_cat = cat_match.group(1)
345
+ categories.add(current_cat)
346
+ current_field = None
347
+ continue
348
+
349
+ # Item heading
350
+ item_match = item_re.match(stripped)
351
+ if item_match:
352
+ current_item_id = item_match.group(1)
353
+ item_cat = category_prefix(current_item_id, scheme_name)
354
+
355
+ if item_cat not in categories:
356
+ errors.append(
357
+ f"Line {line_num}: Item {current_item_id} belongs to "
358
+ f"undeclared category {item_cat}"
359
+ )
360
+
361
+ if current_item_id in item_fields_found:
362
+ errors.append(f"Line {line_num}: Duplicate item ID {current_item_id}")
363
+ else:
364
+ item_fields_found[current_item_id] = set()
365
+ item_text[current_item_id] = {
366
+ "Requirement": [], "Source": [], "Verification": [],
367
+ "Evidence": [], "Severity": [],
368
+ }
369
+ item_ids.append(current_item_id)
370
+ current_field = None
371
+ continue
372
+
373
+ # Detect item fields (bold field labels — including multi-word
374
+ # optional fields like "Risk Domain", "Risk Sub-Domain")
375
+ if current_item_id:
376
+ field_match = field_label_re.match(stripped)
377
+ if field_match:
378
+ field_name = field_match.group(1).strip()
379
+ rest = field_match.group(2)
380
+ item_fields_found[current_item_id].add(field_name)
381
+ current_field = field_name if field_name in item_text[current_item_id] else None
382
+ if current_field and rest:
383
+ item_text[current_item_id][current_field].append(rest)
384
+
385
+ # Validate severity value
386
+ if field_name == "Severity":
387
+ sev_val = re.sub(r"[^a-z]", "", rest.lower())
388
+ if sev_val not in valid_severities:
389
+ errors.append(
390
+ f"Line {line_num}: Invalid severity '{sev_val}' "
391
+ f"in item {current_item_id}. "
392
+ f"Must be one of: {', '.join(sorted(valid_severities))}"
393
+ )
394
+ continue
395
+
396
+ # Continuation of a multi-line field (sub-bullets, wrapped text)
397
+ if current_field and current_item_id and stripped:
398
+ # Strip leading list markers / checkbox so token comparison
399
+ # works on the prose only.
400
+ clean = re.sub(r"^[-*]\s*\[[ xX]\]\s*", "", stripped)
401
+ clean = re.sub(r"^[-*]\s+", "", clean)
402
+ item_text[current_item_id][current_field].append(clean)
403
+
404
+ # Check required fields per item
405
+ for item_id, fields in item_fields_found.items():
406
+ missing = REQUIRED_ITEM_FIELDS - fields
407
+ if missing:
408
+ errors.append(
409
+ f"Item {item_id}: Missing required fields: {', '.join(sorted(missing))}"
410
+ )
411
+
412
+ # Warn if no categories or items found
413
+ if not categories:
414
+ errors.append(f"No categories found (expected '{scheme['cat_hint']}' headings)")
415
+ if not item_ids:
416
+ errors.append(f"No checklist items found (expected '{scheme['item_hint']}' headings)")
417
+
418
+ # Cross-validate summary counts against actual content
419
+ if summary:
420
+ sev_counts: dict[str, int] = {s: 0 for s in scheme["severities"]}
421
+ for item_id, fields in item_fields_found.items():
422
+ in_item = False
423
+ for line in raw_lines:
424
+ s = line.strip()
425
+ if item_re.match(s) and item_id in s:
426
+ in_item = True
427
+ continue
428
+ if in_item and item_re.match(s):
429
+ break
430
+ if in_item and "**Severity:**" in s:
431
+ val = s.split("**Severity:**")[-1].strip().lower()
432
+ val = re.sub(r"[^a-z]", "", val)
433
+ if val in sev_counts:
434
+ sev_counts[val] += 1
435
+ break
436
+
437
+ if summary.get("categories", 0) != len(categories):
438
+ errors.append(f"Summary mismatch: categories={summary['categories']} but found {len(categories)}")
439
+ if summary.get("total_items", 0) != len(item_ids):
440
+ errors.append(f"Summary mismatch: total_items={summary['total_items']} but found {len(item_ids)}")
441
+ for sev in scheme["severities"]:
442
+ if summary.get(sev, 0) != sev_counts[sev]:
443
+ errors.append(f"Summary mismatch: {sev}={summary.get(sev, 0)} but found {sev_counts[sev]}")
444
+
445
+ # --- Semantic checks (warnings only) ---
446
+ source_text_lower: str | None = None
447
+ if source_path:
448
+ if not os.path.isfile(source_path):
449
+ warnings.append(f"--source file not found: {source_path}")
450
+ else:
451
+ with open(source_path, "r", encoding="utf-8") as sf:
452
+ source_text_lower = sf.read().lower()
453
+
454
+ for item_id in item_ids:
455
+ fields = item_text.get(item_id, {})
456
+ req = " ".join(fields.get("Requirement", [])).strip()
457
+ src = " ".join(fields.get("Source", [])).strip()
458
+ verif = " ".join(fields.get("Verification", [])).strip()
459
+ sev = " ".join(fields.get("Severity", [])).strip().lower()
460
+ sev = re.sub(r"[^a-z]", "", sev)
461
+
462
+ # Weak-modal check: hedge present, mandatory anchor absent.
463
+ # Imperative-form requirements ("Collect…", "Verify…") pass.
464
+ # Skip for items explicitly classified as recommended — by
465
+ # definition they aren't binding obligations.
466
+ is_recommended = sev == "recommended"
467
+ if not is_recommended and req and WEAK_RE.search(req) and not MANDATORY_RE.search(req):
468
+ warnings.append(
469
+ f"{item_id}: Requirement uses non-binding language "
470
+ f"(should/may/recommended) with no must/shall anchor — "
471
+ f"verify this is actually mandatory in the source"
472
+ )
473
+
474
+ # Verification independence
475
+ if req and verif:
476
+ req_tok = tokenize(req)
477
+ verif_tok = tokenize(verif)
478
+ if len(req_tok) >= JACCARD_MIN_TOKENS and len(verif_tok) >= JACCARD_MIN_TOKENS:
479
+ score = jaccard(req_tok, verif_tok)
480
+ if score > JACCARD_THRESHOLD:
481
+ warnings.append(
482
+ f"{item_id}: Verification overlaps Requirement "
483
+ f"(Jaccard={score:.2f}) — verification should list "
484
+ f"observable steps, not paraphrase the requirement"
485
+ )
486
+
487
+ # Citation existence
488
+ if source_text_lower is not None and src:
489
+ anchors = ANCHOR_RE.findall(src)
490
+ if anchors:
491
+ if not any(fuzzy_present(a, source_text_lower) for a in anchors):
492
+ preview = ", ".join(anchors[:3])
493
+ warnings.append(
494
+ f"{item_id}: Source citation not found in source markdown "
495
+ f"(anchors checked: {preview})"
496
+ )
497
+
498
+ # Flattening detector — only meaningful for source-native, where
499
+ # nested numbering is a structural commitment. cat scheme uses a
500
+ # synthetic ID that doesn't promise to mirror source bullets.
501
+ if scheme_name == "source-native":
502
+ warnings.extend(verification_flattening_warnings(raw_lines, item_re))
503
+
504
+ return errors, warnings
505
+
506
+
507
+ def main() -> None:
508
+ p = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
509
+ p.add_argument("filepath", help="Path to checklist Markdown file")
510
+ p.add_argument(
511
+ "--source",
512
+ help="Optional path to converted source Markdown for citation, "
513
+ "modal-verb, and verification-independence warnings",
514
+ )
515
+ args = p.parse_args()
516
+
517
+ errors, warnings = validate(args.filepath, args.source)
518
+
519
+ for w in warnings:
520
+ print(f" ⚠️ {w}", file=sys.stderr)
521
+
522
+ if errors:
523
+ print(f"❌ Validation failed for {args.filepath}:", file=sys.stderr)
524
+ for err in errors:
525
+ print(f" • {err}", file=sys.stderr)
526
+ sys.exit(1)
527
+ print(f"✅ {args.filepath} is valid.")
528
+ sys.exit(0)
529
+
530
+
531
+ if __name__ == "__main__":
532
+ main()