Skill Tracer
Skills › Scientific Agent Skills › iso-standards-readiness › gap_analyzer.py

gap_analyzer.py

177 lines · skills/iso-standards-readiness/scripts/gap_analyzer.py at 5f2bc03

1#!/usr/bin/env python3
2"""Create a fail-closed QMS evidence gap report from a local manifest.
3
4The analyzer uses explicit, human-authored domain labels. It does not infer
5conformity from filenames or keywords and never reports a compliance percentage.
6"""
7
8from __future__ import annotations
9
10import argparse
11import re
12from collections import Counter
13from typing import Any
15from _catalog import StandardProfile
16from _common import (
17 InputError,
18 build_report,
19 emit_report,
20 guarded_main,
21 load_json,
22 profile_for,
23 require_root_object,
24 standard_parser,
26from validate_evidence_manifest import validate as validate_manifest
29def _entry_ready(entry: dict[str, Any]) -> bool:
30 status = entry.get("status")
31 approval = entry.get("approval")
32 evidence = entry.get("evidence")
33 sources = entry.get("source_refs")
34 return (
35 isinstance(status, str)
36 and status in {"approved", "implemented", "verified", "closed"}
37 and isinstance(approval, dict)
38 and approval.get("status") == "approved"
39 and isinstance(evidence, list)
40 and bool(evidence)
41 and isinstance(sources, list)
42 and bool(sources)
43 )
46def analyze(
47 data: dict[str, Any],
48 *,
49 base_dir: str | None,
50 verify_files: bool,
51 profile: StandardProfile,
52) -> tuple[dict[str, Any], int]:
53 review, manifest_metrics = validate_manifest(
54 data,
55 base_dir=base_dir,
56 verify_files=verify_files,
57 profile=profile,
58 )
59 expected_raw = data.get("expected_domains")
60 expected = {
61 item
62 for item in expected_raw
63 if isinstance(item, str) and item in profile.process_domains
64 } if isinstance(expected_raw, list) else set()
66 entries_raw = data.get("entries")
67 entries = entries_raw if isinstance(entries_raw, list) else []
68 invalid_entries = {
69 int(match.group(1))
70 for finding in review.findings
71 if (match := re.match(r"entries\[(\d+)\](?:\.|$)", finding.path))
72 }
73 by_domain: dict[str, list[tuple[int, dict[str, Any]]]] = {
74 domain: [] for domain in profile.process_domains
75 }
76 for index, item in enumerate(entries):
77 if not isinstance(item, dict):
78 continue
79 domain = item.get("domain")
80 if isinstance(domain, str) and domain in by_domain:
81 by_domain[domain].append((index, item))
83 domain_results: list[dict[str, Any]] = []
84 statuses: Counter[str] = Counter()
85 for domain in profile.process_domains:
86 domain_entries = by_domain[domain]
87 if domain not in expected:
88 status = "not-assessed"
89 explanation = (
90 "Domain was not declared in expected_domains; this is not a "
91 "not-applicable determination."
92 )
93 elif not domain_entries:
94 status = "evidence-missing"
95 explanation = "No manifest entry was supplied for this expected domain."
96 elif all(
97 index not in invalid_entries and _entry_ready(entry)
98 for index, entry in domain_entries
99 ):
100 status = "evidence-present-for-human-review"
101 explanation = (
102 "Submitted entry metadata passed the requested structural checks; "
103 "substantive adequacy remains for authorized human review."
104 )
105 else:
106 status = "evidence-incomplete"
107 explanation = (
108 "At least one submitted entry has a structural finding, is draft, "
109 "unapproved, unsourced, or missing evidence."
110 )
111 statuses[status] += 1
112 domain_results.append(
113 {
114 "domain": domain,
115 "entry_ids": sorted(
116 str(entry.get("id"))
117 for _, entry in domain_entries
118 if isinstance(entry.get("id"), str)
119 ),
120 "explanation": explanation,
121 "status": status,
122 }
123 )
125 report = build_report(
126 "gap_analyzer",
127 review.findings,
128 metrics={
129 **manifest_metrics,
130 "domain_status_counts": dict(sorted(statuses.items())),
131 },
132 standard=profile.key,
133 )
134 report["domains"] = domain_results
135 report["method"] = (
136 "Explicit manifest coverage only; no filename/keyword scoring, clause-text "
137 "reproduction, compliance percentage, or automated applicability decision."
138 )
139 return report, 1 if review.findings else 0
142def main() -> int:
143 parser = standard_parser(
144 "Create a fail-closed evidence gap report from a local readiness manifest.",
145 "Path to the local evidence-manifest JSON file",
146 with_standard=True,
147 )
148 parser.add_argument(
149 "--base-dir",
150 help="Base directory for contained relative evidence paths",
151 )
152 parser.add_argument(
153 "--verify-files",
154 action="store_true",
155 help="Verify bounded local JSON/Markdown evidence paths and optional hashes",
156 )
157 args: argparse.Namespace = parser.parse_args()
158 if args.verify_files and not args.base_dir:
159 raise InputError("--verify-files requires --base-dir")
160 data = require_root_object(load_json(args.input))
161 report, code = analyze(
162 data,
163 base_dir=args.base_dir,
164 verify_files=args.verify_files,
165 profile=profile_for(args),
166 )
167 emit_report(
168 report,
169 args.output,
170 force=args.force,
171 compact=args.compact,
172 )
173 return code
176if __name__ == "__main__":
177 guarded_main(main)