agentic-project-kit 1.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agentic_project_kit/__init__.py +1 -0
- agentic_project_kit/absolute_path_portability_audit.py +241 -0
- agentic_project_kit/access_level_policy.py +14 -0
- agentic_project_kit/access_levels.py +22 -0
- agentic_project_kit/action_registry.py +195 -0
- agentic_project_kit/action_specs.py +286 -0
- agentic_project_kit/agent_command_runner.py +516 -0
- agentic_project_kit/audit_test_matrix.py +36 -0
- agentic_project_kit/audit_test_matrix_cli.py +43 -0
- agentic_project_kit/chat_bootloader.py +257 -0
- agentic_project_kit/chat_entrypoint_contract.py +297 -0
- agentic_project_kit/checks.py +511 -0
- agentic_project_kit/ci_readiness.py +211 -0
- agentic_project_kit/cli.py +147 -0
- agentic_project_kit/cli_commands/__init__.py +1 -0
- agentic_project_kit/cli_commands/absolute_path_audit.py +25 -0
- agentic_project_kit/cli_commands/actions.py +21 -0
- agentic_project_kit/cli_commands/artifact_gc.py +240 -0
- agentic_project_kit/cli_commands/boot.py +55 -0
- agentic_project_kit/cli_commands/chat.py +34 -0
- agentic_project_kit/cli_commands/checks.py +305 -0
- agentic_project_kit/cli_commands/cockpit.py +103 -0
- agentic_project_kit/cli_commands/command_taxonomy.py +25 -0
- agentic_project_kit/cli_commands/commands.py +129 -0
- agentic_project_kit/cli_commands/dev.py +23 -0
- agentic_project_kit/cli_commands/direction.py +92 -0
- agentic_project_kit/cli_commands/doc_currency_audit.py +25 -0
- agentic_project_kit/cli_commands/doc_registry.py +126 -0
- agentic_project_kit/cli_commands/docs.py +223 -0
- agentic_project_kit/cli_commands/dpa.py +1338 -0
- agentic_project_kit/cli_commands/evidence.py +251 -0
- agentic_project_kit/cli_commands/github.py +34 -0
- agentic_project_kit/cli_commands/governance.py +14 -0
- agentic_project_kit/cli_commands/gui.py +23 -0
- agentic_project_kit/cli_commands/gui_readiness_gate.py +27 -0
- agentic_project_kit/cli_commands/handoff.py +183 -0
- agentic_project_kit/cli_commands/human_workflows.py +430 -0
- agentic_project_kit/cli_commands/init.py +132 -0
- agentic_project_kit/cli_commands/instruction.py +75 -0
- agentic_project_kit/cli_commands/mutation_lock_audit.py +24 -0
- agentic_project_kit/cli_commands/ns_legacy_audit.py +25 -0
- agentic_project_kit/cli_commands/pass_already_done.py +66 -0
- agentic_project_kit/cli_commands/patch_failure_discipline_audit.py +26 -0
- agentic_project_kit/cli_commands/patch_preflight.py +37 -0
- agentic_project_kit/cli_commands/path_literal_audit.py +39 -0
- agentic_project_kit/cli_commands/patterns.py +40 -0
- agentic_project_kit/cli_commands/planning_docs_audit.py +25 -0
- agentic_project_kit/cli_commands/pr.py +133 -0
- agentic_project_kit/cli_commands/pr_hygiene.py +27 -0
- agentic_project_kit/cli_commands/profiles.py +26 -0
- agentic_project_kit/cli_commands/program_redundancy_audit.py +25 -0
- agentic_project_kit/cli_commands/project_direction.py +25 -0
- agentic_project_kit/cli_commands/release.py +392 -0
- agentic_project_kit/cli_commands/release_publish_orchestration.py +36 -0
- agentic_project_kit/cli_commands/remote_branch_hygiene.py +152 -0
- agentic_project_kit/cli_commands/remote_next.py +57 -0
- agentic_project_kit/cli_commands/removed_ns_commands.py +65 -0
- agentic_project_kit/cli_commands/rule_registry.py +129 -0
- agentic_project_kit/cli_commands/rules.py +222 -0
- agentic_project_kit/cli_commands/scaffold.py +46 -0
- agentic_project_kit/cli_commands/slice.py +21 -0
- agentic_project_kit/cli_commands/standard_gates_audit_suite.py +27 -0
- agentic_project_kit/cli_commands/state.py +51 -0
- agentic_project_kit/cli_commands/status_current_state_audit.py +30 -0
- agentic_project_kit/cli_commands/todo.py +53 -0
- agentic_project_kit/cli_commands/transfer.py +19 -0
- agentic_project_kit/cli_commands/transfer_context_flow.py +398 -0
- agentic_project_kit/cli_commands/transfer_context_helpers.py +398 -0
- agentic_project_kit/cli_commands/transfer_diagnostics.py +767 -0
- agentic_project_kit/cli_commands/transfer_evidence_flow.py +368 -0
- agentic_project_kit/cli_commands/transfer_handoff_flow.py +594 -0
- agentic_project_kit/cli_commands/transfer_order_contract.py +74 -0
- agentic_project_kit/cli_commands/transfer_post_merge_complete.py +442 -0
- agentic_project_kit/cli_commands/transfer_post_merge_settle.py +171 -0
- agentic_project_kit/cli_commands/transfer_pr_closeout_complete.py +124 -0
- agentic_project_kit/cli_commands/transfer_pr_create_flow.py +743 -0
- agentic_project_kit/cli_commands/transfer_pr_merge_flow.py +547 -0
- agentic_project_kit/cli_commands/transfer_repo_after_pr.py +160 -0
- agentic_project_kit/cli_commands/transfer_repo_pre_pr.py +664 -0
- agentic_project_kit/cli_commands/transfer_runner_flow.py +131 -0
- agentic_project_kit/cli_commands/transfer_runtime_initial.py +194 -0
- agentic_project_kit/cli_commands/transfer_shared.py +274 -0
- agentic_project_kit/cli_commands/validation.py +200 -0
- agentic_project_kit/cli_commands/version.py +27 -0
- agentic_project_kit/cli_commands/work_orders.py +219 -0
- agentic_project_kit/cli_commands/workflow.py +471 -0
- agentic_project_kit/cli_commands/workflow_guard.py +26 -0
- agentic_project_kit/cli_commands/workspace.py +252 -0
- agentic_project_kit/cockpit.py +608 -0
- agentic_project_kit/cockpit_readiness.py +67 -0
- agentic_project_kit/command_authority_audit.py +192 -0
- agentic_project_kit/command_inbox_check.py +103 -0
- agentic_project_kit/command_manifest.py +591 -0
- agentic_project_kit/command_queue_contract.py +161 -0
- agentic_project_kit/command_selector.py +245 -0
- agentic_project_kit/command_taxonomy.py +264 -0
- agentic_project_kit/commit_guard.py +67 -0
- agentic_project_kit/communication_artifact_gc.py +495 -0
- agentic_project_kit/communication_rule_context.py +437 -0
- agentic_project_kit/communication_state.py +115 -0
- agentic_project_kit/composed_wrapper_invariants.py +97 -0
- agentic_project_kit/composite_short_circuit.py +140 -0
- agentic_project_kit/contract.py +251 -0
- agentic_project_kit/control_state.py +83 -0
- agentic_project_kit/doc_currency_audit.py +536 -0
- agentic_project_kit/doc_lifecycle.py +1179 -0
- agentic_project_kit/doc_lifecycle_signals.py +446 -0
- agentic_project_kit/doc_lifecycle_sweep.py +639 -0
- agentic_project_kit/doc_mesh.py +491 -0
- agentic_project_kit/doctor.py +379 -0
- agentic_project_kit/documentation_registry.py +1122 -0
- agentic_project_kit/documentation_system_audit.py +310 -0
- agentic_project_kit/dpa_current_handoff_lifecycle.py +549 -0
- agentic_project_kit/dpa_dp2_decision_readiness.py +362 -0
- agentic_project_kit/dpa_dp3_dp4_adjudication.py +604 -0
- agentic_project_kit/dpa_dp5_block_new_gate.py +294 -0
- agentic_project_kit/dpa_dp5_stage_adoption.py +615 -0
- agentic_project_kit/dpa_dp5_strict_gate.py +187 -0
- agentic_project_kit/dpa_final_closeout.py +627 -0
- agentic_project_kit/dpa_fixture_evidence.py +680 -0
- agentic_project_kit/dpa_maintainer_record_check.py +711 -0
- agentic_project_kit/dpa_post_dp2_scope_assessment.py +734 -0
- agentic_project_kit/dpa_probe_002_readiness.py +509 -0
- agentic_project_kit/dpa_probe_003_readiness.py +417 -0
- agentic_project_kit/dpa_probe_004_readiness.py +437 -0
- agentic_project_kit/dpa_readiness.py +505 -0
- agentic_project_kit/dpa_readonly_probe_execution.py +500 -0
- agentic_project_kit/dpa_registry_contracts.py +312 -0
- agentic_project_kit/dpa_renderer_readiness.py +407 -0
- agentic_project_kit/dpa_repo_adoption_assessment.py +574 -0
- agentic_project_kit/dpa_stable_readiness.py +743 -0
- agentic_project_kit/dpa_successor_projection.py +168 -0
- agentic_project_kit/dpa_workspace_init_projection.py +93 -0
- agentic_project_kit/dpa_wrt_ch001_evidence.py +327 -0
- agentic_project_kit/entrypoint_slice_runner.py +153 -0
- agentic_project_kit/evidence_clean.py +152 -0
- agentic_project_kit/evidence_commit_paths.py +214 -0
- agentic_project_kit/evidence_finalize_log.py +204 -0
- agentic_project_kit/evidence_guard.py +114 -0
- agentic_project_kit/evidence_inspector.py +507 -0
- agentic_project_kit/evidence_state_contract.py +99 -0
- agentic_project_kit/execution_mode_state.py +148 -0
- agentic_project_kit/final_summary_contract.py +85 -0
- agentic_project_kit/finalize_guard.py +139 -0
- agentic_project_kit/gatekeeper_core.py +232 -0
- agentic_project_kit/github.py +25 -0
- agentic_project_kit/governance.py +59 -0
- agentic_project_kit/gui_action_execution.py +105 -0
- agentic_project_kit/gui_action_renderer.py +81 -0
- agentic_project_kit/gui_action_run_intent.py +98 -0
- agentic_project_kit/gui_action_views.py +195 -0
- agentic_project_kit/gui_activity_log.py +76 -0
- agentic_project_kit/gui_button_catalog.py +605 -0
- agentic_project_kit/gui_cockpit.py +365 -0
- agentic_project_kit/gui_cockpit_actions.py +695 -0
- agentic_project_kit/gui_cockpit_common.py +172 -0
- agentic_project_kit/gui_cockpit_header.py +501 -0
- agentic_project_kit/gui_cockpit_sidebar.py +387 -0
- agentic_project_kit/gui_cockpit_task.py +748 -0
- agentic_project_kit/gui_command_projection.py +355 -0
- agentic_project_kit/gui_communication_modes.py +130 -0
- agentic_project_kit/gui_dry_run.py +219 -0
- agentic_project_kit/gui_gatekeeper_status.py +259 -0
- agentic_project_kit/gui_layout_plan.py +148 -0
- agentic_project_kit/gui_open_folder.py +18 -0
- agentic_project_kit/gui_output_status_panel.py +61 -0
- agentic_project_kit/gui_panel_state.py +47 -0
- agentic_project_kit/gui_presenter.py +62 -0
- agentic_project_kit/gui_readiness_gate.py +206 -0
- agentic_project_kit/gui_release_flow.py +59 -0
- agentic_project_kit/gui_task_editor.py +1077 -0
- agentic_project_kit/gui_tk_widgets.py +177 -0
- agentic_project_kit/gui_tkinter_renderer.py +87 -0
- agentic_project_kit/gui_tkinter_shell.py +1176 -0
- agentic_project_kit/gui_transfer_contract.py +28 -0
- agentic_project_kit/gui_viewmodel.py +698 -0
- agentic_project_kit/gui_window_guard.py +46 -0
- agentic_project_kit/handoff_freshness.py +390 -0
- agentic_project_kit/handoff_prompt.py +180 -0
- agentic_project_kit/handoff_state.py +254 -0
- agentic_project_kit/instruction_lint.py +455 -0
- agentic_project_kit/llm_context_carriers.py +66 -0
- agentic_project_kit/llm_execution_context.py +285 -0
- agentic_project_kit/local_command_stack.py +133 -0
- agentic_project_kit/local_feature_gate.py +82 -0
- agentic_project_kit/local_garbage_collector.py +269 -0
- agentic_project_kit/models.py +18 -0
- agentic_project_kit/mutation_lock_audit.py +421 -0
- agentic_project_kit/next_turn_evidence.py +253 -0
- agentic_project_kit/next_turn_merge_if_green.py +459 -0
- agentic_project_kit/next_turn_pr_status.py +255 -0
- agentic_project_kit/next_turn_result.py +183 -0
- agentic_project_kit/next_turn_runner.py +159 -0
- agentic_project_kit/next_turn_slot.py +107 -0
- agentic_project_kit/next_turn_status.py +274 -0
- agentic_project_kit/ns_legacy_reference_audit.py +258 -0
- agentic_project_kit/ns_slice_runner.py +16 -0
- agentic_project_kit/operational_handoff_projection.py +124 -0
- agentic_project_kit/output_contract.py +80 -0
- agentic_project_kit/output_repair.py +61 -0
- agentic_project_kit/pass_already_done.py +216 -0
- agentic_project_kit/patch_artifact_preflight.py +147 -0
- agentic_project_kit/patch_cycle_workflow.py +440 -0
- agentic_project_kit/patch_failure_discipline_audit.py +223 -0
- agentic_project_kit/patch_preflight.py +215 -0
- agentic_project_kit/path_literal_audit.py +612 -0
- agentic_project_kit/patterns.py +99 -0
- agentic_project_kit/planning_docs_consolidation_audit.py +313 -0
- agentic_project_kit/portability_shell_gate.py +40 -0
- agentic_project_kit/post_merge_handoff_refresh.py +56 -0
- agentic_project_kit/post_release.py +252 -0
- agentic_project_kit/post_release_closeout.py +616 -0
- agentic_project_kit/pr_cleanup.py +172 -0
- agentic_project_kit/pr_closeout.py +50 -0
- agentic_project_kit/pr_closeout_complete.py +344 -0
- agentic_project_kit/pr_create_or_skip.py +71 -0
- agentic_project_kit/pr_hygiene.py +210 -0
- agentic_project_kit/program_redundancy_audit.py +251 -0
- agentic_project_kit/project_direction.py +840 -0
- agentic_project_kit/protected_change_planner.py +194 -0
- agentic_project_kit/release.py +401 -0
- agentic_project_kit/release_doi_safety.py +24 -0
- agentic_project_kit/release_gate_core.py +174 -0
- agentic_project_kit/release_metadata_authority_gate.py +328 -0
- agentic_project_kit/release_metadata_prep.py +43 -0
- agentic_project_kit/release_notes.py +823 -0
- agentic_project_kit/release_prep_core.py +204 -0
- agentic_project_kit/release_prepare.py +369 -0
- agentic_project_kit/release_process_guardrails.py +247 -0
- agentic_project_kit/release_publish_core.py +114 -0
- agentic_project_kit/release_publish_orchestration.py +469 -0
- agentic_project_kit/release_state.py +552 -0
- agentic_project_kit/release_verify_core.py +175 -0
- agentic_project_kit/remote_branch_hygiene.py +223 -0
- agentic_project_kit/remote_next.py +89 -0
- agentic_project_kit/remote_next_closeout.py +101 -0
- agentic_project_kit/remote_next_order_contract.py +391 -0
- agentic_project_kit/removed_ns_commands.py +458 -0
- agentic_project_kit/removed_source_audit.py +225 -0
- agentic_project_kit/rendering.py +13 -0
- agentic_project_kit/repair_report.py +79 -0
- agentic_project_kit/repo_identity.py +59 -0
- agentic_project_kit/result_report_classifier.py +57 -0
- agentic_project_kit/rule_ack.py +105 -0
- agentic_project_kit/rule_preservation.py +58 -0
- agentic_project_kit/rule_refresh.py +174 -0
- agentic_project_kit/rule_registry_registration.py +322 -0
- agentic_project_kit/rule_registry_report.py +206 -0
- agentic_project_kit/rule_registry_validator.py +704 -0
- agentic_project_kit/rule_snapshot.py +98 -0
- agentic_project_kit/rule_source_validator.py +133 -0
- agentic_project_kit/run_summary.py +68 -0
- agentic_project_kit/run_summary_renderer.py +238 -0
- agentic_project_kit/runtime_validator.py +68 -0
- agentic_project_kit/safe_file_replace.py +35 -0
- agentic_project_kit/safe_patch.py +47 -0
- agentic_project_kit/safe_push.py +218 -0
- agentic_project_kit/safe_remove_diagnostic.py +51 -0
- agentic_project_kit/scaffold.py +69 -0
- agentic_project_kit/site_claims.py +444 -0
- agentic_project_kit/site_generator.py +1140 -0
- agentic_project_kit/slice_gate.py +225 -0
- agentic_project_kit/slice_runner.py +75 -0
- agentic_project_kit/standard_gates_audit_suite.py +167 -0
- agentic_project_kit/state_freshness.py +135 -0
- agentic_project_kit/status_current_state_audit.py +762 -0
- agentic_project_kit/successor_handoff_package.py +1763 -0
- agentic_project_kit/templates.py +292 -0
- agentic_project_kit/terminal_block_guard.py +166 -0
- agentic_project_kit/terminal_logging.py +275 -0
- agentic_project_kit/todo.py +97 -0
- agentic_project_kit/transfer_closeout.py +136 -0
- agentic_project_kit/transfer_continue.py +215 -0
- agentic_project_kit/transfer_local_runner.py +101 -0
- agentic_project_kit/transfer_operation_monitor.py +236 -0
- agentic_project_kit/transfer_post_merge_lifecycle.py +356 -0
- agentic_project_kit/transfer_post_merge_settle.py +383 -0
- agentic_project_kit/transfer_pr_actions.py +164 -0
- agentic_project_kit/transfer_remote_next.py +959 -0
- agentic_project_kit/transfer_repo_actions.py +2253 -0
- agentic_project_kit/transfer_runner.py +347 -0
- agentic_project_kit/transfer_safety_context.py +291 -0
- agentic_project_kit/transfer_state.py +483 -0
- agentic_project_kit/transfer_uplink.py +471 -0
- agentic_project_kit/transfer_workflow_next.py +148 -0
- agentic_project_kit/typed_work_order_evidence.py +58 -0
- agentic_project_kit/typed_work_order_queue.py +166 -0
- agentic_project_kit/typed_work_order_runner.py +168 -0
- agentic_project_kit/work_cycle.py +317 -0
- agentic_project_kit/work_discard_changes.py +129 -0
- agentic_project_kit/work_order_patch.py +199 -0
- agentic_project_kit/work_order_runner.py +120 -0
- agentic_project_kit/work_order_uploader.py +220 -0
- agentic_project_kit/work_order_validator.py +185 -0
- agentic_project_kit/work_orders.py +246 -0
- agentic_project_kit/workflow_guard.py +262 -0
- agentic_project_kit/workflow_summary_runner.py +124 -0
- agentic_project_kit/workspace.py +665 -0
- agentic_project_kit/workspace_adopt.py +471 -0
- agentic_project_kit/workspace_dpa_intake.py +406 -0
- agentic_project_kit/workspace_init.py +487 -0
- agentic_project_kit/workspace_lock.py +129 -0
- agentic_project_kit/workspace_remove.py +381 -0
- agentic_project_kit/workspace_upgrade.py +324 -0
- agentic_project_kit/wrapper_live_status.py +104 -0
- agentic_project_kit-1.0.1.dist-info/METADATA +724 -0
- agentic_project_kit-1.0.1.dist-info/RECORD +310 -0
- agentic_project_kit-1.0.1.dist-info/WHEEL +4 -0
- agentic_project_kit-1.0.1.dist-info/entry_points.txt +3 -0
- agentic_project_kit-1.0.1.dist-info/licenses/LICENSE +10 -0
|
@@ -0,0 +1,1122 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from collections import Counter
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from datetime import date
|
|
7
|
+
from fnmatch import fnmatchcase
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import yaml
|
|
12
|
+
|
|
13
|
+
from agentic_project_kit.dpa_registry_contracts import (
|
|
14
|
+
DPA_REGISTRY_OPTIONAL_DOCUMENT_FIELDS,
|
|
15
|
+
validate_dpa_registry_contracts,
|
|
16
|
+
)
|
|
17
|
+
from agentic_project_kit.workspace import LEGACY_DEFAULTS, load_workspace
|
|
18
|
+
|
|
19
|
+
REGISTRY_PATH = Path(LEGACY_DEFAULTS.docs_root) / LEGACY_DEFAULTS.documentation_registry_file
|
|
20
|
+
SCOPE_PATH = Path(LEGACY_DEFAULTS.docs_root) / "DOC_REGISTRY_SCOPE.yaml"
|
|
21
|
+
COMPILED_CONTEXT_PATH = Path(".agentic/compiled_agent_context.yaml")
|
|
22
|
+
REGISTRY_CONTRACT_PATH = Path(LEGACY_DEFAULTS.governance_root) / "DOCUMENTATION_REGISTRY_CONTRACT.md"
|
|
23
|
+
SCOPE_DECISION_PATH = Path(LEGACY_DEFAULTS.governance_root) / "DOC_REGISTRY_SCOPE_DECISION.md"
|
|
24
|
+
COMMUNICATION_ARTIFACTS_PATH = Path(".agentic/communication_artifacts.yaml")
|
|
25
|
+
|
|
26
|
+
DOCUMENT_CLASSES = (
|
|
27
|
+
"governance/system",
|
|
28
|
+
"planning",
|
|
29
|
+
"architecture",
|
|
30
|
+
"release",
|
|
31
|
+
"operational/automation",
|
|
32
|
+
"user-facing description",
|
|
33
|
+
"evidence/log",
|
|
34
|
+
"generated artifact",
|
|
35
|
+
"temporary artifact",
|
|
36
|
+
"historical archive",
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
REQUIRED_CLASS_RULE_FIELDS = (
|
|
40
|
+
"ownership",
|
|
41
|
+
"freshness",
|
|
42
|
+
"language_policy",
|
|
43
|
+
"redundancy_boundary",
|
|
44
|
+
"machine_readability",
|
|
45
|
+
"retention_gc_behavior",
|
|
46
|
+
"update_triggers",
|
|
47
|
+
"portability_local_path_scanning",
|
|
48
|
+
"gate_coverage",
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
REQUIRED_DOCUMENT_FIELDS = (
|
|
52
|
+
"path",
|
|
53
|
+
"class",
|
|
54
|
+
"owner",
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
OPTIONAL_DOCUMENT_FIELDS = (
|
|
58
|
+
"review_after",
|
|
59
|
+
"deferred_until",
|
|
60
|
+
*DPA_REGISTRY_OPTIONAL_DOCUMENT_FIELDS,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
DOCUMENT_REGISTRATION_SCAN_SUFFIXES = frozenset({".md", ".yaml", ".yml"})
|
|
64
|
+
DOCUMENT_REGISTRATION_SCAN_ROOTS = (Path(LEGACY_DEFAULTS.docs_root),)
|
|
65
|
+
DOCUMENT_REGISTRATION_EXCLUDED_PREFIXES = (
|
|
66
|
+
"docs/reports/handoff-packages/",
|
|
67
|
+
"docs/reports/terminal/",
|
|
68
|
+
"docs/reports/transfer_runs/",
|
|
69
|
+
)
|
|
70
|
+
SCOPE_REQUIRED_SUFFIXES = frozenset({".md"})
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass(frozen=True)
|
|
74
|
+
class ScopeExemption:
|
|
75
|
+
path: str
|
|
76
|
+
reason: str
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass(frozen=True)
|
|
80
|
+
class DocumentationRegistryScope:
|
|
81
|
+
present: bool
|
|
82
|
+
required_files: tuple[str, ...]
|
|
83
|
+
required_paths: tuple[str, ...]
|
|
84
|
+
exempt_paths: tuple[ScopeExemption, ...]
|
|
85
|
+
errors: tuple[str, ...] = ()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def load_documentation_registry(project_root: Path) -> dict[str, Any]:
|
|
89
|
+
workspace = load_workspace(project_root)
|
|
90
|
+
path = workspace.doc_registry_path()
|
|
91
|
+
data = yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
92
|
+
if not isinstance(data, dict):
|
|
93
|
+
raise ValueError(f"{workspace.path_text(path)}: root must be a mapping")
|
|
94
|
+
return data
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def load_documentation_registry_scope(project_root: Path) -> DocumentationRegistryScope:
|
|
98
|
+
workspace = load_workspace(project_root)
|
|
99
|
+
path = workspace.docs_file("DOC_REGISTRY_SCOPE.yaml")
|
|
100
|
+
if not path.exists():
|
|
101
|
+
return DocumentationRegistryScope(
|
|
102
|
+
present=False,
|
|
103
|
+
required_files=(),
|
|
104
|
+
required_paths=(),
|
|
105
|
+
exempt_paths=(),
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
errors: list[str] = []
|
|
109
|
+
try:
|
|
110
|
+
data = yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
111
|
+
except (OSError, yaml.YAMLError) as exc:
|
|
112
|
+
return DocumentationRegistryScope(
|
|
113
|
+
present=True,
|
|
114
|
+
required_files=(),
|
|
115
|
+
required_paths=(),
|
|
116
|
+
exempt_paths=(),
|
|
117
|
+
errors=(f"{workspace.path_text(path)}: invalid scope ({exc})",),
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
if data is None:
|
|
121
|
+
data = {}
|
|
122
|
+
if not isinstance(data, dict):
|
|
123
|
+
return DocumentationRegistryScope(
|
|
124
|
+
present=True,
|
|
125
|
+
required_files=(),
|
|
126
|
+
required_paths=(),
|
|
127
|
+
exempt_paths=(),
|
|
128
|
+
errors=(f"{workspace.path_text(path)}: root must be a mapping",),
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
if data.get("schema_version") != 1:
|
|
132
|
+
errors.append(f"{workspace.path_text(path)}: schema_version must be 1")
|
|
133
|
+
|
|
134
|
+
required_files = _parse_scope_required_files(data.get("required_files"), errors)
|
|
135
|
+
required_paths = _parse_scope_required_paths(data.get("required_paths"), errors)
|
|
136
|
+
exempt_paths = _parse_scope_exempt_paths(data.get("exempt_paths"), errors)
|
|
137
|
+
return DocumentationRegistryScope(
|
|
138
|
+
present=True,
|
|
139
|
+
required_files=tuple(required_files),
|
|
140
|
+
required_paths=tuple(required_paths),
|
|
141
|
+
exempt_paths=tuple(exempt_paths),
|
|
142
|
+
errors=tuple(errors),
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def check_documentation_registry_scope(project_root: Path) -> list[str]:
|
|
147
|
+
scope = load_documentation_registry_scope(project_root)
|
|
148
|
+
return list(scope.errors)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def load_communication_artifact_policy(project_root: Path) -> dict[str, Any]:
|
|
152
|
+
path = project_root / COMMUNICATION_ARTIFACTS_PATH
|
|
153
|
+
data = yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
154
|
+
if not isinstance(data, dict):
|
|
155
|
+
raise ValueError(f"{COMMUNICATION_ARTIFACTS_PATH}: root must be a mapping")
|
|
156
|
+
return data
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def build_artifact_policy_summary(project_root: Path) -> dict[str, Any]:
|
|
160
|
+
"""Build a read-only summary of communication artifact policy.
|
|
161
|
+
|
|
162
|
+
This intentionally consumes the machine-readable artifact policy without
|
|
163
|
+
changing cleanup behavior. It is a small integration step between the
|
|
164
|
+
documentation registry and the artifact retention policy.
|
|
165
|
+
"""
|
|
166
|
+
try:
|
|
167
|
+
policy = load_communication_artifact_policy(project_root)
|
|
168
|
+
except FileNotFoundError:
|
|
169
|
+
return {
|
|
170
|
+
"policy_path": str(COMMUNICATION_ARTIFACTS_PATH),
|
|
171
|
+
"present": False,
|
|
172
|
+
"rule_count": 0,
|
|
173
|
+
"delete_allowed_counts": {},
|
|
174
|
+
"default_action_counts": {},
|
|
175
|
+
"protected_rule_ids": [],
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
rules = policy.get("rules", [])
|
|
179
|
+
if not isinstance(rules, list):
|
|
180
|
+
rules = []
|
|
181
|
+
|
|
182
|
+
delete_allowed_counts: Counter[str] = Counter()
|
|
183
|
+
default_action_counts: Counter[str] = Counter()
|
|
184
|
+
protected_rule_ids: list[str] = []
|
|
185
|
+
for rule in rules:
|
|
186
|
+
if not isinstance(rule, dict):
|
|
187
|
+
continue
|
|
188
|
+
delete_allowed = str(rule.get("delete_allowed", "<missing>")).strip() or "<missing>"
|
|
189
|
+
default_action = str(rule.get("default_action", "<missing>")).strip() or "<missing>"
|
|
190
|
+
rule_id = str(rule.get("id", "")).strip()
|
|
191
|
+
delete_allowed_counts[delete_allowed] += 1
|
|
192
|
+
default_action_counts[default_action] += 1
|
|
193
|
+
if delete_allowed in {"false", "False"} or default_action in {"keep", "keep-or-repair"}:
|
|
194
|
+
if rule_id:
|
|
195
|
+
protected_rule_ids.append(rule_id)
|
|
196
|
+
|
|
197
|
+
return {
|
|
198
|
+
"policy_path": str(COMMUNICATION_ARTIFACTS_PATH),
|
|
199
|
+
"present": True,
|
|
200
|
+
"schema_version": policy.get("schema_version"),
|
|
201
|
+
"rule_count": len(rules),
|
|
202
|
+
"delete_allowed_counts": dict(sorted(delete_allowed_counts.items())),
|
|
203
|
+
"default_action_counts": dict(sorted(default_action_counts.items())),
|
|
204
|
+
"protected_rule_ids": sorted(protected_rule_ids),
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def build_documentation_registry_summary(project_root: Path) -> dict[str, Any]:
|
|
209
|
+
"""Build a read-only summary for the documentation registry.
|
|
210
|
+
|
|
211
|
+
This summary is intentionally non-mutating. It is a narrow second-slice
|
|
212
|
+
consumer that makes the registry inspectable without starting a broad
|
|
213
|
+
migration or changing any document lifecycle policy.
|
|
214
|
+
"""
|
|
215
|
+
registry = load_documentation_registry(project_root)
|
|
216
|
+
workspace = load_workspace(project_root)
|
|
217
|
+
documents = registry.get("documents", [])
|
|
218
|
+
if not isinstance(documents, list):
|
|
219
|
+
documents = []
|
|
220
|
+
|
|
221
|
+
class_counts: Counter[str] = Counter()
|
|
222
|
+
owner_counts: Counter[str] = Counter()
|
|
223
|
+
registered_paths: set[str] = set()
|
|
224
|
+
for entry in documents:
|
|
225
|
+
if not isinstance(entry, dict):
|
|
226
|
+
continue
|
|
227
|
+
document_class = str(entry.get("class", "")).strip() or "<missing>"
|
|
228
|
+
owner = str(entry.get("owner", "")).strip() or "<missing>"
|
|
229
|
+
path = str(entry.get("path", "")).strip()
|
|
230
|
+
if path:
|
|
231
|
+
registered_paths.add(path)
|
|
232
|
+
class_counts[document_class] += 1
|
|
233
|
+
owner_counts[owner] += 1
|
|
234
|
+
|
|
235
|
+
unregistered_candidates = find_unregistered_document_candidates(
|
|
236
|
+
project_root,
|
|
237
|
+
registered_paths=registered_paths,
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
return {
|
|
241
|
+
"registry_path": workspace.path_text(workspace.doc_registry_path()),
|
|
242
|
+
"version": registry.get("version"),
|
|
243
|
+
"lifecycle": (registry.get("status") or {}).get("lifecycle")
|
|
244
|
+
if isinstance(registry.get("status"), dict)
|
|
245
|
+
else None,
|
|
246
|
+
"broad_migration_allowed": (registry.get("status") or {}).get(
|
|
247
|
+
"broad_migration_allowed"
|
|
248
|
+
)
|
|
249
|
+
if isinstance(registry.get("status"), dict)
|
|
250
|
+
else None,
|
|
251
|
+
"document_count": len(documents),
|
|
252
|
+
"class_counts": dict(sorted(class_counts.items())),
|
|
253
|
+
"owner_counts": dict(sorted(owner_counts.items())),
|
|
254
|
+
"registration_policy": {
|
|
255
|
+
"status": "reviewed_register_command_available",
|
|
256
|
+
"mutation_allowed": True,
|
|
257
|
+
"allowed_command": "agentic-kit doc-registry register",
|
|
258
|
+
"next_step": (
|
|
259
|
+
"Review unregistered candidates, classify them, then register one explicit "
|
|
260
|
+
"path/class entry through the normal PR lifecycle."
|
|
261
|
+
),
|
|
262
|
+
},
|
|
263
|
+
"unregistered_candidate_count": len(unregistered_candidates),
|
|
264
|
+
"unregistered_candidates": unregistered_candidates,
|
|
265
|
+
"artifact_policy": build_artifact_policy_summary(project_root),
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def render_documentation_registry_summary(summary: dict[str, Any]) -> str:
|
|
270
|
+
lines = [
|
|
271
|
+
"Documentation registry summary",
|
|
272
|
+
f"registry: {summary['registry_path']}",
|
|
273
|
+
f"version: {summary['version']}",
|
|
274
|
+
f"lifecycle: {summary['lifecycle']}",
|
|
275
|
+
f"broad_migration_allowed: {summary['broad_migration_allowed']}",
|
|
276
|
+
f"documents: {summary['document_count']}",
|
|
277
|
+
"classes:",
|
|
278
|
+
]
|
|
279
|
+
class_counts = summary.get("class_counts", {})
|
|
280
|
+
if isinstance(class_counts, dict):
|
|
281
|
+
for class_name, count in class_counts.items():
|
|
282
|
+
lines.append(f"- {class_name}: {count}")
|
|
283
|
+
lines.append("owners:")
|
|
284
|
+
owner_counts = summary.get("owner_counts", {})
|
|
285
|
+
if isinstance(owner_counts, dict):
|
|
286
|
+
for owner, count in owner_counts.items():
|
|
287
|
+
lines.append(f"- {owner}: {count}")
|
|
288
|
+
lines.extend(
|
|
289
|
+
[
|
|
290
|
+
"registration policy:",
|
|
291
|
+
"- status: reviewed_register_command_available",
|
|
292
|
+
"- mutation_allowed: True",
|
|
293
|
+
"- allowed_command: agentic-kit doc-registry register",
|
|
294
|
+
f"- unregistered_candidates: {summary.get('unregistered_candidate_count', 0)}",
|
|
295
|
+
]
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
artifact_policy = summary.get("artifact_policy", {})
|
|
299
|
+
if isinstance(artifact_policy, dict) and artifact_policy.get("present"):
|
|
300
|
+
lines.extend(
|
|
301
|
+
[
|
|
302
|
+
"artifact policy:",
|
|
303
|
+
f"- policy: {artifact_policy['policy_path']}",
|
|
304
|
+
f"- rules: {artifact_policy['rule_count']}",
|
|
305
|
+
"- delete_allowed:",
|
|
306
|
+
]
|
|
307
|
+
)
|
|
308
|
+
delete_counts = artifact_policy.get("delete_allowed_counts", {})
|
|
309
|
+
if isinstance(delete_counts, dict):
|
|
310
|
+
for value, count in delete_counts.items():
|
|
311
|
+
lines.append(f" - {value}: {count}")
|
|
312
|
+
lines.append("- protected rules:")
|
|
313
|
+
protected = artifact_policy.get("protected_rule_ids", [])
|
|
314
|
+
if isinstance(protected, list):
|
|
315
|
+
for rule_id in protected:
|
|
316
|
+
lines.append(f" - {rule_id}")
|
|
317
|
+
return "\n".join(lines)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def find_unregistered_document_candidates(
|
|
321
|
+
project_root: Path,
|
|
322
|
+
*,
|
|
323
|
+
registered_paths: set[str] | None = None,
|
|
324
|
+
) -> list[str]:
|
|
325
|
+
"""Return document-like files that are not registered yet.
|
|
326
|
+
|
|
327
|
+
This is intentionally inventory-only. It does not mutate the protected
|
|
328
|
+
registry, because document classification still requires explicit review.
|
|
329
|
+
"""
|
|
330
|
+
if registered_paths is None:
|
|
331
|
+
try:
|
|
332
|
+
registry = load_documentation_registry(project_root)
|
|
333
|
+
documents = registry.get("documents", [])
|
|
334
|
+
registered_paths = {
|
|
335
|
+
str(entry.get("path", "")).strip()
|
|
336
|
+
for entry in documents
|
|
337
|
+
if isinstance(entry, dict) and str(entry.get("path", "")).strip()
|
|
338
|
+
}
|
|
339
|
+
except (FileNotFoundError, OSError, ValueError, yaml.YAMLError):
|
|
340
|
+
registered_paths = set()
|
|
341
|
+
candidates: list[str] = []
|
|
342
|
+
for root in DOCUMENT_REGISTRATION_SCAN_ROOTS:
|
|
343
|
+
scan_root = project_root / root
|
|
344
|
+
if not scan_root.exists():
|
|
345
|
+
continue
|
|
346
|
+
for path in sorted(scan_root.rglob("*")):
|
|
347
|
+
if not path.is_file() or path.is_symlink():
|
|
348
|
+
continue
|
|
349
|
+
if path.suffix.lower() not in DOCUMENT_REGISTRATION_SCAN_SUFFIXES:
|
|
350
|
+
continue
|
|
351
|
+
rel = path.relative_to(project_root).as_posix()
|
|
352
|
+
if rel in registered_paths:
|
|
353
|
+
continue
|
|
354
|
+
if any(rel.startswith(prefix) for prefix in DOCUMENT_REGISTRATION_EXCLUDED_PREFIXES):
|
|
355
|
+
continue
|
|
356
|
+
candidates.append(rel)
|
|
357
|
+
return candidates
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _registered_document_paths_from_registry(registry: dict[str, Any]) -> set[str]:
|
|
362
|
+
documents = registry.get("documents", [])
|
|
363
|
+
if not isinstance(documents, list):
|
|
364
|
+
return set()
|
|
365
|
+
return {
|
|
366
|
+
str(entry.get("path", "")).strip()
|
|
367
|
+
for entry in documents
|
|
368
|
+
if isinstance(entry, dict) and str(entry.get("path", "")).strip()
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _scope_exempt_matches(scope: DocumentationRegistryScope, rel_path: str) -> bool:
|
|
373
|
+
return any(fnmatchcase(rel_path, exemption.path) for exemption in scope.exempt_paths)
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _markdown_files_under_scope(project_root: Path, scope: DocumentationRegistryScope) -> list[str]:
|
|
377
|
+
paths: set[str] = set()
|
|
378
|
+
|
|
379
|
+
for required_file in scope.required_files:
|
|
380
|
+
candidate = project_root / required_file
|
|
381
|
+
if candidate.is_file() and candidate.suffix.lower() in SCOPE_REQUIRED_SUFFIXES:
|
|
382
|
+
paths.add(required_file)
|
|
383
|
+
|
|
384
|
+
for required_path in scope.required_paths:
|
|
385
|
+
scan_root = project_root / required_path
|
|
386
|
+
if not scan_root.exists():
|
|
387
|
+
continue
|
|
388
|
+
if scan_root.is_file():
|
|
389
|
+
rel = scan_root.relative_to(project_root).as_posix()
|
|
390
|
+
if scan_root.suffix.lower() in SCOPE_REQUIRED_SUFFIXES:
|
|
391
|
+
paths.add(rel)
|
|
392
|
+
continue
|
|
393
|
+
for candidate in scan_root.rglob("*"):
|
|
394
|
+
if not candidate.is_file() or candidate.is_symlink():
|
|
395
|
+
continue
|
|
396
|
+
if candidate.suffix.lower() not in SCOPE_REQUIRED_SUFFIXES:
|
|
397
|
+
continue
|
|
398
|
+
rel = candidate.relative_to(project_root).as_posix()
|
|
399
|
+
if _scope_exempt_matches(scope, rel):
|
|
400
|
+
continue
|
|
401
|
+
paths.add(rel)
|
|
402
|
+
|
|
403
|
+
return sorted(paths)
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def build_doc_registry_reconcile_scope_decision_rows(project_root: Path) -> list[dict[str, Any]]:
|
|
407
|
+
"""Build deterministic docs-root rows for the scope decision table.
|
|
408
|
+
|
|
409
|
+
This is read-only and intentionally limited to Markdown files under docs/.
|
|
410
|
+
"""
|
|
411
|
+
docs_root = project_root / "docs"
|
|
412
|
+
if not docs_root.exists():
|
|
413
|
+
return []
|
|
414
|
+
|
|
415
|
+
rows: list[dict[str, Any]] = []
|
|
416
|
+
registry = load_documentation_registry(project_root)
|
|
417
|
+
registered_paths = _registered_document_paths_from_registry(registry)
|
|
418
|
+
|
|
419
|
+
for docs_path in sorted(p for p in docs_root.iterdir() if p.is_dir()):
|
|
420
|
+
md_files = sorted(
|
|
421
|
+
candidate.relative_to(project_root).as_posix()
|
|
422
|
+
for candidate in docs_path.rglob("*.md")
|
|
423
|
+
if candidate.is_file() and not candidate.is_symlink()
|
|
424
|
+
)
|
|
425
|
+
registered = [path for path in md_files if path in registered_paths]
|
|
426
|
+
rows.append(
|
|
427
|
+
{
|
|
428
|
+
"docs_path": docs_path.relative_to(project_root).as_posix() + "/",
|
|
429
|
+
"md_files": len(md_files),
|
|
430
|
+
"registered": len(registered),
|
|
431
|
+
"unregistered": len(md_files) - len(registered),
|
|
432
|
+
}
|
|
433
|
+
)
|
|
434
|
+
return rows
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def render_doc_registry_scope_decision_table(rows: list[dict[str, Any]]) -> str:
|
|
438
|
+
lines = [
|
|
439
|
+
"| docs path | md files | registered | unregistered | proposed: required / exempt / undecided |",
|
|
440
|
+
"|---|---:|---:|---:|---|",
|
|
441
|
+
]
|
|
442
|
+
for row in rows:
|
|
443
|
+
lines.append(
|
|
444
|
+
f"| {row['docs_path']} | {row['md_files']} | {row['registered']} | "
|
|
445
|
+
f"{row['unregistered']} | |"
|
|
446
|
+
)
|
|
447
|
+
return "\n".join(lines)
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def build_doc_registry_reconcile_report(project_root: Path) -> dict[str, Any]:
|
|
451
|
+
"""Build a read-only reconciliation report for registry/scope state.
|
|
452
|
+
|
|
453
|
+
The report deliberately performs no writes. It centralizes diagnostics that
|
|
454
|
+
were previously assembled manually during documentation taxonomy cleanup.
|
|
455
|
+
"""
|
|
456
|
+
workspace = load_workspace(project_root)
|
|
457
|
+
registry = load_documentation_registry(project_root)
|
|
458
|
+
scope = load_documentation_registry_scope(project_root)
|
|
459
|
+
registered_paths = _registered_document_paths_from_registry(registry)
|
|
460
|
+
scope_required_markdown = _markdown_files_under_scope(project_root, scope)
|
|
461
|
+
unregistered_candidates = find_unregistered_document_candidates(
|
|
462
|
+
project_root,
|
|
463
|
+
registered_paths=registered_paths,
|
|
464
|
+
)
|
|
465
|
+
strict_scope_violations = [
|
|
466
|
+
path for path in scope_required_markdown if path not in registered_paths
|
|
467
|
+
]
|
|
468
|
+
|
|
469
|
+
rows = build_doc_registry_reconcile_scope_decision_rows(project_root)
|
|
470
|
+
rendered_scope_table = render_doc_registry_scope_decision_table(rows)
|
|
471
|
+
|
|
472
|
+
scope_path = workspace.docs_file("DOC_REGISTRY_SCOPE.yaml")
|
|
473
|
+
decision_path = workspace.governance_file("DOC_REGISTRY_SCOPE_DECISION.md")
|
|
474
|
+
decision_present = decision_path.exists()
|
|
475
|
+
decision_text = decision_path.read_text(encoding="utf-8") if decision_present else ""
|
|
476
|
+
decision_table_stale = (
|
|
477
|
+
decision_present
|
|
478
|
+
and rendered_scope_table not in decision_text
|
|
479
|
+
)
|
|
480
|
+
|
|
481
|
+
findings: list[dict[str, str]] = []
|
|
482
|
+
for error in scope.errors:
|
|
483
|
+
findings.append(
|
|
484
|
+
{
|
|
485
|
+
"severity": "BLOCK",
|
|
486
|
+
"kind": "scope_schema",
|
|
487
|
+
"path": workspace.path_text(scope_path),
|
|
488
|
+
"message": error,
|
|
489
|
+
"next_action": "Fix docs/DOC_REGISTRY_SCOPE.yaml before registry reconciliation.",
|
|
490
|
+
}
|
|
491
|
+
)
|
|
492
|
+
|
|
493
|
+
if strict_scope_violations:
|
|
494
|
+
findings.append(
|
|
495
|
+
{
|
|
496
|
+
"severity": "BLOCK",
|
|
497
|
+
"kind": "strict_scope_unregistered",
|
|
498
|
+
"path": workspace.path_text(scope_path),
|
|
499
|
+
"message": f"{len(strict_scope_violations)} required-scope Markdown files are unregistered.",
|
|
500
|
+
"next_action": "Register reviewed required-scope documents or narrow the declared scope.",
|
|
501
|
+
}
|
|
502
|
+
)
|
|
503
|
+
|
|
504
|
+
if decision_table_stale:
|
|
505
|
+
findings.append(
|
|
506
|
+
{
|
|
507
|
+
"severity": "WARN",
|
|
508
|
+
"kind": "scope_decision_table_stale",
|
|
509
|
+
"path": workspace.path_text(decision_path),
|
|
510
|
+
"message": "Rendered docs-root table differs from DOC_REGISTRY_SCOPE_DECISION.md.",
|
|
511
|
+
"next_action": "Refresh the decision table from the reconcile report before committing taxonomy changes.",
|
|
512
|
+
}
|
|
513
|
+
)
|
|
514
|
+
|
|
515
|
+
status = "PASS"
|
|
516
|
+
if any(finding["severity"] == "BLOCK" for finding in findings):
|
|
517
|
+
status = "BLOCK"
|
|
518
|
+
elif findings:
|
|
519
|
+
status = "WARN"
|
|
520
|
+
|
|
521
|
+
return {
|
|
522
|
+
"schema_version": 1,
|
|
523
|
+
"kind": "doc_registry_reconcile_report",
|
|
524
|
+
"result_status": status,
|
|
525
|
+
"mode": "dry-run",
|
|
526
|
+
"registry_path": workspace.path_text(workspace.doc_registry_path()),
|
|
527
|
+
"scope_path": workspace.path_text(scope_path),
|
|
528
|
+
"scope_decision_path": workspace.path_text(decision_path),
|
|
529
|
+
"scope_present": scope.present,
|
|
530
|
+
"registered_count": len(registered_paths),
|
|
531
|
+
"unregistered_candidate_count": len(unregistered_candidates),
|
|
532
|
+
"unregistered_candidates": unregistered_candidates,
|
|
533
|
+
"scope_required_markdown_count": len(scope_required_markdown),
|
|
534
|
+
"strict_scope_violation_count": len(strict_scope_violations),
|
|
535
|
+
"strict_scope_violations": strict_scope_violations,
|
|
536
|
+
"scope_decision_present": decision_present,
|
|
537
|
+
"scope_decision_table_stale": decision_table_stale,
|
|
538
|
+
"scope_decision_rows": rows,
|
|
539
|
+
"rendered_scope_decision_table": rendered_scope_table,
|
|
540
|
+
"findings": findings,
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def render_doc_registry_reconcile_report(report: dict[str, Any]) -> str:
|
|
545
|
+
lines = [
|
|
546
|
+
"DOC_REGISTRY_RECONCILE",
|
|
547
|
+
f"STATE: {report['result_status']}",
|
|
548
|
+
f"MODE: {report['mode']}",
|
|
549
|
+
f"REGISTRY: {report['registry_path']}",
|
|
550
|
+
f"SCOPE: {report['scope_path']}",
|
|
551
|
+
f"SCOPE_DECISION: {report['scope_decision_path']}",
|
|
552
|
+
f"REGISTERED: {report['registered_count']}",
|
|
553
|
+
f"UNREGISTERED_CANDIDATES: {report['unregistered_candidate_count']}",
|
|
554
|
+
f"STRICT_SCOPE_VIOLATIONS: {report['strict_scope_violation_count']}",
|
|
555
|
+
f"SCOPE_DECISION_TABLE_STALE: {report['scope_decision_table_stale']}",
|
|
556
|
+
"",
|
|
557
|
+
"FINDINGS:",
|
|
558
|
+
]
|
|
559
|
+
findings = report.get("findings", [])
|
|
560
|
+
if not findings:
|
|
561
|
+
lines.append("- none")
|
|
562
|
+
else:
|
|
563
|
+
for finding in findings:
|
|
564
|
+
lines.append(
|
|
565
|
+
f"- {finding['severity']} {finding['kind']} {finding['path']}: "
|
|
566
|
+
f"{finding['message']} NEXT={finding['next_action']}"
|
|
567
|
+
)
|
|
568
|
+
|
|
569
|
+
lines.extend(
|
|
570
|
+
[
|
|
571
|
+
"",
|
|
572
|
+
"RENDERED_SCOPE_DECISION_TABLE:",
|
|
573
|
+
report["rendered_scope_decision_table"],
|
|
574
|
+
]
|
|
575
|
+
)
|
|
576
|
+
return "\n".join(lines)
|
|
577
|
+
|
|
578
|
+
def write_documentation_registry_summary_json(
|
|
579
|
+
summary: dict[str, Any], report_path: Path
|
|
580
|
+
) -> None:
|
|
581
|
+
report_path.parent.mkdir(parents=True, exist_ok=True)
|
|
582
|
+
report_path.write_text(
|
|
583
|
+
json.dumps(summary, indent=2, sort_keys=True) + "\n",
|
|
584
|
+
encoding="utf-8",
|
|
585
|
+
)
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def documentation_registry_findings_for_data(
|
|
589
|
+
project_root: Path,
|
|
590
|
+
registry: dict[str, Any],
|
|
591
|
+
) -> list[str]:
|
|
592
|
+
errors: list[str] = []
|
|
593
|
+
if registry.get("version") != 1:
|
|
594
|
+
errors.append(f"{REGISTRY_PATH}: version must be 1")
|
|
595
|
+
|
|
596
|
+
errors.extend(_check_class_rules(registry))
|
|
597
|
+
errors.extend(_check_document_entries(project_root, registry))
|
|
598
|
+
return errors
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
def check_documentation_registry(project_root: Path) -> list[str]:
|
|
602
|
+
workspace = load_workspace(project_root)
|
|
603
|
+
registry_file = workspace.doc_registry_path()
|
|
604
|
+
if not registry_file.exists():
|
|
605
|
+
if _registry_required(project_root):
|
|
606
|
+
return [f"Missing documentation registry: {workspace.path_text(registry_file)}"]
|
|
607
|
+
return []
|
|
608
|
+
|
|
609
|
+
try:
|
|
610
|
+
registry = load_documentation_registry(project_root)
|
|
611
|
+
except (OSError, ValueError, yaml.YAMLError) as exc:
|
|
612
|
+
return [f"{workspace.path_text(registry_file)}: invalid registry ({exc})"]
|
|
613
|
+
|
|
614
|
+
return documentation_registry_findings_for_data(project_root, registry) + (
|
|
615
|
+
check_documentation_registry_scope(project_root)
|
|
616
|
+
)
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
def register_documentation_registry_entry(
|
|
620
|
+
project_root: Path,
|
|
621
|
+
*,
|
|
622
|
+
document_path: str,
|
|
623
|
+
document_class: str,
|
|
624
|
+
owner: str = "maintainers",
|
|
625
|
+
) -> dict[str, Any]:
|
|
626
|
+
normalized_path = document_path.strip().lstrip("./")
|
|
627
|
+
normalized_class = document_class.strip()
|
|
628
|
+
normalized_owner = owner.strip() or "maintainers"
|
|
629
|
+
workspace = load_workspace(project_root)
|
|
630
|
+
registry_path = workspace.doc_registry_path()
|
|
631
|
+
base_payload: dict[str, Any] = {
|
|
632
|
+
"schema_version": 1,
|
|
633
|
+
"kind": "documentation_registry_register_result",
|
|
634
|
+
"registry_path": workspace.path_text(registry_path),
|
|
635
|
+
"path": normalized_path,
|
|
636
|
+
"class": normalized_class,
|
|
637
|
+
"owner": normalized_owner,
|
|
638
|
+
"written": False,
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
registry = load_documentation_registry(project_root)
|
|
642
|
+
existing_findings = documentation_registry_findings_for_data(project_root, registry)
|
|
643
|
+
if existing_findings:
|
|
644
|
+
return {
|
|
645
|
+
**base_payload,
|
|
646
|
+
"result_status": "FAIL",
|
|
647
|
+
"code": "FAIL_INVALID_REGISTRY",
|
|
648
|
+
"findings": existing_findings,
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
allowed_classes = set(DOCUMENT_CLASSES)
|
|
652
|
+
if normalized_class not in allowed_classes:
|
|
653
|
+
return {
|
|
654
|
+
**base_payload,
|
|
655
|
+
"result_status": "FAIL",
|
|
656
|
+
"code": "FAIL_UNKNOWN_CLASS",
|
|
657
|
+
"allowed_classes": sorted(allowed_classes),
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
if not normalized_path or not (project_root / normalized_path).exists():
|
|
661
|
+
return {
|
|
662
|
+
**base_payload,
|
|
663
|
+
"result_status": "FAIL",
|
|
664
|
+
"code": "FAIL_PATH_NOT_FOUND",
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
documents = registry.get("documents")
|
|
668
|
+
if not isinstance(documents, list):
|
|
669
|
+
return {
|
|
670
|
+
**base_payload,
|
|
671
|
+
"result_status": "FAIL",
|
|
672
|
+
"code": "FAIL_INVALID_REGISTRY",
|
|
673
|
+
"findings": [f"{REGISTRY_PATH}: documents must be a list"],
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
registered_paths = {
|
|
677
|
+
str(entry.get("path", "")).strip()
|
|
678
|
+
for entry in documents
|
|
679
|
+
if isinstance(entry, dict)
|
|
680
|
+
}
|
|
681
|
+
if normalized_path in registered_paths:
|
|
682
|
+
return {
|
|
683
|
+
**base_payload,
|
|
684
|
+
"result_status": "FAIL",
|
|
685
|
+
"code": "FAIL_DUPLICATE_PATH",
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
candidate_registry = dict(registry)
|
|
689
|
+
candidate_documents = list(documents)
|
|
690
|
+
candidate_documents.append(
|
|
691
|
+
{
|
|
692
|
+
"path": normalized_path,
|
|
693
|
+
"class": normalized_class,
|
|
694
|
+
"owner": normalized_owner,
|
|
695
|
+
}
|
|
696
|
+
)
|
|
697
|
+
candidate_registry["documents"] = candidate_documents
|
|
698
|
+
candidate_findings = documentation_registry_findings_for_data(
|
|
699
|
+
project_root,
|
|
700
|
+
candidate_registry,
|
|
701
|
+
)
|
|
702
|
+
if candidate_findings:
|
|
703
|
+
return {
|
|
704
|
+
**base_payload,
|
|
705
|
+
"result_status": "FAIL",
|
|
706
|
+
"code": "FAIL_VALIDATION",
|
|
707
|
+
"findings": candidate_findings,
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
registry_path.write_text(
|
|
711
|
+
yaml.safe_dump(candidate_registry, sort_keys=False, allow_unicode=True),
|
|
712
|
+
encoding="utf-8",
|
|
713
|
+
)
|
|
714
|
+
return {
|
|
715
|
+
**base_payload,
|
|
716
|
+
"result_status": "PASS",
|
|
717
|
+
"code": "PASS",
|
|
718
|
+
"written": True,
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
|
|
722
|
+
def build_unregistered_document_candidates_report(
|
|
723
|
+
project_root: Path,
|
|
724
|
+
*,
|
|
725
|
+
strict_scope: bool = False,
|
|
726
|
+
) -> dict[str, Any]:
|
|
727
|
+
scope = load_documentation_registry_scope(project_root)
|
|
728
|
+
workspace = load_workspace(project_root)
|
|
729
|
+
candidates = find_unregistered_document_candidates(project_root)
|
|
730
|
+
candidates, exempted_count = _filter_exempt_candidates(candidates, scope)
|
|
731
|
+
scope_violations = _find_scope_violations(project_root, scope)
|
|
732
|
+
if scope.errors:
|
|
733
|
+
result_status = "FAIL"
|
|
734
|
+
next_action = "Repair docs/DOC_REGISTRY_SCOPE.yaml before checking registry scope."
|
|
735
|
+
elif strict_scope and scope_violations:
|
|
736
|
+
result_status = "FAIL"
|
|
737
|
+
next_action = "Register or exempt unregistered files in required documentation scope paths."
|
|
738
|
+
elif candidates or scope_violations:
|
|
739
|
+
result_status = "WARN"
|
|
740
|
+
next_action = "Review and classify candidates before registering explicit entries."
|
|
741
|
+
else:
|
|
742
|
+
result_status = "PASS"
|
|
743
|
+
next_action = "No unregistered document candidates found."
|
|
744
|
+
return {
|
|
745
|
+
"schema_version": 1,
|
|
746
|
+
"kind": "documentation_registry_unregistered_candidates",
|
|
747
|
+
"registry_path": workspace.path_text(workspace.doc_registry_path()),
|
|
748
|
+
"scope_path": workspace.path_text(workspace.docs_file("DOC_REGISTRY_SCOPE.yaml")),
|
|
749
|
+
"result_status": result_status,
|
|
750
|
+
"final_signal": "d",
|
|
751
|
+
"strict_scope": strict_scope,
|
|
752
|
+
"scope_present": scope.present,
|
|
753
|
+
"scope_required_file_count": len(scope.required_files),
|
|
754
|
+
"scope_required_path_count": len(scope.required_paths),
|
|
755
|
+
"scope_exempt_path_count": len(scope.exempt_paths),
|
|
756
|
+
"scope_errors": list(scope.errors),
|
|
757
|
+
"exempted_count": exempted_count,
|
|
758
|
+
"scope_violation_count": len(scope_violations),
|
|
759
|
+
"scope_violations": scope_violations,
|
|
760
|
+
"candidate_count": len(candidates),
|
|
761
|
+
"candidates": candidates,
|
|
762
|
+
"next_action": next_action,
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
def build_doc_registry_scope_decision_rows(project_root: Path) -> list[dict[str, int | str]]:
|
|
767
|
+
registered_paths = _registered_document_paths(project_root)
|
|
768
|
+
counters: dict[str, Counter[str]] = {}
|
|
769
|
+
for rel in _iter_docs_markdown_files(
|
|
770
|
+
project_root,
|
|
771
|
+
excluded_prefixes=DOCUMENT_REGISTRATION_EXCLUDED_PREFIXES,
|
|
772
|
+
):
|
|
773
|
+
group = _docs_subdirectory_group(rel)
|
|
774
|
+
bucket = counters.setdefault(group, Counter())
|
|
775
|
+
bucket["md_files"] += 1
|
|
776
|
+
if rel in registered_paths:
|
|
777
|
+
bucket["registered"] += 1
|
|
778
|
+
else:
|
|
779
|
+
bucket["unregistered"] += 1
|
|
780
|
+
|
|
781
|
+
return [
|
|
782
|
+
{
|
|
783
|
+
"docs_path": group,
|
|
784
|
+
"md_files": counters[group]["md_files"],
|
|
785
|
+
"registered": counters[group]["registered"],
|
|
786
|
+
"unregistered": counters[group]["unregistered"],
|
|
787
|
+
}
|
|
788
|
+
for group in sorted(counters, key=lambda value: ("0" if value == "docs/" else value))
|
|
789
|
+
]
|
|
790
|
+
|
|
791
|
+
|
|
792
|
+
def render_doc_registry_scope_decision_template(project_root: Path) -> str:
|
|
793
|
+
rows = build_doc_registry_scope_decision_rows(project_root)
|
|
794
|
+
lines = [
|
|
795
|
+
"# Documentation Registry Scope Decision Template",
|
|
796
|
+
"",
|
|
797
|
+
"Status: active",
|
|
798
|
+
"Decision status: undecided",
|
|
799
|
+
"Review policy: required",
|
|
800
|
+
"",
|
|
801
|
+
"Generated from the current repository filesystem and `docs/DOCUMENTATION_REGISTRY.yaml`.",
|
|
802
|
+
"Counts exclude generated report prefixes that are already outside the registry candidate scan.",
|
|
803
|
+
"No scope recommendation is encoded here; maintainers fill the proposed column after review.",
|
|
804
|
+
"",
|
|
805
|
+
"| docs path | md files | registered | unregistered | proposed: required / exempt / undecided |",
|
|
806
|
+
"|---|---:|---:|---:|---|",
|
|
807
|
+
]
|
|
808
|
+
for row in rows:
|
|
809
|
+
lines.append(
|
|
810
|
+
"| {docs_path} | {md_files} | {registered} | {unregistered} | |".format(
|
|
811
|
+
**row
|
|
812
|
+
)
|
|
813
|
+
)
|
|
814
|
+
lines.extend(
|
|
815
|
+
[
|
|
816
|
+
"",
|
|
817
|
+
"Notes:",
|
|
818
|
+
"- `required_files` means each declared file must have a registry entry.",
|
|
819
|
+
"- `required_paths` means every Markdown file in that declared path must be registered.",
|
|
820
|
+
"- `exempt_paths` means a declared path is intentionally registration-free and must carry a reason.",
|
|
821
|
+
"- This template is evidence for a maintainer decision; it does not modify scope by itself.",
|
|
822
|
+
]
|
|
823
|
+
)
|
|
824
|
+
return "\n".join(lines) + "\n"
|
|
825
|
+
|
|
826
|
+
|
|
827
|
+
def _parse_scope_required_paths(value: object, errors: list[str]) -> list[str]:
|
|
828
|
+
if value in (None, []):
|
|
829
|
+
return []
|
|
830
|
+
if not isinstance(value, list):
|
|
831
|
+
errors.append(f"{SCOPE_PATH}: required_paths must be a list")
|
|
832
|
+
return []
|
|
833
|
+
|
|
834
|
+
required_paths: list[str] = []
|
|
835
|
+
for index, item in enumerate(value, start=1):
|
|
836
|
+
if not isinstance(item, str):
|
|
837
|
+
errors.append(f"{SCOPE_PATH}: required_paths entry {index} must be a string")
|
|
838
|
+
continue
|
|
839
|
+
normalized = _normalize_scope_pattern(item)
|
|
840
|
+
if not normalized:
|
|
841
|
+
errors.append(f"{SCOPE_PATH}: required_paths entry {index} must not be empty")
|
|
842
|
+
continue
|
|
843
|
+
if _scope_pattern_is_unsafe(normalized):
|
|
844
|
+
errors.append(f"{SCOPE_PATH}: required_paths entry {index} is not repository-relative")
|
|
845
|
+
continue
|
|
846
|
+
required_paths.append(normalized)
|
|
847
|
+
return required_paths
|
|
848
|
+
|
|
849
|
+
|
|
850
|
+
def _parse_scope_required_files(value: object, errors: list[str]) -> list[str]:
|
|
851
|
+
if value in (None, []):
|
|
852
|
+
return []
|
|
853
|
+
if not isinstance(value, list):
|
|
854
|
+
errors.append(f"{SCOPE_PATH}: required_files must be a list")
|
|
855
|
+
return []
|
|
856
|
+
required_files: list[str] = []
|
|
857
|
+
for index, entry in enumerate(value, start=1):
|
|
858
|
+
if not isinstance(entry, str):
|
|
859
|
+
errors.append(f"{SCOPE_PATH}: required_files entry {index} must be a string")
|
|
860
|
+
continue
|
|
861
|
+
normalized = _normalize_scope_pattern(entry)
|
|
862
|
+
if not normalized:
|
|
863
|
+
errors.append(f"{SCOPE_PATH}: required_files entry {index} must not be empty")
|
|
864
|
+
continue
|
|
865
|
+
if _scope_pattern_is_unsafe(normalized):
|
|
866
|
+
errors.append(f"{SCOPE_PATH}: required_files entry {index} is not repository-relative")
|
|
867
|
+
continue
|
|
868
|
+
if normalized.endswith("/"):
|
|
869
|
+
errors.append(f"{SCOPE_PATH}: required_files entry {index} must be a file path")
|
|
870
|
+
continue
|
|
871
|
+
required_files.append(normalized)
|
|
872
|
+
return required_files
|
|
873
|
+
|
|
874
|
+
|
|
875
|
+
def _parse_scope_exempt_paths(value: object, errors: list[str]) -> list[ScopeExemption]:
|
|
876
|
+
if value in (None, []):
|
|
877
|
+
return []
|
|
878
|
+
if not isinstance(value, list):
|
|
879
|
+
errors.append(f"{SCOPE_PATH}: exempt_paths must be a list")
|
|
880
|
+
return []
|
|
881
|
+
|
|
882
|
+
exempt_paths: list[ScopeExemption] = []
|
|
883
|
+
for index, item in enumerate(value, start=1):
|
|
884
|
+
if not isinstance(item, dict):
|
|
885
|
+
errors.append(f"{SCOPE_PATH}: exempt_paths entry {index} must be a mapping")
|
|
886
|
+
continue
|
|
887
|
+
raw_path = item.get("path")
|
|
888
|
+
raw_reason = item.get("reason")
|
|
889
|
+
path = _normalize_scope_pattern(raw_path) if isinstance(raw_path, str) else ""
|
|
890
|
+
reason = raw_reason.strip() if isinstance(raw_reason, str) else ""
|
|
891
|
+
if not path:
|
|
892
|
+
errors.append(f"{SCOPE_PATH}: exempt_paths entry {index} missing path")
|
|
893
|
+
continue
|
|
894
|
+
if _scope_pattern_is_unsafe(path):
|
|
895
|
+
errors.append(f"{SCOPE_PATH}: exempt_paths entry {index} path is not repository-relative")
|
|
896
|
+
continue
|
|
897
|
+
if not reason:
|
|
898
|
+
errors.append(f"{SCOPE_PATH}: exempt_paths entry {index} missing reason")
|
|
899
|
+
continue
|
|
900
|
+
exempt_paths.append(ScopeExemption(path=path, reason=reason))
|
|
901
|
+
return exempt_paths
|
|
902
|
+
|
|
903
|
+
|
|
904
|
+
def _normalize_scope_pattern(value: str) -> str:
|
|
905
|
+
return value.strip().replace("\\", "/").lstrip("./")
|
|
906
|
+
|
|
907
|
+
|
|
908
|
+
def _scope_pattern_is_unsafe(pattern: str) -> bool:
|
|
909
|
+
return pattern.startswith("/") or any(part == ".." for part in pattern.split("/"))
|
|
910
|
+
|
|
911
|
+
|
|
912
|
+
def _registered_document_paths(project_root: Path) -> set[str]:
|
|
913
|
+
try:
|
|
914
|
+
registry = load_documentation_registry(project_root)
|
|
915
|
+
except (FileNotFoundError, OSError, ValueError, yaml.YAMLError):
|
|
916
|
+
return set()
|
|
917
|
+
documents = registry.get("documents", [])
|
|
918
|
+
if not isinstance(documents, list):
|
|
919
|
+
return set()
|
|
920
|
+
return {
|
|
921
|
+
str(entry.get("path", "")).strip()
|
|
922
|
+
for entry in documents
|
|
923
|
+
if isinstance(entry, dict) and str(entry.get("path", "")).strip()
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
|
|
927
|
+
def _filter_exempt_candidates(
|
|
928
|
+
candidates: list[str],
|
|
929
|
+
scope: DocumentationRegistryScope,
|
|
930
|
+
) -> tuple[list[str], int]:
|
|
931
|
+
if scope.errors or not scope.exempt_paths:
|
|
932
|
+
return candidates, 0
|
|
933
|
+
filtered = [
|
|
934
|
+
candidate
|
|
935
|
+
for candidate in candidates
|
|
936
|
+
if not _matches_any_scope_pattern(candidate, [exemption.path for exemption in scope.exempt_paths])
|
|
937
|
+
]
|
|
938
|
+
return filtered, len(candidates) - len(filtered)
|
|
939
|
+
|
|
940
|
+
|
|
941
|
+
def _find_scope_violations(
|
|
942
|
+
project_root: Path,
|
|
943
|
+
scope: DocumentationRegistryScope,
|
|
944
|
+
) -> list[str]:
|
|
945
|
+
if scope.errors or (not scope.required_files and not scope.required_paths):
|
|
946
|
+
return []
|
|
947
|
+
registered_paths = _registered_document_paths(project_root)
|
|
948
|
+
exempt_patterns = [exemption.path for exemption in scope.exempt_paths]
|
|
949
|
+
violations = []
|
|
950
|
+
for rel in scope.required_files:
|
|
951
|
+
if rel in registered_paths:
|
|
952
|
+
continue
|
|
953
|
+
if exempt_patterns and _matches_any_scope_pattern(rel, exempt_patterns):
|
|
954
|
+
continue
|
|
955
|
+
violations.append(rel)
|
|
956
|
+
for rel in _iter_docs_markdown_files(project_root):
|
|
957
|
+
if rel in registered_paths:
|
|
958
|
+
continue
|
|
959
|
+
if exempt_patterns and _matches_any_scope_pattern(rel, exempt_patterns):
|
|
960
|
+
continue
|
|
961
|
+
if _matches_any_scope_pattern(rel, scope.required_paths):
|
|
962
|
+
violations.append(rel)
|
|
963
|
+
return sorted(set(violations))
|
|
964
|
+
|
|
965
|
+
|
|
966
|
+
def _iter_docs_markdown_files(
|
|
967
|
+
project_root: Path,
|
|
968
|
+
*,
|
|
969
|
+
excluded_prefixes: tuple[str, ...] = (),
|
|
970
|
+
) -> list[str]:
|
|
971
|
+
docs_root = project_root / "docs"
|
|
972
|
+
if not docs_root.exists():
|
|
973
|
+
return []
|
|
974
|
+
result: list[str] = []
|
|
975
|
+
for path in sorted(docs_root.rglob("*")):
|
|
976
|
+
if not path.is_file() or path.is_symlink():
|
|
977
|
+
continue
|
|
978
|
+
if path.suffix.lower() not in SCOPE_REQUIRED_SUFFIXES:
|
|
979
|
+
continue
|
|
980
|
+
rel = path.relative_to(project_root).as_posix()
|
|
981
|
+
if any(rel.startswith(prefix) for prefix in excluded_prefixes):
|
|
982
|
+
continue
|
|
983
|
+
result.append(rel)
|
|
984
|
+
return result
|
|
985
|
+
|
|
986
|
+
|
|
987
|
+
def _docs_subdirectory_group(relative_path: str) -> str:
|
|
988
|
+
parts = relative_path.split("/")
|
|
989
|
+
if len(parts) < 3:
|
|
990
|
+
return "docs/"
|
|
991
|
+
return f"docs/{parts[1]}/"
|
|
992
|
+
|
|
993
|
+
|
|
994
|
+
def _matches_any_scope_pattern(path: str, patterns: list[str] | tuple[str, ...]) -> bool:
|
|
995
|
+
return any(_matches_scope_pattern(path, pattern) for pattern in patterns)
|
|
996
|
+
|
|
997
|
+
|
|
998
|
+
def _matches_scope_pattern(path: str, pattern: str) -> bool:
|
|
999
|
+
if any(token in pattern for token in "*?["):
|
|
1000
|
+
return fnmatchcase(path, pattern)
|
|
1001
|
+
if pattern.endswith("/"):
|
|
1002
|
+
return path.startswith(pattern)
|
|
1003
|
+
return path == pattern or path.startswith(f"{pattern}/")
|
|
1004
|
+
|
|
1005
|
+
|
|
1006
|
+
def _registry_required(project_root: Path) -> bool:
|
|
1007
|
+
if load_workspace(project_root).governance_file("DOCUMENTATION_REGISTRY_CONTRACT.md").exists():
|
|
1008
|
+
return True
|
|
1009
|
+
compiled_context = project_root / COMPILED_CONTEXT_PATH
|
|
1010
|
+
if not compiled_context.exists():
|
|
1011
|
+
return False
|
|
1012
|
+
try:
|
|
1013
|
+
context = yaml.safe_load(compiled_context.read_text(encoding="utf-8")) or {}
|
|
1014
|
+
except (OSError, yaml.YAMLError):
|
|
1015
|
+
return True
|
|
1016
|
+
return str(REGISTRY_PATH) in str(context)
|
|
1017
|
+
|
|
1018
|
+
|
|
1019
|
+
def _check_class_rules(registry: dict[str, Any]) -> list[str]:
|
|
1020
|
+
class_rules = registry.get("class_rules")
|
|
1021
|
+
if not isinstance(class_rules, dict):
|
|
1022
|
+
return [f"{REGISTRY_PATH}: class_rules must be a mapping"]
|
|
1023
|
+
|
|
1024
|
+
errors: list[str] = []
|
|
1025
|
+
allowed = set(DOCUMENT_CLASSES)
|
|
1026
|
+
present = set(str(name) for name in class_rules)
|
|
1027
|
+
for missing in sorted(allowed - present):
|
|
1028
|
+
errors.append(f"{REGISTRY_PATH}: missing class rule for {missing!r}")
|
|
1029
|
+
for unknown in sorted(present - allowed):
|
|
1030
|
+
errors.append(f"{REGISTRY_PATH}: unknown document class {unknown!r}")
|
|
1031
|
+
|
|
1032
|
+
for class_name in DOCUMENT_CLASSES:
|
|
1033
|
+
rules = class_rules.get(class_name)
|
|
1034
|
+
if not isinstance(rules, dict):
|
|
1035
|
+
continue
|
|
1036
|
+
for field in REQUIRED_CLASS_RULE_FIELDS:
|
|
1037
|
+
value = rules.get(field)
|
|
1038
|
+
if value in (None, "", []):
|
|
1039
|
+
errors.append(
|
|
1040
|
+
f"{REGISTRY_PATH}: {class_name!r} "
|
|
1041
|
+
f"missing class rule field {field!r}"
|
|
1042
|
+
)
|
|
1043
|
+
|
|
1044
|
+
return errors
|
|
1045
|
+
|
|
1046
|
+
|
|
1047
|
+
def _check_document_entries(project_root: Path, registry: dict[str, Any]) -> list[str]:
|
|
1048
|
+
documents = registry.get("documents")
|
|
1049
|
+
if not isinstance(documents, list) or not documents:
|
|
1050
|
+
return [f"{REGISTRY_PATH}: documents must be a non-empty list"]
|
|
1051
|
+
|
|
1052
|
+
errors: list[str] = []
|
|
1053
|
+
seen_paths: set[str] = set()
|
|
1054
|
+
allowed = set(DOCUMENT_CLASSES)
|
|
1055
|
+
|
|
1056
|
+
for index, entry in enumerate(documents, start=1):
|
|
1057
|
+
if not isinstance(entry, dict):
|
|
1058
|
+
errors.append(f"{REGISTRY_PATH}: document entry {index} must be a mapping")
|
|
1059
|
+
continue
|
|
1060
|
+
|
|
1061
|
+
for field in REQUIRED_DOCUMENT_FIELDS:
|
|
1062
|
+
if not str(entry.get(field, "")).strip():
|
|
1063
|
+
errors.append(f"{REGISTRY_PATH}: document entry {index} missing field {field!r}")
|
|
1064
|
+
|
|
1065
|
+
relative_path = str(entry.get("path", "")).strip()
|
|
1066
|
+
document_class = str(entry.get("class", "")).strip()
|
|
1067
|
+
if document_class and document_class not in allowed:
|
|
1068
|
+
errors.append(
|
|
1069
|
+
f"{REGISTRY_PATH}: {relative_path or index} "
|
|
1070
|
+
f"has unknown class {document_class!r}"
|
|
1071
|
+
)
|
|
1072
|
+
|
|
1073
|
+
if relative_path:
|
|
1074
|
+
if relative_path in seen_paths:
|
|
1075
|
+
errors.append(f"{REGISTRY_PATH}: duplicate document path {relative_path!r}")
|
|
1076
|
+
seen_paths.add(relative_path)
|
|
1077
|
+
if not (project_root / relative_path).exists():
|
|
1078
|
+
errors.append(
|
|
1079
|
+
f"{REGISTRY_PATH}: registered document does not exist: {relative_path}"
|
|
1080
|
+
)
|
|
1081
|
+
|
|
1082
|
+
review_after = entry.get("review_after")
|
|
1083
|
+
if review_after is not None and not _is_coarse_review_after(review_after):
|
|
1084
|
+
errors.append(
|
|
1085
|
+
f"{REGISTRY_PATH}: {relative_path or index} "
|
|
1086
|
+
"field 'review_after' must be a non-empty date:, release:, or direction: string"
|
|
1087
|
+
)
|
|
1088
|
+
|
|
1089
|
+
deferred_until = entry.get("deferred_until")
|
|
1090
|
+
if deferred_until is not None and not _is_iso_date_string(deferred_until):
|
|
1091
|
+
errors.append(
|
|
1092
|
+
f"{REGISTRY_PATH}: {relative_path or index} "
|
|
1093
|
+
"field 'deferred_until' must be an ISO date string"
|
|
1094
|
+
)
|
|
1095
|
+
|
|
1096
|
+
errors.extend(
|
|
1097
|
+
validate_dpa_registry_contracts(
|
|
1098
|
+
project_root,
|
|
1099
|
+
documents,
|
|
1100
|
+
registry_path=REGISTRY_PATH,
|
|
1101
|
+
)
|
|
1102
|
+
)
|
|
1103
|
+
return errors
|
|
1104
|
+
|
|
1105
|
+
|
|
1106
|
+
def _is_coarse_review_after(value: Any) -> bool:
|
|
1107
|
+
if not isinstance(value, str):
|
|
1108
|
+
return False
|
|
1109
|
+
stripped = value.strip()
|
|
1110
|
+
if not stripped or "\n" in stripped:
|
|
1111
|
+
return False
|
|
1112
|
+
return stripped.startswith(("date:", "release:", "direction:"))
|
|
1113
|
+
|
|
1114
|
+
|
|
1115
|
+
def _is_iso_date_string(value: Any) -> bool:
|
|
1116
|
+
if not isinstance(value, str):
|
|
1117
|
+
return False
|
|
1118
|
+
try:
|
|
1119
|
+
date.fromisoformat(value)
|
|
1120
|
+
except ValueError:
|
|
1121
|
+
return False
|
|
1122
|
+
return True
|