@techierathore/techieflow 1.0.1 → 1.0.3

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