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.
- docassert/__init__.py +8 -0
- docassert/__main__.py +6 -0
- docassert/_data/consistency.yaml +51 -0
- docassert/_data/criteria/adr.criteria.yaml +36 -0
- docassert/_data/criteria/benefits-realization.criteria.yaml +30 -0
- docassert/_data/criteria/brd.criteria.yaml +30 -0
- docassert/_data/criteria/business-case.criteria.yaml +23 -0
- docassert/_data/criteria/charter.criteria.yaml +73 -0
- docassert/_data/criteria/data-migration-plan.criteria.yaml +28 -0
- docassert/_data/criteria/frnfr.criteria.yaml +31 -0
- docassert/_data/criteria/hypercare-plan.criteria.yaml +27 -0
- docassert/_data/criteria/post-implementation-review.criteria.yaml +24 -0
- docassert/_data/criteria/prd.criteria.yaml +31 -0
- docassert/_data/criteria/project.criteria.yaml +32 -0
- docassert/_data/criteria/qa-test-plan.criteria.yaml +27 -0
- docassert/_data/criteria/raci-stakeholder.criteria.yaml +24 -0
- docassert/_data/criteria/release-cutover-plan.criteria.yaml +30 -0
- docassert/_data/criteria/risk-register.criteria.yaml +32 -0
- docassert/_data/criteria/rollback-plan.criteria.yaml +29 -0
- docassert/_data/criteria/runbook.criteria.yaml +30 -0
- docassert/_data/criteria/status-report.criteria.yaml +26 -0
- docassert/_data/criteria/test-cases.criteria.yaml +28 -0
- docassert/_data/criteria/user-story.criteria.yaml +32 -0
- docassert/_data/profiles/agile-delivery.yaml +20 -0
- docassert/_data/profiles/lean-startup.yaml +19 -0
- docassert/_data/profiles/regulated-industry.yaml +31 -0
- docassert/_data/schema/adr.schema.json +45 -0
- docassert/_data/schema/benefits-realization.schema.json +45 -0
- docassert/_data/schema/brd.schema.json +45 -0
- docassert/_data/schema/business-case.schema.json +45 -0
- docassert/_data/schema/charter.schema.json +84 -0
- docassert/_data/schema/data-migration-plan.schema.json +45 -0
- docassert/_data/schema/frnfr.schema.json +45 -0
- docassert/_data/schema/hypercare-plan.schema.json +45 -0
- docassert/_data/schema/post-implementation-review.schema.json +45 -0
- docassert/_data/schema/prd.schema.json +45 -0
- docassert/_data/schema/project.schema.json +32 -0
- docassert/_data/schema/qa-test-plan.schema.json +45 -0
- docassert/_data/schema/raci-stakeholder.schema.json +45 -0
- docassert/_data/schema/release-cutover-plan.schema.json +45 -0
- docassert/_data/schema/risk-register.schema.json +45 -0
- docassert/_data/schema/rollback-plan.schema.json +45 -0
- docassert/_data/schema/runbook.schema.json +45 -0
- docassert/_data/schema/status-report.schema.json +58 -0
- docassert/_data/schema/test-cases.schema.json +45 -0
- docassert/_data/schema/user-story.schema.json +45 -0
- docassert/_data/templates/adr.template.md +17 -0
- docassert/_data/templates/benefits-realization.template.md +25 -0
- docassert/_data/templates/brd.template.md +22 -0
- docassert/_data/templates/business-case.template.md +27 -0
- docassert/_data/templates/charter.template.md +46 -0
- docassert/_data/templates/data-migration-plan.template.md +35 -0
- docassert/_data/templates/frnfr.template.md +19 -0
- docassert/_data/templates/hypercare-plan.template.md +29 -0
- docassert/_data/templates/post-implementation-review.template.md +31 -0
- docassert/_data/templates/prd.template.md +23 -0
- docassert/_data/templates/project.template.md +17 -0
- docassert/_data/templates/qa-test-plan.template.md +31 -0
- docassert/_data/templates/raci-stakeholder.template.md +21 -0
- docassert/_data/templates/release-cutover-plan.template.md +28 -0
- docassert/_data/templates/risk-register.template.md +18 -0
- docassert/_data/templates/rollback-plan.template.md +24 -0
- docassert/_data/templates/runbook.template.md +28 -0
- docassert/_data/templates/status-report.template.md +27 -0
- docassert/_data/templates/test-cases.template.md +17 -0
- docassert/_data/templates/user-story.template.md +17 -0
- docassert/cli.py +291 -0
- docassert/config.py +104 -0
- docassert/consistency.py +167 -0
- docassert/graph.py +68 -0
- docassert/loader.py +116 -0
- docassert/models.py +99 -0
- docassert/profiles.py +111 -0
- docassert/projects.py +49 -0
- docassert/report.py +83 -0
- docassert/rtm.py +70 -0
- docassert/semantic.py +124 -0
- docassert/status.py +538 -0
- docassert/structural.py +406 -0
- docassert-0.1.0.dist-info/METADATA +125 -0
- docassert-0.1.0.dist-info/RECORD +86 -0
- docassert-0.1.0.dist-info/WHEEL +5 -0
- docassert-0.1.0.dist-info/entry_points.txt +2 -0
- docassert-0.1.0.dist-info/licenses/LICENSE +201 -0
- docassert-0.1.0.dist-info/licenses/NOTICE +4 -0
- 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
|
+
"""
|