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.
Files changed (433) hide show
  1. beeverforge/__init__.py +7 -0
  2. beeverforge/actions/__init__.py +506 -0
  3. beeverforge/actions/base.py +490 -0
  4. beeverforge/actions/catalog.py +9549 -0
  5. beeverforge/actions/floor_intents.py +432 -0
  6. beeverforge/adapters/__init__.py +0 -0
  7. beeverforge/adapters/agent/__init__.py +71 -0
  8. beeverforge/adapters/agent/base.py +1101 -0
  9. beeverforge/adapters/agent/claude_code.py +1326 -0
  10. beeverforge/adapters/agent/codex.py +449 -0
  11. beeverforge/adapters/agent/kimi.py +442 -0
  12. beeverforge/adapters/agent/opencode.py +520 -0
  13. beeverforge/adapters/agent/registry.py +550 -0
  14. beeverforge/adapters/agent/roles.py +326 -0
  15. beeverforge/adapters/agent/routes.py +255 -0
  16. beeverforge/adapters/agent/session_store.py +221 -0
  17. beeverforge/adapters/agent/stream.py +367 -0
  18. beeverforge/adapters/agent/techlead.py +108 -0
  19. beeverforge/adapters/agent/token_pool.py +67 -0
  20. beeverforge/adapters/azure_devops.py +466 -0
  21. beeverforge/adapters/board/__init__.py +10 -0
  22. beeverforge/adapters/board/azure_devops.py +831 -0
  23. beeverforge/adapters/board/base.py +545 -0
  24. beeverforge/adapters/board/columns.py +261 -0
  25. beeverforge/adapters/board/factory.py +388 -0
  26. beeverforge/adapters/board/jira.py +487 -0
  27. beeverforge/adapters/board/local.py +360 -0
  28. beeverforge/adapters/board_db.py +423 -0
  29. beeverforge/adapters/board_setup/__init__.py +8 -0
  30. beeverforge/adapters/board_setup/base.py +60 -0
  31. beeverforge/adapters/board_setup/local.py +147 -0
  32. beeverforge/adapters/board_setup/registry.py +41 -0
  33. beeverforge/adapters/channel/__init__.py +5 -0
  34. beeverforge/adapters/channel/base.py +135 -0
  35. beeverforge/adapters/channel/panel.py +107 -0
  36. beeverforge/adapters/channel/registry.py +115 -0
  37. beeverforge/adapters/credential/__init__.py +8 -0
  38. beeverforge/adapters/credential/registry.py +342 -0
  39. beeverforge/adapters/credential/short_lived.py +69 -0
  40. beeverforge/adapters/credential_store/__init__.py +10 -0
  41. beeverforge/adapters/credential_store/base.py +39 -0
  42. beeverforge/adapters/credential_store/env.py +21 -0
  43. beeverforge/adapters/credential_store/file.py +329 -0
  44. beeverforge/adapters/credential_store/registry.py +413 -0
  45. beeverforge/adapters/embed/__init__.py +8 -0
  46. beeverforge/adapters/embed/base.py +39 -0
  47. beeverforge/adapters/embed/local.py +216 -0
  48. beeverforge/adapters/embed/registry.py +136 -0
  49. beeverforge/adapters/environment/__init__.py +30 -0
  50. beeverforge/adapters/environment/azure_pipelines.py +412 -0
  51. beeverforge/adapters/environment/base.py +35 -0
  52. beeverforge/adapters/environment/github_actions.py +108 -0
  53. beeverforge/adapters/environment/none.py +75 -0
  54. beeverforge/adapters/environment/registry.py +210 -0
  55. beeverforge/adapters/extract/__init__.py +6 -0
  56. beeverforge/adapters/extract/base.py +86 -0
  57. beeverforge/adapters/extract/mail.py +83 -0
  58. beeverforge/adapters/extract/markup.py +278 -0
  59. beeverforge/adapters/extract/model.py +94 -0
  60. beeverforge/adapters/extract/office.py +269 -0
  61. beeverforge/adapters/extract/pdf.py +332 -0
  62. beeverforge/adapters/extract/registry.py +218 -0
  63. beeverforge/adapters/extract/text.py +251 -0
  64. beeverforge/adapters/extract/vision.py +82 -0
  65. beeverforge/adapters/forge/__init__.py +30 -0
  66. beeverforge/adapters/forge/azure_devops.py +1961 -0
  67. beeverforge/adapters/forge/base.py +911 -0
  68. beeverforge/adapters/forge/github.py +1428 -0
  69. beeverforge/adapters/forge/local.py +894 -0
  70. beeverforge/adapters/forge/registry.py +252 -0
  71. beeverforge/adapters/github_app.py +173 -0
  72. beeverforge/adapters/github_cli.py +81 -0
  73. beeverforge/adapters/gitworktrees.py +183 -0
  74. beeverforge/adapters/notify/__init__.py +12 -0
  75. beeverforge/adapters/notify/base.py +45 -0
  76. beeverforge/adapters/notify/panel.py +43 -0
  77. beeverforge/adapters/notify/registry.py +301 -0
  78. beeverforge/adapters/preview/__init__.py +23 -0
  79. beeverforge/adapters/preview/base.py +221 -0
  80. beeverforge/adapters/preview/compose.py +1464 -0
  81. beeverforge/adapters/preview/none.py +60 -0
  82. beeverforge/adapters/preview/registry.py +125 -0
  83. beeverforge/adapters/reviewer/__init__.py +30 -0
  84. beeverforge/adapters/reviewer/base.py +75 -0
  85. beeverforge/adapters/reviewer/claude_code.py +195 -0
  86. beeverforge/adapters/reviewer/harness.py +169 -0
  87. beeverforge/adapters/sandbox/__init__.py +33 -0
  88. beeverforge/adapters/sandbox/base.py +470 -0
  89. beeverforge/adapters/sandbox/container.py +903 -0
  90. beeverforge/adapters/sandbox/pull.py +144 -0
  91. beeverforge/adapters/sandbox/registry.py +596 -0
  92. beeverforge/adapters/sandbox/timeouts.py +76 -0
  93. beeverforge/adapters/sandbox/worktree.py +1223 -0
  94. beeverforge/adapters/tracker/__init__.py +30 -0
  95. beeverforge/adapters/tracker/azure_devops.py +1058 -0
  96. beeverforge/adapters/tracker/base.py +876 -0
  97. beeverforge/adapters/tracker/github.py +732 -0
  98. beeverforge/adapters/tracker/github_board_setup.py +241 -0
  99. beeverforge/adapters/tracker/github_project.py +811 -0
  100. beeverforge/adapters/tracker/jira.py +1124 -0
  101. beeverforge/adapters/tracker/local.py +578 -0
  102. beeverforge/adapters/tracker/parse.py +573 -0
  103. beeverforge/adapters/tracker/registry.py +255 -0
  104. beeverforge/after_merge.py +123 -0
  105. beeverforge/agent_answer.py +199 -0
  106. beeverforge/api/__init__.py +4 -0
  107. beeverforge/api/app.py +4603 -0
  108. beeverforge/api/events.py +489 -0
  109. beeverforge/api/metrics_view.py +390 -0
  110. beeverforge/api/openapi.py +55 -0
  111. beeverforge/api/panel.html +6049 -0
  112. beeverforge/api/product_chat.py +709 -0
  113. beeverforge/api/project_route.py +89 -0
  114. beeverforge/api/read_models.py +774 -0
  115. beeverforge/approvals.py +341 -0
  116. beeverforge/box_prove.py +2150 -0
  117. beeverforge/certify/__init__.py +34 -0
  118. beeverforge/certify/controls.py +732 -0
  119. beeverforge/certify/pack.py +779 -0
  120. beeverforge/certify/pack.schema.json +226 -0
  121. beeverforge/certify/redact.py +336 -0
  122. beeverforge/certify/schema.py +144 -0
  123. beeverforge/certify/thresholds.yaml +61 -0
  124. beeverforge/certify/verify.py +469 -0
  125. beeverforge/cli.py +4642 -0
  126. beeverforge/cli_refusals.py +255 -0
  127. beeverforge/conformance/__init__.py +20 -0
  128. beeverforge/conformance/adapters.py +776 -0
  129. beeverforge/contracts/__init__.py +65 -0
  130. beeverforge/contracts/aliases.py +106 -0
  131. beeverforge/contracts/bot.py +42 -0
  132. beeverforge/contracts/checks.py +340 -0
  133. beeverforge/contracts/commands.py +183 -0
  134. beeverforge/contracts/decision.py +286 -0
  135. beeverforge/contracts/document.py +160 -0
  136. beeverforge/contracts/item_space.py +86 -0
  137. beeverforge/contracts/manifest.py +921 -0
  138. beeverforge/contracts/product.py +265 -0
  139. beeverforge/contracts/profile.py +214 -0
  140. beeverforge/contracts/project.py +597 -0
  141. beeverforge/contracts/refs.py +217 -0
  142. beeverforge/contracts/review.py +66 -0
  143. beeverforge/contracts/run.py +456 -0
  144. beeverforge/contracts/state.py +101 -0
  145. beeverforge/contracts/ticket.py +146 -0
  146. beeverforge/credentials.py +853 -0
  147. beeverforge/doctor.py +3504 -0
  148. beeverforge/doors.py +311 -0
  149. beeverforge/envfile.py +55 -0
  150. beeverforge/environ.py +278 -0
  151. beeverforge/factory.py +579 -0
  152. beeverforge/floor/__init__.py +31 -0
  153. beeverforge/floor/ladder.py +725 -0
  154. beeverforge/floor/reading.py +628 -0
  155. beeverforge/gate_seal.py +177 -0
  156. beeverforge/identity/__init__.py +14 -0
  157. beeverforge/identity/base.py +113 -0
  158. beeverforge/identity/local.py +324 -0
  159. beeverforge/identity/oidc.py +654 -0
  160. beeverforge/identity/people.py +444 -0
  161. beeverforge/identity/registry.py +97 -0
  162. beeverforge/knowledge/__init__.py +76 -0
  163. beeverforge/knowledge/bundle.py +220 -0
  164. beeverforge/knowledge/check.py +226 -0
  165. beeverforge/knowledge/contracts.py +407 -0
  166. beeverforge/knowledge/experiment.py +118 -0
  167. beeverforge/knowledge/flows.py +335 -0
  168. beeverforge/knowledge/gaps.py +154 -0
  169. beeverforge/knowledge/gate.py +352 -0
  170. beeverforge/knowledge/gather.py +156 -0
  171. beeverforge/knowledge/generator.py +822 -0
  172. beeverforge/knowledge/inventory.py +557 -0
  173. beeverforge/knowledge/okf.py +370 -0
  174. beeverforge/knowledge/pipeline.py +428 -0
  175. beeverforge/knowledge/render.py +146 -0
  176. beeverforge/knowledge/requests.py +173 -0
  177. beeverforge/knowledge/service.py +53 -0
  178. beeverforge/knowledge/staleness.py +112 -0
  179. beeverforge/knowledge/system/__init__.py +22 -0
  180. beeverforge/knowledge/system/adrs.py +91 -0
  181. beeverforge/knowledge/system/contracts.py +386 -0
  182. beeverforge/knowledge/system/deployments.py +564 -0
  183. beeverforge/knowledge/system/derive.py +887 -0
  184. beeverforge/knowledge/system/interfaces.py +321 -0
  185. beeverforge/knowledge/system/refresh.py +255 -0
  186. beeverforge/knowledge/system/render.py +300 -0
  187. beeverforge/knowledge/system/schemas.py +818 -0
  188. beeverforge/knowledge/system/text.py +201 -0
  189. beeverforge/knowledge/system/tree.py +210 -0
  190. beeverforge/language/__init__.py +15 -0
  191. beeverforge/language/assent.py +135 -0
  192. beeverforge/language/written.py +73 -0
  193. beeverforge/lifecycle/__init__.py +43 -0
  194. beeverforge/lifecycle/card.py +292 -0
  195. beeverforge/lifecycle/executor.py +255 -0
  196. beeverforge/lifecycle/handed_back.py +206 -0
  197. beeverforge/lifecycle/loops.py +404 -0
  198. beeverforge/lifecycle/observed.py +168 -0
  199. beeverforge/lifecycle/ports.py +433 -0
  200. beeverforge/lifecycle/reading.py +100 -0
  201. beeverforge/lifecycle/record.py +211 -0
  202. beeverforge/lifecycle/table.py +673 -0
  203. beeverforge/listeners.py +245 -0
  204. beeverforge/loader.py +173 -0
  205. beeverforge/memory/__init__.py +29 -0
  206. beeverforge/memory/ledger.py +237 -0
  207. beeverforge/memory/messages.py +500 -0
  208. beeverforge/memory/recall.py +414 -0
  209. beeverforge/memory/store.py +157 -0
  210. beeverforge/memory/transcript.py +697 -0
  211. beeverforge/namespace.py +278 -0
  212. beeverforge/observability/__init__.py +23 -0
  213. beeverforge/observability/autonomy.py +233 -0
  214. beeverforge/observability/events.py +195 -0
  215. beeverforge/observability/health.py +533 -0
  216. beeverforge/observability/job_record.py +141 -0
  217. beeverforge/observability/logs.py +368 -0
  218. beeverforge/observability/metrics.py +276 -0
  219. beeverforge/observability/query.py +565 -0
  220. beeverforge/observability/registry.py +260 -0
  221. beeverforge/observability/sqlite_metrics.py +333 -0
  222. beeverforge/observability/trajectory.py +243 -0
  223. beeverforge/onboarding/__init__.py +64 -0
  224. beeverforge/onboarding/concepts.py +450 -0
  225. beeverforge/onboarding/context.py +2428 -0
  226. beeverforge/onboarding/cover.py +97 -0
  227. beeverforge/onboarding/deployment.py +1040 -0
  228. beeverforge/onboarding/firstrun.py +1660 -0
  229. beeverforge/onboarding/history.py +363 -0
  230. beeverforge/onboarding/infer.py +2779 -0
  231. beeverforge/onboarding/live_ci.py +162 -0
  232. beeverforge/onboarding/onboard.py +1101 -0
  233. beeverforge/onboarding/preview_infer.py +1518 -0
  234. beeverforge/onboarding/preview_product.py +354 -0
  235. beeverforge/onboarding/preview_propose.py +1210 -0
  236. beeverforge/onboarding/propose_manifest.py +411 -0
  237. beeverforge/onboarding/questions.py +137 -0
  238. beeverforge/onboarding/readiness.py +1147 -0
  239. beeverforge/onboarding/renew.py +239 -0
  240. beeverforge/onboarding/spend.py +76 -0
  241. beeverforge/onboarding/starter.py +75 -0
  242. beeverforge/ops/__init__.py +6 -0
  243. beeverforge/ops/impediment.py +420 -0
  244. beeverforge/orchestrator/__init__.py +26 -0
  245. beeverforge/orchestrator/context.py +791 -0
  246. beeverforge/orchestrator/errors.py +27 -0
  247. beeverforge/orchestrator/explain.py +430 -0
  248. beeverforge/orchestrator/machine.py +4177 -0
  249. beeverforge/orchestrator/merge_policy.py +244 -0
  250. beeverforge/orchestrator/operator_guidelines.py +406 -0
  251. beeverforge/orchestrator/outcomes.py +39 -0
  252. beeverforge/orchestrator/promotion.py +369 -0
  253. beeverforge/orchestrator/read_only_pass.py +219 -0
  254. beeverforge/orchestrator/risk.py +191 -0
  255. beeverforge/orchestrator/validation.py +172 -0
  256. beeverforge/org_defaults/cards/defect-template.md +23 -0
  257. beeverforge/org_defaults/cards/defect-template.pt-BR.md +23 -0
  258. beeverforge/org_defaults/cards/rubric.yaml +140 -0
  259. beeverforge/org_defaults/cards/template.md +23 -0
  260. beeverforge/org_defaults/cards/template.pt-BR.md +23 -0
  261. beeverforge/org_defaults/engineering.md +92 -0
  262. beeverforge/org_defaults/floor.yaml +192 -0
  263. beeverforge/org_defaults/profiles/prototype.yaml +16 -0
  264. beeverforge/org_defaults/profiles/regulated.yaml +35 -0
  265. beeverforge/org_defaults/requirements/0000-template.md +39 -0
  266. beeverforge/org_defaults/roles/coordinator.md +48 -0
  267. beeverforge/org_defaults/roles/executor.md +100 -0
  268. beeverforge/org_defaults/roles/planner.md +117 -0
  269. beeverforge/org_defaults/roles/product.md +114 -0
  270. beeverforge/org_defaults/roles/recovery.md +35 -0
  271. beeverforge/org_defaults/roles/sizer.md +62 -0
  272. beeverforge/org_defaults/roles/techlead.md +61 -0
  273. beeverforge/org_defaults/tdd.md +10 -0
  274. beeverforge/own_work.py +41 -0
  275. beeverforge/paths.py +144 -0
  276. beeverforge/plugins.py +307 -0
  277. beeverforge/policy/__init__.py +10 -0
  278. beeverforge/policy/authz.py +105 -0
  279. beeverforge/policy/census.py +161 -0
  280. beeverforge/policy/conformance.py +300 -0
  281. beeverforge/policy/context_writes.py +123 -0
  282. beeverforge/policy/floor.py +42 -0
  283. beeverforge/policy/presets.py +174 -0
  284. beeverforge/policy/profiles.py +286 -0
  285. beeverforge/policy/protected.py +175 -0
  286. beeverforge/policy/test_work.py +70 -0
  287. beeverforge/preflight.py +772 -0
  288. beeverforge/presets/node.yaml +6 -0
  289. beeverforge/presets/python.yaml +9 -0
  290. beeverforge/presets/security-oss.yaml +26 -0
  291. beeverforge/presets/terraform.yaml +8 -0
  292. beeverforge/preview/__init__.py +600 -0
  293. beeverforge/preview/admit.py +599 -0
  294. beeverforge/preview/assemble.py +468 -0
  295. beeverforge/preview/demand.py +463 -0
  296. beeverforge/preview/live.py +185 -0
  297. beeverforge/preview/own.py +163 -0
  298. beeverforge/preview/plan.py +205 -0
  299. beeverforge/preview/product.py +219 -0
  300. beeverforge/preview/read.py +379 -0
  301. beeverforge/preview/reap.py +182 -0
  302. beeverforge/preview/siblings.py +140 -0
  303. beeverforge/preview/steps.py +475 -0
  304. beeverforge/preview/unit.py +56 -0
  305. beeverforge/product/__init__.py +56 -0
  306. beeverforge/product/accept.py +276 -0
  307. beeverforge/product/addressing.py +102 -0
  308. beeverforge/product/adjust.py +449 -0
  309. beeverforge/product/agenda.py +329 -0
  310. beeverforge/product/asked.py +270 -0
  311. beeverforge/product/attachments.py +433 -0
  312. beeverforge/product/authoring.py +2727 -0
  313. beeverforge/product/before_code.py +482 -0
  314. beeverforge/product/board.py +634 -0
  315. beeverforge/product/breaking.py +174 -0
  316. beeverforge/product/briefing.py +545 -0
  317. beeverforge/product/brownfield.py +240 -0
  318. beeverforge/product/cap.py +141 -0
  319. beeverforge/product/capabilities.py +438 -0
  320. beeverforge/product/cards.py +1187 -0
  321. beeverforge/product/case.py +545 -0
  322. beeverforge/product/chain.py +389 -0
  323. beeverforge/product/channel.py +215 -0
  324. beeverforge/product/clock.py +102 -0
  325. beeverforge/product/config.py +422 -0
  326. beeverforge/product/confirm.py +1394 -0
  327. beeverforge/product/conversation.py +133 -0
  328. beeverforge/product/corpus.py +624 -0
  329. beeverforge/product/distil.py +597 -0
  330. beeverforge/product/documents/__init__.py +19 -0
  331. beeverforge/product/documents/ingest.py +702 -0
  332. beeverforge/product/documents/reading.py +125 -0
  333. beeverforge/product/documents/record.py +263 -0
  334. beeverforge/product/documents/store.py +146 -0
  335. beeverforge/product/domain.py +283 -0
  336. beeverforge/product/door.py +686 -0
  337. beeverforge/product/engine.py +2686 -0
  338. beeverforge/product/evaluation/__init__.py +26 -0
  339. beeverforge/product/evaluation/__main__.py +80 -0
  340. beeverforge/product/evaluation/battery.py +252 -0
  341. beeverforge/product/evaluation/run.py +428 -0
  342. beeverforge/product/evaluation/score.py +326 -0
  343. beeverforge/product/events.py +968 -0
  344. beeverforge/product/facts.py +371 -0
  345. beeverforge/product/followup.py +761 -0
  346. beeverforge/product/forget.py +718 -0
  347. beeverforge/product/importing.py +1508 -0
  348. beeverforge/product/index/__init__.py +22 -0
  349. beeverforge/product/index/items.py +343 -0
  350. beeverforge/product/index/retrieval.py +577 -0
  351. beeverforge/product/index/search.py +538 -0
  352. beeverforge/product/index/store.py +298 -0
  353. beeverforge/product/index/sync.py +272 -0
  354. beeverforge/product/intents.py +180 -0
  355. beeverforge/product/key.py +43 -0
  356. beeverforge/product/loader.py +302 -0
  357. beeverforge/product/model.py +1591 -0
  358. beeverforge/product/module.py +6800 -0
  359. beeverforge/product/needs_action.py +209 -0
  360. beeverforge/product/onboard.py +402 -0
  361. beeverforge/product/page.py +177 -0
  362. beeverforge/product/progress.py +73 -0
  363. beeverforge/product/queue.py +343 -0
  364. beeverforge/product/reading.py +183 -0
  365. beeverforge/product/refresh.py +402 -0
  366. beeverforge/product/release.py +268 -0
  367. beeverforge/product/requester.py +55 -0
  368. beeverforge/product/role.py +2386 -0
  369. beeverforge/product/semaphore.py +497 -0
  370. beeverforge/product/sessions.py +171 -0
  371. beeverforge/product/sight.py +359 -0
  372. beeverforge/product/sources.py +351 -0
  373. beeverforge/product/speaker.py +166 -0
  374. beeverforge/product/staging.py +992 -0
  375. beeverforge/product/template.py +252 -0
  376. beeverforge/product/triage.py +338 -0
  377. beeverforge/product/unfiled.py +77 -0
  378. beeverforge/product/voice.py +5597 -0
  379. beeverforge/product/waiting.py +113 -0
  380. beeverforge/product/workspace.py +536 -0
  381. beeverforge/registry.py +891 -0
  382. beeverforge/review/__init__.py +11 -0
  383. beeverforge/review/evidence.py +45 -0
  384. beeverforge/review/threads.py +84 -0
  385. beeverforge/review/verdict.py +335 -0
  386. beeverforge/runtime/__init__.py +0 -0
  387. beeverforge/runtime/boxed_job.py +475 -0
  388. beeverforge/runtime/card_repo.py +163 -0
  389. beeverforge/runtime/host.py +398 -0
  390. beeverforge/runtime/repairable.py +232 -0
  391. beeverforge/runtime/repo_cache.py +911 -0
  392. beeverforge/runtime/temporal/__init__.py +26 -0
  393. beeverforge/runtime/temporal/activities.py +7015 -0
  394. beeverforge/runtime/temporal/connection.py +553 -0
  395. beeverforge/runtime/temporal/conversation.py +617 -0
  396. beeverforge/runtime/temporal/deadlines.py +86 -0
  397. beeverforge/runtime/temporal/interceptors.py +81 -0
  398. beeverforge/runtime/temporal/io.py +1042 -0
  399. beeverforge/runtime/temporal/poller.py +185 -0
  400. beeverforge/runtime/temporal/schedule.py +543 -0
  401. beeverforge/runtime/temporal/standing.py +141 -0
  402. beeverforge/runtime/temporal/starter.py +55 -0
  403. beeverforge/runtime/temporal/view.py +1708 -0
  404. beeverforge/runtime/temporal/vocabulary.py +95 -0
  405. beeverforge/runtime/temporal/worker.py +446 -0
  406. beeverforge/runtime/temporal/workflow.py +3550 -0
  407. beeverforge/runtime/toolbox.py +163 -0
  408. beeverforge/scheduler.py +75 -0
  409. beeverforge/semver.py +35 -0
  410. beeverforge/techlead/__init__.py +42 -0
  411. beeverforge/techlead/classify.py +514 -0
  412. beeverforge/techlead/conversation.py +1437 -0
  413. beeverforge/techlead/diagnosis.py +251 -0
  414. beeverforge/techlead/memory.py +292 -0
  415. beeverforge/techlead/pack.py +169 -0
  416. beeverforge/techlead/voice.py +892 -0
  417. beeverforge/techlead/watch.py +647 -0
  418. beeverforge/testing/__init__.py +1 -0
  419. beeverforge/testing/local_flow.py +287 -0
  420. beeverforge/util/__init__.py +0 -0
  421. beeverforge/util/bounded.py +77 -0
  422. beeverforge/util/causes.py +125 -0
  423. beeverforge/util/filelock.py +176 -0
  424. beeverforge/util/scratch.py +80 -0
  425. beeverforge/util/single_flight.py +107 -0
  426. beeverforge/util/yaml_io.py +252 -0
  427. beeverforge-0.1.0.dist-info/METADATA +187 -0
  428. beeverforge-0.1.0.dist-info/RECORD +433 -0
  429. beeverforge-0.1.0.dist-info/WHEEL +5 -0
  430. beeverforge-0.1.0.dist-info/entry_points.txt +2 -0
  431. beeverforge-0.1.0.dist-info/licenses/LICENSE +202 -0
  432. beeverforge-0.1.0.dist-info/licenses/NOTICE +22 -0
  433. 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