@1aboveio/skills 0.20.2 → 0.20.4
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/README.md +14 -6
- package/package.json +1 -1
- package/runtime/skills/distribution/generated/recipes.json +220 -25
- package/runtime/skills/distribution/scripts/bundles.mjs +167 -48
- package/runtime/skills/engineering/engineering-runtime/scripts/workflow-coherence.mjs +4 -0
- package/runtime/skills/engineering/engineering-runtime/scripts/workflow-policy.mjs +1 -1
- package/skills/compliance/compliance-audit/LICENSE +3 -0
- package/skills/compliance/compliance-audit/SKILL.md +466 -0
- package/skills/compliance/compliance-audit/references/findings-schema.md +290 -0
- package/skills/compliance/compliance-audit/references/vars-checklist.md +1068 -0
- package/skills/compliance/compliance-audit/references/vars-findings-schema.md +395 -0
- package/skills/compliance/compliance-audit/scripts/reconcile_tracker.py +454 -0
- package/skills/compliance/compliance-audit/scripts/test_validate_tracker.py +90 -0
- package/skills/compliance/compliance-audit/scripts/validate_tracker.py +752 -0
- package/skills/compliance/doc-authenticity/LICENSE +3 -0
- package/skills/compliance/doc-authenticity/SKILL.md +158 -0
- package/skills/compliance/doc-authenticity/references/agreement.md +66 -0
- package/skills/compliance/doc-authenticity/references/bank-statement.md +150 -0
- package/skills/compliance/doc-authenticity/references/forensic-signals.md +157 -0
- package/skills/compliance/doc-authenticity/references/image-signals.md +114 -0
- package/skills/compliance/doc-authenticity/references/invoice.md +66 -0
- package/skills/compliance/doc-authenticity/references/issuer-profiles.md +86 -0
- package/skills/compliance/doc-authenticity/references/verdict-and-report.md +95 -0
- package/skills/compliance/doc-authenticity/references/verdict.schema.json +81 -0
- package/skills/compliance/doc-authenticity/scripts/image_forensics.py +294 -0
- package/skills/compliance/doc-authenticity/scripts/pdf_forensics.py +401 -0
- package/skills/compliance/doc-authenticity/scripts/validate_codes.py +224 -0
- package/skills/compliance/doc-authenticity/scripts/validate_verdict.py +308 -0
- package/skills/compliance/report-writing/LICENSE +3 -0
- package/skills/compliance/report-writing/SKILL.md +206 -0
- package/skills/compliance/report-writing/references/audit-report-template.md +121 -0
- package/skills/compliance/report-writing/references/external-assessment-template.md +167 -0
- package/skills/compliance/report-writing/references/management-report-template.md +145 -0
- package/skills/compliance/report-writing/references/vars-report-template.md +224 -0
- package/skills/document-utils/doc-to-md/LICENSE +3 -0
- package/skills/document-utils/doc-to-md/SKILL.md +77 -0
- package/skills/document-utils/doc-to-md/scripts/convert.py +157 -0
- package/skills/document-utils/reg-parser/LICENSE +3 -0
- package/skills/document-utils/reg-parser/SKILL.md +201 -0
- package/skills/document-utils/reg-parser/references/checklist-schema.md +223 -0
- package/skills/document-utils/reg-parser/references/checklists/hk-amlo-checklist.json +1306 -0
- package/skills/document-utils/reg-parser/references/checklists/hk-amlo-checklist.md +469 -0
- package/skills/document-utils/reg-parser/references/checklists/hk-fi-corporate-onboarding-cdd-checklist.json +634 -0
- package/skills/document-utils/reg-parser/references/checklists/hk-fi-corporate-onboarding-cdd-checklist.md +233 -0
- package/skills/document-utils/reg-parser/references/checklists/hk-mso-guideline-checklist.json +2194 -0
- package/skills/document-utils/reg-parser/references/checklists/hk-mso-guideline-checklist.md +771 -0
- package/skills/document-utils/reg-parser/references/checklists/kyc-aml-checklist.md +258 -0
- package/skills/document-utils/reg-parser/references/checklists/visa-vars-checklist.md +1058 -0
- package/skills/document-utils/reg-parser/references/mandates/hk-amlo-cap615.md +389 -0
- package/skills/document-utils/reg-parser/references/mandates/visa-vars.md +205 -0
- package/skills/document-utils/reg-parser/scripts/compare_checklist.py +228 -0
- package/skills/document-utils/reg-parser/scripts/dump_checklist.py +348 -0
- package/skills/document-utils/reg-parser/scripts/preprocess-mandate.sh +171 -0
- package/skills/document-utils/reg-parser/scripts/test_quarantine.sh +102 -0
- package/skills/document-utils/reg-parser/scripts/validate_checklist.py +532 -0
- package/skills/document-utils/reg-parser/scripts/validate_mandate.py +242 -0
- package/skills/engineering/engineering-runtime/coherence/workflow.json +16 -16
- package/skills/engineering/engineering-runtime/scripts/workflow-coherence.mjs +4 -0
- package/skills/engineering/engineering-runtime/scripts/workflow-policy.mjs +1 -1
- package/skills/payment/similar-domain/LICENSE +3 -0
- package/skills/payment/similar-domain/SKILL.md +178 -0
- package/skills/payment/similar-domain/references/entities.md +58 -0
- package/skills/payment/similar-domain/references/fingerprints.md +82 -0
- package/skills/payment/similar-domain/references/pivots.md +66 -0
- package/skills/payment/similar-domain/references/report-template.md +76 -0
- package/skills/payment/similar-domain/references/scoring.md +61 -0
- package/skills/payment/similar-domain/references/terminology.md +37 -0
- package/skills/payment/similar-domain/references/workflow.md +79 -0
- package/skills/payment/tld-detection/SKILL.md +76 -21
- package/skills/payment/tld-detection/references/clean.md +1 -1
- package/skills/payment/tld-detection/references/clustering.md +21 -6
- package/skills/payment/tld-detection/references/decision.md +7 -2
- package/skills/payment/tld-detection/references/fx-conversion.md +229 -0
- package/skills/payment/tld-detection/references/report-template.md +63 -27
- package/skills/payment/tld-detection/references/streams.md +90 -0
- package/skills/payment/tld-detection/references/terminology.md +15 -7
- package/skills/payment/tld-detection/references/workflow.md +1 -1
- package/skills/payment/tld-detection/scripts/detect_streams.py +542 -0
- package/skills/engineering/ensure-coverage/evals/evals.json +0 -125
- package/skills/engineering/rush-issues/evals/evals.json +0 -65
- package/skills/engineering/rush-release/evals/evals.json +0 -58
- package/skills/fullstack/shadcn/evals/evals.json +0 -90
- package/skills/payment/fraud-analysis/evals/evals.json +0 -40
- package/skills/payment/tld-detection/evals/evals.json +0 -107
- 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()
|