@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.
Files changed (85) hide show
  1. package/README.md +14 -6
  2. package/package.json +1 -1
  3. package/runtime/skills/distribution/generated/recipes.json +220 -25
  4. package/runtime/skills/distribution/scripts/bundles.mjs +167 -48
  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,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()