docassert 0.14.0__tar.gz → 0.15.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 (120) hide show
  1. {docassert-0.14.0/docassert.egg-info → docassert-0.15.0}/PKG-INFO +1 -1
  2. {docassert-0.14.0 → docassert-0.15.0}/docassert/__init__.py +1 -1
  3. {docassert-0.14.0 → docassert-0.15.0}/docassert/_data/criteria/risk-register.criteria.yaml +5 -0
  4. {docassert-0.14.0 → docassert-0.15.0}/docassert/bridge/board.py +23 -17
  5. {docassert-0.14.0 → docassert-0.15.0}/docassert/bridge/ops.py +29 -0
  6. {docassert-0.14.0 → docassert-0.15.0}/docassert/status/derive.py +10 -3
  7. {docassert-0.14.0 → docassert-0.15.0}/docassert/status/render.py +16 -5
  8. {docassert-0.14.0 → docassert-0.15.0}/docassert/structural.py +16 -0
  9. {docassert-0.14.0 → docassert-0.15.0/docassert.egg-info}/PKG-INFO +1 -1
  10. {docassert-0.14.0 → docassert-0.15.0}/tests/test_bridge.py +29 -0
  11. {docassert-0.14.0 → docassert-0.15.0}/tests/test_status.py +52 -0
  12. {docassert-0.14.0 → docassert-0.15.0}/LICENSE +0 -0
  13. {docassert-0.14.0 → docassert-0.15.0}/NOTICE +0 -0
  14. {docassert-0.14.0 → docassert-0.15.0}/README.md +0 -0
  15. {docassert-0.14.0 → docassert-0.15.0}/docassert/__main__.py +0 -0
  16. {docassert-0.14.0 → docassert-0.15.0}/docassert/_data/consistency.yaml +0 -0
  17. {docassert-0.14.0 → docassert-0.15.0}/docassert/_data/criteria/adr.criteria.yaml +0 -0
  18. {docassert-0.14.0 → docassert-0.15.0}/docassert/_data/criteria/benefits-realization.criteria.yaml +0 -0
  19. {docassert-0.14.0 → docassert-0.15.0}/docassert/_data/criteria/brd.criteria.yaml +0 -0
  20. {docassert-0.14.0 → docassert-0.15.0}/docassert/_data/criteria/business-case.criteria.yaml +0 -0
  21. {docassert-0.14.0 → docassert-0.15.0}/docassert/_data/criteria/charter.criteria.yaml +0 -0
  22. {docassert-0.14.0 → docassert-0.15.0}/docassert/_data/criteria/data-migration-plan.criteria.yaml +0 -0
  23. {docassert-0.14.0 → docassert-0.15.0}/docassert/_data/criteria/frnfr.criteria.yaml +0 -0
  24. {docassert-0.14.0 → docassert-0.15.0}/docassert/_data/criteria/hypercare-plan.criteria.yaml +0 -0
  25. {docassert-0.14.0 → docassert-0.15.0}/docassert/_data/criteria/post-implementation-review.criteria.yaml +0 -0
  26. {docassert-0.14.0 → docassert-0.15.0}/docassert/_data/criteria/prd.criteria.yaml +0 -0
  27. {docassert-0.14.0 → docassert-0.15.0}/docassert/_data/criteria/project.criteria.yaml +0 -0
  28. {docassert-0.14.0 → docassert-0.15.0}/docassert/_data/criteria/qa-test-plan.criteria.yaml +0 -0
  29. {docassert-0.14.0 → docassert-0.15.0}/docassert/_data/criteria/raci-stakeholder.criteria.yaml +0 -0
  30. {docassert-0.14.0 → docassert-0.15.0}/docassert/_data/criteria/release-cutover-plan.criteria.yaml +0 -0
  31. {docassert-0.14.0 → docassert-0.15.0}/docassert/_data/criteria/rollback-plan.criteria.yaml +0 -0
  32. {docassert-0.14.0 → docassert-0.15.0}/docassert/_data/criteria/runbook.criteria.yaml +0 -0
  33. {docassert-0.14.0 → docassert-0.15.0}/docassert/_data/criteria/status-report.criteria.yaml +0 -0
  34. {docassert-0.14.0 → docassert-0.15.0}/docassert/_data/criteria/test-cases.criteria.yaml +0 -0
  35. {docassert-0.14.0 → docassert-0.15.0}/docassert/_data/criteria/user-story.criteria.yaml +0 -0
  36. {docassert-0.14.0 → docassert-0.15.0}/docassert/_data/profiles/agile-delivery.yaml +0 -0
  37. {docassert-0.14.0 → docassert-0.15.0}/docassert/_data/profiles/lean-startup.yaml +0 -0
  38. {docassert-0.14.0 → docassert-0.15.0}/docassert/_data/profiles/regulated-industry.yaml +0 -0
  39. {docassert-0.14.0 → docassert-0.15.0}/docassert/_data/schema/adr.schema.json +0 -0
  40. {docassert-0.14.0 → docassert-0.15.0}/docassert/_data/schema/benefits-realization.schema.json +0 -0
  41. {docassert-0.14.0 → docassert-0.15.0}/docassert/_data/schema/brd.schema.json +0 -0
  42. {docassert-0.14.0 → docassert-0.15.0}/docassert/_data/schema/business-case.schema.json +0 -0
  43. {docassert-0.14.0 → docassert-0.15.0}/docassert/_data/schema/charter.schema.json +0 -0
  44. {docassert-0.14.0 → docassert-0.15.0}/docassert/_data/schema/data-migration-plan.schema.json +0 -0
  45. {docassert-0.14.0 → docassert-0.15.0}/docassert/_data/schema/frnfr.schema.json +0 -0
  46. {docassert-0.14.0 → docassert-0.15.0}/docassert/_data/schema/hypercare-plan.schema.json +0 -0
  47. {docassert-0.14.0 → docassert-0.15.0}/docassert/_data/schema/post-implementation-review.schema.json +0 -0
  48. {docassert-0.14.0 → docassert-0.15.0}/docassert/_data/schema/prd.schema.json +0 -0
  49. {docassert-0.14.0 → docassert-0.15.0}/docassert/_data/schema/project.schema.json +0 -0
  50. {docassert-0.14.0 → docassert-0.15.0}/docassert/_data/schema/qa-test-plan.schema.json +0 -0
  51. {docassert-0.14.0 → docassert-0.15.0}/docassert/_data/schema/raci-stakeholder.schema.json +0 -0
  52. {docassert-0.14.0 → docassert-0.15.0}/docassert/_data/schema/release-cutover-plan.schema.json +0 -0
  53. {docassert-0.14.0 → docassert-0.15.0}/docassert/_data/schema/risk-register.schema.json +0 -0
  54. {docassert-0.14.0 → docassert-0.15.0}/docassert/_data/schema/rollback-plan.schema.json +0 -0
  55. {docassert-0.14.0 → docassert-0.15.0}/docassert/_data/schema/runbook.schema.json +0 -0
  56. {docassert-0.14.0 → docassert-0.15.0}/docassert/_data/schema/status-report.schema.json +0 -0
  57. {docassert-0.14.0 → docassert-0.15.0}/docassert/_data/schema/test-cases.schema.json +0 -0
  58. {docassert-0.14.0 → docassert-0.15.0}/docassert/_data/schema/user-story.schema.json +0 -0
  59. {docassert-0.14.0 → docassert-0.15.0}/docassert/_data/skills/doc-to-pmo/SKILL.md +0 -0
  60. {docassert-0.14.0 → docassert-0.15.0}/docassert/_data/templates/adr.template.md +0 -0
  61. {docassert-0.14.0 → docassert-0.15.0}/docassert/_data/templates/benefits-realization.template.md +0 -0
  62. {docassert-0.14.0 → docassert-0.15.0}/docassert/_data/templates/brd.template.md +0 -0
  63. {docassert-0.14.0 → docassert-0.15.0}/docassert/_data/templates/business-case.template.md +0 -0
  64. {docassert-0.14.0 → docassert-0.15.0}/docassert/_data/templates/charter.template.md +0 -0
  65. {docassert-0.14.0 → docassert-0.15.0}/docassert/_data/templates/data-migration-plan.template.md +0 -0
  66. {docassert-0.14.0 → docassert-0.15.0}/docassert/_data/templates/frnfr.template.md +0 -0
  67. {docassert-0.14.0 → docassert-0.15.0}/docassert/_data/templates/hypercare-plan.template.md +0 -0
  68. {docassert-0.14.0 → docassert-0.15.0}/docassert/_data/templates/post-implementation-review.template.md +0 -0
  69. {docassert-0.14.0 → docassert-0.15.0}/docassert/_data/templates/prd.template.md +0 -0
  70. {docassert-0.14.0 → docassert-0.15.0}/docassert/_data/templates/project.template.md +0 -0
  71. {docassert-0.14.0 → docassert-0.15.0}/docassert/_data/templates/qa-test-plan.template.md +0 -0
  72. {docassert-0.14.0 → docassert-0.15.0}/docassert/_data/templates/raci-stakeholder.template.md +0 -0
  73. {docassert-0.14.0 → docassert-0.15.0}/docassert/_data/templates/release-cutover-plan.template.md +0 -0
  74. {docassert-0.14.0 → docassert-0.15.0}/docassert/_data/templates/risk-register.template.md +0 -0
  75. {docassert-0.14.0 → docassert-0.15.0}/docassert/_data/templates/rollback-plan.template.md +0 -0
  76. {docassert-0.14.0 → docassert-0.15.0}/docassert/_data/templates/runbook.template.md +0 -0
  77. {docassert-0.14.0 → docassert-0.15.0}/docassert/_data/templates/status-report.template.md +0 -0
  78. {docassert-0.14.0 → docassert-0.15.0}/docassert/_data/templates/test-cases.template.md +0 -0
  79. {docassert-0.14.0 → docassert-0.15.0}/docassert/_data/templates/user-story.template.md +0 -0
  80. {docassert-0.14.0 → docassert-0.15.0}/docassert/bridge/__init__.py +0 -0
  81. {docassert-0.14.0 → docassert-0.15.0}/docassert/bridge/gh.py +0 -0
  82. {docassert-0.14.0 → docassert-0.15.0}/docassert/bridge/plan.py +0 -0
  83. {docassert-0.14.0 → docassert-0.15.0}/docassert/cli.py +0 -0
  84. {docassert-0.14.0 → docassert-0.15.0}/docassert/config.py +0 -0
  85. {docassert-0.14.0 → docassert-0.15.0}/docassert/consistency.py +0 -0
  86. {docassert-0.14.0 → docassert-0.15.0}/docassert/extract.py +0 -0
  87. {docassert-0.14.0 → docassert-0.15.0}/docassert/graph.py +0 -0
  88. {docassert-0.14.0 → docassert-0.15.0}/docassert/loader.py +0 -0
  89. {docassert-0.14.0 → docassert-0.15.0}/docassert/models.py +0 -0
  90. {docassert-0.14.0 → docassert-0.15.0}/docassert/profiles.py +0 -0
  91. {docassert-0.14.0 → docassert-0.15.0}/docassert/projects.py +0 -0
  92. {docassert-0.14.0 → docassert-0.15.0}/docassert/report.py +0 -0
  93. {docassert-0.14.0 → docassert-0.15.0}/docassert/rtm.py +0 -0
  94. {docassert-0.14.0 → docassert-0.15.0}/docassert/scaffold.py +0 -0
  95. {docassert-0.14.0 → docassert-0.15.0}/docassert/semantic.py +0 -0
  96. {docassert-0.14.0 → docassert-0.15.0}/docassert/status/__init__.py +0 -0
  97. {docassert-0.14.0 → docassert-0.15.0}/docassert.egg-info/SOURCES.txt +0 -0
  98. {docassert-0.14.0 → docassert-0.15.0}/docassert.egg-info/dependency_links.txt +0 -0
  99. {docassert-0.14.0 → docassert-0.15.0}/docassert.egg-info/entry_points.txt +0 -0
  100. {docassert-0.14.0 → docassert-0.15.0}/docassert.egg-info/requires.txt +0 -0
  101. {docassert-0.14.0 → docassert-0.15.0}/docassert.egg-info/top_level.txt +0 -0
  102. {docassert-0.14.0 → docassert-0.15.0}/pyproject.toml +0 -0
  103. {docassert-0.14.0 → docassert-0.15.0}/setup.cfg +0 -0
  104. {docassert-0.14.0 → docassert-0.15.0}/tests/test_badge.py +0 -0
  105. {docassert-0.14.0 → docassert-0.15.0}/tests/test_config.py +0 -0
  106. {docassert-0.14.0 → docassert-0.15.0}/tests/test_consistency.py +0 -0
  107. {docassert-0.14.0 → docassert-0.15.0}/tests/test_defects.py +0 -0
  108. {docassert-0.14.0 → docassert-0.15.0}/tests/test_draft_relaxation.py +0 -0
  109. {docassert-0.14.0 → docassert-0.15.0}/tests/test_extract.py +0 -0
  110. {docassert-0.14.0 → docassert-0.15.0}/tests/test_graph.py +0 -0
  111. {docassert-0.14.0 → docassert-0.15.0}/tests/test_json_report.py +0 -0
  112. {docassert-0.14.0 → docassert-0.15.0}/tests/test_kinds_delivery.py +0 -0
  113. {docassert-0.14.0 → docassert-0.15.0}/tests/test_kinds_governance.py +0 -0
  114. {docassert-0.14.0 → docassert-0.15.0}/tests/test_kinds_operate.py +0 -0
  115. {docassert-0.14.0 → docassert-0.15.0}/tests/test_kinds_reporting.py +0 -0
  116. {docassert-0.14.0 → docassert-0.15.0}/tests/test_profiles.py +0 -0
  117. {docassert-0.14.0 → docassert-0.15.0}/tests/test_projects.py +0 -0
  118. {docassert-0.14.0 → docassert-0.15.0}/tests/test_scaffold.py +0 -0
  119. {docassert-0.14.0 → docassert-0.15.0}/tests/test_semantic.py +0 -0
  120. {docassert-0.14.0 → docassert-0.15.0}/tests/test_structural.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: docassert
3
- Version: 0.14.0
3
+ Version: 0.15.0
4
4
  Summary: Unit testing for business documents — validate structured Markdown docs against a configurable audit standard.
5
5
  Author: C4G Enterprises Inc.
6
6
  License: Apache-2.0
@@ -5,4 +5,4 @@ standard: deterministic structural checks that gate a merge, plus optional
5
5
  AI-graded semantic checks that advise.
6
6
  """
7
7
 
8
- __version__ = "0.14.0"
8
+ __version__ = "0.15.0"
@@ -30,6 +30,11 @@ checks:
30
30
  type: structural
31
31
  blocking: once-proposed
32
32
  description: Every risk states a Probability, Impact, Owner, and Response.
33
+ - id: risk-disposition-valid
34
+ type: structural
35
+ blocking: true
36
+ description: Any Status field on a RISK item names a valid disposition (open/mitigated/accepted/closed).
37
+
33
38
  - id: unique-id
34
39
  type: structural
35
40
  blocking: true
@@ -7,7 +7,7 @@ writes converge on the same values.
7
7
  """
8
8
  from __future__ import annotations
9
9
 
10
- from .gh import GhRunner
10
+ from .gh import GhError, GhRunner
11
11
 
12
12
  FIELD_SPECS: dict[str, dict] = {
13
13
  "Type": {"dataType": "SINGLE_SELECT", "options": ["Feature", "Story"]},
@@ -67,22 +67,28 @@ def ensure_fields(gh: GhRunner, owner: str, number: int) -> dict:
67
67
  for name, spec in FIELD_SPECS.items():
68
68
  if name in project["fields"]:
69
69
  continue
70
- if spec["dataType"] == "SINGLE_SELECT":
71
- opts = ", ".join(
72
- f'{{name: "{o}", color: GRAY, description: ""}}'
73
- for o in spec["options"])
74
- gh.graphql(
75
- "mutation($p: ID!, $n: String!) { createProjectV2Field(input: {"
76
- "projectId: $p, dataType: SINGLE_SELECT, name: $n, "
77
- f"singleSelectOptions: [{opts}]"
78
- "}) { projectV2Field { ... on ProjectV2FieldCommon { id } } } }",
79
- p=project["id"], n=name)
80
- else:
81
- gh.graphql(
82
- "mutation($p: ID!, $n: String!) { createProjectV2Field(input: {"
83
- "projectId: $p, dataType: TEXT, name: $n"
84
- "}) { projectV2Field { ... on ProjectV2FieldCommon { id } } } }",
85
- p=project["id"], n=name)
70
+ try:
71
+ if spec["dataType"] == "SINGLE_SELECT":
72
+ opts = ", ".join(
73
+ f'{{name: "{o}", color: GRAY, description: ""}}'
74
+ for o in spec["options"])
75
+ gh.graphql(
76
+ "mutation($p: ID!, $n: String!) { createProjectV2Field(input: {"
77
+ "projectId: $p, dataType: SINGLE_SELECT, name: $n, "
78
+ f"singleSelectOptions: [{opts}]"
79
+ "}) { projectV2Field { ... on ProjectV2FieldCommon { id } } } }",
80
+ p=project["id"], n=name)
81
+ else:
82
+ gh.graphql(
83
+ "mutation($p: ID!, $n: String!) { createProjectV2Field(input: {"
84
+ "projectId: $p, dataType: TEXT, name: $n"
85
+ "}) { projectV2Field { ... on ProjectV2FieldCommon { id } } } }",
86
+ p=project["id"], n=name)
87
+ except GhError as exc:
88
+ # A concurrent sync created it between our read and our write;
89
+ # an existing field is the outcome we wanted.
90
+ if "already been taken" not in str(exc).lower():
91
+ raise
86
92
  return get_project(gh, owner, number)
87
93
 
88
94
 
@@ -45,6 +45,33 @@ def _index(issues: list[dict]) -> dict[str, dict]:
45
45
  return out
46
46
 
47
47
 
48
+ def _converge_duplicates(gh, repo: str, issues: list[dict]) -> tuple[list[dict], list[str]]:
49
+ """One open issue per bridge marker: racing scaffolds may have created
50
+ duplicates, so close every open duplicate except the lowest-numbered one
51
+ and drop the closed ones from the working set. First writer wins."""
52
+ from collections import defaultdict
53
+ by_marker: dict[str, list[dict]] = defaultdict(list)
54
+ for issue in issues:
55
+ m = MARKER_RE.search(issue.get("body") or "")
56
+ if m and issue.get("state") == "open":
57
+ by_marker[m.group(1)].append(issue)
58
+ actions: list[str] = []
59
+ losers: set[int] = set()
60
+ for marker, dupes in by_marker.items():
61
+ if len(dupes) < 2:
62
+ continue
63
+ dupes.sort(key=lambda i: i["number"])
64
+ for extra in dupes[1:]:
65
+ G.update_issue(gh, repo, extra["number"], state="closed")
66
+ G.comment(gh, repo, extra["number"],
67
+ f"Duplicate of #{dupes[0]['number']} (bridge marker "
68
+ f"{marker}); racing scaffold runs converge on the "
69
+ "lowest-numbered issue.")
70
+ losers.add(extra["number"])
71
+ actions.append(f"converged duplicate #{extra['number']} -> #{dupes[0]['number']}")
72
+ return [i for i in issues if i["number"] not in losers], actions
73
+
74
+
48
75
  def scaffold(plan: BridgePlan, gh: G.GhRunner, repo: str,
49
76
  docs_url: str | None = None,
50
77
  board_cfg: dict | None = None) -> list[str]:
@@ -59,6 +86,8 @@ def scaffold(plan: BridgePlan, gh: G.GhRunner, repo: str,
59
86
  G.ensure_label(gh, repo, name, color, desc)
60
87
 
61
88
  issues = G.list_issues(gh, repo)
89
+ issues, converge_actions = _converge_duplicates(gh, repo, issues)
90
+ actions.extend(converge_actions)
62
91
  by_key = _index(issues)
63
92
 
64
93
  def ensure(key: str, title: str, body: str, label: str) -> dict:
@@ -82,6 +82,9 @@ def _risks(graph, code=None):
82
82
  description = (item.text[:m.start()] if m else item.text).strip()
83
83
  rm = re.search(r"Response\s*:\s*(.+)$", item.text,
84
84
  re.IGNORECASE | re.DOTALL)
85
+ raw = (_field_value(item.text, "status") or "open").lower()
86
+ disposition = raw if raw in {"open", "mitigated", "accepted",
87
+ "closed"} else "open"
85
88
  risks.append({
86
89
  "id": item.id,
87
90
  "description": description,
@@ -90,9 +93,12 @@ def _risks(graph, code=None):
90
93
  "impact": impact or "?",
91
94
  "owner": _field_value(item.text, "owner") or "?",
92
95
  "response": rm.group(1).strip() if rm else "",
96
+ "disposition": disposition,
93
97
  "score": _SEVERITY.get(prob, 0) * _SEVERITY.get(impact, 0),
94
98
  })
95
- return sorted(risks, key=lambda r: -r["score"])
99
+ # open risks first, then by severity; dispositioned risks stay as record
100
+ return sorted(risks, key=lambda r: (r["disposition"] != "open",
101
+ -r["score"]))
96
102
 
97
103
 
98
104
  def _broken_references(graph, code=None):
@@ -218,7 +224,7 @@ def build_index(documents_dir=DOCUMENTS_DIR) -> dict:
218
224
  "rag": m["rag"],
219
225
  "total": m["counts"]["total"],
220
226
  "failing": m["counts"]["failing"],
221
- "risks": len(m["risks"]),
227
+ "risks": len([r for r in m["risks"] if r.get("disposition", "open") == "open"]),
222
228
  "coverage_gaps": sum(len(c["gaps"]) for c in m["coverage"]),
223
229
  "broken": len(m["broken_references"]),
224
230
  "completeness": m.get("completeness"),
@@ -238,7 +244,8 @@ def derive_rag(model) -> str:
238
244
  reported = (model["latest_report"] or {}).get("rag")
239
245
  completeness_gap = bool(comp and (comp["missing_required"] or comp["incomplete_required"]
240
246
  or comp["recommended_gaps"] or comp.get("unknown")))
241
- if coverage_gap or model["risks"] or reported in {"amber", "red"} or completeness_gap:
247
+ open_risks = [r for r in model["risks"] if r.get("disposition", "open") == "open"]
248
+ if coverage_gap or open_risks or reported in {"amber", "red"} or completeness_gap:
242
249
  return "amber"
243
250
  return "green"
244
251
 
@@ -55,8 +55,12 @@ def render_markdown(model, summary: bool = False) -> str:
55
55
  out.append(f"- 🔴 **Documents failing audit**: {', '.join(failing)}")
56
56
 
57
57
  if model["risks"]:
58
- out.append(f"- 🟠 **Open risks**: {len(model['risks'])}")
59
- for r in model["risks"][:5]:
58
+ open_r = [r for r in model["risks"] if r.get("disposition", "open") == "open"]
59
+ rest = len(model["risks"]) - len(open_r)
60
+ mark = "🟠" if open_r else "🟢"
61
+ out.append(f"- {mark} **Open risks**: {len(open_r)}"
62
+ + (f" ({rest} dispositioned)" if rest else ""))
63
+ for r in open_r[:5]:
60
64
  out.append(f" - `{r['id']}` — {r['probability']}/{r['impact']} · owner: {r['owner']}")
61
65
 
62
66
  lr = model["latest_report"]
@@ -149,6 +153,12 @@ def render_html(model) -> str:
149
153
  value, "var(--muted)")
150
154
  return f'<td style="color:{color};font-weight:600;">{esc(value)}</td>'
151
155
 
156
+ def disp(value):
157
+ color = {"open": "var(--amber)", "mitigated": "var(--ok)",
158
+ "accepted": "var(--muted)", "closed": "var(--muted)"}.get(
159
+ value, "var(--amber)")
160
+ return f'<td style="color:{color};font-weight:600;">{esc(value)}</td>'
161
+
152
162
  if model["risks"]:
153
163
  risk_rows = "".join(
154
164
  f'<tr><td><code>{esc(r["id"])}</code></td>'
@@ -156,11 +166,12 @@ def render_html(model) -> str:
156
166
  f'<td>{", ".join(f"<code>{esc(b)}</code>" for b in r["threatens"]) or "—"}</td>'
157
167
  + sev(r["probability"]) + sev(r["impact"])
158
168
  + f'<td>{esc(r["owner"])}</td>'
159
- f'<td>{esc(r["response"]) or "—"}</td></tr>'
169
+ + disp(r.get("disposition", "open"))
170
+ + f'<td>{esc(r["response"]) or "—"}</td></tr>'
160
171
  for r in model["risks"])
161
172
  risks = ('<table><thead><tr><th>ID</th><th>Risk</th><th>Threatens</th>'
162
173
  '<th>Probability</th><th>Impact</th><th>Owner</th>'
163
- '<th>Response</th></tr></thead>'
174
+ '<th>Status</th><th>Response</th></tr></thead>'
164
175
  f'<tbody>{risk_rows}</tbody></table>')
165
176
  else:
166
177
  risks = "<p>None open.</p>"
@@ -280,7 +291,7 @@ def render_html(model) -> str:
280
291
  {document_set}
281
292
  {execution_html}
282
293
  {problems}
283
- <section><h2>Open risks ({len(model['risks'])})</h2>{risks}</section>
294
+ <section><h2>Risks ({len([r for r in model['risks'] if r.get('disposition', 'open') == 'open'])} open of {len(model['risks'])})</h2>{risks}</section>
284
295
  <section><h2>Latest status report</h2><p>{report}</p></section>
285
296
  <section><h2>Documents ({c['total']})</h2>
286
297
  <table><thead><tr><th>Kind</th><th>ID</th><th>Status</th><th>Audit</th></tr></thead>
@@ -226,6 +226,7 @@ def check_project_id_format(doc: Document, ctx: dict) -> tuple[bool, str]:
226
226
 
227
227
 
228
228
  _RISK_FIELDS = ["Probability", "Impact", "Owner", "Response"]
229
+ _DISPOSITIONS = {"open", "mitigated", "accepted", "closed"}
229
230
  _ADR_STATES = {"proposed", "accepted", "superseded", "deprecated", "rejected"}
230
231
 
231
232
 
@@ -255,6 +256,20 @@ def check_risk_items_complete(doc: Document, ctx: dict) -> tuple[bool, str]:
255
256
  return True, f"All {total} risk(s) state probability, impact, owner, and response."
256
257
 
257
258
 
259
+ def check_risk_disposition_valid(doc: Document, ctx: dict) -> tuple[bool, str]:
260
+ """Where a RISK item yields Status, it names a valid disposition."""
261
+ bad, total = [], 0
262
+ for iid, text in _items_of_prefix(doc, ctx, "RISK"):
263
+ total += 1
264
+ value = _field_value(text, "Status")
265
+ if value is not None and value.lower() not in _DISPOSITIONS:
266
+ bad.append(f"{iid}: {value!r}")
267
+ if bad:
268
+ return False, ("invalid disposition (open|mitigated|accepted|closed): "
269
+ + "; ".join(bad))
270
+ return True, f"All {total} risk disposition(s) valid (absent means open)."
271
+
272
+
258
273
  def check_adr_items_have_status(doc: Document, ctx: dict) -> tuple[bool, str]:
259
274
  """Every ADR item declares a valid Status."""
260
275
  bad, total = [], 0
@@ -402,6 +417,7 @@ CHECKS: dict[str, Callable[[Document, dict], tuple[bool, str]]] = {
402
417
  "unique-id": check_unique_id,
403
418
  "items-well-formed": check_items_well_formed,
404
419
  "risk-items-complete": check_risk_items_complete,
420
+ "risk-disposition-valid": check_risk_disposition_valid,
405
421
  "adr-items-have-status": check_adr_items_have_status,
406
422
  "raci-one-accountable": check_raci_one_accountable,
407
423
  "story-format": check_story_format,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: docassert
3
- Version: 0.14.0
3
+ Version: 0.15.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
@@ -441,3 +441,32 @@ def test_anchor_without_repo_keeps_single_repo_mode_working(tmp_path):
441
441
  gh = FakeGh()
442
442
  ops.scaffold(plan, gh, "o/r", docs_url=None)
443
443
  assert any("issues" in " ".join(c) for c in gh.calls)
444
+
445
+
446
+ # ── concurrency safety (ENG-PR-006) ──────────────────────────────────────────
447
+ def test_scaffold_converges_racing_duplicates(tmp_path):
448
+ plan = build_bridge_plan(_tree(tmp_path))
449
+ fid = plan.features[0].id
450
+ dupes = [_mk_issue(5, fid), _mk_issue(9, fid)]
451
+ gh = FakeGh(issues=dupes)
452
+ actions = ops.scaffold(plan, gh, "o/r", docs_url=None)
453
+ assert any("converged duplicate #9" in a for a in actions)
454
+ patches = [c for c in gh.calls if "repos/o/r/issues/9" in " ".join(c)
455
+ and "state=closed" in " ".join(c)]
456
+ assert patches, "the higher-numbered duplicate is closed"
457
+
458
+
459
+ def test_ensure_fields_tolerates_existing_field():
460
+ gh = BoardGh()
461
+ original = gh.graphql
462
+
463
+ def racing(query, **kw):
464
+ if "createProjectV2Field" in query:
465
+ from docassert.bridge.gh import GhError
466
+ raise GhError('[{"message": "Name has already been taken"}]')
467
+ return original(query, **kw)
468
+
469
+ gh.graphql = racing
470
+ from docassert.bridge import board
471
+ project = board.ensure_fields(gh, "o", 1) # must not raise
472
+ assert project["id"]
@@ -162,3 +162,55 @@ def test_render_html_risk_table():
162
162
  # severity cells are colored
163
163
  assert 'style="color:var(--bad);font-weight:600;">high</td>' in out \
164
164
  or 'style="color:var(--amber);font-weight:600;">medium</td>' in out
165
+
166
+
167
+ # ── risk disposition (ENG-PR-002) ────────────────────────────────────────────
168
+ def _risk_graph(*status_clauses):
169
+ from docassert.graph import Graph
170
+ from docassert.models import Item
171
+ g = Graph()
172
+ for i, clause in enumerate(status_clauses, 1):
173
+ text = (f"Risk {i} description. Probability: low. Impact: high. "
174
+ f"Owner: o.{i}.{clause} Response: watch.")
175
+ g.add(Item(f"XX-RISK-{i:03d}", "XX", "RISK", text, {}, "d.md",
176
+ "risk-register", "approved", "S"))
177
+ return g
178
+
179
+
180
+ def test_disposition_parsed_and_defaulted():
181
+ from docassert.status.derive import _risks
182
+ g = _risk_graph(" Status: Mitigated.", " Status: accepted.", "")
183
+ rs = _risks(g)
184
+ assert [r["disposition"] for r in rs] == ["open", "mitigated", "accepted"]
185
+ assert rs[0]["id"] == "XX-RISK-003" # open risks sort first
186
+
187
+
188
+ def test_rag_green_when_all_risks_dispositioned():
189
+ base = {"documents": [], "coverage": [], "broken_references": [],
190
+ "latest_report": None, "completeness": None}
191
+ open_r = dict(base, risks=[{"id": "R", "score": 2, "disposition": "open"}])
192
+ done_r = dict(base, risks=[{"id": "R", "score": 2, "disposition": "accepted"}])
193
+ assert S.derive_rag(open_r) == "amber"
194
+ assert S.derive_rag(done_r) == "green"
195
+
196
+
197
+ def test_disposition_check_blocks_invalid(tmp_path):
198
+ from docassert.loader import load
199
+ from docassert.structural import check_risk_disposition_valid
200
+ p = tmp_path / "r.md"
201
+ p.write_text(
202
+ "---\nkind: risk-register\nid: XX-risk-register\n"
203
+ "project: PRJ-001-XX\ntitle: T\nowner: o\nstatus: approved\n---\n\n"
204
+ "## Overview\nx\n\n## Risks\n\n"
205
+ "- **XX-RISK-001**: Bad. Probability: low. Impact: low. Owner: o. "
206
+ "Status: wontfix. Response: r.\n", encoding="utf-8")
207
+ doc = load(p)
208
+ ok, detail = check_risk_disposition_valid(
209
+ doc, {"item_sections": [{"section": "Risks", "prefix": "RISK"}]})
210
+ assert not ok and "wontfix" in detail
211
+
212
+
213
+ def test_render_html_shows_disposition_column():
214
+ _cd_root()
215
+ out = S.render_html(S.build_status(ROOT / "documents"))
216
+ assert "<th>Status</th>" in out and "open of" in out
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes