docassert 0.18.0__tar.gz → 0.20.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 (125) hide show
  1. {docassert-0.18.0/docassert.egg-info → docassert-0.20.0}/PKG-INFO +1 -1
  2. {docassert-0.18.0 → docassert-0.20.0}/docassert/__init__.py +1 -1
  3. {docassert-0.18.0 → docassert-0.20.0}/docassert/_data/consistency.yaml +6 -0
  4. {docassert-0.18.0 → docassert-0.20.0}/docassert/consistency.py +32 -0
  5. {docassert-0.18.0 → docassert-0.20.0}/docassert/status/derive.py +69 -7
  6. {docassert-0.18.0 → docassert-0.20.0}/docassert/status/render.py +53 -61
  7. {docassert-0.18.0 → docassert-0.20.0/docassert.egg-info}/PKG-INFO +1 -1
  8. {docassert-0.18.0 → docassert-0.20.0}/tests/test_status.py +95 -3
  9. {docassert-0.18.0 → docassert-0.20.0}/LICENSE +0 -0
  10. {docassert-0.18.0 → docassert-0.20.0}/NOTICE +0 -0
  11. {docassert-0.18.0 → docassert-0.20.0}/README.md +0 -0
  12. {docassert-0.18.0 → docassert-0.20.0}/docassert/__main__.py +0 -0
  13. {docassert-0.18.0 → docassert-0.20.0}/docassert/_data/criteria/adr.criteria.yaml +0 -0
  14. {docassert-0.18.0 → docassert-0.20.0}/docassert/_data/criteria/benefits-realization.criteria.yaml +0 -0
  15. {docassert-0.18.0 → docassert-0.20.0}/docassert/_data/criteria/brd.criteria.yaml +0 -0
  16. {docassert-0.18.0 → docassert-0.20.0}/docassert/_data/criteria/business-case.criteria.yaml +0 -0
  17. {docassert-0.18.0 → docassert-0.20.0}/docassert/_data/criteria/charter.criteria.yaml +0 -0
  18. {docassert-0.18.0 → docassert-0.20.0}/docassert/_data/criteria/data-migration-plan.criteria.yaml +0 -0
  19. {docassert-0.18.0 → docassert-0.20.0}/docassert/_data/criteria/frnfr.criteria.yaml +0 -0
  20. {docassert-0.18.0 → docassert-0.20.0}/docassert/_data/criteria/hypercare-plan.criteria.yaml +0 -0
  21. {docassert-0.18.0 → docassert-0.20.0}/docassert/_data/criteria/operations.criteria.yaml +0 -0
  22. {docassert-0.18.0 → docassert-0.20.0}/docassert/_data/criteria/post-implementation-review.criteria.yaml +0 -0
  23. {docassert-0.18.0 → docassert-0.20.0}/docassert/_data/criteria/prd.criteria.yaml +0 -0
  24. {docassert-0.18.0 → docassert-0.20.0}/docassert/_data/criteria/project.criteria.yaml +0 -0
  25. {docassert-0.18.0 → docassert-0.20.0}/docassert/_data/criteria/qa-test-plan.criteria.yaml +0 -0
  26. {docassert-0.18.0 → docassert-0.20.0}/docassert/_data/criteria/raci-stakeholder.criteria.yaml +0 -0
  27. {docassert-0.18.0 → docassert-0.20.0}/docassert/_data/criteria/release-cutover-plan.criteria.yaml +0 -0
  28. {docassert-0.18.0 → docassert-0.20.0}/docassert/_data/criteria/risk-register.criteria.yaml +0 -0
  29. {docassert-0.18.0 → docassert-0.20.0}/docassert/_data/criteria/rollback-plan.criteria.yaml +0 -0
  30. {docassert-0.18.0 → docassert-0.20.0}/docassert/_data/criteria/runbook.criteria.yaml +0 -0
  31. {docassert-0.18.0 → docassert-0.20.0}/docassert/_data/criteria/status-report.criteria.yaml +0 -0
  32. {docassert-0.18.0 → docassert-0.20.0}/docassert/_data/criteria/test-cases.criteria.yaml +0 -0
  33. {docassert-0.18.0 → docassert-0.20.0}/docassert/_data/criteria/user-story.criteria.yaml +0 -0
  34. {docassert-0.18.0 → docassert-0.20.0}/docassert/_data/profiles/agile-delivery.yaml +0 -0
  35. {docassert-0.18.0 → docassert-0.20.0}/docassert/_data/profiles/lean-startup.yaml +0 -0
  36. {docassert-0.18.0 → docassert-0.20.0}/docassert/_data/profiles/operations.yaml +0 -0
  37. {docassert-0.18.0 → docassert-0.20.0}/docassert/_data/profiles/regulated-industry.yaml +0 -0
  38. {docassert-0.18.0 → docassert-0.20.0}/docassert/_data/schema/adr.schema.json +0 -0
  39. {docassert-0.18.0 → docassert-0.20.0}/docassert/_data/schema/benefits-realization.schema.json +0 -0
  40. {docassert-0.18.0 → docassert-0.20.0}/docassert/_data/schema/brd.schema.json +0 -0
  41. {docassert-0.18.0 → docassert-0.20.0}/docassert/_data/schema/business-case.schema.json +0 -0
  42. {docassert-0.18.0 → docassert-0.20.0}/docassert/_data/schema/charter.schema.json +0 -0
  43. {docassert-0.18.0 → docassert-0.20.0}/docassert/_data/schema/data-migration-plan.schema.json +0 -0
  44. {docassert-0.18.0 → docassert-0.20.0}/docassert/_data/schema/frnfr.schema.json +0 -0
  45. {docassert-0.18.0 → docassert-0.20.0}/docassert/_data/schema/hypercare-plan.schema.json +0 -0
  46. {docassert-0.18.0 → docassert-0.20.0}/docassert/_data/schema/operations.schema.json +0 -0
  47. {docassert-0.18.0 → docassert-0.20.0}/docassert/_data/schema/post-implementation-review.schema.json +0 -0
  48. {docassert-0.18.0 → docassert-0.20.0}/docassert/_data/schema/prd.schema.json +0 -0
  49. {docassert-0.18.0 → docassert-0.20.0}/docassert/_data/schema/project.schema.json +0 -0
  50. {docassert-0.18.0 → docassert-0.20.0}/docassert/_data/schema/qa-test-plan.schema.json +0 -0
  51. {docassert-0.18.0 → docassert-0.20.0}/docassert/_data/schema/raci-stakeholder.schema.json +0 -0
  52. {docassert-0.18.0 → docassert-0.20.0}/docassert/_data/schema/release-cutover-plan.schema.json +0 -0
  53. {docassert-0.18.0 → docassert-0.20.0}/docassert/_data/schema/risk-register.schema.json +0 -0
  54. {docassert-0.18.0 → docassert-0.20.0}/docassert/_data/schema/rollback-plan.schema.json +0 -0
  55. {docassert-0.18.0 → docassert-0.20.0}/docassert/_data/schema/runbook.schema.json +0 -0
  56. {docassert-0.18.0 → docassert-0.20.0}/docassert/_data/schema/status-report.schema.json +0 -0
  57. {docassert-0.18.0 → docassert-0.20.0}/docassert/_data/schema/test-cases.schema.json +0 -0
  58. {docassert-0.18.0 → docassert-0.20.0}/docassert/_data/schema/user-story.schema.json +0 -0
  59. {docassert-0.18.0 → docassert-0.20.0}/docassert/_data/skills/doc-to-pmo/SKILL.md +0 -0
  60. {docassert-0.18.0 → docassert-0.20.0}/docassert/_data/templates/adr.template.md +0 -0
  61. {docassert-0.18.0 → docassert-0.20.0}/docassert/_data/templates/benefits-realization.template.md +0 -0
  62. {docassert-0.18.0 → docassert-0.20.0}/docassert/_data/templates/brd.template.md +0 -0
  63. {docassert-0.18.0 → docassert-0.20.0}/docassert/_data/templates/business-case.template.md +0 -0
  64. {docassert-0.18.0 → docassert-0.20.0}/docassert/_data/templates/charter.template.md +0 -0
  65. {docassert-0.18.0 → docassert-0.20.0}/docassert/_data/templates/data-migration-plan.template.md +0 -0
  66. {docassert-0.18.0 → docassert-0.20.0}/docassert/_data/templates/frnfr.template.md +0 -0
  67. {docassert-0.18.0 → docassert-0.20.0}/docassert/_data/templates/hypercare-plan.template.md +0 -0
  68. {docassert-0.18.0 → docassert-0.20.0}/docassert/_data/templates/operations.template.md +0 -0
  69. {docassert-0.18.0 → docassert-0.20.0}/docassert/_data/templates/post-implementation-review.template.md +0 -0
  70. {docassert-0.18.0 → docassert-0.20.0}/docassert/_data/templates/prd.template.md +0 -0
  71. {docassert-0.18.0 → docassert-0.20.0}/docassert/_data/templates/project.template.md +0 -0
  72. {docassert-0.18.0 → docassert-0.20.0}/docassert/_data/templates/qa-test-plan.template.md +0 -0
  73. {docassert-0.18.0 → docassert-0.20.0}/docassert/_data/templates/raci-stakeholder.template.md +0 -0
  74. {docassert-0.18.0 → docassert-0.20.0}/docassert/_data/templates/release-cutover-plan.template.md +0 -0
  75. {docassert-0.18.0 → docassert-0.20.0}/docassert/_data/templates/risk-register.template.md +0 -0
  76. {docassert-0.18.0 → docassert-0.20.0}/docassert/_data/templates/rollback-plan.template.md +0 -0
  77. {docassert-0.18.0 → docassert-0.20.0}/docassert/_data/templates/runbook.template.md +0 -0
  78. {docassert-0.18.0 → docassert-0.20.0}/docassert/_data/templates/status-report.template.md +0 -0
  79. {docassert-0.18.0 → docassert-0.20.0}/docassert/_data/templates/test-cases.template.md +0 -0
  80. {docassert-0.18.0 → docassert-0.20.0}/docassert/_data/templates/user-story.template.md +0 -0
  81. {docassert-0.18.0 → docassert-0.20.0}/docassert/bridge/__init__.py +0 -0
  82. {docassert-0.18.0 → docassert-0.20.0}/docassert/bridge/board.py +0 -0
  83. {docassert-0.18.0 → docassert-0.20.0}/docassert/bridge/gh.py +0 -0
  84. {docassert-0.18.0 → docassert-0.20.0}/docassert/bridge/ops.py +0 -0
  85. {docassert-0.18.0 → docassert-0.20.0}/docassert/bridge/plan.py +0 -0
  86. {docassert-0.18.0 → docassert-0.20.0}/docassert/cli.py +0 -0
  87. {docassert-0.18.0 → docassert-0.20.0}/docassert/config.py +0 -0
  88. {docassert-0.18.0 → docassert-0.20.0}/docassert/extract.py +0 -0
  89. {docassert-0.18.0 → docassert-0.20.0}/docassert/graph.py +0 -0
  90. {docassert-0.18.0 → docassert-0.20.0}/docassert/loader.py +0 -0
  91. {docassert-0.18.0 → docassert-0.20.0}/docassert/models.py +0 -0
  92. {docassert-0.18.0 → docassert-0.20.0}/docassert/profiles.py +0 -0
  93. {docassert-0.18.0 → docassert-0.20.0}/docassert/projects.py +0 -0
  94. {docassert-0.18.0 → docassert-0.20.0}/docassert/report.py +0 -0
  95. {docassert-0.18.0 → docassert-0.20.0}/docassert/rtm.py +0 -0
  96. {docassert-0.18.0 → docassert-0.20.0}/docassert/scaffold.py +0 -0
  97. {docassert-0.18.0 → docassert-0.20.0}/docassert/semantic.py +0 -0
  98. {docassert-0.18.0 → docassert-0.20.0}/docassert/status/__init__.py +0 -0
  99. {docassert-0.18.0 → docassert-0.20.0}/docassert/structural.py +0 -0
  100. {docassert-0.18.0 → docassert-0.20.0}/docassert.egg-info/SOURCES.txt +0 -0
  101. {docassert-0.18.0 → docassert-0.20.0}/docassert.egg-info/dependency_links.txt +0 -0
  102. {docassert-0.18.0 → docassert-0.20.0}/docassert.egg-info/entry_points.txt +0 -0
  103. {docassert-0.18.0 → docassert-0.20.0}/docassert.egg-info/requires.txt +0 -0
  104. {docassert-0.18.0 → docassert-0.20.0}/docassert.egg-info/top_level.txt +0 -0
  105. {docassert-0.18.0 → docassert-0.20.0}/pyproject.toml +0 -0
  106. {docassert-0.18.0 → docassert-0.20.0}/setup.cfg +0 -0
  107. {docassert-0.18.0 → docassert-0.20.0}/tests/test_badge.py +0 -0
  108. {docassert-0.18.0 → docassert-0.20.0}/tests/test_bridge.py +0 -0
  109. {docassert-0.18.0 → docassert-0.20.0}/tests/test_config.py +0 -0
  110. {docassert-0.18.0 → docassert-0.20.0}/tests/test_consistency.py +0 -0
  111. {docassert-0.18.0 → docassert-0.20.0}/tests/test_defects.py +0 -0
  112. {docassert-0.18.0 → docassert-0.20.0}/tests/test_draft_relaxation.py +0 -0
  113. {docassert-0.18.0 → docassert-0.20.0}/tests/test_extract.py +0 -0
  114. {docassert-0.18.0 → docassert-0.20.0}/tests/test_graph.py +0 -0
  115. {docassert-0.18.0 → docassert-0.20.0}/tests/test_json_report.py +0 -0
  116. {docassert-0.18.0 → docassert-0.20.0}/tests/test_kinds_delivery.py +0 -0
  117. {docassert-0.18.0 → docassert-0.20.0}/tests/test_kinds_governance.py +0 -0
  118. {docassert-0.18.0 → docassert-0.20.0}/tests/test_kinds_operate.py +0 -0
  119. {docassert-0.18.0 → docassert-0.20.0}/tests/test_kinds_reporting.py +0 -0
  120. {docassert-0.18.0 → docassert-0.20.0}/tests/test_profiles.py +0 -0
  121. {docassert-0.18.0 → docassert-0.20.0}/tests/test_projects.py +0 -0
  122. {docassert-0.18.0 → docassert-0.20.0}/tests/test_report_extract.py +0 -0
  123. {docassert-0.18.0 → docassert-0.20.0}/tests/test_scaffold.py +0 -0
  124. {docassert-0.18.0 → docassert-0.20.0}/tests/test_semantic.py +0 -0
  125. {docassert-0.18.0 → docassert-0.20.0}/tests/test_structural.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: docassert
3
- Version: 0.18.0
3
+ Version: 0.20.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.18.0"
8
+ __version__ = "0.20.0"
@@ -39,6 +39,12 @@ coverage:
39
39
  # (0 = no cap).
40
40
  alignment_limit: 25
41
41
 
42
+ # Open risks move derived status to amber only at or above this
43
+ # probability x impact score (low=1 medium=2 high/critical=3). 0 = any open
44
+ # risk ambers (the strict pre-0.19 behavior). Exposure below the threshold
45
+ # stays fully reported; it just doesn't flatten the headline.
46
+ risk_amber_score: 6
47
+
42
48
  # Rules match in order; the first matching rule grades a link. Typed rules
43
49
  # (child_type/parent_type) come before the generic ones.
44
50
  alignment:
@@ -49,6 +49,37 @@ def check_unique_item_ids(graph) -> CheckResult:
49
49
  f"Duplicate item IDs: {detail}")
50
50
 
51
51
 
52
+ def check_sequence_acyclic(graph) -> CheckResult:
53
+ """The `after` digraph contains no cycle (a cycle is broken sequencing)."""
54
+ edges: dict[str, list[str]] = {}
55
+ for item in graph.all_items():
56
+ for target in item.links.get("after", []):
57
+ edges.setdefault(item.id, []).append(target)
58
+ state: dict[str, int] = {} # 0 visiting, 1 done
59
+
60
+ def visit(node, path):
61
+ if state.get(node) == 1:
62
+ return None
63
+ if state.get(node) == 0:
64
+ return path[path.index(node):] + [node]
65
+ state[node] = 0
66
+ for nxt in edges.get(node, []):
67
+ cycle = visit(nxt, path + [node])
68
+ if cycle:
69
+ return cycle
70
+ state[node] = 1
71
+ return None
72
+
73
+ for start in list(edges):
74
+ cycle = visit(start, [])
75
+ if cycle:
76
+ return CheckResult("sequence-acyclic", False, True,
77
+ "after-cycle: " + " → ".join(cycle))
78
+ n = sum(len(v) for v in edges.values())
79
+ return CheckResult("sequence-acyclic", True, True,
80
+ f"{n} after link(s), no cycles.")
81
+
82
+
52
83
  def check_referential_integrity(graph) -> CheckResult:
53
84
  broken = []
54
85
  for item in graph.all_items():
@@ -208,6 +239,7 @@ def run_consistency(documents_dir: str | Path = "documents",
208
239
  results = [
209
240
  check_unique_item_ids(graph),
210
241
  check_referential_integrity(graph),
242
+ check_sequence_acyclic(graph),
211
243
  check_required_links(graph, cfg),
212
244
  check_coverage(graph, cfg),
213
245
  check_profile_completeness(documents_dir),
@@ -116,6 +116,49 @@ def _broken_references(graph, code=None):
116
116
  _MS_RE = re.compile(r"^(?P<label>.+?)[:\u2014\u2013-]\s*(?P<date>\d{4}-\d{2}-\d{2})\.?\s*$")
117
117
 
118
118
 
119
+ _TSHIRT = [(1, "XS"), (2, "S"), (4, "M"), (7, "L")]
120
+
121
+
122
+ def _tshirt(points: int) -> str:
123
+ for cap, label in _TSHIRT:
124
+ if points <= cap:
125
+ return label
126
+ return "XL"
127
+
128
+
129
+ def _features(graph, code):
130
+ """Every product requirement as a chartable feature: scope points =
131
+ traced stories + verifying acceptance criteria (document arithmetic, no
132
+ estimation), plus `after` sequencing resolved into topological layers."""
133
+ prs = [i for i in graph.by_type.get("PR", []) if not code or i.project == code]
134
+ feats = {}
135
+ for pr in prs:
136
+ stories = graph.children(pr.id, "traces", "US")
137
+ acs = graph.children(pr.id, "verifies", "AC")
138
+ feats[pr.id] = {
139
+ "id": pr.id,
140
+ "text": pr.text.split(". ")[0][:80],
141
+ "stories": len(stories),
142
+ "acs": len(acs),
143
+ "points": len(stories) + len(acs),
144
+ "after": [t for t in pr.links.get("after", []) if any(
145
+ p.id == t for p in prs)],
146
+ "layer": 0,
147
+ }
148
+ # Kahn layering; a cycle (gated by sequence-acyclic) degrades to layer 0
149
+ for _ in range(len(feats)):
150
+ changed = False
151
+ for f in feats.values():
152
+ want = max((feats[d]["layer"] + 1 for d in f["after"]
153
+ if d in feats), default=0)
154
+ if want > f["layer"]:
155
+ f["layer"] = want
156
+ changed = True
157
+ if not changed:
158
+ break
159
+ return sorted(feats.values(), key=lambda f: (f["layer"], -f["points"], f["id"]))
160
+
161
+
119
162
  def _milestones(docs):
120
163
  """Dated charter milestones as temporal facts (never completion claims)."""
121
164
  import datetime
@@ -135,15 +178,29 @@ def _milestones(docs):
135
178
  due = datetime.date.fromisoformat(m.group("date"))
136
179
  except ValueError:
137
180
  continue
138
- delta = (due - today).days
139
- out.append({"label": m.group("label").strip(),
140
- "date": due.isoformat(),
141
- "days": delta,
142
- "when": ("today" if delta == 0 else
143
- "elapsed" if delta < 0 else "upcoming")})
181
+ out.append(_ms_entry(m.group("label").strip(), due, today))
182
+ for d in docs:
183
+ if d.kind != "charter":
184
+ continue
185
+ target = (d.frontmatter.get("dates") or {}).get("target")
186
+ if target:
187
+ try:
188
+ due = (target if isinstance(target, datetime.date)
189
+ else datetime.date.fromisoformat(str(target)))
190
+ if not any(x["date"] == due.isoformat() for x in out):
191
+ out.append(_ms_entry("Charter target", due, today))
192
+ except (TypeError, ValueError):
193
+ pass
144
194
  return sorted(out, key=lambda x: x["date"])
145
195
 
146
196
 
197
+ def _ms_entry(label, due, today):
198
+ delta = (due - today).days
199
+ return {"label": label, "date": due.isoformat(), "days": delta,
200
+ "when": ("today" if delta == 0 else
201
+ "elapsed" if delta < 0 else "upcoming")}
202
+
203
+
147
204
  def _operations(docs):
148
205
  """Operations documents with their review state."""
149
206
  import datetime
@@ -231,9 +288,11 @@ def build_status(documents_dir=DOCUMENTS_DIR, project: str | None = None) -> dic
231
288
  "risks": _risks(graph, code),
232
289
  "operations": _operations(docs),
233
290
  "milestones": _milestones(docs),
291
+ "features": _features(graph, code),
234
292
  "broken_references": _broken_references(graph, code),
235
293
  "latest_report": _latest_report(docs),
236
294
  "completeness": completeness,
295
+ "risk_amber_score": int(cfg.get("risk_amber_score", 6) or 0),
237
296
  }
238
297
  model["rag"] = derive_rag(model)
239
298
  return model
@@ -313,7 +372,10 @@ def derive_rag(model) -> str:
313
372
  reported = (model["latest_report"] or {}).get("rag")
314
373
  completeness_gap = bool(comp and (comp["missing_required"] or comp["incomplete_required"]
315
374
  or comp["recommended_gaps"] or comp.get("unknown")))
316
- open_risks = [r for r in model["risks"] if r.get("disposition", "open") == "open"]
375
+ threshold = model.get("risk_amber_score", 6)
376
+ open_risks = [r for r in model["risks"]
377
+ if r.get("disposition", "open") == "open"
378
+ and (threshold == 0 or r.get("score", 0) >= threshold)]
317
379
  stale_ops = [o for o in model.get("operations", []) if not o["fresh"]]
318
380
  if (coverage_gap or open_risks or stale_ops
319
381
  or reported in {"amber", "red"} or completeness_gap):
@@ -362,77 +362,62 @@ def render_html(model) -> str:
362
362
  ex = model.get("execution") or {}
363
363
  lanes_html = ""
364
364
  feats = ex.get("features", [])
365
- dated = [f for f in feats if (f.get("facts") or {}).get("created_at")]
366
- if dated:
367
- starts = [f["facts"]["created_at"] for f in dated]
368
- ends = [(f["facts"].get("closed_at") or today.isoformat()) for f in dated]
369
- bounds = [datetime.date.fromisoformat(min(starts)),
370
- max(datetime.date.fromisoformat(max(ends)), today)]
371
- for m in ms:
372
- try:
373
- bounds[1] = max(bounds[1], datetime.date.fromisoformat(m["date"]))
374
- except ValueError:
375
- pass
376
- w0 = bounds[0]
377
- span = max((bounds[1] - w0).days + 7, 14)
378
- # month gridlines
379
- months = []
380
- cur = datetime.date(w0.year, w0.month, 1)
381
- while cur <= bounds[1]:
382
- if cur >= w0:
383
- months.append(cur)
384
- cur = datetime.date(cur.year + (cur.month == 12), (cur.month % 12) + 1, 1)
385
- month_html = "".join(
386
- f'<span style="position:absolute;left:{_pct(m.isoformat(), w0, span):.2f}%;top:0;'
387
- f'font-size:9px;letter-spacing:.14em;color:{_C["dim"]};padding-left:7px;">'
388
- f'{m.strftime("%b").upper()}</span>' for m in months)
389
- grid = ", ".join(
390
- f'transparent {_pct(m.isoformat(), w0, span):.2f}%, #191d21 {_pct(m.isoformat(), w0, span):.2f}%, '
391
- f'#191d21 calc({_pct(m.isoformat(), w0, span):.2f}% + 1px), transparent calc({_pct(m.isoformat(), w0, span):.2f}% + 1px)'
392
- for m in months) or "transparent 0%"
365
+ ex_state = {}
366
+ for f in feats:
367
+ facts = f.get("facts") or {}
368
+ ex_state[f["id"]] = ("blocked" if "blocked" in (facts.get("labels") or [])
369
+ else "done" if f.get("closed") else "open")
370
+ chart = model.get("features") or []
371
+ if chart:
372
+ maxp = max(f["points"] for f in chart) or 1
373
+ maxlayer = max(f["layer"] for f in chart)
374
+ ncols = maxlayer + 1
375
+ colw = 100.0 / ncols
393
376
  fills = {"done": f'background:rgba(63,185,80,.78);color:{_C["bg"]};',
394
377
  "open": f'background:rgba(255,178,36,.85);color:{_C["bg"]};',
395
- "blocked": f'background:rgba(240,78,78,.82);color:{_C["bg"]};'}
396
- lane_rows = []
397
- for f in dated:
398
- facts = f["facts"]
399
- state = ("blocked" if "blocked" in (facts.get("labels") or [])
400
- else "done" if f.get("closed") else "open")
401
- left = _pct(facts["created_at"], w0, span)
402
- right = _pct(facts.get("closed_at") or today.isoformat(), w0, span)
403
- w = max(2.0, right - left)
404
- label = esc(facts.get("title") or f["id"])
405
- lane_rows.append(
378
+ "blocked": f'background:rgba(240,78,78,.82);color:{_C["bg"]};',
379
+ "scoped": 'background:rgba(255,255,255,.05);color:#9BA1A8;border:1px dashed #3a4046;'}
380
+ headers = "".join(
381
+ f'<span style="position:absolute;left:{i * colw:.2f}%;top:0;font-size:9px;'
382
+ f'letter-spacing:.14em;color:{_C["dim"]};padding-left:7px;">SEQ {i + 1}</span>'
383
+ for i in range(ncols))
384
+ gridlines = ", ".join(
385
+ f'transparent {i * colw:.2f}%, #191d21 {i * colw:.2f}%, '
386
+ f'#191d21 calc({i * colw:.2f}% + 1px), transparent calc({i * colw:.2f}% + 1px)'
387
+ for i in range(1, ncols)) or "transparent 0%"
388
+ rows_html = []
389
+ for f in chart:
390
+ state = ex_state.get(f["id"], "scoped")
391
+ size = _tshirt_label(f["points"])
392
+ width = max(6.0, (f["points"] / maxp) * (colw - 3))
393
+ left = f["layer"] * colw + 1
394
+ dep = f' · after {", ".join(esc(d) for d in f["after"])}' if f["after"] else ""
395
+ rows_html.append(
406
396
  f'<div style="display:grid;grid-template-columns:186px 1fr;align-items:center;'
407
397
  f'border-bottom:1px solid #14181b;">'
408
398
  f'<div style="padding-right:14px;"><div style="font-size:12px;color:{_C["soft"]};'
409
- f'line-height:1.3;">{label[:52]}</div>'
399
+ f'line-height:1.3;">{esc(f["text"])[:52]}</div>'
410
400
  f'<div style="font-size:9.5px;color:{_C["dim"]};margin-top:3px;">{esc(f["id"])}'
411
- f' · {f["stories_closed"]}/{f["stories_total"]}</div></div>'
412
- f'<div style="position:relative;height:40px;background-image:linear-gradient(90deg, {grid});">'
413
- f'<div style="position:absolute;top:9px;left:{left:.2f}%;width:{w:.2f}%;height:22px;'
414
- f'border-radius:3px;display:flex;align-items:center;padding:0 8px;font-size:9.5px;'
415
- f'letter-spacing:.02em;overflow:hidden;white-space:nowrap;{fills[state]}">{state}</div></div></div>')
416
- today_line = (f'<div style="position:absolute;left:{_pct(today.isoformat(), w0, span):.2f}%;'
417
- f'top:0;bottom:0;width:1px;background:rgba(255,178,36,.45);"></div>')
418
- review_marks = "".join(
419
- f'<div style="position:absolute;left:{_pct(o["review_by"], w0, span):.2f}%;top:50%;'
420
- f'width:10px;height:10px;background:{_C["amber"]};transform:translate(-50%,-50%) rotate(45deg);'
421
- f'border:2px solid {_C["bg"]};"></div>'
422
- for o in model.get("operations", []) if len(o.get("review_by", "")) == 10)
401
+ f' · {f["stories"]} st · {f["acs"]} ac{dep}</div></div>'
402
+ f'<div style="position:relative;height:40px;background-image:linear-gradient(90deg, {gridlines});">'
403
+ f'<div style="position:absolute;top:9px;left:{left:.2f}%;width:{width:.2f}%;height:22px;'
404
+ f'border-radius:3px;display:flex;align-items:center;gap:6px;padding:0 8px;font-size:9.5px;'
405
+ f'letter-spacing:.02em;overflow:hidden;white-space:nowrap;{fills[state]}">'
406
+ f'<b>{size}</b> {f["points"]}pt · {state}</div></div></div>')
423
407
  lanes_html = (
424
408
  f'<div style="margin:0 0 60px;">'
425
409
  f'<div style="display:grid;grid-template-columns:186px 1fr;"><span></span>'
426
- f'<div style="position:relative;height:20px;">{month_html}</div></div>'
427
- f'<div style="position:relative;border-top:1px solid {_C["line"]};">'
428
- f'<div style="position:absolute;left:186px;right:0;top:0;bottom:0;pointer-events:none;">'
429
- f'{today_line}{review_marks}</div>{"".join(lane_rows)}</div>'
410
+ f'<div style="position:relative;height:20px;">{headers}</div></div>'
411
+ f'<div style="position:relative;border-top:1px solid {_C["line"]};">{"".join(rows_html)}</div>'
430
412
  f'<div style="display:flex;gap:18px;margin-top:18px;font-size:10.5px;color:{_C["muted"]};flex-wrap:wrap;align-items:center;">'
431
- f'<span style="display:flex;align-items:center;gap:7px;"><span style="width:11px;height:11px;background:rgba(255,178,36,.85);border-radius:2px;display:inline-block;"></span>open · real created→now span</span>'
432
- f'<span style="display:flex;align-items:center;gap:7px;"><span style="width:11px;height:11px;background:rgba(63,185,80,.78);border-radius:2px;display:inline-block;"></span>done · created→closed</span>'
433
- f'<span style="display:flex;align-items:center;gap:7px;"><span style="width:11px;height:11px;background:rgba(240,78,78,.82);border-radius:2px;display:inline-block;"></span>blocked label</span>'
434
- f'<span style="margin-left:auto;display:flex;align-items:center;gap:8px;">'
435
- f'<span style="width:9px;height:9px;background:{_C["amber"]};transform:rotate(45deg);display:inline-block;"></span>operations review</span></div></div>')
413
+ f'<span>position = dependency sequence (`after` layers)</span>'
414
+ f'<span>width = <b>scope points</b> = stories + acceptance criteria (no estimation, no time axis)</span>'
415
+ f'<span>XS=1 · S=2 · M=3–4 · L=5–7 · XL=8+</span>'
416
+ f'<span style="margin-left:auto;display:flex;gap:14px;">'
417
+ f'<span style="display:flex;align-items:center;gap:6px;"><span style="width:11px;height:11px;background:rgba(63,185,80,.78);border-radius:2px;display:inline-block;"></span>done</span>'
418
+ f'<span style="display:flex;align-items:center;gap:6px;"><span style="width:11px;height:11px;background:rgba(255,178,36,.85);border-radius:2px;display:inline-block;"></span>open</span>'
419
+ f'<span style="display:flex;align-items:center;gap:6px;"><span style="width:11px;height:11px;background:rgba(240,78,78,.82);border-radius:2px;display:inline-block;"></span>blocked</span>'
420
+ f'<span style="display:flex;align-items:center;gap:6px;"><span style="width:11px;height:11px;background:rgba(255,255,255,.05);border:1px dashed #3a4046;border-radius:2px;display:inline-block;"></span>scoped</span></span></div></div>')
436
421
 
437
422
  roadmap_html = ""
438
423
  if timeline_html or lanes_html:
@@ -591,6 +576,13 @@ def render_html(model) -> str:
591
576
  """
592
577
 
593
578
 
579
+ def _tshirt_label(points: int) -> str:
580
+ for cap, label in ((1, "XS"), (2, "S"), (4, "M"), (7, "L")):
581
+ if points <= cap:
582
+ return label
583
+ return "XL"
584
+
585
+
594
586
  def _section(num, name, aside) -> str:
595
587
  return (f'<div class="sect"><span class="num">{num}</span>'
596
588
  f'<span class="name">{name}</span><span class="rule"></span>'
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: docassert
3
- Version: 0.18.0
3
+ Version: 0.20.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
@@ -53,7 +53,9 @@ def test_rag_red_on_failing_approved_doc():
53
53
 
54
54
 
55
55
  def test_rag_amber_on_open_risk():
56
- assert S.derive_rag(_model(risks=[{"id": "RISK-1", "score": 4}])) == "amber"
56
+ # at or above the appetite (default 6) ambers; below stays green
57
+ assert S.derive_rag(_model(risks=[{"id": "RISK-1", "score": 6}])) == "amber"
58
+ assert S.derive_rag(_model(risks=[{"id": "RISK-1", "score": 4}])) == "green"
57
59
 
58
60
 
59
61
  def test_rag_amber_on_coverage_gap():
@@ -189,8 +191,8 @@ def test_disposition_parsed_and_defaulted():
189
191
  def test_rag_green_when_all_risks_dispositioned():
190
192
  base = {"documents": [], "coverage": [], "broken_references": [],
191
193
  "latest_report": None, "completeness": None}
192
- open_r = dict(base, risks=[{"id": "R", "score": 2, "disposition": "open"}])
193
- done_r = dict(base, risks=[{"id": "R", "score": 2, "disposition": "accepted"}])
194
+ open_r = dict(base, risks=[{"id": "R", "score": 6, "disposition": "open"}])
195
+ done_r = dict(base, risks=[{"id": "R", "score": 6, "disposition": "accepted"}])
194
196
  assert S.derive_rag(open_r) == "amber"
195
197
  assert S.derive_rag(done_r) == "green"
196
198
 
@@ -275,3 +277,93 @@ def test_svc_and_freshness_checks(tmp_path):
275
277
  assert ok
276
278
  fresh_ok, detail = check_ops_review_fresh(doc, ctx)
277
279
  assert not fresh_ok and "overdue" in detail
280
+
281
+
282
+ # ── risk appetite (spec 0.7.0) ───────────────────────────────────────────────
283
+ def test_low_grade_open_risks_do_not_amber():
284
+ base = {"documents": [], "coverage": [], "broken_references": [],
285
+ "latest_report": None, "completeness": None, "operations": [],
286
+ "risk_amber_score": 6}
287
+ low = dict(base, risks=[{"id": "R", "score": 4, "disposition": "open"}])
288
+ hi = dict(base, risks=[{"id": "R", "score": 6, "disposition": "open"}])
289
+ strict = dict(low, risk_amber_score=0)
290
+ assert S.derive_rag(low) == "green"
291
+ assert S.derive_rag(hi) == "amber"
292
+ assert S.derive_rag(strict) == "amber"
293
+
294
+
295
+ def test_charter_target_becomes_implicit_milestone():
296
+ _cd_root()
297
+ m = S.build_status(ROOT / "documents", project="PRJ-002-ATL")
298
+ dates = [x["label"] for x in m["milestones"]]
299
+ assert any("Charter target" in lbl or lbl for lbl in dates) or m["milestones"] == []
300
+ # Aurora has explicit dated milestones AND a target; no duplicates by date
301
+ m2 = S.build_status(ROOT / "documents", project="PRJ-001-AUR")
302
+ seen = [x["date"] for x in m2["milestones"]]
303
+ assert len(seen) == len(set(seen))
304
+
305
+
306
+ # ── sequence-and-size charts (sponsor direction, spec 0.8.0) ─────────────────
307
+ def _seq_tree(tmp_path, cyclic=False):
308
+ d = tmp_path / "documents" / "PRJ-001-SQ"
309
+ d.mkdir(parents=True)
310
+ (d / "project.md").write_text(
311
+ "---\nkind: project\nid: PRJ-001-SQ\ncode: SQ\nname: Seq Fixture\n"
312
+ "sponsor: sp.owner\nstatus: active\n---\n\n## Overview\nx\n\n## Scope\nx\n",
313
+ encoding="utf-8")
314
+ first_after = "; after: SQ-PR-003" if cyclic else ""
315
+ (d / "prd.md").write_text(
316
+ "---\nkind: prd\nid: SQ-prd\nproject: PRJ-001-SQ\ntitle: T\n"
317
+ "owner: ow.ner\nstatus: approved\n---\n\n## Overview\nx\n\n"
318
+ "## Product Requirements\n\n"
319
+ f"- **SQ-PR-001** (traces: SQ-BR-001{first_after}): First thing.\n"
320
+ "- **SQ-PR-002** (traces: SQ-BR-001; after: SQ-PR-001): Second thing.\n"
321
+ "- **SQ-PR-003** (traces: SQ-BR-001; after: SQ-PR-002): Third thing.\n\n"
322
+ "## Acceptance Criteria\n\n"
323
+ "- **SQ-AC-001** (verifies: SQ-PR-001): Given a, when b, then c.\n"
324
+ "- **SQ-AC-002** (verifies: SQ-PR-001): Given d, when e, then f.\n"
325
+ "- **SQ-AC-003** (verifies: SQ-PR-002): Given g, when h, then i.\n",
326
+ encoding="utf-8")
327
+ (d / "brd.md").write_text(
328
+ "---\nkind: brd\nid: SQ-brd\nproject: PRJ-001-SQ\ntitle: T\n"
329
+ "owner: ow.ner\nstatus: approved\n---\n\n## Purpose\nx\n\n"
330
+ "## Business Requirements\n\n"
331
+ "- **SQ-BR-001**: The business shall reach 100% of the metric by 2026-12-31.\n\n"
332
+ "## Out of Scope\nx\n", encoding="utf-8")
333
+ (d / "user-story.md").write_text(
334
+ "---\nkind: user-story\nid: SQ-user-story\nproject: PRJ-001-SQ\ntitle: T\n"
335
+ "owner: ow.ner\nstatus: approved\n---\n\n## Overview\nx\n\n"
336
+ "## User Stories\n\n"
337
+ "- **SQ-US-001** (traces: SQ-PR-001): As a user, I want one so that value.\n"
338
+ "- **SQ-US-002** (traces: SQ-PR-003): As a user, I want three so that value.\n",
339
+ encoding="utf-8")
340
+ return tmp_path / "documents"
341
+
342
+
343
+ def test_features_points_layers_and_tshirt(tmp_path):
344
+ _cd_root()
345
+ m = S.build_status(_seq_tree(tmp_path), project="PRJ-001-SQ")
346
+ by = {f["id"]: f for f in m["features"]}
347
+ assert by["SQ-PR-001"]["points"] == 3 and by["SQ-PR-001"]["layer"] == 0
348
+ assert by["SQ-PR-002"]["points"] == 1 and by["SQ-PR-002"]["layer"] == 1
349
+ assert by["SQ-PR-003"]["points"] == 1 and by["SQ-PR-003"]["layer"] == 2
350
+ from docassert.status.derive import _tshirt
351
+ assert [_tshirt(n) for n in (1, 2, 3, 5, 9)] == ["XS", "S", "M", "L", "XL"]
352
+
353
+
354
+ def test_sequence_chart_renders_without_time_axis(tmp_path):
355
+ _cd_root()
356
+ m = S.build_status(_seq_tree(tmp_path), project="PRJ-001-SQ")
357
+ out = S.render_html(m)
358
+ assert "SEQ 1" in out and "scope points" in out
359
+ assert "XS=1" in out and "scoped" in out
360
+ assert "created→closed" not in out and "JUL" not in out
361
+
362
+
363
+ def test_after_cycle_blocks(tmp_path):
364
+ from docassert.consistency import check_sequence_acyclic
365
+ from docassert.graph import build_graph
366
+ _cd_root()
367
+ g = build_graph(_seq_tree(tmp_path, cyclic=True))
368
+ r = check_sequence_acyclic(g)
369
+ assert not r.passed and r.blocking and "after-cycle" in r.detail
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes