docassert 0.20.1__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.1/docassert.egg-info → docassert-0.20.2}/PKG-INFO +2 -1
- {docassert-0.20.1 → docassert-0.20.2}/docassert/__init__.py +1 -1
- {docassert-0.20.1 → docassert-0.20.2}/docassert/bridge/board.py +30 -6
- {docassert-0.20.1 → docassert-0.20.2}/docassert/bridge/ops.py +7 -1
- {docassert-0.20.1 → docassert-0.20.2}/docassert/bridge/plan.py +5 -2
- {docassert-0.20.1 → docassert-0.20.2}/docassert/cli.py +19 -3
- {docassert-0.20.1 → docassert-0.20.2}/docassert/config.py +17 -2
- {docassert-0.20.1 → docassert-0.20.2}/docassert/consistency.py +32 -20
- {docassert-0.20.1 → docassert-0.20.2}/docassert/profiles.py +4 -2
- {docassert-0.20.1 → docassert-0.20.2}/docassert/status/__init__.py +2 -1
- {docassert-0.20.1 → docassert-0.20.2}/docassert/status/derive.py +20 -7
- {docassert-0.20.1 → docassert-0.20.2}/docassert/status/render.py +84 -31
- {docassert-0.20.1 → docassert-0.20.2/docassert.egg-info}/PKG-INFO +2 -1
- {docassert-0.20.1 → docassert-0.20.2}/docassert.egg-info/SOURCES.txt +1 -0
- {docassert-0.20.1 → docassert-0.20.2}/pyproject.toml +1 -0
- docassert-0.20.2/tests/test_audit_0_20_2.py +218 -0
- {docassert-0.20.1 → docassert-0.20.2}/LICENSE +0 -0
- {docassert-0.20.1 → docassert-0.20.2}/NOTICE +0 -0
- {docassert-0.20.1 → docassert-0.20.2}/README.md +0 -0
- {docassert-0.20.1 → docassert-0.20.2}/docassert/__main__.py +0 -0
- {docassert-0.20.1 → docassert-0.20.2}/docassert/_data/consistency.yaml +0 -0
- {docassert-0.20.1 → docassert-0.20.2}/docassert/_data/criteria/adr.criteria.yaml +0 -0
- {docassert-0.20.1 → docassert-0.20.2}/docassert/_data/criteria/benefits-realization.criteria.yaml +0 -0
- {docassert-0.20.1 → docassert-0.20.2}/docassert/_data/criteria/brd.criteria.yaml +0 -0
- {docassert-0.20.1 → docassert-0.20.2}/docassert/_data/criteria/business-case.criteria.yaml +0 -0
- {docassert-0.20.1 → docassert-0.20.2}/docassert/_data/criteria/charter.criteria.yaml +0 -0
- {docassert-0.20.1 → docassert-0.20.2}/docassert/_data/criteria/data-migration-plan.criteria.yaml +0 -0
- {docassert-0.20.1 → docassert-0.20.2}/docassert/_data/criteria/frnfr.criteria.yaml +0 -0
- {docassert-0.20.1 → docassert-0.20.2}/docassert/_data/criteria/hypercare-plan.criteria.yaml +0 -0
- {docassert-0.20.1 → docassert-0.20.2}/docassert/_data/criteria/operations.criteria.yaml +0 -0
- {docassert-0.20.1 → docassert-0.20.2}/docassert/_data/criteria/post-implementation-review.criteria.yaml +0 -0
- {docassert-0.20.1 → docassert-0.20.2}/docassert/_data/criteria/prd.criteria.yaml +0 -0
- {docassert-0.20.1 → docassert-0.20.2}/docassert/_data/criteria/project.criteria.yaml +0 -0
- {docassert-0.20.1 → docassert-0.20.2}/docassert/_data/criteria/qa-test-plan.criteria.yaml +0 -0
- {docassert-0.20.1 → docassert-0.20.2}/docassert/_data/criteria/raci-stakeholder.criteria.yaml +0 -0
- {docassert-0.20.1 → docassert-0.20.2}/docassert/_data/criteria/release-cutover-plan.criteria.yaml +0 -0
- {docassert-0.20.1 → docassert-0.20.2}/docassert/_data/criteria/risk-register.criteria.yaml +0 -0
- {docassert-0.20.1 → docassert-0.20.2}/docassert/_data/criteria/rollback-plan.criteria.yaml +0 -0
- {docassert-0.20.1 → docassert-0.20.2}/docassert/_data/criteria/runbook.criteria.yaml +0 -0
- {docassert-0.20.1 → docassert-0.20.2}/docassert/_data/criteria/status-report.criteria.yaml +0 -0
- {docassert-0.20.1 → docassert-0.20.2}/docassert/_data/criteria/test-cases.criteria.yaml +0 -0
- {docassert-0.20.1 → docassert-0.20.2}/docassert/_data/criteria/user-story.criteria.yaml +0 -0
- {docassert-0.20.1 → docassert-0.20.2}/docassert/_data/profiles/agile-delivery.yaml +0 -0
- {docassert-0.20.1 → docassert-0.20.2}/docassert/_data/profiles/lean-startup.yaml +0 -0
- {docassert-0.20.1 → docassert-0.20.2}/docassert/_data/profiles/operations.yaml +0 -0
- {docassert-0.20.1 → docassert-0.20.2}/docassert/_data/profiles/regulated-industry.yaml +0 -0
- {docassert-0.20.1 → docassert-0.20.2}/docassert/_data/schema/adr.schema.json +0 -0
- {docassert-0.20.1 → docassert-0.20.2}/docassert/_data/schema/benefits-realization.schema.json +0 -0
- {docassert-0.20.1 → docassert-0.20.2}/docassert/_data/schema/brd.schema.json +0 -0
- {docassert-0.20.1 → docassert-0.20.2}/docassert/_data/schema/business-case.schema.json +0 -0
- {docassert-0.20.1 → docassert-0.20.2}/docassert/_data/schema/charter.schema.json +0 -0
- {docassert-0.20.1 → docassert-0.20.2}/docassert/_data/schema/data-migration-plan.schema.json +0 -0
- {docassert-0.20.1 → docassert-0.20.2}/docassert/_data/schema/frnfr.schema.json +0 -0
- {docassert-0.20.1 → docassert-0.20.2}/docassert/_data/schema/hypercare-plan.schema.json +0 -0
- {docassert-0.20.1 → docassert-0.20.2}/docassert/_data/schema/operations.schema.json +0 -0
- {docassert-0.20.1 → docassert-0.20.2}/docassert/_data/schema/post-implementation-review.schema.json +0 -0
- {docassert-0.20.1 → docassert-0.20.2}/docassert/_data/schema/prd.schema.json +0 -0
- {docassert-0.20.1 → docassert-0.20.2}/docassert/_data/schema/project.schema.json +0 -0
- {docassert-0.20.1 → docassert-0.20.2}/docassert/_data/schema/qa-test-plan.schema.json +0 -0
- {docassert-0.20.1 → docassert-0.20.2}/docassert/_data/schema/raci-stakeholder.schema.json +0 -0
- {docassert-0.20.1 → docassert-0.20.2}/docassert/_data/schema/release-cutover-plan.schema.json +0 -0
- {docassert-0.20.1 → docassert-0.20.2}/docassert/_data/schema/risk-register.schema.json +0 -0
- {docassert-0.20.1 → docassert-0.20.2}/docassert/_data/schema/rollback-plan.schema.json +0 -0
- {docassert-0.20.1 → docassert-0.20.2}/docassert/_data/schema/runbook.schema.json +0 -0
- {docassert-0.20.1 → docassert-0.20.2}/docassert/_data/schema/status-report.schema.json +0 -0
- {docassert-0.20.1 → docassert-0.20.2}/docassert/_data/schema/test-cases.schema.json +0 -0
- {docassert-0.20.1 → docassert-0.20.2}/docassert/_data/schema/user-story.schema.json +0 -0
- {docassert-0.20.1 → docassert-0.20.2}/docassert/_data/skills/doc-to-pmo/SKILL.md +0 -0
- {docassert-0.20.1 → docassert-0.20.2}/docassert/_data/templates/adr.template.md +0 -0
- {docassert-0.20.1 → docassert-0.20.2}/docassert/_data/templates/benefits-realization.template.md +0 -0
- {docassert-0.20.1 → docassert-0.20.2}/docassert/_data/templates/brd.template.md +0 -0
- {docassert-0.20.1 → docassert-0.20.2}/docassert/_data/templates/business-case.template.md +0 -0
- {docassert-0.20.1 → docassert-0.20.2}/docassert/_data/templates/charter.template.md +0 -0
- {docassert-0.20.1 → docassert-0.20.2}/docassert/_data/templates/data-migration-plan.template.md +0 -0
- {docassert-0.20.1 → docassert-0.20.2}/docassert/_data/templates/frnfr.template.md +0 -0
- {docassert-0.20.1 → docassert-0.20.2}/docassert/_data/templates/hypercare-plan.template.md +0 -0
- {docassert-0.20.1 → docassert-0.20.2}/docassert/_data/templates/operations.template.md +0 -0
- {docassert-0.20.1 → docassert-0.20.2}/docassert/_data/templates/post-implementation-review.template.md +0 -0
- {docassert-0.20.1 → docassert-0.20.2}/docassert/_data/templates/prd.template.md +0 -0
- {docassert-0.20.1 → docassert-0.20.2}/docassert/_data/templates/project.template.md +0 -0
- {docassert-0.20.1 → docassert-0.20.2}/docassert/_data/templates/qa-test-plan.template.md +0 -0
- {docassert-0.20.1 → docassert-0.20.2}/docassert/_data/templates/raci-stakeholder.template.md +0 -0
- {docassert-0.20.1 → docassert-0.20.2}/docassert/_data/templates/release-cutover-plan.template.md +0 -0
- {docassert-0.20.1 → docassert-0.20.2}/docassert/_data/templates/risk-register.template.md +0 -0
- {docassert-0.20.1 → docassert-0.20.2}/docassert/_data/templates/rollback-plan.template.md +0 -0
- {docassert-0.20.1 → docassert-0.20.2}/docassert/_data/templates/runbook.template.md +0 -0
- {docassert-0.20.1 → docassert-0.20.2}/docassert/_data/templates/status-report.template.md +0 -0
- {docassert-0.20.1 → docassert-0.20.2}/docassert/_data/templates/test-cases.template.md +0 -0
- {docassert-0.20.1 → docassert-0.20.2}/docassert/_data/templates/user-story.template.md +0 -0
- {docassert-0.20.1 → docassert-0.20.2}/docassert/bridge/__init__.py +0 -0
- {docassert-0.20.1 → docassert-0.20.2}/docassert/bridge/gh.py +0 -0
- {docassert-0.20.1 → docassert-0.20.2}/docassert/extract.py +0 -0
- {docassert-0.20.1 → docassert-0.20.2}/docassert/graph.py +0 -0
- {docassert-0.20.1 → docassert-0.20.2}/docassert/loader.py +0 -0
- {docassert-0.20.1 → docassert-0.20.2}/docassert/models.py +0 -0
- {docassert-0.20.1 → docassert-0.20.2}/docassert/projects.py +0 -0
- {docassert-0.20.1 → docassert-0.20.2}/docassert/report.py +0 -0
- {docassert-0.20.1 → docassert-0.20.2}/docassert/rtm.py +0 -0
- {docassert-0.20.1 → docassert-0.20.2}/docassert/scaffold.py +0 -0
- {docassert-0.20.1 → docassert-0.20.2}/docassert/semantic.py +0 -0
- {docassert-0.20.1 → docassert-0.20.2}/docassert/structural.py +0 -0
- {docassert-0.20.1 → docassert-0.20.2}/docassert.egg-info/dependency_links.txt +0 -0
- {docassert-0.20.1 → docassert-0.20.2}/docassert.egg-info/entry_points.txt +0 -0
- {docassert-0.20.1 → docassert-0.20.2}/docassert.egg-info/requires.txt +0 -0
- {docassert-0.20.1 → docassert-0.20.2}/docassert.egg-info/top_level.txt +0 -0
- {docassert-0.20.1 → docassert-0.20.2}/setup.cfg +0 -0
- {docassert-0.20.1 → docassert-0.20.2}/tests/test_badge.py +0 -0
- {docassert-0.20.1 → docassert-0.20.2}/tests/test_bridge.py +0 -0
- {docassert-0.20.1 → docassert-0.20.2}/tests/test_config.py +0 -0
- {docassert-0.20.1 → docassert-0.20.2}/tests/test_consistency.py +0 -0
- {docassert-0.20.1 → docassert-0.20.2}/tests/test_defects.py +0 -0
- {docassert-0.20.1 → docassert-0.20.2}/tests/test_draft_relaxation.py +0 -0
- {docassert-0.20.1 → docassert-0.20.2}/tests/test_extract.py +0 -0
- {docassert-0.20.1 → docassert-0.20.2}/tests/test_graph.py +0 -0
- {docassert-0.20.1 → docassert-0.20.2}/tests/test_json_report.py +0 -0
- {docassert-0.20.1 → docassert-0.20.2}/tests/test_kinds_delivery.py +0 -0
- {docassert-0.20.1 → docassert-0.20.2}/tests/test_kinds_governance.py +0 -0
- {docassert-0.20.1 → docassert-0.20.2}/tests/test_kinds_operate.py +0 -0
- {docassert-0.20.1 → docassert-0.20.2}/tests/test_kinds_reporting.py +0 -0
- {docassert-0.20.1 → docassert-0.20.2}/tests/test_profiles.py +0 -0
- {docassert-0.20.1 → docassert-0.20.2}/tests/test_projects.py +0 -0
- {docassert-0.20.1 → docassert-0.20.2}/tests/test_report_extract.py +0 -0
- {docassert-0.20.1 → docassert-0.20.2}/tests/test_scaffold.py +0 -0
- {docassert-0.20.1 → docassert-0.20.2}/tests/test_semantic.py +0 -0
- {docassert-0.20.1 → docassert-0.20.2}/tests/test_status.py +0 -0
- {docassert-0.20.1 → 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:
|
|
@@ -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"({
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
|
201
|
-
"or overdue review stands against this project
|
|
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)
|
|
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])
|
|
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
|
-
|
|
224
|
-
|
|
225
|
-
d
|
|
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
|
-
|
|
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"{
|
|
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,
|
|
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": (
|
|
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.
|
|
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
|
|
@@ -16,6 +16,7 @@ classifiers = [
|
|
|
16
16
|
"Programming Language :: Python :: 3.11",
|
|
17
17
|
"Programming Language :: Python :: 3.12",
|
|
18
18
|
"Programming Language :: Python :: 3.13",
|
|
19
|
+
"Programming Language :: Python :: 3.14",
|
|
19
20
|
"Topic :: Software Development :: Quality Assurance",
|
|
20
21
|
"Topic :: Software Development :: Documentation",
|
|
21
22
|
"Topic :: Text Processing :: Markup :: Markdown",
|