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.
- {docassert-0.20.0/docassert.egg-info → docassert-0.20.2}/PKG-INFO +2 -1
- {docassert-0.20.0 → docassert-0.20.2}/docassert/__init__.py +1 -1
- {docassert-0.20.0 → docassert-0.20.2}/docassert/bridge/board.py +30 -6
- {docassert-0.20.0 → docassert-0.20.2}/docassert/bridge/ops.py +7 -1
- {docassert-0.20.0 → docassert-0.20.2}/docassert/bridge/plan.py +5 -2
- {docassert-0.20.0 → docassert-0.20.2}/docassert/cli.py +19 -3
- {docassert-0.20.0 → docassert-0.20.2}/docassert/config.py +17 -2
- {docassert-0.20.0 → docassert-0.20.2}/docassert/consistency.py +32 -20
- {docassert-0.20.0 → docassert-0.20.2}/docassert/profiles.py +4 -2
- {docassert-0.20.0 → docassert-0.20.2}/docassert/status/__init__.py +2 -1
- {docassert-0.20.0 → docassert-0.20.2}/docassert/status/derive.py +20 -7
- {docassert-0.20.0 → docassert-0.20.2}/docassert/status/render.py +138 -39
- {docassert-0.20.0 → docassert-0.20.2/docassert.egg-info}/PKG-INFO +2 -1
- {docassert-0.20.0 → docassert-0.20.2}/docassert.egg-info/SOURCES.txt +1 -0
- {docassert-0.20.0 → docassert-0.20.2}/pyproject.toml +1 -0
- docassert-0.20.2/tests/test_audit_0_20_2.py +218 -0
- {docassert-0.20.0 → docassert-0.20.2}/tests/test_status.py +12 -0
- {docassert-0.20.0 → docassert-0.20.2}/LICENSE +0 -0
- {docassert-0.20.0 → docassert-0.20.2}/NOTICE +0 -0
- {docassert-0.20.0 → docassert-0.20.2}/README.md +0 -0
- {docassert-0.20.0 → docassert-0.20.2}/docassert/__main__.py +0 -0
- {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/consistency.yaml +0 -0
- {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/criteria/adr.criteria.yaml +0 -0
- {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/criteria/benefits-realization.criteria.yaml +0 -0
- {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/criteria/brd.criteria.yaml +0 -0
- {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/criteria/business-case.criteria.yaml +0 -0
- {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/criteria/charter.criteria.yaml +0 -0
- {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/criteria/data-migration-plan.criteria.yaml +0 -0
- {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/criteria/frnfr.criteria.yaml +0 -0
- {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/criteria/hypercare-plan.criteria.yaml +0 -0
- {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/criteria/operations.criteria.yaml +0 -0
- {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/criteria/post-implementation-review.criteria.yaml +0 -0
- {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/criteria/prd.criteria.yaml +0 -0
- {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/criteria/project.criteria.yaml +0 -0
- {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/criteria/qa-test-plan.criteria.yaml +0 -0
- {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/criteria/raci-stakeholder.criteria.yaml +0 -0
- {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/criteria/release-cutover-plan.criteria.yaml +0 -0
- {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/criteria/risk-register.criteria.yaml +0 -0
- {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/criteria/rollback-plan.criteria.yaml +0 -0
- {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/criteria/runbook.criteria.yaml +0 -0
- {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/criteria/status-report.criteria.yaml +0 -0
- {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/criteria/test-cases.criteria.yaml +0 -0
- {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/criteria/user-story.criteria.yaml +0 -0
- {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/profiles/agile-delivery.yaml +0 -0
- {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/profiles/lean-startup.yaml +0 -0
- {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/profiles/operations.yaml +0 -0
- {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/profiles/regulated-industry.yaml +0 -0
- {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/schema/adr.schema.json +0 -0
- {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/schema/benefits-realization.schema.json +0 -0
- {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/schema/brd.schema.json +0 -0
- {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/schema/business-case.schema.json +0 -0
- {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/schema/charter.schema.json +0 -0
- {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/schema/data-migration-plan.schema.json +0 -0
- {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/schema/frnfr.schema.json +0 -0
- {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/schema/hypercare-plan.schema.json +0 -0
- {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/schema/operations.schema.json +0 -0
- {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/schema/post-implementation-review.schema.json +0 -0
- {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/schema/prd.schema.json +0 -0
- {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/schema/project.schema.json +0 -0
- {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/schema/qa-test-plan.schema.json +0 -0
- {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/schema/raci-stakeholder.schema.json +0 -0
- {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/schema/release-cutover-plan.schema.json +0 -0
- {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/schema/risk-register.schema.json +0 -0
- {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/schema/rollback-plan.schema.json +0 -0
- {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/schema/runbook.schema.json +0 -0
- {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/schema/status-report.schema.json +0 -0
- {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/schema/test-cases.schema.json +0 -0
- {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/schema/user-story.schema.json +0 -0
- {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/skills/doc-to-pmo/SKILL.md +0 -0
- {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/templates/adr.template.md +0 -0
- {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/templates/benefits-realization.template.md +0 -0
- {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/templates/brd.template.md +0 -0
- {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/templates/business-case.template.md +0 -0
- {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/templates/charter.template.md +0 -0
- {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/templates/data-migration-plan.template.md +0 -0
- {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/templates/frnfr.template.md +0 -0
- {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/templates/hypercare-plan.template.md +0 -0
- {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/templates/operations.template.md +0 -0
- {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/templates/post-implementation-review.template.md +0 -0
- {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/templates/prd.template.md +0 -0
- {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/templates/project.template.md +0 -0
- {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/templates/qa-test-plan.template.md +0 -0
- {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/templates/raci-stakeholder.template.md +0 -0
- {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/templates/release-cutover-plan.template.md +0 -0
- {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/templates/risk-register.template.md +0 -0
- {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/templates/rollback-plan.template.md +0 -0
- {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/templates/runbook.template.md +0 -0
- {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/templates/status-report.template.md +0 -0
- {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/templates/test-cases.template.md +0 -0
- {docassert-0.20.0 → docassert-0.20.2}/docassert/_data/templates/user-story.template.md +0 -0
- {docassert-0.20.0 → docassert-0.20.2}/docassert/bridge/__init__.py +0 -0
- {docassert-0.20.0 → docassert-0.20.2}/docassert/bridge/gh.py +0 -0
- {docassert-0.20.0 → docassert-0.20.2}/docassert/extract.py +0 -0
- {docassert-0.20.0 → docassert-0.20.2}/docassert/graph.py +0 -0
- {docassert-0.20.0 → docassert-0.20.2}/docassert/loader.py +0 -0
- {docassert-0.20.0 → docassert-0.20.2}/docassert/models.py +0 -0
- {docassert-0.20.0 → docassert-0.20.2}/docassert/projects.py +0 -0
- {docassert-0.20.0 → docassert-0.20.2}/docassert/report.py +0 -0
- {docassert-0.20.0 → docassert-0.20.2}/docassert/rtm.py +0 -0
- {docassert-0.20.0 → docassert-0.20.2}/docassert/scaffold.py +0 -0
- {docassert-0.20.0 → docassert-0.20.2}/docassert/semantic.py +0 -0
- {docassert-0.20.0 → docassert-0.20.2}/docassert/structural.py +0 -0
- {docassert-0.20.0 → docassert-0.20.2}/docassert.egg-info/dependency_links.txt +0 -0
- {docassert-0.20.0 → docassert-0.20.2}/docassert.egg-info/entry_points.txt +0 -0
- {docassert-0.20.0 → docassert-0.20.2}/docassert.egg-info/requires.txt +0 -0
- {docassert-0.20.0 → docassert-0.20.2}/docassert.egg-info/top_level.txt +0 -0
- {docassert-0.20.0 → docassert-0.20.2}/setup.cfg +0 -0
- {docassert-0.20.0 → docassert-0.20.2}/tests/test_badge.py +0 -0
- {docassert-0.20.0 → docassert-0.20.2}/tests/test_bridge.py +0 -0
- {docassert-0.20.0 → docassert-0.20.2}/tests/test_config.py +0 -0
- {docassert-0.20.0 → docassert-0.20.2}/tests/test_consistency.py +0 -0
- {docassert-0.20.0 → docassert-0.20.2}/tests/test_defects.py +0 -0
- {docassert-0.20.0 → docassert-0.20.2}/tests/test_draft_relaxation.py +0 -0
- {docassert-0.20.0 → docassert-0.20.2}/tests/test_extract.py +0 -0
- {docassert-0.20.0 → docassert-0.20.2}/tests/test_graph.py +0 -0
- {docassert-0.20.0 → docassert-0.20.2}/tests/test_json_report.py +0 -0
- {docassert-0.20.0 → docassert-0.20.2}/tests/test_kinds_delivery.py +0 -0
- {docassert-0.20.0 → docassert-0.20.2}/tests/test_kinds_governance.py +0 -0
- {docassert-0.20.0 → docassert-0.20.2}/tests/test_kinds_operate.py +0 -0
- {docassert-0.20.0 → docassert-0.20.2}/tests/test_kinds_reporting.py +0 -0
- {docassert-0.20.0 → docassert-0.20.2}/tests/test_profiles.py +0 -0
- {docassert-0.20.0 → docassert-0.20.2}/tests/test_projects.py +0 -0
- {docassert-0.20.0 → docassert-0.20.2}/tests/test_report_extract.py +0 -0
- {docassert-0.20.0 → docassert-0.20.2}/tests/test_scaffold.py +0 -0
- {docassert-0.20.0 → docassert-0.20.2}/tests/test_semantic.py +0 -0
- {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.
|
|
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
|
|
@@ -15,9 +15,7 @@ FIELD_SPECS: dict[str, dict] = {
|
|
|
15
15
|
"PMO Project": {"dataType": "TEXT"},
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
190
|
-
|
|
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 "
|
|
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
|
-
|
|
241
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
|
6
|
-
project
|
|
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
|
|
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 =
|
|
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"]
|
|
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,
|
|
372
|
+
return {"projects": cards,
|
|
373
|
+
"overall": build_status(documents_dir, _corpus=corpus)}
|
|
361
374
|
|
|
362
375
|
|
|
363
376
|
def derive_rag(model) -> str:
|