beeverforge 0.1.0__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.
- beeverforge/__init__.py +7 -0
- beeverforge/actions/__init__.py +506 -0
- beeverforge/actions/base.py +490 -0
- beeverforge/actions/catalog.py +9549 -0
- beeverforge/actions/floor_intents.py +432 -0
- beeverforge/adapters/__init__.py +0 -0
- beeverforge/adapters/agent/__init__.py +71 -0
- beeverforge/adapters/agent/base.py +1101 -0
- beeverforge/adapters/agent/claude_code.py +1326 -0
- beeverforge/adapters/agent/codex.py +449 -0
- beeverforge/adapters/agent/kimi.py +442 -0
- beeverforge/adapters/agent/opencode.py +520 -0
- beeverforge/adapters/agent/registry.py +550 -0
- beeverforge/adapters/agent/roles.py +326 -0
- beeverforge/adapters/agent/routes.py +255 -0
- beeverforge/adapters/agent/session_store.py +221 -0
- beeverforge/adapters/agent/stream.py +367 -0
- beeverforge/adapters/agent/techlead.py +108 -0
- beeverforge/adapters/agent/token_pool.py +67 -0
- beeverforge/adapters/azure_devops.py +466 -0
- beeverforge/adapters/board/__init__.py +10 -0
- beeverforge/adapters/board/azure_devops.py +831 -0
- beeverforge/adapters/board/base.py +545 -0
- beeverforge/adapters/board/columns.py +261 -0
- beeverforge/adapters/board/factory.py +388 -0
- beeverforge/adapters/board/jira.py +487 -0
- beeverforge/adapters/board/local.py +360 -0
- beeverforge/adapters/board_db.py +423 -0
- beeverforge/adapters/board_setup/__init__.py +8 -0
- beeverforge/adapters/board_setup/base.py +60 -0
- beeverforge/adapters/board_setup/local.py +147 -0
- beeverforge/adapters/board_setup/registry.py +41 -0
- beeverforge/adapters/channel/__init__.py +5 -0
- beeverforge/adapters/channel/base.py +135 -0
- beeverforge/adapters/channel/panel.py +107 -0
- beeverforge/adapters/channel/registry.py +115 -0
- beeverforge/adapters/credential/__init__.py +8 -0
- beeverforge/adapters/credential/registry.py +342 -0
- beeverforge/adapters/credential/short_lived.py +69 -0
- beeverforge/adapters/credential_store/__init__.py +10 -0
- beeverforge/adapters/credential_store/base.py +39 -0
- beeverforge/adapters/credential_store/env.py +21 -0
- beeverforge/adapters/credential_store/file.py +329 -0
- beeverforge/adapters/credential_store/registry.py +413 -0
- beeverforge/adapters/embed/__init__.py +8 -0
- beeverforge/adapters/embed/base.py +39 -0
- beeverforge/adapters/embed/local.py +216 -0
- beeverforge/adapters/embed/registry.py +136 -0
- beeverforge/adapters/environment/__init__.py +30 -0
- beeverforge/adapters/environment/azure_pipelines.py +412 -0
- beeverforge/adapters/environment/base.py +35 -0
- beeverforge/adapters/environment/github_actions.py +108 -0
- beeverforge/adapters/environment/none.py +75 -0
- beeverforge/adapters/environment/registry.py +210 -0
- beeverforge/adapters/extract/__init__.py +6 -0
- beeverforge/adapters/extract/base.py +86 -0
- beeverforge/adapters/extract/mail.py +83 -0
- beeverforge/adapters/extract/markup.py +278 -0
- beeverforge/adapters/extract/model.py +94 -0
- beeverforge/adapters/extract/office.py +269 -0
- beeverforge/adapters/extract/pdf.py +332 -0
- beeverforge/adapters/extract/registry.py +218 -0
- beeverforge/adapters/extract/text.py +251 -0
- beeverforge/adapters/extract/vision.py +82 -0
- beeverforge/adapters/forge/__init__.py +30 -0
- beeverforge/adapters/forge/azure_devops.py +1961 -0
- beeverforge/adapters/forge/base.py +911 -0
- beeverforge/adapters/forge/github.py +1428 -0
- beeverforge/adapters/forge/local.py +894 -0
- beeverforge/adapters/forge/registry.py +252 -0
- beeverforge/adapters/github_app.py +173 -0
- beeverforge/adapters/github_cli.py +81 -0
- beeverforge/adapters/gitworktrees.py +183 -0
- beeverforge/adapters/notify/__init__.py +12 -0
- beeverforge/adapters/notify/base.py +45 -0
- beeverforge/adapters/notify/panel.py +43 -0
- beeverforge/adapters/notify/registry.py +301 -0
- beeverforge/adapters/preview/__init__.py +23 -0
- beeverforge/adapters/preview/base.py +221 -0
- beeverforge/adapters/preview/compose.py +1464 -0
- beeverforge/adapters/preview/none.py +60 -0
- beeverforge/adapters/preview/registry.py +125 -0
- beeverforge/adapters/reviewer/__init__.py +30 -0
- beeverforge/adapters/reviewer/base.py +75 -0
- beeverforge/adapters/reviewer/claude_code.py +195 -0
- beeverforge/adapters/reviewer/harness.py +169 -0
- beeverforge/adapters/sandbox/__init__.py +33 -0
- beeverforge/adapters/sandbox/base.py +470 -0
- beeverforge/adapters/sandbox/container.py +903 -0
- beeverforge/adapters/sandbox/pull.py +144 -0
- beeverforge/adapters/sandbox/registry.py +596 -0
- beeverforge/adapters/sandbox/timeouts.py +76 -0
- beeverforge/adapters/sandbox/worktree.py +1223 -0
- beeverforge/adapters/tracker/__init__.py +30 -0
- beeverforge/adapters/tracker/azure_devops.py +1058 -0
- beeverforge/adapters/tracker/base.py +876 -0
- beeverforge/adapters/tracker/github.py +732 -0
- beeverforge/adapters/tracker/github_board_setup.py +241 -0
- beeverforge/adapters/tracker/github_project.py +811 -0
- beeverforge/adapters/tracker/jira.py +1124 -0
- beeverforge/adapters/tracker/local.py +578 -0
- beeverforge/adapters/tracker/parse.py +573 -0
- beeverforge/adapters/tracker/registry.py +255 -0
- beeverforge/after_merge.py +123 -0
- beeverforge/agent_answer.py +199 -0
- beeverforge/api/__init__.py +4 -0
- beeverforge/api/app.py +4603 -0
- beeverforge/api/events.py +489 -0
- beeverforge/api/metrics_view.py +390 -0
- beeverforge/api/openapi.py +55 -0
- beeverforge/api/panel.html +6049 -0
- beeverforge/api/product_chat.py +709 -0
- beeverforge/api/project_route.py +89 -0
- beeverforge/api/read_models.py +774 -0
- beeverforge/approvals.py +341 -0
- beeverforge/box_prove.py +2150 -0
- beeverforge/certify/__init__.py +34 -0
- beeverforge/certify/controls.py +732 -0
- beeverforge/certify/pack.py +779 -0
- beeverforge/certify/pack.schema.json +226 -0
- beeverforge/certify/redact.py +336 -0
- beeverforge/certify/schema.py +144 -0
- beeverforge/certify/thresholds.yaml +61 -0
- beeverforge/certify/verify.py +469 -0
- beeverforge/cli.py +4642 -0
- beeverforge/cli_refusals.py +255 -0
- beeverforge/conformance/__init__.py +20 -0
- beeverforge/conformance/adapters.py +776 -0
- beeverforge/contracts/__init__.py +65 -0
- beeverforge/contracts/aliases.py +106 -0
- beeverforge/contracts/bot.py +42 -0
- beeverforge/contracts/checks.py +340 -0
- beeverforge/contracts/commands.py +183 -0
- beeverforge/contracts/decision.py +286 -0
- beeverforge/contracts/document.py +160 -0
- beeverforge/contracts/item_space.py +86 -0
- beeverforge/contracts/manifest.py +921 -0
- beeverforge/contracts/product.py +265 -0
- beeverforge/contracts/profile.py +214 -0
- beeverforge/contracts/project.py +597 -0
- beeverforge/contracts/refs.py +217 -0
- beeverforge/contracts/review.py +66 -0
- beeverforge/contracts/run.py +456 -0
- beeverforge/contracts/state.py +101 -0
- beeverforge/contracts/ticket.py +146 -0
- beeverforge/credentials.py +853 -0
- beeverforge/doctor.py +3504 -0
- beeverforge/doors.py +311 -0
- beeverforge/envfile.py +55 -0
- beeverforge/environ.py +278 -0
- beeverforge/factory.py +579 -0
- beeverforge/floor/__init__.py +31 -0
- beeverforge/floor/ladder.py +725 -0
- beeverforge/floor/reading.py +628 -0
- beeverforge/gate_seal.py +177 -0
- beeverforge/identity/__init__.py +14 -0
- beeverforge/identity/base.py +113 -0
- beeverforge/identity/local.py +324 -0
- beeverforge/identity/oidc.py +654 -0
- beeverforge/identity/people.py +444 -0
- beeverforge/identity/registry.py +97 -0
- beeverforge/knowledge/__init__.py +76 -0
- beeverforge/knowledge/bundle.py +220 -0
- beeverforge/knowledge/check.py +226 -0
- beeverforge/knowledge/contracts.py +407 -0
- beeverforge/knowledge/experiment.py +118 -0
- beeverforge/knowledge/flows.py +335 -0
- beeverforge/knowledge/gaps.py +154 -0
- beeverforge/knowledge/gate.py +352 -0
- beeverforge/knowledge/gather.py +156 -0
- beeverforge/knowledge/generator.py +822 -0
- beeverforge/knowledge/inventory.py +557 -0
- beeverforge/knowledge/okf.py +370 -0
- beeverforge/knowledge/pipeline.py +428 -0
- beeverforge/knowledge/render.py +146 -0
- beeverforge/knowledge/requests.py +173 -0
- beeverforge/knowledge/service.py +53 -0
- beeverforge/knowledge/staleness.py +112 -0
- beeverforge/knowledge/system/__init__.py +22 -0
- beeverforge/knowledge/system/adrs.py +91 -0
- beeverforge/knowledge/system/contracts.py +386 -0
- beeverforge/knowledge/system/deployments.py +564 -0
- beeverforge/knowledge/system/derive.py +887 -0
- beeverforge/knowledge/system/interfaces.py +321 -0
- beeverforge/knowledge/system/refresh.py +255 -0
- beeverforge/knowledge/system/render.py +300 -0
- beeverforge/knowledge/system/schemas.py +818 -0
- beeverforge/knowledge/system/text.py +201 -0
- beeverforge/knowledge/system/tree.py +210 -0
- beeverforge/language/__init__.py +15 -0
- beeverforge/language/assent.py +135 -0
- beeverforge/language/written.py +73 -0
- beeverforge/lifecycle/__init__.py +43 -0
- beeverforge/lifecycle/card.py +292 -0
- beeverforge/lifecycle/executor.py +255 -0
- beeverforge/lifecycle/handed_back.py +206 -0
- beeverforge/lifecycle/loops.py +404 -0
- beeverforge/lifecycle/observed.py +168 -0
- beeverforge/lifecycle/ports.py +433 -0
- beeverforge/lifecycle/reading.py +100 -0
- beeverforge/lifecycle/record.py +211 -0
- beeverforge/lifecycle/table.py +673 -0
- beeverforge/listeners.py +245 -0
- beeverforge/loader.py +173 -0
- beeverforge/memory/__init__.py +29 -0
- beeverforge/memory/ledger.py +237 -0
- beeverforge/memory/messages.py +500 -0
- beeverforge/memory/recall.py +414 -0
- beeverforge/memory/store.py +157 -0
- beeverforge/memory/transcript.py +697 -0
- beeverforge/namespace.py +278 -0
- beeverforge/observability/__init__.py +23 -0
- beeverforge/observability/autonomy.py +233 -0
- beeverforge/observability/events.py +195 -0
- beeverforge/observability/health.py +533 -0
- beeverforge/observability/job_record.py +141 -0
- beeverforge/observability/logs.py +368 -0
- beeverforge/observability/metrics.py +276 -0
- beeverforge/observability/query.py +565 -0
- beeverforge/observability/registry.py +260 -0
- beeverforge/observability/sqlite_metrics.py +333 -0
- beeverforge/observability/trajectory.py +243 -0
- beeverforge/onboarding/__init__.py +64 -0
- beeverforge/onboarding/concepts.py +450 -0
- beeverforge/onboarding/context.py +2428 -0
- beeverforge/onboarding/cover.py +97 -0
- beeverforge/onboarding/deployment.py +1040 -0
- beeverforge/onboarding/firstrun.py +1660 -0
- beeverforge/onboarding/history.py +363 -0
- beeverforge/onboarding/infer.py +2779 -0
- beeverforge/onboarding/live_ci.py +162 -0
- beeverforge/onboarding/onboard.py +1101 -0
- beeverforge/onboarding/preview_infer.py +1518 -0
- beeverforge/onboarding/preview_product.py +354 -0
- beeverforge/onboarding/preview_propose.py +1210 -0
- beeverforge/onboarding/propose_manifest.py +411 -0
- beeverforge/onboarding/questions.py +137 -0
- beeverforge/onboarding/readiness.py +1147 -0
- beeverforge/onboarding/renew.py +239 -0
- beeverforge/onboarding/spend.py +76 -0
- beeverforge/onboarding/starter.py +75 -0
- beeverforge/ops/__init__.py +6 -0
- beeverforge/ops/impediment.py +420 -0
- beeverforge/orchestrator/__init__.py +26 -0
- beeverforge/orchestrator/context.py +791 -0
- beeverforge/orchestrator/errors.py +27 -0
- beeverforge/orchestrator/explain.py +430 -0
- beeverforge/orchestrator/machine.py +4177 -0
- beeverforge/orchestrator/merge_policy.py +244 -0
- beeverforge/orchestrator/operator_guidelines.py +406 -0
- beeverforge/orchestrator/outcomes.py +39 -0
- beeverforge/orchestrator/promotion.py +369 -0
- beeverforge/orchestrator/read_only_pass.py +219 -0
- beeverforge/orchestrator/risk.py +191 -0
- beeverforge/orchestrator/validation.py +172 -0
- beeverforge/org_defaults/cards/defect-template.md +23 -0
- beeverforge/org_defaults/cards/defect-template.pt-BR.md +23 -0
- beeverforge/org_defaults/cards/rubric.yaml +140 -0
- beeverforge/org_defaults/cards/template.md +23 -0
- beeverforge/org_defaults/cards/template.pt-BR.md +23 -0
- beeverforge/org_defaults/engineering.md +92 -0
- beeverforge/org_defaults/floor.yaml +192 -0
- beeverforge/org_defaults/profiles/prototype.yaml +16 -0
- beeverforge/org_defaults/profiles/regulated.yaml +35 -0
- beeverforge/org_defaults/requirements/0000-template.md +39 -0
- beeverforge/org_defaults/roles/coordinator.md +48 -0
- beeverforge/org_defaults/roles/executor.md +100 -0
- beeverforge/org_defaults/roles/planner.md +117 -0
- beeverforge/org_defaults/roles/product.md +114 -0
- beeverforge/org_defaults/roles/recovery.md +35 -0
- beeverforge/org_defaults/roles/sizer.md +62 -0
- beeverforge/org_defaults/roles/techlead.md +61 -0
- beeverforge/org_defaults/tdd.md +10 -0
- beeverforge/own_work.py +41 -0
- beeverforge/paths.py +144 -0
- beeverforge/plugins.py +307 -0
- beeverforge/policy/__init__.py +10 -0
- beeverforge/policy/authz.py +105 -0
- beeverforge/policy/census.py +161 -0
- beeverforge/policy/conformance.py +300 -0
- beeverforge/policy/context_writes.py +123 -0
- beeverforge/policy/floor.py +42 -0
- beeverforge/policy/presets.py +174 -0
- beeverforge/policy/profiles.py +286 -0
- beeverforge/policy/protected.py +175 -0
- beeverforge/policy/test_work.py +70 -0
- beeverforge/preflight.py +772 -0
- beeverforge/presets/node.yaml +6 -0
- beeverforge/presets/python.yaml +9 -0
- beeverforge/presets/security-oss.yaml +26 -0
- beeverforge/presets/terraform.yaml +8 -0
- beeverforge/preview/__init__.py +600 -0
- beeverforge/preview/admit.py +599 -0
- beeverforge/preview/assemble.py +468 -0
- beeverforge/preview/demand.py +463 -0
- beeverforge/preview/live.py +185 -0
- beeverforge/preview/own.py +163 -0
- beeverforge/preview/plan.py +205 -0
- beeverforge/preview/product.py +219 -0
- beeverforge/preview/read.py +379 -0
- beeverforge/preview/reap.py +182 -0
- beeverforge/preview/siblings.py +140 -0
- beeverforge/preview/steps.py +475 -0
- beeverforge/preview/unit.py +56 -0
- beeverforge/product/__init__.py +56 -0
- beeverforge/product/accept.py +276 -0
- beeverforge/product/addressing.py +102 -0
- beeverforge/product/adjust.py +449 -0
- beeverforge/product/agenda.py +329 -0
- beeverforge/product/asked.py +270 -0
- beeverforge/product/attachments.py +433 -0
- beeverforge/product/authoring.py +2727 -0
- beeverforge/product/before_code.py +482 -0
- beeverforge/product/board.py +634 -0
- beeverforge/product/breaking.py +174 -0
- beeverforge/product/briefing.py +545 -0
- beeverforge/product/brownfield.py +240 -0
- beeverforge/product/cap.py +141 -0
- beeverforge/product/capabilities.py +438 -0
- beeverforge/product/cards.py +1187 -0
- beeverforge/product/case.py +545 -0
- beeverforge/product/chain.py +389 -0
- beeverforge/product/channel.py +215 -0
- beeverforge/product/clock.py +102 -0
- beeverforge/product/config.py +422 -0
- beeverforge/product/confirm.py +1394 -0
- beeverforge/product/conversation.py +133 -0
- beeverforge/product/corpus.py +624 -0
- beeverforge/product/distil.py +597 -0
- beeverforge/product/documents/__init__.py +19 -0
- beeverforge/product/documents/ingest.py +702 -0
- beeverforge/product/documents/reading.py +125 -0
- beeverforge/product/documents/record.py +263 -0
- beeverforge/product/documents/store.py +146 -0
- beeverforge/product/domain.py +283 -0
- beeverforge/product/door.py +686 -0
- beeverforge/product/engine.py +2686 -0
- beeverforge/product/evaluation/__init__.py +26 -0
- beeverforge/product/evaluation/__main__.py +80 -0
- beeverforge/product/evaluation/battery.py +252 -0
- beeverforge/product/evaluation/run.py +428 -0
- beeverforge/product/evaluation/score.py +326 -0
- beeverforge/product/events.py +968 -0
- beeverforge/product/facts.py +371 -0
- beeverforge/product/followup.py +761 -0
- beeverforge/product/forget.py +718 -0
- beeverforge/product/importing.py +1508 -0
- beeverforge/product/index/__init__.py +22 -0
- beeverforge/product/index/items.py +343 -0
- beeverforge/product/index/retrieval.py +577 -0
- beeverforge/product/index/search.py +538 -0
- beeverforge/product/index/store.py +298 -0
- beeverforge/product/index/sync.py +272 -0
- beeverforge/product/intents.py +180 -0
- beeverforge/product/key.py +43 -0
- beeverforge/product/loader.py +302 -0
- beeverforge/product/model.py +1591 -0
- beeverforge/product/module.py +6800 -0
- beeverforge/product/needs_action.py +209 -0
- beeverforge/product/onboard.py +402 -0
- beeverforge/product/page.py +177 -0
- beeverforge/product/progress.py +73 -0
- beeverforge/product/queue.py +343 -0
- beeverforge/product/reading.py +183 -0
- beeverforge/product/refresh.py +402 -0
- beeverforge/product/release.py +268 -0
- beeverforge/product/requester.py +55 -0
- beeverforge/product/role.py +2386 -0
- beeverforge/product/semaphore.py +497 -0
- beeverforge/product/sessions.py +171 -0
- beeverforge/product/sight.py +359 -0
- beeverforge/product/sources.py +351 -0
- beeverforge/product/speaker.py +166 -0
- beeverforge/product/staging.py +992 -0
- beeverforge/product/template.py +252 -0
- beeverforge/product/triage.py +338 -0
- beeverforge/product/unfiled.py +77 -0
- beeverforge/product/voice.py +5597 -0
- beeverforge/product/waiting.py +113 -0
- beeverforge/product/workspace.py +536 -0
- beeverforge/registry.py +891 -0
- beeverforge/review/__init__.py +11 -0
- beeverforge/review/evidence.py +45 -0
- beeverforge/review/threads.py +84 -0
- beeverforge/review/verdict.py +335 -0
- beeverforge/runtime/__init__.py +0 -0
- beeverforge/runtime/boxed_job.py +475 -0
- beeverforge/runtime/card_repo.py +163 -0
- beeverforge/runtime/host.py +398 -0
- beeverforge/runtime/repairable.py +232 -0
- beeverforge/runtime/repo_cache.py +911 -0
- beeverforge/runtime/temporal/__init__.py +26 -0
- beeverforge/runtime/temporal/activities.py +7015 -0
- beeverforge/runtime/temporal/connection.py +553 -0
- beeverforge/runtime/temporal/conversation.py +617 -0
- beeverforge/runtime/temporal/deadlines.py +86 -0
- beeverforge/runtime/temporal/interceptors.py +81 -0
- beeverforge/runtime/temporal/io.py +1042 -0
- beeverforge/runtime/temporal/poller.py +185 -0
- beeverforge/runtime/temporal/schedule.py +543 -0
- beeverforge/runtime/temporal/standing.py +141 -0
- beeverforge/runtime/temporal/starter.py +55 -0
- beeverforge/runtime/temporal/view.py +1708 -0
- beeverforge/runtime/temporal/vocabulary.py +95 -0
- beeverforge/runtime/temporal/worker.py +446 -0
- beeverforge/runtime/temporal/workflow.py +3550 -0
- beeverforge/runtime/toolbox.py +163 -0
- beeverforge/scheduler.py +75 -0
- beeverforge/semver.py +35 -0
- beeverforge/techlead/__init__.py +42 -0
- beeverforge/techlead/classify.py +514 -0
- beeverforge/techlead/conversation.py +1437 -0
- beeverforge/techlead/diagnosis.py +251 -0
- beeverforge/techlead/memory.py +292 -0
- beeverforge/techlead/pack.py +169 -0
- beeverforge/techlead/voice.py +892 -0
- beeverforge/techlead/watch.py +647 -0
- beeverforge/testing/__init__.py +1 -0
- beeverforge/testing/local_flow.py +287 -0
- beeverforge/util/__init__.py +0 -0
- beeverforge/util/bounded.py +77 -0
- beeverforge/util/causes.py +125 -0
- beeverforge/util/filelock.py +176 -0
- beeverforge/util/scratch.py +80 -0
- beeverforge/util/single_flight.py +107 -0
- beeverforge/util/yaml_io.py +252 -0
- beeverforge-0.1.0.dist-info/METADATA +187 -0
- beeverforge-0.1.0.dist-info/RECORD +433 -0
- beeverforge-0.1.0.dist-info/WHEEL +5 -0
- beeverforge-0.1.0.dist-info/entry_points.txt +2 -0
- beeverforge-0.1.0.dist-info/licenses/LICENSE +202 -0
- beeverforge-0.1.0.dist-info/licenses/NOTICE +22 -0
- beeverforge-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,624 @@
|
|
|
1
|
+
"""The requirements themselves — parsed, indexed, and checked for the ways a corpus rots.
|
|
2
|
+
|
|
3
|
+
ADR-0019 names the failure mode of this whole design: if execution outcomes stop reaching the
|
|
4
|
+
documents, the repository becomes a wish-list describing a product nobody built, contradicting the
|
|
5
|
+
code with total confidence. That failure is silent by nature — nothing crashes, a file simply stops
|
|
6
|
+
being true — so it has to be *detected* rather than waited for. This module is that detector, and
|
|
7
|
+
the reason it exists before the agent that writes into the corpus does.
|
|
8
|
+
|
|
9
|
+
TWO RULES THROUGHOUT
|
|
10
|
+
|
|
11
|
+
1. **Parsing never raises.** A malformed requirement is a finding, not a crash. A single bad file
|
|
12
|
+
must not make the whole corpus unreadable — that would take the product role offline over one
|
|
13
|
+
typo, which is a far worse outcome than one requirement being flagged.
|
|
14
|
+
2. **Every finding names the file.** A validator that reports "3 problems" without saying where is
|
|
15
|
+
a validator nobody runs twice.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import re
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
from pydantic import BaseModel, Field
|
|
24
|
+
|
|
25
|
+
from beeverforge.product.template import (
|
|
26
|
+
EMPTY,
|
|
27
|
+
TEMPLATE_FILE,
|
|
28
|
+
RequirementTemplate,
|
|
29
|
+
load_template,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
#: `0001-slug.md` — the number is the identity, and it comes from the FILENAME rather than the
|
|
33
|
+
#: heading so that two files can never claim the same id while looking different on disk.
|
|
34
|
+
_FILE_RE = re.compile(r"^(?P<num>\d{4})-(?P<slug>[a-z0-9][a-z0-9-]*)\.md$")
|
|
35
|
+
|
|
36
|
+
#: `# REQ-0001 — title`
|
|
37
|
+
_TITLE_RE = re.compile(r"^#\s+REQ-(?P<num>\d{4})\s*[—–-]\s*(?P<title>.+?)\s*$", re.MULTILINE)
|
|
38
|
+
|
|
39
|
+
#: `- **Status:** accepted` — tolerant of the bold markers being dropped, because a human editing
|
|
40
|
+
#: a markdown file by hand should not lose a requirement's status to a missing asterisk.
|
|
41
|
+
def _field_re(name: str) -> re.Pattern[str]:
|
|
42
|
+
# THE NAME ENDS WHERE THE LABEL ENDS (#1001): at a colon, or at the bold marker that closes it.
|
|
43
|
+
# It used to end nowhere, so `- **Date of go-live:** 2027-03-01` above `- **Date:**` was read as
|
|
44
|
+
# the date "of go-live:** 2027-03-01" — the first line that BEGAN with the word won. A label
|
|
45
|
+
# with no colon and no bold is no label.
|
|
46
|
+
return re.compile(rf"^\s*[-*]\s*\**{re.escape(name)}(?:\**\s*:|\*\*)\**\s*(?P<value>.*?)\s*$",
|
|
47
|
+
re.MULTILINE | re.IGNORECASE)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
#: THE status-line pattern — one regex for reading AND rewriting. The writers in `authoring.py`
|
|
51
|
+
#: (`_set_status_accepted`, `_mark_superseded`) locate the line with this exact object: each once
|
|
52
|
+
#: carried its own stricter pattern, so a file this parser read perfectly well was one the writers
|
|
53
|
+
#: silently failed to flip — and an accept was answered "já estava acordado" over a requirement
|
|
54
|
+
#: still `proposed`. Sharing the object, not a copy, is what makes that drift impossible.
|
|
55
|
+
_STATUS_RE = _field_re("Status")
|
|
56
|
+
_ASKED_RE = _field_re("Asked by")
|
|
57
|
+
_DATE_RE = _field_re("Date")
|
|
58
|
+
_SUPERSEDES_RE = _field_re("Supersedes")
|
|
59
|
+
#: The two lines only a reverse-engineering pass writes (`brownfield.render_candidate`). ANCHORED
|
|
60
|
+
#: ON THE BULLET like every field above, which is what keeps the baseline document's own
|
|
61
|
+
#: `### Evidence: tested` headings from being read as a requirement's evidence.
|
|
62
|
+
#:
|
|
63
|
+
#: AND SEARCHED IN THE HEADER ALONE (`_header`), unlike the four above. Those are in every file's
|
|
64
|
+
#: header, so a search finds them there first; these two are in a reading's header and in no
|
|
65
|
+
#: request's, so on a request the search went on into the body and took the first bullet that began
|
|
66
|
+
#: with the word — "- Evidence of payment is not checked here", under Out of scope, was read as
|
|
67
|
+
#: `evidence == "of"`, and accepting what a person asked for filed nothing (#182).
|
|
68
|
+
_EVIDENCE_RE = _field_re("Evidence")
|
|
69
|
+
_OBSERVED_AT_RE = _field_re("Observed at commit")
|
|
70
|
+
|
|
71
|
+
#: The first section heading. Everything above it is where a requirement states its own fields —
|
|
72
|
+
#: the writers', the template's and a hand-edited file's alike — and everything below is its text.
|
|
73
|
+
_FIRST_SECTION_RE = re.compile(r"^#{2,}\s", re.MULTILINE)
|
|
74
|
+
_HEADING_RE = re.compile(r"^##\s+(.+?)\s*$", re.MULTILINE)
|
|
75
|
+
|
|
76
|
+
#: the number in `superseded-by 0007`
|
|
77
|
+
_SUPERSEDED_BY_RE = re.compile(r"superseded[-\s]?by\s*:?\s*(?:REQ-)?(\d{4})", re.IGNORECASE)
|
|
78
|
+
|
|
79
|
+
#: `observed` is NOT a weaker `proposed`, and the difference is the whole reason it exists.
|
|
80
|
+
#:
|
|
81
|
+
#: `proposed` means a PERSON asked for this. `observed` means the code currently behaves this way
|
|
82
|
+
#: and somebody reverse-engineered it — nobody has said it is intended. Collapsing the two would
|
|
83
|
+
#: freeze bugs into promises: once a behaviour is an accepted requirement the factory DEFENDS it,
|
|
84
|
+
#: so a future fix reads as a violation. Anything reverse-engineered therefore lands as `observed`
|
|
85
|
+
#: and stays there until a human confirms it, which is the only event that turns behaviour into a
|
|
86
|
+
#: commitment.
|
|
87
|
+
PROPOSED, ACCEPTED, SUPERSEDED = "proposed", "accepted", "superseded"
|
|
88
|
+
OBSERVED = "observed"
|
|
89
|
+
|
|
90
|
+
#: The writers' word for provenance nobody supplied — `- **Asked by:** unrecorded`. ONE SPELLING,
|
|
91
|
+
#: written by both requirement writers (`authoring.render_requirement`,
|
|
92
|
+
#: `brownfield.render_candidate`) and read back by everything that asks whose a requirement is:
|
|
93
|
+
#: the acceptance writer fills it, the second-yes gate defers to nobody over it. It used to be one
|
|
94
|
+
#: spelling per writer — brownfield's was "nobody — reverse-engineered from the code" — and a gate
|
|
95
|
+
#: that enumerated the spellings it had seen refused every brownfield requirement to everybody
|
|
96
|
+
#: (#70, found in review): the list was asserted against itself, never against the writers.
|
|
97
|
+
UNRECORDED = "unrecorded"
|
|
98
|
+
|
|
99
|
+
#: How strongly a reverse-engineered behaviour is evidenced, strongest first. DEFINED HERE, where
|
|
100
|
+
#: the reader is, and imported by the writer (`brownfield.py`): the tiers used to live beside the
|
|
101
|
+
#: writer alone, which is how the reader came to have a field for them and no way to check one.
|
|
102
|
+
EVIDENCE_ASKED, EVIDENCE_TESTED, EVIDENCE_CODE = "asked", "tested", "code"
|
|
103
|
+
EVIDENCE_TIERS = (EVIDENCE_ASKED, EVIDENCE_TESTED, EVIDENCE_CODE)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def requester_identity(asked_by: str) -> str:
|
|
107
|
+
"""The identity a requirement's `Asked by:` names, bare — or "" when it names nobody the
|
|
108
|
+
factory could compare an actor against.
|
|
109
|
+
|
|
110
|
+
Nobody is: nothing; the placeholder; or PROSE — anything that is not a single token
|
|
111
|
+
("nobody — reverse-engineered from the code", "não registrado", whatever sentence a writer
|
|
112
|
+
invents next, in any language). Decided by SHAPE rather than by a list of phrases, because a
|
|
113
|
+
list is only ever as long as the phrases its author had seen. A bare name IS somebody:
|
|
114
|
+
`admins: [ana]` is how a panel identity is spelled, and `ana` asked."""
|
|
115
|
+
who = (asked_by or "").strip().strip("<@>").strip()
|
|
116
|
+
if not who or who.lower() == UNRECORDED or any(ch.isspace() for ch in who):
|
|
117
|
+
return ""
|
|
118
|
+
return who
|
|
119
|
+
|
|
120
|
+
#: Abandoned — decided against, with nothing taking its place. A SEPARATE STATUS FROM `superseded`,
|
|
121
|
+
#: and the distinction is the record's honesty rather than taxonomy: `superseded-by NNNN` says "read
|
|
122
|
+
#: that one instead", so using it for a requirement nobody replaced would point the reader at a
|
|
123
|
+
#: document that does not exist, and `_cross_check`'s own dangling-pointer rule would (rightly)
|
|
124
|
+
#: call it an error.
|
|
125
|
+
#:
|
|
126
|
+
#: This status exists because the product owner asked the obvious product question the platform had
|
|
127
|
+
#: no answer to: *"something that will no longer be done — isn't that very common in a conversation
|
|
128
|
+
#: with a PO?"* It is, and until now the only way to retire anything was to WRITE SOMETHING ELSE,
|
|
129
|
+
#: which is the exact opposite of what "we are not doing this" means.
|
|
130
|
+
DROPPED = "dropped"
|
|
131
|
+
_KNOWN_STATUS = {PROPOSED, ACCEPTED, SUPERSEDED, OBSERVED, DROPPED}
|
|
132
|
+
|
|
133
|
+
#: the scaffold shipped with a new documentation repo. It is not a requirement and must never be
|
|
134
|
+
#: counted as one, or every corpus starts life with a permanently unfinished entry.
|
|
135
|
+
TEMPLATE_NUMBER = 0
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class Finding(BaseModel):
|
|
139
|
+
"""One problem with the corpus. `level` is `error` (this requirement cannot be trusted) or
|
|
140
|
+
`warn` (it can, but something is decaying)."""
|
|
141
|
+
|
|
142
|
+
level: str
|
|
143
|
+
code: str
|
|
144
|
+
path: str
|
|
145
|
+
message: str
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class Requirement(BaseModel):
|
|
149
|
+
number: int
|
|
150
|
+
slug: str
|
|
151
|
+
path: str
|
|
152
|
+
title: str = ""
|
|
153
|
+
status: str = PROPOSED
|
|
154
|
+
superseded_by: int | None = None
|
|
155
|
+
asked_by: str = ""
|
|
156
|
+
date: str = ""
|
|
157
|
+
supersedes: list[int] = Field(default_factory=list)
|
|
158
|
+
#: source repos / areas the requirement says it affects, verbatim — never resolved here. A
|
|
159
|
+
#: citation that no longer resolves is a flag for a human to review the requirement, never an
|
|
160
|
+
#: error and never silently repaired (ADR-0019: requirement↔code drift cannot be checksummed).
|
|
161
|
+
affects: list[str] = Field(default_factory=list)
|
|
162
|
+
#: whether the file records anything under "Decisions taken during execution" — the write-back
|
|
163
|
+
#: loop, and the single measurable signal that the corpus is still alive
|
|
164
|
+
has_decisions: bool = False
|
|
165
|
+
body: str = ""
|
|
166
|
+
#: the product's own header fields (`requirements/template.yaml`, ADR-0058), by name, as
|
|
167
|
+
#: written — `""` for a field the file does not carry. Empty when the product has no template.
|
|
168
|
+
fields: dict[str, str] = Field(default_factory=dict)
|
|
169
|
+
#: the product's own sections, by name, with their text — only those the file carries
|
|
170
|
+
sections: dict[str, str] = Field(default_factory=dict)
|
|
171
|
+
|
|
172
|
+
#: how strongly the behaviour is evidenced, for a reverse-engineered entry: `asked` (a human
|
|
173
|
+
#: asked for it in an issue or PR — real provenance), `tested` (a test asserts it, so somebody
|
|
174
|
+
#: made it a promise on purpose), `code` (the code does it, with nothing asserting it — the
|
|
175
|
+
#: most likely to be accidental). Empty for an authored requirement, which needs no tier.
|
|
176
|
+
#:
|
|
177
|
+
#: DECLARED FOR A YEAR AND NEVER SET (#182). `parse_requirement` built the model without it, so
|
|
178
|
+
#: on `1512d0a` a file the baseline wrote with `Evidence: tested` read back as `""` and nothing
|
|
179
|
+
#: downstream could tell a reading of the code from a request — which is how accepting one
|
|
180
|
+
#: came to ask the role to invent work for behaviour that is already built.
|
|
181
|
+
evidence: str = ""
|
|
182
|
+
#: the commit of the SOURCE the behaviour was read at — `Observed at commit:`, written by the
|
|
183
|
+
#: baseline and until #182 read by nothing. It stays in the file through the acceptance, so an
|
|
184
|
+
#: accepted reading records which code it was confirmed against. "" when nobody recorded one.
|
|
185
|
+
observed_at: str = ""
|
|
186
|
+
#: whether the header carries EITHER of those two lines at all, whatever it says. Not the same
|
|
187
|
+
#: as either value: the survey's commit is the model's own answer, so a baseline can write
|
|
188
|
+
#: `Observed at commit: unrecorded`, which is no commit and is still the file saying where it
|
|
189
|
+
#: came from. With the Evidence line tidied away, that line was the entry's only provenance and
|
|
190
|
+
#: it read as none — so accepting it broke built behaviour into cards (#182).
|
|
191
|
+
provenance_line: bool = False
|
|
192
|
+
|
|
193
|
+
@property
|
|
194
|
+
def is_promise(self) -> bool:
|
|
195
|
+
"""Whether the factory should DEFEND this. Only an accepted requirement is a commitment;
|
|
196
|
+
an observed one is a reading of the code that nobody has confirmed."""
|
|
197
|
+
return self.status == ACCEPTED
|
|
198
|
+
|
|
199
|
+
@property
|
|
200
|
+
def came_from_the_code(self) -> bool:
|
|
201
|
+
"""Whether this entry was READ OFF THE CODE rather than asked for — it describes something
|
|
202
|
+
the system already does, so there is nothing to build from it (#182).
|
|
203
|
+
|
|
204
|
+
ABOUT WHERE THE TEXT CAME FROM, NOT ABOUT ITS STATUS, and that is the point: `observed`
|
|
205
|
+
stops being the status at the moment a person confirms the entry, which is exactly the
|
|
206
|
+
moment the question matters. The two provenance lines survive the flip, so the answer is
|
|
207
|
+
the same before the acceptance and after it.
|
|
208
|
+
|
|
209
|
+
EITHER LINE IS ENOUGH, AND IT IS THE LINE THAT COUNTS, NOT ITS VALUE. A person tidying the
|
|
210
|
+
header who deletes one of them has not made the behaviour unbuilt, and the one left may be
|
|
211
|
+
the `unrecorded` placeholder (`provenance_line`). Deleting BOTH is the file's own way of
|
|
212
|
+
saying "this is a request now": the entry then reads as authored everywhere, and accepting
|
|
213
|
+
it files work like any other. Only the HEADER's lines count: the same words in the body are
|
|
214
|
+
the text, and a request that mentions evidence is still a request.
|
|
215
|
+
What this cannot see is a body edited into something the code does not do while the lines
|
|
216
|
+
stay — nothing in the file says so, and the honest way through is a person asking for the
|
|
217
|
+
breakdown, which `break_down(asked_for=True)` always performs."""
|
|
218
|
+
return bool(self.evidence or self.observed_at or self.provenance_line)
|
|
219
|
+
|
|
220
|
+
@property
|
|
221
|
+
def is_live(self) -> bool:
|
|
222
|
+
"""Whether this requirement still describes what the product should do. Superseded and
|
|
223
|
+
dropped ones are kept deliberately — the history of what was decided and then reversed is
|
|
224
|
+
what stops the same question being asked a third time — but they must never be handed to an
|
|
225
|
+
agent as current truth.
|
|
226
|
+
|
|
227
|
+
The two dead states are one predicate here and two facts everywhere else: `superseded`
|
|
228
|
+
sends the reader to a replacement, `dropped` says there is none."""
|
|
229
|
+
return self.status not in (SUPERSEDED, DROPPED)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
class Corpus(BaseModel):
|
|
233
|
+
"""Every requirement in a documentation repo, plus what is wrong with it."""
|
|
234
|
+
|
|
235
|
+
requirements: list[Requirement] = Field(default_factory=list)
|
|
236
|
+
findings: list[Finding] = Field(default_factory=list)
|
|
237
|
+
#: the product's own template, when it has one that could be used (ADR-0058)
|
|
238
|
+
template: RequirementTemplate | None = None
|
|
239
|
+
|
|
240
|
+
def by_number(self, number: int) -> Requirement | None:
|
|
241
|
+
return next((r for r in self.requirements if r.number == number), None)
|
|
242
|
+
|
|
243
|
+
def live(self) -> list[Requirement]:
|
|
244
|
+
return [r for r in self.requirements if r.is_live]
|
|
245
|
+
|
|
246
|
+
@property
|
|
247
|
+
def errors(self) -> list[Finding]:
|
|
248
|
+
return [f for f in self.findings if f.level == "error"]
|
|
249
|
+
|
|
250
|
+
def observed(self) -> list[Requirement]:
|
|
251
|
+
"""Reverse-engineered readings awaiting confirmation."""
|
|
252
|
+
return [r for r in self.requirements if r.status == OBSERVED]
|
|
253
|
+
|
|
254
|
+
def promises(self) -> list[Requirement]:
|
|
255
|
+
return [r for r in self.requirements if r.is_promise]
|
|
256
|
+
|
|
257
|
+
def summary(self) -> str:
|
|
258
|
+
"""Counted so that "we have 40 requirements" cannot quietly mean "40 readings of the code
|
|
259
|
+
and no decisions" — the distinction the `observed` status exists to preserve."""
|
|
260
|
+
obs = len(self.observed())
|
|
261
|
+
parts = [f"{len(self.requirements)} requirements",
|
|
262
|
+
f"{len(self.promises())} accepted"]
|
|
263
|
+
if obs:
|
|
264
|
+
parts.append(f"{obs} observed (unconfirmed)")
|
|
265
|
+
return (f"{', '.join(parts)} — {len(self.errors)} errors, "
|
|
266
|
+
f"{len(self.findings) - len(self.errors)} warnings")
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _status_of(text: str, path: str) -> tuple[str, int | None, list[Finding]]:
|
|
270
|
+
"""The status, plus the requirement that replaced it. Unknown text does NOT become `accepted`:
|
|
271
|
+
an unreadable status must never promote a draft into something the factory will build."""
|
|
272
|
+
m = _STATUS_RE.search(text)
|
|
273
|
+
raw = (m.group("value") if m else "").strip()
|
|
274
|
+
# strip a trailing markdown comment, which the template uses to list the allowed values
|
|
275
|
+
raw = re.sub(r"<!--.*?-->", "", raw).strip().lower()
|
|
276
|
+
if not raw:
|
|
277
|
+
return PROPOSED, None, [Finding(
|
|
278
|
+
level="warn", code="status-missing", path=path,
|
|
279
|
+
message="no `Status:` line; treated as `proposed` (never as accepted — an unreadable "
|
|
280
|
+
"status must not promote a draft into something the factory will build)")]
|
|
281
|
+
sup = _SUPERSEDED_BY_RE.search(raw)
|
|
282
|
+
if sup:
|
|
283
|
+
return SUPERSEDED, int(sup.group(1)), []
|
|
284
|
+
if raw.startswith(SUPERSEDED):
|
|
285
|
+
return SUPERSEDED, None, [Finding(
|
|
286
|
+
level="error", code="superseded-without-target", path=path,
|
|
287
|
+
message="status is `superseded` but names no replacement; write "
|
|
288
|
+
"`superseded-by NNNN` so the reader can follow the decision")]
|
|
289
|
+
word = raw.split()[0]
|
|
290
|
+
if word in _KNOWN_STATUS:
|
|
291
|
+
return word, None, []
|
|
292
|
+
return PROPOSED, None, [Finding(
|
|
293
|
+
level="error", code="status-unknown", path=path,
|
|
294
|
+
message=f"unknown status {raw!r}; expected `proposed`, `accepted`, `dropped` or "
|
|
295
|
+
f"`superseded-by NNNN`. Treated as `proposed`.")]
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _numbers(value: str) -> list[int]:
|
|
299
|
+
return [int(n) for n in re.findall(r"\b(\d{4})\b", value or "")]
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _section(text: str, heading: str) -> str:
|
|
303
|
+
"""The body of one `## Heading` section, or "" — used to read the parts of a requirement whose
|
|
304
|
+
presence is itself information."""
|
|
305
|
+
m = re.search(rf"^##\s+{re.escape(heading)}\s*$(?P<body>.*?)(?=^##\s|\Z)",
|
|
306
|
+
text, re.MULTILINE | re.DOTALL | re.IGNORECASE)
|
|
307
|
+
return (m.group("body") if m else "").strip()
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _bullets(block: str) -> list[str]:
|
|
311
|
+
out = []
|
|
312
|
+
for line in block.splitlines():
|
|
313
|
+
s = line.strip()
|
|
314
|
+
if s.startswith(("-", "*")) and len(s) > 2:
|
|
315
|
+
item = s[1:].strip().strip("`")
|
|
316
|
+
if item and not item.startswith("["): # skip checkbox criteria
|
|
317
|
+
out.append(item)
|
|
318
|
+
return out
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def parse_requirement(
|
|
322
|
+
path: Path, text: str, template: RequirementTemplate | None = None
|
|
323
|
+
) -> tuple[Requirement | None, list[Finding]]:
|
|
324
|
+
"""One requirement file. Returns `(None, findings)` only when the FILENAME is unusable — the
|
|
325
|
+
number is the identity, so without it there is nothing to record the problem against.
|
|
326
|
+
|
|
327
|
+
With the product's `template` (ADR-0058), the file's own fields and sections are read as well,
|
|
328
|
+
and what it does not meet is a `warn` finding — never an error: a requirement that predates the
|
|
329
|
+
template is still a requirement."""
|
|
330
|
+
name = path.name
|
|
331
|
+
fm = _FILE_RE.match(name)
|
|
332
|
+
if not fm:
|
|
333
|
+
return None, [Finding(
|
|
334
|
+
level="error", code="filename", path=name,
|
|
335
|
+
message="expected `NNNN-slug.md` (lowercase slug). The number is the requirement's "
|
|
336
|
+
"identity and is read from the filename, so two files can never claim the "
|
|
337
|
+
"same id while looking different on disk.")]
|
|
338
|
+
|
|
339
|
+
number, slug = int(fm.group("num")), fm.group("slug")
|
|
340
|
+
findings: list[Finding] = []
|
|
341
|
+
|
|
342
|
+
tm = _TITLE_RE.search(text)
|
|
343
|
+
title = tm.group("title").strip() if tm else ""
|
|
344
|
+
if not tm:
|
|
345
|
+
findings.append(Finding(level="warn", code="title-missing", path=name,
|
|
346
|
+
message="no `# REQ-NNNN — title` heading"))
|
|
347
|
+
elif int(tm.group("num")) != number:
|
|
348
|
+
findings.append(Finding(
|
|
349
|
+
level="error", code="number-mismatch", path=name,
|
|
350
|
+
message=f"the heading says REQ-{tm.group('num')} but the filename says "
|
|
351
|
+
f"{number:04d}; the filename wins, and one of the two is a copy-paste"))
|
|
352
|
+
|
|
353
|
+
status, superseded_by, status_findings = _status_of(text, name)
|
|
354
|
+
findings += status_findings
|
|
355
|
+
|
|
356
|
+
asked = _ASKED_RE.search(text)
|
|
357
|
+
date = _DATE_RE.search(text)
|
|
358
|
+
supersedes = _SUPERSEDES_RE.search(text)
|
|
359
|
+
head = _header(text)
|
|
360
|
+
evidence, evidence_findings = _evidence_of(head, name)
|
|
361
|
+
findings += evidence_findings
|
|
362
|
+
observed_at = _OBSERVED_AT_RE.search(head)
|
|
363
|
+
|
|
364
|
+
req = Requirement(
|
|
365
|
+
number=number, slug=slug, path=name, title=title, status=status,
|
|
366
|
+
superseded_by=superseded_by,
|
|
367
|
+
asked_by=_clean(asked.group("value") if asked else ""),
|
|
368
|
+
date=_clean(date.group("value") if date else ""),
|
|
369
|
+
supersedes=_numbers(supersedes.group("value") if supersedes else ""),
|
|
370
|
+
affects=_bullets(_section(text, "Affects")),
|
|
371
|
+
has_decisions=bool(_decision_rows(text)),
|
|
372
|
+
body=text,
|
|
373
|
+
evidence=evidence,
|
|
374
|
+
observed_at=_recorded(observed_at.group("value") if observed_at else ""),
|
|
375
|
+
provenance_line=bool(observed_at or _EVIDENCE_RE.search(head)),
|
|
376
|
+
)
|
|
377
|
+
if template is not None:
|
|
378
|
+
for f in template.fields:
|
|
379
|
+
m = _field_re(f.name).search(head)
|
|
380
|
+
req.fields[f.name] = _clean(m.group("value") if m else "")
|
|
381
|
+
if req.fields[f.name] == EMPTY:
|
|
382
|
+
req.fields[f.name] = ""
|
|
383
|
+
for sec in template.sections:
|
|
384
|
+
if (body := _section(text, sec.name)):
|
|
385
|
+
req.sections[sec.name] = body
|
|
386
|
+
present = {m.group(1).strip().lower() for m in _HEADING_RE.finditer(text)}
|
|
387
|
+
for code, sentence in template.problems_of(req.fields, present):
|
|
388
|
+
findings.append(Finding(level="warn", code=code, path=name, message=sentence))
|
|
389
|
+
|
|
390
|
+
# Provenance is not decoration: a requirement nobody can trace back to a person and a date is
|
|
391
|
+
# one nobody can question later. Only warn — a requirement without it is still a requirement.
|
|
392
|
+
if status == ACCEPTED and not req.asked_by:
|
|
393
|
+
findings.append(Finding(level="warn", code="no-asker", path=name,
|
|
394
|
+
message="accepted, but records no `Asked by:` — the trace back to "
|
|
395
|
+
"whoever wanted it is gone"))
|
|
396
|
+
if status == ACCEPTED and not req.date:
|
|
397
|
+
findings.append(Finding(level="warn", code="no-date", path=name,
|
|
398
|
+
message="accepted, but records no `Date:`"))
|
|
399
|
+
return req, findings
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def _clean(value: str) -> str:
|
|
403
|
+
return re.sub(r"<!--.*?-->", "", value or "").strip().strip("<>").strip()
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _header(text: str) -> str:
|
|
407
|
+
"""The part of a requirement above its first section heading — where it states its own fields.
|
|
408
|
+
The whole text when it has no section at all, which is how a file with no sections was read
|
|
409
|
+
before."""
|
|
410
|
+
first = _FIRST_SECTION_RE.search(text)
|
|
411
|
+
return text[:first.start()] if first else text
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _recorded(value: str) -> str:
|
|
415
|
+
"""A field's value with its code ticks off — or "" for the writers' placeholder, so
|
|
416
|
+
`unrecorded` is read as the absence it stands for and never as a commit called that."""
|
|
417
|
+
text = _clean(value).strip("`").strip()
|
|
418
|
+
return "" if text.lower() == UNRECORDED else text
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def _evidence_of(text: str, path: str) -> tuple[str, list[Finding]]:
|
|
422
|
+
"""The tier on an `Evidence:` line — its first word, because the writer follows it with the
|
|
423
|
+
tier's note (`tested — a test asserts this, …`).
|
|
424
|
+
|
|
425
|
+
A WORD THIS BUILD DOES NOT KNOW IS KEPT, WITH A FINDING, NEVER DROPPED. The line's presence is
|
|
426
|
+
the information: somebody recorded that this behaviour was read off the code. Dropping an
|
|
427
|
+
unrecognised tier would turn the entry into an authored requirement, and accepting it would
|
|
428
|
+
then ask the role to build what is built — the expensive direction, and silent. Keeping it
|
|
429
|
+
files nothing and says so, which a person can see and undo."""
|
|
430
|
+
found = _EVIDENCE_RE.search(text)
|
|
431
|
+
raw = _recorded(found.group("value") if found else "")
|
|
432
|
+
tier = re.split(r"[\s—–:,;(-]", raw, maxsplit=1)[0].lower() if raw else ""
|
|
433
|
+
if not tier or tier in EVIDENCE_TIERS:
|
|
434
|
+
return tier, []
|
|
435
|
+
return tier, [Finding(
|
|
436
|
+
level="warn", code="evidence-unknown", path=path,
|
|
437
|
+
message=f"unknown evidence tier {tier!r}; expected one of "
|
|
438
|
+
f"{', '.join(f'`{t}`' for t in EVIDENCE_TIERS)}. The entry is still read as "
|
|
439
|
+
f"reverse-engineered from the code — delete the line if a person asked for this.")]
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
#: The heading of the decision register, WRITTEN ONCE. The renderer creates this section, this
|
|
443
|
+
#: module reads it, `_cross_check` complains when an agreed requirement has nothing in it, and
|
|
444
|
+
#: `authoring.record_decision` appends to it. Four places agreeing on a string by having all been
|
|
445
|
+
#: typed the same way is three chances for the fourth to drift — and the one that drifts is the
|
|
446
|
+
#: WRITER, whose rows then land in a section nothing reads while the file looks correct.
|
|
447
|
+
DECISIONS_HEADING = "Decisions taken during execution"
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def find_decisions_table(text: str) -> tuple[int, int] | None:
|
|
451
|
+
"""`(start, end)` of the decision table's byte range inside `text`, or None.
|
|
452
|
+
|
|
453
|
+
THE WRITER'S HALF OF THE READER BELOW, and it is here rather than in `authoring` so the two
|
|
454
|
+
cannot disagree about what the table is. A writer with its own idea of where the section ends
|
|
455
|
+
appends rows outside it: `has_decisions` stays False, `no-write-back` keeps firing, and the
|
|
456
|
+
client is looking at a decision the corpus swears was never recorded.
|
|
457
|
+
"""
|
|
458
|
+
m = re.search(rf"^##\s+{re.escape(DECISIONS_HEADING)}\s*$(?P<body>.*?)(?=^##\s|\Z)",
|
|
459
|
+
text, re.MULTILINE | re.DOTALL | re.IGNORECASE)
|
|
460
|
+
return (m.start("body"), m.end("body")) if m else None
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def _decision_rows(text: str) -> list[str]:
|
|
464
|
+
"""Rows written back under "Decisions taken during execution" — the write-back loop. The
|
|
465
|
+
template's header and separator rows are not decisions."""
|
|
466
|
+
block = _section(text, DECISIONS_HEADING)
|
|
467
|
+
rows = []
|
|
468
|
+
for line in block.splitlines():
|
|
469
|
+
s = line.strip()
|
|
470
|
+
if not s.startswith("|"):
|
|
471
|
+
continue
|
|
472
|
+
cells = [c.strip() for c in s.strip("|").split("|")]
|
|
473
|
+
if not any(cells):
|
|
474
|
+
continue
|
|
475
|
+
joined = " ".join(cells).lower()
|
|
476
|
+
if set(joined) <= set("-: ") or joined.startswith("date "):
|
|
477
|
+
continue # separator / header
|
|
478
|
+
rows.append(s)
|
|
479
|
+
return rows
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
#: A cell boundary in a register row: a `|` the writer did not escape (`authoring._cell`).
|
|
483
|
+
_CELL_BOUNDARY = re.compile(r"(?<!\\)\|")
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def decision_cells(text: str) -> list[tuple[str, str, str]]:
|
|
487
|
+
"""Every row of the decision register as its three cells — `(day, decision, who decided and
|
|
488
|
+
where)` — with the writer's escapes undone, so a decision that holds a `|` reads back as the
|
|
489
|
+
sentence it was. THE READER'S HALF of `authoring.add_decision_row`, built on the rows the
|
|
490
|
+
corpus already reads (`_decision_rows`): an import that joins a register compares by day and
|
|
491
|
+
text against exactly what `has_decisions` counts (#993)."""
|
|
492
|
+
out: list[tuple[str, str, str]] = []
|
|
493
|
+
for row in _decision_rows(text):
|
|
494
|
+
cells = [c.strip().replace("\\|", "|")
|
|
495
|
+
for c in _CELL_BOUNDARY.split(row.strip().removeprefix("|").removesuffix("|"))]
|
|
496
|
+
cells += [""] * (3 - len(cells))
|
|
497
|
+
out.append((cells[0], cells[1], " | ".join(cells[2:]).strip(" |")))
|
|
498
|
+
return out
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def load_corpus(requirements_dir: str | Path) -> Corpus:
|
|
502
|
+
"""Every `NNNN-slug.md` under a directory, parsed and cross-checked.
|
|
503
|
+
|
|
504
|
+
A missing directory is a finding, not an exception: the product module must be able to report
|
|
505
|
+
"your requirements repo has no requirements yet" rather than fail to start."""
|
|
506
|
+
root = Path(requirements_dir)
|
|
507
|
+
if not root.is_dir():
|
|
508
|
+
return Corpus(findings=[Finding(
|
|
509
|
+
level="error", code="no-directory", path=str(root),
|
|
510
|
+
message="the requirements directory does not exist")])
|
|
511
|
+
|
|
512
|
+
reqs: list[Requirement] = []
|
|
513
|
+
findings: list[Finding] = []
|
|
514
|
+
seen: dict[int, str] = {}
|
|
515
|
+
# THE PRODUCT'S OWN TEMPLATE sits beside the requirements it describes (ADR-0058). One that
|
|
516
|
+
# cannot be used is a finding naming why, and the core's own shape applies.
|
|
517
|
+
template, why = load_template(root)
|
|
518
|
+
if why:
|
|
519
|
+
findings.append(Finding(level="warn", code="template-refused", path=TEMPLATE_FILE,
|
|
520
|
+
message=why))
|
|
521
|
+
|
|
522
|
+
for path in sorted(root.iterdir()):
|
|
523
|
+
if not path.is_file() or path.suffix != ".md":
|
|
524
|
+
continue
|
|
525
|
+
try:
|
|
526
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
527
|
+
except OSError as exc: # unreadable file — a finding, never a crash
|
|
528
|
+
findings.append(Finding(level="error", code="unreadable", path=path.name,
|
|
529
|
+
message=f"could not be read: {exc}"))
|
|
530
|
+
continue
|
|
531
|
+
req, file_findings = parse_requirement(path, text, template)
|
|
532
|
+
findings += file_findings
|
|
533
|
+
if req is None:
|
|
534
|
+
continue
|
|
535
|
+
if req.number == TEMPLATE_NUMBER:
|
|
536
|
+
continue # the scaffold, not a requirement
|
|
537
|
+
if req.number in seen:
|
|
538
|
+
findings.append(Finding(
|
|
539
|
+
level="error", code="duplicate-number", path=path.name,
|
|
540
|
+
message=f"REQ-{req.number:04d} is already defined by {seen[req.number]}; "
|
|
541
|
+
f"a requirement's number is its identity and two files cannot share one"))
|
|
542
|
+
continue
|
|
543
|
+
seen[req.number] = path.name
|
|
544
|
+
reqs.append(req)
|
|
545
|
+
|
|
546
|
+
findings += _cross_check(reqs)
|
|
547
|
+
return Corpus(requirements=reqs, findings=findings, template=template)
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
def _by_slug(reqs: list[Requirement]) -> dict[str, list[Requirement]]:
|
|
551
|
+
"""Requirements grouped by the slug in their filename, oldest number first.
|
|
552
|
+
|
|
553
|
+
The slug is derived from the title by the same `slugify` the writer uses, so it is the one
|
|
554
|
+
identity two files can share without sharing a number."""
|
|
555
|
+
groups: dict[str, list[Requirement]] = {}
|
|
556
|
+
for r in sorted(reqs, key=lambda x: x.number):
|
|
557
|
+
if r.slug:
|
|
558
|
+
groups.setdefault(r.slug, []).append(r)
|
|
559
|
+
return groups
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def _cross_check(reqs: list[Requirement]) -> list[Finding]:
|
|
563
|
+
"""The checks no single file can make on its own — dangling links between requirements, and the
|
|
564
|
+
decay this whole module exists to catch."""
|
|
565
|
+
out: list[Finding] = []
|
|
566
|
+
known = {r.number for r in reqs}
|
|
567
|
+
|
|
568
|
+
# TWO LIVE FILES CARRYING ONE SLUG ARE ONE PROMISE WEARING TWO NUMBERS, and the corpus can say
|
|
569
|
+
# so without anybody's judgment: the slug comes from the title, so an identical slug is an
|
|
570
|
+
# identical title. Observed in production on 2026-07-30 — `0002-totais-de-iva-…` and
|
|
571
|
+
# `0003-totais-de-iva-…`, both live, one of them accepted. The two supersede paths that
|
|
572
|
+
# existed both require the DRAFTER to know it is replacing something; here it did not, because
|
|
573
|
+
# the conversation called the new text "the final version of requirement 2" and this platform
|
|
574
|
+
# has no update-in-place, so the rewrite minted a new number and left its own predecessor
|
|
575
|
+
# standing. An error rather than a warning: the factory picks whichever it reads first.
|
|
576
|
+
for slug, group in _by_slug(reqs).items():
|
|
577
|
+
live = [r for r in group if r.is_live]
|
|
578
|
+
if len(live) < 2:
|
|
579
|
+
continue
|
|
580
|
+
listed = ", ".join(f"REQ-{r.number:04d} ({r.status})" for r in live)
|
|
581
|
+
out.append(Finding(
|
|
582
|
+
level="error", code="same-promise-twice", path=live[-1].path,
|
|
583
|
+
message=f"{listed} are all live and share one title ({slug!r}) — that is one promise "
|
|
584
|
+
f"under several numbers, and the factory will defend whichever it reads "
|
|
585
|
+
f"first. Exactly one of them may stay live; the rest belong "
|
|
586
|
+
f"`superseded-by` it."))
|
|
587
|
+
|
|
588
|
+
for r in reqs:
|
|
589
|
+
if r.superseded_by is not None:
|
|
590
|
+
if r.superseded_by not in known:
|
|
591
|
+
out.append(Finding(
|
|
592
|
+
level="error", code="dangling-superseded-by", path=r.path,
|
|
593
|
+
message=f"says it was superseded by REQ-{r.superseded_by:04d}, which does not "
|
|
594
|
+
f"exist. The reader is sent to a dead end, which is worse than no "
|
|
595
|
+
f"pointer at all."))
|
|
596
|
+
elif r.superseded_by == r.number:
|
|
597
|
+
out.append(Finding(level="error", code="self-superseded", path=r.path,
|
|
598
|
+
message="says it was superseded by itself"))
|
|
599
|
+
for target in r.supersedes:
|
|
600
|
+
replaced = next((x for x in reqs if x.number == target), None)
|
|
601
|
+
if replaced is None:
|
|
602
|
+
out.append(Finding(
|
|
603
|
+
level="error", code="dangling-supersedes", path=r.path,
|
|
604
|
+
message=f"claims to supersede REQ-{target:04d}, which does not exist"))
|
|
605
|
+
elif replaced.status != SUPERSEDED:
|
|
606
|
+
# the two halves of one decision must agree, or the corpus asserts both that a
|
|
607
|
+
# requirement was replaced and that it is still current
|
|
608
|
+
out.append(Finding(
|
|
609
|
+
level="error", code="supersede-not-mutual", path=r.path,
|
|
610
|
+
message=f"claims to supersede REQ-{target:04d}, but that file's status is "
|
|
611
|
+
f"{replaced.status!r}. Set it to `superseded-by {r.number:04d}`: as it "
|
|
612
|
+
f"stands the corpus says one requirement is replaced AND current."))
|
|
613
|
+
|
|
614
|
+
# THE ROT SIGNAL. An accepted requirement that has been built but records nothing learned
|
|
615
|
+
# is how this repository turns into a wish-list. A warning, deliberately: it is a prompt to
|
|
616
|
+
# write back, not a claim that the requirement is wrong.
|
|
617
|
+
if r.status == ACCEPTED and not r.has_decisions: # observed entries are exempt: nothing
|
|
618
|
+
# has been executed against a reading of the code that nobody has confirmed yet
|
|
619
|
+
out.append(Finding(
|
|
620
|
+
level="warn", code="no-write-back", path=r.path,
|
|
621
|
+
message="accepted, but records no decisions taken during execution. If work has "
|
|
622
|
+
"happened against it, what was learned has not reached the document — the "
|
|
623
|
+
"way this corpus decays into a wish-list nobody trusts."))
|
|
624
|
+
return out
|