docassert 0.20.1__tar.gz → 1.0.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 (126) hide show
  1. {docassert-0.20.1/docassert.egg-info → docassert-1.0.0}/PKG-INFO +3 -2
  2. {docassert-0.20.1 → docassert-1.0.0}/docassert/__init__.py +1 -1
  3. {docassert-0.20.1 → docassert-1.0.0}/docassert/bridge/board.py +30 -6
  4. {docassert-0.20.1 → docassert-1.0.0}/docassert/bridge/ops.py +7 -1
  5. {docassert-0.20.1 → docassert-1.0.0}/docassert/bridge/plan.py +5 -2
  6. {docassert-0.20.1 → docassert-1.0.0}/docassert/cli.py +19 -3
  7. {docassert-0.20.1 → docassert-1.0.0}/docassert/config.py +17 -2
  8. {docassert-0.20.1 → docassert-1.0.0}/docassert/consistency.py +32 -20
  9. {docassert-0.20.1 → docassert-1.0.0}/docassert/profiles.py +4 -2
  10. {docassert-0.20.1 → docassert-1.0.0}/docassert/status/__init__.py +2 -1
  11. {docassert-0.20.1 → docassert-1.0.0}/docassert/status/derive.py +20 -7
  12. {docassert-0.20.1 → docassert-1.0.0}/docassert/status/render.py +84 -31
  13. {docassert-0.20.1 → docassert-1.0.0/docassert.egg-info}/PKG-INFO +3 -2
  14. {docassert-0.20.1 → docassert-1.0.0}/docassert.egg-info/SOURCES.txt +1 -0
  15. {docassert-0.20.1 → docassert-1.0.0}/pyproject.toml +2 -1
  16. docassert-1.0.0/tests/test_audit_0_20_2.py +218 -0
  17. {docassert-0.20.1 → docassert-1.0.0}/LICENSE +0 -0
  18. {docassert-0.20.1 → docassert-1.0.0}/NOTICE +0 -0
  19. {docassert-0.20.1 → docassert-1.0.0}/README.md +0 -0
  20. {docassert-0.20.1 → docassert-1.0.0}/docassert/__main__.py +0 -0
  21. {docassert-0.20.1 → docassert-1.0.0}/docassert/_data/consistency.yaml +0 -0
  22. {docassert-0.20.1 → docassert-1.0.0}/docassert/_data/criteria/adr.criteria.yaml +0 -0
  23. {docassert-0.20.1 → docassert-1.0.0}/docassert/_data/criteria/benefits-realization.criteria.yaml +0 -0
  24. {docassert-0.20.1 → docassert-1.0.0}/docassert/_data/criteria/brd.criteria.yaml +0 -0
  25. {docassert-0.20.1 → docassert-1.0.0}/docassert/_data/criteria/business-case.criteria.yaml +0 -0
  26. {docassert-0.20.1 → docassert-1.0.0}/docassert/_data/criteria/charter.criteria.yaml +0 -0
  27. {docassert-0.20.1 → docassert-1.0.0}/docassert/_data/criteria/data-migration-plan.criteria.yaml +0 -0
  28. {docassert-0.20.1 → docassert-1.0.0}/docassert/_data/criteria/frnfr.criteria.yaml +0 -0
  29. {docassert-0.20.1 → docassert-1.0.0}/docassert/_data/criteria/hypercare-plan.criteria.yaml +0 -0
  30. {docassert-0.20.1 → docassert-1.0.0}/docassert/_data/criteria/operations.criteria.yaml +0 -0
  31. {docassert-0.20.1 → docassert-1.0.0}/docassert/_data/criteria/post-implementation-review.criteria.yaml +0 -0
  32. {docassert-0.20.1 → docassert-1.0.0}/docassert/_data/criteria/prd.criteria.yaml +0 -0
  33. {docassert-0.20.1 → docassert-1.0.0}/docassert/_data/criteria/project.criteria.yaml +0 -0
  34. {docassert-0.20.1 → docassert-1.0.0}/docassert/_data/criteria/qa-test-plan.criteria.yaml +0 -0
  35. {docassert-0.20.1 → docassert-1.0.0}/docassert/_data/criteria/raci-stakeholder.criteria.yaml +0 -0
  36. {docassert-0.20.1 → docassert-1.0.0}/docassert/_data/criteria/release-cutover-plan.criteria.yaml +0 -0
  37. {docassert-0.20.1 → docassert-1.0.0}/docassert/_data/criteria/risk-register.criteria.yaml +0 -0
  38. {docassert-0.20.1 → docassert-1.0.0}/docassert/_data/criteria/rollback-plan.criteria.yaml +0 -0
  39. {docassert-0.20.1 → docassert-1.0.0}/docassert/_data/criteria/runbook.criteria.yaml +0 -0
  40. {docassert-0.20.1 → docassert-1.0.0}/docassert/_data/criteria/status-report.criteria.yaml +0 -0
  41. {docassert-0.20.1 → docassert-1.0.0}/docassert/_data/criteria/test-cases.criteria.yaml +0 -0
  42. {docassert-0.20.1 → docassert-1.0.0}/docassert/_data/criteria/user-story.criteria.yaml +0 -0
  43. {docassert-0.20.1 → docassert-1.0.0}/docassert/_data/profiles/agile-delivery.yaml +0 -0
  44. {docassert-0.20.1 → docassert-1.0.0}/docassert/_data/profiles/lean-startup.yaml +0 -0
  45. {docassert-0.20.1 → docassert-1.0.0}/docassert/_data/profiles/operations.yaml +0 -0
  46. {docassert-0.20.1 → docassert-1.0.0}/docassert/_data/profiles/regulated-industry.yaml +0 -0
  47. {docassert-0.20.1 → docassert-1.0.0}/docassert/_data/schema/adr.schema.json +0 -0
  48. {docassert-0.20.1 → docassert-1.0.0}/docassert/_data/schema/benefits-realization.schema.json +0 -0
  49. {docassert-0.20.1 → docassert-1.0.0}/docassert/_data/schema/brd.schema.json +0 -0
  50. {docassert-0.20.1 → docassert-1.0.0}/docassert/_data/schema/business-case.schema.json +0 -0
  51. {docassert-0.20.1 → docassert-1.0.0}/docassert/_data/schema/charter.schema.json +0 -0
  52. {docassert-0.20.1 → docassert-1.0.0}/docassert/_data/schema/data-migration-plan.schema.json +0 -0
  53. {docassert-0.20.1 → docassert-1.0.0}/docassert/_data/schema/frnfr.schema.json +0 -0
  54. {docassert-0.20.1 → docassert-1.0.0}/docassert/_data/schema/hypercare-plan.schema.json +0 -0
  55. {docassert-0.20.1 → docassert-1.0.0}/docassert/_data/schema/operations.schema.json +0 -0
  56. {docassert-0.20.1 → docassert-1.0.0}/docassert/_data/schema/post-implementation-review.schema.json +0 -0
  57. {docassert-0.20.1 → docassert-1.0.0}/docassert/_data/schema/prd.schema.json +0 -0
  58. {docassert-0.20.1 → docassert-1.0.0}/docassert/_data/schema/project.schema.json +0 -0
  59. {docassert-0.20.1 → docassert-1.0.0}/docassert/_data/schema/qa-test-plan.schema.json +0 -0
  60. {docassert-0.20.1 → docassert-1.0.0}/docassert/_data/schema/raci-stakeholder.schema.json +0 -0
  61. {docassert-0.20.1 → docassert-1.0.0}/docassert/_data/schema/release-cutover-plan.schema.json +0 -0
  62. {docassert-0.20.1 → docassert-1.0.0}/docassert/_data/schema/risk-register.schema.json +0 -0
  63. {docassert-0.20.1 → docassert-1.0.0}/docassert/_data/schema/rollback-plan.schema.json +0 -0
  64. {docassert-0.20.1 → docassert-1.0.0}/docassert/_data/schema/runbook.schema.json +0 -0
  65. {docassert-0.20.1 → docassert-1.0.0}/docassert/_data/schema/status-report.schema.json +0 -0
  66. {docassert-0.20.1 → docassert-1.0.0}/docassert/_data/schema/test-cases.schema.json +0 -0
  67. {docassert-0.20.1 → docassert-1.0.0}/docassert/_data/schema/user-story.schema.json +0 -0
  68. {docassert-0.20.1 → docassert-1.0.0}/docassert/_data/skills/doc-to-pmo/SKILL.md +0 -0
  69. {docassert-0.20.1 → docassert-1.0.0}/docassert/_data/templates/adr.template.md +0 -0
  70. {docassert-0.20.1 → docassert-1.0.0}/docassert/_data/templates/benefits-realization.template.md +0 -0
  71. {docassert-0.20.1 → docassert-1.0.0}/docassert/_data/templates/brd.template.md +0 -0
  72. {docassert-0.20.1 → docassert-1.0.0}/docassert/_data/templates/business-case.template.md +0 -0
  73. {docassert-0.20.1 → docassert-1.0.0}/docassert/_data/templates/charter.template.md +0 -0
  74. {docassert-0.20.1 → docassert-1.0.0}/docassert/_data/templates/data-migration-plan.template.md +0 -0
  75. {docassert-0.20.1 → docassert-1.0.0}/docassert/_data/templates/frnfr.template.md +0 -0
  76. {docassert-0.20.1 → docassert-1.0.0}/docassert/_data/templates/hypercare-plan.template.md +0 -0
  77. {docassert-0.20.1 → docassert-1.0.0}/docassert/_data/templates/operations.template.md +0 -0
  78. {docassert-0.20.1 → docassert-1.0.0}/docassert/_data/templates/post-implementation-review.template.md +0 -0
  79. {docassert-0.20.1 → docassert-1.0.0}/docassert/_data/templates/prd.template.md +0 -0
  80. {docassert-0.20.1 → docassert-1.0.0}/docassert/_data/templates/project.template.md +0 -0
  81. {docassert-0.20.1 → docassert-1.0.0}/docassert/_data/templates/qa-test-plan.template.md +0 -0
  82. {docassert-0.20.1 → docassert-1.0.0}/docassert/_data/templates/raci-stakeholder.template.md +0 -0
  83. {docassert-0.20.1 → docassert-1.0.0}/docassert/_data/templates/release-cutover-plan.template.md +0 -0
  84. {docassert-0.20.1 → docassert-1.0.0}/docassert/_data/templates/risk-register.template.md +0 -0
  85. {docassert-0.20.1 → docassert-1.0.0}/docassert/_data/templates/rollback-plan.template.md +0 -0
  86. {docassert-0.20.1 → docassert-1.0.0}/docassert/_data/templates/runbook.template.md +0 -0
  87. {docassert-0.20.1 → docassert-1.0.0}/docassert/_data/templates/status-report.template.md +0 -0
  88. {docassert-0.20.1 → docassert-1.0.0}/docassert/_data/templates/test-cases.template.md +0 -0
  89. {docassert-0.20.1 → docassert-1.0.0}/docassert/_data/templates/user-story.template.md +0 -0
  90. {docassert-0.20.1 → docassert-1.0.0}/docassert/bridge/__init__.py +0 -0
  91. {docassert-0.20.1 → docassert-1.0.0}/docassert/bridge/gh.py +0 -0
  92. {docassert-0.20.1 → docassert-1.0.0}/docassert/extract.py +0 -0
  93. {docassert-0.20.1 → docassert-1.0.0}/docassert/graph.py +0 -0
  94. {docassert-0.20.1 → docassert-1.0.0}/docassert/loader.py +0 -0
  95. {docassert-0.20.1 → docassert-1.0.0}/docassert/models.py +0 -0
  96. {docassert-0.20.1 → docassert-1.0.0}/docassert/projects.py +0 -0
  97. {docassert-0.20.1 → docassert-1.0.0}/docassert/report.py +0 -0
  98. {docassert-0.20.1 → docassert-1.0.0}/docassert/rtm.py +0 -0
  99. {docassert-0.20.1 → docassert-1.0.0}/docassert/scaffold.py +0 -0
  100. {docassert-0.20.1 → docassert-1.0.0}/docassert/semantic.py +0 -0
  101. {docassert-0.20.1 → docassert-1.0.0}/docassert/structural.py +0 -0
  102. {docassert-0.20.1 → docassert-1.0.0}/docassert.egg-info/dependency_links.txt +0 -0
  103. {docassert-0.20.1 → docassert-1.0.0}/docassert.egg-info/entry_points.txt +0 -0
  104. {docassert-0.20.1 → docassert-1.0.0}/docassert.egg-info/requires.txt +0 -0
  105. {docassert-0.20.1 → docassert-1.0.0}/docassert.egg-info/top_level.txt +0 -0
  106. {docassert-0.20.1 → docassert-1.0.0}/setup.cfg +0 -0
  107. {docassert-0.20.1 → docassert-1.0.0}/tests/test_badge.py +0 -0
  108. {docassert-0.20.1 → docassert-1.0.0}/tests/test_bridge.py +0 -0
  109. {docassert-0.20.1 → docassert-1.0.0}/tests/test_config.py +0 -0
  110. {docassert-0.20.1 → docassert-1.0.0}/tests/test_consistency.py +0 -0
  111. {docassert-0.20.1 → docassert-1.0.0}/tests/test_defects.py +0 -0
  112. {docassert-0.20.1 → docassert-1.0.0}/tests/test_draft_relaxation.py +0 -0
  113. {docassert-0.20.1 → docassert-1.0.0}/tests/test_extract.py +0 -0
  114. {docassert-0.20.1 → docassert-1.0.0}/tests/test_graph.py +0 -0
  115. {docassert-0.20.1 → docassert-1.0.0}/tests/test_json_report.py +0 -0
  116. {docassert-0.20.1 → docassert-1.0.0}/tests/test_kinds_delivery.py +0 -0
  117. {docassert-0.20.1 → docassert-1.0.0}/tests/test_kinds_governance.py +0 -0
  118. {docassert-0.20.1 → docassert-1.0.0}/tests/test_kinds_operate.py +0 -0
  119. {docassert-0.20.1 → docassert-1.0.0}/tests/test_kinds_reporting.py +0 -0
  120. {docassert-0.20.1 → docassert-1.0.0}/tests/test_profiles.py +0 -0
  121. {docassert-0.20.1 → docassert-1.0.0}/tests/test_projects.py +0 -0
  122. {docassert-0.20.1 → docassert-1.0.0}/tests/test_report_extract.py +0 -0
  123. {docassert-0.20.1 → docassert-1.0.0}/tests/test_scaffold.py +0 -0
  124. {docassert-0.20.1 → docassert-1.0.0}/tests/test_semantic.py +0 -0
  125. {docassert-0.20.1 → docassert-1.0.0}/tests/test_status.py +0 -0
  126. {docassert-0.20.1 → docassert-1.0.0}/tests/test_structural.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: docassert
3
- Version: 0.20.1
3
+ Version: 1.0.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
@@ -8,7 +8,7 @@ Project-URL: Homepage, https://docassert.com
8
8
  Project-URL: Repository, https://github.com/c4g-john/docassert
9
9
  Project-URL: Issues, https://github.com/c4g-john/docassert/issues
10
10
  Keywords: pmo,documentation,validation,governance,markdown,audit,traceability,ci
11
- Classifier: Development Status :: 4 - Beta
11
+ Classifier: Development Status :: 5 - Production/Stable
12
12
  Classifier: Intended Audience :: Developers
13
13
  Classifier: License :: OSI Approved :: Apache Software License
14
14
  Classifier: Programming Language :: Python :: 3
@@ -16,6 +16,7 @@ Classifier: Programming Language :: Python :: 3.10
16
16
  Classifier: Programming Language :: Python :: 3.11
17
17
  Classifier: Programming Language :: Python :: 3.12
18
18
  Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Programming Language :: Python :: 3.14
19
20
  Classifier: Topic :: Software Development :: Quality Assurance
20
21
  Classifier: Topic :: Software Development :: Documentation
21
22
  Classifier: Topic :: Text Processing :: Markup :: Markdown
@@ -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.20.1"
8
+ __version__ = "1.0.0"
@@ -15,9 +15,7 @@ FIELD_SPECS: dict[str, dict] = {
15
15
  "PMO Project": {"dataType": "TEXT"},
16
16
  }
17
17
 
18
- _FIELDS_QUERY = """
19
- query($owner: String!, $number: Int!) {
20
- user(login: $owner) {
18
+ _PROJECT_FRAGMENT = """
21
19
  projectV2(number: $number) {
22
20
  id
23
21
  title
@@ -28,8 +26,18 @@ query($owner: String!, $number: Int!) {
28
26
  }
29
27
  }
30
28
  }
31
- }
32
- }
29
+ """
30
+
31
+ _FIELDS_QUERY = f"""
32
+ query($owner: String!, $number: Int!) {{
33
+ user(login: $owner) {{ {_PROJECT_FRAGMENT} }}
34
+ }}
35
+ """
36
+
37
+ _FIELDS_QUERY_ORG = f"""
38
+ query($owner: String!, $number: Int!) {{
39
+ organization(login: $owner) {{ {_PROJECT_FRAGMENT} }}
40
+ }}
33
41
  """
34
42
 
35
43
 
@@ -48,7 +56,23 @@ def create_project(gh: GhRunner, title: str) -> dict:
48
56
 
49
57
 
50
58
  def get_project(gh: GhRunner, owner: str, number: int) -> dict:
51
- proj = gh.graphql(_FIELDS_QUERY, owner=owner, number=number)["user"]["projectV2"]
59
+ """Look the board up under a user owner first, then an organization —
60
+ GraphQL has no owner-agnostic ProjectV2 lookup, and a login of the wrong
61
+ type raises NOT_FOUND rather than returning null."""
62
+ proj = None
63
+ try:
64
+ proj = (gh.graphql(_FIELDS_QUERY, owner=owner, number=number)
65
+ .get("user") or {}).get("projectV2")
66
+ except GhError:
67
+ pass
68
+ if proj is None:
69
+ try:
70
+ proj = (gh.graphql(_FIELDS_QUERY_ORG, owner=owner, number=number)
71
+ .get("organization") or {}).get("projectV2")
72
+ except GhError:
73
+ pass
74
+ if proj is None:
75
+ raise GhError(f"no ProjectV2 #{number} owned by '{owner}' (user or organization)")
52
76
  fields = {}
53
77
  for node in proj["fields"]["nodes"]:
54
78
  if not node:
@@ -37,10 +37,16 @@ def _marker_of(issue: dict) -> str | None:
37
37
 
38
38
 
39
39
  def _index(issues: list[dict]) -> dict[str, dict]:
40
+ """marker -> its authoritative issue. The REST API lists issues newest
41
+ first, and a converged duplicate is always newer than the issue it lost
42
+ to — so rank open before closed, then lowest number, or a closed
43
+ duplicate would shadow the real open issue and report it as done."""
44
+ def rank(issue: dict) -> tuple:
45
+ return (issue.get("state") != "open", issue.get("number", 0))
40
46
  out: dict[str, dict] = {}
41
47
  for i in issues:
42
48
  key = _marker_of(i)
43
- if key and key not in out: # first (oldest) wins
49
+ if key and (key not in out or rank(i) < rank(out[key])):
44
50
  out[key] = i
45
51
  return out
46
52
 
@@ -186,8 +186,11 @@ def plans_by_repo(plan: BridgePlan) -> dict[str, BridgePlan]:
186
186
  + " (add `repo: OWNER/NAME` to project.md or pass --repo)")
187
187
  groups: dict[str, BridgePlan] = {}
188
188
  for proj in plan.projects:
189
- groups.setdefault(proj["repo"],
190
- BridgePlan(skipped=plan.skipped)).projects.append(proj)
189
+ if proj["repo"] not in groups:
190
+ # Each sub-plan gets its own copy: shared state across repos would
191
+ # let one repo's run mutate another's, and skip lines print per repo.
192
+ groups[proj["repo"]] = BridgePlan(skipped=list(plan.skipped))
193
+ groups[proj["repo"]].projects.append(proj)
191
194
  return groups
192
195
 
193
196
 
@@ -54,7 +54,13 @@ def _build_id_index(documents_dir: Path) -> dict[str, list[str]]:
54
54
 
55
55
  def _validate_one(path: str, id_index: dict) -> list[CheckResult]:
56
56
  doc = load(path)
57
- kind = doc.kind or "charter"
57
+ kind = doc.kind or ""
58
+ if not kind:
59
+ # Refuse to guess: validating an unkinded document against an arbitrary
60
+ # kind's criteria would produce misleading failures (or worse, passes).
61
+ return [CheckResult("kind", False, True,
62
+ "document declares no `kind` in its frontmatter",
63
+ kind="structural")]
58
64
  criteria = config.read_criteria(kind)
59
65
  schema = config.read_schema(kind)
60
66
 
@@ -237,8 +243,14 @@ def cmd_pages(args: argparse.Namespace) -> int:
237
243
  import json as _json
238
244
  data = _json.loads(Path(args.execution).read_text())
239
245
  for proj in data.get("projects", []):
240
- execution[proj["id"]] = {**proj, "scope": data.get("scope"),
241
- "repo": proj.get("repo") or data.get("repo")}
246
+ repo = proj.get("repo") or data.get("repo")
247
+ # Scope findings belong to the repo a project routes to; showing
248
+ # another repo's strays on this project's page would misattribute.
249
+ scope = data.get("scope") or {}
250
+ scoped = {k: [i for i in scope.get(k, [])
251
+ if not repo or i.get("repo") in (None, repo)]
252
+ for k in ("unverified", "orphaned")}
253
+ execution[proj["id"]] = {**proj, "scope": scoped, "repo": repo}
242
254
 
243
255
  def _activity(project_id: str) -> list[dict]:
244
256
  """Recent documents/ commits for one project (best-effort, git only)."""
@@ -369,6 +381,10 @@ def cmd_bridge(args: argparse.Namespace) -> int:
369
381
  "(needed for --project-number)", file=sys.stderr)
370
382
  return 2
371
383
  bgh = gh if isinstance(gh, ghmod.DryRunner) else ghmod.GhRunner(token=token)
384
+ if not args.project_owner and not repo_plans:
385
+ print("docassert bridge: --project-number needs --project-owner when "
386
+ "no project routes to a repository", file=sys.stderr)
387
+ return 2
372
388
  owner = args.project_owner or next(iter(repo_plans)).split("/")[0]
373
389
  board_cfg = {"gh": bgh, "owner": owner,
374
390
  "number": args.project_number, "init": args.init_board}
@@ -12,6 +12,7 @@ from __future__ import annotations
12
12
 
13
13
  import json
14
14
  import shutil
15
+ from functools import lru_cache
15
16
  from importlib.resources import files
16
17
  from pathlib import Path
17
18
 
@@ -30,8 +31,22 @@ def _resolve(local: Path, packaged_rel: str) -> Path | None:
30
31
  return packaged if packaged.is_file() else None
31
32
 
32
33
 
34
+ # Criteria and schemas are read once per check per document, which multiplies
35
+ # fast on a portfolio (projects × documents × checks). Cache on (path, mtime)
36
+ # so an edit mid-process still invalidates. Cached objects are shared: callers
37
+ # treat them as read-only.
38
+ @lru_cache(maxsize=256)
39
+ def _read_yaml_cached(path_str: str, mtime_ns: int) -> dict:
40
+ return yaml.safe_load(Path(path_str).read_text(encoding="utf-8")) or {}
41
+
42
+
43
+ @lru_cache(maxsize=256)
44
+ def _read_json_cached(path_str: str, mtime_ns: int) -> dict:
45
+ return json.loads(Path(path_str).read_text(encoding="utf-8"))
46
+
47
+
33
48
  def _read_yaml(path: Path) -> dict:
34
- return yaml.safe_load(path.read_text(encoding="utf-8")) or {}
49
+ return _read_yaml_cached(str(path), path.stat().st_mtime_ns)
35
50
 
36
51
 
37
52
  # ── criteria ────────────────────────────────────────────────────────────────
@@ -57,7 +72,7 @@ def read_schema(kind: str) -> dict:
57
72
  path = _resolve(Path("schema") / f"{kind}.schema.json", f"schema/{kind}.schema.json")
58
73
  if path is None:
59
74
  raise FileNotFoundError(f"no schema for kind '{kind}'")
60
- return json.loads(path.read_text(encoding="utf-8"))
75
+ return _read_json_cached(str(path), path.stat().st_mtime_ns)
61
76
 
62
77
 
63
78
  # ── consistency config ──────────────────────────────────────────────────────
@@ -3,10 +3,13 @@
3
3
  Structural checks are deterministic and blocking:
4
4
  - item-id-uniqueness : IDs unique across the repo (always blocks)
5
5
  - referential-integrity: every link target exists (always blocks)
6
+ - sequence-acyclic : the `after` digraph has no cycle (always blocks)
6
7
  - required-links : downstream items declare their upstream link
7
8
  (blocks only when the item's doc is approved)
8
9
  - coverage : every parent item has >=1 downstream child
9
10
  (blocks only when the parent's doc is approved)
11
+ - profile-completeness : enforced projects carry their profile's required
12
+ documents (blocks when enforced or profile unknown)
10
13
 
11
14
  Semantic alignment is AI-graded and advisory (never blocks).
12
15
  """
@@ -50,31 +53,40 @@ def check_unique_item_ids(graph) -> CheckResult:
50
53
 
51
54
 
52
55
  def check_sequence_acyclic(graph) -> CheckResult:
53
- """The `after` digraph contains no cycle (a cycle is broken sequencing)."""
56
+ """The `after` digraph contains no cycle (a cycle is broken sequencing).
57
+
58
+ Iterative three-color DFS: a dependency chain as long as the item count
59
+ must not hit the interpreter recursion limit.
60
+ """
54
61
  edges: dict[str, list[str]] = {}
55
62
  for item in graph.all_items():
56
63
  for target in item.links.get("after", []):
57
64
  edges.setdefault(item.id, []).append(target)
58
- state: dict[str, int] = {} # 0 visiting, 1 done
59
-
60
- def visit(node, path):
61
- if state.get(node) == 1:
62
- return None
63
- if state.get(node) == 0:
64
- return path[path.index(node):] + [node]
65
- state[node] = 0
66
- for nxt in edges.get(node, []):
67
- cycle = visit(nxt, path + [node])
68
- if cycle:
69
- return cycle
70
- state[node] = 1
71
- return None
72
-
65
+ WHITE, GRAY, BLACK = 0, 1, 2
66
+ color: dict[str, int] = {}
73
67
  for start in list(edges):
74
- cycle = visit(start, [])
75
- if cycle:
76
- return CheckResult("sequence-acyclic", False, True,
77
- "after-cycle: " + " → ".join(cycle))
68
+ if color.get(start, WHITE) != WHITE:
69
+ continue
70
+ color[start] = GRAY
71
+ path = [start]
72
+ stack = [iter(edges.get(start, ()))]
73
+ while stack:
74
+ advanced = False
75
+ for nxt in stack[-1]:
76
+ c = color.get(nxt, WHITE)
77
+ if c == GRAY:
78
+ cycle = path[path.index(nxt):] + [nxt]
79
+ return CheckResult("sequence-acyclic", False, True,
80
+ "after-cycle: " + " → ".join(cycle))
81
+ if c == WHITE:
82
+ color[nxt] = GRAY
83
+ path.append(nxt)
84
+ stack.append(iter(edges.get(nxt, ())))
85
+ advanced = True
86
+ break
87
+ if not advanced:
88
+ color[path.pop()] = BLACK
89
+ stack.pop()
78
90
  n = sum(len(v) for v in edges.values())
79
91
  return CheckResult("sequence-acyclic", True, True,
80
92
  f"{n} after link(s), no cycles.")
@@ -2,8 +2,10 @@
2
2
 
3
3
  A profile (profiles/<name>.yaml) lists the document kinds a project is expected
4
4
  to carry, at two levels:
5
- - required : must be present and complete; a missing one blocks CI once the
6
- project reaches the profile's `enforce_when` lifecycle stage.
5
+ - required : must be present and complete; a missing one blocks CI while
6
+ the project's status equals the profile's `enforce_when`
7
+ stage (deliberately exact: an on-hold or closed project is
8
+ not punished for its document set).
7
9
  - recommended : surfaced as a gap on the project page, but never blocking.
8
10
 
9
11
  A project opts in with `profile: <name>` in its project.md. No profile means no
@@ -8,6 +8,7 @@ from .derive import (
8
8
  build_status,
9
9
  completeness_report,
10
10
  derive_rag,
11
+ load_corpus,
11
12
  )
12
13
  from .render import (
13
14
  render_badge_json,
@@ -20,7 +21,7 @@ from .render import (
20
21
 
21
22
  __all__ = [
22
23
  "DOCUMENTS_DIR", "APPROVED",
23
- "build_status", "build_index", "completeness_report", "derive_rag",
24
+ "build_status", "build_index", "completeness_report", "derive_rag", "load_corpus",
24
25
  "render_markdown", "render_json", "render_html",
25
26
  "render_index_markdown", "render_index_html", "render_badge_json",
26
27
  ]
@@ -234,16 +234,25 @@ def _latest_report(docs):
234
234
 
235
235
 
236
236
  # ── the model + derived RAG ─────────────────────────────────────────────────
237
- def build_status(documents_dir=DOCUMENTS_DIR, project: str | None = None) -> dict:
237
+ def load_corpus(documents_dir=DOCUMENTS_DIR) -> tuple[list, object, dict]:
238
+ """(all documents, item graph, consistency config) for one tree — loaded
239
+ once and shared across the per-project derivations of an index or pages
240
+ build via build_status(..., _corpus=...)."""
241
+ all_docs = [load(p) for p in sorted(Path(documents_dir).rglob("*.md"))]
242
+ graph = build_graph(documents_dir)
243
+ cfg = config_mod.read_consistency_config()
244
+ return all_docs, graph, cfg
245
+
246
+
247
+ def build_status(documents_dir=DOCUMENTS_DIR, project: str | None = None,
248
+ *, _corpus=None) -> dict:
238
249
  """Derive the status model for the whole repo, or for one project.
239
250
 
240
251
  `project` is a canonical project id (PRJ-NNN-CODE). When given, documents,
241
252
  coverage, risks, broken references and the latest report are all scoped to
242
253
  that project; the graph stays global so cross-project targets still resolve.
243
254
  """
244
- all_docs = [load(p) for p in sorted(Path(documents_dir).rglob("*.md"))]
245
- graph = build_graph(documents_dir)
246
- cfg = config_mod.read_consistency_config()
255
+ all_docs, graph, cfg = _corpus if _corpus else load_corpus(documents_dir)
247
256
 
248
257
  code = project.split("-")[-1] if project else None
249
258
  if project:
@@ -314,9 +323,11 @@ def completeness_report(documents_dir=DOCUMENTS_DIR) -> list[dict]:
314
323
  """Per-project completeness for every profiled project (used by the
315
324
  blocking profile-completeness consistency check)."""
316
325
  from .. import projects as projects_mod
326
+ corpus = load_corpus(documents_dir)
317
327
  out = []
318
328
  for p in projects_mod.load_projects(documents_dir):
319
- comp = build_status(documents_dir, project=p["id"]).get("completeness")
329
+ comp = build_status(documents_dir, project=p["id"],
330
+ _corpus=corpus).get("completeness")
320
331
  if comp:
321
332
  out.append({"id": p["id"], "name": p["name"], "lifecycle": p["status"], **comp})
322
333
  return out
@@ -339,9 +350,10 @@ def build_index(documents_dir=DOCUMENTS_DIR) -> dict:
339
350
  """The multi-project view: each project's derived RAG + headline signals,
340
351
  plus the whole-repo rollup."""
341
352
  from .. import projects as projects_mod
353
+ corpus = load_corpus(documents_dir)
342
354
  cards = []
343
355
  for p in projects_mod.load_projects(documents_dir):
344
- m = build_status(documents_dir, project=p["id"])
356
+ m = build_status(documents_dir, project=p["id"], _corpus=corpus)
345
357
  cards.append({
346
358
  "id": p["id"], "code": p["code"], "name": p["name"],
347
359
  "sponsor": p["sponsor"], "lifecycle": p["status"],
@@ -357,7 +369,8 @@ def build_index(documents_dir=DOCUMENTS_DIR) -> dict:
357
369
  "broken": len(m["broken_references"]),
358
370
  "completeness": m.get("completeness"),
359
371
  })
360
- return {"projects": cards, "overall": build_status(documents_dir)}
372
+ return {"projects": cards,
373
+ "overall": build_status(documents_dir, _corpus=corpus)}
361
374
 
362
375
 
363
376
  def derive_rag(model) -> str:
@@ -48,8 +48,10 @@ def render_markdown(model, summary: bool = False) -> str:
48
48
  f"{comp['required_complete']}/{comp['required_total']} complete{detail}")
49
49
 
50
50
  if model["broken_references"]:
51
+ shown = ", ".join(model["broken_references"][:3])
52
+ more = "…" if len(model["broken_references"]) > 3 else ""
51
53
  out.append(f"- 🔴 **Broken references**: {len(model['broken_references'])} "
52
- f"({', '.join(model['broken_references'][:3])}…)")
54
+ f"({shown}{more})")
53
55
  failing = [d["id"] for d in model["documents"] if not d["passing"]]
54
56
  if failing:
55
57
  out.append(f"- 🔴 **Documents failing audit**: {', '.join(failing)}")
@@ -142,8 +144,35 @@ def _today():
142
144
  return datetime.date.today()
143
145
 
144
146
 
147
+ def _cap(s: str) -> str:
148
+ """Uppercase the first character only (str.capitalize lowercases the rest)."""
149
+ return s[:1].upper() + s[1:]
150
+
151
+
152
+ def _amber_completeness_causes(comp) -> list[str]:
153
+ """The non-blocking profile gaps that amber the derived RAG."""
154
+ if not comp:
155
+ return []
156
+ if comp.get("unknown"):
157
+ return [f"profile '{comp['profile']}' not found under profiles/"]
158
+ causes = []
159
+ if comp["missing_required"]:
160
+ causes.append(f"{len(comp['missing_required'])} required document kind(s) "
161
+ f"missing ({', '.join(comp['missing_required'])})")
162
+ if comp["incomplete_required"]:
163
+ causes.append(f"{len(comp['incomplete_required'])} required document kind(s) "
164
+ f"not yet complete ({', '.join(comp['incomplete_required'])})")
165
+ if comp.get("recommended_gaps"):
166
+ causes.append(f"{len(comp['recommended_gaps'])} recommended document kind(s) open")
167
+ return causes
168
+
169
+
145
170
  def _verdict_causes(model) -> list[str]:
146
- """The raw cause strings behind the verdict (also the verdict tooltip)."""
171
+ """The raw cause strings behind the verdict (also the verdict tooltip).
172
+
173
+ Mirrors derive_rag exactly: every condition that can amber or red the RAG
174
+ appears here, so an amber page can never claim it has no recorded causes.
175
+ """
147
176
  causes = []
148
177
  gaps = [c for c in model["coverage"] if c["gaps"]]
149
178
  if gaps:
@@ -156,6 +185,8 @@ def _verdict_causes(model) -> list[str]:
156
185
  comp = model.get("completeness")
157
186
  if comp and comp.get("blocks"):
158
187
  causes.append("required documents missing under an enforced profile")
188
+ else:
189
+ causes.extend(_amber_completeness_causes(comp))
159
190
  thr = model.get("risk_amber_score", 6)
160
191
  open_r = [r for r in model["risks"] if r.get("disposition", "open") == "open"
161
192
  and (thr == 0 or r.get("score", 0) >= thr)]
@@ -164,6 +195,9 @@ def _verdict_causes(model) -> list[str]:
164
195
  stale = [o for o in model.get("operations", []) if not o["fresh"]]
165
196
  if stale:
166
197
  causes.append(f"operations review overdue since {stale[0]['review_by']}")
198
+ reported = (model.get("latest_report") or {}).get("rag")
199
+ if reported in ("amber", "red"):
200
+ causes.append(f"latest status report recorded {reported}")
167
201
  return causes
168
202
 
169
203
 
@@ -176,35 +210,48 @@ def _verdict(model) -> str:
176
210
  causes_amber.append(f"{len(gaps)} coverage chain(s) carry gaps")
177
211
  else:
178
212
  goods.append("coverage is complete")
179
- failing = [d for d in model["documents"] if not d["passing"]]
180
- if failing:
181
- causes_red.append(f"{len(failing)} document(s) fail audit")
213
+ failing_approved = [d for d in model["documents"]
214
+ if not d["passing"] and d["status"] in ("approved", "baselined")]
215
+ failing_draft = [d for d in model["documents"]
216
+ if not d["passing"] and d["status"] not in ("approved", "baselined")]
217
+ if failing_approved:
218
+ causes_red.append(f"{len(failing_approved)} approved document(s) fail audit")
182
219
  else:
183
- goods.append("every document passes audit")
220
+ goods.append("every approved document passes audit")
184
221
  if model["broken_references"]:
185
222
  causes_red.append(f"{len(model['broken_references'])} reference(s) are broken")
186
223
  comp = model.get("completeness")
187
224
  if comp and comp.get("blocks"):
188
225
  causes_red.append("required documents are missing under an enforced profile")
189
- open_r = [r for r in model["risks"] if r.get("disposition", "open") == "open"]
190
- hi = [r for r in open_r if r.get("impact") in ("high", "critical")]
191
- if open_r:
192
- clause = f"{len(open_r)} risk(s) stay open"
226
+ else:
227
+ causes_amber.extend(_amber_completeness_causes(comp))
228
+ thr = model.get("risk_amber_score", 6)
229
+ open_thr = [r for r in model["risks"] if r.get("disposition", "open") == "open"
230
+ and (thr == 0 or r.get("score", 0) >= thr)]
231
+ hi = [r for r in open_thr if r.get("impact") in ("high", "critical")]
232
+ if open_thr:
233
+ clause = f"{len(open_thr)} risk(s) at or above the appetite stay open"
193
234
  if hi:
194
235
  clause += f", {len(hi)} of them high-impact"
195
236
  causes_amber.append(clause)
196
237
  stale = [o for o in model.get("operations", []) if not o["fresh"]]
197
238
  if stale:
198
239
  causes_amber.append(f"the operations review is overdue since {stale[0]['review_by']}")
240
+ reported = (model.get("latest_report") or {}).get("rag")
241
+ if reported in ("amber", "red"):
242
+ causes_amber.append(f"the latest status report recorded {reported}")
243
+ draft_note = (f" ({len(failing_draft)} draft document(s) still failing checks — "
244
+ "work in progress, never punished)" if failing_draft else "")
199
245
  if rag == "green":
200
- return ("Coverage is complete, every document passes audit, and no open risk "
201
- "or overdue review stands against this project. Green is earned, not assumed.")
246
+ return ("Coverage is complete, every approved document passes audit, and no "
247
+ "open risk or overdue review stands against this project"
248
+ + draft_note + ". Green is earned, not assumed.")
202
249
  if rag == "red":
203
- return (" and ".join(causes_red).capitalize()
250
+ return (_cap(" and ".join(causes_red))
204
251
  + " — red means something is objectively broken, and the gates say what.")
205
- lead = " and ".join(goods[:2]).capitalize() if goods else "The structure holds"
252
+ lead = _cap(" and ".join(goods[:2])) if goods else "The structure holds"
206
253
  tail = "; ".join(causes_amber) if causes_amber else "signals carry residual risk"
207
- return f"{lead} — but {tail}. Amber is a recorded state, not neglect."
254
+ return f"{lead} — but {tail}{draft_note}. Amber is a recorded state, not neglect."
208
255
 
209
256
 
210
257
  def _days_label(days: int) -> str:
@@ -215,18 +262,25 @@ def _days_label(days: int) -> str:
215
262
  return f"IN {days} DAYS"
216
263
 
217
264
 
265
+ def _days_until(date_str) -> int | None:
266
+ """Days from today to an ISO date string, or None when it isn't one.
267
+ Every date that reaches the renderer goes through this: derive tolerates
268
+ malformed frontmatter (e.g. review_by '2026-13-45'), so rendering must too."""
269
+ import datetime
270
+ try:
271
+ return (datetime.date.fromisoformat(str(date_str)) - _today()).days
272
+ except (TypeError, ValueError):
273
+ return None
274
+
275
+
218
276
  def _next_marker(model):
219
277
  """Nearest upcoming dated marker: milestone or operations review."""
220
278
  cands = [(m["days"], m["label"], m["date"]) for m in model.get("milestones", [])
221
279
  if m["days"] >= 0]
222
280
  for o in model.get("operations", []):
223
- try:
224
- import datetime
225
- d = (datetime.date.fromisoformat(o["review_by"]) - _today()).days
226
- if d >= 0:
227
- cands.append((d, "Operations review", o["review_by"]))
228
- except ValueError:
229
- pass
281
+ d = _days_until(o["review_by"])
282
+ if d is not None and d >= 0:
283
+ cands.append((d, "Operations review", o["review_by"]))
230
284
  return min(cands) if cands else None
231
285
 
232
286
 
@@ -237,18 +291,18 @@ def _pct(d, w0, span):
237
291
 
238
292
 
239
293
  def _json_embed(data) -> str:
240
- return json.dumps(data).replace("</", "<\\\\/")
294
+ """JSON for an inline <script>, with `</` split so the parser can never
295
+ see `</script>`. JSON reads `\\/` as `/`, so the payload is lossless."""
296
+ return json.dumps(data).replace("</", "<\\/")
241
297
 
242
298
 
243
299
  def render_html(model) -> str:
244
- import datetime
245
300
  rag = model["rag"]
246
301
  c = model["counts"]
247
302
  rc, rt = _RAGC[rag], _RAGT[rag]
248
303
  title = esc(model.get("title", "Project Status"))
249
304
  pid = model.get("project") or ""
250
305
  gen = _now_utc()
251
- today = _today()
252
306
 
253
307
  passing = sum(1 for d in model["documents"] if d["passing"])
254
308
  open_r = [r for r in model["risks"] if r.get("disposition", "open") == "open"]
@@ -281,11 +335,11 @@ def render_html(model) -> str:
281
335
  ("OPEN RISKS", str(len(open_r)), _C["amberT"] if open_r else _C["green"],
282
336
  f"{disp_r} dispositioned" if disp_r else "0 dispositioned"),
283
337
  ("NEXT MILESTONE", (f"{nxt[0]}d" if nxt else "—"), _C["text"],
284
- (f"{esc(nxt[1])} · {nxt[2]}" if nxt else "no dated milestone")),
338
+ (f"{nxt[1]} · {nxt[2]}" if nxt else "no dated milestone")),
285
339
  ("DOC SET", docset_v, docset_c, docset_sub),
286
340
  ]
287
341
  stat_tips = {
288
- "HEALTH": "Derived RAG. Red: failing approved docs, broken references, or enforced profile gaps. Amber: coverage gaps, open risks at or above the risk appetite, stale operations review, or an amber/red status report. Green: none of those.",
342
+ "HEALTH": "Derived RAG. Red: failing approved docs, broken references, or enforced profile gaps. Amber: coverage gaps, open risks at or above the risk appetite, stale operations review, an amber/red status report, or profile completeness gaps (required kinds missing/incomplete, recommended kinds open, unknown profile). Green: none of those.",
289
343
  "DOCS APPROVED": "Documents with approved or baselined status, of all documents in this project. The sub-line counts documents passing every blocking structural check.",
290
344
  "COVERAGE": "Traceability links present over links required, across every configured chain (BR→PR, PR→AC, AC→TC, and any others).",
291
345
  "OPEN RISKS": "Risks with disposition open. Dispositioned risks (mitigated, accepted, closed) stay on the register as record but do not count here.",
@@ -297,7 +351,7 @@ def render_html(model) -> str:
297
351
  f'<div class="chiplabel" style="margin-bottom:14px;">{k}</div>'
298
352
  f'<div style="font-family:Archivo,system-ui,sans-serif;font-weight:800;font-size:30px;letter-spacing:-.02em;'
299
353
  f'line-height:1;color:{col};font-variant-numeric:tabular-nums;">{esc(v)}</div>'
300
- f'<div style="font-size:11px;color:{_C["muted"]};margin-top:9px;line-height:1.4;">{sub}</div></div>'
354
+ f'<div style="font-size:11px;color:{_C["muted"]};margin-top:9px;line-height:1.4;">{esc(sub)}</div></div>'
301
355
  for k, v, col, sub in stats)
302
356
 
303
357
  # ── 01 coverage meters + doc set ────────────────────────────────────────
@@ -358,8 +412,7 @@ def render_html(model) -> str:
358
412
  ms = list(model.get("milestones", []))
359
413
  for o in model.get("operations", []):
360
414
  ms.append({"label": "Operations review", "date": o["review_by"],
361
- "days": (datetime.date.fromisoformat(o["review_by"]) - today).days
362
- if len(o["review_by"]) == 10 else 0,
415
+ "days": _days_until(o["review_by"]) or 0,
363
416
  "when": "upcoming" if o["fresh"] else "elapsed"})
364
417
  ms.sort(key=lambda m: m["date"])
365
418
  timeline_html = ""
@@ -674,7 +727,7 @@ function riskCtl(){
674
727
  el.innerHTML = '<span class="chiplabel" style="margin-right:2px;">SORT</span>';
675
728
  el.appendChild(btn('severity', S.sort==='severity', ()=>{S.sort='severity';render();}));
676
729
  el.appendChild(btn('id', S.sort==='id', ()=>{S.sort='id';render();}));
677
- if(S.sel){ el.appendChild(btn('isolating '+S.sel.split('RISK-')[1]+' ×', true, ()=>{S.sel=null;render();})); }
730
+ if(S.sel){ el.appendChild(btn('isolating '+(S.sel.split('RISK-')[1]||S.sel)+' ×', true, ()=>{S.sel=null;render();})); }
678
731
  }
679
732
 
680
733
  function riskTable(){
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: docassert
3
- Version: 0.20.1
3
+ Version: 1.0.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
@@ -8,7 +8,7 @@ Project-URL: Homepage, https://docassert.com
8
8
  Project-URL: Repository, https://github.com/c4g-john/docassert
9
9
  Project-URL: Issues, https://github.com/c4g-john/docassert/issues
10
10
  Keywords: pmo,documentation,validation,governance,markdown,audit,traceability,ci
11
- Classifier: Development Status :: 4 - Beta
11
+ Classifier: Development Status :: 5 - Production/Stable
12
12
  Classifier: Intended Audience :: Developers
13
13
  Classifier: License :: OSI Approved :: Apache Software License
14
14
  Classifier: Programming Language :: Python :: 3
@@ -16,6 +16,7 @@ Classifier: Programming Language :: Python :: 3.10
16
16
  Classifier: Programming Language :: Python :: 3.11
17
17
  Classifier: Programming Language :: Python :: 3.12
18
18
  Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Programming Language :: Python :: 3.14
19
20
  Classifier: Topic :: Software Development :: Quality Assurance
20
21
  Classifier: Topic :: Software Development :: Documentation
21
22
  Classifier: Topic :: Text Processing :: Markup :: Markdown
@@ -101,6 +101,7 @@ docassert/bridge/plan.py
101
101
  docassert/status/__init__.py
102
102
  docassert/status/derive.py
103
103
  docassert/status/render.py
104
+ tests/test_audit_0_20_2.py
104
105
  tests/test_badge.py
105
106
  tests/test_bridge.py
106
107
  tests/test_config.py