docassert 0.7.1__tar.gz → 0.8.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 (118) hide show
  1. {docassert-0.7.1/docassert.egg-info → docassert-0.8.1}/PKG-INFO +3 -2
  2. {docassert-0.7.1 → docassert-0.8.1}/README.md +2 -1
  3. {docassert-0.7.1 → docassert-0.8.1}/docassert/__init__.py +1 -1
  4. docassert-0.8.1/docassert/bridge/__init__.py +9 -0
  5. docassert-0.8.1/docassert/bridge/gh.py +118 -0
  6. docassert-0.8.1/docassert/bridge/ops.py +179 -0
  7. docassert-0.8.1/docassert/bridge/plan.py +178 -0
  8. {docassert-0.7.1 → docassert-0.8.1}/docassert/cli.py +48 -0
  9. {docassert-0.7.1 → docassert-0.8.1/docassert.egg-info}/PKG-INFO +3 -2
  10. {docassert-0.7.1 → docassert-0.8.1}/docassert.egg-info/SOURCES.txt +5 -0
  11. docassert-0.8.1/tests/test_bridge.py +249 -0
  12. {docassert-0.7.1 → docassert-0.8.1}/LICENSE +0 -0
  13. {docassert-0.7.1 → docassert-0.8.1}/NOTICE +0 -0
  14. {docassert-0.7.1 → docassert-0.8.1}/docassert/__main__.py +0 -0
  15. {docassert-0.7.1 → docassert-0.8.1}/docassert/_data/consistency.yaml +0 -0
  16. {docassert-0.7.1 → docassert-0.8.1}/docassert/_data/criteria/adr.criteria.yaml +0 -0
  17. {docassert-0.7.1 → docassert-0.8.1}/docassert/_data/criteria/benefits-realization.criteria.yaml +0 -0
  18. {docassert-0.7.1 → docassert-0.8.1}/docassert/_data/criteria/brd.criteria.yaml +0 -0
  19. {docassert-0.7.1 → docassert-0.8.1}/docassert/_data/criteria/business-case.criteria.yaml +0 -0
  20. {docassert-0.7.1 → docassert-0.8.1}/docassert/_data/criteria/charter.criteria.yaml +0 -0
  21. {docassert-0.7.1 → docassert-0.8.1}/docassert/_data/criteria/data-migration-plan.criteria.yaml +0 -0
  22. {docassert-0.7.1 → docassert-0.8.1}/docassert/_data/criteria/frnfr.criteria.yaml +0 -0
  23. {docassert-0.7.1 → docassert-0.8.1}/docassert/_data/criteria/hypercare-plan.criteria.yaml +0 -0
  24. {docassert-0.7.1 → docassert-0.8.1}/docassert/_data/criteria/post-implementation-review.criteria.yaml +0 -0
  25. {docassert-0.7.1 → docassert-0.8.1}/docassert/_data/criteria/prd.criteria.yaml +0 -0
  26. {docassert-0.7.1 → docassert-0.8.1}/docassert/_data/criteria/project.criteria.yaml +0 -0
  27. {docassert-0.7.1 → docassert-0.8.1}/docassert/_data/criteria/qa-test-plan.criteria.yaml +0 -0
  28. {docassert-0.7.1 → docassert-0.8.1}/docassert/_data/criteria/raci-stakeholder.criteria.yaml +0 -0
  29. {docassert-0.7.1 → docassert-0.8.1}/docassert/_data/criteria/release-cutover-plan.criteria.yaml +0 -0
  30. {docassert-0.7.1 → docassert-0.8.1}/docassert/_data/criteria/risk-register.criteria.yaml +0 -0
  31. {docassert-0.7.1 → docassert-0.8.1}/docassert/_data/criteria/rollback-plan.criteria.yaml +0 -0
  32. {docassert-0.7.1 → docassert-0.8.1}/docassert/_data/criteria/runbook.criteria.yaml +0 -0
  33. {docassert-0.7.1 → docassert-0.8.1}/docassert/_data/criteria/status-report.criteria.yaml +0 -0
  34. {docassert-0.7.1 → docassert-0.8.1}/docassert/_data/criteria/test-cases.criteria.yaml +0 -0
  35. {docassert-0.7.1 → docassert-0.8.1}/docassert/_data/criteria/user-story.criteria.yaml +0 -0
  36. {docassert-0.7.1 → docassert-0.8.1}/docassert/_data/profiles/agile-delivery.yaml +0 -0
  37. {docassert-0.7.1 → docassert-0.8.1}/docassert/_data/profiles/lean-startup.yaml +0 -0
  38. {docassert-0.7.1 → docassert-0.8.1}/docassert/_data/profiles/regulated-industry.yaml +0 -0
  39. {docassert-0.7.1 → docassert-0.8.1}/docassert/_data/schema/adr.schema.json +0 -0
  40. {docassert-0.7.1 → docassert-0.8.1}/docassert/_data/schema/benefits-realization.schema.json +0 -0
  41. {docassert-0.7.1 → docassert-0.8.1}/docassert/_data/schema/brd.schema.json +0 -0
  42. {docassert-0.7.1 → docassert-0.8.1}/docassert/_data/schema/business-case.schema.json +0 -0
  43. {docassert-0.7.1 → docassert-0.8.1}/docassert/_data/schema/charter.schema.json +0 -0
  44. {docassert-0.7.1 → docassert-0.8.1}/docassert/_data/schema/data-migration-plan.schema.json +0 -0
  45. {docassert-0.7.1 → docassert-0.8.1}/docassert/_data/schema/frnfr.schema.json +0 -0
  46. {docassert-0.7.1 → docassert-0.8.1}/docassert/_data/schema/hypercare-plan.schema.json +0 -0
  47. {docassert-0.7.1 → docassert-0.8.1}/docassert/_data/schema/post-implementation-review.schema.json +0 -0
  48. {docassert-0.7.1 → docassert-0.8.1}/docassert/_data/schema/prd.schema.json +0 -0
  49. {docassert-0.7.1 → docassert-0.8.1}/docassert/_data/schema/project.schema.json +0 -0
  50. {docassert-0.7.1 → docassert-0.8.1}/docassert/_data/schema/qa-test-plan.schema.json +0 -0
  51. {docassert-0.7.1 → docassert-0.8.1}/docassert/_data/schema/raci-stakeholder.schema.json +0 -0
  52. {docassert-0.7.1 → docassert-0.8.1}/docassert/_data/schema/release-cutover-plan.schema.json +0 -0
  53. {docassert-0.7.1 → docassert-0.8.1}/docassert/_data/schema/risk-register.schema.json +0 -0
  54. {docassert-0.7.1 → docassert-0.8.1}/docassert/_data/schema/rollback-plan.schema.json +0 -0
  55. {docassert-0.7.1 → docassert-0.8.1}/docassert/_data/schema/runbook.schema.json +0 -0
  56. {docassert-0.7.1 → docassert-0.8.1}/docassert/_data/schema/status-report.schema.json +0 -0
  57. {docassert-0.7.1 → docassert-0.8.1}/docassert/_data/schema/test-cases.schema.json +0 -0
  58. {docassert-0.7.1 → docassert-0.8.1}/docassert/_data/schema/user-story.schema.json +0 -0
  59. {docassert-0.7.1 → docassert-0.8.1}/docassert/_data/skills/doc-to-pmo/SKILL.md +0 -0
  60. {docassert-0.7.1 → docassert-0.8.1}/docassert/_data/templates/adr.template.md +0 -0
  61. {docassert-0.7.1 → docassert-0.8.1}/docassert/_data/templates/benefits-realization.template.md +0 -0
  62. {docassert-0.7.1 → docassert-0.8.1}/docassert/_data/templates/brd.template.md +0 -0
  63. {docassert-0.7.1 → docassert-0.8.1}/docassert/_data/templates/business-case.template.md +0 -0
  64. {docassert-0.7.1 → docassert-0.8.1}/docassert/_data/templates/charter.template.md +0 -0
  65. {docassert-0.7.1 → docassert-0.8.1}/docassert/_data/templates/data-migration-plan.template.md +0 -0
  66. {docassert-0.7.1 → docassert-0.8.1}/docassert/_data/templates/frnfr.template.md +0 -0
  67. {docassert-0.7.1 → docassert-0.8.1}/docassert/_data/templates/hypercare-plan.template.md +0 -0
  68. {docassert-0.7.1 → docassert-0.8.1}/docassert/_data/templates/post-implementation-review.template.md +0 -0
  69. {docassert-0.7.1 → docassert-0.8.1}/docassert/_data/templates/prd.template.md +0 -0
  70. {docassert-0.7.1 → docassert-0.8.1}/docassert/_data/templates/project.template.md +0 -0
  71. {docassert-0.7.1 → docassert-0.8.1}/docassert/_data/templates/qa-test-plan.template.md +0 -0
  72. {docassert-0.7.1 → docassert-0.8.1}/docassert/_data/templates/raci-stakeholder.template.md +0 -0
  73. {docassert-0.7.1 → docassert-0.8.1}/docassert/_data/templates/release-cutover-plan.template.md +0 -0
  74. {docassert-0.7.1 → docassert-0.8.1}/docassert/_data/templates/risk-register.template.md +0 -0
  75. {docassert-0.7.1 → docassert-0.8.1}/docassert/_data/templates/rollback-plan.template.md +0 -0
  76. {docassert-0.7.1 → docassert-0.8.1}/docassert/_data/templates/runbook.template.md +0 -0
  77. {docassert-0.7.1 → docassert-0.8.1}/docassert/_data/templates/status-report.template.md +0 -0
  78. {docassert-0.7.1 → docassert-0.8.1}/docassert/_data/templates/test-cases.template.md +0 -0
  79. {docassert-0.7.1 → docassert-0.8.1}/docassert/_data/templates/user-story.template.md +0 -0
  80. {docassert-0.7.1 → docassert-0.8.1}/docassert/config.py +0 -0
  81. {docassert-0.7.1 → docassert-0.8.1}/docassert/consistency.py +0 -0
  82. {docassert-0.7.1 → docassert-0.8.1}/docassert/extract.py +0 -0
  83. {docassert-0.7.1 → docassert-0.8.1}/docassert/graph.py +0 -0
  84. {docassert-0.7.1 → docassert-0.8.1}/docassert/loader.py +0 -0
  85. {docassert-0.7.1 → docassert-0.8.1}/docassert/models.py +0 -0
  86. {docassert-0.7.1 → docassert-0.8.1}/docassert/profiles.py +0 -0
  87. {docassert-0.7.1 → docassert-0.8.1}/docassert/projects.py +0 -0
  88. {docassert-0.7.1 → docassert-0.8.1}/docassert/report.py +0 -0
  89. {docassert-0.7.1 → docassert-0.8.1}/docassert/rtm.py +0 -0
  90. {docassert-0.7.1 → docassert-0.8.1}/docassert/scaffold.py +0 -0
  91. {docassert-0.7.1 → docassert-0.8.1}/docassert/semantic.py +0 -0
  92. {docassert-0.7.1 → docassert-0.8.1}/docassert/status/__init__.py +0 -0
  93. {docassert-0.7.1 → docassert-0.8.1}/docassert/status/derive.py +0 -0
  94. {docassert-0.7.1 → docassert-0.8.1}/docassert/status/render.py +0 -0
  95. {docassert-0.7.1 → docassert-0.8.1}/docassert/structural.py +0 -0
  96. {docassert-0.7.1 → docassert-0.8.1}/docassert.egg-info/dependency_links.txt +0 -0
  97. {docassert-0.7.1 → docassert-0.8.1}/docassert.egg-info/entry_points.txt +0 -0
  98. {docassert-0.7.1 → docassert-0.8.1}/docassert.egg-info/requires.txt +0 -0
  99. {docassert-0.7.1 → docassert-0.8.1}/docassert.egg-info/top_level.txt +0 -0
  100. {docassert-0.7.1 → docassert-0.8.1}/pyproject.toml +0 -0
  101. {docassert-0.7.1 → docassert-0.8.1}/setup.cfg +0 -0
  102. {docassert-0.7.1 → docassert-0.8.1}/tests/test_badge.py +0 -0
  103. {docassert-0.7.1 → docassert-0.8.1}/tests/test_config.py +0 -0
  104. {docassert-0.7.1 → docassert-0.8.1}/tests/test_consistency.py +0 -0
  105. {docassert-0.7.1 → docassert-0.8.1}/tests/test_defects.py +0 -0
  106. {docassert-0.7.1 → docassert-0.8.1}/tests/test_draft_relaxation.py +0 -0
  107. {docassert-0.7.1 → docassert-0.8.1}/tests/test_extract.py +0 -0
  108. {docassert-0.7.1 → docassert-0.8.1}/tests/test_graph.py +0 -0
  109. {docassert-0.7.1 → docassert-0.8.1}/tests/test_json_report.py +0 -0
  110. {docassert-0.7.1 → docassert-0.8.1}/tests/test_kinds_delivery.py +0 -0
  111. {docassert-0.7.1 → docassert-0.8.1}/tests/test_kinds_governance.py +0 -0
  112. {docassert-0.7.1 → docassert-0.8.1}/tests/test_kinds_operate.py +0 -0
  113. {docassert-0.7.1 → docassert-0.8.1}/tests/test_kinds_reporting.py +0 -0
  114. {docassert-0.7.1 → docassert-0.8.1}/tests/test_profiles.py +0 -0
  115. {docassert-0.7.1 → docassert-0.8.1}/tests/test_projects.py +0 -0
  116. {docassert-0.7.1 → docassert-0.8.1}/tests/test_scaffold.py +0 -0
  117. {docassert-0.7.1 → docassert-0.8.1}/tests/test_status.py +0 -0
  118. {docassert-0.7.1 → docassert-0.8.1}/tests/test_structural.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: docassert
3
- Version: 0.7.1
3
+ Version: 0.8.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
@@ -54,7 +54,7 @@ derived from the documents rather than self-reported.
54
54
 
55
55
  docassert is the reference implementation of **[PMO as Code](https://c4g-john.github.io/pmo-as-code/)** —
56
56
  a vendor-neutral standard for running a PMO from version-controlled, declarative
57
- files. It implements the [PMO as Code specification](https://github.com/c4g-john/pmo-as-code-spec) **v0.2**.
57
+ files. It implements the [PMO as Code specification](https://github.com/c4g-john/pmo-as-code-spec) **v0.3** and passes its [conformance suite](https://github.com/c4g-john/pmo-as-code-spec/tree/main/conformance) in CI.
58
58
 
59
59
  ## Install
60
60
 
@@ -98,6 +98,7 @@ flagged as TODOs, never invented). The skill's source is
98
98
  | `docassert projects [--out] [--check]` | Generate / verify the project registry. |
99
99
  | `docassert new <kind> --project ID` | Scaffold a document from its template with identity filled in (`new project --code XYZ` auto-numbers the id); suggests the next free item ids. |
100
100
  | `docassert init [DIR]` | Scaffold the default config into a repo. |
101
+ | `docassert bridge <action> --repo O/N` | Execution bridge: `scaffold` Features/Stories from approved user stories, `reconcile` (police the board against the docs), `status` (delivery figures). Needs the GitHub CLI. |
101
102
  | `docassert extract <file>` | Extract plain text from a source `.docx` / `.pdf` / `.md` / `.txt` (the first step of doc-to-pmo conversion). Needs the `convert` extra: `pip install "docassert[convert]"`. |
102
103
 
103
104
  Every document-reading command accepts `--documents-dir` (default `documents/`).
@@ -14,7 +14,7 @@ derived from the documents rather than self-reported.
14
14
 
15
15
  docassert is the reference implementation of **[PMO as Code](https://c4g-john.github.io/pmo-as-code/)** —
16
16
  a vendor-neutral standard for running a PMO from version-controlled, declarative
17
- files. It implements the [PMO as Code specification](https://github.com/c4g-john/pmo-as-code-spec) **v0.2**.
17
+ files. It implements the [PMO as Code specification](https://github.com/c4g-john/pmo-as-code-spec) **v0.3** and passes its [conformance suite](https://github.com/c4g-john/pmo-as-code-spec/tree/main/conformance) in CI.
18
18
 
19
19
  ## Install
20
20
 
@@ -58,6 +58,7 @@ flagged as TODOs, never invented). The skill's source is
58
58
  | `docassert projects [--out] [--check]` | Generate / verify the project registry. |
59
59
  | `docassert new <kind> --project ID` | Scaffold a document from its template with identity filled in (`new project --code XYZ` auto-numbers the id); suggests the next free item ids. |
60
60
  | `docassert init [DIR]` | Scaffold the default config into a repo. |
61
+ | `docassert bridge <action> --repo O/N` | Execution bridge: `scaffold` Features/Stories from approved user stories, `reconcile` (police the board against the docs), `status` (delivery figures). Needs the GitHub CLI. |
61
62
  | `docassert extract <file>` | Extract plain text from a source `.docx` / `.pdf` / `.md` / `.txt` (the first step of doc-to-pmo conversion). Needs the `convert` extra: `pip install "docassert[convert]"`. |
62
63
 
63
64
  Every document-reading command accepts `--documents-dir` (default `documents/`).
@@ -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.7.1"
8
+ __version__ = "0.8.1"
@@ -0,0 +1,9 @@
1
+ """The execution bridge: documents -> GitHub issues/boards -> dashboards.
2
+
3
+ Scope authority is one-directional. The bridge scaffolds Features and Stories
4
+ from approved user stories, polices the board against the documents, and reads
5
+ delivery progress back for rendering. It never modifies documents.
6
+ """
7
+ from .plan import BridgePlan, build_bridge_plan
8
+
9
+ __all__ = ["BridgePlan", "build_bridge_plan"]
@@ -0,0 +1,118 @@
1
+ """Thin wrapper around the GitHub CLI for the bridge (injectable for tests).
2
+
3
+ All GitHub access goes through `gh` so token handling stays with gh/Actions
4
+ and docassert gains no HTTP dependencies. Every mutation the bridge performs
5
+ funnels through GhRunner, which dry-run mode replaces with a recorder.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import subprocess
11
+
12
+
13
+ class GhError(RuntimeError):
14
+ pass
15
+
16
+
17
+ class GhRunner:
18
+ """Executes gh commands. Tests substitute a fake with the same interface."""
19
+
20
+ def run(self, args: list[str], input_: str | None = None) -> str:
21
+ proc = subprocess.run(["gh", *args], capture_output=True, text=True,
22
+ input=input_)
23
+ if proc.returncode != 0:
24
+ raise GhError(proc.stderr.strip() or f"gh {' '.join(args)} failed")
25
+ return proc.stdout
26
+
27
+ def api_json(self, path: str, *flags: str):
28
+ out = self.run(["api", path, *flags])
29
+ return json.loads(out) if out.strip() else None
30
+
31
+ def graphql(self, query: str, **variables) -> dict:
32
+ args = ["api", "graphql", "-f", f"query={query}"]
33
+ for k, v in variables.items():
34
+ args += (["-F", f"{k}={v}"] if isinstance(v, int)
35
+ else ["-f", f"{k}={v}"])
36
+ data = json.loads(self.run(args))
37
+ if data.get("errors"):
38
+ raise GhError(json.dumps(data["errors"])[:400])
39
+ return data["data"]
40
+
41
+
42
+ class DryRunner(GhRunner):
43
+ """Reads pass through; mutations are recorded, not executed."""
44
+
45
+ MUTATING = ("-X POST", "-X PATCH", "-X PUT", "-X DELETE", "mutation")
46
+
47
+ def __init__(self) -> None:
48
+ self.planned: list[str] = []
49
+
50
+ def run(self, args: list[str], input_: str | None = None) -> str:
51
+ joined = " ".join(args)
52
+ if any(m in joined for m in ("-X", "--method")) or "mutation" in joined:
53
+ self.planned.append(f"gh {joined}")
54
+ return "{}"
55
+ return super().run(args, input_)
56
+
57
+
58
+ def list_issues(gh: GhRunner, repo: str, state: str = "all") -> list[dict]:
59
+ """Every issue (not PR) in the repo, paginated."""
60
+ raw = gh.run(["api", f"repos/{repo}/issues?state={state}&per_page=100",
61
+ "--paginate"])
62
+ # --paginate concatenates JSON arrays
63
+ items: list[dict] = []
64
+ dec = json.JSONDecoder()
65
+ idx = 0
66
+ raw = raw.strip()
67
+ while idx < len(raw):
68
+ chunk, end = dec.raw_decode(raw, idx)
69
+ items.extend(chunk)
70
+ idx = end
71
+ while idx < len(raw) and raw[idx] in " \n\r\t":
72
+ idx += 1
73
+ return [i for i in items if "pull_request" not in i]
74
+
75
+
76
+ def ensure_label(gh: GhRunner, repo: str, name: str, color: str, desc: str) -> None:
77
+ try:
78
+ gh.run(["api", f"repos/{repo}/labels", "-X", "POST",
79
+ "-f", f"name={name}", "-f", f"color={color}",
80
+ "-f", f"description={desc}"])
81
+ except GhError as exc:
82
+ if "already_exists" not in str(exc) and "422" not in str(exc):
83
+ raise
84
+
85
+
86
+ def create_issue(gh: GhRunner, repo: str, title: str, body: str,
87
+ labels: list[str]) -> dict:
88
+ args = ["api", f"repos/{repo}/issues", "-X", "POST",
89
+ "-f", f"title={title}", "-f", f"body={body}"]
90
+ for lb in labels:
91
+ args += ["-f", f"labels[]={lb}"]
92
+ return json.loads(gh.run(args) or "{}")
93
+
94
+
95
+ def update_issue(gh: GhRunner, repo: str, number: int, **fields) -> None:
96
+ args = ["api", f"repos/{repo}/issues/{number}", "-X", "PATCH"]
97
+ for k, v in fields.items():
98
+ args += ["-f", f"{k}={v}"]
99
+ gh.run(args)
100
+
101
+
102
+ def comment(gh: GhRunner, repo: str, number: int, body: str) -> None:
103
+ gh.run(["api", f"repos/{repo}/issues/{number}/comments", "-X", "POST",
104
+ "-f", f"body={body}"])
105
+
106
+
107
+ def add_sub_issue(gh: GhRunner, parent_node: str, child_node: str) -> None:
108
+ q = ("mutation($p: ID!, $c: ID!) { addSubIssue(input: {issueId: $p, "
109
+ "subIssueId: $c}) { issue { id } } }")
110
+ try:
111
+ gh.graphql(q, p=parent_node, c=child_node)
112
+ except GhError as exc:
113
+ # An existing identical link is success, not failure. GitHub words it
114
+ # several ways ("duplicate sub-issues", "may only have one parent").
115
+ msg = str(exc).lower()
116
+ if not any(s in msg for s in ("already", "duplicate sub-issue",
117
+ "only have one parent")):
118
+ raise
@@ -0,0 +1,179 @@
1
+ """Bridge operations: scaffold, reconcile, status.
2
+
3
+ scaffold documents -> issues/board (idempotent; never deletes)
4
+ reconcile board -> alerts (labels + one pinned scope report; never deletes)
5
+ status board -> read-only delivery figures
6
+
7
+ The bridge never writes to documents/ — scope changes go through document PRs.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import re
13
+
14
+ from . import gh as G
15
+ from .plan import (
16
+ MARKER,
17
+ BridgePlan,
18
+ feature_body,
19
+ feature_title,
20
+ story_body,
21
+ story_title,
22
+ )
23
+
24
+ MARKER_RE = re.compile(r"<!-- docassert-bridge: ([A-Za-z0-9-]+) -->")
25
+ SCOPE_REPORT_KEY = "scope-report"
26
+ LABELS = {
27
+ "bridge:feature": ("1d76db", "Feature scaffolded from a product requirement"),
28
+ "bridge:story": ("0e8a16", "Story scaffolded from an approved user story"),
29
+ "scope:unverified": ("d93f0b", "No matching item in the governing documents"),
30
+ "scope:orphaned": ("b60205", "Its document item was removed or demoted"),
31
+ }
32
+
33
+
34
+ def _marker_of(issue: dict) -> str | None:
35
+ m = MARKER_RE.search(issue.get("body") or "")
36
+ return m.group(1) if m else None
37
+
38
+
39
+ def _index(issues: list[dict]) -> dict[str, dict]:
40
+ out: dict[str, dict] = {}
41
+ for i in issues:
42
+ key = _marker_of(i)
43
+ if key and key not in out: # first (oldest) wins
44
+ out[key] = i
45
+ return out
46
+
47
+
48
+ def scaffold(plan: BridgePlan, gh: G.GhRunner, repo: str,
49
+ docs_url: str | None = None) -> list[str]:
50
+ """Make the repo's issues match the plan. Returns human-readable actions."""
51
+ actions: list[str] = []
52
+ for name, (color, desc) in LABELS.items():
53
+ G.ensure_label(gh, repo, name, color, desc)
54
+
55
+ issues = G.list_issues(gh, repo)
56
+ by_key = _index(issues)
57
+
58
+ def ensure(key: str, title: str, body: str, label: str) -> dict:
59
+ existing = by_key.get(key)
60
+ if existing is None:
61
+ created = G.create_issue(gh, repo, title, body, [label])
62
+ created["body"] = body
63
+ by_key[key] = created
64
+ actions.append(f"created #{created.get('number', '?')} {title}")
65
+ return created
66
+ if existing.get("title") != title or (existing.get("body") or "").strip() != body.strip():
67
+ G.update_issue(gh, repo, existing["number"], title=title, body=body)
68
+ actions.append(f"updated #{existing['number']} {title}")
69
+ if existing.get("state") == "closed" and key in plan.managed_ids:
70
+ pass # closed means delivered; scaffold never reopens
71
+ return existing
72
+
73
+ for f in plan.features:
74
+ feat_issue = ensure(f.id, feature_title(f), feature_body(f, docs_url),
75
+ "bridge:feature")
76
+ for s in f.stories:
77
+ story_issue = ensure(s.id, story_title(s), story_body(s, docs_url),
78
+ "bridge:story")
79
+ pnode, cnode = feat_issue.get("node_id"), story_issue.get("node_id")
80
+ if pnode and cnode:
81
+ G.add_sub_issue(gh, pnode, cnode)
82
+
83
+ for sk in plan.skipped:
84
+ actions.append(f"skipped {sk['id']}: {sk['reason']}")
85
+ return actions
86
+
87
+
88
+ def reconcile(plan: BridgePlan, gh: G.GhRunner, repo: str) -> tuple[list[str], int]:
89
+ """Police the board against the documents. Returns (report lines, exit code)."""
90
+ for name, (color, desc) in LABELS.items():
91
+ G.ensure_label(gh, repo, name, color, desc)
92
+
93
+ managed = plan.managed_ids
94
+ issues = G.list_issues(gh, repo, state="open")
95
+ unverified, orphaned = [], []
96
+
97
+ for issue in issues:
98
+ key = _marker_of(issue)
99
+ if key == SCOPE_REPORT_KEY:
100
+ continue
101
+ labels = {lb["name"] for lb in issue.get("labels", [])}
102
+ number = issue["number"]
103
+
104
+ if key is None:
105
+ unverified.append(issue)
106
+ if "scope:unverified" not in labels:
107
+ gh.run(["api", f"repos/{repo}/issues/{number}/labels", "-X", "POST",
108
+ "-f", "labels[]=scope:unverified"])
109
+ G.comment(gh, repo, number,
110
+ "**Scope check:** this issue has no matching item in the "
111
+ "governing documents. Scope is defined in the PMO repo; "
112
+ "either link it to an approved item or move the scope "
113
+ "change through a documents PR.")
114
+ elif key not in managed:
115
+ orphaned.append(issue)
116
+ if "scope:orphaned" not in labels:
117
+ gh.run(["api", f"repos/{repo}/issues/{number}/labels", "-X", "POST",
118
+ "-f", "labels[]=scope:orphaned"])
119
+ G.comment(gh, repo, number,
120
+ f"**Scope check:** `{key}` no longer resolves to an "
121
+ "approved item in the documents (removed, renamed, or "
122
+ "demoted). A human should decide whether to close this "
123
+ "issue or restore the item.")
124
+
125
+ lines = [f"managed items: {len(managed)}",
126
+ f"open issues checked: {len(issues)}",
127
+ f"unverified: {len(unverified)}", f"orphaned: {len(orphaned)}"]
128
+ for i in unverified:
129
+ lines.append(f" unverified: #{i['number']} {i['title']}")
130
+ for i in orphaned:
131
+ lines.append(f" orphaned: #{i['number']} {i['title']} [{_marker_of(i)}]")
132
+
133
+ body = (MARKER.format(SCOPE_REPORT_KEY)
134
+ + "\n_Maintained by `docassert bridge reconcile`. The documents are "
135
+ "the authoritative scope source._\n\n```\n" + "\n".join(lines) + "\n```\n")
136
+ report = _index(G.list_issues(gh, repo)).get(SCOPE_REPORT_KEY)
137
+ if report is None:
138
+ G.create_issue(gh, repo, "Scope report — docassert bridge", body, [])
139
+ else:
140
+ G.update_issue(gh, repo, report["number"], body=body)
141
+
142
+ return lines, (1 if (unverified or orphaned) else 0)
143
+
144
+
145
+ def status(plan: BridgePlan, gh: G.GhRunner, repo: str) -> dict:
146
+ """Read-only: delivery progress per feature from issue states."""
147
+ by_key = _index(G.list_issues(gh, repo))
148
+ out: dict = {"projects": []}
149
+ for p in plan.projects:
150
+ feats = []
151
+ for f in p["features"]:
152
+ states = [(by_key.get(s.id) or {}).get("state") for s in f.stories]
153
+ feats.append({
154
+ "id": f.id,
155
+ "issue": (by_key.get(f.id) or {}).get("number"),
156
+ "stories_total": len(f.stories),
157
+ "stories_closed": sum(1 for s in states if s == "closed"),
158
+ "closed": (by_key.get(f.id) or {}).get("state") == "closed",
159
+ })
160
+ done = sum(x["stories_closed"] for x in feats)
161
+ total = sum(x["stories_total"] for x in feats)
162
+ out["projects"].append({"id": p["id"], "features": feats,
163
+ "stories_closed": done, "stories_total": total})
164
+ return out
165
+
166
+
167
+ def render_status(data: dict) -> str:
168
+ lines = []
169
+ for p in data["projects"]:
170
+ lines.append(f"{p['id']}: {p['stories_closed']}/{p['stories_total']} stories closed")
171
+ for f in p["features"]:
172
+ mark = "✓" if f["closed"] else " "
173
+ lines.append(f" [{mark}] {f['id']}: {f['stories_closed']}/{f['stories_total']}"
174
+ + (f" (#{f['issue']})" if f["issue"] else " (no issue)"))
175
+ return "\n".join(lines) or "no qualifying projects"
176
+
177
+
178
+ def to_json(data: dict) -> str:
179
+ return json.dumps(data, indent=2) + "\n"
@@ -0,0 +1,178 @@
1
+ """Build the execution-bridge plan from the document graph (pure, no I/O).
2
+
3
+ Scope authority flows one way: documents -> GitHub. This module decides WHAT
4
+ should exist on the board (Features from product requirements, Stories from
5
+ approved user stories); the executor makes GitHub match it. Nothing here, or
6
+ anywhere in the bridge, writes to documents/.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+ from pathlib import Path
12
+
13
+ from ..graph import build_graph
14
+ from ..loader import load
15
+
16
+ APPROVED = {"approved", "baselined"}
17
+ MARKER = "<!-- docassert-bridge: {} -->"
18
+
19
+
20
+ @dataclass
21
+ class Story:
22
+ id: str
23
+ text: str
24
+ feature_id: str
25
+ doc_path: str
26
+
27
+
28
+ @dataclass
29
+ class Feature:
30
+ id: str
31
+ text: str
32
+ doc_path: str
33
+ project: str
34
+ stories: list[Story] = field(default_factory=list)
35
+ acceptance: list[tuple[str, str]] = field(default_factory=list) # (id, text)
36
+ tests: list[str] = field(default_factory=list) # tc ids
37
+
38
+
39
+ @dataclass
40
+ class BridgePlan:
41
+ projects: list[dict] = field(default_factory=list) # {id, code, features}
42
+ skipped: list[dict] = field(default_factory=list) # {id, reason}
43
+
44
+ @property
45
+ def features(self) -> list[Feature]:
46
+ return [f for p in self.projects for f in p["features"]]
47
+
48
+ @property
49
+ def stories(self) -> list[Story]:
50
+ return [s for f in self.features for s in f.stories]
51
+
52
+ @property
53
+ def managed_ids(self) -> set[str]:
54
+ return {f.id for f in self.features} | {s.id for s in self.stories}
55
+
56
+
57
+ def _title(item_text: str, limit: int = 64) -> str:
58
+ """First sentence of the item text, trimmed for an issue title."""
59
+ first = item_text.split(". ")[0].rstrip(".")
60
+ return first if len(first) <= limit else first[: limit - 1].rstrip() + "…"
61
+
62
+
63
+ def _anchors(documents_dir: Path) -> dict[str, dict]:
64
+ """project id -> {code, name} for every project anchor."""
65
+ out: dict[str, dict] = {}
66
+ for path in sorted(documents_dir.rglob("project.md")):
67
+ try:
68
+ doc = load(path)
69
+ except ValueError:
70
+ continue
71
+ if doc.kind == "project" and doc.id:
72
+ out[doc.id] = {"code": str(doc.frontmatter.get("code", "")),
73
+ "name": str(doc.frontmatter.get("name", ""))}
74
+ return out
75
+
76
+
77
+ def _story_docs(documents_dir: Path) -> dict[str, list]:
78
+ """project id -> its user-story documents."""
79
+ out: dict[str, list] = {}
80
+ for path in sorted(documents_dir.rglob("*.md")):
81
+ try:
82
+ doc = load(path)
83
+ except ValueError:
84
+ continue
85
+ if doc.kind == "user-story":
86
+ proj = str(doc.frontmatter.get("project", ""))
87
+ out.setdefault(proj, []).append(doc)
88
+ return out
89
+
90
+
91
+ def build_bridge_plan(documents_dir: str | Path = "documents") -> BridgePlan:
92
+ """The gate (Decision D): a project qualifies when it has a user-story
93
+ document with approved status, and every approved story traces to an
94
+ existing product requirement. Draft/proposed stories never reach the board.
95
+ """
96
+ documents_dir = Path(documents_dir)
97
+ graph = build_graph(documents_dir)
98
+ plan = BridgePlan()
99
+
100
+ for proj_id, meta in _anchors(documents_dir).items():
101
+ docs = _story_docs(documents_dir).get(proj_id, [])
102
+ if not docs:
103
+ plan.skipped.append({"id": proj_id, "reason": "no user-story document"})
104
+ continue
105
+ approved_docs = [d for d in docs
106
+ if str(d.frontmatter.get("status", "")).lower() in APPROVED]
107
+ if not approved_docs:
108
+ plan.skipped.append({"id": proj_id, "reason": "user stories not approved"})
109
+ continue
110
+
111
+ code = meta["code"]
112
+ stories = [i for i in graph.by_type.get("US", [])
113
+ if i.project == code and i.doc_status.lower() in APPROVED]
114
+ features: dict[str, Feature] = {}
115
+ problems: list[str] = []
116
+ for s in stories:
117
+ targets = s.targets("traces")
118
+ parents = [t for t in targets if graph.canonical(t) is not None]
119
+ if not parents:
120
+ problems.append(f"{s.id} has no resolvable traces link")
121
+ continue
122
+ for pid in parents:
123
+ parent = graph.canonical(pid)
124
+ if parent is None: # filtered above; narrows the type
125
+ continue
126
+ feat = features.get(parent.id)
127
+ if feat is None:
128
+ feat = Feature(id=parent.id, text=parent.text,
129
+ doc_path=parent.doc_path, project=proj_id)
130
+ feat.acceptance = [(a.id, a.text) for a in
131
+ graph.children(parent.id, "verifies", "AC")]
132
+ for aid, _ in feat.acceptance:
133
+ feat.tests += [t.id for t in graph.children(aid, "tests", "TC")]
134
+ features[parent.id] = feat
135
+ feat.stories.append(Story(id=s.id, text=s.text,
136
+ feature_id=parent.id, doc_path=s.doc_path))
137
+ if problems:
138
+ plan.skipped.append({"id": proj_id,
139
+ "reason": "; ".join(problems)})
140
+ continue
141
+ plan.projects.append({"id": proj_id, "code": code, "name": meta["name"],
142
+ "features": list(features.values())})
143
+ return plan
144
+
145
+
146
+ def feature_title(f: Feature) -> str:
147
+ return f"[{f.id}] {_title(f.text)}"
148
+
149
+
150
+ def story_title(s: Story) -> str:
151
+ return f"[{s.id}] {_title(s.text)}"
152
+
153
+
154
+ def feature_body(f: Feature, docs_url: str | None) -> str:
155
+ lines = [MARKER.format(f.id),
156
+ "_Managed by the docassert bridge. Scope lives in the documents;"
157
+ " edits here are overwritten._", "",
158
+ f.text, "", f"**Project:** {f.project}"]
159
+ if docs_url:
160
+ lines.append(f"**Source:** {docs_url}/{f.doc_path}")
161
+ if f.stories:
162
+ lines += ["", "**Stories:**"] + [f"- {s.id}" for s in f.stories]
163
+ if f.acceptance:
164
+ lines += ["", "**Acceptance criteria:**"] + \
165
+ [f"- [ ] {aid}: {text}" for aid, text in f.acceptance]
166
+ if f.tests:
167
+ lines += ["", "**Verified by:** " + ", ".join(sorted(set(f.tests)))]
168
+ return "\n".join(lines) + "\n"
169
+
170
+
171
+ def story_body(s: Story, docs_url: str | None) -> str:
172
+ lines = [MARKER.format(s.id),
173
+ "_Managed by the docassert bridge. Scope lives in the documents;"
174
+ " edits here are overwritten._", "",
175
+ s.text, "", f"**Feature:** {s.feature_id}"]
176
+ if docs_url:
177
+ lines.append(f"**Source:** {docs_url}/{s.doc_path}")
178
+ return "\n".join(lines) + "\n"
@@ -289,6 +289,42 @@ def cmd_new(args: argparse.Namespace) -> int:
289
289
  return 0
290
290
 
291
291
 
292
+
293
+
294
+ def cmd_bridge(args: argparse.Namespace) -> int:
295
+ """Execution bridge: scaffold/police/read the GitHub board from the docs.
296
+ Scope flows documents -> GitHub only; the bridge never edits documents."""
297
+ from .bridge import build_bridge_plan, ops
298
+ from .bridge import gh as ghmod
299
+
300
+ plan = build_bridge_plan(args.documents_dir)
301
+ gh = ghmod.DryRunner() if getattr(args, "dry_run", False) else ghmod.GhRunner()
302
+
303
+ if args.action == "scaffold":
304
+ actions = ops.scaffold(plan, gh, args.repo, docs_url=args.docs_url)
305
+ for a in actions:
306
+ print(f"docassert bridge: {a}")
307
+ if isinstance(gh, ghmod.DryRunner):
308
+ print(f"docassert bridge: dry run — {len(gh.planned)} mutation(s) planned")
309
+ for m in gh.planned:
310
+ print(f" {m}")
311
+ return 0
312
+ if args.action == "reconcile":
313
+ lines, code = ops.reconcile(plan, gh, args.repo)
314
+ for line in lines:
315
+ print(f"docassert bridge: {line}")
316
+ return code
317
+ if args.action == "status":
318
+ data = ops.status(plan, gh, args.repo)
319
+ sys.stdout.write(ops.to_json(data) if args.json else
320
+ ops.render_status(data) + "\n")
321
+ if args.json and args.out:
322
+ Path(args.out).write_text(ops.to_json(data))
323
+ return 0
324
+ print(f"docassert bridge: unknown action {args.action!r}", file=sys.stderr)
325
+ return 2
326
+
327
+
292
328
  def main(argv: list[str] | None = None) -> int:
293
329
  from . import __version__
294
330
  parser = argparse.ArgumentParser(prog="docassert",
@@ -367,6 +403,18 @@ def main(argv: list[str] | None = None) -> int:
367
403
  docs_dir_opt(n)
368
404
  n.set_defaults(func=cmd_new)
369
405
 
406
+
407
+ b = sub.add_parser("bridge", help="Execution bridge: scaffold and police the GitHub board from approved stories.")
408
+ b.add_argument("action", choices=["scaffold", "reconcile", "status"],
409
+ help="scaffold: docs -> issues/board · reconcile: police the board · status: delivery figures.")
410
+ b.add_argument("--repo", required=True, help="Target GitHub repo (OWNER/NAME) holding the issues.")
411
+ b.add_argument("--docs-url", help="Base URL of the documents repo, for source links in issue bodies.")
412
+ b.add_argument("--dry-run", action="store_true", help="Print planned mutations without executing them.")
413
+ b.add_argument("--json", action="store_true", help="status: emit JSON.")
414
+ b.add_argument("--out", help="status --json: also write to this path.")
415
+ docs_dir_opt(b)
416
+ b.set_defaults(func=cmd_bridge)
417
+
370
418
  args = parser.parse_args(argv)
371
419
  return args.func(args)
372
420
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: docassert
3
- Version: 0.7.1
3
+ Version: 0.8.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
@@ -54,7 +54,7 @@ derived from the documents rather than self-reported.
54
54
 
55
55
  docassert is the reference implementation of **[PMO as Code](https://c4g-john.github.io/pmo-as-code/)** —
56
56
  a vendor-neutral standard for running a PMO from version-controlled, declarative
57
- files. It implements the [PMO as Code specification](https://github.com/c4g-john/pmo-as-code-spec) **v0.2**.
57
+ files. It implements the [PMO as Code specification](https://github.com/c4g-john/pmo-as-code-spec) **v0.3** and passes its [conformance suite](https://github.com/c4g-john/pmo-as-code-spec/tree/main/conformance) in CI.
58
58
 
59
59
  ## Install
60
60
 
@@ -98,6 +98,7 @@ flagged as TODOs, never invented). The skill's source is
98
98
  | `docassert projects [--out] [--check]` | Generate / verify the project registry. |
99
99
  | `docassert new <kind> --project ID` | Scaffold a document from its template with identity filled in (`new project --code XYZ` auto-numbers the id); suggests the next free item ids. |
100
100
  | `docassert init [DIR]` | Scaffold the default config into a repo. |
101
+ | `docassert bridge <action> --repo O/N` | Execution bridge: `scaffold` Features/Stories from approved user stories, `reconcile` (police the board against the docs), `status` (delivery figures). Needs the GitHub CLI. |
101
102
  | `docassert extract <file>` | Extract plain text from a source `.docx` / `.pdf` / `.md` / `.txt` (the first step of doc-to-pmo conversion). Needs the `convert` extra: `pip install "docassert[convert]"`. |
102
103
 
103
104
  Every document-reading command accepts `--documents-dir` (default `documents/`).
@@ -89,10 +89,15 @@ docassert/_data/templates/runbook.template.md
89
89
  docassert/_data/templates/status-report.template.md
90
90
  docassert/_data/templates/test-cases.template.md
91
91
  docassert/_data/templates/user-story.template.md
92
+ docassert/bridge/__init__.py
93
+ docassert/bridge/gh.py
94
+ docassert/bridge/ops.py
95
+ docassert/bridge/plan.py
92
96
  docassert/status/__init__.py
93
97
  docassert/status/derive.py
94
98
  docassert/status/render.py
95
99
  tests/test_badge.py
100
+ tests/test_bridge.py
96
101
  tests/test_config.py
97
102
  tests/test_consistency.py
98
103
  tests/test_defects.py