docassert 0.1.0__py3-none-any.whl

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 (86) hide show
  1. docassert/__init__.py +8 -0
  2. docassert/__main__.py +6 -0
  3. docassert/_data/consistency.yaml +51 -0
  4. docassert/_data/criteria/adr.criteria.yaml +36 -0
  5. docassert/_data/criteria/benefits-realization.criteria.yaml +30 -0
  6. docassert/_data/criteria/brd.criteria.yaml +30 -0
  7. docassert/_data/criteria/business-case.criteria.yaml +23 -0
  8. docassert/_data/criteria/charter.criteria.yaml +73 -0
  9. docassert/_data/criteria/data-migration-plan.criteria.yaml +28 -0
  10. docassert/_data/criteria/frnfr.criteria.yaml +31 -0
  11. docassert/_data/criteria/hypercare-plan.criteria.yaml +27 -0
  12. docassert/_data/criteria/post-implementation-review.criteria.yaml +24 -0
  13. docassert/_data/criteria/prd.criteria.yaml +31 -0
  14. docassert/_data/criteria/project.criteria.yaml +32 -0
  15. docassert/_data/criteria/qa-test-plan.criteria.yaml +27 -0
  16. docassert/_data/criteria/raci-stakeholder.criteria.yaml +24 -0
  17. docassert/_data/criteria/release-cutover-plan.criteria.yaml +30 -0
  18. docassert/_data/criteria/risk-register.criteria.yaml +32 -0
  19. docassert/_data/criteria/rollback-plan.criteria.yaml +29 -0
  20. docassert/_data/criteria/runbook.criteria.yaml +30 -0
  21. docassert/_data/criteria/status-report.criteria.yaml +26 -0
  22. docassert/_data/criteria/test-cases.criteria.yaml +28 -0
  23. docassert/_data/criteria/user-story.criteria.yaml +32 -0
  24. docassert/_data/profiles/agile-delivery.yaml +20 -0
  25. docassert/_data/profiles/lean-startup.yaml +19 -0
  26. docassert/_data/profiles/regulated-industry.yaml +31 -0
  27. docassert/_data/schema/adr.schema.json +45 -0
  28. docassert/_data/schema/benefits-realization.schema.json +45 -0
  29. docassert/_data/schema/brd.schema.json +45 -0
  30. docassert/_data/schema/business-case.schema.json +45 -0
  31. docassert/_data/schema/charter.schema.json +84 -0
  32. docassert/_data/schema/data-migration-plan.schema.json +45 -0
  33. docassert/_data/schema/frnfr.schema.json +45 -0
  34. docassert/_data/schema/hypercare-plan.schema.json +45 -0
  35. docassert/_data/schema/post-implementation-review.schema.json +45 -0
  36. docassert/_data/schema/prd.schema.json +45 -0
  37. docassert/_data/schema/project.schema.json +32 -0
  38. docassert/_data/schema/qa-test-plan.schema.json +45 -0
  39. docassert/_data/schema/raci-stakeholder.schema.json +45 -0
  40. docassert/_data/schema/release-cutover-plan.schema.json +45 -0
  41. docassert/_data/schema/risk-register.schema.json +45 -0
  42. docassert/_data/schema/rollback-plan.schema.json +45 -0
  43. docassert/_data/schema/runbook.schema.json +45 -0
  44. docassert/_data/schema/status-report.schema.json +58 -0
  45. docassert/_data/schema/test-cases.schema.json +45 -0
  46. docassert/_data/schema/user-story.schema.json +45 -0
  47. docassert/_data/templates/adr.template.md +17 -0
  48. docassert/_data/templates/benefits-realization.template.md +25 -0
  49. docassert/_data/templates/brd.template.md +22 -0
  50. docassert/_data/templates/business-case.template.md +27 -0
  51. docassert/_data/templates/charter.template.md +46 -0
  52. docassert/_data/templates/data-migration-plan.template.md +35 -0
  53. docassert/_data/templates/frnfr.template.md +19 -0
  54. docassert/_data/templates/hypercare-plan.template.md +29 -0
  55. docassert/_data/templates/post-implementation-review.template.md +31 -0
  56. docassert/_data/templates/prd.template.md +23 -0
  57. docassert/_data/templates/project.template.md +17 -0
  58. docassert/_data/templates/qa-test-plan.template.md +31 -0
  59. docassert/_data/templates/raci-stakeholder.template.md +21 -0
  60. docassert/_data/templates/release-cutover-plan.template.md +28 -0
  61. docassert/_data/templates/risk-register.template.md +18 -0
  62. docassert/_data/templates/rollback-plan.template.md +24 -0
  63. docassert/_data/templates/runbook.template.md +28 -0
  64. docassert/_data/templates/status-report.template.md +27 -0
  65. docassert/_data/templates/test-cases.template.md +17 -0
  66. docassert/_data/templates/user-story.template.md +17 -0
  67. docassert/cli.py +291 -0
  68. docassert/config.py +104 -0
  69. docassert/consistency.py +167 -0
  70. docassert/graph.py +68 -0
  71. docassert/loader.py +116 -0
  72. docassert/models.py +99 -0
  73. docassert/profiles.py +111 -0
  74. docassert/projects.py +49 -0
  75. docassert/report.py +83 -0
  76. docassert/rtm.py +70 -0
  77. docassert/semantic.py +124 -0
  78. docassert/status.py +538 -0
  79. docassert/structural.py +406 -0
  80. docassert-0.1.0.dist-info/METADATA +125 -0
  81. docassert-0.1.0.dist-info/RECORD +86 -0
  82. docassert-0.1.0.dist-info/WHEEL +5 -0
  83. docassert-0.1.0.dist-info/entry_points.txt +2 -0
  84. docassert-0.1.0.dist-info/licenses/LICENSE +201 -0
  85. docassert-0.1.0.dist-info/licenses/NOTICE +4 -0
  86. docassert-0.1.0.dist-info/top_level.txt +1 -0
docassert/status.py ADDED
@@ -0,0 +1,538 @@
1
+ """Derive a project status view from the documents.
2
+
3
+ Nothing here is authored: every number comes from the actual files and the
4
+ traceability graph. This is "derived status over self-reported status" — the
5
+ page computes its own RAG from real signals rather than trusting a typed field.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from pathlib import Path
11
+
12
+ from . import config as config_mod
13
+ from . import profiles as profiles_mod
14
+ from .graph import build_graph
15
+ from .loader import load
16
+ from .structural import _field_value, run_structural
17
+
18
+ DOCUMENTS_DIR = Path("documents")
19
+ APPROVED = {"approved", "baselined"}
20
+ _SEVERITY = {"low": 1, "medium": 2, "high": 3, "critical": 4}
21
+
22
+
23
+ # ── per-document validity (blocking structural checks only) ─────────────────
24
+ def _doc_passes(doc, id_index) -> bool:
25
+ kind = doc.kind or ""
26
+ if not config_mod.criteria_exists(kind):
27
+ return True
28
+ criteria = config_mod.read_criteria(kind)
29
+ try:
30
+ schema = config_mod.read_schema(kind)
31
+ except FileNotFoundError:
32
+ schema = {}
33
+ ctx = {
34
+ "schema": schema,
35
+ "required_sections": criteria.get("required_sections", []),
36
+ "item_sections": criteria.get("item_sections", []),
37
+ "steps_sections": criteria.get("steps_sections", []),
38
+ "measurable_sections": criteria.get("measurable_sections", []),
39
+ "id_index": id_index,
40
+ }
41
+ for spec in criteria.get("checks", []):
42
+ if spec.get("type") == "structural":
43
+ if run_structural(doc, spec, ctx).is_blocking_failure:
44
+ return False
45
+ return True
46
+
47
+
48
+ # ── signal extractors ───────────────────────────────────────────────────────
49
+ # `code` optionally scopes a signal to one project (by item project code, e.g.
50
+ # "AUR"). The graph itself stays global so cross-project link targets resolve.
51
+ def _coverage(graph, config, code=None):
52
+ out = []
53
+ for rule in config.get("coverage", []):
54
+ parent_prefix, relation = rule["parent"], rule["relation"]
55
+ by_prefix = rule.get("by_prefix")
56
+ parents = graph.by_type.get(parent_prefix, [])
57
+ if code:
58
+ parents = [p for p in parents if p.project == code]
59
+ covered = [p for p in parents if graph.children(p.id, relation, by_prefix)]
60
+ out.append({
61
+ "label": rule.get("label", f"{parent_prefix} → {by_prefix}"),
62
+ "covered": len(covered),
63
+ "total": len(parents),
64
+ "gaps": [p.id for p in parents if p not in covered],
65
+ })
66
+ return out
67
+
68
+
69
+ def _risks(graph, code=None):
70
+ risks = []
71
+ for item in graph.by_type.get("RISK", []):
72
+ if code and item.project != code:
73
+ continue
74
+ prob = (_field_value(item.text, "probability") or "").lower()
75
+ impact = (_field_value(item.text, "impact") or "").lower()
76
+ risks.append({
77
+ "id": item.id,
78
+ "probability": prob or "?",
79
+ "impact": impact or "?",
80
+ "owner": _field_value(item.text, "owner") or "?",
81
+ "score": _SEVERITY.get(prob, 0) * _SEVERITY.get(impact, 0),
82
+ })
83
+ return sorted(risks, key=lambda r: -r["score"])
84
+
85
+
86
+ def _broken_references(graph, code=None):
87
+ broken = []
88
+ for item in graph.all_items():
89
+ if code and item.project != code:
90
+ continue
91
+ for relation, targets in item.links.items():
92
+ for target in targets:
93
+ if not graph.exists(target):
94
+ broken.append(f"{item.id} —{relation}→ {target}")
95
+ return broken
96
+
97
+
98
+ def _latest_report(docs):
99
+ reports = [d for d in docs if d.kind == "status-report"]
100
+ if not reports:
101
+ return None
102
+ reports.sort(key=lambda d: str(d.frontmatter.get("period", "")), reverse=True)
103
+ top = reports[0]
104
+ return {
105
+ "id": top.id,
106
+ "period": str(top.frontmatter.get("period", "")),
107
+ "rag": str(top.frontmatter.get("rag", "")).lower(),
108
+ }
109
+
110
+
111
+ # ── the model + derived RAG ─────────────────────────────────────────────────
112
+ def build_status(documents_dir=DOCUMENTS_DIR, project: str | None = None) -> dict:
113
+ """Derive the status model for the whole repo, or for one project.
114
+
115
+ `project` is a canonical project id (PRJ-NNN-CODE). When given, documents,
116
+ coverage, risks, broken references and the latest report are all scoped to
117
+ that project; the graph stays global so cross-project targets still resolve.
118
+ """
119
+ all_docs = [load(p) for p in sorted(Path(documents_dir).rglob("*.md"))]
120
+ graph = build_graph(documents_dir)
121
+ cfg = config_mod.read_consistency_config()
122
+
123
+ code = project.split("-")[-1] if project else None
124
+ if project:
125
+ docs = [d for d in all_docs
126
+ if d.frontmatter.get("project") == project
127
+ or (d.kind == "project" and d.id == project)]
128
+ else:
129
+ docs = all_docs
130
+
131
+ id_index = {}
132
+ for d in all_docs: # uniqueness is always global
133
+ id_index.setdefault(d.id, []).append(d.path)
134
+
135
+ documents = [{
136
+ "kind": d.kind,
137
+ "id": d.id,
138
+ "title": d.frontmatter.get("title", d.id),
139
+ "status": str(d.frontmatter.get("status", "draft")),
140
+ "passing": _doc_passes(d, id_index),
141
+ } for d in docs]
142
+
143
+ completeness = None
144
+ if project:
145
+ anchor = next((d for d in docs if d.kind == "project"), None)
146
+ title = str(anchor.frontmatter.get("name", project)) if anchor else project
147
+ if anchor is not None:
148
+ completeness = _completeness_for(anchor, documents)
149
+ else:
150
+ title = "Project Status"
151
+
152
+ model = {
153
+ "project": project,
154
+ "title": title,
155
+ "documents": documents,
156
+ "counts": {
157
+ "total": len(documents),
158
+ "kinds": len({d["kind"] for d in documents}),
159
+ "approved": sum(1 for d in documents if d["status"] in APPROVED),
160
+ "failing": sum(1 for d in documents if not d["passing"]),
161
+ },
162
+ "coverage": _coverage(graph, cfg, code),
163
+ "risks": _risks(graph, code),
164
+ "broken_references": _broken_references(graph, code),
165
+ "latest_report": _latest_report(docs),
166
+ "completeness": completeness,
167
+ }
168
+ model["rag"] = derive_rag(model)
169
+ return model
170
+
171
+
172
+ def _completeness_for(anchor, documents: list[dict]) -> dict | None:
173
+ """Assess a project's documents against the profile its anchor declares."""
174
+ prof_name = anchor.frontmatter.get("profile")
175
+ if not prof_name:
176
+ return None
177
+ profile = profiles_mod.load_profile(prof_name)
178
+ if profile is None:
179
+ return profiles_mod.unknown(prof_name)
180
+ return profiles_mod.completeness(
181
+ profile, documents, str(anchor.frontmatter.get("status", "")))
182
+
183
+
184
+ def completeness_report(documents_dir=DOCUMENTS_DIR) -> list[dict]:
185
+ """Per-project completeness for every profiled project (used by the
186
+ blocking profile-completeness consistency check)."""
187
+ from . import projects as projects_mod
188
+ out = []
189
+ for p in projects_mod.load_projects(documents_dir):
190
+ comp = build_status(documents_dir, project=p["id"]).get("completeness")
191
+ if comp:
192
+ out.append({"id": p["id"], "name": p["name"], "lifecycle": p["status"], **comp})
193
+ return out
194
+
195
+
196
+ def build_index(documents_dir=DOCUMENTS_DIR) -> dict:
197
+ """The multi-project view: each project's derived RAG + headline signals,
198
+ plus the whole-repo rollup."""
199
+ from . import projects as projects_mod
200
+ cards = []
201
+ for p in projects_mod.load_projects(documents_dir):
202
+ m = build_status(documents_dir, project=p["id"])
203
+ cards.append({
204
+ "id": p["id"], "code": p["code"], "name": p["name"],
205
+ "sponsor": p["sponsor"], "lifecycle": p["status"],
206
+ "rag": m["rag"],
207
+ "total": m["counts"]["total"],
208
+ "failing": m["counts"]["failing"],
209
+ "risks": len(m["risks"]),
210
+ "coverage_gaps": sum(len(c["gaps"]) for c in m["coverage"]),
211
+ "broken": len(m["broken_references"]),
212
+ "completeness": m.get("completeness"),
213
+ })
214
+ return {"projects": cards, "overall": build_status(documents_dir)}
215
+
216
+
217
+ def derive_rag(model) -> str:
218
+ """Red = something is objectively broken. Amber = carrying risk or
219
+ incompleteness. Green = clean."""
220
+ comp = model.get("completeness")
221
+ approved_failing = any(not d["passing"] for d in model["documents"]
222
+ if d["status"] in APPROVED)
223
+ if approved_failing or model["broken_references"] or (comp and comp["blocks"]):
224
+ return "red"
225
+ coverage_gap = any(c["gaps"] for c in model["coverage"])
226
+ reported = (model["latest_report"] or {}).get("rag")
227
+ completeness_gap = bool(comp and (comp["missing_required"] or comp["incomplete_required"]
228
+ or comp["recommended_gaps"] or comp.get("unknown")))
229
+ if coverage_gap or model["risks"] or reported in {"amber", "red"} or completeness_gap:
230
+ return "amber"
231
+ return "green"
232
+
233
+
234
+ # ── renderers ───────────────────────────────────────────────────────────────
235
+ _EMOJI = {"green": "🟢", "amber": "🟠", "red": "🔴"}
236
+
237
+
238
+ def render_markdown(model, summary: bool = False) -> str:
239
+ rag = model["rag"]
240
+ c = model["counts"]
241
+ title = model.get("title", "Project Status")
242
+ heading = ("## " if summary else "# ") + title
243
+ out = [f"{heading} — {_EMOJI[rag]} {rag.upper()}", ""]
244
+ out.append(f"_Derived from {c['total']} documents across {c['kinds']} kinds "
245
+ f"({c['approved']} approved). Generated by `docassert status` — do not edit._")
246
+ out += ["", "## Signals", ""]
247
+
248
+ for cov in model["coverage"]:
249
+ pct = round(100 * cov["covered"] / cov["total"]) if cov["total"] else 100
250
+ mark = "🟢" if not cov["gaps"] else "🟠"
251
+ detail = f" — gaps: {', '.join(cov['gaps'])}" if cov["gaps"] else ""
252
+ out.append(f"- {mark} **{cov['label']}**: {cov['covered']}/{cov['total']} ({pct}%){detail}")
253
+
254
+ comp = model.get("completeness")
255
+ if comp and comp.get("unknown"):
256
+ out.append(f"- 🟠 **Profile** `{comp['profile']}` not found under profiles/")
257
+ elif comp:
258
+ mark = "🔴" if comp["blocks"] else (
259
+ "🟠" if (comp["missing_required"] or comp["incomplete_required"]) else "🟢")
260
+ if comp["missing_required"]:
261
+ detail = f" — missing: {', '.join(comp['missing_required'])}"
262
+ elif comp["incomplete_required"]:
263
+ detail = f" — incomplete: {', '.join(comp['incomplete_required'])}"
264
+ else:
265
+ detail = ""
266
+ out.append(f"- {mark} **Required documents** ({comp['profile']}): "
267
+ f"{comp['required_complete']}/{comp['required_total']} complete{detail}")
268
+
269
+ if model["broken_references"]:
270
+ out.append(f"- 🔴 **Broken references**: {len(model['broken_references'])} "
271
+ f"({', '.join(model['broken_references'][:3])}…)")
272
+ failing = [d["id"] for d in model["documents"] if not d["passing"]]
273
+ if failing:
274
+ out.append(f"- 🔴 **Documents failing audit**: {', '.join(failing)}")
275
+
276
+ if model["risks"]:
277
+ out.append(f"- 🟠 **Open risks**: {len(model['risks'])}")
278
+ for r in model["risks"][:5]:
279
+ out.append(f" - `{r['id']}` — {r['probability']}/{r['impact']} · owner: {r['owner']}")
280
+
281
+ lr = model["latest_report"]
282
+ if lr:
283
+ out.append(f"- ℹ️ Latest status report `{lr['id']}` ({lr['period']}) reported **{lr['rag']}**")
284
+
285
+ if not summary:
286
+ out += ["", "## Documents", "", "| Kind | ID | Status | Audit |", "|---|---|---|---|"]
287
+ for d in sorted(model["documents"], key=lambda x: (x["kind"], x["id"])):
288
+ out.append(f"| {d['kind']} | {d['id']} | {d['status']} | {'✓' if d['passing'] else '✗'} |")
289
+ else:
290
+ out += ["", f"<sub>Full inventory of {c['total']} documents in "
291
+ "[`STATUS.md`](../../blob/main/STATUS.md).</sub>"]
292
+ return "\n".join(out) + "\n"
293
+
294
+
295
+ def render_json(model) -> str:
296
+ return json.dumps(model, indent=2) + "\n"
297
+
298
+
299
+ _HTML_CSS = """
300
+ :root { --bg:#0d1117; --panel:#161b22; --border:#30363d; --ink:#e6edf3;
301
+ --muted:#8b949e; --ok:#2ea043; --amber:#d29922; --bad:#f85149; }
302
+ * { box-sizing:border-box; }
303
+ body { margin:0; background:var(--bg); color:var(--ink);
304
+ font:15px/1.5 -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif; }
305
+ main { max-width:860px; margin:0 auto; padding:40px 24px 64px; }
306
+ header { display:flex; align-items:center; gap:16px; flex-wrap:wrap; margin-bottom:8px; }
307
+ .rag { font-weight:700; font-size:13px; letter-spacing:.08em; color:#0d1117;
308
+ padding:5px 12px; border-radius:999px; }
309
+ h1 { font-size:26px; margin:0; }
310
+ .meta { color:var(--muted); font-size:13px; margin:2px 0 30px; }
311
+ section { margin-bottom:30px; }
312
+ h2 { font-size:15px; text-transform:uppercase; letter-spacing:.05em;
313
+ color:var(--muted); border-bottom:1px solid var(--border); padding-bottom:8px; }
314
+ .sig { margin:12px 0; }
315
+ .sig-h { display:flex; justify-content:space-between; font-size:14px; margin-bottom:5px; }
316
+ .bar { height:8px; background:var(--border); border-radius:4px; overflow:hidden; }
317
+ .bar-f { height:100%; background:var(--ok); border-radius:4px; }
318
+ ul { padding-left:18px; } li { margin:4px 0; }
319
+ code { font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-size:13px;
320
+ background:#21262d; padding:1px 5px; border-radius:4px; }
321
+ table { width:100%; border-collapse:collapse; font-size:13.5px; }
322
+ th,td { text-align:left; padding:7px 10px; border-bottom:1px solid var(--border); }
323
+ th { color:var(--muted); font-weight:600; }
324
+ td.ok { color:var(--ok); } td.bad { color:var(--bad); font-weight:700; }
325
+ footer { color:var(--muted); font-size:12px; margin-top:36px; border-top:1px solid var(--border); padding-top:14px; }
326
+ .back { display:inline-block; color:var(--muted); text-decoration:none; font-size:13px; margin-bottom:14px; }
327
+ .back:hover { color:var(--ink); }
328
+ """
329
+
330
+ _INDEX_CSS = """
331
+ .grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(260px,1fr)); gap:16px; }
332
+ a.card { display:block; background:var(--panel); border:1px solid var(--border);
333
+ border-radius:10px; padding:18px; text-decoration:none; color:var(--ink);
334
+ transition:border-color .15s, transform .15s; }
335
+ a.card:hover { border-color:var(--muted); transform:translateY(-2px); }
336
+ .card-h { display:flex; align-items:center; gap:8px; margin-bottom:10px; }
337
+ .dot { width:10px; height:10px; border-radius:50%; }
338
+ .rag-t { font-size:12px; font-weight:700; letter-spacing:.06em; }
339
+ .code { margin-left:auto; font:12px ui-monospace,SFMono-Regular,Menlo,monospace;
340
+ background:#21262d; color:var(--muted); padding:2px 7px; border-radius:5px; }
341
+ .name { font-size:16px; font-weight:600; margin-bottom:3px; }
342
+ .card .pid { color:var(--muted); font-size:12px;
343
+ font-family:ui-monospace,SFMono-Regular,Menlo,monospace; margin-bottom:8px; }
344
+ .sponsor { font-size:13px; color:var(--muted); margin-bottom:10px; }
345
+ .stats { font-size:12.5px; color:var(--muted); border-top:1px solid var(--border); padding-top:8px; }
346
+ """
347
+
348
+
349
+ def render_html(model) -> str:
350
+ import datetime
351
+ import html as _h
352
+ rag = model["rag"]
353
+ c = model["counts"]
354
+ color = {"green": "var(--ok)", "amber": "var(--amber)", "red": "var(--bad)"}[rag]
355
+ bar_color = {"green": "var(--ok)", "amber": "var(--amber)", "red": "var(--bad)"}[rag]
356
+ def esc(s):
357
+ return _h.escape(str(s))
358
+
359
+ coverage = "".join(
360
+ f'<div class="sig"><div class="sig-h"><span>{esc(cov["label"])}</span>'
361
+ f'<span>{cov["covered"]}/{cov["total"]}'
362
+ + (f' · gaps: {esc(", ".join(cov["gaps"]))}' if cov["gaps"] else "")
363
+ + '</span></div><div class="bar"><div class="bar-f" style="width:'
364
+ f'{(100 * cov["covered"] // cov["total"]) if cov["total"] else 100}%'
365
+ + (";background:var(--amber)" if cov["gaps"] else "") + '"></div></div></div>'
366
+ for cov in model["coverage"])
367
+
368
+ risks = "".join(
369
+ f'<li><code>{esc(r["id"])}</code> — {esc(r["probability"])}/{esc(r["impact"])}'
370
+ f' · owner {esc(r["owner"])}</li>' for r in model["risks"]) or "<li>None open.</li>"
371
+
372
+ problems = ""
373
+ if model["broken_references"]:
374
+ problems += ("<section><h2>Broken references</h2><ul>"
375
+ + "".join(f"<li><code>{esc(b)}</code></li>" for b in model["broken_references"])
376
+ + "</ul></section>")
377
+ failing = [d for d in model["documents"] if not d["passing"]]
378
+ if failing:
379
+ problems += ("<section><h2>Documents failing audit</h2><ul>"
380
+ + "".join(f'<li><code>{esc(d["id"])}</code></li>' for d in failing)
381
+ + "</ul></section>")
382
+
383
+ rows = "".join(
384
+ f'<tr><td>{esc(d["kind"])}</td><td><code>{esc(d["id"])}</code></td>'
385
+ f'<td>{esc(d["status"])}</td>'
386
+ f'<td class="{"ok" if d["passing"] else "bad"}">{"✓" if d["passing"] else "✗"}</td></tr>'
387
+ for d in sorted(model["documents"], key=lambda x: (x["kind"], x["id"])))
388
+
389
+ lr = model["latest_report"]
390
+ report = (f'<code>{esc(lr["id"])}</code> ({esc(lr["period"])}) → reported <b>{esc(lr["rag"])}</b>'
391
+ if lr else "None on record.")
392
+ gen = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
393
+
394
+ comp = model.get("completeness")
395
+ document_set = ""
396
+ if comp and comp.get("unknown"):
397
+ document_set = (f'<section><h2>Document set</h2><p style="color:var(--bad)">'
398
+ f'Profile <code>{esc(comp["profile"])}</code> not found under profiles/.</p></section>')
399
+ elif comp:
400
+ _st = {"complete": ("var(--ok)", "complete"),
401
+ "incomplete": ("var(--amber)", "incomplete"),
402
+ "missing": ("var(--bad)", "missing")}
403
+
404
+ def _row(item, required):
405
+ col, lab = _st[item["state"]]
406
+ if item["state"] == "missing" and not required:
407
+ col, lab = "var(--muted)", "not added"
408
+ return (f'<tr><td><code>{esc(item["kind"])}</code></td>'
409
+ f'<td style="color:{col};font-weight:600;">{lab}</td></tr>')
410
+
411
+ req_rows = "".join(_row(i, True) for i in comp["required"])
412
+ rec_rows = "".join(_row(i, False) for i in comp["recommended"])
413
+ sep = ('<tr><td colspan="2" style="color:var(--muted);border:0;padding:14px 10px 4px;'
414
+ 'font-size:11px;text-transform:uppercase;letter-spacing:.05em;">Recommended</td></tr>'
415
+ if rec_rows else "")
416
+ if comp["blocks"]:
417
+ note = (f'<p style="color:var(--bad);font-size:13px;margin:0 0 10px;">Missing required: '
418
+ f'<code>{esc(", ".join(comp["missing_required"]))}</code> — blocks release '
419
+ f'while <code>{esc(comp["enforce_when"])}</code>.</p>')
420
+ elif comp["missing_required"] or comp["incomplete_required"]:
421
+ note = (f'<p style="color:var(--amber);font-size:13px;margin:0 0 10px;">Advisory until '
422
+ f'the project is <code>{esc(comp["enforce_when"])}</code>.</p>')
423
+ else:
424
+ note = ""
425
+ document_set = (f'<section><h2>Document set · {esc(comp["profile"])} '
426
+ f'({comp["required_complete"]}/{comp["required_total"]} required complete)</h2>'
427
+ f'{note}<table><thead><tr><th>Kind</th><th>State</th></tr></thead>'
428
+ f'<tbody>{req_rows}{sep}{rec_rows}</tbody></table></section>')
429
+
430
+ title = esc(model.get("title", "Project Status"))
431
+ pid = model.get("project")
432
+ back = '<a class="back" href="index.html">← all projects</a>' if pid else ""
433
+ scope = f" · <code>{esc(pid)}</code>" if pid else ""
434
+
435
+ return f"""<!doctype html>
436
+ <html lang="en"><head><meta charset="utf-8">
437
+ <meta name="viewport" content="width=device-width,initial-scale=1">
438
+ <title>{title} — {rag.upper()}</title>
439
+ <style>{_HTML_CSS}
440
+ .bar-f {{ background:{bar_color}; }}</style></head>
441
+ <body><main>
442
+ {back}
443
+ <header>
444
+ <span class="rag" style="background:{color}">{rag.upper()}</span>
445
+ <h1>{title}</h1>
446
+ </header>
447
+ <p class="meta">Derived from {c['total']} documents · {c['kinds']} kinds ·
448
+ {c['approved']} approved{scope} · generated {gen}. Do not edit — regenerated from the documents.</p>
449
+ <section><h2>Traceability coverage</h2>{coverage}</section>
450
+ {document_set}
451
+ {problems}
452
+ <section><h2>Open risks ({len(model['risks'])})</h2><ul>{risks}</ul></section>
453
+ <section><h2>Latest status report</h2><p>{report}</p></section>
454
+ <section><h2>Documents ({c['total']})</h2>
455
+ <table><thead><tr><th>Kind</th><th>ID</th><th>Status</th><th>Audit</th></tr></thead>
456
+ <tbody>{rows}</tbody></table></section>
457
+ <footer>Generated by <code>docassert status</code> from the documents in this repository.</footer>
458
+ </main></body></html>
459
+ """
460
+
461
+
462
+ _RAG_COLOR = {"green": "var(--ok)", "amber": "var(--amber)", "red": "var(--bad)"}
463
+
464
+
465
+ def _index_card(p, esc) -> str:
466
+ dot = _RAG_COLOR[p["rag"]]
467
+
468
+ def plural(n, word):
469
+ return f"{n} {word}" + ("s" if n != 1 else "")
470
+
471
+ stats = [plural(p["total"], "doc")]
472
+ comp = p.get("completeness")
473
+ if comp and not comp.get("unknown"):
474
+ stats.append(f'{comp["required_complete"]}/{comp["required_total"]} required')
475
+ if p["risks"]:
476
+ stats.append(plural(p["risks"], "open risk"))
477
+ if p["coverage_gaps"]:
478
+ stats.append(plural(p["coverage_gaps"], "coverage gap"))
479
+ if p["failing"]:
480
+ stats.append(plural(p["failing"], "failing doc"))
481
+ if p["broken"]:
482
+ stats.append(plural(p["broken"], "broken ref"))
483
+
484
+ return (f'<a class="card" href="{esc(p["id"])}.html">'
485
+ f'<div class="card-h"><span class="dot" style="background:{dot}"></span>'
486
+ f'<span class="rag-t" style="color:{dot}">{esc(p["rag"].upper())}</span>'
487
+ f'<span class="code">{esc(p["code"])}</span></div>'
488
+ f'<div class="name">{esc(p["name"])}</div>'
489
+ f'<div class="pid">{esc(p["id"])} · {esc(p["lifecycle"])}</div>'
490
+ f'<div class="sponsor">Sponsor: {esc(p["sponsor"])}</div>'
491
+ f'<div class="stats">{" · ".join(esc(s) for s in stats)}</div></a>')
492
+
493
+
494
+ def render_index_markdown(index) -> str:
495
+ """A portfolio table: one row per project with its derived RAG."""
496
+ overall = index["overall"]["rag"]
497
+ out = [f"# Projects — {_EMOJI[overall]} {overall.upper()}", "",
498
+ "| Project | Code | RAG | Docs | Required | Open risks | Sponsor |",
499
+ "|---|---|---|---|---|---|---|"]
500
+ for p in index["projects"]:
501
+ comp = p.get("completeness")
502
+ required = (f"{comp['required_complete']}/{comp['required_total']} ({comp['profile']})"
503
+ if comp and not comp.get("unknown") else "—")
504
+ out.append(
505
+ f"| {p['name']} | `{p['code']}` | {_EMOJI[p['rag']]} {p['rag'].upper()} "
506
+ f"| {p['total']} | {required} | {p['risks']} | {p['sponsor']} |")
507
+ return "\n".join(out) + "\n"
508
+
509
+
510
+ def render_index_html(index) -> str:
511
+ """The multi-project landing page: one linked RAG card per project."""
512
+ import datetime
513
+ import html as _h
514
+ def esc(s):
515
+ return _h.escape(str(s))
516
+
517
+ overall = index["overall"]["rag"]
518
+ ocolor = _RAG_COLOR[overall]
519
+ gen = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
520
+ cards = "".join(_index_card(p, esc) for p in index["projects"])
521
+ n = len(index["projects"])
522
+
523
+ return f"""<!doctype html>
524
+ <html lang="en"><head><meta charset="utf-8">
525
+ <meta name="viewport" content="width=device-width,initial-scale=1">
526
+ <title>PMO as Code — Projects</title>
527
+ <style>{_HTML_CSS}{_INDEX_CSS}</style></head>
528
+ <body><main>
529
+ <header>
530
+ <span class="rag" style="background:{ocolor}">{overall.upper()}</span>
531
+ <h1>Projects</h1>
532
+ </header>
533
+ <p class="meta">{n} project(s) · derived portfolio health · generated {gen}.
534
+ Each card's RAG is computed from that project's own documents.</p>
535
+ <section class="grid">{cards}</section>
536
+ <footer>Generated by <code>docassert pages</code> from the documents in this repository.</footer>
537
+ </main></body></html>
538
+ """