docassert 0.14.1__tar.gz → 0.16.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 (124) hide show
  1. {docassert-0.14.1/docassert.egg-info → docassert-0.16.0}/PKG-INFO +1 -1
  2. {docassert-0.14.1 → docassert-0.16.0}/docassert/__init__.py +1 -1
  3. docassert-0.16.0/docassert/_data/criteria/operations.criteria.yaml +40 -0
  4. {docassert-0.14.1 → docassert-0.16.0}/docassert/_data/criteria/risk-register.criteria.yaml +5 -0
  5. docassert-0.16.0/docassert/_data/profiles/operations.yaml +18 -0
  6. docassert-0.16.0/docassert/_data/schema/operations.schema.json +50 -0
  7. docassert-0.16.0/docassert/_data/templates/operations.template.md +17 -0
  8. {docassert-0.14.1 → docassert-0.16.0}/docassert/status/derive.py +32 -3
  9. {docassert-0.14.1 → docassert-0.16.0}/docassert/status/render.py +29 -6
  10. {docassert-0.14.1 → docassert-0.16.0}/docassert/structural.py +47 -0
  11. {docassert-0.14.1 → docassert-0.16.0/docassert.egg-info}/PKG-INFO +1 -1
  12. {docassert-0.14.1 → docassert-0.16.0}/docassert.egg-info/SOURCES.txt +4 -0
  13. {docassert-0.14.1 → docassert-0.16.0}/tests/test_profiles.py +3 -2
  14. {docassert-0.14.1 → docassert-0.16.0}/tests/test_status.py +111 -0
  15. {docassert-0.14.1 → docassert-0.16.0}/LICENSE +0 -0
  16. {docassert-0.14.1 → docassert-0.16.0}/NOTICE +0 -0
  17. {docassert-0.14.1 → docassert-0.16.0}/README.md +0 -0
  18. {docassert-0.14.1 → docassert-0.16.0}/docassert/__main__.py +0 -0
  19. {docassert-0.14.1 → docassert-0.16.0}/docassert/_data/consistency.yaml +0 -0
  20. {docassert-0.14.1 → docassert-0.16.0}/docassert/_data/criteria/adr.criteria.yaml +0 -0
  21. {docassert-0.14.1 → docassert-0.16.0}/docassert/_data/criteria/benefits-realization.criteria.yaml +0 -0
  22. {docassert-0.14.1 → docassert-0.16.0}/docassert/_data/criteria/brd.criteria.yaml +0 -0
  23. {docassert-0.14.1 → docassert-0.16.0}/docassert/_data/criteria/business-case.criteria.yaml +0 -0
  24. {docassert-0.14.1 → docassert-0.16.0}/docassert/_data/criteria/charter.criteria.yaml +0 -0
  25. {docassert-0.14.1 → docassert-0.16.0}/docassert/_data/criteria/data-migration-plan.criteria.yaml +0 -0
  26. {docassert-0.14.1 → docassert-0.16.0}/docassert/_data/criteria/frnfr.criteria.yaml +0 -0
  27. {docassert-0.14.1 → docassert-0.16.0}/docassert/_data/criteria/hypercare-plan.criteria.yaml +0 -0
  28. {docassert-0.14.1 → docassert-0.16.0}/docassert/_data/criteria/post-implementation-review.criteria.yaml +0 -0
  29. {docassert-0.14.1 → docassert-0.16.0}/docassert/_data/criteria/prd.criteria.yaml +0 -0
  30. {docassert-0.14.1 → docassert-0.16.0}/docassert/_data/criteria/project.criteria.yaml +0 -0
  31. {docassert-0.14.1 → docassert-0.16.0}/docassert/_data/criteria/qa-test-plan.criteria.yaml +0 -0
  32. {docassert-0.14.1 → docassert-0.16.0}/docassert/_data/criteria/raci-stakeholder.criteria.yaml +0 -0
  33. {docassert-0.14.1 → docassert-0.16.0}/docassert/_data/criteria/release-cutover-plan.criteria.yaml +0 -0
  34. {docassert-0.14.1 → docassert-0.16.0}/docassert/_data/criteria/rollback-plan.criteria.yaml +0 -0
  35. {docassert-0.14.1 → docassert-0.16.0}/docassert/_data/criteria/runbook.criteria.yaml +0 -0
  36. {docassert-0.14.1 → docassert-0.16.0}/docassert/_data/criteria/status-report.criteria.yaml +0 -0
  37. {docassert-0.14.1 → docassert-0.16.0}/docassert/_data/criteria/test-cases.criteria.yaml +0 -0
  38. {docassert-0.14.1 → docassert-0.16.0}/docassert/_data/criteria/user-story.criteria.yaml +0 -0
  39. {docassert-0.14.1 → docassert-0.16.0}/docassert/_data/profiles/agile-delivery.yaml +0 -0
  40. {docassert-0.14.1 → docassert-0.16.0}/docassert/_data/profiles/lean-startup.yaml +0 -0
  41. {docassert-0.14.1 → docassert-0.16.0}/docassert/_data/profiles/regulated-industry.yaml +0 -0
  42. {docassert-0.14.1 → docassert-0.16.0}/docassert/_data/schema/adr.schema.json +0 -0
  43. {docassert-0.14.1 → docassert-0.16.0}/docassert/_data/schema/benefits-realization.schema.json +0 -0
  44. {docassert-0.14.1 → docassert-0.16.0}/docassert/_data/schema/brd.schema.json +0 -0
  45. {docassert-0.14.1 → docassert-0.16.0}/docassert/_data/schema/business-case.schema.json +0 -0
  46. {docassert-0.14.1 → docassert-0.16.0}/docassert/_data/schema/charter.schema.json +0 -0
  47. {docassert-0.14.1 → docassert-0.16.0}/docassert/_data/schema/data-migration-plan.schema.json +0 -0
  48. {docassert-0.14.1 → docassert-0.16.0}/docassert/_data/schema/frnfr.schema.json +0 -0
  49. {docassert-0.14.1 → docassert-0.16.0}/docassert/_data/schema/hypercare-plan.schema.json +0 -0
  50. {docassert-0.14.1 → docassert-0.16.0}/docassert/_data/schema/post-implementation-review.schema.json +0 -0
  51. {docassert-0.14.1 → docassert-0.16.0}/docassert/_data/schema/prd.schema.json +0 -0
  52. {docassert-0.14.1 → docassert-0.16.0}/docassert/_data/schema/project.schema.json +0 -0
  53. {docassert-0.14.1 → docassert-0.16.0}/docassert/_data/schema/qa-test-plan.schema.json +0 -0
  54. {docassert-0.14.1 → docassert-0.16.0}/docassert/_data/schema/raci-stakeholder.schema.json +0 -0
  55. {docassert-0.14.1 → docassert-0.16.0}/docassert/_data/schema/release-cutover-plan.schema.json +0 -0
  56. {docassert-0.14.1 → docassert-0.16.0}/docassert/_data/schema/risk-register.schema.json +0 -0
  57. {docassert-0.14.1 → docassert-0.16.0}/docassert/_data/schema/rollback-plan.schema.json +0 -0
  58. {docassert-0.14.1 → docassert-0.16.0}/docassert/_data/schema/runbook.schema.json +0 -0
  59. {docassert-0.14.1 → docassert-0.16.0}/docassert/_data/schema/status-report.schema.json +0 -0
  60. {docassert-0.14.1 → docassert-0.16.0}/docassert/_data/schema/test-cases.schema.json +0 -0
  61. {docassert-0.14.1 → docassert-0.16.0}/docassert/_data/schema/user-story.schema.json +0 -0
  62. {docassert-0.14.1 → docassert-0.16.0}/docassert/_data/skills/doc-to-pmo/SKILL.md +0 -0
  63. {docassert-0.14.1 → docassert-0.16.0}/docassert/_data/templates/adr.template.md +0 -0
  64. {docassert-0.14.1 → docassert-0.16.0}/docassert/_data/templates/benefits-realization.template.md +0 -0
  65. {docassert-0.14.1 → docassert-0.16.0}/docassert/_data/templates/brd.template.md +0 -0
  66. {docassert-0.14.1 → docassert-0.16.0}/docassert/_data/templates/business-case.template.md +0 -0
  67. {docassert-0.14.1 → docassert-0.16.0}/docassert/_data/templates/charter.template.md +0 -0
  68. {docassert-0.14.1 → docassert-0.16.0}/docassert/_data/templates/data-migration-plan.template.md +0 -0
  69. {docassert-0.14.1 → docassert-0.16.0}/docassert/_data/templates/frnfr.template.md +0 -0
  70. {docassert-0.14.1 → docassert-0.16.0}/docassert/_data/templates/hypercare-plan.template.md +0 -0
  71. {docassert-0.14.1 → docassert-0.16.0}/docassert/_data/templates/post-implementation-review.template.md +0 -0
  72. {docassert-0.14.1 → docassert-0.16.0}/docassert/_data/templates/prd.template.md +0 -0
  73. {docassert-0.14.1 → docassert-0.16.0}/docassert/_data/templates/project.template.md +0 -0
  74. {docassert-0.14.1 → docassert-0.16.0}/docassert/_data/templates/qa-test-plan.template.md +0 -0
  75. {docassert-0.14.1 → docassert-0.16.0}/docassert/_data/templates/raci-stakeholder.template.md +0 -0
  76. {docassert-0.14.1 → docassert-0.16.0}/docassert/_data/templates/release-cutover-plan.template.md +0 -0
  77. {docassert-0.14.1 → docassert-0.16.0}/docassert/_data/templates/risk-register.template.md +0 -0
  78. {docassert-0.14.1 → docassert-0.16.0}/docassert/_data/templates/rollback-plan.template.md +0 -0
  79. {docassert-0.14.1 → docassert-0.16.0}/docassert/_data/templates/runbook.template.md +0 -0
  80. {docassert-0.14.1 → docassert-0.16.0}/docassert/_data/templates/status-report.template.md +0 -0
  81. {docassert-0.14.1 → docassert-0.16.0}/docassert/_data/templates/test-cases.template.md +0 -0
  82. {docassert-0.14.1 → docassert-0.16.0}/docassert/_data/templates/user-story.template.md +0 -0
  83. {docassert-0.14.1 → docassert-0.16.0}/docassert/bridge/__init__.py +0 -0
  84. {docassert-0.14.1 → docassert-0.16.0}/docassert/bridge/board.py +0 -0
  85. {docassert-0.14.1 → docassert-0.16.0}/docassert/bridge/gh.py +0 -0
  86. {docassert-0.14.1 → docassert-0.16.0}/docassert/bridge/ops.py +0 -0
  87. {docassert-0.14.1 → docassert-0.16.0}/docassert/bridge/plan.py +0 -0
  88. {docassert-0.14.1 → docassert-0.16.0}/docassert/cli.py +0 -0
  89. {docassert-0.14.1 → docassert-0.16.0}/docassert/config.py +0 -0
  90. {docassert-0.14.1 → docassert-0.16.0}/docassert/consistency.py +0 -0
  91. {docassert-0.14.1 → docassert-0.16.0}/docassert/extract.py +0 -0
  92. {docassert-0.14.1 → docassert-0.16.0}/docassert/graph.py +0 -0
  93. {docassert-0.14.1 → docassert-0.16.0}/docassert/loader.py +0 -0
  94. {docassert-0.14.1 → docassert-0.16.0}/docassert/models.py +0 -0
  95. {docassert-0.14.1 → docassert-0.16.0}/docassert/profiles.py +0 -0
  96. {docassert-0.14.1 → docassert-0.16.0}/docassert/projects.py +0 -0
  97. {docassert-0.14.1 → docassert-0.16.0}/docassert/report.py +0 -0
  98. {docassert-0.14.1 → docassert-0.16.0}/docassert/rtm.py +0 -0
  99. {docassert-0.14.1 → docassert-0.16.0}/docassert/scaffold.py +0 -0
  100. {docassert-0.14.1 → docassert-0.16.0}/docassert/semantic.py +0 -0
  101. {docassert-0.14.1 → docassert-0.16.0}/docassert/status/__init__.py +0 -0
  102. {docassert-0.14.1 → docassert-0.16.0}/docassert.egg-info/dependency_links.txt +0 -0
  103. {docassert-0.14.1 → docassert-0.16.0}/docassert.egg-info/entry_points.txt +0 -0
  104. {docassert-0.14.1 → docassert-0.16.0}/docassert.egg-info/requires.txt +0 -0
  105. {docassert-0.14.1 → docassert-0.16.0}/docassert.egg-info/top_level.txt +0 -0
  106. {docassert-0.14.1 → docassert-0.16.0}/pyproject.toml +0 -0
  107. {docassert-0.14.1 → docassert-0.16.0}/setup.cfg +0 -0
  108. {docassert-0.14.1 → docassert-0.16.0}/tests/test_badge.py +0 -0
  109. {docassert-0.14.1 → docassert-0.16.0}/tests/test_bridge.py +0 -0
  110. {docassert-0.14.1 → docassert-0.16.0}/tests/test_config.py +0 -0
  111. {docassert-0.14.1 → docassert-0.16.0}/tests/test_consistency.py +0 -0
  112. {docassert-0.14.1 → docassert-0.16.0}/tests/test_defects.py +0 -0
  113. {docassert-0.14.1 → docassert-0.16.0}/tests/test_draft_relaxation.py +0 -0
  114. {docassert-0.14.1 → docassert-0.16.0}/tests/test_extract.py +0 -0
  115. {docassert-0.14.1 → docassert-0.16.0}/tests/test_graph.py +0 -0
  116. {docassert-0.14.1 → docassert-0.16.0}/tests/test_json_report.py +0 -0
  117. {docassert-0.14.1 → docassert-0.16.0}/tests/test_kinds_delivery.py +0 -0
  118. {docassert-0.14.1 → docassert-0.16.0}/tests/test_kinds_governance.py +0 -0
  119. {docassert-0.14.1 → docassert-0.16.0}/tests/test_kinds_operate.py +0 -0
  120. {docassert-0.14.1 → docassert-0.16.0}/tests/test_kinds_reporting.py +0 -0
  121. {docassert-0.14.1 → docassert-0.16.0}/tests/test_projects.py +0 -0
  122. {docassert-0.14.1 → docassert-0.16.0}/tests/test_scaffold.py +0 -0
  123. {docassert-0.14.1 → docassert-0.16.0}/tests/test_semantic.py +0 -0
  124. {docassert-0.14.1 → docassert-0.16.0}/tests/test_structural.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: docassert
3
- Version: 0.14.1
3
+ Version: 0.16.0
4
4
  Summary: Unit testing for business documents — validate structured Markdown docs against a configurable audit standard.
5
5
  Author: C4G Enterprises Inc.
6
6
  License: Apache-2.0
@@ -5,4 +5,4 @@ standard: deterministic structural checks that gate a merge, plus optional
5
5
  AI-graded semantic checks that advise.
6
6
  """
7
7
 
8
- __version__ = "0.14.1"
8
+ __version__ = "0.16.0"
@@ -0,0 +1,40 @@
1
+ # Audit criteria for the Operations kind (spec §13.21).
2
+ kind: operations
3
+
4
+ required_sections:
5
+ - Overview
6
+ - Services
7
+
8
+ item_sections:
9
+ - section: Services
10
+ prefix: SVC
11
+
12
+ checks:
13
+ - id: frontmatter-schema
14
+ type: structural
15
+ blocking: true
16
+ description: Frontmatter is valid against schema/operations.schema.json.
17
+ - id: frontmatter-complete
18
+ type: structural
19
+ blocking: once-proposed
20
+ description: Every schema-required frontmatter field is present (advisory while draft).
21
+ - id: required-sections
22
+ type: structural
23
+ blocking: once-proposed
24
+ description: Every required section is present and non-empty.
25
+ - id: items-well-formed
26
+ type: structural
27
+ blocking: true
28
+ description: Every SVC item follows the item grammar in its declared section.
29
+ - id: svc-items-complete
30
+ type: structural
31
+ blocking: once-proposed
32
+ description: Every SVC item states a Level (the commitment) and a Measure (how attainment is read).
33
+ - id: ops-review-fresh
34
+ type: structural
35
+ blocking: false
36
+ description: review_by is today or later; a past date is an amber input to derived status, never a merge blocker.
37
+ - id: unique-id
38
+ type: structural
39
+ blocking: true
40
+ description: The document id is unique across the repository.
@@ -30,6 +30,11 @@ checks:
30
30
  type: structural
31
31
  blocking: once-proposed
32
32
  description: Every risk states a Probability, Impact, Owner, and Response.
33
+ - id: risk-disposition-valid
34
+ type: structural
35
+ blocking: true
36
+ description: Any Status field on a RISK item names a valid disposition (open/mitigated/accepted/closed).
37
+
33
38
  - id: unique-id
34
39
  type: structural
35
40
  blocking: true
@@ -0,0 +1,18 @@
1
+ # Profile: operations
2
+ #
3
+ # For a repository (or project) whose work is a standing service catalog
4
+ # rather than a delivery arc. The catalog itself is the one required kind;
5
+ # the register and runbook are strongly recommended companions.
6
+ name: operations
7
+ description: >
8
+ A governed service catalog: the operations document is required, with the
9
+ risk register and runbook recommended alongside it.
10
+
11
+ enforce_when: active
12
+
13
+ expects:
14
+ required:
15
+ - operations
16
+ recommended:
17
+ - risk-register
18
+ - runbook
@@ -0,0 +1,50 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "title": "Operations frontmatter",
4
+ "type": "object",
5
+ "required": [
6
+ "kind",
7
+ "id",
8
+ "project",
9
+ "title",
10
+ "owner",
11
+ "status",
12
+ "review_by"
13
+ ],
14
+ "additionalProperties": true,
15
+ "properties": {
16
+ "kind": {
17
+ "const": "operations"
18
+ },
19
+ "id": {
20
+ "type": "string",
21
+ "pattern": "^[A-Z]{2,6}-[a-z0-9][a-z0-9-]*$",
22
+ "description": "Stable id, <CODE>-<slug>. Unique across documents/."
23
+ },
24
+ "title": {
25
+ "type": "string",
26
+ "minLength": 3
27
+ },
28
+ "owner": {
29
+ "type": "string",
30
+ "minLength": 2
31
+ },
32
+ "status": {
33
+ "enum": [
34
+ "draft",
35
+ "proposed",
36
+ "approved",
37
+ "baselined"
38
+ ]
39
+ },
40
+ "project": {
41
+ "type": "string",
42
+ "pattern": "^PRJ-\\d{3,}-[A-Z]{2,6}$"
43
+ },
44
+ "review_by": {
45
+ "type": "string",
46
+ "format": "date",
47
+ "description": "The next operations review; a past date is an amber input to derived status."
48
+ }
49
+ }
50
+ }
@@ -0,0 +1,17 @@
1
+ ---
2
+ kind: operations
3
+ project: PRJ-000-XXX # the owning project's id
4
+ id: XXX-operations # <CODE>-<slug>; the project code namespaces it
5
+ title: My Operations
6
+ owner: jane.doe
7
+ status: draft
8
+ review_by: 2026-12-31 # the next operations review; stale = amber
9
+ ---
10
+
11
+ ## Overview
12
+
13
+ <!-- The operating period, the envelope, and what this catalog covers. -->
14
+
15
+ ## Services
16
+
17
+ <!-- - **XXX-SVC-001**: What the service is. Level: the commitment, measurably stated. Measure: how attainment is read. -->
@@ -82,6 +82,9 @@ def _risks(graph, code=None):
82
82
  description = (item.text[:m.start()] if m else item.text).strip()
83
83
  rm = re.search(r"Response\s*:\s*(.+)$", item.text,
84
84
  re.IGNORECASE | re.DOTALL)
85
+ raw = (_field_value(item.text, "status") or "open").lower()
86
+ disposition = raw if raw in {"open", "mitigated", "accepted",
87
+ "closed"} else "open"
85
88
  risks.append({
86
89
  "id": item.id,
87
90
  "description": description,
@@ -90,9 +93,12 @@ def _risks(graph, code=None):
90
93
  "impact": impact or "?",
91
94
  "owner": _field_value(item.text, "owner") or "?",
92
95
  "response": rm.group(1).strip() if rm else "",
96
+ "disposition": disposition,
93
97
  "score": _SEVERITY.get(prob, 0) * _SEVERITY.get(impact, 0),
94
98
  })
95
- return sorted(risks, key=lambda r: -r["score"])
99
+ # open risks first, then by severity; dispositioned risks stay as record
100
+ return sorted(risks, key=lambda r: (r["disposition"] != "open",
101
+ -r["score"]))
96
102
 
97
103
 
98
104
  def _broken_references(graph, code=None):
@@ -107,6 +113,25 @@ def _broken_references(graph, code=None):
107
113
  return broken
108
114
 
109
115
 
116
+ def _operations(docs):
117
+ """Operations documents with their review state."""
118
+ import datetime
119
+ out = []
120
+ for d in docs:
121
+ if d.kind != "operations":
122
+ continue
123
+ raw = d.frontmatter.get("review_by")
124
+ try:
125
+ due = (raw if isinstance(raw, datetime.date)
126
+ else datetime.date.fromisoformat(str(raw)))
127
+ fresh = due >= datetime.date.today()
128
+ due_s = due.isoformat()
129
+ except (TypeError, ValueError):
130
+ fresh, due_s = False, str(raw)
131
+ out.append({"id": d.id, "review_by": due_s, "fresh": fresh})
132
+ return out
133
+
134
+
110
135
  def _latest_report(docs):
111
136
  reports = [d for d in docs if d.kind == "status-report"]
112
137
  if not reports:
@@ -173,6 +198,7 @@ def build_status(documents_dir=DOCUMENTS_DIR, project: str | None = None) -> dic
173
198
  },
174
199
  "coverage": _coverage(graph, cfg, code),
175
200
  "risks": _risks(graph, code),
201
+ "operations": _operations(docs),
176
202
  "broken_references": _broken_references(graph, code),
177
203
  "latest_report": _latest_report(docs),
178
204
  "completeness": completeness,
@@ -218,7 +244,7 @@ def build_index(documents_dir=DOCUMENTS_DIR) -> dict:
218
244
  "rag": m["rag"],
219
245
  "total": m["counts"]["total"],
220
246
  "failing": m["counts"]["failing"],
221
- "risks": len(m["risks"]),
247
+ "risks": len([r for r in m["risks"] if r.get("disposition", "open") == "open"]),
222
248
  "coverage_gaps": sum(len(c["gaps"]) for c in m["coverage"]),
223
249
  "broken": len(m["broken_references"]),
224
250
  "completeness": m.get("completeness"),
@@ -238,7 +264,10 @@ def derive_rag(model) -> str:
238
264
  reported = (model["latest_report"] or {}).get("rag")
239
265
  completeness_gap = bool(comp and (comp["missing_required"] or comp["incomplete_required"]
240
266
  or comp["recommended_gaps"] or comp.get("unknown")))
241
- if coverage_gap or model["risks"] or reported in {"amber", "red"} or completeness_gap:
267
+ open_risks = [r for r in model["risks"] if r.get("disposition", "open") == "open"]
268
+ stale_ops = [o for o in model.get("operations", []) if not o["fresh"]]
269
+ if (coverage_gap or open_risks or stale_ops
270
+ or reported in {"amber", "red"} or completeness_gap):
242
271
  return "amber"
243
272
  return "green"
244
273
 
@@ -54,9 +54,17 @@ def render_markdown(model, summary: bool = False) -> str:
54
54
  if failing:
55
55
  out.append(f"- 🔴 **Documents failing audit**: {', '.join(failing)}")
56
56
 
57
+ for o in model.get("operations", []):
58
+ mark = "🟢" if o["fresh"] else "🟠"
59
+ verb = "next" if o["fresh"] else "OVERDUE since"
60
+ out.append(f"- {mark} **Operations review** (`{o['id']}`): {verb} {o['review_by']}")
57
61
  if model["risks"]:
58
- out.append(f"- 🟠 **Open risks**: {len(model['risks'])}")
59
- for r in model["risks"][:5]:
62
+ open_r = [r for r in model["risks"] if r.get("disposition", "open") == "open"]
63
+ rest = len(model["risks"]) - len(open_r)
64
+ mark = "🟠" if open_r else "🟢"
65
+ out.append(f"- {mark} **Open risks**: {len(open_r)}"
66
+ + (f" ({rest} dispositioned)" if rest else ""))
67
+ for r in open_r[:5]:
60
68
  out.append(f" - `{r['id']}` — {r['probability']}/{r['impact']} · owner: {r['owner']}")
61
69
 
62
70
  lr = model["latest_report"]
@@ -149,6 +157,12 @@ def render_html(model) -> str:
149
157
  value, "var(--muted)")
150
158
  return f'<td style="color:{color};font-weight:600;">{esc(value)}</td>'
151
159
 
160
+ def disp(value):
161
+ color = {"open": "var(--amber)", "mitigated": "var(--ok)",
162
+ "accepted": "var(--muted)", "closed": "var(--muted)"}.get(
163
+ value, "var(--amber)")
164
+ return f'<td style="color:{color};font-weight:600;">{esc(value)}</td>'
165
+
152
166
  if model["risks"]:
153
167
  risk_rows = "".join(
154
168
  f'<tr><td><code>{esc(r["id"])}</code></td>'
@@ -156,15 +170,24 @@ def render_html(model) -> str:
156
170
  f'<td>{", ".join(f"<code>{esc(b)}</code>" for b in r["threatens"]) or "—"}</td>'
157
171
  + sev(r["probability"]) + sev(r["impact"])
158
172
  + f'<td>{esc(r["owner"])}</td>'
159
- f'<td>{esc(r["response"]) or "—"}</td></tr>'
173
+ + disp(r.get("disposition", "open"))
174
+ + f'<td>{esc(r["response"]) or "—"}</td></tr>'
160
175
  for r in model["risks"])
161
176
  risks = ('<table><thead><tr><th>ID</th><th>Risk</th><th>Threatens</th>'
162
177
  '<th>Probability</th><th>Impact</th><th>Owner</th>'
163
- '<th>Response</th></tr></thead>'
178
+ '<th>Status</th><th>Response</th></tr></thead>'
164
179
  f'<tbody>{risk_rows}</tbody></table>')
165
180
  else:
166
181
  risks = "<p>None open.</p>"
167
182
 
183
+ ops_html = "".join(
184
+ f'<div class="sig"><div class="sig-h"><span>Operations review · '
185
+ f'<code>{esc(o["id"])}</code></span><span style="color:'
186
+ + ("var(--ok)" if o["fresh"] else "var(--amber)") + ';font-weight:600;">'
187
+ + ("next " if o["fresh"] else "overdue since ") + f'{esc(o["review_by"])}'
188
+ '</span></div></div>'
189
+ for o in model.get("operations", []))
190
+
168
191
  problems = ""
169
192
  if model["broken_references"]:
170
193
  problems += ("<section><h2>Broken references</h2><ul>"
@@ -276,11 +299,11 @@ def render_html(model) -> str:
276
299
  </header>
277
300
  <p class="meta">Derived from {c['total']} documents · {c['kinds']} kinds ·
278
301
  {c['approved']} approved{scope} · generated {gen}. Do not edit — regenerated from the documents.</p>
279
- <section><h2>Traceability coverage</h2>{coverage}</section>
302
+ <section><h2>Traceability coverage</h2>{coverage}{ops_html}</section>
280
303
  {document_set}
281
304
  {execution_html}
282
305
  {problems}
283
- <section><h2>Open risks ({len(model['risks'])})</h2>{risks}</section>
306
+ <section><h2>Risks ({len([r for r in model['risks'] if r.get('disposition', 'open') == 'open'])} open of {len(model['risks'])})</h2>{risks}</section>
284
307
  <section><h2>Latest status report</h2><p>{report}</p></section>
285
308
  <section><h2>Documents ({c['total']})</h2>
286
309
  <table><thead><tr><th>Kind</th><th>ID</th><th>Status</th><th>Audit</th></tr></thead>
@@ -226,6 +226,7 @@ def check_project_id_format(doc: Document, ctx: dict) -> tuple[bool, str]:
226
226
 
227
227
 
228
228
  _RISK_FIELDS = ["Probability", "Impact", "Owner", "Response"]
229
+ _DISPOSITIONS = {"open", "mitigated", "accepted", "closed"}
229
230
  _ADR_STATES = {"proposed", "accepted", "superseded", "deprecated", "rejected"}
230
231
 
231
232
 
@@ -255,6 +256,49 @@ def check_risk_items_complete(doc: Document, ctx: dict) -> tuple[bool, str]:
255
256
  return True, f"All {total} risk(s) state probability, impact, owner, and response."
256
257
 
257
258
 
259
+ def check_risk_disposition_valid(doc: Document, ctx: dict) -> tuple[bool, str]:
260
+ """Where a RISK item yields Status, it names a valid disposition."""
261
+ bad, total = [], 0
262
+ for iid, text in _items_of_prefix(doc, ctx, "RISK"):
263
+ total += 1
264
+ value = _field_value(text, "Status")
265
+ if value is not None and value.lower() not in _DISPOSITIONS:
266
+ bad.append(f"{iid}: {value!r}")
267
+ if bad:
268
+ return False, ("invalid disposition (open|mitigated|accepted|closed): "
269
+ + "; ".join(bad))
270
+ return True, f"All {total} risk disposition(s) valid (absent means open)."
271
+
272
+
273
+ def check_svc_items_complete(doc: Document, ctx: dict) -> tuple[bool, str]:
274
+ """Every SVC item states a Level and a Measure."""
275
+ incomplete, total = [], 0
276
+ for iid, text in _items_of_prefix(doc, ctx, "SVC"):
277
+ total += 1
278
+ missing = [f for f in ("Level", "Measure") if _field_value(text, f) is None]
279
+ if missing:
280
+ incomplete.append(f'{iid} missing {", ".join(missing)}')
281
+ if incomplete:
282
+ return False, "; ".join(incomplete)
283
+ return True, f"All {total} service(s) state a level and a measure."
284
+
285
+
286
+ def check_ops_review_fresh(doc: Document, ctx: dict) -> tuple[bool, str]:
287
+ """review_by is today or later (advisory; staleness ambers derived status)."""
288
+ import datetime
289
+ raw = doc.frontmatter.get("review_by")
290
+ if raw is None:
291
+ return False, "No review_by date."
292
+ try:
293
+ due = raw if isinstance(raw, datetime.date) else datetime.date.fromisoformat(str(raw))
294
+ except ValueError:
295
+ return False, f"review_by is not a date: {raw!r}"
296
+ today = datetime.date.today()
297
+ if due < today:
298
+ return False, f"operations review overdue since {due.isoformat()}"
299
+ return True, f"Next operations review {due.isoformat()}."
300
+
301
+
258
302
  def check_adr_items_have_status(doc: Document, ctx: dict) -> tuple[bool, str]:
259
303
  """Every ADR item declares a valid Status."""
260
304
  bad, total = [], 0
@@ -402,6 +446,9 @@ CHECKS: dict[str, Callable[[Document, dict], tuple[bool, str]]] = {
402
446
  "unique-id": check_unique_id,
403
447
  "items-well-formed": check_items_well_formed,
404
448
  "risk-items-complete": check_risk_items_complete,
449
+ "risk-disposition-valid": check_risk_disposition_valid,
450
+ "svc-items-complete": check_svc_items_complete,
451
+ "ops-review-fresh": check_ops_review_fresh,
405
452
  "adr-items-have-status": check_adr_items_have_status,
406
453
  "raci-one-accountable": check_raci_one_accountable,
407
454
  "story-format": check_story_format,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: docassert
3
- Version: 0.14.1
3
+ Version: 0.16.0
4
4
  Summary: Unit testing for business documents — validate structured Markdown docs against a configurable audit standard.
5
5
  Author: C4G Enterprises Inc.
6
6
  License: Apache-2.0
@@ -33,6 +33,7 @@ docassert/_data/criteria/charter.criteria.yaml
33
33
  docassert/_data/criteria/data-migration-plan.criteria.yaml
34
34
  docassert/_data/criteria/frnfr.criteria.yaml
35
35
  docassert/_data/criteria/hypercare-plan.criteria.yaml
36
+ docassert/_data/criteria/operations.criteria.yaml
36
37
  docassert/_data/criteria/post-implementation-review.criteria.yaml
37
38
  docassert/_data/criteria/prd.criteria.yaml
38
39
  docassert/_data/criteria/project.criteria.yaml
@@ -47,6 +48,7 @@ docassert/_data/criteria/test-cases.criteria.yaml
47
48
  docassert/_data/criteria/user-story.criteria.yaml
48
49
  docassert/_data/profiles/agile-delivery.yaml
49
50
  docassert/_data/profiles/lean-startup.yaml
51
+ docassert/_data/profiles/operations.yaml
50
52
  docassert/_data/profiles/regulated-industry.yaml
51
53
  docassert/_data/schema/adr.schema.json
52
54
  docassert/_data/schema/benefits-realization.schema.json
@@ -56,6 +58,7 @@ docassert/_data/schema/charter.schema.json
56
58
  docassert/_data/schema/data-migration-plan.schema.json
57
59
  docassert/_data/schema/frnfr.schema.json
58
60
  docassert/_data/schema/hypercare-plan.schema.json
61
+ docassert/_data/schema/operations.schema.json
59
62
  docassert/_data/schema/post-implementation-review.schema.json
60
63
  docassert/_data/schema/prd.schema.json
61
64
  docassert/_data/schema/project.schema.json
@@ -77,6 +80,7 @@ docassert/_data/templates/charter.template.md
77
80
  docassert/_data/templates/data-migration-plan.template.md
78
81
  docassert/_data/templates/frnfr.template.md
79
82
  docassert/_data/templates/hypercare-plan.template.md
83
+ docassert/_data/templates/operations.template.md
80
84
  docassert/_data/templates/post-implementation-review.template.md
81
85
  docassert/_data/templates/prd.template.md
82
86
  docassert/_data/templates/project.template.md
@@ -60,8 +60,9 @@ def test_load_profile_and_unknown():
60
60
  assert P.load_profile("does-not-exist", PROFILES) is None
61
61
 
62
62
 
63
- def test_available_lists_the_three_profiles():
64
- assert set(P.available(PROFILES)) == {"regulated-industry", "lean-startup", "agile-delivery"}
63
+ def test_available_lists_the_shipped_profiles():
64
+ assert set(P.available(PROFILES)) == {"regulated-industry", "lean-startup",
65
+ "agile-delivery", "operations"}
65
66
 
66
67
 
67
68
  # ── integration with the real repo ──────────────────────────────────────────
@@ -162,3 +162,114 @@ def test_render_html_risk_table():
162
162
  # severity cells are colored
163
163
  assert 'style="color:var(--bad);font-weight:600;">high</td>' in out \
164
164
  or 'style="color:var(--amber);font-weight:600;">medium</td>' in out
165
+
166
+
167
+ # ── risk disposition (ENG-PR-002) ────────────────────────────────────────────
168
+ def _risk_graph(*status_clauses):
169
+ from docassert.graph import Graph
170
+ from docassert.models import Item
171
+ g = Graph()
172
+ for i, clause in enumerate(status_clauses, 1):
173
+ text = (f"Risk {i} description. Probability: low. Impact: high. "
174
+ f"Owner: o.{i}.{clause} Response: watch.")
175
+ g.add(Item(f"XX-RISK-{i:03d}", "XX", "RISK", text, {}, "d.md",
176
+ "risk-register", "approved", "S"))
177
+ return g
178
+
179
+
180
+ def test_disposition_parsed_and_defaulted():
181
+ from docassert.status.derive import _risks
182
+ g = _risk_graph(" Status: Mitigated.", " Status: accepted.", "")
183
+ rs = _risks(g)
184
+ assert [r["disposition"] for r in rs] == ["open", "mitigated", "accepted"]
185
+ assert rs[0]["id"] == "XX-RISK-003" # open risks sort first
186
+
187
+
188
+ def test_rag_green_when_all_risks_dispositioned():
189
+ base = {"documents": [], "coverage": [], "broken_references": [],
190
+ "latest_report": None, "completeness": None}
191
+ open_r = dict(base, risks=[{"id": "R", "score": 2, "disposition": "open"}])
192
+ done_r = dict(base, risks=[{"id": "R", "score": 2, "disposition": "accepted"}])
193
+ assert S.derive_rag(open_r) == "amber"
194
+ assert S.derive_rag(done_r) == "green"
195
+
196
+
197
+ def test_disposition_check_blocks_invalid(tmp_path):
198
+ from docassert.loader import load
199
+ from docassert.structural import check_risk_disposition_valid
200
+ p = tmp_path / "r.md"
201
+ p.write_text(
202
+ "---\nkind: risk-register\nid: XX-risk-register\n"
203
+ "project: PRJ-001-XX\ntitle: T\nowner: o\nstatus: approved\n---\n\n"
204
+ "## Overview\nx\n\n## Risks\n\n"
205
+ "- **XX-RISK-001**: Bad. Probability: low. Impact: low. Owner: o. "
206
+ "Status: wontfix. Response: r.\n", encoding="utf-8")
207
+ doc = load(p)
208
+ ok, detail = check_risk_disposition_valid(
209
+ doc, {"item_sections": [{"section": "Risks", "prefix": "RISK"}]})
210
+ assert not ok and "wontfix" in detail
211
+
212
+
213
+ def test_render_html_shows_disposition_column():
214
+ _cd_root()
215
+ out = S.render_html(S.build_status(ROOT / "documents"))
216
+ assert "<th>Status</th>" in out and "open of" in out
217
+
218
+
219
+ # ── operations kind (ENG-PR-003) ─────────────────────────────────────────────
220
+ OPS_DOC = """---
221
+ kind: operations
222
+ id: XX-operations
223
+ project: PRJ-001-XX
224
+ title: Ops fixture
225
+ owner: ops.owner
226
+ status: approved
227
+ review_by: {due}
228
+ ---
229
+
230
+ ## Overview
231
+ x
232
+
233
+ ## Services
234
+
235
+ - **XX-SVC-001**: Triage. Level: within 14 days. Measure: monthly report.
236
+ """
237
+
238
+
239
+ def _ops_tree(tmp_path, due):
240
+ d = tmp_path / "documents" / "PRJ-001-XX"
241
+ d.mkdir(parents=True)
242
+ (d / "project.md").write_text(
243
+ "---\nkind: project\nid: PRJ-001-XX\ncode: XX\nname: Xy Ops\n"
244
+ "sponsor: sp.owner\nstatus: active\n---\n\n## Overview\nx\n\n## Scope\nx\n",
245
+ encoding="utf-8")
246
+ (d / "operations.md").write_text(OPS_DOC.format(due=due), encoding="utf-8")
247
+ return tmp_path / "documents"
248
+
249
+
250
+ def test_stale_operations_review_ambers_status(tmp_path):
251
+ _cd_root()
252
+ m = S.build_status(_ops_tree(tmp_path, "2020-01-01"), project="PRJ-001-XX")
253
+ assert m["operations"][0]["fresh"] is False
254
+ assert m["rag"] == "amber"
255
+ assert "OVERDUE" in S.render_markdown(m)
256
+
257
+
258
+ def test_fresh_operations_review_stays_green(tmp_path):
259
+ _cd_root()
260
+ m = S.build_status(_ops_tree(tmp_path, "2099-12-31"), project="PRJ-001-XX")
261
+ assert m["operations"][0]["fresh"] is True
262
+ assert m["rag"] == "green"
263
+ assert "Operations review" in S.render_html(m)
264
+
265
+
266
+ def test_svc_and_freshness_checks(tmp_path):
267
+ from docassert.loader import load
268
+ from docassert.structural import check_ops_review_fresh, check_svc_items_complete
269
+ docs = _ops_tree(tmp_path, "2020-01-01")
270
+ doc = load(docs / "PRJ-001-XX" / "operations.md")
271
+ ctx = {"item_sections": [{"section": "Services", "prefix": "SVC"}]}
272
+ ok, _ = check_svc_items_complete(doc, ctx)
273
+ assert ok
274
+ fresh_ok, detail = check_ops_review_fresh(doc, ctx)
275
+ assert not fresh_ok and "overdue" in detail
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes