@1aboveio/skills 0.20.1 → 0.20.3
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 +12 -5
- package/package.json +1 -1
- package/runtime/skills/distribution/generated/recipes.json +220 -25
- package/runtime/skills/distribution/scripts/bundles.mjs +49 -5
- 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/cicd-pipeline/cloud-build/SKILL.md +6 -6
- 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 +17 -17
- package/skills/engineering/engineering-runtime/scripts/workflow-coherence.mjs +4 -0
- package/skills/engineering/engineering-runtime/scripts/workflow-policy.mjs +1 -1
- package/skills/engineering/rush-release/SKILL.md +46 -25
- package/skills/engineering/rush-release/references/cut.md +21 -4
- package/skills/engineering/rush-release/references/preflight.md +25 -6
- package/skills/engineering/rush-release/references/promotion.md +70 -0
- package/skills/engineering/rush-release/references/publish.md +49 -16
- package/skills/engineering/rush-release/scripts/plan.mjs +33 -7
- package/skills/engineering/smoke/SKILL.md +4 -4
- package/skills/engineering/smoke/references/manifest.md +3 -3
- 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 -44
- 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,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")
|