docassert 0.19.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.19.0/docassert.egg-info → docassert-0.20.0}/PKG-INFO +1 -1
  2. {docassert-0.19.0 → docassert-0.20.0}/docassert/__init__.py +1 -1
  3. {docassert-0.19.0 → docassert-0.20.0}/docassert/consistency.py +32 -0
  4. {docassert-0.19.0 → docassert-0.20.0}/docassert/status/derive.py +44 -0
  5. {docassert-0.19.0 → docassert-0.20.0}/docassert/status/render.py +53 -61
  6. {docassert-0.19.0 → docassert-0.20.0/docassert.egg-info}/PKG-INFO +1 -1
  7. {docassert-0.19.0 → docassert-0.20.0}/tests/test_status.py +66 -0
  8. {docassert-0.19.0 → docassert-0.20.0}/LICENSE +0 -0
  9. {docassert-0.19.0 → docassert-0.20.0}/NOTICE +0 -0
  10. {docassert-0.19.0 → docassert-0.20.0}/README.md +0 -0
  11. {docassert-0.19.0 → docassert-0.20.0}/docassert/__main__.py +0 -0
  12. {docassert-0.19.0 → docassert-0.20.0}/docassert/_data/consistency.yaml +0 -0
  13. {docassert-0.19.0 → docassert-0.20.0}/docassert/_data/criteria/adr.criteria.yaml +0 -0
  14. {docassert-0.19.0 → docassert-0.20.0}/docassert/_data/criteria/benefits-realization.criteria.yaml +0 -0
  15. {docassert-0.19.0 → docassert-0.20.0}/docassert/_data/criteria/brd.criteria.yaml +0 -0
  16. {docassert-0.19.0 → docassert-0.20.0}/docassert/_data/criteria/business-case.criteria.yaml +0 -0
  17. {docassert-0.19.0 → docassert-0.20.0}/docassert/_data/criteria/charter.criteria.yaml +0 -0
  18. {docassert-0.19.0 → docassert-0.20.0}/docassert/_data/criteria/data-migration-plan.criteria.yaml +0 -0
  19. {docassert-0.19.0 → docassert-0.20.0}/docassert/_data/criteria/frnfr.criteria.yaml +0 -0
  20. {docassert-0.19.0 → docassert-0.20.0}/docassert/_data/criteria/hypercare-plan.criteria.yaml +0 -0
  21. {docassert-0.19.0 → docassert-0.20.0}/docassert/_data/criteria/operations.criteria.yaml +0 -0
  22. {docassert-0.19.0 → docassert-0.20.0}/docassert/_data/criteria/post-implementation-review.criteria.yaml +0 -0
  23. {docassert-0.19.0 → docassert-0.20.0}/docassert/_data/criteria/prd.criteria.yaml +0 -0
  24. {docassert-0.19.0 → docassert-0.20.0}/docassert/_data/criteria/project.criteria.yaml +0 -0
  25. {docassert-0.19.0 → docassert-0.20.0}/docassert/_data/criteria/qa-test-plan.criteria.yaml +0 -0
  26. {docassert-0.19.0 → docassert-0.20.0}/docassert/_data/criteria/raci-stakeholder.criteria.yaml +0 -0
  27. {docassert-0.19.0 → docassert-0.20.0}/docassert/_data/criteria/release-cutover-plan.criteria.yaml +0 -0
  28. {docassert-0.19.0 → docassert-0.20.0}/docassert/_data/criteria/risk-register.criteria.yaml +0 -0
  29. {docassert-0.19.0 → docassert-0.20.0}/docassert/_data/criteria/rollback-plan.criteria.yaml +0 -0
  30. {docassert-0.19.0 → docassert-0.20.0}/docassert/_data/criteria/runbook.criteria.yaml +0 -0
  31. {docassert-0.19.0 → docassert-0.20.0}/docassert/_data/criteria/status-report.criteria.yaml +0 -0
  32. {docassert-0.19.0 → docassert-0.20.0}/docassert/_data/criteria/test-cases.criteria.yaml +0 -0
  33. {docassert-0.19.0 → docassert-0.20.0}/docassert/_data/criteria/user-story.criteria.yaml +0 -0
  34. {docassert-0.19.0 → docassert-0.20.0}/docassert/_data/profiles/agile-delivery.yaml +0 -0
  35. {docassert-0.19.0 → docassert-0.20.0}/docassert/_data/profiles/lean-startup.yaml +0 -0
  36. {docassert-0.19.0 → docassert-0.20.0}/docassert/_data/profiles/operations.yaml +0 -0
  37. {docassert-0.19.0 → docassert-0.20.0}/docassert/_data/profiles/regulated-industry.yaml +0 -0
  38. {docassert-0.19.0 → docassert-0.20.0}/docassert/_data/schema/adr.schema.json +0 -0
  39. {docassert-0.19.0 → docassert-0.20.0}/docassert/_data/schema/benefits-realization.schema.json +0 -0
  40. {docassert-0.19.0 → docassert-0.20.0}/docassert/_data/schema/brd.schema.json +0 -0
  41. {docassert-0.19.0 → docassert-0.20.0}/docassert/_data/schema/business-case.schema.json +0 -0
  42. {docassert-0.19.0 → docassert-0.20.0}/docassert/_data/schema/charter.schema.json +0 -0
  43. {docassert-0.19.0 → docassert-0.20.0}/docassert/_data/schema/data-migration-plan.schema.json +0 -0
  44. {docassert-0.19.0 → docassert-0.20.0}/docassert/_data/schema/frnfr.schema.json +0 -0
  45. {docassert-0.19.0 → docassert-0.20.0}/docassert/_data/schema/hypercare-plan.schema.json +0 -0
  46. {docassert-0.19.0 → docassert-0.20.0}/docassert/_data/schema/operations.schema.json +0 -0
  47. {docassert-0.19.0 → docassert-0.20.0}/docassert/_data/schema/post-implementation-review.schema.json +0 -0
  48. {docassert-0.19.0 → docassert-0.20.0}/docassert/_data/schema/prd.schema.json +0 -0
  49. {docassert-0.19.0 → docassert-0.20.0}/docassert/_data/schema/project.schema.json +0 -0
  50. {docassert-0.19.0 → docassert-0.20.0}/docassert/_data/schema/qa-test-plan.schema.json +0 -0
  51. {docassert-0.19.0 → docassert-0.20.0}/docassert/_data/schema/raci-stakeholder.schema.json +0 -0
  52. {docassert-0.19.0 → docassert-0.20.0}/docassert/_data/schema/release-cutover-plan.schema.json +0 -0
  53. {docassert-0.19.0 → docassert-0.20.0}/docassert/_data/schema/risk-register.schema.json +0 -0
  54. {docassert-0.19.0 → docassert-0.20.0}/docassert/_data/schema/rollback-plan.schema.json +0 -0
  55. {docassert-0.19.0 → docassert-0.20.0}/docassert/_data/schema/runbook.schema.json +0 -0
  56. {docassert-0.19.0 → docassert-0.20.0}/docassert/_data/schema/status-report.schema.json +0 -0
  57. {docassert-0.19.0 → docassert-0.20.0}/docassert/_data/schema/test-cases.schema.json +0 -0
  58. {docassert-0.19.0 → docassert-0.20.0}/docassert/_data/schema/user-story.schema.json +0 -0
  59. {docassert-0.19.0 → docassert-0.20.0}/docassert/_data/skills/doc-to-pmo/SKILL.md +0 -0
  60. {docassert-0.19.0 → docassert-0.20.0}/docassert/_data/templates/adr.template.md +0 -0
  61. {docassert-0.19.0 → docassert-0.20.0}/docassert/_data/templates/benefits-realization.template.md +0 -0
  62. {docassert-0.19.0 → docassert-0.20.0}/docassert/_data/templates/brd.template.md +0 -0
  63. {docassert-0.19.0 → docassert-0.20.0}/docassert/_data/templates/business-case.template.md +0 -0
  64. {docassert-0.19.0 → docassert-0.20.0}/docassert/_data/templates/charter.template.md +0 -0
  65. {docassert-0.19.0 → docassert-0.20.0}/docassert/_data/templates/data-migration-plan.template.md +0 -0
  66. {docassert-0.19.0 → docassert-0.20.0}/docassert/_data/templates/frnfr.template.md +0 -0
  67. {docassert-0.19.0 → docassert-0.20.0}/docassert/_data/templates/hypercare-plan.template.md +0 -0
  68. {docassert-0.19.0 → docassert-0.20.0}/docassert/_data/templates/operations.template.md +0 -0
  69. {docassert-0.19.0 → docassert-0.20.0}/docassert/_data/templates/post-implementation-review.template.md +0 -0
  70. {docassert-0.19.0 → docassert-0.20.0}/docassert/_data/templates/prd.template.md +0 -0
  71. {docassert-0.19.0 → docassert-0.20.0}/docassert/_data/templates/project.template.md +0 -0
  72. {docassert-0.19.0 → docassert-0.20.0}/docassert/_data/templates/qa-test-plan.template.md +0 -0
  73. {docassert-0.19.0 → docassert-0.20.0}/docassert/_data/templates/raci-stakeholder.template.md +0 -0
  74. {docassert-0.19.0 → docassert-0.20.0}/docassert/_data/templates/release-cutover-plan.template.md +0 -0
  75. {docassert-0.19.0 → docassert-0.20.0}/docassert/_data/templates/risk-register.template.md +0 -0
  76. {docassert-0.19.0 → docassert-0.20.0}/docassert/_data/templates/rollback-plan.template.md +0 -0
  77. {docassert-0.19.0 → docassert-0.20.0}/docassert/_data/templates/runbook.template.md +0 -0
  78. {docassert-0.19.0 → docassert-0.20.0}/docassert/_data/templates/status-report.template.md +0 -0
  79. {docassert-0.19.0 → docassert-0.20.0}/docassert/_data/templates/test-cases.template.md +0 -0
  80. {docassert-0.19.0 → docassert-0.20.0}/docassert/_data/templates/user-story.template.md +0 -0
  81. {docassert-0.19.0 → docassert-0.20.0}/docassert/bridge/__init__.py +0 -0
  82. {docassert-0.19.0 → docassert-0.20.0}/docassert/bridge/board.py +0 -0
  83. {docassert-0.19.0 → docassert-0.20.0}/docassert/bridge/gh.py +0 -0
  84. {docassert-0.19.0 → docassert-0.20.0}/docassert/bridge/ops.py +0 -0
  85. {docassert-0.19.0 → docassert-0.20.0}/docassert/bridge/plan.py +0 -0
  86. {docassert-0.19.0 → docassert-0.20.0}/docassert/cli.py +0 -0
  87. {docassert-0.19.0 → docassert-0.20.0}/docassert/config.py +0 -0
  88. {docassert-0.19.0 → docassert-0.20.0}/docassert/extract.py +0 -0
  89. {docassert-0.19.0 → docassert-0.20.0}/docassert/graph.py +0 -0
  90. {docassert-0.19.0 → docassert-0.20.0}/docassert/loader.py +0 -0
  91. {docassert-0.19.0 → docassert-0.20.0}/docassert/models.py +0 -0
  92. {docassert-0.19.0 → docassert-0.20.0}/docassert/profiles.py +0 -0
  93. {docassert-0.19.0 → docassert-0.20.0}/docassert/projects.py +0 -0
  94. {docassert-0.19.0 → docassert-0.20.0}/docassert/report.py +0 -0
  95. {docassert-0.19.0 → docassert-0.20.0}/docassert/rtm.py +0 -0
  96. {docassert-0.19.0 → docassert-0.20.0}/docassert/scaffold.py +0 -0
  97. {docassert-0.19.0 → docassert-0.20.0}/docassert/semantic.py +0 -0
  98. {docassert-0.19.0 → docassert-0.20.0}/docassert/status/__init__.py +0 -0
  99. {docassert-0.19.0 → docassert-0.20.0}/docassert/structural.py +0 -0
  100. {docassert-0.19.0 → docassert-0.20.0}/docassert.egg-info/SOURCES.txt +0 -0
  101. {docassert-0.19.0 → docassert-0.20.0}/docassert.egg-info/dependency_links.txt +0 -0
  102. {docassert-0.19.0 → docassert-0.20.0}/docassert.egg-info/entry_points.txt +0 -0
  103. {docassert-0.19.0 → docassert-0.20.0}/docassert.egg-info/requires.txt +0 -0
  104. {docassert-0.19.0 → docassert-0.20.0}/docassert.egg-info/top_level.txt +0 -0
  105. {docassert-0.19.0 → docassert-0.20.0}/pyproject.toml +0 -0
  106. {docassert-0.19.0 → docassert-0.20.0}/setup.cfg +0 -0
  107. {docassert-0.19.0 → docassert-0.20.0}/tests/test_badge.py +0 -0
  108. {docassert-0.19.0 → docassert-0.20.0}/tests/test_bridge.py +0 -0
  109. {docassert-0.19.0 → docassert-0.20.0}/tests/test_config.py +0 -0
  110. {docassert-0.19.0 → docassert-0.20.0}/tests/test_consistency.py +0 -0
  111. {docassert-0.19.0 → docassert-0.20.0}/tests/test_defects.py +0 -0
  112. {docassert-0.19.0 → docassert-0.20.0}/tests/test_draft_relaxation.py +0 -0
  113. {docassert-0.19.0 → docassert-0.20.0}/tests/test_extract.py +0 -0
  114. {docassert-0.19.0 → docassert-0.20.0}/tests/test_graph.py +0 -0
  115. {docassert-0.19.0 → docassert-0.20.0}/tests/test_json_report.py +0 -0
  116. {docassert-0.19.0 → docassert-0.20.0}/tests/test_kinds_delivery.py +0 -0
  117. {docassert-0.19.0 → docassert-0.20.0}/tests/test_kinds_governance.py +0 -0
  118. {docassert-0.19.0 → docassert-0.20.0}/tests/test_kinds_operate.py +0 -0
  119. {docassert-0.19.0 → docassert-0.20.0}/tests/test_kinds_reporting.py +0 -0
  120. {docassert-0.19.0 → docassert-0.20.0}/tests/test_profiles.py +0 -0
  121. {docassert-0.19.0 → docassert-0.20.0}/tests/test_projects.py +0 -0
  122. {docassert-0.19.0 → docassert-0.20.0}/tests/test_report_extract.py +0 -0
  123. {docassert-0.19.0 → docassert-0.20.0}/tests/test_scaffold.py +0 -0
  124. {docassert-0.19.0 → docassert-0.20.0}/tests/test_semantic.py +0 -0
  125. {docassert-0.19.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.19.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.19.0"
8
+ __version__ = "0.20.0"
@@ -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,
@@ -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.19.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
@@ -301,3 +301,69 @@ 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
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes