docassert 0.19.0__tar.gz → 0.20.1__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.19.0/docassert.egg-info → docassert-0.20.1}/PKG-INFO +1 -1
  2. {docassert-0.19.0 → docassert-0.20.1}/docassert/__init__.py +1 -1
  3. {docassert-0.19.0 → docassert-0.20.1}/docassert/consistency.py +32 -0
  4. {docassert-0.19.0 → docassert-0.20.1}/docassert/status/derive.py +44 -0
  5. {docassert-0.19.0 → docassert-0.20.1}/docassert/status/render.py +109 -71
  6. {docassert-0.19.0 → docassert-0.20.1/docassert.egg-info}/PKG-INFO +1 -1
  7. {docassert-0.19.0 → docassert-0.20.1}/tests/test_status.py +78 -0
  8. {docassert-0.19.0 → docassert-0.20.1}/LICENSE +0 -0
  9. {docassert-0.19.0 → docassert-0.20.1}/NOTICE +0 -0
  10. {docassert-0.19.0 → docassert-0.20.1}/README.md +0 -0
  11. {docassert-0.19.0 → docassert-0.20.1}/docassert/__main__.py +0 -0
  12. {docassert-0.19.0 → docassert-0.20.1}/docassert/_data/consistency.yaml +0 -0
  13. {docassert-0.19.0 → docassert-0.20.1}/docassert/_data/criteria/adr.criteria.yaml +0 -0
  14. {docassert-0.19.0 → docassert-0.20.1}/docassert/_data/criteria/benefits-realization.criteria.yaml +0 -0
  15. {docassert-0.19.0 → docassert-0.20.1}/docassert/_data/criteria/brd.criteria.yaml +0 -0
  16. {docassert-0.19.0 → docassert-0.20.1}/docassert/_data/criteria/business-case.criteria.yaml +0 -0
  17. {docassert-0.19.0 → docassert-0.20.1}/docassert/_data/criteria/charter.criteria.yaml +0 -0
  18. {docassert-0.19.0 → docassert-0.20.1}/docassert/_data/criteria/data-migration-plan.criteria.yaml +0 -0
  19. {docassert-0.19.0 → docassert-0.20.1}/docassert/_data/criteria/frnfr.criteria.yaml +0 -0
  20. {docassert-0.19.0 → docassert-0.20.1}/docassert/_data/criteria/hypercare-plan.criteria.yaml +0 -0
  21. {docassert-0.19.0 → docassert-0.20.1}/docassert/_data/criteria/operations.criteria.yaml +0 -0
  22. {docassert-0.19.0 → docassert-0.20.1}/docassert/_data/criteria/post-implementation-review.criteria.yaml +0 -0
  23. {docassert-0.19.0 → docassert-0.20.1}/docassert/_data/criteria/prd.criteria.yaml +0 -0
  24. {docassert-0.19.0 → docassert-0.20.1}/docassert/_data/criteria/project.criteria.yaml +0 -0
  25. {docassert-0.19.0 → docassert-0.20.1}/docassert/_data/criteria/qa-test-plan.criteria.yaml +0 -0
  26. {docassert-0.19.0 → docassert-0.20.1}/docassert/_data/criteria/raci-stakeholder.criteria.yaml +0 -0
  27. {docassert-0.19.0 → docassert-0.20.1}/docassert/_data/criteria/release-cutover-plan.criteria.yaml +0 -0
  28. {docassert-0.19.0 → docassert-0.20.1}/docassert/_data/criteria/risk-register.criteria.yaml +0 -0
  29. {docassert-0.19.0 → docassert-0.20.1}/docassert/_data/criteria/rollback-plan.criteria.yaml +0 -0
  30. {docassert-0.19.0 → docassert-0.20.1}/docassert/_data/criteria/runbook.criteria.yaml +0 -0
  31. {docassert-0.19.0 → docassert-0.20.1}/docassert/_data/criteria/status-report.criteria.yaml +0 -0
  32. {docassert-0.19.0 → docassert-0.20.1}/docassert/_data/criteria/test-cases.criteria.yaml +0 -0
  33. {docassert-0.19.0 → docassert-0.20.1}/docassert/_data/criteria/user-story.criteria.yaml +0 -0
  34. {docassert-0.19.0 → docassert-0.20.1}/docassert/_data/profiles/agile-delivery.yaml +0 -0
  35. {docassert-0.19.0 → docassert-0.20.1}/docassert/_data/profiles/lean-startup.yaml +0 -0
  36. {docassert-0.19.0 → docassert-0.20.1}/docassert/_data/profiles/operations.yaml +0 -0
  37. {docassert-0.19.0 → docassert-0.20.1}/docassert/_data/profiles/regulated-industry.yaml +0 -0
  38. {docassert-0.19.0 → docassert-0.20.1}/docassert/_data/schema/adr.schema.json +0 -0
  39. {docassert-0.19.0 → docassert-0.20.1}/docassert/_data/schema/benefits-realization.schema.json +0 -0
  40. {docassert-0.19.0 → docassert-0.20.1}/docassert/_data/schema/brd.schema.json +0 -0
  41. {docassert-0.19.0 → docassert-0.20.1}/docassert/_data/schema/business-case.schema.json +0 -0
  42. {docassert-0.19.0 → docassert-0.20.1}/docassert/_data/schema/charter.schema.json +0 -0
  43. {docassert-0.19.0 → docassert-0.20.1}/docassert/_data/schema/data-migration-plan.schema.json +0 -0
  44. {docassert-0.19.0 → docassert-0.20.1}/docassert/_data/schema/frnfr.schema.json +0 -0
  45. {docassert-0.19.0 → docassert-0.20.1}/docassert/_data/schema/hypercare-plan.schema.json +0 -0
  46. {docassert-0.19.0 → docassert-0.20.1}/docassert/_data/schema/operations.schema.json +0 -0
  47. {docassert-0.19.0 → docassert-0.20.1}/docassert/_data/schema/post-implementation-review.schema.json +0 -0
  48. {docassert-0.19.0 → docassert-0.20.1}/docassert/_data/schema/prd.schema.json +0 -0
  49. {docassert-0.19.0 → docassert-0.20.1}/docassert/_data/schema/project.schema.json +0 -0
  50. {docassert-0.19.0 → docassert-0.20.1}/docassert/_data/schema/qa-test-plan.schema.json +0 -0
  51. {docassert-0.19.0 → docassert-0.20.1}/docassert/_data/schema/raci-stakeholder.schema.json +0 -0
  52. {docassert-0.19.0 → docassert-0.20.1}/docassert/_data/schema/release-cutover-plan.schema.json +0 -0
  53. {docassert-0.19.0 → docassert-0.20.1}/docassert/_data/schema/risk-register.schema.json +0 -0
  54. {docassert-0.19.0 → docassert-0.20.1}/docassert/_data/schema/rollback-plan.schema.json +0 -0
  55. {docassert-0.19.0 → docassert-0.20.1}/docassert/_data/schema/runbook.schema.json +0 -0
  56. {docassert-0.19.0 → docassert-0.20.1}/docassert/_data/schema/status-report.schema.json +0 -0
  57. {docassert-0.19.0 → docassert-0.20.1}/docassert/_data/schema/test-cases.schema.json +0 -0
  58. {docassert-0.19.0 → docassert-0.20.1}/docassert/_data/schema/user-story.schema.json +0 -0
  59. {docassert-0.19.0 → docassert-0.20.1}/docassert/_data/skills/doc-to-pmo/SKILL.md +0 -0
  60. {docassert-0.19.0 → docassert-0.20.1}/docassert/_data/templates/adr.template.md +0 -0
  61. {docassert-0.19.0 → docassert-0.20.1}/docassert/_data/templates/benefits-realization.template.md +0 -0
  62. {docassert-0.19.0 → docassert-0.20.1}/docassert/_data/templates/brd.template.md +0 -0
  63. {docassert-0.19.0 → docassert-0.20.1}/docassert/_data/templates/business-case.template.md +0 -0
  64. {docassert-0.19.0 → docassert-0.20.1}/docassert/_data/templates/charter.template.md +0 -0
  65. {docassert-0.19.0 → docassert-0.20.1}/docassert/_data/templates/data-migration-plan.template.md +0 -0
  66. {docassert-0.19.0 → docassert-0.20.1}/docassert/_data/templates/frnfr.template.md +0 -0
  67. {docassert-0.19.0 → docassert-0.20.1}/docassert/_data/templates/hypercare-plan.template.md +0 -0
  68. {docassert-0.19.0 → docassert-0.20.1}/docassert/_data/templates/operations.template.md +0 -0
  69. {docassert-0.19.0 → docassert-0.20.1}/docassert/_data/templates/post-implementation-review.template.md +0 -0
  70. {docassert-0.19.0 → docassert-0.20.1}/docassert/_data/templates/prd.template.md +0 -0
  71. {docassert-0.19.0 → docassert-0.20.1}/docassert/_data/templates/project.template.md +0 -0
  72. {docassert-0.19.0 → docassert-0.20.1}/docassert/_data/templates/qa-test-plan.template.md +0 -0
  73. {docassert-0.19.0 → docassert-0.20.1}/docassert/_data/templates/raci-stakeholder.template.md +0 -0
  74. {docassert-0.19.0 → docassert-0.20.1}/docassert/_data/templates/release-cutover-plan.template.md +0 -0
  75. {docassert-0.19.0 → docassert-0.20.1}/docassert/_data/templates/risk-register.template.md +0 -0
  76. {docassert-0.19.0 → docassert-0.20.1}/docassert/_data/templates/rollback-plan.template.md +0 -0
  77. {docassert-0.19.0 → docassert-0.20.1}/docassert/_data/templates/runbook.template.md +0 -0
  78. {docassert-0.19.0 → docassert-0.20.1}/docassert/_data/templates/status-report.template.md +0 -0
  79. {docassert-0.19.0 → docassert-0.20.1}/docassert/_data/templates/test-cases.template.md +0 -0
  80. {docassert-0.19.0 → docassert-0.20.1}/docassert/_data/templates/user-story.template.md +0 -0
  81. {docassert-0.19.0 → docassert-0.20.1}/docassert/bridge/__init__.py +0 -0
  82. {docassert-0.19.0 → docassert-0.20.1}/docassert/bridge/board.py +0 -0
  83. {docassert-0.19.0 → docassert-0.20.1}/docassert/bridge/gh.py +0 -0
  84. {docassert-0.19.0 → docassert-0.20.1}/docassert/bridge/ops.py +0 -0
  85. {docassert-0.19.0 → docassert-0.20.1}/docassert/bridge/plan.py +0 -0
  86. {docassert-0.19.0 → docassert-0.20.1}/docassert/cli.py +0 -0
  87. {docassert-0.19.0 → docassert-0.20.1}/docassert/config.py +0 -0
  88. {docassert-0.19.0 → docassert-0.20.1}/docassert/extract.py +0 -0
  89. {docassert-0.19.0 → docassert-0.20.1}/docassert/graph.py +0 -0
  90. {docassert-0.19.0 → docassert-0.20.1}/docassert/loader.py +0 -0
  91. {docassert-0.19.0 → docassert-0.20.1}/docassert/models.py +0 -0
  92. {docassert-0.19.0 → docassert-0.20.1}/docassert/profiles.py +0 -0
  93. {docassert-0.19.0 → docassert-0.20.1}/docassert/projects.py +0 -0
  94. {docassert-0.19.0 → docassert-0.20.1}/docassert/report.py +0 -0
  95. {docassert-0.19.0 → docassert-0.20.1}/docassert/rtm.py +0 -0
  96. {docassert-0.19.0 → docassert-0.20.1}/docassert/scaffold.py +0 -0
  97. {docassert-0.19.0 → docassert-0.20.1}/docassert/semantic.py +0 -0
  98. {docassert-0.19.0 → docassert-0.20.1}/docassert/status/__init__.py +0 -0
  99. {docassert-0.19.0 → docassert-0.20.1}/docassert/structural.py +0 -0
  100. {docassert-0.19.0 → docassert-0.20.1}/docassert.egg-info/SOURCES.txt +0 -0
  101. {docassert-0.19.0 → docassert-0.20.1}/docassert.egg-info/dependency_links.txt +0 -0
  102. {docassert-0.19.0 → docassert-0.20.1}/docassert.egg-info/entry_points.txt +0 -0
  103. {docassert-0.19.0 → docassert-0.20.1}/docassert.egg-info/requires.txt +0 -0
  104. {docassert-0.19.0 → docassert-0.20.1}/docassert.egg-info/top_level.txt +0 -0
  105. {docassert-0.19.0 → docassert-0.20.1}/pyproject.toml +0 -0
  106. {docassert-0.19.0 → docassert-0.20.1}/setup.cfg +0 -0
  107. {docassert-0.19.0 → docassert-0.20.1}/tests/test_badge.py +0 -0
  108. {docassert-0.19.0 → docassert-0.20.1}/tests/test_bridge.py +0 -0
  109. {docassert-0.19.0 → docassert-0.20.1}/tests/test_config.py +0 -0
  110. {docassert-0.19.0 → docassert-0.20.1}/tests/test_consistency.py +0 -0
  111. {docassert-0.19.0 → docassert-0.20.1}/tests/test_defects.py +0 -0
  112. {docassert-0.19.0 → docassert-0.20.1}/tests/test_draft_relaxation.py +0 -0
  113. {docassert-0.19.0 → docassert-0.20.1}/tests/test_extract.py +0 -0
  114. {docassert-0.19.0 → docassert-0.20.1}/tests/test_graph.py +0 -0
  115. {docassert-0.19.0 → docassert-0.20.1}/tests/test_json_report.py +0 -0
  116. {docassert-0.19.0 → docassert-0.20.1}/tests/test_kinds_delivery.py +0 -0
  117. {docassert-0.19.0 → docassert-0.20.1}/tests/test_kinds_governance.py +0 -0
  118. {docassert-0.19.0 → docassert-0.20.1}/tests/test_kinds_operate.py +0 -0
  119. {docassert-0.19.0 → docassert-0.20.1}/tests/test_kinds_reporting.py +0 -0
  120. {docassert-0.19.0 → docassert-0.20.1}/tests/test_profiles.py +0 -0
  121. {docassert-0.19.0 → docassert-0.20.1}/tests/test_projects.py +0 -0
  122. {docassert-0.19.0 → docassert-0.20.1}/tests/test_report_extract.py +0 -0
  123. {docassert-0.19.0 → docassert-0.20.1}/tests/test_scaffold.py +0 -0
  124. {docassert-0.19.0 → docassert-0.20.1}/tests/test_semantic.py +0 -0
  125. {docassert-0.19.0 → docassert-0.20.1}/tests/test_structural.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: docassert
3
- Version: 0.19.0
3
+ Version: 0.20.1
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.19.0"
8
+ __version__ = "0.20.1"
@@ -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
@@ -245,6 +288,7 @@ def build_status(documents_dir=DOCUMENTS_DIR, project: str | None = None) -> dic
245
288
  "risks": _risks(graph, code),
246
289
  "operations": _operations(docs),
247
290
  "milestones": _milestones(docs),
291
+ "features": _features(graph, code),
248
292
  "broken_references": _broken_references(graph, code),
249
293
  "latest_report": _latest_report(docs),
250
294
  "completeness": completeness,
@@ -142,6 +142,31 @@ def _today():
142
142
  return datetime.date.today()
143
143
 
144
144
 
145
+ def _verdict_causes(model) -> list[str]:
146
+ """The raw cause strings behind the verdict (also the verdict tooltip)."""
147
+ causes = []
148
+ gaps = [c for c in model["coverage"] if c["gaps"]]
149
+ if gaps:
150
+ causes.append(f"{len(gaps)} coverage chain(s) with gaps")
151
+ failing = [d for d in model["documents"] if not d["passing"]]
152
+ if failing:
153
+ causes.append(f"{len(failing)} document(s) failing audit")
154
+ if model["broken_references"]:
155
+ causes.append(f"{len(model['broken_references'])} broken reference(s)")
156
+ comp = model.get("completeness")
157
+ if comp and comp.get("blocks"):
158
+ causes.append("required documents missing under an enforced profile")
159
+ thr = model.get("risk_amber_score", 6)
160
+ open_r = [r for r in model["risks"] if r.get("disposition", "open") == "open"
161
+ and (thr == 0 or r.get("score", 0) >= thr)]
162
+ if open_r:
163
+ causes.append(f"{len(open_r)} open risk(s) at or above the appetite (score ≥ {thr})")
164
+ stale = [o for o in model.get("operations", []) if not o["fresh"]]
165
+ if stale:
166
+ causes.append(f"operations review overdue since {stale[0]['review_by']}")
167
+ return causes
168
+
169
+
145
170
  def _verdict(model) -> str:
146
171
  """One decision-grade sentence composed only from recorded causes."""
147
172
  rag = model["rag"]
@@ -259,8 +284,16 @@ def render_html(model) -> str:
259
284
  (f"{esc(nxt[1])} · {nxt[2]}" if nxt else "no dated milestone")),
260
285
  ("DOC SET", docset_v, docset_c, docset_sub),
261
286
  ]
287
+ stat_tips = {
288
+ "HEALTH": "Derived RAG. Red: failing approved docs, broken references, or enforced profile gaps. Amber: coverage gaps, open risks at or above the risk appetite, stale operations review, or an amber/red status report. Green: none of those.",
289
+ "DOCS APPROVED": "Documents with approved or baselined status, of all documents in this project. The sub-line counts documents passing every blocking structural check.",
290
+ "COVERAGE": "Traceability links present over links required, across every configured chain (BR→PR, PR→AC, AC→TC, and any others).",
291
+ "OPEN RISKS": "Risks with disposition open. Dispositioned risks (mitigated, accepted, closed) stay on the register as record but do not count here.",
292
+ "NEXT MILESTONE": "Days to the nearest upcoming dated marker: a dated charter milestone or the operations review. Temporal fact only.",
293
+ "DOC SET": "Profile completeness: required kinds complete (approved and passing audit) over required kinds expected.",
294
+ }
262
295
  stat_html = "".join(
263
- f'<div style="padding:22px 20px;border-left:1px solid {_C["line"]};">'
296
+ f'<div title="{esc(stat_tips.get(k, ""))}" style="cursor:help;padding:22px 20px;border-left:1px solid {_C["line"]};">'
264
297
  f'<div class="chiplabel" style="margin-bottom:14px;">{k}</div>'
265
298
  f'<div style="font-family:Archivo,system-ui,sans-serif;font-weight:800;font-size:30px;letter-spacing:-.02em;'
266
299
  f'line-height:1;color:{col};font-variant-numeric:tabular-nums;">{esc(v)}</div>'
@@ -278,8 +311,11 @@ def render_html(model) -> str:
278
311
  f'{_C["green"] if i < filled else _C["line"]};"></span>'
279
312
  for i in range(cells))
280
313
  ratio_c = _C["green"] if covered == total else _C["amberT"]
314
+ tip = ("all links traced" if not cov["gaps"] else
315
+ "gaps: " + ", ".join(cov["gaps"]))
281
316
  meters.append(
282
- f'<div><div style="display:flex;justify-content:space-between;align-items:baseline;margin-bottom:11px;">'
317
+ f'<div title="{esc(tip)}" style="cursor:help;">'
318
+ f'<div style="display:flex;justify-content:space-between;align-items:baseline;margin-bottom:11px;">'
283
319
  f'<span style="font-size:12.5px;color:{_C["soft"]};">{esc(cov["label"])}</span>'
284
320
  f'<span style="font-family:Archivo,system-ui,sans-serif;font-weight:700;font-size:13px;color:{ratio_c};">'
285
321
  f'{covered}/{total}</span></div><div style="display:flex;gap:4px;">{cell_html}</div></div>')
@@ -342,8 +378,11 @@ def render_html(model) -> str:
342
378
  if m["when"] == "elapsed":
343
379
  return f'color:{_C["muted"]};background:#14181b;'
344
380
  return f'color:{_C["muted"]};background:#14181b;'
381
+ ms_tip = ("Temporal fact only: elapsed means the date passed, "
382
+ "not that the milestone completed (spec §13.2).")
345
383
  cols = "".join(
346
- f'<div style="display:flex;flex-direction:column;align-items:flex-start;">'
384
+ f'<div title="{esc(m["label"])} · {esc(m["date"])} · {esc(ms_tip)}" '
385
+ f'style="cursor:help;display:flex;flex-direction:column;align-items:flex-start;">'
347
386
  f'<span style="display:block;width:14px;height:14px;border-radius:50%;'
348
387
  f'border:2px solid {_C["bg"]};{_dot(m)}"></span>'
349
388
  f'<div style="font-family:Archivo,system-ui,sans-serif;font-weight:700;font-size:13px;color:{_C["text"]};'
@@ -362,77 +401,67 @@ def render_html(model) -> str:
362
401
  ex = model.get("execution") or {}
363
402
  lanes_html = ""
364
403
  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%"
404
+ ex_state = {}
405
+ for f in feats:
406
+ facts = f.get("facts") or {}
407
+ ex_state[f["id"]] = ("blocked" if "blocked" in (facts.get("labels") or [])
408
+ else "done" if f.get("closed") else "open")
409
+ chart = model.get("features") or []
410
+ if chart:
411
+ maxp = max(f["points"] for f in chart) or 1
412
+ maxlayer = max(f["layer"] for f in chart)
413
+ ncols = maxlayer + 1
414
+ colw = 100.0 / ncols
393
415
  fills = {"done": f'background:rgba(63,185,80,.78);color:{_C["bg"]};',
394
416
  "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(
406
- f'<div style="display:grid;grid-template-columns:186px 1fr;align-items:center;'
417
+ "blocked": f'background:rgba(240,78,78,.82);color:{_C["bg"]};',
418
+ "scoped": 'background:rgba(255,255,255,.05);color:#9BA1A8;border:1px dashed #3a4046;'}
419
+ headers = "".join(
420
+ f'<span style="position:absolute;left:{i * colw:.2f}%;top:0;font-size:9px;'
421
+ f'letter-spacing:.14em;color:{_C["dim"]};padding-left:7px;">SEQ {i + 1}</span>'
422
+ for i in range(ncols))
423
+ gridlines = ", ".join(
424
+ f'transparent {i * colw:.2f}%, #191d21 {i * colw:.2f}%, '
425
+ f'#191d21 calc({i * colw:.2f}% + 1px), transparent calc({i * colw:.2f}% + 1px)'
426
+ for i in range(1, ncols)) or "transparent 0%"
427
+ rows_html = []
428
+ for f in chart:
429
+ state = ex_state.get(f["id"], "scoped")
430
+ size = _tshirt_label(f["points"])
431
+ width = max(6.0, (f["points"] / maxp) * (colw - 3))
432
+ left = f["layer"] * colw + 1
433
+ dep = f' · after {", ".join(esc(d) for d in f["after"])}' if f["after"] else ""
434
+ bar_tip = (f'{f["text"]} — {f["points"]} scope point(s) = '
435
+ f'{f["stories"]} story(ies) + {f["acs"]} acceptance criteria. '
436
+ + (f'Sequenced after {", ".join(f["after"])}. ' if f["after"] else "")
437
+ + ("State from the bridge issue." if f["id"] in ex_state
438
+ else "No execution data yet: scoped only."))
439
+ rows_html.append(
440
+ f'<div title="{esc(bar_tip)}" style="cursor:help;display:grid;grid-template-columns:186px 1fr;align-items:center;'
407
441
  f'border-bottom:1px solid #14181b;">'
408
442
  f'<div style="padding-right:14px;"><div style="font-size:12px;color:{_C["soft"]};'
409
- f'line-height:1.3;">{label[:52]}</div>'
443
+ f'line-height:1.3;">{esc(f["text"])[:52]}</div>'
410
444
  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)
445
+ f' · {f["stories"]} st · {f["acs"]} ac{dep}</div></div>'
446
+ f'<div style="position:relative;height:40px;background-image:linear-gradient(90deg, {gridlines});">'
447
+ f'<div style="position:absolute;top:9px;left:{left:.2f}%;width:{width:.2f}%;height:22px;'
448
+ f'border-radius:3px;display:flex;align-items:center;gap:6px;padding:0 8px;font-size:9.5px;'
449
+ f'letter-spacing:.02em;overflow:hidden;white-space:nowrap;{fills[state]}">'
450
+ f'<b>{size}</b> {f["points"]}pt · {state}</div></div></div>')
423
451
  lanes_html = (
424
452
  f'<div style="margin:0 0 60px;">'
425
453
  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>'
454
+ f'<div style="position:relative;height:20px;">{headers}</div></div>'
455
+ f'<div style="position:relative;border-top:1px solid {_C["line"]};">{"".join(rows_html)}</div>'
430
456
  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>')
457
+ f'<span>position = dependency sequence (`after` layers)</span>'
458
+ f'<span>width = <b>scope points</b> = stories + acceptance criteria (no estimation, no time axis)</span>'
459
+ f'<span>XS=1 · S=2 · M=3–4 · L=5–7 · XL=8+</span>'
460
+ f'<span style="margin-left:auto;display:flex;gap:14px;">'
461
+ 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>'
462
+ 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>'
463
+ 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>'
464
+ 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
465
 
437
466
  roadmap_html = ""
438
467
  if timeline_html or lanes_html:
@@ -505,6 +534,8 @@ def render_html(model) -> str:
505
534
  work_aside = (f"{ex.get('stories_closed', 0)}/{ex.get('stories_total', 0)} stories closed"
506
535
  if ex else "bridge-managed issues")
507
536
  verdict = _verdict(model)
537
+ causes = _verdict_causes(model)
538
+ verdict_tip = ("Causes: " + "; ".join(causes)) if causes else "No amber or red causes recorded."
508
539
  payload = _json_embed({"risks": risks_data, "docs": docs_data, "work": work_data,
509
540
  "C": _C})
510
541
 
@@ -539,7 +570,7 @@ def render_html(model) -> str:
539
570
 
540
571
  <div style="display:flex;gap:18px;margin:28px 0 44px;padding:20px 22px;background:rgba(255,178,36,.06);border-left:3px solid {rc};">
541
572
  <span style="font-family:Archivo,system-ui,sans-serif;font-weight:900;font-size:12px;letter-spacing:.16em;color:{rt};writing-mode:vertical-rl;transform:rotate(180deg);">VERDICT</span>
542
- <p style="margin:0;font-family:Archivo,system-ui,sans-serif;font-weight:500;font-size:17.5px;line-height:1.5;color:#DDE1E5;max-width:88ch;">{esc(verdict)}</p>
573
+ <p title="{esc(verdict_tip)}" style="cursor:help;margin:0;font-family:Archivo,system-ui,sans-serif;font-weight:500;font-size:17.5px;line-height:1.5;color:#DDE1E5;max-width:88ch;">{esc(verdict)}</p>
543
574
  </div>
544
575
 
545
576
  <div style="display:grid;grid-template-columns:repeat(6,1fr);border-top:1px solid {_C['line']};border-bottom:1px solid {_C['line']};margin-bottom:56px;">{stat_html}</div>
@@ -591,6 +622,13 @@ def render_html(model) -> str:
591
622
  """
592
623
 
593
624
 
625
+ def _tshirt_label(points: int) -> str:
626
+ for cap, label in ((1, "XS"), (2, "S"), (4, "M"), (7, "L")):
627
+ if points <= cap:
628
+ return label
629
+ return "XL"
630
+
631
+
594
632
  def _section(num, name, aside) -> str:
595
633
  return (f'<div class="sect"><span class="num">{num}</span>'
596
634
  f'<span class="name">{name}</span><span class="rule"></span>'
@@ -617,7 +655,7 @@ function matrix(){
617
655
  probs.forEach(pr => {
618
656
  const inCell = DATA.risks.filter(r => r.disposition==='open' && r.prob===pr && (r.impact===im || (r.impact==='critical' && im==='high')));
619
657
  const z = zone(PV[pr]*PV[im]); const occ = inCell.length>0;
620
- const chips = inCell.map(r => `<span style="font-size:11px;font-weight:600;padding:2px 6px;border-radius:3px;border:1px solid ${z.b};color:${S.sel===r.id?C.bg:z.t};background:${S.sel===r.id?z.t:'transparent'};">${esc(r.short)}</span>`).join('');
658
+ const chips = inCell.map(r => `<span title="${esc(r.id)}: ${esc(r.desc)}" style="cursor:help;font-size:11px;font-weight:600;padding:2px 6px;border-radius:3px;border:1px solid ${z.b};color:${S.sel===r.id?C.bg:z.t};background:${S.sel===r.id?z.t:'transparent'};">${esc(r.short)}</span>`).join('');
621
659
  h += `<div data-risk="${occ?inCell[0].id:''}" style="min-height:56px;border:1px solid ${occ?z.b:'#191d21'};background:${occ?z.s:z.f};border-radius:3px;display:flex;gap:4px;align-items:center;justify-content:center;flex-wrap:wrap;cursor:${occ?'pointer':'default'};">${chips}</div>`;
622
660
  });
623
661
  });
@@ -682,7 +720,7 @@ function docs(){
682
720
  <span style="font-size:12.5px;color:${C.text};">${esc(d.kind)}</span>
683
721
  <span class="mono-tag" style="justify-self:start;">${esc(d.id)}</span>
684
722
  <span style="font-size:12px;color:${d.status==='active'?C.amberT:C.muted};">${esc(d.status)}</span>
685
- <span style="text-align:right;color:${d.passing?C.green:C.red};font-size:14px;">${d.passing?'✓':'✗'}</span></div>`).join('');
723
+ <span title="${d.passing?'passes every blocking structural check':'failing one or more blocking checks'}" style="cursor:help;text-align:right;color:${d.passing?C.green:C.red};font-size:14px;">${d.passing?'✓':'✗'}</span></div>`).join('');
686
724
  }
687
725
 
688
726
  function work(){
@@ -708,7 +746,7 @@ function work(){
708
746
  <div><div style="font-size:13px;color:${C.text};line-height:1.45;">${w.url?`<a style="text-decoration:none;" href="${esc(w.url)}">${esc(w.title)}</a>`:esc(w.title)}</div>
709
747
  <div style="font-size:10.5px;color:${C.dim};margin-top:6px;">linked ${esc(w.link)}</div></div>
710
748
  <span style="font-size:11.5px;color:${C.muted};">${esc(w.assignee)}</span>
711
- <span style="text-align:right;font-size:11.5px;color:${stC(w.state)};">${w.state==='done'?'done ✓':esc(w.state)}</span></div>`).join('');
749
+ <span title="state read from the bridge-managed issue; presentation never alters the derived RAG" style="cursor:help;text-align:right;font-size:11.5px;color:${stC(w.state)};">${w.state==='done'?'done ✓':esc(w.state)}</span></div>`).join('');
712
750
  }
713
751
 
714
752
  function render(){ matrix(); riskCtl(); riskTable(); docs(); work(); }
@@ -730,12 +768,12 @@ def _index_row(p) -> str:
730
768
  f'<div><div style="font-family:Archivo,system-ui,sans-serif;font-weight:700;font-size:15px;color:#F1F3F5;line-height:1.2;">{esc(p["name"])}</div>'
731
769
  f'<div style="font-size:10.5px;color:{_C["dim"]};margin-top:5px;display:flex;gap:10px;">'
732
770
  f'<span>{esc(p["id"])}</span><span>·</span><span>{esc(p["sponsor"])}</span></div></div>'
733
- f'<div style="text-align:center;"><div style="font-size:12.5px;color:{covc};">{cov}%</div>'
771
+ f'<div title="traceability links traced over required, all chains" style="cursor:help;text-align:center;"><div style="font-size:12.5px;color:{covc};">{cov}%</div>'
734
772
  f'<div style="height:4px;background:#16191c;margin-top:7px;border-radius:2px;overflow:hidden;">'
735
773
  f'<span style="display:block;height:100%;width:{cov}%;background:{covc};"></span></div></div>'
736
774
  f'<span style="text-align:center;font-size:12.5px;color:{_C["soft"]};">{esc(p["docs"])}</span>'
737
775
  f'<span style="text-align:center;font-size:12px;color:{_C["amberT"] if p["risks"] else _C["faint"]};">{risks}</span>'
738
- f'<span style="text-align:right;font-size:11.5px;color:{_C["muted"]};">{esc(p["next"])}</span></a>')
776
+ f'<span title="nearest upcoming dated marker (milestone or operations review)" style="cursor:help;text-align:right;font-size:11.5px;color:{_C["muted"]};">{esc(p["next"])}</span></a>')
739
777
 
740
778
 
741
779
  def render_index_html(index) -> str:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: docassert
3
- Version: 0.19.0
3
+ Version: 0.20.1
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
@@ -301,3 +301,81 @@ def test_charter_target_becomes_implicit_milestone():
301
301
  m2 = S.build_status(ROOT / "documents", project="PRJ-001-AUR")
302
302
  seen = [x["date"] for x in m2["milestones"]]
303
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
370
+
371
+
372
+ def test_report_tooltips_present(tmp_path):
373
+ _cd_root()
374
+ m = S.build_status(_seq_tree(tmp_path), project="PRJ-001-SQ")
375
+ out = S.render_html(m)
376
+ assert 'title="Derived RAG.' in out # stat derivation
377
+ assert "not that the milestone completed" in out or "Temporal fact" in out
378
+ assert "scope point(s) = " in out # bar breakdown tooltip
379
+ assert 'title="Causes:' in out or "No amber or red causes" in out
380
+ idx = S.render_index_html(S.build_index(ROOT / "documents"))
381
+ assert "nearest upcoming dated marker" in idx
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes