docassert 0.12.1__tar.gz → 0.14.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.12.1/docassert.egg-info → docassert-0.14.0}/PKG-INFO +1 -1
  2. {docassert-0.12.1 → docassert-0.14.0}/docassert/__init__.py +1 -1
  3. {docassert-0.12.1 → docassert-0.14.0}/docassert/_data/schema/project.schema.json +5 -0
  4. {docassert-0.12.1 → docassert-0.14.0}/docassert/bridge/plan.py +33 -1
  5. {docassert-0.12.1 → docassert-0.14.0}/docassert/cli.py +53 -15
  6. {docassert-0.12.1 → docassert-0.14.0}/docassert/status/derive.py +15 -0
  7. {docassert-0.12.1 → docassert-0.14.0}/docassert/status/render.py +23 -5
  8. {docassert-0.12.1 → docassert-0.14.0/docassert.egg-info}/PKG-INFO +1 -1
  9. {docassert-0.12.1 → docassert-0.14.0}/tests/test_bridge.py +92 -0
  10. {docassert-0.12.1 → docassert-0.14.0}/tests/test_status.py +33 -0
  11. {docassert-0.12.1 → docassert-0.14.0}/LICENSE +0 -0
  12. {docassert-0.12.1 → docassert-0.14.0}/NOTICE +0 -0
  13. {docassert-0.12.1 → docassert-0.14.0}/README.md +0 -0
  14. {docassert-0.12.1 → docassert-0.14.0}/docassert/__main__.py +0 -0
  15. {docassert-0.12.1 → docassert-0.14.0}/docassert/_data/consistency.yaml +0 -0
  16. {docassert-0.12.1 → docassert-0.14.0}/docassert/_data/criteria/adr.criteria.yaml +0 -0
  17. {docassert-0.12.1 → docassert-0.14.0}/docassert/_data/criteria/benefits-realization.criteria.yaml +0 -0
  18. {docassert-0.12.1 → docassert-0.14.0}/docassert/_data/criteria/brd.criteria.yaml +0 -0
  19. {docassert-0.12.1 → docassert-0.14.0}/docassert/_data/criteria/business-case.criteria.yaml +0 -0
  20. {docassert-0.12.1 → docassert-0.14.0}/docassert/_data/criteria/charter.criteria.yaml +0 -0
  21. {docassert-0.12.1 → docassert-0.14.0}/docassert/_data/criteria/data-migration-plan.criteria.yaml +0 -0
  22. {docassert-0.12.1 → docassert-0.14.0}/docassert/_data/criteria/frnfr.criteria.yaml +0 -0
  23. {docassert-0.12.1 → docassert-0.14.0}/docassert/_data/criteria/hypercare-plan.criteria.yaml +0 -0
  24. {docassert-0.12.1 → docassert-0.14.0}/docassert/_data/criteria/post-implementation-review.criteria.yaml +0 -0
  25. {docassert-0.12.1 → docassert-0.14.0}/docassert/_data/criteria/prd.criteria.yaml +0 -0
  26. {docassert-0.12.1 → docassert-0.14.0}/docassert/_data/criteria/project.criteria.yaml +0 -0
  27. {docassert-0.12.1 → docassert-0.14.0}/docassert/_data/criteria/qa-test-plan.criteria.yaml +0 -0
  28. {docassert-0.12.1 → docassert-0.14.0}/docassert/_data/criteria/raci-stakeholder.criteria.yaml +0 -0
  29. {docassert-0.12.1 → docassert-0.14.0}/docassert/_data/criteria/release-cutover-plan.criteria.yaml +0 -0
  30. {docassert-0.12.1 → docassert-0.14.0}/docassert/_data/criteria/risk-register.criteria.yaml +0 -0
  31. {docassert-0.12.1 → docassert-0.14.0}/docassert/_data/criteria/rollback-plan.criteria.yaml +0 -0
  32. {docassert-0.12.1 → docassert-0.14.0}/docassert/_data/criteria/runbook.criteria.yaml +0 -0
  33. {docassert-0.12.1 → docassert-0.14.0}/docassert/_data/criteria/status-report.criteria.yaml +0 -0
  34. {docassert-0.12.1 → docassert-0.14.0}/docassert/_data/criteria/test-cases.criteria.yaml +0 -0
  35. {docassert-0.12.1 → docassert-0.14.0}/docassert/_data/criteria/user-story.criteria.yaml +0 -0
  36. {docassert-0.12.1 → docassert-0.14.0}/docassert/_data/profiles/agile-delivery.yaml +0 -0
  37. {docassert-0.12.1 → docassert-0.14.0}/docassert/_data/profiles/lean-startup.yaml +0 -0
  38. {docassert-0.12.1 → docassert-0.14.0}/docassert/_data/profiles/regulated-industry.yaml +0 -0
  39. {docassert-0.12.1 → docassert-0.14.0}/docassert/_data/schema/adr.schema.json +0 -0
  40. {docassert-0.12.1 → docassert-0.14.0}/docassert/_data/schema/benefits-realization.schema.json +0 -0
  41. {docassert-0.12.1 → docassert-0.14.0}/docassert/_data/schema/brd.schema.json +0 -0
  42. {docassert-0.12.1 → docassert-0.14.0}/docassert/_data/schema/business-case.schema.json +0 -0
  43. {docassert-0.12.1 → docassert-0.14.0}/docassert/_data/schema/charter.schema.json +0 -0
  44. {docassert-0.12.1 → docassert-0.14.0}/docassert/_data/schema/data-migration-plan.schema.json +0 -0
  45. {docassert-0.12.1 → docassert-0.14.0}/docassert/_data/schema/frnfr.schema.json +0 -0
  46. {docassert-0.12.1 → docassert-0.14.0}/docassert/_data/schema/hypercare-plan.schema.json +0 -0
  47. {docassert-0.12.1 → docassert-0.14.0}/docassert/_data/schema/post-implementation-review.schema.json +0 -0
  48. {docassert-0.12.1 → docassert-0.14.0}/docassert/_data/schema/prd.schema.json +0 -0
  49. {docassert-0.12.1 → docassert-0.14.0}/docassert/_data/schema/qa-test-plan.schema.json +0 -0
  50. {docassert-0.12.1 → docassert-0.14.0}/docassert/_data/schema/raci-stakeholder.schema.json +0 -0
  51. {docassert-0.12.1 → docassert-0.14.0}/docassert/_data/schema/release-cutover-plan.schema.json +0 -0
  52. {docassert-0.12.1 → docassert-0.14.0}/docassert/_data/schema/risk-register.schema.json +0 -0
  53. {docassert-0.12.1 → docassert-0.14.0}/docassert/_data/schema/rollback-plan.schema.json +0 -0
  54. {docassert-0.12.1 → docassert-0.14.0}/docassert/_data/schema/runbook.schema.json +0 -0
  55. {docassert-0.12.1 → docassert-0.14.0}/docassert/_data/schema/status-report.schema.json +0 -0
  56. {docassert-0.12.1 → docassert-0.14.0}/docassert/_data/schema/test-cases.schema.json +0 -0
  57. {docassert-0.12.1 → docassert-0.14.0}/docassert/_data/schema/user-story.schema.json +0 -0
  58. {docassert-0.12.1 → docassert-0.14.0}/docassert/_data/skills/doc-to-pmo/SKILL.md +0 -0
  59. {docassert-0.12.1 → docassert-0.14.0}/docassert/_data/templates/adr.template.md +0 -0
  60. {docassert-0.12.1 → docassert-0.14.0}/docassert/_data/templates/benefits-realization.template.md +0 -0
  61. {docassert-0.12.1 → docassert-0.14.0}/docassert/_data/templates/brd.template.md +0 -0
  62. {docassert-0.12.1 → docassert-0.14.0}/docassert/_data/templates/business-case.template.md +0 -0
  63. {docassert-0.12.1 → docassert-0.14.0}/docassert/_data/templates/charter.template.md +0 -0
  64. {docassert-0.12.1 → docassert-0.14.0}/docassert/_data/templates/data-migration-plan.template.md +0 -0
  65. {docassert-0.12.1 → docassert-0.14.0}/docassert/_data/templates/frnfr.template.md +0 -0
  66. {docassert-0.12.1 → docassert-0.14.0}/docassert/_data/templates/hypercare-plan.template.md +0 -0
  67. {docassert-0.12.1 → docassert-0.14.0}/docassert/_data/templates/post-implementation-review.template.md +0 -0
  68. {docassert-0.12.1 → docassert-0.14.0}/docassert/_data/templates/prd.template.md +0 -0
  69. {docassert-0.12.1 → docassert-0.14.0}/docassert/_data/templates/project.template.md +0 -0
  70. {docassert-0.12.1 → docassert-0.14.0}/docassert/_data/templates/qa-test-plan.template.md +0 -0
  71. {docassert-0.12.1 → docassert-0.14.0}/docassert/_data/templates/raci-stakeholder.template.md +0 -0
  72. {docassert-0.12.1 → docassert-0.14.0}/docassert/_data/templates/release-cutover-plan.template.md +0 -0
  73. {docassert-0.12.1 → docassert-0.14.0}/docassert/_data/templates/risk-register.template.md +0 -0
  74. {docassert-0.12.1 → docassert-0.14.0}/docassert/_data/templates/rollback-plan.template.md +0 -0
  75. {docassert-0.12.1 → docassert-0.14.0}/docassert/_data/templates/runbook.template.md +0 -0
  76. {docassert-0.12.1 → docassert-0.14.0}/docassert/_data/templates/status-report.template.md +0 -0
  77. {docassert-0.12.1 → docassert-0.14.0}/docassert/_data/templates/test-cases.template.md +0 -0
  78. {docassert-0.12.1 → docassert-0.14.0}/docassert/_data/templates/user-story.template.md +0 -0
  79. {docassert-0.12.1 → docassert-0.14.0}/docassert/bridge/__init__.py +0 -0
  80. {docassert-0.12.1 → docassert-0.14.0}/docassert/bridge/board.py +0 -0
  81. {docassert-0.12.1 → docassert-0.14.0}/docassert/bridge/gh.py +0 -0
  82. {docassert-0.12.1 → docassert-0.14.0}/docassert/bridge/ops.py +0 -0
  83. {docassert-0.12.1 → docassert-0.14.0}/docassert/config.py +0 -0
  84. {docassert-0.12.1 → docassert-0.14.0}/docassert/consistency.py +0 -0
  85. {docassert-0.12.1 → docassert-0.14.0}/docassert/extract.py +0 -0
  86. {docassert-0.12.1 → docassert-0.14.0}/docassert/graph.py +0 -0
  87. {docassert-0.12.1 → docassert-0.14.0}/docassert/loader.py +0 -0
  88. {docassert-0.12.1 → docassert-0.14.0}/docassert/models.py +0 -0
  89. {docassert-0.12.1 → docassert-0.14.0}/docassert/profiles.py +0 -0
  90. {docassert-0.12.1 → docassert-0.14.0}/docassert/projects.py +0 -0
  91. {docassert-0.12.1 → docassert-0.14.0}/docassert/report.py +0 -0
  92. {docassert-0.12.1 → docassert-0.14.0}/docassert/rtm.py +0 -0
  93. {docassert-0.12.1 → docassert-0.14.0}/docassert/scaffold.py +0 -0
  94. {docassert-0.12.1 → docassert-0.14.0}/docassert/semantic.py +0 -0
  95. {docassert-0.12.1 → docassert-0.14.0}/docassert/status/__init__.py +0 -0
  96. {docassert-0.12.1 → docassert-0.14.0}/docassert/structural.py +0 -0
  97. {docassert-0.12.1 → docassert-0.14.0}/docassert.egg-info/SOURCES.txt +0 -0
  98. {docassert-0.12.1 → docassert-0.14.0}/docassert.egg-info/dependency_links.txt +0 -0
  99. {docassert-0.12.1 → docassert-0.14.0}/docassert.egg-info/entry_points.txt +0 -0
  100. {docassert-0.12.1 → docassert-0.14.0}/docassert.egg-info/requires.txt +0 -0
  101. {docassert-0.12.1 → docassert-0.14.0}/docassert.egg-info/top_level.txt +0 -0
  102. {docassert-0.12.1 → docassert-0.14.0}/pyproject.toml +0 -0
  103. {docassert-0.12.1 → docassert-0.14.0}/setup.cfg +0 -0
  104. {docassert-0.12.1 → docassert-0.14.0}/tests/test_badge.py +0 -0
  105. {docassert-0.12.1 → docassert-0.14.0}/tests/test_config.py +0 -0
  106. {docassert-0.12.1 → docassert-0.14.0}/tests/test_consistency.py +0 -0
  107. {docassert-0.12.1 → docassert-0.14.0}/tests/test_defects.py +0 -0
  108. {docassert-0.12.1 → docassert-0.14.0}/tests/test_draft_relaxation.py +0 -0
  109. {docassert-0.12.1 → docassert-0.14.0}/tests/test_extract.py +0 -0
  110. {docassert-0.12.1 → docassert-0.14.0}/tests/test_graph.py +0 -0
  111. {docassert-0.12.1 → docassert-0.14.0}/tests/test_json_report.py +0 -0
  112. {docassert-0.12.1 → docassert-0.14.0}/tests/test_kinds_delivery.py +0 -0
  113. {docassert-0.12.1 → docassert-0.14.0}/tests/test_kinds_governance.py +0 -0
  114. {docassert-0.12.1 → docassert-0.14.0}/tests/test_kinds_operate.py +0 -0
  115. {docassert-0.12.1 → docassert-0.14.0}/tests/test_kinds_reporting.py +0 -0
  116. {docassert-0.12.1 → docassert-0.14.0}/tests/test_profiles.py +0 -0
  117. {docassert-0.12.1 → docassert-0.14.0}/tests/test_projects.py +0 -0
  118. {docassert-0.12.1 → docassert-0.14.0}/tests/test_scaffold.py +0 -0
  119. {docassert-0.12.1 → docassert-0.14.0}/tests/test_semantic.py +0 -0
  120. {docassert-0.12.1 → docassert-0.14.0}/tests/test_structural.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: docassert
3
- Version: 0.12.1
3
+ Version: 0.14.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.12.1"
8
+ __version__ = "0.14.0"
@@ -7,6 +7,11 @@
7
7
  "additionalProperties": true,
8
8
  "properties": {
9
9
  "kind": { "const": "project" },
10
+ "repo": {
11
+ "type": "string",
12
+ "pattern": "^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$",
13
+ "description": "Optional OWNER/NAME of the code repository this project's execution bridge targets."
14
+ },
10
15
  "id": {
11
16
  "type": "string",
12
17
  "pattern": "^PRJ-\\d{3,}-[A-Z]{2,6}$",
@@ -83,7 +83,8 @@ def _anchors(documents_dir: Path) -> dict[str, dict]:
83
83
  continue
84
84
  if doc.kind == "project" and doc.id:
85
85
  out[doc.id] = {"code": str(doc.frontmatter.get("code", "")),
86
- "name": str(doc.frontmatter.get("name", ""))}
86
+ "name": str(doc.frontmatter.get("name", "")),
87
+ "repo": str(doc.frontmatter.get("repo", "") or "")}
87
88
  return out
88
89
 
89
90
 
@@ -155,10 +156,41 @@ def build_bridge_plan(documents_dir: str | Path = "documents") -> BridgePlan:
155
156
  "reason": "; ".join(problems)})
156
157
  continue
157
158
  plan.projects.append({"id": proj_id, "code": code, "name": meta["name"],
159
+ "repo": meta.get("repo", ""),
158
160
  "features": list(features.values())})
159
161
  return plan
160
162
 
161
163
 
164
+ def filter_plan(plan: BridgePlan, project: str) -> BridgePlan:
165
+ """A plan reduced to one project (matched by id or code). Unknown ids
166
+ yield an empty plan whose skipped list explains why."""
167
+ key = project.strip()
168
+ keep = [p for p in plan.projects
169
+ if p["id"] == key or p["code"] == key]
170
+ out = BridgePlan(projects=keep,
171
+ skipped=[s for s in plan.skipped if s["id"] == key])
172
+ if not keep and not out.skipped:
173
+ out.skipped.append({"id": key, "reason": "no such project in the plan"})
174
+ return out
175
+
176
+
177
+ def plans_by_repo(plan: BridgePlan) -> dict[str, BridgePlan]:
178
+ """Group a plan by each project's mapped repository (the `repo` field on
179
+ the project anchor). Projects sharing a repo share one sub-plan, so
180
+ reconciliation in that repo sees the union of their managed ids.
181
+ Raises ValueError naming any project without a mapping."""
182
+ unmapped = [p["id"] for p in plan.projects if not p.get("repo")]
183
+ if unmapped:
184
+ raise ValueError(
185
+ "no repo mapping on project anchor(s): " + ", ".join(unmapped)
186
+ + " (add `repo: OWNER/NAME` to project.md or pass --repo)")
187
+ groups: dict[str, BridgePlan] = {}
188
+ for proj in plan.projects:
189
+ groups.setdefault(proj["repo"],
190
+ BridgePlan(skipped=plan.skipped)).projects.append(proj)
191
+ return groups
192
+
193
+
162
194
  def feature_title(f: Feature) -> str:
163
195
  return f"[{f.id}] {_title(f.text)}"
164
196
 
@@ -238,7 +238,7 @@ def cmd_pages(args: argparse.Namespace) -> int:
238
238
  data = _json.loads(Path(args.execution).read_text())
239
239
  for proj in data.get("projects", []):
240
240
  execution[proj["id"]] = {**proj, "scope": data.get("scope"),
241
- "repo": data.get("repo")}
241
+ "repo": proj.get("repo") or data.get("repo")}
242
242
 
243
243
  plist = projects_mod.load_projects(docs_dir)
244
244
  for p in plist:
@@ -306,10 +306,30 @@ def cmd_bridge(args: argparse.Namespace) -> int:
306
306
  Scope flows documents -> GitHub only; the bridge never edits documents."""
307
307
  from .bridge import build_bridge_plan, ops
308
308
  from .bridge import gh as ghmod
309
+ from .bridge.plan import filter_plan, plans_by_repo
309
310
 
310
311
  plan = build_bridge_plan(args.documents_dir)
312
+ if getattr(args, "project", None):
313
+ plan = filter_plan(plan, args.project)
311
314
  gh = ghmod.DryRunner() if getattr(args, "dry_run", False) else ghmod.GhRunner()
312
315
 
316
+ # Repo resolution: an explicit --repo applies the whole plan to one
317
+ # repository (single-repo portfolios, back-compatible); without it, each
318
+ # project routes to the repo mapped on its anchor.
319
+ if args.action != "create-board":
320
+ if args.repo:
321
+ repo_plans = {args.repo: plan}
322
+ else:
323
+ try:
324
+ repo_plans = plans_by_repo(plan)
325
+ except ValueError as exc:
326
+ print(f"docassert bridge: {exc}", file=sys.stderr)
327
+ return 2
328
+ if not repo_plans and plan.skipped:
329
+ for s in plan.skipped:
330
+ print(f"docassert bridge: skipped {s['id']}: {s['reason']}")
331
+ return 0
332
+
313
333
  if args.action == "create-board":
314
334
  from .bridge import board as board_mod
315
335
  token = os.environ.get(args.board_token_env, "")
@@ -329,30 +349,46 @@ def cmd_bridge(args: argparse.Namespace) -> int:
329
349
  "(needed for --project-number)", file=sys.stderr)
330
350
  return 2
331
351
  bgh = gh if isinstance(gh, ghmod.DryRunner) else ghmod.GhRunner(token=token)
332
- board_cfg = {"gh": bgh, "owner": args.project_owner or args.repo.split("/")[0],
352
+ owner = args.project_owner or next(iter(repo_plans)).split("/")[0]
353
+ board_cfg = {"gh": bgh, "owner": owner,
333
354
  "number": args.project_number, "init": args.init_board}
334
355
 
335
356
  if args.action == "scaffold":
336
- actions = ops.scaffold(plan, gh, args.repo, docs_url=args.docs_url,
337
- board_cfg=board_cfg)
338
- for a in actions:
339
- print(f"docassert bridge: {a}")
357
+ for repo, sub in repo_plans.items():
358
+ actions = ops.scaffold(sub, gh, repo, docs_url=args.docs_url,
359
+ board_cfg=board_cfg)
360
+ for a in actions:
361
+ print(f"docassert bridge: [{repo}] {a}")
340
362
  if isinstance(gh, ghmod.DryRunner):
341
363
  print(f"docassert bridge: dry run — {len(gh.planned)} mutation(s) planned")
342
364
  for m in gh.planned:
343
365
  print(f" {m}")
344
366
  return 0
345
367
  if args.action == "reconcile":
346
- lines, code = ops.reconcile(plan, gh, args.repo)
347
- for line in lines:
348
- print(f"docassert bridge: {line}")
349
- return code
368
+ worst = 0
369
+ for repo, sub in repo_plans.items():
370
+ lines, code = ops.reconcile(sub, gh, repo)
371
+ for line in lines:
372
+ print(f"docassert bridge: [{repo}] {line}")
373
+ worst = max(worst, code)
374
+ return worst
350
375
  if args.action == "status":
351
- data = ops.status(plan, gh, args.repo)
352
- sys.stdout.write(ops.to_json(data) if args.json else
353
- ops.render_status(data) + "\n")
376
+ merged: dict = {"repo": None, "projects": [], "scope": {"unverified": [], "orphaned": []}}
377
+ for repo, sub in repo_plans.items():
378
+ data = ops.status(sub, gh, repo)
379
+ for proj in data.get("projects", []):
380
+ proj["repo"] = repo
381
+ merged["projects"].append(proj)
382
+ for key in ("unverified", "orphaned"):
383
+ for item in data.get("scope", {}).get(key, []):
384
+ item["repo"] = repo
385
+ merged["scope"][key].append(item)
386
+ if len(repo_plans) == 1:
387
+ merged["repo"] = next(iter(repo_plans))
388
+ sys.stdout.write(ops.to_json(merged) if args.json else
389
+ ops.render_status(merged) + "\n")
354
390
  if args.json and args.out:
355
- Path(args.out).write_text(ops.to_json(data))
391
+ Path(args.out).write_text(ops.to_json(merged))
356
392
  return 0
357
393
  print(f"docassert bridge: unknown action {args.action!r}", file=sys.stderr)
358
394
  return 2
@@ -441,7 +477,9 @@ def main(argv: list[str] | None = None) -> int:
441
477
  b = sub.add_parser("bridge", help="Execution bridge: scaffold and police the GitHub board from approved stories.")
442
478
  b.add_argument("action", choices=["scaffold", "reconcile", "status", "create-board"],
443
479
  help="scaffold: docs -> issues/board · reconcile: police the board · status: delivery figures.")
444
- b.add_argument("--repo", required=True, help="Target GitHub repo (OWNER/NAME) holding the issues.")
480
+ b.add_argument("--repo", help="Target GitHub repo (OWNER/NAME) for the whole plan; "
481
+ "omit to route each project to the `repo:` on its anchor.")
482
+ b.add_argument("--project", help="Scope to one project (PRJ-NNN-CODE id or CODE).")
445
483
  b.add_argument("--docs-url", help="Base URL of the documents repo, for source links in issue bodies.")
446
484
  b.add_argument("--dry-run", action="store_true", help="Print planned mutations without executing them.")
447
485
  b.add_argument("--json", action="store_true", help="status: emit JSON.")
@@ -5,6 +5,7 @@ lives in docassert.status.render.
5
5
  """
6
6
  from __future__ import annotations
7
7
 
8
+ import re
8
9
  from pathlib import Path
9
10
 
10
11
  from .. import config as config_mod
@@ -63,6 +64,10 @@ def _coverage(graph, config, code=None):
63
64
  return out
64
65
 
65
66
 
67
+ _RISK_FIELD_RE = re.compile(
68
+ r"\s*(?:Probability|Impact|Owner|Response)\s*:", re.IGNORECASE)
69
+
70
+
66
71
  def _risks(graph, code=None):
67
72
  risks = []
68
73
  for item in graph.by_type.get("RISK", []):
@@ -70,11 +75,21 @@ def _risks(graph, code=None):
70
75
  continue
71
76
  prob = (_field_value(item.text, "probability") or "").lower()
72
77
  impact = (_field_value(item.text, "impact") or "").lower()
78
+ # The description is the item text up to the first metadata field;
79
+ # the response runs from its label to the end (it may hold clauses
80
+ # that _field_value would truncate at a sentence break).
81
+ m = _RISK_FIELD_RE.search(item.text)
82
+ description = (item.text[:m.start()] if m else item.text).strip()
83
+ rm = re.search(r"Response\s*:\s*(.+)$", item.text,
84
+ re.IGNORECASE | re.DOTALL)
73
85
  risks.append({
74
86
  "id": item.id,
87
+ "description": description,
88
+ "threatens": item.links.get("threatens", []),
75
89
  "probability": prob or "?",
76
90
  "impact": impact or "?",
77
91
  "owner": _field_value(item.text, "owner") or "?",
92
+ "response": rm.group(1).strip() if rm else "",
78
93
  "score": _SEVERITY.get(prob, 0) * _SEVERITY.get(impact, 0),
79
94
  })
80
95
  return sorted(risks, key=lambda r: -r["score"])
@@ -143,9 +143,27 @@ def render_html(model) -> str:
143
143
  + (";background:var(--amber)" if cov["gaps"] else "") + '"></div></div></div>'
144
144
  for cov in model["coverage"])
145
145
 
146
- risks = "".join(
147
- f'<li><code>{esc(r["id"])}</code> — {esc(r["probability"])}/{esc(r["impact"])}'
148
- f' · owner {esc(r["owner"])}</li>' for r in model["risks"]) or "<li>None open.</li>"
146
+ def sev(value):
147
+ color = {"critical": "var(--bad)", "high": "var(--bad)",
148
+ "medium": "var(--amber)", "low": "var(--ok)"}.get(
149
+ value, "var(--muted)")
150
+ return f'<td style="color:{color};font-weight:600;">{esc(value)}</td>'
151
+
152
+ if model["risks"]:
153
+ risk_rows = "".join(
154
+ f'<tr><td><code>{esc(r["id"])}</code></td>'
155
+ f'<td>{esc(r["description"])}</td>'
156
+ f'<td>{", ".join(f"<code>{esc(b)}</code>" for b in r["threatens"]) or "—"}</td>'
157
+ + sev(r["probability"]) + sev(r["impact"])
158
+ + f'<td>{esc(r["owner"])}</td>'
159
+ f'<td>{esc(r["response"]) or "—"}</td></tr>'
160
+ for r in model["risks"])
161
+ risks = ('<table><thead><tr><th>ID</th><th>Risk</th><th>Threatens</th>'
162
+ '<th>Probability</th><th>Impact</th><th>Owner</th>'
163
+ '<th>Response</th></tr></thead>'
164
+ f'<tbody>{risk_rows}</tbody></table>')
165
+ else:
166
+ risks = "<p>None open.</p>"
149
167
 
150
168
  problems = ""
151
169
  if model["broken_references"]:
@@ -230,7 +248,7 @@ def render_html(model) -> str:
230
248
  bad = scope.get("unverified", []) + scope.get("orphaned", [])
231
249
  if bad:
232
250
  items = "".join(
233
- f'<li><a href="https://github.com/{esc(ex.get("repo", ""))}/issues/{i["number"]}">#{i["number"]}</a> {esc(i["title"])}</li>'
251
+ f'<li><a href="https://github.com/{esc(i.get("repo") or ex.get("repo") or "")}/issues/{i["number"]}">#{i["number"]}</a> {esc(i["title"])}</li>'
234
252
  for i in bad)
235
253
  execution_html += (
236
254
  f'<section><h2 style="color:var(--bad)">Scope · {len(bad)} unmatched item(s)</h2>'
@@ -262,7 +280,7 @@ def render_html(model) -> str:
262
280
  {document_set}
263
281
  {execution_html}
264
282
  {problems}
265
- <section><h2>Open risks ({len(model['risks'])})</h2><ul>{risks}</ul></section>
283
+ <section><h2>Open risks ({len(model['risks'])})</h2>{risks}</section>
266
284
  <section><h2>Latest status report</h2><p>{report}</p></section>
267
285
  <section><h2>Documents ({c['total']})</h2>
268
286
  <table><thead><tr><th>Kind</th><th>ID</th><th>Status</th><th>Audit</th></tr></thead>
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: docassert
3
- Version: 0.12.1
3
+ Version: 0.14.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
@@ -349,3 +349,95 @@ def test_status_includes_scope_classification(tmp_path):
349
349
  assert data["repo"] == "o/r"
350
350
  assert [i["number"] for i in data["scope"]["unverified"]] == [2]
351
351
  assert [i["doc"] for i in data["scope"]["orphaned"]] == ["TST-US-999"]
352
+
353
+
354
+ # ── project filter and per-repo mapping (ENG-US-001 / ENG-US-002) ────────────
355
+ from docassert.bridge.plan import filter_plan, plans_by_repo # noqa: E402
356
+
357
+ ANCHOR2 = """---
358
+ kind: project
359
+ id: PRJ-002-TWO
360
+ code: TWO
361
+ name: Second Project
362
+ sponsor: S
363
+ status: active
364
+ repo: owner/two-repo
365
+ ---
366
+
367
+ ## Overview
368
+
369
+ x
370
+
371
+ ## Scope
372
+
373
+ x
374
+ """
375
+
376
+ PRD2 = PRD.replace("PRJ-001-TST", "PRJ-002-TWO").replace("TST", "TWO")
377
+ STORIES2 = STORIES.replace("PRJ-001-TST", "PRJ-002-TWO").replace("TST", "TWO")
378
+
379
+
380
+ def _two_project_tree(tmp_path, repo_one="owner/one-repo"):
381
+ docs = _tree(tmp_path)
382
+ if repo_one:
383
+ anchor = docs / "PRJ-001-TST" / "project.md"
384
+ anchor.write_text(anchor.read_text().replace(
385
+ "status: active", f"status: active\nrepo: {repo_one}"), encoding="utf-8")
386
+ d = docs / "PRJ-002-TWO"
387
+ d.mkdir()
388
+ (d / "project.md").write_text(ANCHOR2, encoding="utf-8")
389
+ (d / "prd.md").write_text(PRD2, encoding="utf-8")
390
+ (d / "user-story.md").write_text(STORIES2.format(status="approved"), encoding="utf-8")
391
+ return docs
392
+
393
+
394
+ def test_filter_plan_by_id_and_code(tmp_path):
395
+ plan = build_bridge_plan(_two_project_tree(tmp_path))
396
+ assert len(plan.projects) == 2
397
+ only = filter_plan(plan, "PRJ-002-TWO")
398
+ assert [p["id"] for p in only.projects] == ["PRJ-002-TWO"]
399
+ by_code = filter_plan(plan, "TST")
400
+ assert [p["code"] for p in by_code.projects] == ["TST"]
401
+ missing = filter_plan(plan, "PRJ-999-NOPE")
402
+ assert not missing.projects and missing.skipped
403
+
404
+
405
+ def test_plans_by_repo_routes_each_project(tmp_path):
406
+ plan = build_bridge_plan(_two_project_tree(tmp_path))
407
+ groups = plans_by_repo(plan)
408
+ assert set(groups) == {"owner/one-repo", "owner/two-repo"}
409
+ assert [p["id"] for p in groups["owner/one-repo"].projects] == ["PRJ-001-TST"]
410
+ assert [p["id"] for p in groups["owner/two-repo"].projects] == ["PRJ-002-TWO"]
411
+
412
+
413
+ def test_plans_by_repo_shares_a_repo_as_a_union(tmp_path):
414
+ docs = _two_project_tree(tmp_path)
415
+ anchor = docs / "PRJ-002-TWO" / "project.md"
416
+ anchor.write_text(anchor.read_text().replace("owner/two-repo", "owner/one-repo"),
417
+ encoding="utf-8")
418
+ plan = build_bridge_plan(docs)
419
+ groups = plans_by_repo(plan)
420
+ assert set(groups) == {"owner/one-repo"}
421
+ union = groups["owner/one-repo"]
422
+ assert len(union.projects) == 2
423
+ # both projects' ids are managed, so neither would be orphaned in reconcile
424
+ assert any(i.startswith("TST-") for i in union.managed_ids)
425
+ assert any(i.startswith("TWO-") for i in union.managed_ids)
426
+
427
+
428
+ def test_plans_by_repo_names_unmapped_projects(tmp_path):
429
+ plan = build_bridge_plan(_two_project_tree(tmp_path, repo_one=None))
430
+ try:
431
+ plans_by_repo(plan)
432
+ raise AssertionError("expected ValueError")
433
+ except ValueError as exc:
434
+ assert "PRJ-001-TST" in str(exc) and "--repo" in str(exc)
435
+
436
+
437
+ def test_anchor_without_repo_keeps_single_repo_mode_working(tmp_path):
438
+ # the original single-repo path: no repo fields, explicit --repo, unchanged
439
+ plan = build_bridge_plan(_tree(tmp_path))
440
+ assert plan.projects[0].get("repo", "") == ""
441
+ gh = FakeGh()
442
+ ops.scaffold(plan, gh, "o/r", docs_url=None)
443
+ assert any("issues" in " ".join(c) for c in gh.calls)
@@ -129,3 +129,36 @@ def test_render_project_page_has_back_link_and_scoped_title():
129
129
  assert 'href="index.html"' in out # back to the portfolio index
130
130
  assert "Atlas" in out and "AMBER" in out
131
131
  assert "http://" not in out and "https://" not in out
132
+
133
+
134
+ # ── the risk model carries the full story ────────────────────────────────────
135
+ def test_risks_carry_description_threatens_and_response():
136
+ _cd_root()
137
+ m = S.build_status(ROOT / "documents")
138
+ r = next(r for r in m["risks"] if r["id"] == "AUR-RISK-001")
139
+ assert r["description"] and "Probability" not in r["description"]
140
+ assert r["threatens"] # links to the BRs at stake
141
+ assert r["response"] # the full mitigation text
142
+ assert r["probability"] in {"low", "medium", "high", "critical", "?"}
143
+
144
+
145
+ def test_risks_tolerate_missing_fields():
146
+ from docassert.graph import Graph
147
+ from docassert.models import Item
148
+ from docassert.status.derive import _risks
149
+ g = Graph()
150
+ g.add(Item("XX-RISK-001", "XX", "RISK", "Bare risk with no fields yet.",
151
+ {}, "d.md", "risk-register", "draft", "S"))
152
+ (r,) = _risks(g)
153
+ assert r["description"] == "Bare risk with no fields yet."
154
+ assert r["response"] == "" and r["probability"] == "?"
155
+
156
+
157
+ def test_render_html_risk_table():
158
+ _cd_root()
159
+ out = S.render_html(S.build_status(ROOT / "documents"))
160
+ assert "<th>Risk</th>" in out and "<th>Response</th>" in out
161
+ assert "<th>Threatens</th>" in out and "<th>Owner</th>" in out
162
+ # severity cells are colored
163
+ assert 'style="color:var(--bad);font-weight:600;">high</td>' in out \
164
+ or 'style="color:var(--amber);font-weight:600;">medium</td>' in out
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes