@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,454 @@
1
+ #!/usr/bin/env python3
2
+ """Reconcile validated findings into the tracker (Step 9E).
3
+
4
+ Reads ``tracker.md`` and applies findings that are deterministically
5
+ verifiable, so hallucinated coverage cannot survive into the deliverable:
6
+
7
+ - From ``validate_tracker.py --json``: citation_grounding "missing clause"
8
+ warnings. The cited ``section X`` is not in the evidence file, so the citation
9
+ is hallucinated. Bullet flips to ``Not addressed`` with the reason.
10
+ - From the citation-judge JSON sidecars (Variant C, Step 9C): verdicts of
11
+ ``Off-target`` and ``Missing`` flip the bullet to ``Not addressed`` with
12
+ the judge's reason quoted.
13
+
14
+ Findings that are *not* auto-applied (judge ``Weak``, adversarial-challenger
15
+ recommendations) are surfaced in the reconciliation log for the human
16
+ reviewer to decide on.
17
+
18
+ After flipping bullets, the Control Effectiveness Rating is recomputed using
19
+ the same rubric as ``validate_tracker.py``. A line tagging the row as
20
+ "reconciled by Step 9E" is prepended to the Issue Description cell so the
21
+ reviewer can see what changed at a glance; the existing prose is preserved.
22
+
23
+ Usage:
24
+ python3 reconcile_tracker.py <tracker.md> --checklist <path> \\
25
+ [--validation <validate_tracker --json output>] \\
26
+ [--judge <judge-<group>.json>]... \\
27
+ [--output <path>] [--log <reconciliation.md>] [--dry-run]
28
+
29
+ Default ``--output`` is the tracker path itself (in-place rewrite).
30
+ Default ``--log`` is ``reconciliation.md`` next to the tracker.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import argparse
36
+ import json
37
+ import re
38
+ import sys
39
+ from dataclasses import dataclass, field
40
+ from datetime import datetime, timezone
41
+ from pathlib import Path
42
+ from typing import Optional
43
+
44
+ SCRIPT_DIR = Path(__file__).resolve().parent
45
+ sys.path.insert(0, str(SCRIPT_DIR))
46
+
47
+ from validate_tracker import ( # noqa: E402 (import after sys.path)
48
+ BULLET_PREFIX_RE,
49
+ BULLET_SPLIT_RE,
50
+ Observation,
51
+ classify_status,
52
+ derive_expected_rating,
53
+ normalize_item_id,
54
+ parse_checklist,
55
+ )
56
+
57
+
58
+ VALIDATION_MISSING_CLAUSE_RE = re.compile(
59
+ r"^\((?P<item>[^)]+)\)\s*clause\s*'(?:§\s*|section\s+)(?P<clause>[^']+?)'\s*not\s+found\s+in\s+(?P<doc>.+?)\s*$",
60
+ re.IGNORECASE,
61
+ )
62
+ VALIDATION_NO_DOC_RE = re.compile(
63
+ r"^\((?P<item>[^)]+)\)\s*no\s+evidence\s+file\s+matches\s*'(?P<doc>[^']+)'",
64
+ re.IGNORECASE,
65
+ )
66
+
67
+ CONTROL_ID_RE = re.compile(r"^[A-Z][A-Z0-9]*\.C\d+(?:\.\d+)?$")
68
+ TABLE_HEADER_RE = re.compile(r"^\|\s*Control ID\s*\|", re.IGNORECASE)
69
+ SEPARATOR_RE = re.compile(r"^\|[\s\-:|]+\|\s*$")
70
+ BULLET_PREFIX_LITERAL_RE = re.compile(r"^([\s•\-\*]*)Control\s+([^:]+):\s*(.*)$", re.IGNORECASE)
71
+
72
+
73
+ @dataclass
74
+ class AutoApplyFinding:
75
+ """A single finding that should auto-flip a bullet."""
76
+
77
+ control_id: str
78
+ item: str # canonical item id
79
+ source: str # "9B-clause-missing" | "9B-doc-missing" | "9C-off-target" | "9C-missing"
80
+ reason: str
81
+
82
+ def short_label(self) -> str:
83
+ return {
84
+ "9B-clause-missing": "9B — cited clause not in evidence",
85
+ "9B-doc-missing": "9B — cited document not found",
86
+ "9C-off-target": "9C judge — Off-target",
87
+ "9C-missing": "9C judge — Missing",
88
+ }.get(self.source, self.source)
89
+
90
+
91
+ @dataclass
92
+ class ReviewerMediated:
93
+ """A finding that needs human judgement; not auto-applied."""
94
+
95
+ control_id: str
96
+ item: str
97
+ source: str # "9C-weak" | "9D-challenge"
98
+ reason: str
99
+
100
+
101
+ @dataclass
102
+ class RowChange:
103
+ control_id: str
104
+ flips: list[tuple[str, str]] = field(default_factory=list) # (item, reason)
105
+ old_rating: str = ""
106
+ new_rating: str = ""
107
+
108
+
109
+ def load_validation_findings(path: Path) -> tuple[list[AutoApplyFinding], list[str]]:
110
+ """Extract auto-apply findings from validate_tracker.py --json output.
111
+
112
+ Returns (findings, unparsed_messages). Unparsed messages are surfaced in
113
+ the reconciliation log so the reviewer knows the validator flagged
114
+ something the reconciler didn't know how to act on.
115
+ """
116
+ data = json.loads(path.read_text(encoding="utf-8"))
117
+ findings: list[AutoApplyFinding] = []
118
+ unparsed: list[str] = []
119
+ for entry in data.get("warnings", []):
120
+ if entry.get("check") != "citation_grounding":
121
+ continue
122
+ ctrl = entry.get("control_id") or ""
123
+ msg = entry.get("message") or ""
124
+ m = VALIDATION_MISSING_CLAUSE_RE.match(msg)
125
+ if m:
126
+ findings.append(
127
+ AutoApplyFinding(
128
+ control_id=ctrl,
129
+ item=normalize_item_id(m.group("item")),
130
+ source="9B-clause-missing",
131
+ reason=f"cited clause section {m.group('clause').strip()} not found in {m.group('doc').strip()}",
132
+ )
133
+ )
134
+ continue
135
+ m = VALIDATION_NO_DOC_RE.match(msg)
136
+ if m:
137
+ findings.append(
138
+ AutoApplyFinding(
139
+ control_id=ctrl,
140
+ item=normalize_item_id(m.group("item")),
141
+ source="9B-doc-missing",
142
+ reason=f"cited document '{m.group('doc')}' not found among evidence files",
143
+ )
144
+ )
145
+ continue
146
+ unparsed.append(f"[{ctrl or '-'}] {msg}")
147
+ return findings, unparsed
148
+
149
+
150
+ def load_judge_findings(path: Path) -> tuple[list[AutoApplyFinding], list[ReviewerMediated]]:
151
+ auto: list[AutoApplyFinding] = []
152
+ mediated: list[ReviewerMediated] = []
153
+ raw = json.loads(path.read_text(encoding="utf-8"))
154
+ if not isinstance(raw, list):
155
+ raise ValueError(f"{path}: expected a JSON array of verdicts")
156
+ for entry in raw:
157
+ ctrl = (entry.get("control_id") or "").strip()
158
+ item_raw = (entry.get("item") or "").strip()
159
+ verdict = (entry.get("verdict") or "").strip()
160
+ reason = (entry.get("reason") or "").strip()
161
+ if not ctrl or not item_raw or not verdict:
162
+ continue
163
+ item = normalize_item_id(item_raw)
164
+ v_low = verdict.lower()
165
+ if v_low == "off-target":
166
+ auto.append(AutoApplyFinding(ctrl, item, "9C-off-target", reason or "judge: clause does not address the requirement"))
167
+ elif v_low == "missing":
168
+ auto.append(AutoApplyFinding(ctrl, item, "9C-missing", reason or "judge: cited clause not located in evidence"))
169
+ elif v_low == "weak":
170
+ mediated.append(ReviewerMediated(ctrl, item, "9C-weak", reason or "judge: clause references topic but does not operationalise"))
171
+ elif v_low == "confirmed":
172
+ continue
173
+ else:
174
+ mediated.append(ReviewerMediated(ctrl, item, f"9C-{v_low}", reason))
175
+ return auto, mediated
176
+
177
+
178
+ # --- Tracker mutation -----------------------------------------------------
179
+
180
+
181
+ def split_bullets(cell: str) -> list[str]:
182
+ pieces = [c for c in BULLET_SPLIT_RE.split(cell)]
183
+ return pieces
184
+
185
+
186
+ def join_bullets(bullets: list[str]) -> str:
187
+ return "<br>".join(bullets)
188
+
189
+
190
+ def parse_bullet(text: str) -> Optional[tuple[str, str, str, str]]:
191
+ """Return (leading_whitespace_and_bullet, item_raw, item_canon, body) or None."""
192
+ m = BULLET_PREFIX_LITERAL_RE.match(text)
193
+ if not m:
194
+ return None
195
+ prefix = m.group(1) or ""
196
+ item_raw = m.group(2).strip()
197
+ body = m.group(3).strip()
198
+ return (prefix, item_raw, normalize_item_id(item_raw), body)
199
+
200
+
201
+ def flip_bullet_text(original_bullet: str, reason: str) -> Optional[str]:
202
+ parsed = parse_bullet(original_bullet)
203
+ if not parsed:
204
+ return None
205
+ prefix, item_raw, _canon, _body = parsed
206
+ return f"{prefix}Control {item_raw}: Not addressed — {reason}"
207
+
208
+
209
+ def parse_table_row(line: str) -> Optional[list[str]]:
210
+ if not line.startswith("|"):
211
+ return None
212
+ cells = [c.strip() for c in line.strip().strip("|").split("|")]
213
+ if not cells or not cells[0]:
214
+ return None
215
+ if not CONTROL_ID_RE.match(cells[0]):
216
+ return None
217
+ return cells
218
+
219
+
220
+ def render_table_row(cells: list[str]) -> str:
221
+ return "| " + " | ".join(cells) + " |"
222
+
223
+
224
+ def reconcile_row(
225
+ cells: list[str],
226
+ findings: list[AutoApplyFinding],
227
+ control_items: list,
228
+ change: RowChange,
229
+ ) -> list[str]:
230
+ """Apply auto-apply findings to a single tracker row's cells."""
231
+ if len(cells) < 6:
232
+ return cells
233
+
234
+ obs_cell = cells[3]
235
+ issue_cell = cells[4]
236
+ rating_cell = cells[5]
237
+
238
+ bullets = split_bullets(obs_cell)
239
+ findings_by_item: dict[str, list[AutoApplyFinding]] = {}
240
+ for f in findings:
241
+ findings_by_item.setdefault(f.item, []).append(f)
242
+
243
+ new_bullets: list[str] = []
244
+ for raw in bullets:
245
+ chunk = BULLET_PREFIX_RE.sub("", raw).strip()
246
+ if not chunk:
247
+ new_bullets.append(raw)
248
+ continue
249
+ parsed = parse_bullet(raw)
250
+ if not parsed:
251
+ new_bullets.append(raw)
252
+ continue
253
+ prefix, item_raw, item_canon, body = parsed
254
+ applicable = findings_by_item.get(item_canon)
255
+ if not applicable:
256
+ new_bullets.append(raw)
257
+ continue
258
+ # Skip if the bullet already says Not addressed (no-op flip).
259
+ if classify_status(body) == "not_addressed":
260
+ new_bullets.append(raw)
261
+ continue
262
+ reasons = "; ".join(f.reason for f in applicable)
263
+ flipped = flip_bullet_text(raw, reasons)
264
+ if flipped is None:
265
+ new_bullets.append(raw)
266
+ continue
267
+ new_bullets.append(flipped)
268
+ change.flips.append((item_raw, "; ".join(f.short_label() for f in applicable)))
269
+
270
+ new_obs = join_bullets(new_bullets)
271
+
272
+ # Recompute rating from the new bullet states.
273
+ observations: list[Observation] = []
274
+ for raw in new_bullets:
275
+ parsed = parse_bullet(raw)
276
+ if not parsed:
277
+ continue
278
+ _prefix, item_raw, _canon, body = parsed
279
+ observations.append(Observation(item_raw, classify_status(body), body))
280
+
281
+ new_rating = derive_expected_rating(observations, control_items)
282
+ change.old_rating = rating_cell
283
+ change.new_rating = new_rating or rating_cell
284
+
285
+ rating_out = new_rating if new_rating else rating_cell
286
+
287
+ if change.flips:
288
+ flipped_summary = "; ".join(
289
+ f"Control {item} ({label})" for item, label in change.flips
290
+ )
291
+ annotation = f"**[Step 9E reconciled]:** {flipped_summary}.<br>"
292
+ if not issue_cell.startswith("**[Step 9E reconciled]"):
293
+ issue_cell = annotation + issue_cell
294
+
295
+ new_cells = list(cells)
296
+ new_cells[3] = new_obs
297
+ new_cells[4] = issue_cell
298
+ new_cells[5] = rating_out
299
+ return new_cells
300
+
301
+
302
+ def reconcile_tracker(
303
+ tracker_text: str,
304
+ auto_findings: list[AutoApplyFinding],
305
+ controls: dict,
306
+ ) -> tuple[str, list[RowChange]]:
307
+ findings_by_control: dict[str, list[AutoApplyFinding]] = {}
308
+ for f in auto_findings:
309
+ findings_by_control.setdefault(f.control_id, []).append(f)
310
+
311
+ out_lines: list[str] = []
312
+ changes: list[RowChange] = []
313
+ for line in tracker_text.splitlines():
314
+ cells = parse_table_row(line)
315
+ if cells is None:
316
+ out_lines.append(line)
317
+ continue
318
+ ctrl_id = cells[0]
319
+ applicable = findings_by_control.get(ctrl_id, [])
320
+ control = controls.get(ctrl_id)
321
+ if not applicable or control is None:
322
+ out_lines.append(line)
323
+ continue
324
+ change = RowChange(control_id=ctrl_id)
325
+ new_cells = reconcile_row(cells, applicable, control.items, change)
326
+ if change.flips:
327
+ changes.append(change)
328
+ out_lines.append(render_table_row(new_cells))
329
+ else:
330
+ out_lines.append(line)
331
+
332
+ return "\n".join(out_lines) + ("\n" if tracker_text.endswith("\n") else ""), changes
333
+
334
+
335
+ # --- Reconciliation log ----------------------------------------------------
336
+
337
+
338
+ def write_log(
339
+ log_path: Path,
340
+ tracker_path: Path,
341
+ output_path: Path,
342
+ auto_findings: list[AutoApplyFinding],
343
+ mediated: list[ReviewerMediated],
344
+ unparsed_validation: list[str],
345
+ changes: list[RowChange],
346
+ ) -> None:
347
+ now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
348
+ lines: list[str] = []
349
+ lines.append("# Reconciliation Log (Step 9E)")
350
+ lines.append("")
351
+ lines.append(f"- **Generated:** {now}")
352
+ lines.append(f"- **Source tracker:** `{tracker_path}`")
353
+ lines.append(f"- **Reconciled tracker:** `{output_path}`")
354
+ lines.append(f"- **Auto-applied findings:** {len(auto_findings)}")
355
+ lines.append(f"- **Reviewer-mediated findings:** {len(mediated)}")
356
+ lines.append(f"- **Rows changed:** {len(changes)}")
357
+ lines.append("")
358
+
359
+ lines.append("## Auto-applied")
360
+ if not changes:
361
+ lines.append("")
362
+ lines.append("_None — no auto-apply findings hit any tracker bullet._")
363
+ else:
364
+ for change in changes:
365
+ lines.append("")
366
+ lines.append(f"### {change.control_id}")
367
+ lines.append("")
368
+ lines.append(f"- **Rating:** `{change.old_rating}` → **`{change.new_rating}`**")
369
+ lines.append("- **Bullets flipped:**")
370
+ for item, label in change.flips:
371
+ lines.append(f" - Control {item}: {label}")
372
+ lines.append("")
373
+
374
+ lines.append("## Pending review (NOT auto-applied)")
375
+ if not mediated:
376
+ lines.append("")
377
+ lines.append("_None._")
378
+ else:
379
+ by_ctrl: dict[str, list[ReviewerMediated]] = {}
380
+ for m in mediated:
381
+ by_ctrl.setdefault(m.control_id, []).append(m)
382
+ for ctrl_id, items in sorted(by_ctrl.items()):
383
+ lines.append("")
384
+ lines.append(f"### {ctrl_id}")
385
+ lines.append("")
386
+ for m in items:
387
+ lines.append(f"- **Control {m.item}** ({m.source}): {m.reason}")
388
+ lines.append("")
389
+
390
+ if unparsed_validation:
391
+ lines.append("## Validator messages the reconciler could not parse")
392
+ lines.append("")
393
+ lines.append("These were emitted by `validate_tracker.py` but did not match a known auto-apply pattern. Review manually.")
394
+ lines.append("")
395
+ for msg in unparsed_validation:
396
+ lines.append(f"- {msg}")
397
+ lines.append("")
398
+
399
+ log_path.write_text("\n".join(lines), encoding="utf-8")
400
+
401
+
402
+ # --- CLI -------------------------------------------------------------------
403
+
404
+
405
+ def main() -> int:
406
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
407
+ ap.add_argument("tracker", type=Path, help="path to tracker.md")
408
+ ap.add_argument("--checklist", type=Path, required=True)
409
+ ap.add_argument("--validation", type=Path, default=None, help="JSON output from validate_tracker.py --json")
410
+ ap.add_argument("--judge", type=Path, action="append", default=[], help="judge-<group>.json sidecar (may be passed multiple times)")
411
+ ap.add_argument("--output", type=Path, default=None, help="where to write the reconciled tracker (default: overwrite input)")
412
+ ap.add_argument("--log", type=Path, default=None, help="reconciliation log path (default: reconciliation.md alongside tracker)")
413
+ ap.add_argument("--dry-run", action="store_true", help="don't write the reconciled tracker; still write the log")
414
+ args = ap.parse_args()
415
+
416
+ if not args.tracker.exists():
417
+ print(f"tracker not found: {args.tracker}", file=sys.stderr)
418
+ return 1
419
+
420
+ controls = parse_checklist(args.checklist)
421
+
422
+ auto: list[AutoApplyFinding] = []
423
+ mediated: list[ReviewerMediated] = []
424
+ unparsed: list[str] = []
425
+
426
+ if args.validation:
427
+ v_auto, v_unparsed = load_validation_findings(args.validation)
428
+ auto.extend(v_auto)
429
+ unparsed.extend(v_unparsed)
430
+
431
+ for jp in args.judge:
432
+ j_auto, j_mediated = load_judge_findings(jp)
433
+ auto.extend(j_auto)
434
+ mediated.extend(j_mediated)
435
+
436
+ tracker_text = args.tracker.read_text(encoding="utf-8")
437
+ new_text, changes = reconcile_tracker(tracker_text, auto, controls)
438
+
439
+ output_path = args.output or args.tracker
440
+ if not args.dry_run:
441
+ output_path.write_text(new_text, encoding="utf-8")
442
+
443
+ log_path = args.log or args.tracker.with_name("reconciliation.md")
444
+ write_log(log_path, args.tracker, output_path, auto, mediated, unparsed, changes)
445
+
446
+ print(f"auto-applied: {sum(len(c.flips) for c in changes)} bullet(s) across {len(changes)} control(s)", file=sys.stderr)
447
+ print(f"reviewer-mediated: {len(mediated)} finding(s)", file=sys.stderr)
448
+ print(f"reconciled tracker: {output_path}", file=sys.stderr)
449
+ print(f"log: {log_path}", file=sys.stderr)
450
+ return 0
451
+
452
+
453
+ if __name__ == "__main__":
454
+ sys.exit(main())
@@ -0,0 +1,90 @@
1
+ #!/usr/bin/env python3
2
+ """Tests for validate_tracker.py header/scope parsing.
3
+
4
+ Runs under pytest (``pytest scripts/test_validate_tracker.py``) or directly
5
+ (``python3 scripts/test_validate_tracker.py``). No third-party dependencies.
6
+ """
7
+ import importlib.util
8
+ import pathlib
9
+ import tempfile
10
+
11
+ _SPEC = importlib.util.spec_from_file_location(
12
+ "validate_tracker", pathlib.Path(__file__).with_name("validate_tracker.py")
13
+ )
14
+ v = importlib.util.module_from_spec(_SPEC)
15
+ _SPEC.loader.exec_module(v)
16
+
17
+
18
+ _TRACKER = """# VARS Assessment Tracker
19
+
20
+ - **Archetypes In Scope:** AACQ (22 controls), ATPA (15 controls)
21
+
22
+ | Control ID | Control Title | Archetype | Observations | Issue Description | Control Effectiveness Rating | Proposed Remediation | Complete By | Remediation Status |
23
+ |---|---|---|---|---|---|---|---|---|
24
+ | AACQ.C1.1 | T | AACQ | • Control 1: Covered | | Satisfactory | | | |
25
+ """
26
+
27
+ _CHECKLIST = """### AACQ.C1.1: T
28
+ - **Severity:** mandatory
29
+ - **Verification:**
30
+ - [ ] **(1)** thing
31
+ - **Evidence:**
32
+ - doc
33
+
34
+ ### AHIR.C1.1: Out of scope
35
+ - **Severity:** mandatory
36
+ - **Verification:**
37
+ - [ ] **(1)** thing
38
+ - **Evidence:**
39
+ - doc
40
+ """
41
+
42
+
43
+ def _write(tmp, name, content):
44
+ p = pathlib.Path(tmp) / name
45
+ p.write_text(content, encoding="utf-8")
46
+ return p
47
+
48
+
49
+ def test_annotated_archetype_header_parses_to_bare_codes():
50
+ """'AACQ (22 controls)' must yield the code 'AACQ', not the whole token."""
51
+ phases, archetypes, scope, rows = v.parse_tracker(
52
+ _write(tempfile.mkdtemp(), "tracker.md", _TRACKER)
53
+ )
54
+ assert archetypes == ["AACQ", "ATPA"]
55
+
56
+
57
+ def test_derive_scope_resolves_annotated_header():
58
+ """With cleaned codes, scope includes the annotated archetypes' controls."""
59
+ controls = {"AACQ.C1.1": None, "ATPA.C1.1": None, "AHIR.C1.1": None}
60
+ scope = v.derive_scope(controls, ["AACQ", "ATPA"], None, [])
61
+ assert scope == {"AACQ.C1.1", "ATPA.C1.1"}
62
+
63
+
64
+ def test_annotated_header_does_not_flag_in_scope_rows():
65
+ """End-to-end: an in-scope row is not reported 'outside declared scope'."""
66
+ tmp = tempfile.mkdtemp()
67
+ findings = v.validate(
68
+ _write(tmp, "tracker.md", _TRACKER),
69
+ _write(tmp, "checklist.md", _CHECKLIST),
70
+ None,
71
+ )
72
+ out_of_scope = [
73
+ f for f in findings
74
+ if f["check"] == "row_coverage" and "outside declared scope" in f["message"]
75
+ ]
76
+ assert out_of_scope == [], out_of_scope
77
+
78
+
79
+ def test_bare_archetype_header_still_works():
80
+ tracker = _TRACKER.replace("AACQ (22 controls), ATPA (15 controls)", "AACQ, ATPA")
81
+ _, archetypes, _, _ = v.parse_tracker(_write(tempfile.mkdtemp(), "t.md", tracker))
82
+ assert archetypes == ["AACQ", "ATPA"]
83
+
84
+
85
+ if __name__ == "__main__":
86
+ fns = [f for n, f in sorted(globals().items()) if n.startswith("test_")]
87
+ for fn in fns:
88
+ fn()
89
+ print(f"ok {fn.__name__}")
90
+ print(f"\n{len(fns)} passed")