opencomplai-cli 0.6.0__tar.gz → 0.7.0__tar.gz

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 (54) hide show
  1. {opencomplai_cli-0.6.0 → opencomplai_cli-0.7.0}/.gitignore +2 -0
  2. {opencomplai_cli-0.6.0 → opencomplai_cli-0.7.0}/PKG-INFO +1 -1
  3. {opencomplai_cli-0.6.0 → opencomplai_cli-0.7.0}/pyproject.toml +1 -1
  4. {opencomplai_cli-0.6.0 → opencomplai_cli-0.7.0}/src/opencomplai_cli/__init__.py +1 -1
  5. opencomplai_cli-0.7.0/src/opencomplai_cli/commands/qms.py +177 -0
  6. {opencomplai_cli-0.6.0 → opencomplai_cli-0.7.0}/src/opencomplai_cli/main.py +246 -3
  7. {opencomplai_cli-0.6.0 → opencomplai_cli-0.7.0}/tests/test_ci_integration_guide.py +44 -6
  8. {opencomplai_cli-0.6.0 → opencomplai_cli-0.7.0}/tests/test_docs_generate_wiring.py +68 -1
  9. opencomplai_cli-0.7.0/tests/test_fria_generate.py +153 -0
  10. opencomplai_cli-0.7.0/tests/test_gaps_mapped_column.py +54 -0
  11. opencomplai_cli-0.7.0/tests/test_gaps_nist_rmf_target.py +211 -0
  12. {opencomplai_cli-0.6.0 → opencomplai_cli-0.7.0}/tests/test_publish.py +10 -0
  13. opencomplai_cli-0.7.0/tests/test_qms_generate_cmd.py +96 -0
  14. opencomplai_cli-0.7.0/tests/test_readme_quickstart_pin.py +94 -0
  15. opencomplai_cli-0.7.0/tests/test_recommend_qms_clauses.py +77 -0
  16. {opencomplai_cli-0.6.0 → opencomplai_cli-0.7.0}/README.md +0 -0
  17. {opencomplai_cli-0.6.0 → opencomplai_cli-0.7.0}/src/opencomplai_cli/_encoding.py +0 -0
  18. {opencomplai_cli-0.6.0 → opencomplai_cli-0.7.0}/src/opencomplai_cli/commands/__init__.py +0 -0
  19. {opencomplai_cli-0.6.0 → opencomplai_cli-0.7.0}/src/opencomplai_cli/commands/checker.py +0 -0
  20. {opencomplai_cli-0.6.0 → opencomplai_cli-0.7.0}/src/opencomplai_cli/commands/controls.py +0 -0
  21. {opencomplai_cli-0.6.0 → opencomplai_cli-0.7.0}/src/opencomplai_cli/commands/dashboard.py +0 -0
  22. {opencomplai_cli-0.6.0 → opencomplai_cli-0.7.0}/src/opencomplai_cli/commands/halt.py +0 -0
  23. {opencomplai_cli-0.6.0 → opencomplai_cli-0.7.0}/src/opencomplai_cli/commands/interactive_init.py +0 -0
  24. {opencomplai_cli-0.6.0 → opencomplai_cli-0.7.0}/src/opencomplai_cli/commands/push.py +0 -0
  25. {opencomplai_cli-0.6.0 → opencomplai_cli-0.7.0}/src/opencomplai_cli/commands/serve.py +0 -0
  26. {opencomplai_cli-0.6.0 → opencomplai_cli-0.7.0}/src/opencomplai_cli/connectors/__init__.py +0 -0
  27. {opencomplai_cli-0.6.0 → opencomplai_cli-0.7.0}/src/opencomplai_cli/connectors/github_actions.py +0 -0
  28. {opencomplai_cli-0.6.0 → opencomplai_cli-0.7.0}/src/opencomplai_cli/connectors/gitlab_ci.py +0 -0
  29. {opencomplai_cli-0.6.0 → opencomplai_cli-0.7.0}/src/opencomplai_cli/data/checker-local.html +0 -0
  30. {opencomplai_cli-0.6.0 → opencomplai_cli-0.7.0}/src/opencomplai_cli/exit_codes.py +0 -0
  31. {opencomplai_cli-0.6.0 → opencomplai_cli-0.7.0}/src/opencomplai_cli/oidc_client.py +0 -0
  32. {opencomplai_cli-0.6.0 → opencomplai_cli-0.7.0}/src/opencomplai_cli/publish.py +0 -0
  33. {opencomplai_cli-0.6.0 → opencomplai_cli-0.7.0}/tests/__init__.py +0 -0
  34. {opencomplai_cli-0.6.0 → opencomplai_cli-0.7.0}/tests/fixtures/trap/trap-manifest.json +0 -0
  35. {opencomplai_cli-0.6.0 → opencomplai_cli-0.7.0}/tests/test_ai_preload.py +0 -0
  36. {opencomplai_cli-0.6.0 → opencomplai_cli-0.7.0}/tests/test_checker_cmd.py +0 -0
  37. {opencomplai_cli-0.6.0 → opencomplai_cli-0.7.0}/tests/test_cli.py +0 -0
  38. {opencomplai_cli-0.6.0 → opencomplai_cli-0.7.0}/tests/test_connectors.py +0 -0
  39. {opencomplai_cli-0.6.0 → opencomplai_cli-0.7.0}/tests/test_controls_cmd.py +0 -0
  40. {opencomplai_cli-0.6.0 → opencomplai_cli-0.7.0}/tests/test_controls_sync.py +0 -0
  41. {opencomplai_cli-0.6.0 → opencomplai_cli-0.7.0}/tests/test_dashboard_cmd.py +0 -0
  42. {opencomplai_cli-0.6.0 → opencomplai_cli-0.7.0}/tests/test_docs_generate_push.py +0 -0
  43. {opencomplai_cli-0.6.0 → opencomplai_cli-0.7.0}/tests/test_encoding.py +0 -0
  44. {opencomplai_cli-0.6.0 → opencomplai_cli-0.7.0}/tests/test_eu_ai_scan_golden.py +0 -0
  45. {opencomplai_cli-0.6.0 → opencomplai_cli-0.7.0}/tests/test_evidence_provenance.py +0 -0
  46. {opencomplai_cli-0.6.0 → opencomplai_cli-0.7.0}/tests/test_exit_codes.py +0 -0
  47. {opencomplai_cli-0.6.0 → opencomplai_cli-0.7.0}/tests/test_halt_wire.py +0 -0
  48. {opencomplai_cli-0.6.0 → opencomplai_cli-0.7.0}/tests/test_oidc_client.py +0 -0
  49. {opencomplai_cli-0.6.0 → opencomplai_cli-0.7.0}/tests/test_push_cmd.py +0 -0
  50. {opencomplai_cli-0.6.0 → opencomplai_cli-0.7.0}/tests/test_push_cmd_dossier.py +0 -0
  51. {opencomplai_cli-0.6.0 → opencomplai_cli-0.7.0}/tests/test_readme_command_table.py +0 -0
  52. {opencomplai_cli-0.6.0 → opencomplai_cli-0.7.0}/tests/test_scan_cmd.py +0 -0
  53. {opencomplai_cli-0.6.0 → opencomplai_cli-0.7.0}/tests/test_scan_quick.py +0 -0
  54. {opencomplai_cli-0.6.0 → opencomplai_cli-0.7.0}/tests/test_service_check.py +0 -0
@@ -86,3 +86,5 @@ tmp/
86
86
 
87
87
  # Scratch worktrees created by local agent tooling.
88
88
  .harness-worktrees/
89
+
90
+ .vercel
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: opencomplai-cli
3
- Version: 0.6.0
3
+ Version: 0.7.0
4
4
  Summary: Opencomplai command-line interface for EU AI Act compliance assessment
5
5
  Project-URL: Homepage, https://opencomplai.com
6
6
  Project-URL: Documentation, https://docs.opencomplai.com
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "opencomplai-cli"
3
- version = "0.6.0"
3
+ version = "0.7.0"
4
4
  description = "Opencomplai command-line interface for EU AI Act compliance assessment"
5
5
  readme = "README.md"
6
6
  license = { text = "AGPL-3.0-only" }
@@ -1,6 +1,6 @@
1
1
  """Opencomplai CLI."""
2
2
 
3
- __version__ = "0.6.0"
3
+ __version__ = "0.7.0"
4
4
 
5
5
  # Canonical project metadata — single source of truth for the `--version`
6
6
  # flag and the `opencomplai info` command. Mirrors the [project] table in
@@ -0,0 +1,177 @@
1
+ """
2
+ `opencomplai qms generate` -- CP-15.
3
+
4
+ Renders the Art. 17(1)(a)-(m) quality-management-system document by directly
5
+ reusing CP-7's per-clause probes (`qms_article_17_clause_statuses`, via
6
+ `opencomplai_core.qms_document`) -- never re-derives clause status, so this
7
+ command can't disagree with what `opencomplai recommend`/`opencomplai gaps`
8
+ already report for the same repo.
9
+
10
+ Templated on `commands/controls.py` for the Typer/console/output-format
11
+ conventions; `console`/`err_console` are obtained LAZILY inside the command
12
+ via `from opencomplai_cli import main as _main` (not at module load time)
13
+ because `main.py` imports this module's `app` to register the `qms`
14
+ sub-typer, so a module-level import back into `main` would be circular.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ from datetime import UTC, datetime
21
+ from enum import StrEnum
22
+ from pathlib import Path
23
+
24
+ import typer
25
+ from opencomplai_core.control_catalog import get_catalog
26
+ from opencomplai_core.models import CorroborationReport, EvalReport
27
+ from opencomplai_core.qms_document import (
28
+ build_qms_document,
29
+ render_qms_document_markdown,
30
+ )
31
+ from rich.table import Table
32
+
33
+ app = typer.Typer(help="Art. 17 quality-management-system document commands.")
34
+
35
+
36
+ class OutputFormat(StrEnum):
37
+ """CLI output format -- mirrors `opencomplai_cli.main.OutputFormat`'s values."""
38
+
39
+ human = "human"
40
+ json = "json"
41
+
42
+
43
+ def _evidence_hashes(
44
+ scan_report: CorroborationReport | None, eval_report: EvalReport | None
45
+ ) -> list[str]:
46
+ """SHA-256 evidence-object hashes, sourced exactly like `generate_dossier`
47
+ (doc-generator) does: the scan report's own hash plus each evaluator
48
+ result's evidence hash. No new evidence-vault client -- same inputs
49
+ `opencomplai check --scan`/`--sample-set` already write to disk."""
50
+ hashes: list[str] = []
51
+ if scan_report is not None:
52
+ hashes.append(scan_report.report_hash)
53
+ if eval_report is not None:
54
+ hashes.extend(r.evidence_hash for r in eval_report.results)
55
+ return hashes
56
+
57
+
58
+ @app.command("generate")
59
+ def generate_cmd(
60
+ system_id: str = typer.Option(
61
+ "", "--system-id", help="System identifier (document header only)"
62
+ ),
63
+ commit_ref: str = typer.Option("HEAD", "--commit-ref", help="Git commit reference"),
64
+ repo_root: Path = typer.Option(
65
+ Path("."),
66
+ "--repo-root",
67
+ help="Repository root for the Art. 17(1)(a)-(m) per-clause artifact "
68
+ "probes -- the same probes `opencomplai recommend`/`opencomplai "
69
+ "gaps` use for Art. 17",
70
+ ),
71
+ scan_report_file: Path = typer.Option(
72
+ Path("scan-report.json"),
73
+ "--scan-report",
74
+ help="Optional CorroborationReport JSON (written by `opencomplai "
75
+ "check --scan`); its report_hash is cited as evidence when present. "
76
+ "Absence stays honest -- never fabricated.",
77
+ ),
78
+ eval_report_file: Path = typer.Option(
79
+ Path("eval-report.json"),
80
+ "--eval-report",
81
+ help="Optional EvalReport JSON (written by `opencomplai check "
82
+ "--sample-set ...`); its per-evaluator evidence hashes are cited "
83
+ "when present. Absence stays honest -- never fabricated.",
84
+ ),
85
+ output_file: Path = typer.Option(
86
+ Path("qms-document.md"),
87
+ "--output",
88
+ "-o",
89
+ help="Path to write the rendered Markdown document",
90
+ ),
91
+ output_format: OutputFormat = typer.Option(OutputFormat.human, "--output-format"),
92
+ ) -> None:
93
+ """Generate a filled Art. 17(1)(a)-(m) QMS document: per-clause evidence
94
+ status (present/missing/partial) for each of the 13 sub-points, not a
95
+ single article-level verdict."""
96
+ from opencomplai_cli import main as _main
97
+
98
+ scan_report: CorroborationReport | None = None
99
+ if scan_report_file.exists():
100
+ try:
101
+ scan_report = CorroborationReport.model_validate(
102
+ json.loads(scan_report_file.read_text())
103
+ )
104
+ except Exception as exc:
105
+ _main.err_console.print(
106
+ f"[yellow]Warning:[/yellow] failed to parse {scan_report_file}: {exc}"
107
+ )
108
+
109
+ eval_report: EvalReport | None = None
110
+ if eval_report_file.exists():
111
+ try:
112
+ eval_report = EvalReport.model_validate(
113
+ json.loads(eval_report_file.read_text())
114
+ )
115
+ except Exception as exc:
116
+ _main.err_console.print(
117
+ f"[yellow]Warning:[/yellow] failed to parse {eval_report_file}: {exc}"
118
+ )
119
+
120
+ resolved_repo_root = repo_root.resolve()
121
+ doc = build_qms_document(
122
+ resolved_repo_root,
123
+ system_id=system_id,
124
+ commit_ref=commit_ref,
125
+ generated_at=datetime.now(UTC).isoformat(),
126
+ evidence_hashes=_evidence_hashes(scan_report, eval_report),
127
+ )
128
+
129
+ markdown = render_qms_document_markdown(doc)
130
+ output_file.write_text(markdown, encoding="utf-8")
131
+
132
+ if output_format == OutputFormat.json:
133
+ payload = {
134
+ "system_id": doc.system_id,
135
+ "commit_ref": doc.commit_ref,
136
+ "generated_at": doc.generated_at,
137
+ "clauses": [
138
+ {
139
+ "clause": f"Art. 17(1)({c.letter})",
140
+ "title": c.title,
141
+ "status": c.status.value,
142
+ "status_label": c.status_label,
143
+ "evidence_ref": c.evidence_ref,
144
+ "rationale": c.rationale,
145
+ }
146
+ for c in doc.clauses
147
+ ],
148
+ "present_count": doc.present_count,
149
+ "missing_count": doc.missing_count,
150
+ "unverified_count": doc.unverified_count,
151
+ "evidence_hashes": doc.evidence_hashes,
152
+ "output_file": str(output_file),
153
+ }
154
+ _main.console.print_json(json.dumps(payload))
155
+ return
156
+
157
+ catalog_entry = get_catalog().get("Art. 17")
158
+ title = catalog_entry.title if catalog_entry else "Quality management system"
159
+ _main.console.print(f"\n[bold]Art. 17 -- {title}[/bold]\n")
160
+
161
+ table = Table(show_header=True, header_style="bold")
162
+ table.add_column("#", style="dim")
163
+ table.add_column("QMS element")
164
+ table.add_column("Status", min_width=10)
165
+ table.add_column("Evidence", style="dim")
166
+ for clause in doc.clauses:
167
+ table.add_row(
168
+ f"({clause.letter})", clause.title, clause.status_label, clause.evidence_ref
169
+ )
170
+ _main.console.print(table)
171
+
172
+ summary = f"{doc.present_count} present / {doc.missing_count} missing"
173
+ if doc.unverified_count:
174
+ summary += f" / {doc.unverified_count} unverified"
175
+ _main.console.print(
176
+ f"\n[bold]{summary}[/bold] (of 13 clauses) -- wrote {output_file}\n"
177
+ )
@@ -27,14 +27,18 @@ from typing import TYPE_CHECKING
27
27
  from urllib.parse import urlparse
28
28
 
29
29
  import typer
30
+ from opencomplai_core.compliance_checker.models import ComplianceCheckerResult
30
31
  from opencomplai_core.control_assessment import build_controls_block, derive_controls
31
32
  from opencomplai_core.control_catalog import get_catalog
32
33
  from opencomplai_core.control_identity import fingerprint_manifest
33
34
  from opencomplai_core.engine import assess
34
35
  from opencomplai_core.eval_engine import eval_summary_from_report, run_evals
36
+ from opencomplai_core.fria import generate_fria, render_fria_markdown
37
+ from opencomplai_core.gap_probes import qms_article_17_clause_statuses
35
38
  from opencomplai_core.gap_report import build_gap_report
36
39
  from opencomplai_core.models import (
37
40
  AssessmentInput,
41
+ ComplianceTarget,
38
42
  ControlInstance,
39
43
  ControlState,
40
44
  CorroborationReport,
@@ -53,6 +57,7 @@ from opencomplai_core.models import (
53
57
  SystemManifest,
54
58
  SystemState,
55
59
  )
60
+ from opencomplai_core.nist_rmf_report import build_nist_rmf_report
56
61
  from opencomplai_core.output_envelope import wrap_scan_output
57
62
  from opencomplai_core.principle_report import build_principle_summary
58
63
  from opencomplai_core.recommend_engine import render_recommendations
@@ -102,6 +107,7 @@ app = typer.Typer(
102
107
  )
103
108
  risk_app = typer.Typer(help="Risk classification commands.")
104
109
  docs_app = typer.Typer(help="Documentation generation commands.")
110
+ fria_app = typer.Typer(help="Fundamental rights impact assessment (Art. 27) commands.")
105
111
  sync_app = typer.Typer(help="Metadata sync commands.")
106
112
  keys_app = typer.Typer(
107
113
  help="Signing key management (ISO 27001 A.8.24 / FedRAMP SC-12)."
@@ -121,6 +127,7 @@ from opencomplai_cli.commands.dashboard import app as dashboard_app # noqa: E40
121
127
  from opencomplai_cli.commands.halt import approve_cmd, resume_cmd # noqa: E402
122
128
  from opencomplai_cli.commands.interactive_init import run_interactive_init # noqa: E402
123
129
  from opencomplai_cli.commands.push import run_push, run_push_dossier # noqa: E402
130
+ from opencomplai_cli.commands.qms import app as qms_app # noqa: E402
124
131
  from opencomplai_cli.commands.serve import run_serve # noqa: E402
125
132
 
126
133
  ai_app = typer.Typer(help="AI intent analysis commands.")
@@ -132,11 +139,13 @@ install_ascii_fallback(sys.stdout, sys.stderr)
132
139
 
133
140
  app.add_typer(risk_app, name="risk")
134
141
  app.add_typer(docs_app, name="docs")
142
+ app.add_typer(fria_app, name="fria")
135
143
  app.add_typer(sync_app, name="sync")
136
144
  app.add_typer(dashboard_app, name="dashboard")
137
145
  app.add_typer(keys_app, name="keys")
138
146
  app.add_typer(ai_app, name="ai")
139
147
  app.add_typer(controls_app, name="controls")
148
+ app.add_typer(qms_app, name="qms")
140
149
  # HALT-WIRE: `approve`/`resume` are top-level commands (not a sub-typer),
141
150
  # implemented in commands/halt.py and registered here to avoid a circular
142
151
  # import (halt.py needs `console`/`_emit_event` from this module, lazily).
@@ -1184,6 +1193,15 @@ def gaps_cmd(
1184
1193
  "--repo-root",
1185
1194
  help="Repository root for artifact path probes (Arts. 9/13/14/16/24/43)",
1186
1195
  ),
1196
+ target: ComplianceTarget = typer.Option(
1197
+ ComplianceTarget.EU_AI_ACT,
1198
+ "--target",
1199
+ help=(
1200
+ "EU_AI_ACT (default, evaluated natively) or NIST_AI_RMF "
1201
+ "(evaluated by re-projecting the EU_AI_ACT evidence below through "
1202
+ "data/framework_crosswalk.json — see docs/src/concepts/nist-ai-rmf.md)"
1203
+ ),
1204
+ ),
1187
1205
  output: OutputFormat = typer.Option(OutputFormat.human, "--output", "-o"),
1188
1206
  ) -> None:
1189
1207
  """
@@ -1191,6 +1209,8 @@ def gaps_cmd(
1191
1209
 
1192
1210
  Purely a projection of already-computed rule/obligation/scan/eval results —
1193
1211
  informational only, never gates CI (see `opencomplai check` for the CI gate).
1212
+ `--target NIST_AI_RMF` re-projects the same evidence into a per-subcategory
1213
+ NIST AI RMF 1.0 profile instead — no new scanner or evaluator involved.
1194
1214
  """
1195
1215
  if not manifest_file.exists():
1196
1216
  err_console.print(f"[red]Error:[/red] manifest file not found: {manifest_file}")
@@ -1250,34 +1270,87 @@ def gaps_cmd(
1250
1270
 
1251
1271
  _sync_controls_to_vault(report, manifest, quiet=(output == OutputFormat.json))
1252
1272
 
1273
+ nist_report = (
1274
+ build_nist_rmf_report(report)
1275
+ if target == ComplianceTarget.NIST_AI_RMF
1276
+ else None
1277
+ )
1278
+
1253
1279
  if output == OutputFormat.json:
1280
+ envelope_payload = json.loads(report.model_dump_json())
1281
+ if nist_report is not None:
1282
+ envelope_payload["nist_rmf_report"] = json.loads(
1283
+ nist_report.model_dump_json()
1284
+ )
1254
1285
  envelope = wrap_scan_output(
1255
- json.loads(report.model_dump_json()),
1286
+ envelope_payload,
1256
1287
  scan_errors=[],
1257
1288
  tool_version=__version__,
1258
1289
  )
1259
1290
  console.print_json(envelope.model_dump_json(indent=2))
1260
1291
  return
1261
1292
 
1293
+ if nist_report is not None:
1294
+ console.print(
1295
+ f"\n[bold]Opencomplai NIST AI RMF Gap Report[/bold] — {manifest.system_id}\n"
1296
+ )
1297
+ console.print(
1298
+ "[dim]Re-projected from EU AI Act evidence via the framework crosswalk "
1299
+ "(data/framework_crosswalk.json) — heuristic estimates, not a legal "
1300
+ "determination. See docs/src/concepts/nist-ai-rmf.md for the "
1301
+ "methodology; no new scanner or evaluator backs this.[/dim]\n"
1302
+ )
1303
+ rmf_table = Table(show_header=True, header_style="bold")
1304
+ rmf_table.add_column("Subcategory", style="dim")
1305
+ rmf_table.add_column("Status", min_width=10)
1306
+ rmf_table.add_column("Mapping conf.", style="dim")
1307
+ rmf_table.add_column("From (EU AI Act)", style="dim")
1308
+ rmf_table.add_column("Rationale")
1309
+ for row in nist_report.subcategories:
1310
+ rmf_table.add_row(
1311
+ row.subcategory,
1312
+ _GAP_STATUS_STYLE[row.status],
1313
+ row.mapping_confidence or "—",
1314
+ ", ".join(row.source_eu_ai_act_articles) or "—",
1315
+ row.rationale,
1316
+ )
1317
+ console.print(rmf_table)
1318
+ console.print(
1319
+ "\n[dim]NIST AI RMF report is informational only — it does not gate "
1320
+ "CI. Every row cites the EU AI Act evidence it was derived from; "
1321
+ "UNVERIFIED rows have no framework_crosswalk.json coverage yet.[/dim]\n"
1322
+ )
1323
+ return
1324
+
1262
1325
  console.print(f"\n[bold]Opencomplai Gap Report[/bold] — {manifest.system_id}\n")
1263
1326
  console.print(
1264
1327
  "[dim]Statuses are heuristic estimates — not a legal determination.[/dim]\n"
1265
1328
  )
1266
1329
  table = Table(show_header=True, header_style="bold")
1267
1330
  table.add_column("Article", style="dim")
1331
+ table.add_column("Mapped", style="dim")
1268
1332
  table.add_column("Status", min_width=10)
1269
1333
  table.add_column("Source", style="dim")
1270
1334
  table.add_column("Evidence", style="dim")
1271
1335
  table.add_column("Rationale")
1336
+ catalog = get_catalog()
1272
1337
  for row in report.articles:
1338
+ catalog_entry = catalog.get(row.article)
1339
+ mapped = catalog_entry.iso_42001_clause if catalog_entry else None
1273
1340
  table.add_row(
1274
1341
  row.article,
1342
+ mapped or "—",
1275
1343
  _GAP_STATUS_STYLE[row.status],
1276
1344
  row.source.value,
1277
1345
  row.evidence_ref,
1278
1346
  row.rationale,
1279
1347
  )
1280
1348
  console.print(table)
1349
+ console.print(
1350
+ "[dim]Mapped: ISO/IEC 42001:2023 clause from the framework crosswalk "
1351
+ "(data/framework_crosswalk.json) — a citation, not a computed "
1352
+ "conformity verdict.[/dim]\n"
1353
+ )
1281
1354
 
1282
1355
  console.print("\n[bold]Principle Summary[/bold]\n")
1283
1356
  principle_table = Table(show_header=True, header_style="bold")
@@ -1326,6 +1399,12 @@ def recommend_cmd(
1326
1399
  "-o",
1327
1400
  help="Directory to write remediation templates to",
1328
1401
  ),
1402
+ repo_root: Path = typer.Option(
1403
+ Path("."),
1404
+ "--repo-root",
1405
+ help="Repository root for artifact path probes (Arts. 9/13/14/16/17/24/43), "
1406
+ "including the Art. 17 QMS per-clause breakdown",
1407
+ ),
1329
1408
  ) -> None:
1330
1409
  """
1331
1410
  Write copy-paste remediation templates for every Missing/Partial gap-report row.
@@ -1391,9 +1470,11 @@ def recommend_cmd(
1391
1470
  risk_result=risk_result,
1392
1471
  corroboration_report=corroboration_report,
1393
1472
  eval_report=eval_report,
1473
+ repo_root=repo_root.resolve(),
1394
1474
  )
1395
1475
 
1396
- written = render_recommendations(report, output_dir)
1476
+ resolved_repo_root = repo_root.resolve()
1477
+ written = render_recommendations(report, output_dir, repo_root=resolved_repo_root)
1397
1478
 
1398
1479
  if not written:
1399
1480
  console.print(
@@ -1407,6 +1488,140 @@ def recommend_cmd(
1407
1488
  for path in written:
1408
1489
  console.print(f" {path}")
1409
1490
 
1491
+ if any(row.article == "Art. 17" for row in report.articles):
1492
+ qms_rows = qms_article_17_clause_statuses(resolved_repo_root)
1493
+ present = sum(1 for r in qms_rows if r.status == GapStatus.PARTIAL)
1494
+ missing = sum(1 for r in qms_rows if r.status == GapStatus.MISSING)
1495
+ console.print(
1496
+ f"\n[bold]Art. 17 QMS per-clause:[/bold] {present}/13 present, "
1497
+ f"{missing}/13 missing (see the qms_outline.md written above for detail)",
1498
+ soft_wrap=True,
1499
+ )
1500
+
1501
+
1502
+ @fria_app.command("generate")
1503
+ def fria_generate_cmd(
1504
+ manifest_file: Path = typer.Option(
1505
+ Path("system-manifest.json"),
1506
+ "--manifest",
1507
+ "-m",
1508
+ help="Path to system manifest JSON file",
1509
+ ),
1510
+ commit_ref: str = typer.Option("HEAD", "--commit-ref", help="Git commit reference"),
1511
+ checker_report_file: Path | None = typer.Option(
1512
+ None,
1513
+ "--checker-report",
1514
+ help=(
1515
+ "Path to a checker JSON report (written by `opencomplai checker "
1516
+ "--export-json ...`). Defaults to the manifest's own "
1517
+ "checker_session.report_json_path when set. When neither is "
1518
+ "available, Art. 27(1)(c) stays honestly unpopulated."
1519
+ ),
1520
+ ),
1521
+ repo_root: Path = typer.Option(
1522
+ Path("."),
1523
+ "--repo-root",
1524
+ help="Repository root for the Art. 27 control-register probe (provider_fria)",
1525
+ ),
1526
+ output_dir: Path = typer.Option(Path("./fria"), "--output-dir"),
1527
+ output: OutputFormat = typer.Option(OutputFormat.human, "--output", "-o"),
1528
+ ) -> None:
1529
+ """Draft an Art. 27 fundamental rights impact assessment (D-1, CP-14).
1530
+
1531
+ Populates Art. 27(1)(a)-(f) from the system manifest, the checker's own
1532
+ persisted session/obligations when one was run, and the Art. 27 row
1533
+ `opencomplai gaps` already computes (CP-6's control-register wiring) --
1534
+ not just CP-6's fill-in-only template. A point with no real data behind
1535
+ it is labelled "not captured", never fabricated.
1536
+ """
1537
+ if not manifest_file.exists():
1538
+ err_console.print(f"[red]Error:[/red] manifest file not found: {manifest_file}")
1539
+ err_console.print("Run [bold]opencomplai init[/bold] first.")
1540
+ sys.exit(2)
1541
+ try:
1542
+ manifest = SystemManifest.model_validate(json.loads(manifest_file.read_text()))
1543
+ except Exception as e:
1544
+ err_console.print(f"[red]Validation error:[/red] {e}")
1545
+ sys.exit(2)
1546
+
1547
+ # The checker persists its own session/obligations as a plain
1548
+ # ComplianceCheckerResult JSON export (opencomplai checker --export-json,
1549
+ # or the file `opencomplai init --interactive` writes and references via
1550
+ # manifest.checker_session.report_json_path) -- read it the same way,
1551
+ # no new storage mechanism.
1552
+ resolved_checker_path = checker_report_file
1553
+ if resolved_checker_path is None and manifest.checker_session is not None:
1554
+ candidate = manifest.checker_session.report_json_path
1555
+ if candidate:
1556
+ resolved_checker_path = Path(candidate)
1557
+
1558
+ checker_result: ComplianceCheckerResult | None = None
1559
+ if resolved_checker_path is not None:
1560
+ if resolved_checker_path.exists():
1561
+ try:
1562
+ checker_result = ComplianceCheckerResult.model_validate_json(
1563
+ resolved_checker_path.read_text(encoding="utf-8")
1564
+ )
1565
+ except Exception as exc:
1566
+ err_console.print(
1567
+ f"[yellow]Warning:[/yellow] failed to parse checker report "
1568
+ f"{resolved_checker_path}: {exc}"
1569
+ )
1570
+ else:
1571
+ console.print(
1572
+ f"[dim]No checker report found at {resolved_checker_path} -- "
1573
+ "Art. 27(1)(c) stays unpopulated (run 'opencomplai checker' "
1574
+ "first, or pass --checker-report).[/dim]"
1575
+ )
1576
+
1577
+ # Control register: the Art. 27 row CP-6 already wires into gaps.json
1578
+ # (obligation 'fria' + artifact probe 'provider_fria', worst-status-wins).
1579
+ resolved_repo_root = repo_root.resolve()
1580
+ assessment_input = AssessmentInput(
1581
+ model=ModelMetadata(
1582
+ name=manifest.system_id,
1583
+ version=commit_ref,
1584
+ modality="text",
1585
+ use_case=manifest.intended_purpose,
1586
+ deployment_context="local",
1587
+ )
1588
+ )
1589
+ risk_result = assess(assessment_input)
1590
+ report = build_gap_report(
1591
+ system_id=manifest.system_id,
1592
+ commit_ref=commit_ref,
1593
+ risk_result=risk_result,
1594
+ repo_root=resolved_repo_root,
1595
+ )
1596
+ gap_row = next((r for r in report.articles if r.article == "Art. 27"), None)
1597
+
1598
+ document = generate_fria(manifest, gap_row=gap_row, checker_result=checker_result)
1599
+
1600
+ output_dir.mkdir(parents=True, exist_ok=True)
1601
+ json_path = output_dir / f"fria_{document.fria_id}.json"
1602
+ md_path = output_dir / f"fria_{document.fria_id}.md"
1603
+ json_path.write_text(document.model_dump_json(indent=2), encoding="utf-8")
1604
+ md_path.write_text(render_fria_markdown(document), encoding="utf-8")
1605
+
1606
+ if output == OutputFormat.json:
1607
+ console.print_json(document.model_dump_json(indent=2))
1608
+ return
1609
+
1610
+ console.print("\n[bold]Fundamental Rights Impact Assessment Draft (Art. 27)[/bold]")
1611
+ console.print(f" fria_id: {document.fria_id}")
1612
+ console.print(
1613
+ f" populated: {document.populated_point_count}/{document.total_points} points"
1614
+ )
1615
+ console.print(f" json: {json_path}")
1616
+ console.print(f" markdown: {md_path}")
1617
+ for point in document.points:
1618
+ marker = (
1619
+ "[green]populated[/green]"
1620
+ if point.populated
1621
+ else "[yellow]not captured[/yellow]"
1622
+ )
1623
+ console.print(f" ({point.point}) {marker} -- source: {point.source}")
1624
+
1410
1625
 
1411
1626
  @app.command("report")
1412
1627
  def report_cmd(
@@ -2591,8 +2806,17 @@ def check_cmd(
2591
2806
  if derived_controls is not None
2592
2807
  else None
2593
2808
  )
2809
+ nist_rmf_report = (
2810
+ build_nist_rmf_report(gap_report)
2811
+ if manifest.compliance_target == ComplianceTarget.NIST_AI_RMF
2812
+ else None
2813
+ )
2594
2814
  artifact = artifact.model_copy(
2595
- update={"gap_report": gap_report, "controls": controls_block}
2815
+ update={
2816
+ "gap_report": gap_report,
2817
+ "controls": controls_block,
2818
+ "nist_rmf_report": nist_rmf_report,
2819
+ }
2596
2820
  )
2597
2821
 
2598
2822
  # Write artifact to disk for CI consumption
@@ -3405,6 +3629,18 @@ def docs_generate_cmd(
3405
3629
  "see module notes below."
3406
3630
  ),
3407
3631
  ),
3632
+ allow_incomplete: bool = typer.Option(
3633
+ False,
3634
+ "--allow-incomplete",
3635
+ help=(
3636
+ "Exit 0 even when the generated dossier fails Annex IV schema "
3637
+ "validation. Off by default: an invalid dossier is still "
3638
+ "written to disk (or persisted server-side in service-backed "
3639
+ "mode) so an auditor can see what failed, but the process exits "
3640
+ "2 (VALIDATION_FAIL) to fail CI closed. Pass this flag to "
3641
+ "restore the old always-exit-0 behaviour."
3642
+ ),
3643
+ ),
3408
3644
  ) -> None:
3409
3645
  """Generate an Annex IV technical documentation dossier (REQ-DOC-001)."""
3410
3646
  # HALT-WIRE / E-12(d): a HALTED_PENDING_REVIEW system refuses dossier
@@ -3477,6 +3713,7 @@ def docs_generate_cmd(
3477
3713
  "commit_ref": commit_ref,
3478
3714
  "intended_purpose": intended_purpose,
3479
3715
  "provider_name": provider_name,
3716
+ "allow_incomplete": allow_incomplete,
3480
3717
  }
3481
3718
  if loaded_manifest is not None:
3482
3719
  payload.update(
@@ -3622,6 +3859,12 @@ def docs_generate_cmd(
3622
3859
  dossier, repo_dir=out_file.resolve().parent
3623
3860
  ):
3624
3861
  sys.exit(3)
3862
+ # Fail-closed dossier gate (D-2): the file above is always written
3863
+ # first so an auditor can see what failed, but an invalid dossier
3864
+ # must not report success unless the caller explicitly opted into
3865
+ # the old always-exit-0 behaviour via --allow-incomplete.
3866
+ if not schema_valid and not allow_incomplete:
3867
+ sys.exit(HARD_FAIL_EXIT_CODES[ScanResult.VALIDATION_FAIL])
3625
3868
  sys.exit(0)
3626
3869
  except Exception as exc:
3627
3870
  err_console.print(f"[red]Dossier generation failed:[/red] {exc}")