docassert 0.20.0__tar.gz → 0.20.2__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.0/docassert.egg-info → docassert-0.20.2}/PKG-INFO +2 -1
  2. {docassert-0.20.0 → docassert-0.20.2}/docassert/__init__.py +1 -1
  3. {docassert-0.20.0 → docassert-0.20.2}/docassert/bridge/board.py +30 -6
  4. {docassert-0.20.0 → docassert-0.20.2}/docassert/bridge/ops.py +7 -1
  5. {docassert-0.20.0 → docassert-0.20.2}/docassert/bridge/plan.py +5 -2
  6. {docassert-0.20.0 → docassert-0.20.2}/docassert/cli.py +19 -3
  7. {docassert-0.20.0 → docassert-0.20.2}/docassert/config.py +17 -2
  8. {docassert-0.20.0 → docassert-0.20.2}/docassert/consistency.py +32 -20
  9. {docassert-0.20.0 → docassert-0.20.2}/docassert/profiles.py +4 -2
  10. {docassert-0.20.0 → docassert-0.20.2}/docassert/status/__init__.py +2 -1
  11. {docassert-0.20.0 → docassert-0.20.2}/docassert/status/derive.py +20 -7
  12. {docassert-0.20.0 → docassert-0.20.2}/docassert/status/render.py +138 -39
  13. {docassert-0.20.0 → docassert-0.20.2/docassert.egg-info}/PKG-INFO +2 -1
  14. {docassert-0.20.0 → docassert-0.20.2}/docassert.egg-info/SOURCES.txt +1 -0
  15. {docassert-0.20.0 → docassert-0.20.2}/pyproject.toml +1 -0
  16. docassert-0.20.2/tests/test_audit_0_20_2.py +218 -0
  17. {docassert-0.20.0 → docassert-0.20.2}/tests/test_status.py +12 -0
  18. {docassert-0.20.0 → docassert-0.20.2}/LICENSE +0 -0
  19. {docassert-0.20.0 → docassert-0.20.2}/NOTICE +0 -0
  20. {docassert-0.20.0 → docassert-0.20.2}/README.md +0 -0
  21. {docassert-0.20.0 → docassert-0.20.2}/docassert/__main__.py +0 -0
  22. {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/consistency.yaml +0 -0
  23. {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/criteria/adr.criteria.yaml +0 -0
  24. {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/criteria/benefits-realization.criteria.yaml +0 -0
  25. {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/criteria/brd.criteria.yaml +0 -0
  26. {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/criteria/business-case.criteria.yaml +0 -0
  27. {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/criteria/charter.criteria.yaml +0 -0
  28. {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/criteria/data-migration-plan.criteria.yaml +0 -0
  29. {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/criteria/frnfr.criteria.yaml +0 -0
  30. {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/criteria/hypercare-plan.criteria.yaml +0 -0
  31. {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/criteria/operations.criteria.yaml +0 -0
  32. {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/criteria/post-implementation-review.criteria.yaml +0 -0
  33. {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/criteria/prd.criteria.yaml +0 -0
  34. {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/criteria/project.criteria.yaml +0 -0
  35. {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/criteria/qa-test-plan.criteria.yaml +0 -0
  36. {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/criteria/raci-stakeholder.criteria.yaml +0 -0
  37. {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/criteria/release-cutover-plan.criteria.yaml +0 -0
  38. {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/criteria/risk-register.criteria.yaml +0 -0
  39. {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/criteria/rollback-plan.criteria.yaml +0 -0
  40. {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/criteria/runbook.criteria.yaml +0 -0
  41. {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/criteria/status-report.criteria.yaml +0 -0
  42. {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/criteria/test-cases.criteria.yaml +0 -0
  43. {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/criteria/user-story.criteria.yaml +0 -0
  44. {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/profiles/agile-delivery.yaml +0 -0
  45. {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/profiles/lean-startup.yaml +0 -0
  46. {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/profiles/operations.yaml +0 -0
  47. {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/profiles/regulated-industry.yaml +0 -0
  48. {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/schema/adr.schema.json +0 -0
  49. {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/schema/benefits-realization.schema.json +0 -0
  50. {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/schema/brd.schema.json +0 -0
  51. {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/schema/business-case.schema.json +0 -0
  52. {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/schema/charter.schema.json +0 -0
  53. {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/schema/data-migration-plan.schema.json +0 -0
  54. {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/schema/frnfr.schema.json +0 -0
  55. {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/schema/hypercare-plan.schema.json +0 -0
  56. {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/schema/operations.schema.json +0 -0
  57. {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/schema/post-implementation-review.schema.json +0 -0
  58. {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/schema/prd.schema.json +0 -0
  59. {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/schema/project.schema.json +0 -0
  60. {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/schema/qa-test-plan.schema.json +0 -0
  61. {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/schema/raci-stakeholder.schema.json +0 -0
  62. {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/schema/release-cutover-plan.schema.json +0 -0
  63. {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/schema/risk-register.schema.json +0 -0
  64. {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/schema/rollback-plan.schema.json +0 -0
  65. {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/schema/runbook.schema.json +0 -0
  66. {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/schema/status-report.schema.json +0 -0
  67. {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/schema/test-cases.schema.json +0 -0
  68. {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/schema/user-story.schema.json +0 -0
  69. {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/skills/doc-to-pmo/SKILL.md +0 -0
  70. {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/templates/adr.template.md +0 -0
  71. {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/templates/benefits-realization.template.md +0 -0
  72. {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/templates/brd.template.md +0 -0
  73. {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/templates/business-case.template.md +0 -0
  74. {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/templates/charter.template.md +0 -0
  75. {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/templates/data-migration-plan.template.md +0 -0
  76. {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/templates/frnfr.template.md +0 -0
  77. {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/templates/hypercare-plan.template.md +0 -0
  78. {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/templates/operations.template.md +0 -0
  79. {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/templates/post-implementation-review.template.md +0 -0
  80. {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/templates/prd.template.md +0 -0
  81. {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/templates/project.template.md +0 -0
  82. {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/templates/qa-test-plan.template.md +0 -0
  83. {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/templates/raci-stakeholder.template.md +0 -0
  84. {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/templates/release-cutover-plan.template.md +0 -0
  85. {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/templates/risk-register.template.md +0 -0
  86. {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/templates/rollback-plan.template.md +0 -0
  87. {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/templates/runbook.template.md +0 -0
  88. {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/templates/status-report.template.md +0 -0
  89. {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/templates/test-cases.template.md +0 -0
  90. {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/templates/user-story.template.md +0 -0
  91. {docassert-0.20.0 → docassert-0.20.2}/docassert/bridge/__init__.py +0 -0
  92. {docassert-0.20.0 → docassert-0.20.2}/docassert/bridge/gh.py +0 -0
  93. {docassert-0.20.0 → docassert-0.20.2}/docassert/extract.py +0 -0
  94. {docassert-0.20.0 → docassert-0.20.2}/docassert/graph.py +0 -0
  95. {docassert-0.20.0 → docassert-0.20.2}/docassert/loader.py +0 -0
  96. {docassert-0.20.0 → docassert-0.20.2}/docassert/models.py +0 -0
  97. {docassert-0.20.0 → docassert-0.20.2}/docassert/projects.py +0 -0
  98. {docassert-0.20.0 → docassert-0.20.2}/docassert/report.py +0 -0
  99. {docassert-0.20.0 → docassert-0.20.2}/docassert/rtm.py +0 -0
  100. {docassert-0.20.0 → docassert-0.20.2}/docassert/scaffold.py +0 -0
  101. {docassert-0.20.0 → docassert-0.20.2}/docassert/semantic.py +0 -0
  102. {docassert-0.20.0 → docassert-0.20.2}/docassert/structural.py +0 -0
  103. {docassert-0.20.0 → docassert-0.20.2}/docassert.egg-info/dependency_links.txt +0 -0
  104. {docassert-0.20.0 → docassert-0.20.2}/docassert.egg-info/entry_points.txt +0 -0
  105. {docassert-0.20.0 → docassert-0.20.2}/docassert.egg-info/requires.txt +0 -0
  106. {docassert-0.20.0 → docassert-0.20.2}/docassert.egg-info/top_level.txt +0 -0
  107. {docassert-0.20.0 → docassert-0.20.2}/setup.cfg +0 -0
  108. {docassert-0.20.0 → docassert-0.20.2}/tests/test_badge.py +0 -0
  109. {docassert-0.20.0 → docassert-0.20.2}/tests/test_bridge.py +0 -0
  110. {docassert-0.20.0 → docassert-0.20.2}/tests/test_config.py +0 -0
  111. {docassert-0.20.0 → docassert-0.20.2}/tests/test_consistency.py +0 -0
  112. {docassert-0.20.0 → docassert-0.20.2}/tests/test_defects.py +0 -0
  113. {docassert-0.20.0 → docassert-0.20.2}/tests/test_draft_relaxation.py +0 -0
  114. {docassert-0.20.0 → docassert-0.20.2}/tests/test_extract.py +0 -0
  115. {docassert-0.20.0 → docassert-0.20.2}/tests/test_graph.py +0 -0
  116. {docassert-0.20.0 → docassert-0.20.2}/tests/test_json_report.py +0 -0
  117. {docassert-0.20.0 → docassert-0.20.2}/tests/test_kinds_delivery.py +0 -0
  118. {docassert-0.20.0 → docassert-0.20.2}/tests/test_kinds_governance.py +0 -0
  119. {docassert-0.20.0 → docassert-0.20.2}/tests/test_kinds_operate.py +0 -0
  120. {docassert-0.20.0 → docassert-0.20.2}/tests/test_kinds_reporting.py +0 -0
  121. {docassert-0.20.0 → docassert-0.20.2}/tests/test_profiles.py +0 -0
  122. {docassert-0.20.0 → docassert-0.20.2}/tests/test_projects.py +0 -0
  123. {docassert-0.20.0 → docassert-0.20.2}/tests/test_report_extract.py +0 -0
  124. {docassert-0.20.0 → docassert-0.20.2}/tests/test_scaffold.py +0 -0
  125. {docassert-0.20.0 → docassert-0.20.2}/tests/test_semantic.py +0 -0
  126. {docassert-0.20.0 → docassert-0.20.2}/tests/test_structural.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: docassert
3
- Version: 0.20.0
3
+ Version: 0.20.2
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
@@ -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.0"
8
+ __version__ = "0.20.2"
@@ -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: