@techierathore/techieflow 1.0.2 → 1.0.4

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 (204) hide show
  1. package/.claude/commands/TechieFlow/agents/analyst.md +5 -13
  2. package/.claude/commands/TechieFlow/agents/architect.md +1 -16
  3. package/.claude/commands/TechieFlow/agents/flow-master.md +42 -215
  4. package/.claude/commands/TechieFlow/agents/verifier.md +24 -64
  5. package/.claude/commands/TechieFlow/tasks/_metrics-emit-gate.md +24 -117
  6. package/.claude/commands/TechieFlow/tasks/_owner-language.md +26 -0
  7. package/.claude/commands/TechieFlow/tasks/_smoke-test-policy.md +20 -84
  8. package/.claude/commands/TechieFlow/tasks/_status-update-gate.md +10 -74
  9. package/.claude/commands/TechieFlow/tasks/_yolo-mode.md +20 -58
  10. package/.claude/commands/TechieFlow/tasks/amend-docs.md +21 -143
  11. package/.claude/commands/TechieFlow/tasks/build-phase.md +14 -224
  12. package/.claude/commands/TechieFlow/tasks/create-deep-research-prompt.md +4 -0
  13. package/.claude/commands/TechieFlow/tasks/create-doc.md +4 -0
  14. package/.claude/commands/TechieFlow/tasks/create-project-brief.md +22 -0
  15. package/.claude/commands/TechieFlow/tasks/day1-brownfield.md +31 -505
  16. package/.claude/commands/TechieFlow/tasks/day1-greenfield.md +28 -213
  17. package/.claude/commands/TechieFlow/tasks/deploy-checklist.md +19 -0
  18. package/.claude/commands/TechieFlow/tasks/devguide.md +10 -219
  19. package/.claude/commands/TechieFlow/tasks/facilitate-brainstorming-session.md +4 -0
  20. package/.claude/commands/TechieFlow/tasks/fix-issues.md +10 -129
  21. package/.claude/commands/TechieFlow/tasks/generate-html.md +5 -1
  22. package/.claude/commands/TechieFlow/tasks/handoff-phase.md +18 -16
  23. package/.claude/commands/TechieFlow/tasks/log-miss.md +19 -158
  24. package/.claude/commands/TechieFlow/tasks/metrics-report.md +3 -1
  25. package/.claude/commands/TechieFlow/tasks/mockups.md +12 -117
  26. package/.claude/commands/TechieFlow/tasks/refresh-status.md +9 -130
  27. package/.claude/commands/TechieFlow/tasks/split-brd.md +10 -126
  28. package/.claude/commands/TechieFlow/tasks/triage-and-fix.md +14 -0
  29. package/.claude/commands/TechieFlow/tasks/triage-issues.md +11 -152
  30. package/.claude/commands/TechieFlow/tasks/verify-phase.md +36 -572
  31. package/.opencode/plugin/techieflow.js +40 -13
  32. package/.tfcore/agents/analyst.md +5 -13
  33. package/.tfcore/agents/architect.md +1 -16
  34. package/.tfcore/agents/flow-master.md +42 -215
  35. package/.tfcore/agents/verifier.md +24 -64
  36. package/.tfcore/core-config.yaml +10 -0
  37. package/.tfcore/hooks/block-git.sh +4 -4
  38. package/.tfcore/hooks/guard-artifacts.sh +2 -2
  39. package/.tfcore/hooks/guard-build.sh +76 -0
  40. package/.tfcore/hooks/guard-db.sh +90 -0
  41. package/.tfcore/hooks/guard-metrics.sh +68 -0
  42. package/.tfcore/hooks/guard-status-html.sh +116 -71
  43. package/.tfcore/hooks/guard-status.sh +43 -16
  44. package/.tfcore/hooks/guard-verify-deps.sh +124 -0
  45. package/.tfcore/hooks/guard-verify.sh +70 -48
  46. package/.tfcore/hooks/sweep-artifacts.sh +2 -2
  47. package/.tfcore/install-manifest.yaml +0 -18
  48. package/.tfcore/routing.yaml +86 -14
  49. package/.tfcore/standards/coding-standards-core.md +61 -0
  50. package/.tfcore/standards/coding-standards-dotnet.md +93 -0
  51. package/.tfcore/tasks/_metrics-emit-gate.md +24 -117
  52. package/.tfcore/tasks/_owner-language.md +26 -0
  53. package/.tfcore/tasks/_smoke-test-policy.md +20 -84
  54. package/.tfcore/tasks/_status-update-gate.md +10 -74
  55. package/.tfcore/tasks/_yolo-mode.md +20 -58
  56. package/.tfcore/tasks/amend-docs.md +21 -143
  57. package/.tfcore/tasks/build-phase.md +14 -224
  58. package/.tfcore/tasks/create-deep-research-prompt.md +4 -0
  59. package/.tfcore/tasks/create-doc.md +4 -0
  60. package/.tfcore/tasks/create-project-brief.md +22 -0
  61. package/.tfcore/tasks/day1-brownfield.md +31 -505
  62. package/.tfcore/tasks/day1-greenfield.md +28 -213
  63. package/.tfcore/tasks/deploy-checklist.md +19 -0
  64. package/.tfcore/tasks/devguide.md +10 -219
  65. package/.tfcore/tasks/facilitate-brainstorming-session.md +4 -0
  66. package/.tfcore/tasks/fix-issues.md +10 -129
  67. package/.tfcore/tasks/generate-html.md +5 -1
  68. package/.tfcore/tasks/handoff-phase.md +18 -16
  69. package/.tfcore/tasks/log-miss.md +19 -158
  70. package/.tfcore/tasks/metrics-report.md +3 -1
  71. package/.tfcore/tasks/mockups.md +12 -117
  72. package/.tfcore/tasks/refresh-status.md +9 -130
  73. package/.tfcore/tasks/split-brd.md +10 -126
  74. package/.tfcore/tasks/triage-and-fix.md +14 -0
  75. package/.tfcore/tasks/triage-issues.md +11 -152
  76. package/.tfcore/tasks/verify-phase.md +36 -572
  77. package/.tfcore/telemetry/SCHEMA.md +100 -8
  78. package/.tfcore/telemetry/tf-metrics.sh +564 -32
  79. package/.tfcore/templates/stack-defaults/dotnet.md +35 -0
  80. package/.tfcore/templates/stack-questions.md +77 -0
  81. package/.tfcore/templates/v4custom/app-agents-md-tmpl.md +3 -4
  82. package/.tfcore/templates/v4custom/app-architecture-tmpl.md +107 -115
  83. package/.tfcore/templates/v4custom/app-brd-tmpl.md +108 -184
  84. package/.tfcore/templates/v4custom/app-brief-tmpl.md +65 -0
  85. package/.tfcore/templates/v4custom/app-checklist-tmpl.md +48 -73
  86. package/.tfcore/templates/v4custom/app-claude-md-tmpl.md +1 -1
  87. package/.tfcore/templates/v4custom/app-coding-standards-tmpl.md +52 -0
  88. package/.tfcore/templates/v4custom/app-decision-request-tmpl.md +85 -0
  89. package/.tfcore/templates/v4custom/app-deployment-checklist-tmpl.md +86 -0
  90. package/.tfcore/templates/v4custom/app-devguide-tmpl.md +69 -146
  91. package/.tfcore/templates/v4custom/app-library-feedback-tmpl.md +86 -31
  92. package/.tfcore/templates/v4custom/app-phases-tmpl.md +35 -0
  93. package/.tfcore/templates/v4custom/app-productguide-tmpl.md +38 -35
  94. package/.tfcore/templates/v4custom/app-project-status-tmpl.md +63 -73
  95. package/.tfcore/templates/v4custom/app-uidesign-tmpl.md +62 -37
  96. package/.tfcore/templates/v4custom/app-usageguide-tmpl.md +69 -54
  97. package/.tfcore/templates/v4custom/build-invocation-ladder.md +17 -180
  98. package/.tfcore/templates/v4custom/build-subagent-prompt.md +17 -0
  99. package/.tfcore/templates/v4custom/html-render-shell.md +1 -1
  100. package/.tfcore/templates/v4custom/metrics-report-template.md +2 -2
  101. package/.tfcore/user-guide.md +0 -23
  102. package/.tfcore/utils/__pycache__/tf-brd-status.cpython-312.pyc +0 -0
  103. package/.tfcore/utils/__pycache__/tf-checklist-edit.cpython-312.pyc +0 -0
  104. package/.tfcore/utils/__pycache__/tf-codex-bind.cpython-312.pyc +0 -0
  105. package/.tfcore/utils/__pycache__/tf-codex-telemetry.cpython-312.pyc +0 -0
  106. package/.tfcore/utils/__pycache__/tf-day1-files.cpython-312.pyc +0 -0
  107. package/.tfcore/utils/__pycache__/tf-devguide-list.cpython-312.pyc +0 -0
  108. package/.tfcore/utils/__pycache__/tf-doc-check.cpython-312.pyc +0 -0
  109. package/.tfcore/utils/__pycache__/tf-log-miss.cpython-312.pyc +0 -0
  110. package/.tfcore/utils/__pycache__/tf-misses-md.cpython-312.pyc +0 -0
  111. package/.tfcore/utils/__pycache__/tf-mockups-locate.cpython-312.pyc +0 -0
  112. package/.tfcore/utils/__pycache__/tf-opencode-audit.cpython-312.pyc +0 -0
  113. package/.tfcore/utils/__pycache__/tf-perf-grade.cpython-312.pyc +0 -0
  114. package/.tfcore/utils/__pycache__/tf-render-html.cpython-312.pyc +0 -0
  115. package/.tfcore/utils/__pycache__/tf-split-brd.cpython-312.pyc +0 -0
  116. package/.tfcore/utils/__pycache__/tf-status-facts.cpython-312.pyc +0 -0
  117. package/.tfcore/utils/__pycache__/tf-triage.cpython-312.pyc +0 -0
  118. package/.tfcore/utils/__pycache__/tf-verify-list.cpython-312.pyc +0 -0
  119. package/.tfcore/utils/__pycache__/tf-verify-verdict.cpython-312.pyc +0 -0
  120. package/.tfcore/utils/tf-brd-status.py +176 -0
  121. package/.tfcore/utils/tf-brd-status.sh +19 -0
  122. package/.tfcore/utils/tf-build-list.py +211 -0
  123. package/.tfcore/utils/tf-build-list.sh +21 -0
  124. package/.tfcore/utils/tf-build.sh +143 -0
  125. package/.tfcore/utils/tf-cdp-relay.ps1 +64 -0
  126. package/.tfcore/utils/tf-checklist-edit.py +133 -0
  127. package/.tfcore/utils/tf-day1-files.py +208 -0
  128. package/.tfcore/utils/tf-day1-files.sh +20 -0
  129. package/.tfcore/utils/tf-devguide-list.py +249 -0
  130. package/.tfcore/utils/tf-devguide-list.sh +18 -0
  131. package/.tfcore/utils/tf-doc-check.py +1369 -0
  132. package/.tfcore/utils/tf-doc-check.sh +29 -0
  133. package/.tfcore/utils/tf-emit.sh +545 -18
  134. package/.tfcore/utils/tf-fix-close.sh +115 -0
  135. package/.tfcore/utils/tf-gitignore-audit.sh +50 -3
  136. package/.tfcore/utils/tf-goal.sh +284 -66
  137. package/.tfcore/utils/tf-harness.sh +4 -9
  138. package/.tfcore/utils/tf-log-miss.py +197 -0
  139. package/.tfcore/utils/tf-log-miss.sh +14 -0
  140. package/.tfcore/utils/tf-misses-md.py +208 -0
  141. package/.tfcore/utils/tf-misses-md.sh +14 -0
  142. package/.tfcore/utils/tf-mockups-locate.py +66 -0
  143. package/.tfcore/utils/tf-mockups-locate.sh +18 -0
  144. package/.tfcore/utils/tf-model-pick.sh +475 -0
  145. package/.tfcore/utils/tf-opencode-audit.py +23 -7
  146. package/.tfcore/utils/tf-perf-grade.py +97 -0
  147. package/.tfcore/utils/tf-perf-grade.sh +12 -0
  148. package/.tfcore/utils/tf-phase.sh +92 -0
  149. package/.tfcore/utils/tf-render-html.py +20 -0
  150. package/.tfcore/utils/tf-routing-bind.sh +97 -6
  151. package/.tfcore/utils/tf-routing.sh +13 -7
  152. package/.tfcore/utils/tf-selfcheck.sh +323 -0
  153. package/.tfcore/utils/tf-split-brd.py +309 -0
  154. package/.tfcore/utils/tf-split-brd.sh +22 -0
  155. package/.tfcore/utils/tf-status-evidence.sh +68 -0
  156. package/.tfcore/utils/tf-status-facts.py +358 -0
  157. package/.tfcore/utils/tf-status-facts.sh +20 -0
  158. package/.tfcore/utils/tf-triage.py +243 -0
  159. package/.tfcore/utils/tf-triage.sh +16 -0
  160. package/.tfcore/utils/tf-verify-boot.sh +250 -0
  161. package/.tfcore/utils/tf-verify-emit.sh +110 -0
  162. package/.tfcore/utils/tf-verify-env.sh +88 -0
  163. package/.tfcore/utils/tf-verify-list.py +270 -0
  164. package/.tfcore/utils/tf-verify-list.sh +13 -0
  165. package/.tfcore/utils/tf-verify-screens.mjs +355 -0
  166. package/.tfcore/utils/tf-verify-screens.sh +31 -0
  167. package/.tfcore/utils/tf-verify-tests.sh +166 -0
  168. package/.tfcore/utils/tf-verify-verdict.py +292 -0
  169. package/.tfcore/utils/tf-verify-verdict.sh +15 -0
  170. package/.tfcore/utils/tf-yolo.sh +59 -0
  171. package/README.md +120 -1145
  172. package/docs/TechieFlow-Installation.md +1 -5
  173. package/opencode.jsonc +37 -33
  174. package/package.json +1 -5
  175. package/scripts/install.mjs +56 -46
  176. package/.claude/commands/TechieFlow/tasks/advanced-elicitation.md +0 -117
  177. package/.claude/commands/TechieFlow/tasks/author-brd.md +0 -79
  178. package/.claude/commands/TechieFlow/tasks/document-project.md +0 -343
  179. package/.claude/commands/TechieFlow/tasks/execute-checklist.md +0 -86
  180. package/.claude/commands/TechieFlow/tasks/index-docs.md +0 -173
  181. package/.claude/commands/TechieFlow/tasks/kb-mode-interaction.md +0 -75
  182. package/.claude/commands/TechieFlow/tasks/shard-doc.md +0 -185
  183. package/.codex/config.toml +0 -46
  184. package/.codex/hooks.json +0 -63
  185. package/.codex/rules/techieflow.rules +0 -8
  186. package/.tfcore/hooks/codex-adapter.py +0 -248
  187. package/.tfcore/tasks/advanced-elicitation.md +0 -117
  188. package/.tfcore/tasks/author-brd.md +0 -79
  189. package/.tfcore/tasks/document-project.md +0 -343
  190. package/.tfcore/tasks/execute-checklist.md +0 -86
  191. package/.tfcore/tasks/index-docs.md +0 -173
  192. package/.tfcore/tasks/kb-mode-interaction.md +0 -75
  193. package/.tfcore/tasks/shard-doc.md +0 -185
  194. package/.tfcore/templates/architecture-tmpl.yaml +0 -650
  195. package/.tfcore/templates/brownfield-architecture-tmpl.yaml +0 -476
  196. package/.tfcore/templates/competitor-analysis-tmpl.yaml +0 -306
  197. package/.tfcore/templates/front-end-architecture-tmpl.yaml +0 -218
  198. package/.tfcore/templates/fullstack-architecture-tmpl.yaml +0 -823
  199. package/.tfcore/templates/market-research-tmpl.yaml +0 -252
  200. package/.tfcore/templates/project-brief-tmpl.yaml +0 -221
  201. package/.tfcore/utils/techieflow-doc-template.md +0 -325
  202. package/.tfcore/utils/tf-codex-bind.py +0 -196
  203. package/.tfcore/utils/tf-codex-telemetry.py +0 -60
  204. package/WORKFLOW.html +0 -1986
@@ -0,0 +1,1369 @@
1
+ #!/usr/bin/env python3
2
+ """tf-doc-check.py — check a TechieFlow human document against its template schema.
3
+
4
+ Every template under .tfcore/templates/v4custom/ opens with a `<!-- tf-schema ... -->`
5
+ block: required sections in order, word budgets per size, per-entry limits, and the
6
+ named row rules. This script reads that block and checks a generated document
7
+ against it. One line per problem, in plain words:
8
+
9
+ FAIL docs/MyApp-BRD.md: section "Scope" is missing
10
+ WARN docs/MyApp-BRD.md: 6,410 words; the Small target is 6,000 (maximum 8,000)
11
+
12
+ Exit 0 when nothing FAILs, 1 when something does, 2 when it could not run.
13
+ `--warn` prints every finding as WARN and exits 0 (report mode for existing projects).
14
+
15
+ Called through tf-doc-check.sh. Python 3 standard library only.
16
+ Readable description of the same rules: docs/TechieFlow-Document-Schemas.md.
17
+
18
+ Schema grammar (one `key: value` per line inside the comment):
19
+ doc: brd file: docs/{App}-BRD.md
20
+ header: App, Kind, Size fields that must be present and filled in
21
+ section: Name | flag | max N flag = required | optional | optional-small (required for M/L)
22
+ | app (required when Kind=app) | library (required when Kind=library)
23
+ a trailing * on the name matches any heading with that prefix
24
+ budget: S 6000 8000 | M … target and maximum words per size (code blocks and comments excluded)
25
+ entries: Section | Prefix: the section holding one ### entry per screen/task; optional H3 prefix
26
+ per-entry: 250 400 target and maximum words per entry
27
+ max-lines: 120 target-lines: 60
28
+ rule: name a named check implemented below (rules starting entry- run per entry)
29
+ """
30
+ from __future__ import annotations
31
+
32
+ import argparse
33
+ import json
34
+ import os
35
+ import re
36
+ import sys
37
+
38
+ SELF_DIR = os.path.dirname(os.path.abspath(__file__))
39
+ TEMPLATE_DIR = os.path.normpath(os.path.join(SELF_DIR, "..", "templates", "v4custom"))
40
+
41
+ # file-name suffix (lower-case) -> (doc id, template file)
42
+ DOC_KINDS = [
43
+ ("-brd.md", "brd", "app-brd-tmpl.md"),
44
+ ("-architecture.md", "architecture", "app-architecture-tmpl.md"),
45
+ ("-uidesign.md", "uidesign", "app-uidesign-tmpl.md"),
46
+ ("-deployment-checklist.md", "deployment-checklist", "app-deployment-checklist-tmpl.md"), # before -checklist.md
47
+ ("-checklist.md", "checklist", "app-checklist-tmpl.md"),
48
+ ("coding-standards.md", "coding-standards", "app-coding-standards-tmpl.md"),
49
+ ("project-status.md", "project-status", "app-project-status-tmpl.md"),
50
+ ("-usageguide.md", "usageguide", "app-usageguide-tmpl.md"),
51
+ ("-usage-guide.md", "usageguide", "app-usageguide-tmpl.md"),
52
+ ("-devguide.md", "devguide", "app-devguide-tmpl.md"),
53
+ ("-productguide.md", "productguide", "app-productguide-tmpl.md"),
54
+ ("-phases.md", "phases", "app-phases-tmpl.md"),
55
+ ("-brief.md", "brief", "app-brief-tmpl.md"),
56
+ ("-decision-request.md", "decision-request", "app-decision-request-tmpl.md"),
57
+ ("-feedback.md", "feedback", "app-library-feedback-tmpl.md"),
58
+ ]
59
+ SIZED_DOCS = {"brd", "architecture", "uidesign", "checklist", "usageguide", "devguide", "productguide"}
60
+ # Large projects: these four split by phase. Phase 1 keeps the plain name (App-BRD.md);
61
+ # phase 2 onward are App-P2-BRD.md and so on. docs/TechieFlow-Document-Schemas.md §2.
62
+ PHASED_DOCS = {"brd", "checklist", "uidesign", "devguide"}
63
+ PHASED_SUFFIX = {"brd": "BRD", "checklist": "Checklist", "uidesign": "UIDesign", "devguide": "DevGuide"}
64
+ # A feedback entry's heading opens with its id: TF-016, TR-010, TR-RAG-002.
65
+ ENTRY_ID = re.compile(r"^[A-Z][A-Z0-9]*(?:-[A-Z0-9]+)*-\d+\b")
66
+ # A closed entry carries its resolution banner near the top of its body. The banner is not
67
+ # always first: an entry may open with a renumbering or scope note, so the window is generous.
68
+ FEEDBACK_CLOSED = re.compile(r"(?i)(✅|\bfixed upstream\b|\bwill not fix\b|\bwont-fix\b|is \*\*closed\*\*)")
69
+ DOC_NAME = re.compile(
70
+ r"^(.+?)(?:-P(\d+))?-(BRD|Deployment-Checklist(?:-[\w]+)?|Checklist|UIDesign|DevGuide|Architecture|Coding-Standards|UsageGuide|Usage-Guide|ProductGuide|Phases)\.md$",
71
+ re.I)
72
+ PHASE_FORM = re.compile(r"^(\d+)\s+of\s+(\d+)$", re.I)
73
+ PHASE_STATUS = {"planned", "building", "done"}
74
+ BRD_RANGE = re.compile(r"(?:BRD-)?(\d+)\s*(?:to|-|–|—)\s*(?:BRD-)?(\d+)", re.I)
75
+
76
+ SIZE_NAMES = {"s": "S", "small": "S", "m": "M", "medium": "M", "l": "L", "large": "L"}
77
+ SIZE_LONG = {"S": "Small", "M": "Medium", "L": "Large"}
78
+ REQ_CAP = {"S": 50, "M": 100, "L": 100}
79
+
80
+ CHECKLIST_HEADER = "| ID | Requirement | Status | % | Remarks | Details |"
81
+ STATUS_VALUES = {
82
+ "not started", "in progress", "implemented", "verified", "done (pre-existing)",
83
+ "needs re-verify", "partial", "fail", "blocked", "n/a", "owner-uat",
84
+ }
85
+ NOT_PRESENT = re.compile(r"\b(not|never)\s+(present|found|installed|available)\b|does not exist|no such file", re.I)
86
+ NAMES_PATH = re.compile(r"(?:\.tfcore|\.claude|\.opencode|docs|src|tests)/[\w./-]+|[\w-]+\.(?:sh|py|md|json|yaml|razor|cs)\b")
87
+ PERF_BUDGET = re.compile(
88
+ r"perf-budget:\s*(p50|p95|max)\s+(ttfb|load)\s*<=\s*\d+\s*ms(\s*@\s*concurrency\s+\d+)?", re.I
89
+ )
90
+ ACCEPT_LINE = re.compile(r"\*Acceptance:?\*|^\s*[-*]\s*Acceptance:", re.I)
91
+ ACCEPT_FORM = re.compile(
92
+ r"^\s*[-*]\s*\*?Acceptance:?\*?:?\s*(?:Given\b[^,]*,\s*)?When\b.+?,\s*then\b.+", re.I
93
+ )
94
+ NAMES_SCREEN = re.compile(r"\b(on|opens?|from|in)\b", re.I)
95
+
96
+
97
+ def acceptance_words(line: str) -> int:
98
+ """Words in an acceptance line, without the "Acceptance:" label and without a perf-budget tail."""
99
+ t = re.sub(r"^\s*[-*]\s*\*?Acceptance:?\*?:?\s*", "", line)
100
+ t = re.split(r"perf-budget:", t, flags=re.I)[0]
101
+ return len(re.findall(r"\S+", t))
102
+
103
+
104
+ ACC_TARGET, ACC_MAX = 20, 30 # owner, 2026-09-06 (miss 13): a line a person can read at a glance
105
+
106
+
107
+ # ----------------------------------------------------------------------------
108
+ # helpers
109
+ # ----------------------------------------------------------------------------
110
+ def norm_heading(text: str) -> str:
111
+ """'## 3. Users and roles (all)' -> 'users and roles'."""
112
+ h = text.strip().rstrip(":")
113
+ h = re.sub(r"^[#\s]*", "", h)
114
+ h = re.sub(r"^(?:§\s*)?\d+(?:\.\d+)*[.)]?\s+", "", h) # leading numbering
115
+ h = re.sub(r"[\U0001F300-\U0001FAFF☀-➿]", "", h) # emoji
116
+ h = re.sub(r"\s*[—(].*$", "", h) # trailing qualifiers: "— …" or "(…)"
117
+ h = re.sub(r"\s+", " ", h).strip().lower()
118
+ return h
119
+
120
+
121
+ def key_matches(declared_key: str, present_key: str) -> bool:
122
+ if declared_key.endswith("*"):
123
+ return present_key.startswith(declared_key[:-1].rstrip())
124
+ return declared_key == present_key
125
+
126
+
127
+ def strip_comments(text: str) -> str:
128
+ return re.sub(r"<!--.*?-->", "", text, flags=re.S)
129
+
130
+
131
+ def strip_noise(text: str) -> str:
132
+ """Remove HTML comments and fenced code blocks (they are not prose)."""
133
+ return re.sub(r"```.*?```", "", strip_comments(text), flags=re.S)
134
+
135
+
136
+ def word_count(text: str) -> int:
137
+ return len(re.findall(r"\S+", strip_noise(text)))
138
+
139
+
140
+ def split_sections(body: str, level: int = 2):
141
+ """Return [(heading_text or None, section_text)] split on headings of `level`."""
142
+ pat = re.compile(rf"(?m)^{'#' * level}\s+(.+)$")
143
+ out, last, last_head = [], 0, None
144
+ for m in pat.finditer(body):
145
+ out.append((last_head, body[last:m.start()]))
146
+ last_head, last = m.group(1).strip(), m.end()
147
+ out.append((last_head, body[last:]))
148
+ return out
149
+
150
+
151
+ def parse_header(body: str) -> dict:
152
+ """Header fields from YAML frontmatter and from the first `| Key | Value |` table."""
153
+ fields = {}
154
+ text = strip_comments(body).lstrip()
155
+ fm = re.match(r"^---\s*\n(.*?)\n---\s*\n", text, re.S)
156
+ if fm:
157
+ for line in fm.group(1).splitlines():
158
+ m = re.match(r"^\s*([A-Za-z_][\w -]*):\s*(.*?)\s*$", line)
159
+ if m:
160
+ fields[m.group(1).strip().lower()] = m.group(2).strip()
161
+ head = text.split("\n## ", 1)[0]
162
+ for m in re.finditer(r"(?m)^\|\s*([^|]+?)\s*\|\s*([^|]*?)\s*\|\s*$", head):
163
+ key, val = m.group(1).strip().lower(), m.group(2).strip()
164
+ if key and not set(key) <= set("-: ") and key not in fields:
165
+ fields[key] = val
166
+ return fields
167
+
168
+
169
+ def is_placeholder(val: str) -> bool:
170
+ v = val.strip()
171
+ return v == "" or v.startswith("{") or v.startswith("<") or v in ("…", "...")
172
+
173
+
174
+ def tables_in(text: str):
175
+ """Yield (header_cells, rows) for every markdown table in text."""
176
+ lines = strip_noise(text).splitlines()
177
+ i = 0
178
+ while i < len(lines) - 1:
179
+ if lines[i].lstrip().startswith("|") and re.match(r"^\s*\|?\s*:?-{2,}", lines[i + 1]):
180
+ header = [c.strip() for c in lines[i].strip().strip("|").split("|")]
181
+ rows, j = [], i + 2
182
+ while j < len(lines) and lines[j].lstrip().startswith("|"):
183
+ rows.append([c.strip() for c in lines[j].strip().strip("|").split("|")])
184
+ j += 1
185
+ yield header, rows
186
+ i = j
187
+ else:
188
+ i += 1
189
+
190
+
191
+ def has_table_with(text: str, *cols: str) -> bool:
192
+ want = [c.lower() for c in cols]
193
+ for header, _rows in tables_in(text):
194
+ low = " | ".join(h.lower() for h in header)
195
+ if all(w in low for w in want):
196
+ return True
197
+ return False
198
+
199
+
200
+ def links_to(text: str, folder: str):
201
+ """All paths in the text that point into `folder` (mockups, screenshots)."""
202
+ return {m.group(1).rstrip(".,;:") for m in re.finditer(rf"((?:\./|\.\./|docs/)?{folder}/[^\s)\]\"'`>|]+)", text)}
203
+
204
+
205
+ def resolve(root: str, doc_path: str, link: str) -> bool:
206
+ link = link.split("#", 1)[0]
207
+ cands = [os.path.join(root, link), os.path.join(os.path.dirname(doc_path), link), os.path.join(root, "docs", link)]
208
+ return any(os.path.exists(os.path.normpath(c)) for c in cands)
209
+
210
+
211
+ # ----------------------------------------------------------------------------
212
+ # schema
213
+ # ----------------------------------------------------------------------------
214
+ class Schema:
215
+ def __init__(self, text: str):
216
+ self.doc = self.file = None
217
+ self.header = []
218
+ self.sections = [] # (name, flag, max_words)
219
+ self.budget = {} # size -> (target, max)
220
+ self.entries = None # (section name, h3 prefix)
221
+ self.per_entry = None # (target, max)
222
+ self.max_lines = self.target_lines = None
223
+ self.rules = []
224
+ m = re.search(r"<!--\s*tf-schema\s*\n(.*?)-->", text, re.S)
225
+ if not m:
226
+ raise ValueError("no tf-schema block")
227
+ for raw in m.group(1).splitlines():
228
+ line = raw.strip()
229
+ if not line or line.startswith("#") or ":" not in line:
230
+ continue
231
+ key, val = line.split(":", 1)
232
+ key, val = key.strip().lower(), val.strip()
233
+ if key == "doc":
234
+ self.doc = val
235
+ elif key == "file":
236
+ self.file = val
237
+ elif key == "header":
238
+ self.header = [h.strip() for h in val.split(",") if h.strip()]
239
+ elif key == "section":
240
+ parts = [p.strip() for p in val.split("|")]
241
+ flag = parts[1].lower() if len(parts) > 1 and parts[1] else "required"
242
+ mx = None
243
+ for p in parts[2:]:
244
+ mm = re.match(r"max\s+(\d+)", p, re.I)
245
+ if mm:
246
+ mx = int(mm.group(1))
247
+ self.sections.append((parts[0], flag, mx))
248
+ elif key == "budget":
249
+ for part in val.split("|"):
250
+ bits = part.split()
251
+ if len(bits) == 3:
252
+ self.budget[bits[0].upper()] = (int(bits[1]), int(bits[2]))
253
+ elif key == "entries":
254
+ parts = [p.strip() for p in val.split("|")]
255
+ self.entries = (parts[0], parts[1] if len(parts) > 1 else "")
256
+ elif key == "per-entry":
257
+ bits = val.split()
258
+ self.per_entry = (int(bits[0]), int(bits[1]))
259
+ elif key == "max-lines":
260
+ self.max_lines = int(val)
261
+ elif key == "target-lines":
262
+ self.target_lines = int(val)
263
+ elif key == "rule":
264
+ self.rules.append(val.lower())
265
+
266
+ def declared(self, present_key: str):
267
+ for name, flag, mx in self.sections:
268
+ if key_matches(norm_heading(name), present_key):
269
+ return name, flag, mx
270
+ return None
271
+
272
+
273
+ def load_schema(template_file: str) -> Schema:
274
+ with open(os.path.join(TEMPLATE_DIR, template_file), encoding="utf-8") as fh:
275
+ return Schema(fh.read())
276
+
277
+
278
+ # ----------------------------------------------------------------------------
279
+ # reporting and context
280
+ # ----------------------------------------------------------------------------
281
+ class Report:
282
+ def __init__(self, warn_only: bool):
283
+ self.warn_only = warn_only
284
+ self.fails = self.warns = 0
285
+ self.lines = []
286
+
287
+ def fail(self, path, msg):
288
+ if self.warn_only:
289
+ self.warns += 1
290
+ self.lines.append(f"WARN {path}: {msg}")
291
+ else:
292
+ self.fails += 1
293
+ self.lines.append(f"FAIL {path}: {msg}")
294
+
295
+ def warn(self, path, msg):
296
+ self.warns += 1
297
+ self.lines.append(f"WARN {path}: {msg}")
298
+
299
+
300
+ def detect_kind(path: str):
301
+ name = os.path.basename(path).lower()
302
+ if re.search(r"-deployment-checklist(-[\w]+)?\.md$", name):
303
+ return "deployment-checklist", "app-deployment-checklist-tmpl.md"
304
+ for suffix, doc, tmpl in DOC_KINDS:
305
+ if name.endswith(suffix):
306
+ return doc, tmpl
307
+ return None, None
308
+
309
+
310
+ def find_root(path: str) -> str:
311
+ d = os.path.dirname(os.path.abspath(path))
312
+ while True:
313
+ if os.path.isdir(os.path.join(d, ".tfcore")) or os.path.isdir(os.path.join(d, "docs")):
314
+ return d
315
+ parent = os.path.dirname(d)
316
+ if parent == d:
317
+ return os.path.dirname(os.path.abspath(path))
318
+ d = parent
319
+
320
+
321
+ def read_core_config(root: str) -> dict:
322
+ out = {}
323
+ p = os.path.join(root, ".tfcore", "core-config.yaml")
324
+ if os.path.exists(p):
325
+ with open(p, encoding="utf-8") as fh:
326
+ for line in fh:
327
+ m = re.match(r"^(appSize|appKind|appPhase):\s*(\S+)", line)
328
+ if m and m.group(2) not in ("null", "~", "''", '""'):
329
+ out[m.group(1)] = m.group(2).strip("'\"")
330
+ return out
331
+
332
+
333
+ def split_name(path: str):
334
+ """'docs/MyApp-P2-BRD.md' -> ('MyApp', 2); 'docs/MyApp-BRD.md' -> ('MyApp', 1); other -> (None, 1)."""
335
+ m = DOC_NAME.match(os.path.basename(path))
336
+ if not m:
337
+ return None, 1
338
+ return m.group(1), int(m.group(2) or 1)
339
+
340
+
341
+ def phase_file(app: str, phase: int, suffix: str) -> str:
342
+ return f"{app}-{suffix}.md" if phase <= 1 else f"{app}-P{phase}-{suffix}.md"
343
+
344
+
345
+ def sibling_brd_header(path: str) -> dict:
346
+ """Size/Kind from docs/<App>-BRD.md when another document of the app lacks them."""
347
+ base = os.path.basename(path)
348
+ m = re.match(r"(.+?)-(Architecture|UIDesign|Checklist|Coding-Standards|UsageGuide|Usage-Guide|DevGuide|ProductGuide)\.md$", base, re.I)
349
+ if not m:
350
+ return {}
351
+ brd = os.path.join(os.path.dirname(path), f"{m.group(1)}-BRD.md")
352
+ if not os.path.exists(brd):
353
+ return {}
354
+ with open(brd, encoding="utf-8") as fh:
355
+ return parse_header(fh.read())
356
+
357
+
358
+ def resolve_size(header, brd_header, cfg, cli_size, rep, rel, need):
359
+ raw = header.get("size") or brd_header.get("size") or cfg.get("appSize") or cli_size
360
+ if raw:
361
+ size = SIZE_NAMES.get(str(raw).strip().lower())
362
+ if size:
363
+ return size
364
+ if need:
365
+ rep.warn(rel, f'Size "{raw}" is not Small, Medium or Large; Small assumed')
366
+ elif need:
367
+ rep.warn(rel, "no Size recorded (document header, the BRD header, or appSize in core-config.yaml); Small assumed")
368
+ return "S"
369
+
370
+
371
+ def resolve_kind(header, brd_header, cfg) -> str:
372
+ raw = (header.get("kind") or brd_header.get("kind") or cfg.get("appKind") or "app").strip().lower()
373
+ return "library" if raw.startswith("lib") else "app"
374
+
375
+
376
+ def section_text(present, name):
377
+ key = norm_heading(name)
378
+ return next((txt for k, _h, txt in present if key_matches(key, k)), None)
379
+
380
+
381
+ # ----------------------------------------------------------------------------
382
+ # the checker
383
+ # ----------------------------------------------------------------------------
384
+ def check_document(path: str, rep: Report, cli_size=None, root=None):
385
+ doc, tmpl = detect_kind(path)
386
+ if not doc:
387
+ rep.warn(path, "not a TechieFlow document name; skipped")
388
+ return None
389
+ try:
390
+ schema = load_schema(tmpl)
391
+ except (OSError, ValueError) as e:
392
+ rep.warn(path, f"template {tmpl} has no usable tf-schema block ({e}); skipped")
393
+ return None
394
+ with open(path, encoding="utf-8") as fh:
395
+ body = fh.read()
396
+ root = root or find_root(path)
397
+ rel = os.path.relpath(path, root)
398
+ cfg = read_core_config(root)
399
+ header = parse_header(body)
400
+ brd_header = sibling_brd_header(path) if doc != "brd" else {}
401
+ size = resolve_size(header, brd_header, cfg, cli_size, rep, rel, doc in SIZED_DOCS)
402
+ kind = resolve_kind(header, brd_header, cfg)
403
+ clean = strip_noise(body)
404
+ nocomment = strip_comments(body)
405
+
406
+ # 1. header fields
407
+ for field in schema.header:
408
+ val = header.get(field.lower())
409
+ if val is None:
410
+ rep.fail(rel, f'header field "{field}" is missing')
411
+ elif is_placeholder(val):
412
+ rep.fail(rel, f'header field "{field}" is still a placeholder')
413
+
414
+ # 1b. the Phase field of a phased document (Large projects; Schemas §2)
415
+ app, phase = split_name(path)
416
+ if doc in PHASED_DOCS and app:
417
+ phases_doc = os.path.join(os.path.dirname(path), f"{app}-Phases.md")
418
+ pval = header.get("phase")
419
+ if pval is not None and is_placeholder(pval):
420
+ rep.fail(rel, 'header field "Phase" is still a placeholder; write "1 of 3", or delete the row when the project has no phases')
421
+ elif pval is not None:
422
+ m = PHASE_FORM.match(pval.strip("`* "))
423
+ if not m:
424
+ rep.fail(rel, f'header field "Phase" reads "{pval}"; it must read "<n> of <m>", for example "2 of 3"')
425
+ elif int(m.group(1)) != phase:
426
+ rep.fail(rel, f'header field "Phase" says {m.group(1)} but the file name says phase {phase} (phase 1 is {app}-{PHASED_SUFFIX[doc]}.md; phase n is {app}-Pn-{PHASED_SUFFIX[doc]}.md)')
427
+ elif phase > 1:
428
+ rep.fail(rel, f'a phase-{phase} file needs the header row "Phase | {phase} of <m>"')
429
+ elif os.path.exists(phases_doc):
430
+ rep.fail(rel, f'the project has phases ({os.path.relpath(phases_doc, root)}); this file needs the header row "Phase | 1 of <m>"')
431
+
432
+ # 2. sections: strangers, order, presence
433
+ present = [(norm_heading(h), h, txt) for h, txt in split_sections(clean, 2) if h is not None]
434
+ always_ok = {"table of contents"} | ({"recovery note"} if doc == "project-status" else set())
435
+ for key, h, txt in present:
436
+ if schema.declared(key) or key in always_ok:
437
+ continue
438
+ if doc == "checklist" and re.search(r"<a id=['\"]d-req-", txt, re.I):
439
+ continue # a page group holding detail entries
440
+ if doc == "feedback" and ENTRY_ID.match(h.strip()):
441
+ continue # an entry written at ## level: the layout every feedback file shipped
442
+ # with before this schema existed. Read as an entry, not a stranger.
443
+ extra = "; bugs and feedback go to the misses stream, not the checklist" if doc == "checklist" else ""
444
+ rep.fail(rel, f'section "{h}" is not in the template{extra}')
445
+
446
+ order_of = {norm_heading(n): i for i, (n, _f, _m) in enumerate(schema.sections)}
447
+ last_idx, last_name = -1, None
448
+ for key, h, _txt in present:
449
+ d = schema.declared(key)
450
+ if not d:
451
+ continue
452
+ if doc == "feedback" and d[1] != "required":
453
+ continue # correspondence blocks are appended over time and sit either side
454
+ # of the entries; where they land carries no meaning
455
+ idx = order_of[norm_heading(d[0])]
456
+ if idx < last_idx:
457
+ rep.fail(rel, f'section "{h}" comes after "{last_name}"; the template order is the other way round')
458
+ else:
459
+ last_idx, last_name = idx, h
460
+
461
+ present_keys = [k for k, _h, _t in present]
462
+ for name, flag, _mx in schema.sections:
463
+ key = norm_heading(name)
464
+ needed = (flag == "required" or (flag == "optional-small" and size in ("M", "L"))
465
+ or (flag == "app" and kind == "app") or (flag == "library" and kind == "library"))
466
+ if needed and not any(key_matches(key, k) for k in present_keys):
467
+ why = "" if flag == "required" else f" (required for {SIZE_LONG[size] if flag == 'optional-small' else kind})"
468
+ rep.fail(rel, f'section "{name.rstrip("*")}" is missing{why}')
469
+
470
+ # 3. budgets
471
+ total = word_count(body)
472
+ if size in schema.budget:
473
+ target, mx = schema.budget[size]
474
+ if total > mx:
475
+ rep.fail(rel, f"{total:,} words; the {SIZE_LONG[size]} maximum is {mx:,} (target {target:,}). Shorten prose; never drop a screen, field or requirement to fit")
476
+ elif total > target:
477
+ rep.warn(rel, f"{total:,} words; the {SIZE_LONG[size]} target is {target:,} (maximum {mx:,})")
478
+ for key, h, txt in present:
479
+ d = schema.declared(key)
480
+ if d and d[2]:
481
+ # A `####` block inside a section is detail the reader chooses to open, and is not
482
+ # counted — the same rule as a feedback entry's `#### Detail` (owner, 2026-09-08).
483
+ n = word_count(re.split(r"(?m)^####\s", txt)[0])
484
+ if n > d[2]:
485
+ rep.fail(rel, f'section "{h}" is {n} words; at most {d[2]}')
486
+ if schema.max_lines:
487
+ n_lines = nocomment.strip().count("\n") + 1
488
+ if n_lines > schema.max_lines:
489
+ rep.fail(rel, f"{n_lines} lines; at most {schema.max_lines}")
490
+ elif schema.target_lines and n_lines > schema.target_lines:
491
+ rep.warn(rel, f"{n_lines} lines; the target is {schema.target_lines} (maximum {schema.max_lines})")
492
+
493
+ # 4. per-entry checks (screens, tasks, components)
494
+ entry_names = []
495
+ if schema.entries:
496
+ sec_txt = section_text(present, schema.entries[0])
497
+ prefix = schema.entries[1]
498
+ pairs = list(split_sections(sec_txt, 3)) if sec_txt is not None else []
499
+ if doc == "feedback":
500
+ # Entries may sit under "Entries" as ###, or stand at ## level as every
501
+ # feedback file wrote them before this schema. Both are read the same way.
502
+ pairs += [(h, t) for _k, h, t in present if ENTRY_ID.match(h.strip())]
503
+ sec_txt = sec_txt if sec_txt is not None else ""
504
+ if sec_txt is not None:
505
+ for h3, etxt in pairs:
506
+ if h3 is None:
507
+ continue
508
+ if prefix and not h3.lower().startswith(prefix.lower()):
509
+ rep.fail(rel, f'entry "{h3}" must start with "{prefix}"')
510
+ continue
511
+ ename = h3[len(prefix):].strip() if prefix else h3
512
+ ename = re.sub(r"\s*\(.*$", "", ename).strip("` ").strip()
513
+ entry_names.append(ename)
514
+ if doc == "feedback" and FEEDBACK_CLOSED.search(etxt[:2500]):
515
+ continue # a closed entry is the record of what was wrong and why.
516
+ # History may be as long as it needs to be; the shape and the
517
+ # word cap bind the LIVE ones, which are what the owner reads.
518
+ if schema.per_entry:
519
+ # The cap binds the REPORT, not the working-out. Anything under a `####`
520
+ # heading inside the entry is detail a reader chooses to open, and is not
521
+ # counted: a thorough analysis is worth having, an entry the owner cannot
522
+ # read at a glance is not (owner, 2026-09-08).
523
+ n = word_count(re.split(r"(?m)^####\s", etxt)[0])
524
+ if n > schema.per_entry[1]:
525
+ rep.fail(rel, f'entry "{ename}" is {n} words; maximum {schema.per_entry[1]} (target {schema.per_entry[0]})')
526
+ elif n > schema.per_entry[0]:
527
+ rep.warn(rel, f'entry "{ename}" is {n} words; target {schema.per_entry[0]} (maximum {schema.per_entry[1]})')
528
+ for rule in schema.rules:
529
+ if rule.startswith("entry-"):
530
+ check_entry_rule(rule, ename, etxt, rel, root, path, rep)
531
+ if not entry_names and kind == "app":
532
+ rep.fail(rel, f'section "{schema.entries[0].rstrip("*")}" has no "###" entries')
533
+
534
+ # 5. document rules
535
+ ctx = dict(doc=doc, body=body, clean=clean, nocomment=nocomment, present=present, header=header,
536
+ size=size, kind=kind, root=root, path=path, rel=rel, entry_names=entry_names,
537
+ app=app, phase=phase)
538
+ for rule in schema.rules:
539
+ if not rule.startswith("entry-"):
540
+ check_doc_rule(rule, ctx, rep)
541
+ return ctx
542
+
543
+
544
+ def check_entry_rule(rule, ename, etxt, rel, root, path, rep):
545
+ if rule == "entry-mockup":
546
+ links = links_to(etxt, "mockups")
547
+ if not links:
548
+ rep.fail(rel, f'screen "{ename}" has no mockup link (docs/mockups/<screen>.html)')
549
+ for l in sorted(links):
550
+ if not resolve(root, path, l):
551
+ rep.fail(rel, f'screen "{ename}" links mockup {l}, which does not exist')
552
+ elif rule == "entry-screenshot":
553
+ imgs = re.findall(r"!\[[^\]]*\]\(([^)]+)\)", etxt)
554
+ if not imgs:
555
+ rep.fail(rel, f'entry "{ename}" has no screenshot image')
556
+ for l in imgs:
557
+ if not resolve(root, path, l):
558
+ rep.fail(rel, f'entry "{ename}" screenshot {l} does not exist')
559
+ elif rule == "entry-fields-table":
560
+ if not has_table_with(etxt, "field", "type"):
561
+ rep.fail(rel, f'screen "{ename}" has no fields table (Field, Type, Required, Validation)')
562
+ elif rule == "entry-regions-table":
563
+ if not has_table_with(etxt, "region", "control"):
564
+ rep.fail(rel, f'screen "{ename}" has no regions-to-controls table')
565
+ elif rule == "entry-states":
566
+ low = etxt.lower()
567
+ missing = [s for s in ("empty", "loading", "error") if s not in low]
568
+ if missing:
569
+ rep.fail(rel, f'screen "{ename}" does not say what it shows when {", ".join(missing)}')
570
+ elif rule == "entry-break-table":
571
+ if not has_table_with(etxt, "file", "function", "watch", "expected"):
572
+ rep.fail(rel, f'entry "{ename}" has no where-to-break table (File and line, Function, Watch, Expected value)')
573
+ elif rule == "entry-call-chain":
574
+ if not re.search(r"(?im)^\**call chain:?\**", etxt):
575
+ rep.fail(rel, f'entry "{ename}" has no "Call chain:" line')
576
+ elif rule == "entry-steps":
577
+ if not re.search(r"(?mi)^\s*(?:[-*]\s*[*_]*steps?[*_:]*\s*)?1[.)]\s+\S", etxt):
578
+ rep.fail(rel, f'entry "{ename}" has no numbered steps')
579
+ elif rule == "entry-expected":
580
+ if not re.search(r"(?im)^\s*[-*]?\s*\**expected", etxt):
581
+ rep.fail(rel, f'entry "{ename}" has no "Expected:" line')
582
+ elif rule == "entry-feedback-fields":
583
+ # A feedback entry is a bug report, not an essay. Same eight fields every time,
584
+ # so the upstream team reads them in one shape and the owner reads "Blocks" first.
585
+ for field in ("Severity", "Blocks", "Repro", "Expected", "Actual",
586
+ "Encountered in", "Workaround", "Suggested fix"):
587
+ if not re.search(r"(?im)^\s*[-*]?\s*\**%s\**\s*:" % re.escape(field), etxt):
588
+ rep.fail(rel, f'entry "{ename}" has no "{field}:" line')
589
+ elif rule == "entry-blocks-line":
590
+ # The one word that decides whether the run stops. "no" also has to say what was
591
+ # done instead, because a non-blocking entry is filed and the work carries on.
592
+ m = re.search(r"(?im)^\s*[-*]?\s*\**Blocks\**\s*:\s*(.+)$", etxt)
593
+ if m:
594
+ val = m.group(1).strip().strip("*` ").lower()
595
+ if not re.match(r"^(yes|no)\b", val):
596
+ rep.fail(rel, f'entry "{ename}" says "Blocks: {m.group(1).strip()[:40]}"; it must start "yes" or "no"')
597
+ elif val.startswith("no") and len(val) < 8:
598
+ rep.fail(rel, f'entry "{ename}" says "Blocks: no" and stops; say what was done instead and that the work carried on')
599
+ elif rule == "entry-decision-options":
600
+ if not has_table_with(etxt, "option", "what happens"):
601
+ rep.fail(rel, f'decision "{ename}" has no options table (Option, What happens, What it costs)')
602
+ elif rule == "entry-recommendation":
603
+ if not re.search(r"(?im)^\s*\**My recommendation", etxt):
604
+ rep.fail(rel, f'decision "{ename}" has no "My recommendation:" line — the owner is owed one, with the reason in a sentence')
605
+
606
+
607
+ def check_doc_rule(rule, c, rep):
608
+ rel, root, path, body, clean, nocomment, present, size = (
609
+ c["rel"], c["root"], c["path"], c["body"], c["clean"], c["nocomment"], c["present"], c["size"])
610
+
611
+ if rule == "brd-ledger":
612
+ ids = re.findall(r"\*\*BRD-(\d+)\*\*", clean)
613
+ # the Non-functional table carries ids too (| BRD-31 | Performance | … |); they count,
614
+ # they need checklist rows, and they must sit inside the phase's range
615
+ nfr = section_text(present, "Non-functional requirements") or ""
616
+ ids += re.findall(r"(?m)^\s*\|\s*`?\**BRD-(\d+)", nfr)
617
+ if not ids:
618
+ rep.fail(rel, "no **BRD-N** items found in the Requirements section")
619
+ return
620
+ seen, dupes = set(), set()
621
+ for i in ids:
622
+ (dupes if i in seen else seen).add(i)
623
+ if dupes:
624
+ rep.fail(rel, "duplicate requirement ids: " + ", ".join(f"BRD-{d}" for d in sorted(dupes, key=int)))
625
+ if len(seen) > REQ_CAP[size]:
626
+ rep.fail(rel, f"{len(seen)} requirements; the {SIZE_LONG[size]} cap is {REQ_CAP[size]}. Split into phases (each phase its own BRD) instead of growing this one")
627
+ c["brd_ids"] = seen
628
+ req = section_text(present, "Requirements") or ""
629
+ cur = None
630
+ for line in req.splitlines():
631
+ m = re.search(r"\*\*BRD-(\d+)\*\*", line)
632
+ if m:
633
+ cur = m.group(1)
634
+ if cur and ACCEPT_LINE.search(line):
635
+ n_acc = acceptance_words(line)
636
+ if n_acc > ACC_MAX:
637
+ rep.fail(rel, f"BRD-{cur} acceptance line is {n_acc} words; at most {ACC_MAX} (target {ACC_TARGET}). One behaviour per item: split it, do not bundle steps")
638
+ elif n_acc > ACC_TARGET:
639
+ rep.warn(rel, f"BRD-{cur} acceptance line is {n_acc} words; the target is {ACC_TARGET} (maximum {ACC_MAX})")
640
+
641
+ elif rule == "mockup-links":
642
+ for l in sorted(links_to(clean, "mockups")):
643
+ if not resolve(root, path, l):
644
+ rep.fail(rel, f"mockup link {l} points at a file that does not exist")
645
+
646
+ elif rule == "screens-table":
647
+ txt = section_text(present, "Screens and flow")
648
+ if txt is None:
649
+ return
650
+ if not has_table_with(txt, "screen", "route", "mockup"):
651
+ rep.fail(rel, 'the "Screens and flow" table needs the columns Screen, Route, Role, Mockup, Fields')
652
+ return
653
+ names = []
654
+ for header, rows in tables_in(txt):
655
+ low = [h.lower() for h in header]
656
+ if "screen" in low and "route" in low:
657
+ sc, rc = low.index("screen"), low.index("route")
658
+ for r in rows:
659
+ if len(r) <= max(sc, rc) or not r[sc] or r[sc].startswith("{"):
660
+ continue
661
+ if "dialog" in r[sc].lower() or r[rc].strip("` ").lower().startswith("on "):
662
+ continue
663
+ names.append(r[sc])
664
+ c["brd_screens"] = names
665
+
666
+ elif rule == "stack-table":
667
+ txt = section_text(present, "Stack decisions")
668
+ if txt is not None and not any(True for _ in tables_in(txt)):
669
+ rep.fail(rel, 'the "Stack decisions" section needs a table (one row per stack question)')
670
+
671
+ elif rule == "solution-table":
672
+ txt = section_text(present, "Solution structure")
673
+ if txt is not None and not has_table_with(txt, "project", "kind"):
674
+ rep.fail(rel, 'the "Solution structure" table needs the columns Project, Kind, Purpose')
675
+
676
+ elif rule == "stack-rows":
677
+ # one row per stack question; Q9 and Q10 (hosting, production secrets) are decided after UAT
678
+ txt = section_text(present, "Stack decisions")
679
+ if txt is None:
680
+ return
681
+ have = set()
682
+ for _h, rows in tables_in(txt):
683
+ for r in rows:
684
+ m = re.match(r"q\s*(\d+)", r[0].strip("`* ").lower()) if r else None
685
+ if m:
686
+ have.add(int(m.group(1)))
687
+ missing = [q for q in (1, 2, 3, 4, 5, 6, 7, 8, 11) if q not in have]
688
+ if missing:
689
+ rep.fail(rel, "the Stack decisions table has no row for " + ", ".join(f"Q{q}" for q in missing)
690
+ + "; one row per stack question, citing its source (Q9 and Q10 are decided after UAT)")
691
+
692
+ elif rule == "head-project":
693
+ # an app's primary head project is named exactly <App>; <App>.App is refused (Stack Q7, owner 2026-09-06)
694
+ txt = section_text(present, "Solution structure")
695
+ app = c.get("app")
696
+ if txt is None or c["kind"] != "app" or not app:
697
+ return
698
+ names = [r[0].strip("`* ") for _h, rows in tables_in(txt) for r in rows if r and r[0].strip()]
699
+ names = [n for n in names if not is_placeholder(n)]
700
+ bad = [n for n in names if re.fullmatch(rf"{re.escape(app)}\.App", n, re.I)]
701
+ if bad:
702
+ rep.fail(rel, f'project "{bad[0]}" is named <App>.App; the primary head is named exactly "{app}" (Stack Q7)')
703
+ elif names and not any(n.lower() == app.lower() for n in names):
704
+ rep.fail(rel, f'no project named exactly "{app}" in the Solution structure; the primary head carries the product name (Stack Q7)')
705
+
706
+ elif rule == "er-diagram":
707
+ if section_text(present, "Data model") is not None and not re.search(r"```mermaid\s*\n\s*erDiagram", nocomment):
708
+ rep.fail(rel, 'the "Data model" section needs a mermaid erDiagram')
709
+
710
+ elif rule == "decisions-log":
711
+ txt = section_text(present, "Decisions log")
712
+ if txt is not None and not has_table_with(txt, "date", "decision", "why", "status"):
713
+ rep.fail(rel, 'the "Decisions log" table needs the columns Date, Decision, Why, Status')
714
+
715
+ elif rule == "request-flow":
716
+ txt = section_text(present, "Component map")
717
+ if txt is not None and not re.search(r"(?im)^\**how a request travels", txt):
718
+ rep.fail(rel, 'the "Component map" section needs a "How a request travels" numbered list')
719
+
720
+ elif rule == "execution-code":
721
+ seg = re.search(r"(?ms)^##\s+(?:\d+[.)]\s+)?Execution guide.*?\n(.*?)(?=^## |\Z)", nocomment)
722
+ if seg and "```" not in seg.group(1):
723
+ rep.fail(rel, 'the "Execution guide" needs the start commands in a code block')
724
+
725
+ elif rule == "test-users-table":
726
+ txt = section_text(present, "Test users")
727
+ if txt is not None and not has_table_with(txt, "user", "role"):
728
+ rep.fail(rel, 'the "Test users" section needs a table with User and Role columns')
729
+
730
+ elif rule == "next-command-blocks":
731
+ seg = re.search(r"(?ms)^## Next command to run\s*\n(.*?)(?=^## |\Z)", nocomment)
732
+ if not seg:
733
+ return
734
+ txt = seg.group(1)
735
+ blocks = re.findall(r"```[^\n]*\n(.*?)```", txt, re.S)
736
+ if len(blocks) != 2:
737
+ rep.fail(rel, f'"Next command to run" must hold exactly two code blocks, Claude Code then OpenCode; found {len(blocks)}')
738
+ return
739
+ for label, blk in zip(("Claude Code", "OpenCode"), blocks):
740
+ lines = [l for l in blk.splitlines() if l.strip()]
741
+ if len(lines) != 1:
742
+ rep.fail(rel, f"the {label} command block must be one line; found {len(lines)}")
743
+ parts = txt.split("```")
744
+ if "claude code" not in parts[0].lower():
745
+ rep.fail(rel, 'the first command block must be labelled "Claude Code" on the line before it')
746
+ if len(parts) > 2 and "opencode" not in parts[2].lower():
747
+ rep.fail(rel, 'the second command block must be labelled "OpenCode" on the line before it')
748
+
749
+ elif rule == "verification-log":
750
+ txt = section_text(present, "Verification log")
751
+ if txt is None:
752
+ return
753
+ for _h, rows in tables_in(txt):
754
+ if len(rows) > 5:
755
+ rep.fail(rel, f"the Verification log holds {len(rows)} rows; keep the last five, the rest lives in gates.jsonl and runs.jsonl")
756
+ for r in rows:
757
+ for cell in r:
758
+ n = len(re.findall(r"\S+", cell))
759
+ if n > 20:
760
+ rep.fail(rel, f"a Verification log cell is {n} words; at most 20, a result is a count not a story")
761
+ break
762
+
763
+ elif rule == "open-requirements-max-10":
764
+ txt = section_text(present, "Open requirements")
765
+ if txt is not None:
766
+ n = len(re.findall(r"(?m)^\s*[-*]\s*\[?[ x]?\]?\s*REQ-", txt))
767
+ if n > 10:
768
+ rep.fail(rel, f"Open requirements names {n} rows; show counts by status and at most ten named rows")
769
+
770
+ elif rule == "checklist-rows":
771
+ check_checklist(c, rep)
772
+
773
+ elif rule == "standards-pointer":
774
+ if ".tfcore/standards/" not in body:
775
+ rep.fail(rel, "must name the framework standard files it applies (.tfcore/standards/...)")
776
+
777
+ elif rule == "deploy-who-table":
778
+ txt = section_text(present, "Who does what")
779
+ if txt is not None and not has_table_with(txt, "step", "done by"):
780
+ rep.fail(rel, 'the "Who does what" section needs a table with the columns Step and Done by')
781
+
782
+ elif rule == "deploy-secrets-once":
783
+ txt = section_text(present, "Secrets and settings")
784
+ if txt is None:
785
+ return
786
+ names = []
787
+ for header, rows in tables_in(txt):
788
+ low = [h.lower() for h in header]
789
+ if "name" in low:
790
+ names += [r[low.index("name")].strip("`* ") for r in rows if r and r[low.index("name")].strip()]
791
+ if not names:
792
+ rep.fail(rel, 'the "Secrets and settings" table needs the columns Name, Where it is set, What breaks without it, and at least one row')
793
+ return
794
+ dupes = sorted({n for n in names if names.count(n) > 1})
795
+ if dupes:
796
+ rep.fail(rel, "a secret or setting is listed more than once: " + ", ".join(dupes))
797
+ # named again as the first cell of a row in any other table: re-described elsewhere
798
+ for key, h, other in present:
799
+ if key_matches(norm_heading("Secrets and settings"), key):
800
+ continue
801
+ for _hdr, rows in tables_in(other):
802
+ for r in rows:
803
+ if r and r[0].strip("`* ") in names:
804
+ rep.fail(rel, f'"{r[0].strip("`* ")}" is described again under "{h}"; each secret or setting appears in exactly one row of Secrets and settings')
805
+
806
+ elif rule == "deploy-checkboxes":
807
+ for name in ("Before the first deploy", "Deploy", "After the deploy", "Rollback"):
808
+ txt = section_text(present, name)
809
+ if txt is None:
810
+ continue
811
+ boxes = 0
812
+ for line in txt.splitlines():
813
+ t = line.strip()
814
+ if not t or t.startswith("|") or t.startswith("<"):
815
+ continue
816
+ if re.match(r"^[-*]\s*\[[ xX]\]\s+\S", t):
817
+ boxes += 1
818
+ elif name == "Rollback" and re.match(r"^A rollback does not undo", t, re.I):
819
+ continue
820
+ else:
821
+ rep.fail(rel, f'"{name}" holds a line that is not a checkbox: "{t[:60]}"; every item is "- [ ] action — result", no narrative')
822
+ if boxes == 0:
823
+ rep.fail(rel, f'"{name}" has no checkbox items')
824
+ if name == "Rollback" and not re.search(r"(?im)^A rollback does not undo", txt):
825
+ rep.fail(rel, 'the "Rollback" section needs the line "A rollback does not undo: …"')
826
+
827
+ elif rule == "deploy-proven":
828
+ val = (c["header"].get("proven") or "").strip("`* ")
829
+ if val and not (val.lower() == "never" or re.fullmatch(r"\d{4}-\d{2}-\d{2}", val)):
830
+ rep.fail(rel, f'header field "Proven" reads "{val}"; write "never" or the date of the last real deploy')
831
+ txt = section_text(present, "Proven")
832
+ if txt is not None and not has_table_with(txt, "what", "executed", "when"):
833
+ rep.fail(rel, 'the "Proven" section needs a table with the columns What, Executed for real, When')
834
+ tgt = (c["header"].get("hosting target") or "")
835
+ if re.search(r"\b(and|or)\b|,|/", tgt):
836
+ rep.fail(rel, f'Hosting target "{tgt}" names more than one target; one document per hosting target')
837
+
838
+ elif rule == "brief-must-do":
839
+ txt = section_text(present, "Must do")
840
+ if txt is None:
841
+ return
842
+ n = len(re.findall(r"(?m)^\s*\d+[.)]\s+\S", txt))
843
+ if n == 0:
844
+ rep.fail(rel, 'the "Must do" section is not a numbered list; day-1 turns each line into requirements, so one line is one thing the product does')
845
+ elif n > 25:
846
+ rep.fail(rel, f'the "Must do" section has {n} lines; a brief that long is a BRD. Keep the page, and let day-1 expand it')
847
+
848
+ elif rule == "no-glossary":
849
+ # A document written for the owner explains itself in plain words or it is
850
+ # written wrong. A section that defines the document's own vocabulary before it
851
+ # can ask its question is the tell: the fix is to change the words, never to
852
+ # teach them (owner, 2026-09-08).
853
+ for _key, h, _txt in present:
854
+ if re.search(r"(?i)\b(glossary|terminology|words this document uses|"
855
+ r"terms? (used|you)|what these words mean|definitions)\b", h):
856
+ rep.fail(rel, f'section "{h}" defines this document\'s own vocabulary; '
857
+ f'write it in plain English instead — if a word needs a glossary, use a different word')
858
+
859
+ elif rule == "feedback-blocking-count":
860
+ txt = section_text(present, "Summary")
861
+ if txt is None:
862
+ return
863
+ if not re.search(r"(?i)\bblocking\b|\bnothing is blocked\b", txt):
864
+ rep.fail(rel, 'the Summary must say how many entries are blocking right now, '
865
+ 'or "Nothing is blocked" — it is the first thing the owner reads')
866
+
867
+ elif rule == "paste-back-block":
868
+ # `present` is built from the prose-only copy, which drops fenced blocks — and a
869
+ # fenced block is exactly what this rule looks for. Re-split the uncommented source.
870
+ raw_present = [(norm_heading(h), h, t) for h, t in split_sections(nocomment, 2) if h is not None]
871
+ txt = section_text(raw_present, "Copy this back to me")
872
+ if txt is None:
873
+ return
874
+ blocks = re.findall(r"^```", txt, re.M)
875
+ if len(blocks) < 2:
876
+ rep.fail(rel, 'the "Copy this back to me" section has no fenced block; '
877
+ 'the owner answers by pasting it, so it must be there to paste')
878
+ n_dec = len(c.get("entry_names") or [])
879
+ if n_dec and len(blocks) // 2 < n_dec:
880
+ rep.fail(rel, f'{n_dec} decisions but {len(blocks) // 2} paste-back block(s); one per decision')
881
+
882
+ elif rule == "phases-table":
883
+ txt = section_text(present, "Phases")
884
+ if txt is None:
885
+ return
886
+ if not has_table_with(txt, "phase", "name", "screens", "brd range", "status"):
887
+ rep.fail(rel, 'the "Phases" table needs the columns Phase, Name, Screens, BRD range, Status')
888
+ return
889
+ rows_out = []
890
+ for header, rows in tables_in(txt):
891
+ low = [h.lower() for h in header]
892
+ if not all(k in low for k in ("phase", "name", "screens", "brd range", "status")):
893
+ continue
894
+ ip, iname, iscr, irng, ist = (low.index(k) for k in ("phase", "name", "screens", "brd range", "status"))
895
+ for r in rows:
896
+ if len(r) <= max(ip, iname, iscr, irng, ist):
897
+ continue
898
+ pnum = r[ip].strip("`* ")
899
+ if not pnum.isdigit():
900
+ rep.fail(rel, f'Phases row "{r[ip]}": the Phase cell must be a number')
901
+ continue
902
+ n = int(pnum)
903
+ if is_placeholder(r[iname]):
904
+ rep.fail(rel, f"phase {n} has no name")
905
+ screens = [s.strip("`* ") for s in r[iscr].split(",") if s.strip()]
906
+ if not screens or any(is_placeholder(s) for s in screens):
907
+ rep.warn(rel, f"phase {n} lists no screens")
908
+ screens = [s for s in screens if not is_placeholder(s)]
909
+ # one range, or several separated by commas when items were added to an earlier phase
910
+ # after a later one existed ("BRD-1 to BRD-73, BRD-111 to BRD-118"): ids run on, never renumber
911
+ ranges = [(int(x), int(y)) for x, y in BRD_RANGE.findall(r[irng])] if not is_placeholder(r[irng]) else []
912
+ lo = hi = None
913
+ if not ranges:
914
+ rep.fail(rel, f'phase {n} BRD range "{r[irng]}" must read "BRD-<a> to BRD-<b>" (several ranges separated by commas are allowed)')
915
+ else:
916
+ lo, hi = ranges[0]
917
+ for x, y in ranges:
918
+ if x > y:
919
+ rep.fail(rel, f"phase {n} BRD range runs backwards ({x} to {y})")
920
+ st = r[ist].strip("`* ").lower()
921
+ if st not in PHASE_STATUS:
922
+ rep.fail(rel, f'phase {n} status "{r[ist]}" must be planned, building or done')
923
+ rows_out.append(dict(n=n, name=r[iname].strip("`* "), screens=screens, lo=lo, hi=hi, ranges=ranges, status=st))
924
+ nums = [p["n"] for p in rows_out]
925
+ if nums != list(range(1, len(nums) + 1)):
926
+ rep.fail(rel, f"phases must be numbered 1, 2, 3 … in order; found {', '.join(str(x) for x in nums) or 'none'}")
927
+ for a in rows_out:
928
+ for b in rows_out:
929
+ if a["n"] >= b["n"]:
930
+ continue
931
+ for x1, y1 in a["ranges"]:
932
+ for x2, y2 in b["ranges"]:
933
+ if x1 <= y2 and x2 <= y1:
934
+ rep.fail(rel, f"phase {a['n']} (BRD-{x1} to BRD-{y1}) and phase {b['n']} (BRD-{x2} to BRD-{y2}) overlap; ids run on across phases and are never reused")
935
+ seen = {}
936
+ for p in rows_out:
937
+ for s in p["screens"]:
938
+ key = s.lower()
939
+ if key in seen and seen[key] != p["n"]:
940
+ rep.fail(rel, f'screen "{s}" is in phase {seen[key]} and phase {p["n"]}; a screen sits in exactly one phase')
941
+ seen[key] = p["n"]
942
+ if not rows_out:
943
+ rep.fail(rel, 'the "Phases" table has no rows')
944
+ c["phases"] = rows_out
945
+
946
+
947
+ def check_checklist(c, rep):
948
+ rel, root, path, body, clean, present, size = (
949
+ c["rel"], c["root"], c["path"], c["body"], c["clean"], c["present"], c["size"])
950
+ txt = section_text(present, "Requirements Status")
951
+ if txt is None:
952
+ return
953
+ if not any(re.sub(r"\s+", " ", l.strip()) == CHECKLIST_HEADER for l in clean.splitlines()):
954
+ rep.fail(rel, f"the Requirements Status table header must be exactly {CHECKLIST_HEADER}")
955
+ rows = [l for l in txt.splitlines() if re.match(r"^\s*\|\s*REQ-", l)]
956
+ ids = []
957
+ row_status = {}
958
+ for l in rows:
959
+ cells = [x.strip() for x in l.strip().strip("|").split("|")]
960
+ if len(cells) < 6:
961
+ rep.fail(rel, f"row {cells[0] if cells else '?'} has {len(cells)} cells; six are needed")
962
+ continue
963
+ rid, _req, status, pct, remarks, details = cells[:6]
964
+ rid = rid.strip("`* ")
965
+ ids.append(rid)
966
+ row_status[rid] = status.strip("`* ").lower()
967
+ if not re.fullmatch(r"REQ-(UI|FN|RAG|NFR)-\d{3}", rid):
968
+ rep.fail(rel, f'id "{rid}" is not REQ-UI/FN/RAG/NFR- plus three digits')
969
+ if status.strip("`* ").lower() not in STATUS_VALUES:
970
+ rep.fail(rel, f'{rid} status "{status}" is not one of the fixed values')
971
+ if pct.strip("`* ").rstrip("%").strip() not in ("0", "25", "50", "75", "100"):
972
+ rep.fail(rel, f'{rid} % "{pct}" must be 0, 25, 50, 75 or 100')
973
+ n = len(re.findall(r"\S+", remarks))
974
+ if n > 60:
975
+ rep.fail(rel, f"{rid} Remarks is {n} words; at most 60, current state only (history lives in the telemetry streams)")
976
+ if NOT_PRESENT.search(remarks) and not NAMES_PATH.search(remarks):
977
+ rep.fail(rel, f"{rid} Remarks says something is not present without naming the path that was tried; search tools skip .tfcore/, so read the literal path first")
978
+ m = re.search(r"\(#([^)]+)\)", details)
979
+ if not m:
980
+ rep.fail(rel, f"{rid} Details cell has no link to its detail entry")
981
+ elif not re.search(rf"<a id=['\"]{re.escape(m.group(1))}['\"]", body):
982
+ rep.fail(rel, f"{rid} Details link #{m.group(1)} has no matching anchor in this file")
983
+ if not ids:
984
+ rep.fail(rel, "the Requirements Status table has no REQ- rows")
985
+ if len(ids) > REQ_CAP[size]:
986
+ rep.fail(rel, f"{len(ids)} rows; the {SIZE_LONG[size]} cap is {REQ_CAP[size]}. Split into phases instead of growing this checklist")
987
+ dupes = sorted({i for i in ids if ids.count(i) > 1})
988
+ if dupes:
989
+ rep.fail(rel, "duplicate rows: " + ", ".join(dupes))
990
+
991
+ brd_refs = set()
992
+ for rid in ids:
993
+ # a row logged from UAT by *triage-issues or *log-miss has no BRD item until *amend-docs
994
+ # gives it one; it carries the marker BRD-pending and stays Not Started (Sitting 4c, 2026-09-06)
995
+ m = re.search(rf"<a id=['\"]d-{re.escape(rid.lower())}['\"]", body)
996
+ if not m:
997
+ continue # reported above through the Details link
998
+ pos = m.start()
999
+ nxt = re.search(r"\n\s*(?:[-*]\s*)?<a id=|\n## |\n### ", body[pos + 1:]) # an entry is a list item: the dash precedes the anchor (miss 21)
1000
+ entry = body[pos: pos + 1 + nxt.start()] if nxt else body[pos:]
1001
+ acc = [l for l in entry.splitlines() if ACCEPT_LINE.search(l)]
1002
+ if len(acc) != 1:
1003
+ rep.fail(rel, f"{rid} must have exactly one acceptance line; found {len(acc)}")
1004
+ elif not ACCEPT_FORM.match(acc[0]):
1005
+ rep.fail(rel, f'{rid} acceptance line does not read "When <actor> <does what> on <screen>, then <observable result>"')
1006
+ elif rid.startswith(("REQ-UI", "REQ-FN")) and not NAMES_SCREEN.search(acc[0].split(", then", 1)[0]):
1007
+ rep.fail(rel, f'{rid} acceptance line must name the screen ("… on <screen>, then …")')
1008
+ if len(acc) == 1:
1009
+ n_acc = acceptance_words(acc[0])
1010
+ if n_acc > ACC_MAX:
1011
+ rep.fail(rel, f"{rid} acceptance line is {n_acc} words; at most {ACC_MAX} (target {ACC_TARGET}). One behaviour per row: split it, do not bundle steps")
1012
+ elif n_acc > ACC_TARGET:
1013
+ rep.warn(rel, f"{rid} acceptance line is {n_acc} words; the target is {ACC_TARGET} (maximum {ACC_MAX})")
1014
+ for l in entry.splitlines():
1015
+ if "perf-budget:" in l.lower() and not PERF_BUDGET.search(l):
1016
+ rep.fail(rel, f"{rid} perf-budget line is not in the form perf-budget: <p50|p95|max> <ttfb|load> <= <N>ms [@ concurrency <N>]")
1017
+ refs = re.findall(r"BRD-(\d+)", entry)
1018
+ if not refs and "brd-pending" in entry.lower() and row_status.get(rid) == "not started":
1019
+ rep.warn(rel, f"{rid} is a Not Started row logged from UAT with BRD-pending; *amend-docs gives it its BRD item")
1020
+ elif not refs:
1021
+ rep.fail(rel, f"{rid} detail entry does not name its BRD-N item")
1022
+ brd_refs.update(refs)
1023
+ if rid.startswith("REQ-UI"):
1024
+ links = links_to(entry, "mockups")
1025
+ if not links:
1026
+ rep.fail(rel, f"{rid} is a UI row without a mockup link")
1027
+ for l in sorted(links):
1028
+ if not resolve(root, path, l):
1029
+ rep.fail(rel, f"{rid} mockup {l} does not exist")
1030
+ c["checklist_brd_refs"] = brd_refs
1031
+ c["checklist_ids"] = ids
1032
+
1033
+
1034
+ # ----------------------------------------------------------------------------
1035
+ # cross-document rules
1036
+ # ----------------------------------------------------------------------------
1037
+ def _screen_key(s):
1038
+ s = re.sub(r"\s*[—(].*$", "", s.strip("`* ")) # "Profile (planned)" -> "Profile"
1039
+ return re.sub(r"\s+", " ", s).strip().lower()
1040
+
1041
+
1042
+ def cross_checks(ctxs: dict, rep: Report, root: str):
1043
+ """ctxs is keyed by (doc, phase). Single documents sit at phase 1."""
1044
+ phases = sorted({p for (_d, p) in ctxs})
1045
+ for p in phases:
1046
+ brd, ui, cl = ctxs.get(("brd", p)), ctxs.get(("uidesign", p)), ctxs.get(("checklist", p))
1047
+ if brd and ui and brd.get("brd_screens") is not None and ui["kind"] == "app":
1048
+ b = {_screen_key(s) for s in brd["brd_screens"]}
1049
+ u = {_screen_key(s) for s in ui["entry_names"]}
1050
+ for s in sorted(b - u):
1051
+ rep.fail(brd["rel"], f'screen "{s}" is in the BRD but has no "### Screen:" entry in the UIDesign')
1052
+ for s in sorted(u - b):
1053
+ rep.fail(ui["rel"], f'screen "{s}" is in the UIDesign but not in the BRD screens table')
1054
+ if brd and cl and brd.get("brd_ids") is not None and cl.get("checklist_brd_refs") is not None:
1055
+ missing = sorted(brd["brd_ids"] - cl["checklist_brd_refs"], key=int)
1056
+ if missing:
1057
+ shown = ", ".join(f"BRD-{m}" for m in missing[:20]) + (" …" if len(missing) > 20 else "")
1058
+ rep.fail(cl["rel"], f"BRD items with no checklist row ({len(missing)}): {shown}")
1059
+ uis = [ctxs[k] for k in sorted(ctxs) if k[0] == "uidesign"]
1060
+ if uis:
1061
+ mock_dir = os.path.join(root, "docs", "mockups")
1062
+ if os.path.isdir(mock_dir):
1063
+ linked = set()
1064
+ for ui in uis:
1065
+ linked |= {os.path.basename(l.split("#")[0]) for l in links_to(ui["clean"], "mockups")}
1066
+ for f in sorted(os.listdir(mock_dir)):
1067
+ if f.lower().endswith(".html") and f not in linked:
1068
+ rep.warn(uis[0]["rel"], f"mockup {f} is not linked from any screen")
1069
+ check_mockups(root, rep)
1070
+ cross_phase_checks(ctxs, rep, root, phases)
1071
+
1072
+
1073
+ def cross_phase_checks(ctxs, rep, root, phases):
1074
+ """Large projects: ids never reused across phases, every screen in exactly one phase,
1075
+ every phase row backed by its files (docs/TechieFlow-Document-Schemas.md §2, §3.11)."""
1076
+ ph = ctxs.get(("phases", 1))
1077
+ brds = {p: c for (d, p), c in ctxs.items() if d == "brd"}
1078
+ cls = {p: c for (d, p), c in ctxs.items() if d == "checklist"}
1079
+ uis = {p: c for (d, p), c in ctxs.items() if d == "uidesign"}
1080
+ app = next((c["app"] for c in ctxs.values() if c.get("app")), None)
1081
+
1082
+ seen = {}
1083
+ for p in sorted(brds):
1084
+ for i in sorted(brds[p].get("brd_ids") or (), key=int):
1085
+ if i in seen:
1086
+ rep.fail(brds[p]["rel"], f"BRD-{i} is also in phase {seen[i]}'s BRD; ids run on across phases and are never reused")
1087
+ seen[i] = p
1088
+ seen = {}
1089
+ for p in sorted(cls):
1090
+ for i in cls[p].get("checklist_ids") or ():
1091
+ if i in seen and seen[i] != p:
1092
+ rep.fail(cls[p]["rel"], f"{i} is also in phase {seen[i]}'s checklist; REQ ids run on across phases and are never reused")
1093
+ seen.setdefault(i, p)
1094
+
1095
+ if not ph:
1096
+ if len(phases) > 1 and app:
1097
+ rep.fail(f"docs/{app}-Phases.md", f"phase files exist ({', '.join(f'P{p}' for p in phases if p > 1)}) but the Phases document does not; write it from app-phases-tmpl.md")
1098
+ return
1099
+ rows = ph.get("phases") or []
1100
+ if not rows:
1101
+ return
1102
+ docs = os.path.dirname(ph["path"])
1103
+ app = app or ph["app"]
1104
+ for r in rows:
1105
+ n = r["n"]
1106
+ brd_name = phase_file(app, n, "BRD")
1107
+ if not os.path.exists(os.path.join(docs, brd_name)):
1108
+ rep.fail(ph["rel"], f"phase {n} has no BRD: docs/{brd_name} does not exist")
1109
+ cl_name = phase_file(app, n, "Checklist")
1110
+ if not os.path.exists(os.path.join(docs, cl_name)):
1111
+ rep.warn(ph["rel"], f"phase {n} has no checklist yet (docs/{cl_name}); day-1 stage 2 writes it")
1112
+ b = brds.get(n)
1113
+ if b and b.get("brd_ids") and r.get("ranges"):
1114
+ out = sorted((i for i in b["brd_ids"] if not any(x <= int(i) <= y for x, y in r["ranges"])), key=int)
1115
+ if out:
1116
+ shown = ", ".join(f"BRD-{i}" for i in out[:10]) + (" …" if len(out) > 10 else "")
1117
+ rng = ", ".join(f"BRD-{x} to BRD-{y}" for x, y in r["ranges"])
1118
+ rep.fail(b["rel"], f"{len(out)} requirement(s) outside phase {n}'s range {rng}: {shown}; add a second range to the Phases row for items added later")
1119
+ u = uis.get(n)
1120
+ if u and u["kind"] == "app":
1121
+ want = {_screen_key(s) for s in r["screens"]}
1122
+ have = {_screen_key(s) for s in u["entry_names"]}
1123
+ for s in sorted(have - want):
1124
+ rep.fail(ph["rel"], f'screen "{s}" is in phase {n}\'s UIDesign but not in its Phases row')
1125
+ for s in sorted(want - have):
1126
+ rep.fail(ph["rel"], f'screen "{s}" is listed under phase {n} but has no "### Screen:" entry in {os.path.basename(u["path"])}')
1127
+ listed = {n for n in (p for p in phases) if any(r["n"] == n for r in rows)}
1128
+ for p in sorted(phases):
1129
+ if p not in listed and (p in brds or p in cls or p in uis):
1130
+ rep.fail(ph["rel"], f"phase {p} has files but no row in the Phases table")
1131
+
1132
+
1133
+ ENTRY_NAMES = ("login", "signin", "sign-in", "onboarding", "welcome", "splash", "lock", "index", "home", "dashboard", "landing", "start")
1134
+ ANCHOR_HREF = re.compile(r"""<a\b[^>]*\bhref\s*=\s*["']([^"'#?]+)""", re.I)
1135
+ LINK_TAG = re.compile(r"<link\b[^>]*>", re.I)
1136
+ TAG_HREF = re.compile(r"""\bhref\s*=\s*["']([^"'#?]+)""", re.I)
1137
+ FORM_ACTION = re.compile(r"""<form\b[^>]*\baction\s*=\s*["']([^"'#?]+)""", re.I)
1138
+ BUTTON = re.compile(r"<button\b[^>]*>", re.I)
1139
+ # navigation done by script instead of a link: works only where scripts run, so a viewer
1140
+ # without them (a preview pane, a rendered-doc iframe) shows a dead page (owner, 2026-09-05)
1141
+ SCRIPT_NAV = re.compile(r"""onclick\s*=\s*["'][^"']*(?:location\.href|window\.location|location\.(?:assign|replace)|history\.(?:back|go))[^"']*["']""", re.I)
1142
+ SCRIPT_NAV_TARGET = re.compile(r"""(?:location\.href|window\.location(?:\.href)?|location\.(?:assign|replace))\s*(?:=|\()\s*["']([^"'#?]+)""", re.I)
1143
+ NAV_BLOCK = re.compile(r"<nav\b[^>]*>(.*?)</nav>", re.I | re.S)
1144
+ NAV_ITEM = re.compile(r"""<(div|li|span|button)\b[^>]*\bclass\s*=\s*["'][^"']*(?:item|link|tab)[^"']*["'][^>]*>(.*?)</\1>""", re.I | re.S)
1145
+
1146
+
1147
+ def _mock_target(mock_dir: str, t: str):
1148
+ """Resolve a link the way a browser opening docs/mockups/<file> would. Returns the file
1149
+ name when it lands on an existing file inside the mockup folder, else None."""
1150
+ if re.match(r"^(https?:|mailto:|tel:|javascript:|data:)", t, re.I) or t.startswith("/"):
1151
+ return None
1152
+ p = os.path.normpath(os.path.join(mock_dir, t))
1153
+ if os.path.dirname(p) != os.path.normpath(mock_dir) or not os.path.isfile(p):
1154
+ return None
1155
+ return os.path.basename(p)
1156
+
1157
+
1158
+ def check_mockups(root: str, rep: Report):
1159
+ """A mockup set is a click-through: every link resolves, every screen is reachable from
1160
+ the entry screen, every screen has a way out, every button does something, and every
1161
+ file carries data-testid anchors the verifier can grade (FR-53, owner rule 2026-09-05).
1162
+ Since Sitting 4b: navigation is an <a href>, never a script; a menu item goes somewhere;
1163
+ a link is resolved from the mockup's own folder, not by its file name; stylesheets exist."""
1164
+ mock_dir = os.path.join(root, "docs", "mockups")
1165
+ files = sorted(f for f in os.listdir(mock_dir) if f.lower().endswith((".html", ".htm")))
1166
+ if not files:
1167
+ return
1168
+ rel_dir = "docs/mockups"
1169
+ out_links = {}
1170
+ for f in files:
1171
+ try:
1172
+ with open(os.path.join(mock_dir, f), encoding="utf-8", errors="replace") as fh:
1173
+ html = fh.read()
1174
+ except Exception:
1175
+ continue
1176
+ rel = f"{rel_dir}/{f}"
1177
+ if "data-testid" not in html:
1178
+ rep.fail(rel, "mockup has no data-testid anchors; the verifier cannot compare the built screen to it")
1179
+ targets = []
1180
+ for m in list(ANCHOR_HREF.finditer(html)) + list(FORM_ACTION.finditer(html)):
1181
+ t = m.group(1).strip()
1182
+ if not t or re.match(r"^(https?:|mailto:|tel:|javascript:|data:)", t, re.I):
1183
+ continue
1184
+ if not t.lower().endswith((".html", ".htm")):
1185
+ continue
1186
+ name = _mock_target(mock_dir, t)
1187
+ if name is None:
1188
+ hint = "write the file name alone, not a folder or a leading slash" if ("/" in t) else "the file does not exist"
1189
+ rep.fail(rel, f'links to "{t}", which does not open from docs/mockups/ ({hint})')
1190
+ else:
1191
+ targets.append(name)
1192
+ for m in LINK_TAG.finditer(html):
1193
+ tag = m.group(0)
1194
+ if not re.search(r"""rel\s*=\s*["']stylesheet["']""", tag, re.I):
1195
+ continue
1196
+ h = TAG_HREF.search(tag)
1197
+ if h and not re.match(r"^https?:", h.group(1), re.I) and _mock_target(mock_dir, h.group(1).strip()) is None:
1198
+ rep.fail(rel, f'stylesheet "{h.group(1)}" does not open from docs/mockups/; the mockup renders unstyled')
1199
+ nav_by_script = SCRIPT_NAV.findall(html)
1200
+ if nav_by_script:
1201
+ rep.fail(rel, f'{len(nav_by_script)} element(s) navigate by script (onclick location.href); write <a href="screen.html"> so the link works in every viewer')
1202
+ for m in SCRIPT_NAV_TARGET.finditer(html):
1203
+ name = _mock_target(mock_dir, m.group(1).strip())
1204
+ if name:
1205
+ targets.append(name) # keep the graph honest, the file is still refused above
1206
+ for block in NAV_BLOCK.findall(html):
1207
+ for m in NAV_ITEM.finditer(block):
1208
+ tag_and_body = m.group(0)
1209
+ if re.search(r"<a\b[^>]*\bhref", tag_and_body, re.I) or re.search(r"\bonclick\s*=", tag_and_body, re.I):
1210
+ continue
1211
+ if re.search(r"""\b(active|current|selected)\b""", re.match(r"<[^>]*>", tag_and_body).group(0), re.I):
1212
+ continue # the item for the page itself
1213
+ label = re.sub(r"<[^>]+>", " ", m.group(2))
1214
+ label = re.sub(r"\s+", " ", label).strip()[:40] or "(unnamed)"
1215
+ rep.fail(rel, f'menu item "{label}" goes nowhere; every menu item is an <a href> to its screen')
1216
+ out_links[f] = set(targets) - {f}
1217
+ inert = 0
1218
+ for m in BUTTON.finditer(html):
1219
+ tag = m.group(0)
1220
+ if re.search(r"\bonclick\s*=", tag, re.I):
1221
+ continue
1222
+ if re.search(r"""type\s*=\s*["']submit["']""", tag, re.I) and FORM_ACTION.search(html):
1223
+ continue
1224
+ inert += 1
1225
+ if inert:
1226
+ rep.fail(rel, f"{inert} button(s) do nothing: give each an onclick that shows a message, make it a form submit with an action, or make it a link")
1227
+ if len(files) < 2:
1228
+ return
1229
+ for f in files:
1230
+ if not out_links.get(f):
1231
+ rep.fail(f"{rel_dir}/{f}", "has no link to any other mockup; every screen needs a way out (a menu, a back link or a logout)")
1232
+ entry = next((f for n in ENTRY_NAMES for f in files if f.lower().startswith(n)), files[0])
1233
+ seen, todo = {entry}, [entry]
1234
+ while todo:
1235
+ cur = todo.pop()
1236
+ for t in out_links.get(cur, ()):
1237
+ if t not in seen:
1238
+ seen.add(t)
1239
+ todo.append(t)
1240
+ unreachable = [f for f in files if f not in seen]
1241
+ if unreachable:
1242
+ rep.fail(rel_dir, f"{len(unreachable)} mockup(s) cannot be reached by clicking from {entry}: " + ", ".join(unreachable[:10]) + (" …" if len(unreachable) > 10 else ""))
1243
+
1244
+
1245
+ # ----------------------------------------------------------------------------
1246
+ def app_files(root: str, app: str):
1247
+ docs = os.path.join(root, "docs")
1248
+ names = [f"{app}-Brief.md", f"{app}-Phases.md", f"{app}-BRD.md", f"{app}-Architecture.md", f"{app}-UIDesign.md", f"{app}-Checklist.md",
1249
+ f"{app}-Coding-Standards.md", f"{app}-UsageGuide.md", f"{app}-DevGuide.md", f"{app}-ProductGuide.md"]
1250
+ # later phases of a Large project: App-P2-BRD.md, App-P2-Checklist.md, … in phase order
1251
+ extra = []
1252
+ if os.path.isdir(docs):
1253
+ for f in os.listdir(docs):
1254
+ m = re.match(rf"^{re.escape(app)}-P(\d+)-(BRD|Checklist|UIDesign|DevGuide)\.md$", f)
1255
+ if m:
1256
+ extra.append((int(m.group(1)), ["BRD", "UIDesign", "Checklist", "DevGuide"].index(m.group(2)), f))
1257
+ names += [f for _p, _o, f in sorted(extra)]
1258
+ if os.path.isdir(docs):
1259
+ names += sorted(f for f in os.listdir(docs) if re.match(rf"^{re.escape(app)}-Deployment-Checklist(-[\w]+)?\.md$", f))
1260
+ # the two documents written for a reader outside this project: the owner's open
1261
+ # decisions, and whatever this project has reported upstream (one file per upstream)
1262
+ names += sorted(f for f in os.listdir(docs)
1263
+ if re.match(rf"^{re.escape(app)}-Decision-Request\.md$", f)
1264
+ or re.match(rf"^{re.escape(app)}-[\w.]+-Feedback\.md$", f))
1265
+ out = [os.path.join(docs, n) for n in names if os.path.exists(os.path.join(docs, n))]
1266
+ ps = os.path.join(root, "PROJECT-STATUS.md")
1267
+ if os.path.exists(ps):
1268
+ out.append(ps)
1269
+ return out
1270
+
1271
+
1272
+ def main(argv=None):
1273
+ ap = argparse.ArgumentParser(description="Check TechieFlow documents against their template schemas.")
1274
+ ap.add_argument("files", nargs="*", help="document paths")
1275
+ ap.add_argument("--app", help="check every human document of this app under <root>/docs plus PROJECT-STATUS.md")
1276
+ ap.add_argument("--root", help="project root (default: found from the first file, or the current directory)")
1277
+ ap.add_argument("--size", help="Small|Medium|Large when no header or core-config carries it")
1278
+ ap.add_argument("--warn", action="store_true", help="report only: every finding is WARN and the exit code is 0")
1279
+ ap.add_argument("--quiet", action="store_true", help="print findings and the summary only")
1280
+ ap.add_argument("--strict", action="store_true", help="ignore the baseline: every finding FAILs, old or new")
1281
+ ap.add_argument("--baseline-write", action="store_true",
1282
+ help="record the current findings of these files as the baseline (.tfcore/.session/doc-check-baseline.json) and print nothing else")
1283
+ a = ap.parse_args(argv)
1284
+
1285
+ if not os.path.isdir(TEMPLATE_DIR):
1286
+ print(f"tf-doc-check: template folder not found at {TEMPLATE_DIR}", file=sys.stderr)
1287
+ return 2
1288
+ root = os.path.abspath(a.root) if a.root else None
1289
+ files = list(a.files)
1290
+ if a.app:
1291
+ root = root or os.getcwd()
1292
+ files += app_files(root, a.app)
1293
+ if not files:
1294
+ print(f"tf-doc-check: no documents for app {a.app} under {root}", file=sys.stderr)
1295
+ return 2
1296
+ if not files:
1297
+ ap.print_help()
1298
+ return 2
1299
+ root = root or find_root(files[0])
1300
+
1301
+ rep = Report(a.warn)
1302
+ ctxs, checked = {}, 0
1303
+ for f in files:
1304
+ if not os.path.exists(f):
1305
+ rep.fail(os.path.relpath(f, root), "file not found")
1306
+ continue
1307
+ ctx = check_document(f, rep, a.size, root)
1308
+ checked += 1
1309
+ if ctx:
1310
+ ctxs[(ctx["doc"], ctx["phase"])] = ctx
1311
+ if len(ctxs) > 1:
1312
+ cross_checks(ctxs, rep, root)
1313
+
1314
+ # The baseline (Sitting 4c, 2026-09-06; Schemas §7.1 decision 8): findings that were already
1315
+ # there when the command started are printed as OLD and do not block; only a finding this
1316
+ # command introduced FAILs. tf-phase.sh start writes the baseline; --strict ignores it.
1317
+ base_path = os.path.join(root, ".tfcore", ".session", "doc-check-baseline.json")
1318
+ if a.baseline_write:
1319
+ base = {}
1320
+ try:
1321
+ with open(base_path, encoding="utf-8") as fh:
1322
+ base = json.load(fh) or {}
1323
+ except Exception:
1324
+ base = {}
1325
+ for f in files:
1326
+ r = os.path.relpath(f, root)
1327
+ base[r] = sorted({l.split(": ", 1)[1] for l in rep.lines if l.startswith("FAIL ") and l.split(": ", 1)[0].endswith(" " + r)})
1328
+ os.makedirs(os.path.dirname(base_path), exist_ok=True)
1329
+ with open(base_path, "w", encoding="utf-8") as fh:
1330
+ json.dump(base, fh, indent=1)
1331
+ print(f"tf-doc-check: baseline written for {len(files)} file(s) ({sum(len(v) for v in base.values())} old finding(s))")
1332
+ return 0
1333
+ old = 0
1334
+ if not a.strict and not a.warn and os.path.isfile(base_path):
1335
+ try:
1336
+ import time as _t
1337
+ if _t.time() - os.path.getmtime(base_path) < 24 * 3600:
1338
+ with open(base_path, encoding="utf-8") as fh:
1339
+ base = json.load(fh) or {}
1340
+ relined = []
1341
+ for l in rep.lines:
1342
+ if l.startswith("FAIL "):
1343
+ head, msg = l.split(": ", 1)
1344
+ r = head[len("FAIL "):]
1345
+ if msg in set(base.get(r, [])):
1346
+ relined.append("OLD " + l[5:])
1347
+ rep.fails -= 1
1348
+ old += 1
1349
+ continue
1350
+ relined.append(l)
1351
+ rep.lines = relined
1352
+ except Exception:
1353
+ pass
1354
+
1355
+ for line in rep.lines:
1356
+ print(line)
1357
+ if not a.quiet:
1358
+ for f in files:
1359
+ if os.path.exists(f):
1360
+ r = os.path.relpath(f, root)
1361
+ if not any(l.split(": ", 1)[0].endswith(" " + r) for l in rep.lines):
1362
+ print(f"OK {r}")
1363
+ print(f"tf-doc-check: {rep.fails} FAIL, {rep.warns} WARN in {checked} document(s)"
1364
+ + (f"; {old} OLD finding(s) from before this command, not blocking (repair through *amend-docs)" if old else ""))
1365
+ return 1 if rep.fails else 0
1366
+
1367
+
1368
+ if __name__ == "__main__":
1369
+ sys.exit(main())