@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,752 @@
1
+ #!/usr/bin/env python3
2
+ """Validate a compliance audit tracker against its checklist and evidence.
3
+
4
+ Usage:
5
+ python3 validate_tracker.py <tracker.md> --checklist <checklist.md> [--evidence DIR] [--json] [--strict]
6
+
7
+ Checks (Step 9A — structural):
8
+ 1. row_coverage Every Control ID in checklist scope has exactly one tracker row.
9
+ 2. item_coverage Every numbered item from the checklist row appears as an "Control N:"
10
+ bullet in the row's Observations cell.
11
+ 3. rating_value Control Effectiveness Rating is one of the four canonical
12
+ outcomes: Satisfactory / Improvement Recommended /
13
+ Unsatisfactory / N/A.
14
+ 4. rubric_consistency Effectiveness rating obeys the VARS rubric:
15
+ - any mandatory item "Partial" or "Not addressed" => Unsatisfactory
16
+ - all mandatory met but a recommended item is missing/partial
17
+ (or a mandatory item can be strengthened) => Improvement Recommended
18
+ - all items Covered => Satisfactory
19
+ - rows rated N/A are exempt (scoping decision, not derivable).
20
+ 5. gap_xref Each gap referenced in Issue Description by "(Control N)"
21
+ or "[Control N: ...]" must correspond to an item marked
22
+ "Not addressed" or "Partial" in the same row's Observations
23
+ cell. (Recommendations may also reference a "Covered"
24
+ mandatory item flagged for strengthening.)
25
+
26
+ Checks (Step 9B — citation grounding, requires --evidence):
27
+ 6. citation_grounding For every "Covered in <doc> section <clause>" (or similar)
28
+ citation in Observations, the cited clause string must
29
+ appear in the corresponding evidence markdown file.
30
+
31
+ Exit codes:
32
+ 0 = no errors (warnings allowed unless --strict).
33
+ 1 = errors found.
34
+
35
+ Errors vs warnings:
36
+ - row_coverage missing rows -> ERROR
37
+ - item_coverage missing bullets -> ERROR
38
+ - rating_value not in canonical set -> ERROR
39
+ - rubric_consistency violations -> ERROR
40
+ - gap_xref dangling reference -> WARNING
41
+ - citation_grounding missing clause -> WARNING
42
+ - citation_grounding missing doc -> WARNING
43
+ """
44
+
45
+ from __future__ import annotations
46
+
47
+ import argparse
48
+ import json
49
+ import re
50
+ import sys
51
+ from pathlib import Path
52
+ from typing import Optional
53
+
54
+ # ---------------------------------------------------------------------------
55
+ # Item-ID normalization
56
+ # ---------------------------------------------------------------------------
57
+
58
+ _ITEM_ID_PUNCT = re.compile(r"[()\s]+")
59
+
60
+
61
+ def normalize_item_id(raw: str) -> str:
62
+ """Canonicalise an item identifier.
63
+
64
+ Examples
65
+ --------
66
+ "(2.a)" -> "2.a"
67
+ "Control 2a" -> "2a"
68
+ "1.b" -> "1.b"
69
+ """
70
+ s = raw.strip().lower()
71
+ s = re.sub(r"^control\s+", "", s)
72
+ s = _ITEM_ID_PUNCT.sub("", s)
73
+ # collapse "2.a" and "2a" to the same canonical form by stripping
74
+ # the dot when it sits between digit(s) and letter(s)
75
+ s = re.sub(r"^(\d+)\.([a-z])", r"\1\2", s)
76
+ s = re.sub(r"^(\d+)\.([ivx]+)$", r"\1.\2", s) # keep 3.i, 3.ii intact
77
+ return s
78
+
79
+
80
+ def ancestor_canons(raw_id: str) -> set[str]:
81
+ """Canonical ids of an item plus all its dotted ancestors.
82
+
83
+ "6.a.i" -> {"6", "6a", "6a.i"}. Used so a parent bullet (e.g. the tracker
84
+ listing "Control 6.a") satisfies coverage of its descendant checklist
85
+ items (6.a.i … 6.a.x), which auditors routinely roll up rather than
86
+ enumerate leaf-by-leaf.
87
+ """
88
+ parts = raw_id.split(".")
89
+ return {normalize_item_id(".".join(parts[:k])) for k in range(1, len(parts) + 1)}
90
+
91
+
92
+ # ---------------------------------------------------------------------------
93
+ # Checklist parsing
94
+ # ---------------------------------------------------------------------------
95
+
96
+ CONTROL_HEAD_RE = re.compile(r"^###\s+([A-Z][A-Z0-9]*\.C\d+(?:\.\d+)?):\s+(.+?)\s*$")
97
+ SEVERITY_RE = re.compile(r"^-\s+\*\*Severity:\*\*\s*(\w+)", re.IGNORECASE)
98
+ ITEM_LINE_RE = re.compile(
99
+ r"^\s*-\s+\[\s\]\s+\*\*\(([^)]+)\)\*\*\s*(\(Recommended\)\s*)?(.*)$",
100
+ re.IGNORECASE,
101
+ )
102
+ SUB_ITEM_LINE_RE = re.compile(
103
+ r"^\s+-\s+\[\s\]\s+\*\*\(([^)]+)\)\*\*\s*(\(Recommended\)\s*)?(.*)$",
104
+ re.IGNORECASE,
105
+ )
106
+
107
+
108
+ class ChecklistItem:
109
+ __slots__ = ("id", "canonical", "mandatory", "text")
110
+
111
+ def __init__(self, raw_id: str, mandatory: bool, text: str):
112
+ self.id = raw_id
113
+ self.canonical = normalize_item_id(raw_id)
114
+ self.mandatory = mandatory
115
+ self.text = text
116
+
117
+
118
+ class ChecklistControl:
119
+ __slots__ = ("id", "title", "severity", "items")
120
+
121
+ def __init__(self, ctrl_id: str, title: str, severity: str):
122
+ self.id = ctrl_id
123
+ self.title = title
124
+ self.severity = severity.lower() # "mandatory" or "recommended"
125
+ self.items: list[ChecklistItem] = []
126
+
127
+
128
+ def parse_checklist(path: Path) -> dict[str, ChecklistControl]:
129
+ controls: dict[str, ChecklistControl] = {}
130
+ current: Optional[ChecklistControl] = None
131
+ in_verification = False
132
+
133
+ for line in path.read_text(encoding="utf-8").splitlines():
134
+ m = CONTROL_HEAD_RE.match(line)
135
+ if m:
136
+ current = ChecklistControl(m.group(1), m.group(2).strip(), "mandatory")
137
+ controls[current.id] = current
138
+ in_verification = False
139
+ continue
140
+
141
+ if current is None:
142
+ continue
143
+
144
+ sev = SEVERITY_RE.match(line)
145
+ if sev:
146
+ current.severity = sev.group(1).lower()
147
+ continue
148
+
149
+ if line.strip().startswith("- **Verification:**"):
150
+ in_verification = True
151
+ continue
152
+ if line.strip().startswith("- **Evidence:**") or line.strip().startswith("- **Source:**"):
153
+ in_verification = False
154
+ continue
155
+
156
+ if not in_verification:
157
+ continue
158
+
159
+ m = ITEM_LINE_RE.match(line) or SUB_ITEM_LINE_RE.match(line)
160
+ if not m:
161
+ continue
162
+ raw_id, recommended_marker, text = m.group(1), m.group(2), m.group(3)
163
+ # An item is recommended if the control is recommended-severity, the line
164
+ # carries the (Recommended) marker, or the id is R-prefixed (R1, R1.a, …)
165
+ # — the last covers sub-items of a recommended parent, which inherit the
166
+ # prefix but not the inline marker.
167
+ is_rec_id = normalize_item_id(raw_id).startswith("r")
168
+ mandatory = current.severity == "mandatory" and not recommended_marker and not is_rec_id
169
+ current.items.append(ChecklistItem(raw_id, mandatory, text.strip()))
170
+
171
+ return controls
172
+
173
+
174
+ # ---------------------------------------------------------------------------
175
+ # Tracker parsing
176
+ # ---------------------------------------------------------------------------
177
+
178
+ TABLE_HEADER_RE = re.compile(r"^\|\s*Control ID\s*\|", re.IGNORECASE)
179
+ SEPARATOR_RE = re.compile(r"^\|[\s\-:|]+\|\s*$")
180
+ PHASES_RE = re.compile(r"\*\*Audit Phases Completed:\*\*\s*(.+?)\s*$", re.IGNORECASE)
181
+ ARCHETYPES_RE = re.compile(r"\*\*Archetypes In Scope:\*\*\s*(.+?)\s*$", re.IGNORECASE)
182
+ SCOPE_RE = re.compile(r"\*\*Scope:\*\*\s*(.+?)\s*$", re.IGNORECASE)
183
+
184
+
185
+ class TrackerRow:
186
+ __slots__ = ("control_id", "title", "archetype", "observations", "issue", "rating", "remediation", "complete_by", "status", "raw")
187
+
188
+ def __init__(self, cells: list[str]):
189
+ # Expected column order: Control ID | Control Title | Archetype | Observations
190
+ # | Issue Description | Control Effectiveness Rating | Proposed Remediation
191
+ # | Complete By | Remediation Status
192
+ # Pad cells defensively in case of trailing-empty stripping.
193
+ cells = cells + [""] * (9 - len(cells))
194
+ self.control_id = cells[0].strip()
195
+ self.title = cells[1].strip()
196
+ self.archetype = cells[2].strip()
197
+ self.observations = cells[3]
198
+ self.issue = cells[4]
199
+ self.rating = cells[5].strip()
200
+ self.remediation = cells[6]
201
+ self.complete_by = cells[7].strip()
202
+ self.status = cells[8]
203
+ self.raw = cells
204
+
205
+
206
+ def parse_tracker(path: Path) -> tuple[list[str], list[str], Optional[str], list[TrackerRow]]:
207
+ """Return (phases, archetypes_in_scope, scope_string, rows)."""
208
+ text = path.read_text(encoding="utf-8")
209
+ phases: list[str] = []
210
+ archetypes: list[str] = []
211
+ scope: Optional[str] = None
212
+ rows: list[TrackerRow] = []
213
+
214
+ in_table = False
215
+ for line in text.splitlines():
216
+ m = PHASES_RE.search(line)
217
+ if m:
218
+ phase_str = m.group(1).strip().strip("[]")
219
+ phases = [p.strip() for p in re.split(r"[,;]", phase_str) if p.strip()]
220
+ continue
221
+
222
+ m = ARCHETYPES_RE.search(line)
223
+ if m:
224
+ arch_str = m.group(1).strip().strip("[]")
225
+ # Each token may be annotated, e.g. "AACQ (22 controls)". Keep only the
226
+ # leading archetype code so it matches a control's "AACQ.Cx.y" prefix;
227
+ # otherwise the scope set comes out empty and every row is flagged
228
+ # "outside declared scope" (and the missing-row check is disabled).
229
+ archetypes = [
230
+ am.group(1).upper()
231
+ for a in re.split(r"[,;]", arch_str)
232
+ if (am := re.match(r"\s*([A-Za-z]+)", a))
233
+ ]
234
+ continue
235
+
236
+ m = SCOPE_RE.search(line)
237
+ if m:
238
+ scope = m.group(1).strip()
239
+ continue
240
+
241
+ if not in_table:
242
+ if TABLE_HEADER_RE.match(line):
243
+ in_table = True
244
+ continue
245
+
246
+ if SEPARATOR_RE.match(line):
247
+ continue
248
+ if not line.strip().startswith("|"):
249
+ in_table = False
250
+ continue
251
+
252
+ cells = [c.strip() for c in line.strip().strip("|").split("|")]
253
+ if not cells or not cells[0]:
254
+ continue
255
+ rows.append(TrackerRow(cells))
256
+
257
+ return phases, archetypes, scope, rows
258
+
259
+
260
+ # ---------------------------------------------------------------------------
261
+ # Observation cell parsing
262
+ # ---------------------------------------------------------------------------
263
+
264
+ BULLET_SPLIT_RE = re.compile(r"\s*<br\s*/?>\s*|\n+", re.IGNORECASE)
265
+ BULLET_PREFIX_RE = re.compile(r"^[\s•\-\*]+")
266
+ OBS_BULLET_RE = re.compile(
267
+ r"^Control\s+([^:]+?):\s*(.*)$",
268
+ re.IGNORECASE,
269
+ )
270
+
271
+
272
+ class Observation:
273
+ __slots__ = ("item_id", "canonical", "status", "text")
274
+
275
+ def __init__(self, item_id: str, status: str, text: str):
276
+ self.item_id = item_id
277
+ self.canonical = normalize_item_id(item_id)
278
+ self.status = status # "covered" | "partial" | "not_addressed" | "unknown"
279
+ self.text = text
280
+
281
+
282
+ def classify_status(text: str) -> str:
283
+ low = text.lower().strip()
284
+ if "not addressed" in low:
285
+ return "not_addressed"
286
+ if low.startswith("partial") or "partial —" in low or "partial -" in low:
287
+ return "partial"
288
+ if "covered in" in low or low.startswith("covered"):
289
+ return "covered"
290
+ return "unknown"
291
+
292
+
293
+ def parse_observations(cell: str) -> list[Observation]:
294
+ out: list[Observation] = []
295
+ for chunk in BULLET_SPLIT_RE.split(cell):
296
+ chunk = BULLET_PREFIX_RE.sub("", chunk).strip()
297
+ if not chunk:
298
+ continue
299
+ m = OBS_BULLET_RE.match(chunk)
300
+ if not m:
301
+ continue
302
+ item_id = m.group(1).strip()
303
+ text = m.group(2).strip()
304
+ out.append(Observation(item_id, classify_status(text), text))
305
+ return out
306
+
307
+
308
+ # ---------------------------------------------------------------------------
309
+ # Issue Description gap parsing
310
+ # ---------------------------------------------------------------------------
311
+
312
+ # Matches both the legacy parenthetical form "(Control 1c)" and the enumerated
313
+ # bracket form "[Control 1.a: Partial]" / "[Control R1.a]". The item id is
314
+ # captured up to the first ":" , ")" or "]".
315
+ GAP_REF_RE = re.compile(r"[\[(]\s*Control\s+([^\]:)]+?)\s*[\]:)]", re.IGNORECASE)
316
+
317
+ # Split point between the two Issue Description groups.
318
+ REC_LABEL_RE = re.compile(r"\*{0,2}\s*recommendations\s*\*{0,2}\s*:", re.IGNORECASE)
319
+
320
+
321
+ def extract_gap_refs(cell: str) -> list[str]:
322
+ return [normalize_item_id(m) for m in GAP_REF_RE.findall(cell)]
323
+
324
+
325
+ def split_issue_sections(cell: str) -> tuple[str, str]:
326
+ """Split an Issue Description cell into (actions_required, recommendations).
327
+
328
+ Everything before the "Recommendations:" label is Actions Required; the
329
+ label and everything after is Recommendations. Works for both the legacy
330
+ inline format and the enumerated format.
331
+ """
332
+ m = REC_LABEL_RE.search(cell)
333
+ if m:
334
+ return cell[: m.start()], cell[m.start():]
335
+ return cell, ""
336
+
337
+
338
+ # ---------------------------------------------------------------------------
339
+ # Citation grounding
340
+ # ---------------------------------------------------------------------------
341
+
342
+ CITATION_RE = re.compile(
343
+ r"covered in\s+(?P<rest>[^•<]+?)(?=(?:\s+and\s+covered\s+in\s|<br|$|•))",
344
+ re.IGNORECASE,
345
+ )
346
+ # Clause marker is the word "section" (prescribed). Legacy "§" is still accepted
347
+ # so trackers written under the old convention keep grounding. The "section"
348
+ # form requires a following digit so document names containing the word
349
+ # "section" aren't mistaken for a clause marker.
350
+ CLAUSE_RE = re.compile(r"(?:§\s*|\b[Ss]ection\s+(?=\d))([^\s,;]+)")
351
+ CLAUSE_SPLIT_RE = re.compile(r"§|\b[Ss]ection\s+(?=\d)")
352
+ # Sentence-break markers that end a citation phrase. After the citation
353
+ # "Covered in X section 3.2(a)", any prose introduced by " — ", "; ", " -- ", or
354
+ # ". " (period + space) is descriptive, not part of the citation, and its
355
+ # clause references should NOT be treated as citations to ground.
356
+ CITATION_END_RE = re.compile(r"\s+(?:—|--|;|\.\s)")
357
+
358
+
359
+ def list_evidence_files(evidence_dir: Path) -> list[Path]:
360
+ return sorted([p for p in evidence_dir.rglob("*.md") if p.is_file()])
361
+
362
+
363
+ def normalize_doc_token(s: str) -> str:
364
+ return re.sub(r"[^a-z0-9]+", "", s.lower())
365
+
366
+
367
+ def find_evidence_file(citation: str, evidence_files: list[Path]) -> Optional[Path]:
368
+ """Best-effort match of a free-text doc reference to an evidence file."""
369
+ cite_norm = normalize_doc_token(citation)
370
+ if not cite_norm:
371
+ return None
372
+ best = None
373
+ best_overlap = 0
374
+ for path in evidence_files:
375
+ stem = normalize_doc_token(path.stem)
376
+ if not stem:
377
+ continue
378
+ # use longest common contiguous run as a cheap similarity score
379
+ overlap = 0
380
+ for length in range(min(len(stem), len(cite_norm)), 3, -1):
381
+ for i in range(len(cite_norm) - length + 1):
382
+ if cite_norm[i : i + length] in stem:
383
+ overlap = length
384
+ break
385
+ if overlap:
386
+ break
387
+ if overlap > best_overlap:
388
+ best, best_overlap = path, overlap
389
+ return best
390
+
391
+
392
+ def is_clause_grounded(clause: str, body: str) -> bool:
393
+ """Return True if the clause string is anchored in the evidence body.
394
+
395
+ Tolerates nested clause references — a cited ``section 3.2(a)(i)`` still grounds
396
+ when the evidence has ``3.2(a)`` as a heading and ``(i)`` listed as a
397
+ sub-item underneath. This matches how regulatory and contractual prose
398
+ is actually structured: parent headings carry the section number, child
399
+ bullets carry only the inner marker.
400
+ """
401
+ needle = clause.strip().rstrip(".,;")
402
+ if not needle:
403
+ return True
404
+ if needle in body:
405
+ return True
406
+ # Walk up by stripping the trailing parenthetical group. If the parent
407
+ # appears in the body and the leaf (e.g. "(i)") is also somewhere in the
408
+ # body, accept the citation as grounded.
409
+ parent_match = re.search(r"\(([^()]*)\)$", needle)
410
+ if not parent_match:
411
+ return False
412
+ parent = needle[: parent_match.start()].rstrip(".")
413
+ leaf = needle[parent_match.start():]
414
+ if not parent:
415
+ return False
416
+ if parent in body and leaf in body:
417
+ return True
418
+ return False
419
+
420
+
421
+ def check_citation(text: str, evidence_files: list[Path]) -> list[tuple[str, str]]:
422
+ """Return list of (level, message) for citation issues in one bullet text."""
423
+ issues: list[tuple[str, str]] = []
424
+ for cite in CITATION_RE.finditer(text):
425
+ rest = cite.group("rest").strip().rstrip(".,;")
426
+ # Trim descriptive prose after the citation phrase. Without this, a
427
+ # bullet like "Covered in MA section 3.2(a) — also see section 99.99 for context"
428
+ # would falsely flag section 99.99 as a missing citation.
429
+ end = CITATION_END_RE.search(rest)
430
+ if end:
431
+ rest = rest[: end.start()].strip()
432
+ clauses = CLAUSE_RE.findall(rest)
433
+ # doc-name fragment is the part before the first clause marker
434
+ doc_part = CLAUSE_SPLIT_RE.split(rest, maxsplit=1)[0].strip().rstrip("(),. ")
435
+ evidence = find_evidence_file(doc_part, evidence_files)
436
+ if evidence is None:
437
+ issues.append(("warning", f"no evidence file matches '{doc_part}'"))
438
+ continue
439
+ body = evidence.read_text(encoding="utf-8", errors="replace")
440
+ for clause in clauses:
441
+ if not is_clause_grounded(clause, body):
442
+ # Use the clause as cited, only stripping trailing punctuation
443
+ # (not closing parens, which are part of identifiers like (i)).
444
+ shown = clause.strip().rstrip(".,;")
445
+ issues.append(
446
+ (
447
+ "warning",
448
+ f"clause 'section {shown}' not found in {evidence.name}",
449
+ )
450
+ )
451
+ return issues
452
+
453
+
454
+ # ---------------------------------------------------------------------------
455
+ # Validation
456
+ # ---------------------------------------------------------------------------
457
+
458
+
459
+ # Canonical VARS effectiveness outcomes (strict set). N/A covers controls that
460
+ # are not applicable to the entity's scope.
461
+ CANONICAL_RATINGS = {"satisfactory", "improvement recommended", "unsatisfactory", "na"}
462
+ NA_VALUES = {"na", "n/a", "n.a.", "not applicable"}
463
+
464
+
465
+ def normalize_rating(raw: str) -> str:
466
+ """Lower-case + collapse the N/A variants to a single 'na' token."""
467
+ r = raw.strip().lower()
468
+ return "na" if r in NA_VALUES else r
469
+
470
+
471
+ def derive_expected_rating(observations: list[Observation], items: list[ChecklistItem]) -> Optional[str]:
472
+ """Apply the VARS effectiveness rubric to predict the rating.
473
+
474
+ Rubric (rated prior to remediation):
475
+ - Unsatisfactory — any MANDATORY item is not fulfilled, i.e.
476
+ "Partial" or "Not addressed" (these become
477
+ Actions Required).
478
+ - Improvement Recommended — all mandatory items fulfilled, but at least
479
+ one RECOMMENDED item is missing or partial
480
+ (these become Recommendations).
481
+ - Satisfactory — every item (mandatory and recommended) Covered.
482
+
483
+ Returns one of "Unsatisfactory", "Improvement Recommended", "Satisfactory",
484
+ or None if the input is incomplete (missing observations). N/A is never
485
+ derived — it is a human scoping decision applied to the row directly.
486
+ """
487
+ if not items:
488
+ return None
489
+
490
+ observed_by_canon: dict[str, Observation] = {obs.canonical: obs for obs in observations}
491
+
492
+ def effective_status(item: ChecklistItem) -> str:
493
+ """Status of an item, inheriting from the nearest bulleted ancestor.
494
+
495
+ Mirrors item_coverage: if a leaf (e.g. 3.b.i) has no direct bullet but
496
+ its parent (3.b) is bulleted, the leaf takes the parent's status. Returns
497
+ 'missing' when neither the item nor any ancestor is observed.
498
+ """
499
+ parts = item.id.split(".")
500
+ for k in range(len(parts), 0, -1): # self -> root, most specific first
501
+ canon = normalize_item_id(".".join(parts[:k]))
502
+ if canon in observed_by_canon:
503
+ return observed_by_canon[canon].status
504
+ return "missing"
505
+
506
+ UNMET = ("missing", "not_addressed", "partial")
507
+
508
+ # any mandatory item not fully covered (Partial or Not addressed) -> Unsatisfactory
509
+ if any(item.mandatory and effective_status(item) in UNMET for item in items):
510
+ return "Unsatisfactory"
511
+
512
+ # all mandatory covered; any recommended item missing or partial -> Improvement Recommended
513
+ if any((not item.mandatory) and effective_status(item) in UNMET for item in items):
514
+ return "Improvement Recommended"
515
+
516
+ return "Satisfactory"
517
+
518
+
519
+ def derive_scope(
520
+ controls: dict[str, ChecklistControl],
521
+ archetypes: list[str],
522
+ scope_str: Optional[str],
523
+ explicit_scope: list[str],
524
+ ) -> set[str]:
525
+ """Determine which Control IDs are in scope for this audit.
526
+
527
+ Precedence: --scope CLI flag > tracker "Scope: targeted: ..." > tracker
528
+ "Archetypes In Scope" > all controls.
529
+ """
530
+ if explicit_scope:
531
+ return {cid for cid in explicit_scope if cid in controls}
532
+
533
+ if scope_str and scope_str.lower().startswith("targeted"):
534
+ # form: "targeted: AACQ.C2.1, AACQ.C14.1"
535
+ rest = scope_str.split(":", 1)[1] if ":" in scope_str else ""
536
+ ids = [s.strip() for s in re.split(r"[,;]", rest) if s.strip()]
537
+ return {cid for cid in ids if cid in controls}
538
+
539
+ if archetypes:
540
+ return {cid for cid in controls if cid.split(".")[0].upper() in archetypes}
541
+
542
+ return set(controls.keys())
543
+
544
+
545
+ def validate(
546
+ tracker_path: Path,
547
+ checklist_path: Path,
548
+ evidence_dir: Optional[Path],
549
+ explicit_scope: Optional[list[str]] = None,
550
+ ) -> list[dict]:
551
+ findings: list[dict] = []
552
+ controls = parse_checklist(checklist_path)
553
+ phases, archetypes, scope_str, rows = parse_tracker(tracker_path)
554
+
555
+ if not rows:
556
+ findings.append(
557
+ {
558
+ "check": "tracker_parse",
559
+ "level": "error",
560
+ "control_id": None,
561
+ "message": "no tracker rows parsed (table not found or empty)",
562
+ }
563
+ )
564
+ return findings
565
+
566
+ in_scope = derive_scope(controls, archetypes, scope_str, explicit_scope or [])
567
+ rows_by_id = {r.control_id: r for r in rows}
568
+ evidence_files: list[Path] = list_evidence_files(evidence_dir) if evidence_dir else []
569
+
570
+ # 1. row_coverage — only check controls in scope
571
+ for ctrl_id in in_scope:
572
+ if ctrl_id not in rows_by_id:
573
+ findings.append(
574
+ {
575
+ "check": "row_coverage",
576
+ "level": "error",
577
+ "control_id": ctrl_id,
578
+ "message": f"checklist control {ctrl_id} has no tracker row",
579
+ }
580
+ )
581
+ for ctrl_id in rows_by_id:
582
+ if ctrl_id not in controls:
583
+ findings.append(
584
+ {
585
+ "check": "row_coverage",
586
+ "level": "error",
587
+ "control_id": ctrl_id,
588
+ "message": f"tracker row {ctrl_id} not present in checklist",
589
+ }
590
+ )
591
+ elif ctrl_id not in in_scope:
592
+ findings.append(
593
+ {
594
+ "check": "row_coverage",
595
+ "level": "warning",
596
+ "control_id": ctrl_id,
597
+ "message": f"tracker row {ctrl_id} present but outside declared scope",
598
+ }
599
+ )
600
+
601
+ for row in rows:
602
+ control = controls.get(row.control_id)
603
+ if control is None:
604
+ continue
605
+
606
+ observations = parse_observations(row.observations)
607
+ observed = {obs.canonical for obs in observations}
608
+ item_canon = {item.canonical for item in control.items}
609
+
610
+ # 2. item_coverage — a checklist item is covered if it OR any dotted
611
+ # ancestor is bulleted (parent bullets roll up their sub-items).
612
+ for item in control.items:
613
+ if not (ancestor_canons(item.id) & observed):
614
+ findings.append(
615
+ {
616
+ "check": "item_coverage",
617
+ "level": "error",
618
+ "control_id": row.control_id,
619
+ "message": f"item ({item.id}) missing from Observations",
620
+ }
621
+ )
622
+ for canon in observed - item_canon:
623
+ findings.append(
624
+ {
625
+ "check": "item_coverage",
626
+ "level": "warning",
627
+ "control_id": row.control_id,
628
+ "message": f"Observations bullet 'Control {canon}' has no matching checklist item",
629
+ }
630
+ )
631
+
632
+ # 3. rating_value — must be one of the four canonical outcomes
633
+ rating_norm = normalize_rating(row.rating) if row.rating else ""
634
+ if row.rating and rating_norm not in CANONICAL_RATINGS:
635
+ findings.append(
636
+ {
637
+ "check": "rating_value",
638
+ "level": "error",
639
+ "control_id": row.control_id,
640
+ "message": (
641
+ f"rating '{row.rating}' is not a canonical outcome "
642
+ "(Satisfactory / Improvement Recommended / Unsatisfactory / N/A)"
643
+ ),
644
+ }
645
+ )
646
+
647
+ # 4. rubric_consistency — N/A rows are exempt (scoping decision)
648
+ expected = derive_expected_rating(observations, control.items)
649
+ if expected and row.rating and rating_norm != "na" and rating_norm != expected.lower():
650
+ findings.append(
651
+ {
652
+ "check": "rubric_consistency",
653
+ "level": "error",
654
+ "control_id": row.control_id,
655
+ "message": f"rating '{row.rating}' inconsistent with rubric (expected '{expected}')",
656
+ }
657
+ )
658
+
659
+ # 5. gap_xref — Actions Required gaps must not be "Covered"; Recommendations
660
+ # may reference a Covered mandatory item flagged for strengthening.
661
+ actions_text, rec_text = split_issue_sections(row.issue)
662
+ for section, refs, enforce_not_covered in (
663
+ ("Actions Required", extract_gap_refs(actions_text), True),
664
+ ("Recommendations", extract_gap_refs(rec_text), False),
665
+ ):
666
+ for gap_canon in refs:
667
+ obs = next((o for o in observations if o.canonical == gap_canon), None)
668
+ if obs is None:
669
+ findings.append(
670
+ {
671
+ "check": "gap_xref",
672
+ "level": "warning",
673
+ "control_id": row.control_id,
674
+ "message": f"{section} references (Control {gap_canon}) which has no Observations bullet",
675
+ }
676
+ )
677
+ elif enforce_not_covered and obs.status == "covered":
678
+ findings.append(
679
+ {
680
+ "check": "gap_xref",
681
+ "level": "warning",
682
+ "control_id": row.control_id,
683
+ "message": f"{section} references (Control {gap_canon}) but Observation is 'Covered'",
684
+ }
685
+ )
686
+
687
+ # 6. citation_grounding
688
+ if evidence_files:
689
+ for obs in observations:
690
+ if obs.status not in ("covered", "partial"):
691
+ continue
692
+ for level, msg in check_citation(obs.text, evidence_files):
693
+ findings.append(
694
+ {
695
+ "check": "citation_grounding",
696
+ "level": level,
697
+ "control_id": row.control_id,
698
+ "message": f"({obs.item_id}) {msg}",
699
+ }
700
+ )
701
+
702
+ return findings
703
+
704
+
705
+ # ---------------------------------------------------------------------------
706
+ # CLI
707
+ # ---------------------------------------------------------------------------
708
+
709
+
710
+ def main() -> int:
711
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
712
+ ap.add_argument("tracker", type=Path)
713
+ ap.add_argument("--checklist", type=Path, required=True)
714
+ ap.add_argument("--evidence", type=Path, default=None, help="evidence markdown directory (enables citation grounding)")
715
+ ap.add_argument(
716
+ "--scope",
717
+ type=str,
718
+ default=None,
719
+ help="comma-separated Control IDs to validate (overrides tracker-declared scope)",
720
+ )
721
+ ap.add_argument("--json", action="store_true", help="emit findings as JSON")
722
+ ap.add_argument("--strict", action="store_true", help="exit non-zero on warnings as well")
723
+ args = ap.parse_args()
724
+
725
+ explicit_scope = (
726
+ [s.strip() for s in args.scope.split(",") if s.strip()] if args.scope else None
727
+ )
728
+ findings = validate(args.tracker, args.checklist, args.evidence, explicit_scope)
729
+
730
+ errors = [f for f in findings if f["level"] == "error"]
731
+ warnings = [f for f in findings if f["level"] == "warning"]
732
+
733
+ if args.json:
734
+ json.dump({"errors": errors, "warnings": warnings}, sys.stdout, indent=2)
735
+ sys.stdout.write("\n")
736
+ else:
737
+ for f in findings:
738
+ tag = "ERROR" if f["level"] == "error" else "WARN"
739
+ ctl = f["control_id"] or "-"
740
+ print(f"[{tag}] [{f['check']}] {ctl}: {f['message']}", file=sys.stderr)
741
+ print(
742
+ f"\n{len(errors)} error(s), {len(warnings)} warning(s)",
743
+ file=sys.stderr,
744
+ )
745
+
746
+ if errors or (args.strict and warnings):
747
+ return 1
748
+ return 0
749
+
750
+
751
+ if __name__ == "__main__":
752
+ sys.exit(main())