docassert 0.1.0__py3-none-any.whl

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 (86) hide show
  1. docassert/__init__.py +8 -0
  2. docassert/__main__.py +6 -0
  3. docassert/_data/consistency.yaml +51 -0
  4. docassert/_data/criteria/adr.criteria.yaml +36 -0
  5. docassert/_data/criteria/benefits-realization.criteria.yaml +30 -0
  6. docassert/_data/criteria/brd.criteria.yaml +30 -0
  7. docassert/_data/criteria/business-case.criteria.yaml +23 -0
  8. docassert/_data/criteria/charter.criteria.yaml +73 -0
  9. docassert/_data/criteria/data-migration-plan.criteria.yaml +28 -0
  10. docassert/_data/criteria/frnfr.criteria.yaml +31 -0
  11. docassert/_data/criteria/hypercare-plan.criteria.yaml +27 -0
  12. docassert/_data/criteria/post-implementation-review.criteria.yaml +24 -0
  13. docassert/_data/criteria/prd.criteria.yaml +31 -0
  14. docassert/_data/criteria/project.criteria.yaml +32 -0
  15. docassert/_data/criteria/qa-test-plan.criteria.yaml +27 -0
  16. docassert/_data/criteria/raci-stakeholder.criteria.yaml +24 -0
  17. docassert/_data/criteria/release-cutover-plan.criteria.yaml +30 -0
  18. docassert/_data/criteria/risk-register.criteria.yaml +32 -0
  19. docassert/_data/criteria/rollback-plan.criteria.yaml +29 -0
  20. docassert/_data/criteria/runbook.criteria.yaml +30 -0
  21. docassert/_data/criteria/status-report.criteria.yaml +26 -0
  22. docassert/_data/criteria/test-cases.criteria.yaml +28 -0
  23. docassert/_data/criteria/user-story.criteria.yaml +32 -0
  24. docassert/_data/profiles/agile-delivery.yaml +20 -0
  25. docassert/_data/profiles/lean-startup.yaml +19 -0
  26. docassert/_data/profiles/regulated-industry.yaml +31 -0
  27. docassert/_data/schema/adr.schema.json +45 -0
  28. docassert/_data/schema/benefits-realization.schema.json +45 -0
  29. docassert/_data/schema/brd.schema.json +45 -0
  30. docassert/_data/schema/business-case.schema.json +45 -0
  31. docassert/_data/schema/charter.schema.json +84 -0
  32. docassert/_data/schema/data-migration-plan.schema.json +45 -0
  33. docassert/_data/schema/frnfr.schema.json +45 -0
  34. docassert/_data/schema/hypercare-plan.schema.json +45 -0
  35. docassert/_data/schema/post-implementation-review.schema.json +45 -0
  36. docassert/_data/schema/prd.schema.json +45 -0
  37. docassert/_data/schema/project.schema.json +32 -0
  38. docassert/_data/schema/qa-test-plan.schema.json +45 -0
  39. docassert/_data/schema/raci-stakeholder.schema.json +45 -0
  40. docassert/_data/schema/release-cutover-plan.schema.json +45 -0
  41. docassert/_data/schema/risk-register.schema.json +45 -0
  42. docassert/_data/schema/rollback-plan.schema.json +45 -0
  43. docassert/_data/schema/runbook.schema.json +45 -0
  44. docassert/_data/schema/status-report.schema.json +58 -0
  45. docassert/_data/schema/test-cases.schema.json +45 -0
  46. docassert/_data/schema/user-story.schema.json +45 -0
  47. docassert/_data/templates/adr.template.md +17 -0
  48. docassert/_data/templates/benefits-realization.template.md +25 -0
  49. docassert/_data/templates/brd.template.md +22 -0
  50. docassert/_data/templates/business-case.template.md +27 -0
  51. docassert/_data/templates/charter.template.md +46 -0
  52. docassert/_data/templates/data-migration-plan.template.md +35 -0
  53. docassert/_data/templates/frnfr.template.md +19 -0
  54. docassert/_data/templates/hypercare-plan.template.md +29 -0
  55. docassert/_data/templates/post-implementation-review.template.md +31 -0
  56. docassert/_data/templates/prd.template.md +23 -0
  57. docassert/_data/templates/project.template.md +17 -0
  58. docassert/_data/templates/qa-test-plan.template.md +31 -0
  59. docassert/_data/templates/raci-stakeholder.template.md +21 -0
  60. docassert/_data/templates/release-cutover-plan.template.md +28 -0
  61. docassert/_data/templates/risk-register.template.md +18 -0
  62. docassert/_data/templates/rollback-plan.template.md +24 -0
  63. docassert/_data/templates/runbook.template.md +28 -0
  64. docassert/_data/templates/status-report.template.md +27 -0
  65. docassert/_data/templates/test-cases.template.md +17 -0
  66. docassert/_data/templates/user-story.template.md +17 -0
  67. docassert/cli.py +291 -0
  68. docassert/config.py +104 -0
  69. docassert/consistency.py +167 -0
  70. docassert/graph.py +68 -0
  71. docassert/loader.py +116 -0
  72. docassert/models.py +99 -0
  73. docassert/profiles.py +111 -0
  74. docassert/projects.py +49 -0
  75. docassert/report.py +83 -0
  76. docassert/rtm.py +70 -0
  77. docassert/semantic.py +124 -0
  78. docassert/status.py +538 -0
  79. docassert/structural.py +406 -0
  80. docassert-0.1.0.dist-info/METADATA +125 -0
  81. docassert-0.1.0.dist-info/RECORD +86 -0
  82. docassert-0.1.0.dist-info/WHEEL +5 -0
  83. docassert-0.1.0.dist-info/entry_points.txt +2 -0
  84. docassert-0.1.0.dist-info/licenses/LICENSE +201 -0
  85. docassert-0.1.0.dist-info/licenses/NOTICE +4 -0
  86. docassert-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,406 @@
1
+ """Deterministic structural checks. These are reliable and BLOCKING."""
2
+ from __future__ import annotations
3
+
4
+ import datetime as dt
5
+ import re
6
+ from collections.abc import Callable
7
+
8
+ from jsonschema import Draft7Validator, FormatChecker
9
+
10
+ from .loader import iter_item_lines
11
+ from .models import CheckResult, Document
12
+
13
+ # ── measurability heuristic ────────────────────────────────────────────────
14
+ # A success criterion is "measurable" if it contains a number AND either a
15
+ # comparator or a unit/symbol/date — enough to make pass/fail unambiguous.
16
+ _NUMBER = re.compile(r"\d")
17
+ _COMPARATOR = re.compile(
18
+ r"[<>]=?|\b(?:below|under|above|over|at least|at most|no more than|"
19
+ r"no less than|fewer than|more than|greater than|less than|within|by|"
20
+ r"reach|reduce|increase|decrease|drop|rise|cut|from|to)\b",
21
+ re.IGNORECASE,
22
+ )
23
+ _UNIT_OR_SYMBOL = re.compile(
24
+ r"%|[$€£]|/\s*\d|\d{4}-\d{2}-\d{2}|"
25
+ r"\b(?:hours?|hrs?|days?|weeks?|months?|minutes?|mins?|seconds?|secs?|"
26
+ r"USD|EUR|GBP|k|m|bn|pts?|points?|x)\b",
27
+ re.IGNORECASE,
28
+ )
29
+
30
+
31
+ def _is_measurable(text: str) -> bool:
32
+ if not _NUMBER.search(text):
33
+ return False
34
+ return bool(_COMPARATOR.search(text) or _UNIT_OR_SYMBOL.search(text))
35
+
36
+
37
+ def _field_value(item: str, field: str) -> str | None:
38
+ """Return the text after `field:` (up to a sentence/clause break), or None.
39
+
40
+ Stops at a semicolon or a period that ends a clause (period + space, or
41
+ period at end) so dotted handles like ``alex.kim`` are not truncated.
42
+ """
43
+ m = re.search(rf"{field}\s*:\s*(.+?)(?:;|\.\s|\.$|$)", item, re.IGNORECASE)
44
+ if not m:
45
+ return None
46
+ val = m.group(1).strip()
47
+ return val if len(val) >= 2 else None
48
+
49
+
50
+ def _jsonify(value):
51
+ """Convert YAML date/datetime objects to ISO strings so a JSON Schema with
52
+ `type: string, format: date` validates correctly."""
53
+ if isinstance(value, dict):
54
+ return {k: _jsonify(v) for k, v in value.items()}
55
+ if isinstance(value, list):
56
+ return [_jsonify(v) for v in value]
57
+ if isinstance(value, (dt.date, dt.datetime)):
58
+ return value.isoformat()
59
+ return value
60
+
61
+
62
+ def _as_date(value) -> dt.date | None:
63
+ if isinstance(value, dt.datetime):
64
+ return value.date()
65
+ if isinstance(value, dt.date):
66
+ return value
67
+ if isinstance(value, str):
68
+ try:
69
+ return dt.date.fromisoformat(value.strip())
70
+ except ValueError:
71
+ return None
72
+ return None
73
+
74
+
75
+ # ── individual checks ──────────────────────────────────────────────────────
76
+ def check_frontmatter_schema(doc: Document, ctx: dict) -> tuple[bool, str]:
77
+ schema = ctx["schema"]
78
+ validator = Draft7Validator(schema, format_checker=FormatChecker())
79
+ errors = sorted(validator.iter_errors(_jsonify(doc.frontmatter)), key=str)
80
+ if not errors:
81
+ return True, "Frontmatter is valid against the schema."
82
+ msgs = "; ".join(f"{'/'.join(str(p) for p in e.path) or '(root)'}: {e.message}"
83
+ for e in errors)
84
+ return False, f"Frontmatter schema errors: {msgs}"
85
+
86
+
87
+ def check_required_sections(doc: Document, ctx: dict) -> tuple[bool, str]:
88
+ required = ctx["required_sections"]
89
+ missing = [s for s in required if s not in doc.sections]
90
+ empty = [s for s in required if s in doc.sections and doc.sections[s].is_empty]
91
+ problems = []
92
+ if missing:
93
+ problems.append(f"missing: {', '.join(missing)}")
94
+ if empty:
95
+ problems.append(f"empty: {', '.join(empty)}")
96
+ if problems:
97
+ return False, "Required sections — " + "; ".join(problems)
98
+ return True, f"All {len(required)} required sections present and non-empty."
99
+
100
+
101
+ def check_measurable_success_criteria(doc: Document, ctx: dict) -> tuple[bool, str]:
102
+ section = doc.section("Success Criteria")
103
+ if section is None:
104
+ return False, "No Success Criteria section."
105
+ items = section.list_items
106
+ if not items:
107
+ return False, "Success Criteria has no bulleted criteria."
108
+ unmeasurable = [it for it in items if not _is_measurable(it)]
109
+ if unmeasurable:
110
+ preview = "; ".join(f'“{u[:60]}”' for u in unmeasurable)
111
+ return False, f"{len(unmeasurable)}/{len(items)} criteria lack a measurable threshold: {preview}"
112
+ return True, f"All {len(items)} success criteria state a measurable threshold."
113
+
114
+
115
+ def check_risks_owner_mitigation(doc: Document, ctx: dict) -> tuple[bool, str]:
116
+ section = doc.section("Risks")
117
+ if section is None:
118
+ return False, "No Risks section."
119
+ items = section.list_items
120
+ if not items:
121
+ return False, "Risks has no bulleted risks."
122
+ bad = []
123
+ for it in items:
124
+ if _field_value(it, "owner") is None or _field_value(it, "mitigation") is None:
125
+ bad.append(it)
126
+ if bad:
127
+ preview = "; ".join(f'“{b[:60]}”' for b in bad)
128
+ return False, f"{len(bad)}/{len(items)} risks miss an Owner and/or Mitigation: {preview}"
129
+ return True, f"All {len(items)} risks name an owner and a mitigation."
130
+
131
+
132
+ def check_dates_consistent(doc: Document, ctx: dict) -> tuple[bool, str]:
133
+ dates = doc.frontmatter.get("dates") or {}
134
+ created = _as_date(dates.get("created"))
135
+ target = _as_date(dates.get("target"))
136
+ if created is None or target is None:
137
+ return False, "dates.created and dates.target must be valid ISO dates."
138
+ if target < created:
139
+ return False, f"target ({target}) is before created ({created})."
140
+ return True, f"Dates consistent (created {created} → target {target})."
141
+
142
+
143
+ def check_unique_id(doc: Document, ctx: dict) -> tuple[bool, str]:
144
+ if not doc.id:
145
+ return False, "Document has no id."
146
+ others = [p for p in ctx.get("id_index", {}).get(doc.id, []) if p != doc.path]
147
+ if others:
148
+ return False, f"id '{doc.id}' also used by: {', '.join(others)}"
149
+ return True, f"id '{doc.id}' is unique."
150
+
151
+
152
+ def _project_code(doc: Document) -> str:
153
+ """The project code for a document, e.g. 'AUR' from project: PRJ-001-AUR."""
154
+ return str(doc.frontmatter.get("project", "")).split("-")[-1]
155
+
156
+
157
+ def check_items_well_formed(doc: Document, ctx: dict) -> tuple[bool, str]:
158
+ """Every bullet in a declared item-section is a valid item
159
+ ``**<CODE>-<TYPE>-<NNN>**``: the type matches the section, and the project
160
+ code matches the document's project."""
161
+ specs = ctx.get("item_sections") or []
162
+ if not specs:
163
+ return True, "No item sections for this kind."
164
+ code = _project_code(doc)
165
+ problems: list[str] = []
166
+ total = 0
167
+ for spec in specs:
168
+ section = doc.section(spec["section"])
169
+ if section is None:
170
+ continue # a missing section is the required-sections check's job
171
+ for raw, m in iter_item_lines(section):
172
+ total += 1
173
+ if not m:
174
+ problems.append(f'malformed item in "{spec["section"]}": “{raw[:50]}”')
175
+ continue
176
+ if m.group("type") != spec["prefix"]:
177
+ problems.append(
178
+ f'“{m.group("id")}” in "{spec["section"]}" should be type '
179
+ f'{spec["prefix"]} ({code}-{spec["prefix"]}-NNN)')
180
+ if code and m.group("project") != code:
181
+ problems.append(
182
+ f'“{m.group("id")}” uses project {m.group("project")} but the '
183
+ f'document belongs to {code}')
184
+ if problems:
185
+ return False, "; ".join(problems)
186
+ return True, f"All {total} item(s) well-formed."
187
+
188
+
189
+ _PROJECT_ID_RE = re.compile(r"^PRJ-\d{3,}-(?P<code>[A-Z]{2,6})$")
190
+
191
+
192
+ def check_project_id_format(doc: Document, ctx: dict) -> tuple[bool, str]:
193
+ """A project.md declares a well-formed id (PRJ-NNN-CODE) whose tail matches
194
+ its `code` field."""
195
+ pid = str(doc.frontmatter.get("id", ""))
196
+ code = str(doc.frontmatter.get("code", ""))
197
+ m = _PROJECT_ID_RE.match(pid)
198
+ if not m:
199
+ return False, f"project id {pid!r} must match PRJ-NNN-CODE (e.g. PRJ-001-AUR)."
200
+ if m.group("code") != code:
201
+ return False, f"project id tail ({m.group('code')}) must equal code ({code!r})."
202
+ return True, f"Project id {pid} is well-formed."
203
+
204
+
205
+ _RISK_FIELDS = ["Probability", "Impact", "Owner", "Response"]
206
+ _ADR_STATES = {"proposed", "accepted", "superseded", "deprecated", "rejected"}
207
+
208
+
209
+ def _items_of_prefix(doc: Document, ctx: dict, prefix: str):
210
+ """Yield (id, text) for every well-formed item of `prefix` in the doc."""
211
+ for spec in (ctx.get("item_sections") or []):
212
+ if spec.get("prefix") != prefix:
213
+ continue
214
+ section = doc.section(spec["section"])
215
+ if section is None:
216
+ continue
217
+ for _raw, m in iter_item_lines(section):
218
+ if m:
219
+ yield m.group("id"), m.group("text")
220
+
221
+
222
+ def check_risk_items_complete(doc: Document, ctx: dict) -> tuple[bool, str]:
223
+ """Every RISK item names a Probability, Impact, Owner, and Response."""
224
+ incomplete, total = [], 0
225
+ for iid, text in _items_of_prefix(doc, ctx, "RISK"):
226
+ total += 1
227
+ missing = [f for f in _RISK_FIELDS if _field_value(text, f) is None]
228
+ if missing:
229
+ incomplete.append(f'{iid} missing {", ".join(missing)}')
230
+ if incomplete:
231
+ return False, "; ".join(incomplete)
232
+ return True, f"All {total} risk(s) state probability, impact, owner, and response."
233
+
234
+
235
+ def check_adr_items_have_status(doc: Document, ctx: dict) -> tuple[bool, str]:
236
+ """Every ADR item declares a valid Status."""
237
+ bad, total = [], 0
238
+ for iid, text in _items_of_prefix(doc, ctx, "ADR"):
239
+ total += 1
240
+ status = _field_value(text, "status")
241
+ if status is None or status.lower() not in _ADR_STATES:
242
+ bad.append(f"{iid} status {status!r}")
243
+ if bad:
244
+ return False, ("; ".join(bad)
245
+ + f" (expected one of: {', '.join(sorted(_ADR_STATES))})")
246
+ return True, f"All {total} decision(s) have a valid status."
247
+
248
+
249
+ def check_raci_one_accountable(doc: Document, ctx: dict) -> tuple[bool, str]:
250
+ """The RACI Matrix table has exactly one Accountable (A) per activity."""
251
+ section = doc.section("RACI Matrix")
252
+ if section is None:
253
+ return False, "No RACI Matrix section."
254
+ rows = [ln.strip() for ln in section.body.splitlines() if ln.strip().startswith("|")]
255
+ data = [r for r in rows if "---" not in r][1:] # drop header + separator
256
+ if not data:
257
+ return False, "RACI Matrix has no activity rows."
258
+ problems = []
259
+ for row in data:
260
+ cells = [c.strip() for c in row.strip("|").split("|")]
261
+ activity = cells[0] if cells else "?"
262
+ a_count = sum(1 for c in cells[1:] if c.upper() == "A")
263
+ if a_count != 1:
264
+ problems.append(f'"{activity}" has {a_count} Accountable (need exactly 1)')
265
+ if problems:
266
+ return False, "; ".join(problems)
267
+ return True, f"All {len(data)} activities have exactly one Accountable role."
268
+
269
+
270
+ def check_story_format(doc: Document, ctx: dict) -> tuple[bool, str]:
271
+ """Every US item follows 'As a … I want … so that …'."""
272
+ bad, total = [], 0
273
+ for iid, text in _items_of_prefix(doc, ctx, "US"):
274
+ total += 1
275
+ low = text.lower()
276
+ if not (("as a " in low or "as an " in low) and "i want" in low):
277
+ bad.append(iid)
278
+ if bad:
279
+ return False, "stories not in 'As a … I want …' form: " + ", ".join(bad)
280
+ return True, f"All {total} user story(ies) follow the standard form."
281
+
282
+
283
+ def check_measurable_exit_criteria(doc: Document, ctx: dict) -> tuple[bool, str]:
284
+ """Every Exit Criteria bullet states a measurable threshold."""
285
+ section = doc.section("Exit Criteria")
286
+ if section is None:
287
+ return False, "No Exit Criteria section."
288
+ items = section.list_items
289
+ if not items:
290
+ return False, "Exit Criteria has no bulleted criteria."
291
+ unmeasurable = [it for it in items if not _is_measurable(it)]
292
+ if unmeasurable:
293
+ preview = "; ".join(f'“{u[:50]}”' for u in unmeasurable)
294
+ return False, f"{len(unmeasurable)}/{len(items)} exit criteria lack a measurable threshold: {preview}"
295
+ return True, f"All {len(items)} exit criteria state a measurable threshold."
296
+
297
+
298
+ def check_has_mapping_table(doc: Document, ctx: dict) -> tuple[bool, str]:
299
+ """The Field Mapping section contains a table with at least one row."""
300
+ section = doc.section("Field Mapping")
301
+ if section is None:
302
+ return False, "No Field Mapping section."
303
+ rows = [ln.strip() for ln in section.body.splitlines() if ln.strip().startswith("|")]
304
+ data = [r for r in rows if "---" not in r][1:]
305
+ if not data:
306
+ return False, "Field Mapping has no mapping table rows."
307
+ return True, f"Field Mapping table has {len(data)} row(s)."
308
+
309
+
310
+ _STEP_RE = re.compile(r"^\s*\d+\.\s")
311
+
312
+
313
+ def check_numbered_steps(doc: Document, ctx: dict) -> tuple[bool, str]:
314
+ """Each section named in `steps_sections` has an ordered (numbered) list of
315
+ at least two steps."""
316
+ specs = ctx.get("steps_sections") or []
317
+ if not specs:
318
+ return True, "No step sections for this kind."
319
+ problems, total = [], 0
320
+ for name in specs:
321
+ section = doc.section(name)
322
+ if section is None:
323
+ continue
324
+ n = sum(1 for ln in section.body.splitlines() if _STEP_RE.match(ln))
325
+ total += n
326
+ if n < 2:
327
+ problems.append(f'"{name}" needs at least 2 numbered steps (found {n})')
328
+ if problems:
329
+ return False, "; ".join(problems)
330
+ return True, f"Step sections have {total} numbered step(s)."
331
+
332
+
333
+ def check_measurable_items(doc: Document, ctx: dict) -> tuple[bool, str]:
334
+ """Every bullet in each section named in `measurable_sections` states a
335
+ measurable threshold."""
336
+ specs = ctx.get("measurable_sections") or []
337
+ if not specs:
338
+ return True, "No measurable sections for this kind."
339
+ problems, total = [], 0
340
+ for name in specs:
341
+ section = doc.section(name)
342
+ if section is None:
343
+ continue
344
+ items = section.list_items
345
+ if not items:
346
+ problems.append(f'"{name}" has no bulleted items')
347
+ continue
348
+ total += len(items)
349
+ bad = [it for it in items if not _is_measurable(it)]
350
+ if bad:
351
+ problems.append(f'{len(bad)}/{len(items)} in "{name}" lack a measurable '
352
+ f'threshold: ' + "; ".join(f'“{b[:40]}”' for b in bad))
353
+ if problems:
354
+ return False, "; ".join(problems)
355
+ return True, f"All {total} item(s) in measurable sections state a threshold."
356
+
357
+
358
+ _RISK_REF_RE = re.compile(r"\b[A-Z]{2,6}-RISK-\d+\b")
359
+
360
+
361
+ def check_references_risk(doc: Document, ctx: dict) -> tuple[bool, str]:
362
+ """The 'Risks & Issues' section cites at least one <CODE>-RISK-### from the register."""
363
+ section = doc.section("Risks & Issues")
364
+ if section is None:
365
+ return False, "No Risks & Issues section."
366
+ refs = _RISK_REF_RE.findall(section.body)
367
+ if not refs:
368
+ return False, "Risks & Issues cites no <CODE>-RISK-### from the register."
369
+ return True, f"Cites {len(set(refs))} risk(s) from the register: {', '.join(sorted(set(refs)))}."
370
+
371
+
372
+ CHECKS: dict[str, Callable[[Document, dict], tuple[bool, str]]] = {
373
+ "frontmatter-schema": check_frontmatter_schema,
374
+ "required-sections": check_required_sections,
375
+ "measurable-success-criteria": check_measurable_success_criteria,
376
+ "risks-have-owner-and-mitigation": check_risks_owner_mitigation,
377
+ "dates-consistent": check_dates_consistent,
378
+ "unique-id": check_unique_id,
379
+ "items-well-formed": check_items_well_formed,
380
+ "risk-items-complete": check_risk_items_complete,
381
+ "adr-items-have-status": check_adr_items_have_status,
382
+ "raci-one-accountable": check_raci_one_accountable,
383
+ "story-format": check_story_format,
384
+ "measurable-exit-criteria": check_measurable_exit_criteria,
385
+ "mapping-table": check_has_mapping_table,
386
+ "numbered-steps": check_numbered_steps,
387
+ "measurable-items": check_measurable_items,
388
+ "references-risk": check_references_risk,
389
+ "project-id-format": check_project_id_format,
390
+ }
391
+
392
+
393
+ def run_structural(doc: Document, spec: dict, ctx: dict) -> CheckResult:
394
+ """Run one structural check described by a criteria `spec` dict."""
395
+ check_id = spec["id"]
396
+ fn = CHECKS.get(check_id)
397
+ blocking = bool(spec.get("blocking", True))
398
+ if fn is None:
399
+ return CheckResult(check_id, False, blocking,
400
+ f"Unknown structural check '{check_id}'.", kind="structural")
401
+ try:
402
+ passed, detail = fn(doc, ctx)
403
+ except Exception as exc: # a check crash is a failure, never a silent pass
404
+ return CheckResult(check_id, False, blocking,
405
+ f"Check errored: {exc}", kind="structural")
406
+ return CheckResult(check_id, passed, blocking, detail, kind="structural")
@@ -0,0 +1,125 @@
1
+ Metadata-Version: 2.4
2
+ Name: docassert
3
+ Version: 0.1.0
4
+ Summary: Unit testing for business documents — validate structured Markdown docs against a configurable audit standard.
5
+ Author: C4G Enterprises Inc.
6
+ License: Apache-2.0
7
+ Project-URL: Homepage, https://docassert.com
8
+ Project-URL: Repository, https://github.com/c4g-john/docassert
9
+ Project-URL: Issues, https://github.com/c4g-john/docassert/issues
10
+ Keywords: pmo,documentation,validation,governance,markdown,audit,traceability,ci
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: Apache Software License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Software Development :: Quality Assurance
20
+ Classifier: Topic :: Software Development :: Documentation
21
+ Classifier: Topic :: Text Processing :: Markup :: Markdown
22
+ Requires-Python: >=3.10
23
+ Description-Content-Type: text/markdown
24
+ License-File: LICENSE
25
+ License-File: NOTICE
26
+ Requires-Dist: python-frontmatter>=1.1
27
+ Requires-Dist: PyYAML>=6.0
28
+ Requires-Dist: jsonschema>=4.0
29
+ Provides-Extra: ai
30
+ Requires-Dist: anthropic>=0.40; extra == "ai"
31
+ Provides-Extra: convert
32
+ Requires-Dist: python-docx>=1.1; extra == "convert"
33
+ Requires-Dist: pypdf>=4.0; extra == "convert"
34
+ Provides-Extra: dev
35
+ Requires-Dist: pytest>=8.0; extra == "dev"
36
+ Requires-Dist: ruff>=0.6; extra == "dev"
37
+ Dynamic: license-file
38
+
39
+ # docassert
40
+
41
+ **Unit testing for business documents.** Validate structured Markdown documents
42
+ (charters, BRDs, PRDs, risk registers, …) against a configurable audit standard:
43
+ deterministic structural checks that gate a merge, plus optional AI-graded
44
+ semantic checks that advise. Requirements trace end to end, and project status is
45
+ derived from the documents rather than self-reported.
46
+
47
+ docassert is the reference implementation of **[PMO as Code](https://c4g-john.github.io/pmo-as-code/)** —
48
+ a vendor-neutral standard for running a PMO from version-controlled, declarative files.
49
+
50
+ ## Install
51
+
52
+ ```bash
53
+ pip install "docassert @ git+https://github.com/c4g-john/docassert" # PyPI release coming
54
+ # with the AI advisory extra:
55
+ pip install "docassert[ai] @ git+https://github.com/c4g-john/docassert"
56
+ ```
57
+
58
+ ## Quickstart
59
+
60
+ ```bash
61
+ docassert init # scaffold criteria/schema/profiles/templates into your repo
62
+ docassert validate documents/**/*.md # unit-test your documents
63
+ docassert consistency # cross-document traceability + profile completeness
64
+ docassert status --index # derived RAG per project
65
+ docassert pages --out _site # a portfolio dashboard + a page per project
66
+ ```
67
+
68
+ Config resolves **local override → packaged default**: docassert ships sensible
69
+ defaults, and your repo's own `criteria/` (or `schema/`, `profiles/`,
70
+ `consistency.yaml`) wins when present. `docassert init` copies the defaults in so
71
+ you can customize them.
72
+
73
+ ## Commands
74
+
75
+ | Command | What it does |
76
+ |---|---|
77
+ | `docassert validate <globs>` | Validate documents against their kind's criteria. Exit code = number of blocking failures. |
78
+ | `docassert consistency` | Cross-document checks: referential integrity, coverage, required links, profile completeness. |
79
+ | `docassert rtm [--project ID]` | Requirements traceability matrix (Markdown or CSV). |
80
+ | `docassert status [--project ID] [--index]` | Derived project status (md / json / html). |
81
+ | `docassert pages --out DIR` | Build the portfolio site (index + a page per project). |
82
+ | `docassert projects [--out] [--check]` | Generate / verify the project registry. |
83
+ | `docassert init [DIR]` | Scaffold the default config into a repo. |
84
+
85
+ ## Document kinds
86
+
87
+ Twenty kinds, each a `templates/<kind>.template.md` + `schema/<kind>.schema.json`
88
+ + `criteria/<kind>.criteria.yaml` trio: `project`, `charter`, `business-case`,
89
+ `brd`, `prd`, `frnfr`, `user-story`, `test-cases`, `adr`, `risk-register`,
90
+ `raci-stakeholder`, `qa-test-plan`, `data-migration-plan`,
91
+ `release-cutover-plan`, `rollback-plan`, `hypercare-plan`, `runbook`,
92
+ `status-report`, `post-implementation-review`, `benefits-realization`. Adding a
93
+ kind is adding a trio — no code for the common cases.
94
+
95
+ ## Two tiers of checks
96
+
97
+ - **Structural — deterministic, blocking.** Required fields and sections,
98
+ measurable success criteria, risks with owner + mitigation, resolving
99
+ references, unique ids. Plain Python, reliable enough to gate a merge.
100
+ - **Semantic — AI-graded, advisory.** Scored via the Anthropic API and posted to
101
+ the PR — never blocking. Set `ANTHROPIC_API_KEY` to enable; skipped otherwise.
102
+
103
+ ## Development
104
+
105
+ ```bash
106
+ python3 -m venv .venv && source .venv/bin/activate
107
+ pip install -e ".[dev]"
108
+ pytest
109
+ ruff check .
110
+ ```
111
+
112
+ This repo ships example `documents/` (four sample projects) that the test suite
113
+ validates against.
114
+
115
+ ## The reference deployment
116
+
117
+ [**pmo-as-code-pipeline**](https://github.com/c4g-john/pmo-as-code-pipeline) is a
118
+ living example — sample projects, the gate on every pull request, and a published
119
+ dashboard at
120
+ [c4g-john.github.io/pmo-as-code-pipeline](https://c4g-john.github.io/pmo-as-code-pipeline/).
121
+ The standard's site is [c4g-john.github.io/pmo-as-code](https://c4g-john.github.io/pmo-as-code/).
122
+
123
+ ## License
124
+
125
+ Apache-2.0 — see [LICENSE](LICENSE) and [NOTICE](NOTICE). © 2026 C4G Enterprises Inc.
@@ -0,0 +1,86 @@
1
+ docassert/__init__.py,sha256=r3cAnfv14fB1O1GUSxbmeJO-I6iAqcraldvzs-hCW98,273
2
+ docassert/__main__.py,sha256=4JMK66Wj4uLZTKbF-sT3LAxOsr6buig77PmOkJCRRxw,83
3
+ docassert/cli.py,sha256=3yKxLNx3Z0cvBbOySmc23Il0tG1Ksm4cjKnuQHLiYTA,11452
4
+ docassert/config.py,sha256=kRzBvqKIbC3nsviUf6uo9IMP4DXzrEhB0ebJ_5CjjRM,4299
5
+ docassert/consistency.py,sha256=nRbavINfL3A_ZRZb4SP74s7Ub706mTHaHDDArULVO3g,7531
6
+ docassert/graph.py,sha256=JXQyRmBU3bqAcquFK9T2G49ZOp8i9I9or0oV5qc1FGA,2647
7
+ docassert/loader.py,sha256=04rtye1e95sN6-brPiCNHZzPqZu9c1rntJTP1gLpbhA,4000
8
+ docassert/models.py,sha256=xUz3-Ke-UiSf9oWx3j3MtJsXj6kwTLpEcWd2IUopd5s,3148
9
+ docassert/profiles.py,sha256=Wav3XU4znkLXOOuO674JJgAmaOccPSLCMKpzQJQ6N7c,4477
10
+ docassert/projects.py,sha256=wbN7ZPXUSbiz8uMRQHbj04iLavFzdbvrPuuz84mDA9I,1751
11
+ docassert/report.py,sha256=6klvTyAS2HF-XAXDAH8m48s0jQYZvUAAmHwdGF798nQ,3679
12
+ docassert/rtm.py,sha256=yj_OsON0sdez_qvxEEQT36FTHToopbxWIca60wTkkxk,2474
13
+ docassert/semantic.py,sha256=bPGnT1ascg6sTKBRZqd7pABKZO0rEyUftH1gF9z6swA,4720
14
+ docassert/status.py,sha256=plpo6ofS1QVRp0ol-IZX0Zty0Iq05hEvUXqlqbecs7s,24464
15
+ docassert/structural.py,sha256=ryUy8xFG0BQmEBl731xtDwN8fEuAYfmjkEn7CffUkyY,16979
16
+ docassert/_data/consistency.yaml,sha256=YSe8zaebPVoZLcjfUbLPrPl0JE2gwz1hg2nUQXkz59s,2130
17
+ docassert/_data/criteria/adr.criteria.yaml,sha256=2tTiw4A1EUM2dTTkFKaeIWCSi8RyJYpygDb2APEKo6c,1073
18
+ docassert/_data/criteria/benefits-realization.criteria.yaml,sha256=fwHyKLN6YfHH4DU-20_qcmH_udt6xu96nfmJ4c_PbKs,797
19
+ docassert/_data/criteria/brd.criteria.yaml,sha256=5enPIUR9F7BAOemK1KYHmDu9nmQOshXusz5a77Cw95s,801
20
+ docassert/_data/criteria/business-case.criteria.yaml,sha256=PdxKSELE6L1rfXNVUwbq1SLNlLbGn9YVm4kjUoHCEjY,580
21
+ docassert/_data/criteria/charter.criteria.yaml,sha256=3JpgfHO8qXRiFvSuRZUKwX3SwbgVt_T-kCsTZFp-XdM,2468
22
+ docassert/_data/criteria/data-migration-plan.criteria.yaml,sha256=RyeQUfkR2Tf7DfGxkflDsFbwu9DO3TCf0gayZinFXVw,729
23
+ docassert/_data/criteria/frnfr.criteria.yaml,sha256=w6q8wQmr54fWMfPe48qoG7QvOhYNmawQssb_Kxc5epM,857
24
+ docassert/_data/criteria/hypercare-plan.criteria.yaml,sha256=hYsWDmsw4G2aOAsBElzQMvn_h4dZ6rtmBHbOFtDlxz8,733
25
+ docassert/_data/criteria/post-implementation-review.criteria.yaml,sha256=Kx2vbm4SchJ-xJ50C6XQG9RSx4FqDKrOTH_hwcS5RlM,655
26
+ docassert/_data/criteria/prd.criteria.yaml,sha256=n7wAYWGrlwpO_cvY3ZNYnOjwKQQiJw0D-JMN1elVYjg,838
27
+ docassert/_data/criteria/project.criteria.yaml,sha256=Ix-D1VPbFzPBZZHb2b8fpc7z_0pTNW_qvEnkb-WA2JQ,883
28
+ docassert/_data/criteria/qa-test-plan.criteria.yaml,sha256=mGlbwekp1DBwG0cEy_WxiGm5YhiKVX1KbG9w9jUe2aY,716
29
+ docassert/_data/criteria/raci-stakeholder.criteria.yaml,sha256=TKmB-1ExX2RQvbXNmAVgYL4GBwjqIf8M8XKy1joZpNg,704
30
+ docassert/_data/criteria/release-cutover-plan.criteria.yaml,sha256=tOSCtZG0sH_irYUOQVFqPZhk5NJUkpIioAFq9vYfsrQ,791
31
+ docassert/_data/criteria/risk-register.criteria.yaml,sha256=FQZ5-PnYrSsWCgjuZgEVNEq9t8ueMJ5nEJ7avQ19FJA,839
32
+ docassert/_data/criteria/rollback-plan.criteria.yaml,sha256=UNcYLcS1ydE4GTbGwzVC5jGFpoS8C20IikiB-DEMNQU,747
33
+ docassert/_data/criteria/runbook.criteria.yaml,sha256=BRziVcsrxJz3YfrpJ4cJ4WSvPaIVvfWb2YIgynybPtg,725
34
+ docassert/_data/criteria/status-report.criteria.yaml,sha256=BxSEA_zK6_Y6ZcDMPdry-4NeDNUawQGnhvgBJ8LT4_k,717
35
+ docassert/_data/criteria/test-cases.criteria.yaml,sha256=9R-9bBW0Tnyh4DxN2BYl035C_YZnWpr3nCWb0_NrM_c,729
36
+ docassert/_data/criteria/user-story.criteria.yaml,sha256=Z_CZpb1F4S_X2zQt7mV7y3DjI9gZIs_jQKyUhTK6u1Y,829
37
+ docassert/_data/profiles/agile-delivery.yaml,sha256=BKELKUX2288YJ8jXtFiXOoX-VdcYmDq8PPI_SSnTmTc,486
38
+ docassert/_data/profiles/lean-startup.yaml,sha256=uxHvGvb-aLE97SbdpGKubOAbe5mrR04-LYiDlUKZyTE,449
39
+ docassert/_data/profiles/regulated-industry.yaml,sha256=rId3JCU9uMiSseP9eDz-4xQMDN5jkjVAWZKzBh2J4Lw,948
40
+ docassert/_data/schema/adr.schema.json,sha256=EpBNzWNDX-pcVtfLmz0O_91mUmnWrFgeDw-sxxWCeNk,1002
41
+ docassert/_data/schema/benefits-realization.schema.json,sha256=CnLIl26L88mjZEsQH0rIYB8zxJCQACSddnvQGSsxaCI,1005
42
+ docassert/_data/schema/brd.schema.json,sha256=FZBUFu_V7l7x5LkAjkmQTWZBYSQU4PsH5Oafod2OzhU,998
43
+ docassert/_data/schema/business-case.schema.json,sha256=yEu7tzw-kqioNZDi4InrBfQK0JArGXox6LMHYgFjTdg,995
44
+ docassert/_data/schema/charter.schema.json,sha256=Sf_KV1cWqudAC6qTSaHgWc-ZhKjCUthg1cBHjcTf4k0,1853
45
+ docassert/_data/schema/data-migration-plan.schema.json,sha256=KHPLI541f9LmXoGYBxS51Wa_O7Ub5nhrzaEtPR4ZOCc,1003
46
+ docassert/_data/schema/frnfr.schema.json,sha256=u2B8camWpNKeIph-kFzNkUQvX6xPA_HVI8MOTuA4VAA,1010
47
+ docassert/_data/schema/hypercare-plan.schema.json,sha256=dmAGt9esQwnNs1cMwhStOXf1zRH8xuWXLS21p3iouBo,993
48
+ docassert/_data/schema/post-implementation-review.schema.json,sha256=LC35mO2FuWmbnUfLseb2T4XnIvsnk3-m-94m3paFut8,1017
49
+ docassert/_data/schema/prd.schema.json,sha256=ib_znW0Z8wfbe5_x2o0eQZUynJxvTedL_MXYpmEUWCA,997
50
+ docassert/_data/schema/project.schema.json,sha256=pHByCi1h8H8OS6REOBXMPxqmlSrAHfgZ975cfT1wYYU,1198
51
+ docassert/_data/schema/qa-test-plan.schema.json,sha256=0V7BLmfQcI4ggvEdNJ6uOuYmPzc2AwCvZstu9OBNCBE,991
52
+ docassert/_data/schema/raci-stakeholder.schema.json,sha256=6vyXjl4OJTSik5h8p6dSFjt8-bgzsY2TdvzhvzwazI4,1008
53
+ docassert/_data/schema/release-cutover-plan.schema.json,sha256=PScrP5QqpKs37AVk_vQGjt5uEToZRnf3AjBSrBkSe50,1007
54
+ docassert/_data/schema/risk-register.schema.json,sha256=XmMkK88km78fZuHx53tCTIpY-5X3hmaoogQU4iulTv0,991
55
+ docassert/_data/schema/rollback-plan.schema.json,sha256=a29Se0qm656XkP4d3eEdVLT68ieU0bO4GpZ-_zxa-3A,991
56
+ docassert/_data/schema/runbook.schema.json,sha256=6OE1P3DfWNDQ_W8YGLyqz9Zd4V1jP4PS7Uv3N6pY9gg,979
57
+ docassert/_data/schema/status-report.schema.json,sha256=LfrEpGyrahn4TybEdBCdf2cgeLEB_TGYYwFsh0mKH5U,1178
58
+ docassert/_data/schema/test-cases.schema.json,sha256=Lawn-Zi2t4vBf3P4SRSfrwHrdtaV_Mj0lkqdDSkbYxs,985
59
+ docassert/_data/schema/user-story.schema.json,sha256=NL5TtXVtmeK6ysO2viwVaobImwtBghK_t2ga9a9PNxU,987
60
+ docassert/_data/templates/adr.template.md,sha256=36Jh7najTUM8vSKqnkceIW26RnTWZLLiFnZULvWkH3A,396
61
+ docassert/_data/templates/benefits-realization.template.md,sha256=liJ4cLJRkVX4mMK_JQDh_nJMIAf_6rFnx_NzOehLhkk,498
62
+ docassert/_data/templates/brd.template.md,sha256=9Z6YN9hqfxfaAyt4Ub9achrcLdolkWlCTSRrtQSH9sw,439
63
+ docassert/_data/templates/business-case.template.md,sha256=Zk4ijavPO6btlpUlbedGy-NrRrDbbUWbPP-Xmvu9ROY,457
64
+ docassert/_data/templates/charter.template.md,sha256=qdLVQicWGxSt9HO6LDMjAoiF_w_ld4sMoOkCQQ8BlGQ,1335
65
+ docassert/_data/templates/data-migration-plan.template.md,sha256=mFl0nijXISoEQtnaxyYKra6d-rFGyQaIU2P_RQTwAO4,603
66
+ docassert/_data/templates/frnfr.template.md,sha256=kvg47hrpNdLWWA9wc7-D3q6_R1ISjr1aUQi1aUOcGmY,383
67
+ docassert/_data/templates/hypercare-plan.template.md,sha256=UOk0FWlKoZA4Uu0xE44nJ4SnnlSIWcc1z_e71yo3U0s,524
68
+ docassert/_data/templates/post-implementation-review.template.md,sha256=F3BWgMdEFJuxx9vbebTEFfmuMA20lCiSuFZf5-HWA00,475
69
+ docassert/_data/templates/prd.template.md,sha256=GgaMCNZjGSEIQ8fKgSH04oILpAZMW6xtGX-3vFsWZHQ,472
70
+ docassert/_data/templates/project.template.md,sha256=oqYclBtMRSyvn-Jc3wHyElM1in7Dj9xDfhBm6xHvEgI,539
71
+ docassert/_data/templates/qa-test-plan.template.md,sha256=btpDuJ7cyEvO19SOSSZQaetKZ7zV1ctnifxQjDDCyu4,600
72
+ docassert/_data/templates/raci-stakeholder.template.md,sha256=lzgKvXz7G-VKIePlji6J4uYQs_WDIF_JPrKbVFeeDlI,422
73
+ docassert/_data/templates/release-cutover-plan.template.md,sha256=KD-uwP7q8cPX9befWgzRjGV6WtUfHOVz2O_4kOG7d7Q,468
74
+ docassert/_data/templates/risk-register.template.md,sha256=qB8AGkgA_zevbUfZFuCvb1YZy0u7Wx47NHYeuZJhUCA,437
75
+ docassert/_data/templates/rollback-plan.template.md,sha256=TitKTHUQ54oUr2WTbEhJWfdmznMuhwaB9LkRWRWgrRo,383
76
+ docassert/_data/templates/runbook.template.md,sha256=duHGiIvueDNpk1HX5ZJlkJFdPNjLzA8BtstDxx-9Cco,425
77
+ docassert/_data/templates/status-report.template.md,sha256=y_08jiUg1yCt2GvvBY5YfwyXZg_riO3ixlFMh6MiVbE,495
78
+ docassert/_data/templates/test-cases.template.md,sha256=0iZXwA-VLEcbTdW-RGL7B7W46s7hav0NyqP_8a2_HDw,290
79
+ docassert/_data/templates/user-story.template.md,sha256=KKQrxQrBqMTAwv6LUDG1ktkGcMdjhO5qj-kOutkJ_Vg,332
80
+ docassert-0.1.0.dist-info/licenses/LICENSE,sha256=xBe1Yr3cUoPAUIQEuhY3lYqIl9g9Sz2-7TFyPJrEyIk,11350
81
+ docassert-0.1.0.dist-info/licenses/NOTICE,sha256=X68MqGpL0ZAaP0yxmH4R8v5pZOcU0w5chz4USnTp44M,125
82
+ docassert-0.1.0.dist-info/METADATA,sha256=VQvQkpYGr652Y7XSoUfQMZY14oKy_P-kW0fWuyvDL08,5458
83
+ docassert-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
84
+ docassert-0.1.0.dist-info/entry_points.txt,sha256=TMhMaSwJcQWPMydjO1CWK0IuLQTBO_b4655glFXsBms,49
85
+ docassert-0.1.0.dist-info/top_level.txt,sha256=JrVdJYhp8XhcWeuXFoV7FZmI9PZ6TmcaTukUKKsVePU,10
86
+ docassert-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ docassert = docassert.cli:main