@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,228 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Compare a generated checklist against a test target file.
|
|
3
|
+
|
|
4
|
+
Targets often live in a different format than the skill's own output
|
|
5
|
+
(e.g. tests/vars-checklist.md is a wide markdown table; reg-parser
|
|
6
|
+
output is heading-form per checklist-schema.md). This script does a
|
|
7
|
+
structural comparison rather than a byte diff:
|
|
8
|
+
|
|
9
|
+
* Extract source-native control IDs from both files (regex over the
|
|
10
|
+
file text — works for headings, table rows, prose).
|
|
11
|
+
* Report set diffs: missing-from-output, extra-in-output, matched.
|
|
12
|
+
* From the output only, count items by archetype prefix and severity
|
|
13
|
+
(target files generally don't carry severity in a uniform place).
|
|
14
|
+
|
|
15
|
+
Exit code:
|
|
16
|
+
0 — every target ID is present in the output (extras allowed)
|
|
17
|
+
1 — at least one target ID is missing in the output
|
|
18
|
+
|
|
19
|
+
Usage:
|
|
20
|
+
python3 compare_checklist.py <output-checklist.md> <target-file.md>
|
|
21
|
+
"""
|
|
22
|
+
import argparse
|
|
23
|
+
import re
|
|
24
|
+
import sys
|
|
25
|
+
from collections import Counter
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
|
|
28
|
+
# Source-native control IDs: <CAT>.<seg>(.<seg>)+
|
|
29
|
+
# CAT is an uppercase token; segments are alphanumeric (preserve source
|
|
30
|
+
# punctuation). Examples: AACQ.C1.1, ATPA.C2.1, AVDC.C2.1.
|
|
31
|
+
ID_RE = re.compile(r"\b([A-Z][A-Z0-9]{2,})\.([A-Za-z0-9]+(?:\.[A-Za-z0-9]+)+)\b")
|
|
32
|
+
# A checklist item heading (used to count by severity in the output).
|
|
33
|
+
ITEM_HEADING_RE = re.compile(
|
|
34
|
+
r"^###\s+([A-Z][A-Z0-9]*\.[A-Za-z0-9]+(?:\.[A-Za-z0-9]+)+):\s+",
|
|
35
|
+
re.MULTILINE,
|
|
36
|
+
)
|
|
37
|
+
SEVERITY_RE = re.compile(r"\*\*Severity:\*\*\s*([A-Za-z]+)", re.IGNORECASE)
|
|
38
|
+
VERIFICATION_HEADER_RE = re.compile(r"^-?\s*\*\*Verification:\*\*", re.MULTILINE)
|
|
39
|
+
NEXT_FIELD_RE = re.compile(r"^-?\s*\*\*[A-Z][\w\s/-]*:\*\*")
|
|
40
|
+
CHECKBOX_INDENT_RE = re.compile(r"^(\s*)-\s*\[[ xX]\]\s*(.+)$")
|
|
41
|
+
LETTER_ENUM_PROSE_RE = re.compile(r"\([a-z]\)")
|
|
42
|
+
ROMAN_ENUM_PROSE_RE = re.compile(r"\([ivx]{1,4}\)")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def verification_stats(text: str) -> dict[str, dict]:
|
|
46
|
+
"""Per-item Verification structure stats: total checkbox count, max
|
|
47
|
+
nesting depth, and a flattening flag (top-level bullet with no
|
|
48
|
+
children but containing 2+ lettered/roman enumerator markers).
|
|
49
|
+
|
|
50
|
+
The depth distribution and flattening count are useful as a sanity
|
|
51
|
+
check for whether an agent run preserved source nesting. A run that
|
|
52
|
+
produces only 1-checkbox items across the board, or many flattened
|
|
53
|
+
bullets, is almost certainly mis-parsing source structure.
|
|
54
|
+
"""
|
|
55
|
+
stats: dict[str, dict] = {}
|
|
56
|
+
item_matches = list(ITEM_HEADING_RE.finditer(text))
|
|
57
|
+
for i, m in enumerate(item_matches):
|
|
58
|
+
item_id = m.group(1)
|
|
59
|
+
seg_start = m.end()
|
|
60
|
+
seg_end = item_matches[i + 1].start() if i + 1 < len(item_matches) else len(text)
|
|
61
|
+
segment = text[seg_start:seg_end]
|
|
62
|
+
verif_match = VERIFICATION_HEADER_RE.search(segment)
|
|
63
|
+
if not verif_match:
|
|
64
|
+
stats[item_id] = {"total": 0, "top_level": 0, "max_depth": 0, "flattened": 0}
|
|
65
|
+
continue
|
|
66
|
+
verif_body = segment[verif_match.end():]
|
|
67
|
+
bullets: list[tuple[int, str]] = []
|
|
68
|
+
for line in verif_body.splitlines():
|
|
69
|
+
stripped = line.strip()
|
|
70
|
+
if not stripped:
|
|
71
|
+
continue
|
|
72
|
+
cb = CHECKBOX_INDENT_RE.match(line.rstrip())
|
|
73
|
+
if cb:
|
|
74
|
+
bullets.append((len(cb.group(1)), cb.group(2)))
|
|
75
|
+
continue
|
|
76
|
+
# Stop at next field
|
|
77
|
+
if NEXT_FIELD_RE.match(stripped):
|
|
78
|
+
break
|
|
79
|
+
if not bullets:
|
|
80
|
+
stats[item_id] = {"total": 0, "top_level": 0, "max_depth": 0, "flattened": 0}
|
|
81
|
+
continue
|
|
82
|
+
min_indent = min(b[0] for b in bullets)
|
|
83
|
+
depth_levels = sorted({b[0] for b in bullets})
|
|
84
|
+
max_depth = depth_levels.index(max(depth_levels)) + 1 if depth_levels else 0
|
|
85
|
+
top_level = sum(1 for b in bullets if b[0] == min_indent)
|
|
86
|
+
flattened = 0
|
|
87
|
+
for idx, (indent, t) in enumerate(bullets):
|
|
88
|
+
if indent != min_indent:
|
|
89
|
+
continue
|
|
90
|
+
has_nested = idx + 1 < len(bullets) and bullets[idx + 1][0] > indent
|
|
91
|
+
if has_nested:
|
|
92
|
+
continue
|
|
93
|
+
if len(LETTER_ENUM_PROSE_RE.findall(t)) >= 2 or len(
|
|
94
|
+
ROMAN_ENUM_PROSE_RE.findall(t)
|
|
95
|
+
) >= 2:
|
|
96
|
+
flattened += 1
|
|
97
|
+
stats[item_id] = {
|
|
98
|
+
"total": len(bullets),
|
|
99
|
+
"top_level": top_level,
|
|
100
|
+
"max_depth": max_depth,
|
|
101
|
+
"flattened": flattened,
|
|
102
|
+
}
|
|
103
|
+
return stats
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def extract_ids(text: str) -> set[str]:
|
|
107
|
+
return {f"{m.group(1)}.{m.group(2)}" for m in ID_RE.finditer(text)}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def archetype(item_id: str) -> str:
|
|
111
|
+
return item_id.split(".", 1)[0]
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def output_severity_counts(text: str) -> Counter:
|
|
115
|
+
"""Count severities by walking item-headings in the output and
|
|
116
|
+
grabbing the first Severity line that follows each."""
|
|
117
|
+
counts: Counter = Counter()
|
|
118
|
+
matches = list(ITEM_HEADING_RE.finditer(text))
|
|
119
|
+
for i, m in enumerate(matches):
|
|
120
|
+
start = m.end()
|
|
121
|
+
end = matches[i + 1].start() if i + 1 < len(matches) else len(text)
|
|
122
|
+
segment = text[start:end]
|
|
123
|
+
sev_match = SEVERITY_RE.search(segment)
|
|
124
|
+
if sev_match:
|
|
125
|
+
counts[sev_match.group(1).lower()] += 1
|
|
126
|
+
else:
|
|
127
|
+
counts["<none>"] += 1
|
|
128
|
+
return counts
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def main() -> int:
|
|
132
|
+
p = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
|
|
133
|
+
p.add_argument("output", help="Generated checklist (skill output)")
|
|
134
|
+
p.add_argument("target", help="Test target file (ground truth)")
|
|
135
|
+
args = p.parse_args()
|
|
136
|
+
|
|
137
|
+
out_path = Path(args.output)
|
|
138
|
+
tgt_path = Path(args.target)
|
|
139
|
+
if not out_path.is_file():
|
|
140
|
+
print(f"❌ Output file not found: {out_path}", file=sys.stderr)
|
|
141
|
+
return 2
|
|
142
|
+
if not tgt_path.is_file():
|
|
143
|
+
print(f"❌ Target file not found: {tgt_path}", file=sys.stderr)
|
|
144
|
+
return 2
|
|
145
|
+
|
|
146
|
+
out_text = out_path.read_text(encoding="utf-8")
|
|
147
|
+
tgt_text = tgt_path.read_text(encoding="utf-8")
|
|
148
|
+
|
|
149
|
+
out_ids = extract_ids(out_text)
|
|
150
|
+
tgt_ids = extract_ids(tgt_text)
|
|
151
|
+
|
|
152
|
+
missing = sorted(tgt_ids - out_ids)
|
|
153
|
+
extra = sorted(out_ids - tgt_ids)
|
|
154
|
+
matched = sorted(out_ids & tgt_ids)
|
|
155
|
+
|
|
156
|
+
out_arch = Counter(archetype(i) for i in out_ids)
|
|
157
|
+
tgt_arch = Counter(archetype(i) for i in tgt_ids)
|
|
158
|
+
sev = output_severity_counts(out_text)
|
|
159
|
+
|
|
160
|
+
print(f"Output: {out_path}")
|
|
161
|
+
print(f"Target: {tgt_path}")
|
|
162
|
+
print()
|
|
163
|
+
print(f"Control IDs — target: {len(tgt_ids)}, output: {len(out_ids)}, matched: {len(matched)}")
|
|
164
|
+
|
|
165
|
+
if missing:
|
|
166
|
+
print(f"\n❌ Missing from output ({len(missing)}):")
|
|
167
|
+
for i in missing:
|
|
168
|
+
print(f" - {i}")
|
|
169
|
+
else:
|
|
170
|
+
print("\n✅ Every target control ID is present in the output.")
|
|
171
|
+
|
|
172
|
+
if extra:
|
|
173
|
+
print(f"\n⚠️ Extra in output, not in target ({len(extra)}):")
|
|
174
|
+
for i in extra:
|
|
175
|
+
print(f" + {i}")
|
|
176
|
+
|
|
177
|
+
print("\nBy archetype:")
|
|
178
|
+
archetypes = sorted(set(out_arch) | set(tgt_arch))
|
|
179
|
+
print(f" {'archetype':<8} {'target':>7} {'output':>7}")
|
|
180
|
+
for a in archetypes:
|
|
181
|
+
print(f" {a:<8} {tgt_arch.get(a, 0):>7} {out_arch.get(a, 0):>7}")
|
|
182
|
+
|
|
183
|
+
print("\nOutput severity counts (from item bodies):")
|
|
184
|
+
for s, n in sorted(sev.items()):
|
|
185
|
+
print(f" {s:<14} {n}")
|
|
186
|
+
|
|
187
|
+
# Verification structure stats — useful flattening signal
|
|
188
|
+
vstats = verification_stats(out_text)
|
|
189
|
+
if vstats:
|
|
190
|
+
totals = [v["total"] for v in vstats.values()]
|
|
191
|
+
depths = [v["max_depth"] for v in vstats.values()]
|
|
192
|
+
flat_total = sum(v["flattened"] for v in vstats.values())
|
|
193
|
+
flat_items = [iid for iid, v in vstats.items() if v["flattened"] > 0]
|
|
194
|
+
single_bullet_items = [iid for iid, v in vstats.items() if v["total"] == 1]
|
|
195
|
+
depth_dist = Counter(depths)
|
|
196
|
+
|
|
197
|
+
print("\nOutput Verification structure:")
|
|
198
|
+
print(f" total bullets across {len(vstats)} items: {sum(totals)}")
|
|
199
|
+
if totals:
|
|
200
|
+
print(
|
|
201
|
+
f" bullets per item: min={min(totals)} max={max(totals)} "
|
|
202
|
+
f"mean={sum(totals)/len(totals):.1f}"
|
|
203
|
+
)
|
|
204
|
+
print(" max-depth distribution:")
|
|
205
|
+
for d in sorted(depth_dist):
|
|
206
|
+
print(f" depth {d}: {depth_dist[d]} item(s)")
|
|
207
|
+
if flat_total:
|
|
208
|
+
print(
|
|
209
|
+
f"\n⚠️ Flattening signals: {flat_total} bullet(s) across "
|
|
210
|
+
f"{len(flat_items)} item(s) contain 2+ lettered/roman "
|
|
211
|
+
f"enumerator markers in prose with no nested checkboxes."
|
|
212
|
+
)
|
|
213
|
+
for iid in flat_items[:10]:
|
|
214
|
+
print(f" - {iid}")
|
|
215
|
+
if len(flat_items) > 10:
|
|
216
|
+
print(f" … and {len(flat_items) - 10} more")
|
|
217
|
+
if single_bullet_items and len(single_bullet_items) > len(vstats) * 0.5:
|
|
218
|
+
print(
|
|
219
|
+
f"\n⚠️ {len(single_bullet_items)}/{len(vstats)} items have only "
|
|
220
|
+
f"a single Verification bullet — agent may have collapsed "
|
|
221
|
+
f"multi-step controls into single statements."
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
return 1 if missing else 0
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
if __name__ == "__main__":
|
|
228
|
+
sys.exit(main())
|
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Dump a compliance checklist Markdown file to JSON.
|
|
3
|
+
|
|
4
|
+
The markdown file is the authored source of truth — auditors read and
|
|
5
|
+
review it, authors edit it, GitHub renders it. Downstream consumers
|
|
6
|
+
(compliance-audit, report-writing) want structure, not regex parsers.
|
|
7
|
+
This script emits a JSON representation that mirrors the markdown's
|
|
8
|
+
nested structure verbatim so consumers can iterate items, walk the
|
|
9
|
+
verification tree, and look up severity/evidence without re-parsing.
|
|
10
|
+
|
|
11
|
+
The output JSON is a build artifact. Treat the markdown as canonical
|
|
12
|
+
and regenerate JSON on every change (CI guard or pre-commit hook).
|
|
13
|
+
|
|
14
|
+
Usage:
|
|
15
|
+
python3 dump_checklist.py <checklist.md> # to stdout
|
|
16
|
+
python3 dump_checklist.py <checklist.md> --out <out.json>
|
|
17
|
+
|
|
18
|
+
Schema (illustrative):
|
|
19
|
+
{
|
|
20
|
+
"metadata": {"name": ..., "regulation": ..., "id_scheme": ..., "summary": {...}},
|
|
21
|
+
"categories": [{"id": "AACQ", "title": "...", "item_ids": [...]}],
|
|
22
|
+
"items": [
|
|
23
|
+
{
|
|
24
|
+
"id": "AACQ.C5.1",
|
|
25
|
+
"title": "Underwriting Policy",
|
|
26
|
+
"category": "AACQ",
|
|
27
|
+
"risk_domain": "...",
|
|
28
|
+
"risk_sub_domain": "...",
|
|
29
|
+
"risk_statement": "...",
|
|
30
|
+
"parent_control": "AACQ.C5",
|
|
31
|
+
"requirement": "...",
|
|
32
|
+
"source": "AACQ.C5.1 (Visa VARS, October 2024)",
|
|
33
|
+
"severity": "mandatory",
|
|
34
|
+
"verification": [
|
|
35
|
+
{
|
|
36
|
+
"prefix": "(1)",
|
|
37
|
+
"text": "...",
|
|
38
|
+
"section": "mandatory", # only on top-level nodes
|
|
39
|
+
"children": [
|
|
40
|
+
{"prefix": "(a)", "text": "...", "children": [...]}
|
|
41
|
+
]
|
|
42
|
+
}
|
|
43
|
+
],
|
|
44
|
+
"evidence": ["...", "..."]
|
|
45
|
+
}
|
|
46
|
+
]
|
|
47
|
+
}
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
import argparse
|
|
51
|
+
import json
|
|
52
|
+
import re
|
|
53
|
+
import sys
|
|
54
|
+
from typing import Any
|
|
55
|
+
|
|
56
|
+
# ---- regexes shared with validate_checklist.py -----------------------------
|
|
57
|
+
|
|
58
|
+
YAML_BLOCK_RE = re.compile(r"^```yaml\s*$")
|
|
59
|
+
YAML_END_RE = re.compile(r"^```\s*$")
|
|
60
|
+
|
|
61
|
+
CATEGORY_RES = {
|
|
62
|
+
"cat": re.compile(r"^##\s+(CAT-\d{2}):\s+(.+)$"),
|
|
63
|
+
"source-native": re.compile(r"^##\s+([A-Z][A-Z0-9]*):\s+(.+)$"),
|
|
64
|
+
}
|
|
65
|
+
ITEM_RES = {
|
|
66
|
+
"cat": re.compile(r"^###\s+(CAT-\d{2}-\d{3}):\s+(.+)$"),
|
|
67
|
+
"source-native": re.compile(
|
|
68
|
+
r"^###\s+([A-Z][A-Z0-9]*\.[A-Za-z0-9]+(?:\.[A-Za-z0-9]+)+):\s+(.+)$"
|
|
69
|
+
),
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
# Bold field label, e.g. "- **Risk Domain:** 4.1.2 ..."
|
|
73
|
+
FIELD_LINE_RE = re.compile(r"^-\s+\*\*([A-Za-z][\w\s/-]*?):\*\*\s*(.*)$")
|
|
74
|
+
# Continuation of a single-line field (next line starts with " " but not a bullet/checkbox)
|
|
75
|
+
CHECKBOX_RE = re.compile(r"^(\s*)-\s+\[[ xX]\]\s+(.+)$")
|
|
76
|
+
PLAIN_BULLET_RE = re.compile(r"^(\s*)-\s+(?!\[[ xX]\])(.+)$")
|
|
77
|
+
# Optional bold prefix at the start of a checkbox text: "**(1)** rest" or "**(a)** rest"
|
|
78
|
+
PREFIX_RE = re.compile(r"^\*\*\(([^)]+)\)\*\*\s*(.*)$")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# ---- YAML parser (lightweight, only what the schema uses) ------------------
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def parse_yaml(lines: list[str]) -> tuple[dict[str, Any], int]:
|
|
85
|
+
"""Parse the first ```yaml ... ``` block. Return (meta, end_line_idx)."""
|
|
86
|
+
meta: dict[str, Any] = {}
|
|
87
|
+
summary: dict[str, int] = {}
|
|
88
|
+
in_block = False
|
|
89
|
+
in_summary = False
|
|
90
|
+
end = 0
|
|
91
|
+
for i, line in enumerate(lines):
|
|
92
|
+
stripped = line.rstrip("\n")
|
|
93
|
+
if not in_block:
|
|
94
|
+
if YAML_BLOCK_RE.match(stripped):
|
|
95
|
+
in_block = True
|
|
96
|
+
continue
|
|
97
|
+
if YAML_END_RE.match(stripped):
|
|
98
|
+
end = i + 1
|
|
99
|
+
break
|
|
100
|
+
s = stripped.strip()
|
|
101
|
+
if not s:
|
|
102
|
+
continue
|
|
103
|
+
if s == "summary:":
|
|
104
|
+
in_summary = True
|
|
105
|
+
continue
|
|
106
|
+
if in_summary:
|
|
107
|
+
sm = re.match(r"^(\w+):\s*(\d+)\s*$", s)
|
|
108
|
+
if sm:
|
|
109
|
+
summary[sm.group(1)] = int(sm.group(2))
|
|
110
|
+
continue
|
|
111
|
+
# End of summary block
|
|
112
|
+
in_summary = False
|
|
113
|
+
m = re.match(r'^(\w+):\s*"?(.*?)"?\s*$', s)
|
|
114
|
+
if m:
|
|
115
|
+
meta[m.group(1)] = m.group(2)
|
|
116
|
+
if summary:
|
|
117
|
+
meta["summary"] = summary
|
|
118
|
+
return meta, end
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# ---- Verification tree builder ---------------------------------------------
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def build_verification_tree(raw_lines: list[str]) -> list[dict[str, Any]]:
|
|
125
|
+
"""Convert a sequence of checkbox lines (already isolated to the
|
|
126
|
+
Verification field's body) into a nested tree.
|
|
127
|
+
|
|
128
|
+
Each node: {"prefix": "(N)", "text": "rest", "children": [...]}.
|
|
129
|
+
The prefix is whatever sits inside the leading `**(...)**` bold; if
|
|
130
|
+
no prefix is present, prefix is None and text is the whole bullet.
|
|
131
|
+
Indent depth determines parent/child relationships — every level of
|
|
132
|
+
deeper indent is a child of the most recent shallower node.
|
|
133
|
+
"""
|
|
134
|
+
# Each entry: (indent, node)
|
|
135
|
+
stack: list[tuple[int, dict[str, Any]]] = []
|
|
136
|
+
roots: list[dict[str, Any]] = []
|
|
137
|
+
for line in raw_lines:
|
|
138
|
+
m = CHECKBOX_RE.match(line.rstrip())
|
|
139
|
+
if not m:
|
|
140
|
+
continue
|
|
141
|
+
indent = len(m.group(1))
|
|
142
|
+
body = m.group(2).strip()
|
|
143
|
+
prefix_m = PREFIX_RE.match(body)
|
|
144
|
+
if prefix_m:
|
|
145
|
+
prefix = prefix_m.group(1)
|
|
146
|
+
text = prefix_m.group(2).strip()
|
|
147
|
+
else:
|
|
148
|
+
prefix = None
|
|
149
|
+
text = body
|
|
150
|
+
node = {"prefix": prefix, "text": text, "children": []}
|
|
151
|
+
# Pop stack until we find a parent at strictly smaller indent
|
|
152
|
+
while stack and stack[-1][0] >= indent:
|
|
153
|
+
stack.pop()
|
|
154
|
+
if stack:
|
|
155
|
+
stack[-1][1]["children"].append(node)
|
|
156
|
+
else:
|
|
157
|
+
roots.append(node)
|
|
158
|
+
stack.append((indent, node))
|
|
159
|
+
return roots
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
# ---- Item parser -----------------------------------------------------------
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def parse_item(
|
|
166
|
+
cid: str,
|
|
167
|
+
title: str,
|
|
168
|
+
body_lines: list[str],
|
|
169
|
+
scheme: str,
|
|
170
|
+
) -> dict[str, Any]:
|
|
171
|
+
"""Parse the body of one ### heading into a structured item."""
|
|
172
|
+
item: dict[str, Any] = {
|
|
173
|
+
"id": cid,
|
|
174
|
+
"title": title.strip(),
|
|
175
|
+
"category": cid.split(".", 1)[0] if scheme == "source-native" else cid[:6],
|
|
176
|
+
"risk_domain": None,
|
|
177
|
+
"risk_sub_domain": None,
|
|
178
|
+
"risk_statement": None,
|
|
179
|
+
"parent_control": None,
|
|
180
|
+
"requirement": None,
|
|
181
|
+
"source": None,
|
|
182
|
+
"severity": None,
|
|
183
|
+
"verification": [],
|
|
184
|
+
"evidence": [],
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
# State machine: walk lines, dispatching by current field.
|
|
188
|
+
current_field: str | None = None
|
|
189
|
+
field_buffer: list[str] = [] # for verification + evidence
|
|
190
|
+
simple_fields = {
|
|
191
|
+
"Risk Domain": "risk_domain",
|
|
192
|
+
"Risk Sub-Domain": "risk_sub_domain",
|
|
193
|
+
"Risk Statement": "risk_statement",
|
|
194
|
+
"Parent Control": "parent_control",
|
|
195
|
+
"Requirement": "requirement",
|
|
196
|
+
"Source": "source",
|
|
197
|
+
"Severity": "severity",
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
def flush_buffer() -> None:
|
|
201
|
+
nonlocal field_buffer
|
|
202
|
+
if not current_field:
|
|
203
|
+
field_buffer = []
|
|
204
|
+
return
|
|
205
|
+
if current_field == "Verification":
|
|
206
|
+
tree = build_verification_tree(field_buffer)
|
|
207
|
+
# Tag each top-level node as mandatory vs recommended so
|
|
208
|
+
# downstream consumers can disambiguate cleanly when
|
|
209
|
+
# mandatory and recommended controls share `(1)`/`(2)`
|
|
210
|
+
# prefixes (per source numbering). For items whose own
|
|
211
|
+
# severity is "recommended" the whole tree is recommended.
|
|
212
|
+
sev = item.get("severity") or ""
|
|
213
|
+
for node in tree:
|
|
214
|
+
if sev == "recommended":
|
|
215
|
+
node["section"] = "recommended"
|
|
216
|
+
elif (node.get("text") or "").lstrip().startswith("(Recommended)"):
|
|
217
|
+
node["section"] = "recommended"
|
|
218
|
+
else:
|
|
219
|
+
node["section"] = "mandatory"
|
|
220
|
+
item["verification"] = tree
|
|
221
|
+
elif current_field == "Evidence":
|
|
222
|
+
evidence: list[str] = []
|
|
223
|
+
for ln in field_buffer:
|
|
224
|
+
m = PLAIN_BULLET_RE.match(ln.rstrip())
|
|
225
|
+
if m:
|
|
226
|
+
evidence.append(m.group(2).strip())
|
|
227
|
+
item["evidence"] = evidence
|
|
228
|
+
field_buffer = []
|
|
229
|
+
|
|
230
|
+
for line in body_lines:
|
|
231
|
+
m = FIELD_LINE_RE.match(line.rstrip())
|
|
232
|
+
if m:
|
|
233
|
+
# Starting a new field — flush the previous list-bearing one
|
|
234
|
+
flush_buffer()
|
|
235
|
+
label = m.group(1).strip()
|
|
236
|
+
rest = m.group(2).strip()
|
|
237
|
+
if label in simple_fields:
|
|
238
|
+
key = simple_fields[label]
|
|
239
|
+
# Keep severity normalized lowercase
|
|
240
|
+
value: Any = rest.strip()
|
|
241
|
+
if key == "severity":
|
|
242
|
+
value = re.sub(r"[^a-z]", "", value.lower())
|
|
243
|
+
item[key] = value
|
|
244
|
+
current_field = None
|
|
245
|
+
elif label in ("Verification", "Evidence"):
|
|
246
|
+
current_field = label
|
|
247
|
+
# `rest` should be empty for these fields, but tolerate inline content
|
|
248
|
+
if rest:
|
|
249
|
+
field_buffer.append(rest)
|
|
250
|
+
else:
|
|
251
|
+
current_field = None
|
|
252
|
+
continue
|
|
253
|
+
# Continuation under Verification / Evidence
|
|
254
|
+
if current_field in ("Verification", "Evidence"):
|
|
255
|
+
field_buffer.append(line)
|
|
256
|
+
flush_buffer()
|
|
257
|
+
return item
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
# ---- Top-level walker ------------------------------------------------------
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def parse_checklist(text: str) -> dict[str, Any]:
|
|
264
|
+
lines = text.splitlines()
|
|
265
|
+
meta, yaml_end = parse_yaml(lines)
|
|
266
|
+
scheme = meta.get("id_scheme", "cat")
|
|
267
|
+
if scheme not in CATEGORY_RES:
|
|
268
|
+
raise SystemExit(f"Unknown id_scheme '{scheme}'")
|
|
269
|
+
cat_re = CATEGORY_RES[scheme]
|
|
270
|
+
item_re = ITEM_RES[scheme]
|
|
271
|
+
|
|
272
|
+
categories: list[dict[str, Any]] = []
|
|
273
|
+
items: list[dict[str, Any]] = []
|
|
274
|
+
cat_by_id: dict[str, dict[str, Any]] = {}
|
|
275
|
+
|
|
276
|
+
current_item_id: str | None = None
|
|
277
|
+
current_item_title: str | None = None
|
|
278
|
+
current_item_lines: list[str] = []
|
|
279
|
+
|
|
280
|
+
def flush_item() -> None:
|
|
281
|
+
nonlocal current_item_id, current_item_title, current_item_lines
|
|
282
|
+
if current_item_id is None:
|
|
283
|
+
return
|
|
284
|
+
parsed = parse_item(
|
|
285
|
+
current_item_id, current_item_title or "", current_item_lines, scheme
|
|
286
|
+
)
|
|
287
|
+
items.append(parsed)
|
|
288
|
+
cat = cat_by_id.get(parsed["category"])
|
|
289
|
+
if cat is not None:
|
|
290
|
+
cat["item_ids"].append(parsed["id"])
|
|
291
|
+
current_item_id = None
|
|
292
|
+
current_item_title = None
|
|
293
|
+
current_item_lines = []
|
|
294
|
+
|
|
295
|
+
for line in lines[yaml_end:]:
|
|
296
|
+
cm = cat_re.match(line.rstrip())
|
|
297
|
+
if cm:
|
|
298
|
+
flush_item()
|
|
299
|
+
cat_id = cm.group(1)
|
|
300
|
+
cat = {"id": cat_id, "title": cm.group(2).strip(), "item_ids": []}
|
|
301
|
+
categories.append(cat)
|
|
302
|
+
cat_by_id[cat_id] = cat
|
|
303
|
+
continue
|
|
304
|
+
im = item_re.match(line.rstrip())
|
|
305
|
+
if im:
|
|
306
|
+
flush_item()
|
|
307
|
+
current_item_id = im.group(1)
|
|
308
|
+
current_item_title = im.group(2)
|
|
309
|
+
current_item_lines = []
|
|
310
|
+
continue
|
|
311
|
+
if current_item_id is not None:
|
|
312
|
+
current_item_lines.append(line)
|
|
313
|
+
flush_item()
|
|
314
|
+
|
|
315
|
+
return {"metadata": meta, "categories": categories, "items": items}
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
# ---- CLI -------------------------------------------------------------------
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def main() -> None:
|
|
322
|
+
p = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
|
|
323
|
+
p.add_argument("filepath", help="Path to checklist Markdown file")
|
|
324
|
+
p.add_argument("--out", help="Output JSON path (default: stdout)")
|
|
325
|
+
p.add_argument(
|
|
326
|
+
"--indent",
|
|
327
|
+
type=int,
|
|
328
|
+
default=2,
|
|
329
|
+
help="JSON indent (default: 2; use 0 for minified)",
|
|
330
|
+
)
|
|
331
|
+
args = p.parse_args()
|
|
332
|
+
|
|
333
|
+
with open(args.filepath, "r", encoding="utf-8") as f:
|
|
334
|
+
text = f.read()
|
|
335
|
+
parsed = parse_checklist(text)
|
|
336
|
+
indent = args.indent if args.indent > 0 else None
|
|
337
|
+
out = json.dumps(parsed, indent=indent, ensure_ascii=False)
|
|
338
|
+
if args.out:
|
|
339
|
+
with open(args.out, "w", encoding="utf-8") as f:
|
|
340
|
+
f.write(out)
|
|
341
|
+
f.write("\n")
|
|
342
|
+
else:
|
|
343
|
+
sys.stdout.write(out)
|
|
344
|
+
sys.stdout.write("\n")
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
if __name__ == "__main__":
|
|
348
|
+
main()
|