@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,270 @@
1
+ #!/usr/bin/env python3
2
+ """tf-verify-list.py — the working list of a verify run (see tf-verify-list.sh).
3
+
4
+ bash .tfcore/utils/tf-verify-list.sh <App> <scope> [--phase N] [--json-out <file>]
5
+
6
+ scope: ui (REQ-UI rows) · functional (REQ-FN, REQ-NFR, REQ-RAG) · all · a comma list of ids.
7
+ Reads the phase's checklist, the UIDesign, the BRD screens table and the UsageGuide, and prints:
8
+ - every row in scope with its status, acceptance line, screen, route, mockup and perf budget;
9
+ N/A rows are skipped, every other status is graded (a Verified row is re-confirmed)
10
+ - the screens to drive, each with its route, its mockup and the rows it owns; a dialog row
11
+ is listed under its parent screen (Schemas §2: a dialog is verified on its page)
12
+ - rows whose screen could not be resolved (graded by their test only)
13
+ - the test users from the UsageGuide
14
+ Writes the same as JSON (default tests/.artifacts/verify/list.json) for the other verify scripts.
15
+ Python 3 standard library only. Exit 0 printed, 2 could not run.
16
+ """
17
+ import json
18
+ import os
19
+ import re
20
+ import sys
21
+
22
+ NA = {"n/a"}
23
+
24
+
25
+ def die(msg):
26
+ print(f"tf-verify-list: {msg}", file=sys.stderr)
27
+ sys.exit(2)
28
+
29
+
30
+ def read(p):
31
+ with open(p, encoding="utf-8-sig", errors="replace") as f:
32
+ return f.read()
33
+
34
+
35
+ def cfg(key, default):
36
+ p = os.path.join(".tfcore", "core-config.yaml")
37
+ if os.path.isfile(p):
38
+ m = re.search(rf"(?m)^{key}:\s*(\S+)", read(p))
39
+ if m and m.group(1) not in ("null", "~"):
40
+ return m.group(1).strip("'\"")
41
+ return default
42
+
43
+
44
+ def norm_status(s):
45
+ s = s.strip().strip("`* ").lower()
46
+ return re.sub(r"\s*\(.*$", "", s).strip()
47
+
48
+
49
+ def norm_name(s):
50
+ s = re.sub(r"\(.*?\)", "", s or "") # "Today (home)" -> "Today"
51
+ s = re.sub(r"\b(screen|page|dialog)\b", "", s, flags=re.I)
52
+ return re.sub(r"[^a-z0-9]+", " ", s.lower()).strip()
53
+
54
+
55
+ def rows_of(text):
56
+ rows = []
57
+ for line in text.splitlines():
58
+ if re.match(r"^\s*\|\s*`?REQ-", line):
59
+ c = [x.strip() for x in line.strip().strip("|").split("|")]
60
+ if len(c) >= 3:
61
+ rid = c[0].strip("`* ").upper()
62
+ pct = re.sub(r"[^0-9]", "", c[3]) if len(c) > 3 else ""
63
+ rows.append({"id": rid, "class": re.match(r"REQ-([A-Z]+)-", rid).group(1) if re.match(r"REQ-([A-Z]+)-", rid) else "FN",
64
+ "title": c[1].strip("`* "), "status_raw": c[2].strip(), "status": norm_status(c[2]),
65
+ "pct": int(pct) if pct.isdigit() else None, "remarks": c[4] if len(c) > 4 else ""})
66
+ return rows
67
+
68
+
69
+ def entries_of(text):
70
+ """id -> {section, acceptance, mockup, brd, perf_budget}"""
71
+ out, current = {}, ""
72
+ lines = text.splitlines()
73
+ for i, line in enumerate(lines):
74
+ m = re.match(r"^##\s+(.+)$", line)
75
+ if m:
76
+ current = re.sub(r"^(?:page|screen)\s*:\s*", "", m.group(1).strip(), flags=re.I)
77
+ continue
78
+ a = re.search(r"<a id=['\"]d-(req-[a-z]+-\d+)['\"]", line, re.I)
79
+ if a:
80
+ rid = a.group(1).upper()
81
+ e = {"section": current, "acceptance": "", "mockup": "", "brd": "", "perf_budget": ""}
82
+ for j in range(i, min(i + 14, len(lines))):
83
+ l = lines[j]
84
+ if re.search(r"\*Acceptance:?\*|^\s*[-*]\s*Acceptance:", l, re.I) and not e["acceptance"]:
85
+ e["acceptance"] = re.sub(r"^\s*[-*]\s*\*?Acceptance:?\*?:?\s*", "", l).strip()
86
+ mm = re.search(r"((?:docs/)?mockups/[\w./-]+\.html)", l)
87
+ if mm and not e["mockup"]:
88
+ e["mockup"] = mm.group(1) if mm.group(1).startswith("docs/") else "docs/" + mm.group(1)
89
+ bm = re.search(r"BRD-\d+", l)
90
+ if bm and not e["brd"]:
91
+ e["brd"] = bm.group(0)
92
+ pb = re.search(r"perf-budget:\s*([^|`]+)", l)
93
+ if pb and not e["perf_budget"]:
94
+ e["perf_budget"] = pb.group(1).strip()
95
+ if j > i and re.match(r"^\s*(?:[-*]\s*)?<a id=|^## ", l):
96
+ break # the next entry starts: nothing below belongs to this row
97
+ out[rid] = e
98
+ return out
99
+
100
+
101
+ def screens_of_uidesign(path):
102
+ """[{name, route, mockup}] from '### Screen: Name (/route)' sections."""
103
+ out = []
104
+ if not os.path.isfile(path):
105
+ return out
106
+ txt = read(path)
107
+ parts = re.split(r"(?m)^(?=###\s+Screen:)", txt)
108
+ for p in parts:
109
+ m = re.match(r"###\s+Screen:\s*(.+?)\s*\(`?([^)`]+)`?\)\s*$", p.splitlines()[0] if p.strip() else "")
110
+ if not m:
111
+ continue
112
+ mk = re.search(r"((?:docs/)?mockups/[\w./-]+\.html)", p)
113
+ mock = mk.group(1) if mk else ""
114
+ if mock and not mock.startswith("docs/"):
115
+ mock = "docs/" + mock
116
+ out.append({"name": m.group(1).strip(), "route": m.group(2).strip(), "mockup": mock})
117
+ return out
118
+
119
+
120
+ def dialogs_of_brd(path):
121
+ """name -> parent route, from the BRD screens table rows whose Route cell reads 'on /route'."""
122
+ out = {}
123
+ if not os.path.isfile(path):
124
+ return out
125
+ for line in read(path).splitlines():
126
+ if not line.startswith("|"):
127
+ continue
128
+ c = [x.strip().strip("`* ") for x in line.strip().strip("|").split("|")]
129
+ if len(c) >= 2 and re.match(r"(?i)^on\s+/", c[1]):
130
+ out[norm_name(c[0])] = re.sub(r"(?i)^on\s+", "", c[1]).strip()
131
+ return out
132
+
133
+
134
+ def test_users(path):
135
+ users = []
136
+ if not os.path.isfile(path):
137
+ return users
138
+ m = re.search(r"(?ms)^##\s+(?:\d+[.)]\s*)?Test users[^\n]*\n(.*?)(?=^## |\Z)", read(path))
139
+ if not m:
140
+ return users
141
+ for line in m.group(1).splitlines():
142
+ c = [x.strip().strip("`* ") for x in line.strip().strip("|").split("|")]
143
+ if len(c) >= 4 and c[0].isdigit():
144
+ users.append({"user": c[1], "password_source": c[2], "role": c[3]})
145
+ return users
146
+
147
+
148
+ def in_scope(rid, scope):
149
+ s = scope.strip().lower()
150
+ if s == "all":
151
+ return True
152
+ if s == "ui":
153
+ return rid.startswith("REQ-UI-")
154
+ if s == "functional":
155
+ return rid.startswith(("REQ-FN-", "REQ-NFR-", "REQ-RAG-"))
156
+ ids = {x.strip().upper() for x in re.split(r"[,\s]+", scope) if x.strip()}
157
+ return rid in ids
158
+
159
+
160
+ def screen_from_acceptance(acc):
161
+ """'When the user taps Save on Entry Editor, then ...' -> 'Entry Editor'."""
162
+ if not acc:
163
+ return ""
164
+ head = re.split(r",\s*then\b|\bthen\b", acc, maxsplit=1)[0]
165
+ hits = re.findall(r"\bon\s+(?:the\s+)?([A-Z][\w'()/&-]*(?:\s+[\w'()/&-]+){0,4}?)(?=\s*(?:,|$|\s+(?:and|with|for|then)\b))", head)
166
+ return hits[-1].strip() if hits else ""
167
+
168
+
169
+ def main(argv):
170
+ if len(argv) < 3 or argv[1] in ("-h", "--help"):
171
+ print(__doc__)
172
+ return 0
173
+ app, scope = argv[1], argv[2]
174
+ phase = int(cfg("appPhase", "1"))
175
+ if "--phase" in argv:
176
+ phase = int(argv[argv.index("--phase") + 1])
177
+ out_path = os.path.join("tests", ".artifacts", "verify", "list.json")
178
+ if "--json-out" in argv:
179
+ out_path = argv[argv.index("--json-out") + 1]
180
+ pfx = f"{app}-" if phase <= 1 else f"{app}-P{phase}-"
181
+ cl = os.path.join("docs", f"{pfx}Checklist.md")
182
+ if not os.path.isfile(cl):
183
+ die(f"{cl} does not exist")
184
+ text = read(cl)
185
+ rows = rows_of(text)
186
+ if not rows:
187
+ die(f"{cl} has no REQ rows")
188
+ ent = entries_of(text)
189
+ screens = screens_of_uidesign(os.path.join("docs", f"{pfx}UIDesign.md"))
190
+ dialogs = dialogs_of_brd(os.path.join("docs", f"{pfx}BRD.md"))
191
+ users = test_users(os.path.join("docs", f"{app}-UsageGuide.md"))
192
+ by_name = {norm_name(s["name"]): s for s in screens}
193
+ by_mock = {s["mockup"]: s for s in screens if s["mockup"]}
194
+ by_route = {s["route"].lower(): s for s in screens}
195
+
196
+ work, skipped, unresolved = [], 0, []
197
+ for r in rows:
198
+ if not in_scope(r["id"], scope):
199
+ continue
200
+ if r["status"] in NA:
201
+ skipped += 1
202
+ continue
203
+ e = ent.get(r["id"], {"section": "", "acceptance": "", "mockup": "", "brd": "", "perf_budget": ""})
204
+ row = dict(r, acceptance=e["acceptance"], mockup=e["mockup"], brd=e["brd"], perf_budget=e["perf_budget"],
205
+ section=e["section"], screen="", route="", dialog="")
206
+ cand = [screen_from_acceptance(e["acceptance"]), e["section"], r["title"]]
207
+ for c in cand:
208
+ n = norm_name(c)
209
+ if not n:
210
+ continue
211
+ if n in by_name:
212
+ row["screen"], row["route"] = by_name[n]["name"], by_name[n]["route"]
213
+ break
214
+ if n in dialogs and dialogs[n].lower() in by_route:
215
+ s = by_route[dialogs[n].lower()]
216
+ row["screen"], row["route"], row["dialog"] = s["name"], s["route"], c.strip()
217
+ break
218
+ if not row["screen"] and e["mockup"] in by_mock:
219
+ s = by_mock[e["mockup"]]
220
+ row["screen"], row["route"] = s["name"], s["route"]
221
+ if not row["screen"] and row["class"] != "NFR":
222
+ unresolved.append(row["id"])
223
+ work.append(row)
224
+
225
+ screen_list = []
226
+ for s in screens:
227
+ owned = [r["id"] for r in work if r["screen"] == s["name"]]
228
+ if owned:
229
+ screen_list.append(dict(s, rows=owned))
230
+
231
+ print(f"# tf-verify-list — {app} — {cl} — phase {phase} — scope {scope}")
232
+ counts = {}
233
+ for r in work:
234
+ counts[r["status_raw"] or "?"] = counts.get(r["status_raw"] or "?", 0) + 1
235
+ print(f"Rows to grade: {len(work)} ({', '.join(f'{v} {k}' for k, v in sorted(counts.items()))}); {skipped} N/A skipped; {len(rows)} in the checklist")
236
+ if not work:
237
+ print("NOTHING: no row in scope. Run the status gate and stop.")
238
+ print()
239
+ print(f"## Screens to drive ({len(screen_list)} of {len(screens)} in the UIDesign)")
240
+ for s in screen_list:
241
+ print(f"- {s['name']} ({s['route']}) — mockup {s['mockup'] or 'none'} — rows {', '.join(s['rows'])}")
242
+ print()
243
+ print("## Rows")
244
+ for r in work:
245
+ where = f"{r['screen']} {r['route']}" + (f" — dialog {r['dialog']}" if r["dialog"] else "") if r["screen"] else "no screen resolved"
246
+ extra = f" — perf-budget: {r['perf_budget']}" if r["perf_budget"] else ""
247
+ print(f"- {r['id']} [{r['status_raw']}] {r['title']} — {where}{extra}")
248
+ print(f" acceptance: {r['acceptance'] or 'MISSING — the row cannot be graded; fix the checklist'}")
249
+ if unresolved:
250
+ print()
251
+ print(f"## No screen resolved ({len(unresolved)}): graded by their test only — " + ", ".join(unresolved))
252
+ print()
253
+ print("## Test users (UsageGuide)")
254
+ for u in users:
255
+ print(f"- {u['user']} — {u['role']} — password: {u['password_source']}")
256
+ if not users:
257
+ print("- none in the UsageGuide: the smoke policy says what to do")
258
+
259
+ os.makedirs(os.path.dirname(out_path) or ".", exist_ok=True)
260
+ with open(out_path, "w", encoding="utf-8") as f:
261
+ json.dump({"app": app, "scope": scope, "phase": phase, "kind": cfg("appKind", "app"), "checklist": cl,
262
+ "rows": work, "screens": screen_list, "unresolved": unresolved, "test_users": users,
263
+ "skipped_na": skipped, "total": len(rows)}, f, indent=1)
264
+ print()
265
+ print(f"JSON: {out_path}")
266
+ return 0
267
+
268
+
269
+ if __name__ == "__main__":
270
+ sys.exit(main(sys.argv))
@@ -0,0 +1,13 @@
1
+ #!/usr/bin/env bash
2
+ # tf-verify-list.sh — the working list of a verify run (Sitting 4c, 2026-09-06).
3
+ #
4
+ # bash .tfcore/utils/tf-verify-list.sh <App> <scope> [--phase N] [--json-out <file>]
5
+ #
6
+ # scope: ui · functional · all · a comma list of REQ ids. Prints every row to grade with its
7
+ # acceptance line, screen, route, mockup and perf budget, the screens to drive, and the test
8
+ # users; writes tests/.artifacts/verify/list.json for the other verify scripts. Skips N/A rows
9
+ # only. Writes nothing else. See tf-verify-list.py for the details.
10
+ set -u
11
+ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
12
+ command -v python3 >/dev/null 2>&1 || { echo "tf-verify-list: python3 is required" >&2; exit 2; }
13
+ exec python3 "$HERE/tf-verify-list.py" "$@"
@@ -0,0 +1,355 @@
1
+ // tf-verify-screens.mjs — drive every screen and check that it renders and looks right.
2
+ // Run through tf-verify-screens.sh, not directly. Sitting 4c, 2026-09-06.
3
+ //
4
+ // Two of the seven verify checks, over one browser session:
5
+ // render every control the mockup anchors (data-testid) is present and shows something; no
6
+ // table with a header and no rows; no blank page; no error banner; no console error.
7
+ // visual no two visible controls overlap; no anchored control has zero size or sits off-screen;
8
+ // no horizontal overflow; the page has a stylesheet. Overlap and off-screen are measured
9
+ // on the rectangle an element PAINTS in — its box clipped by every scrolling ancestor —
10
+ // so a wide table inside a horizontal scroller is not read as covering its neighbour.
11
+ // Measurement waits for the screen's first render (--render-wait, default 5000 ms) before reading
12
+ // the page: it waits for content to appear, knowing nothing about how the page is built.
13
+ // At every width it saves a full-page screenshot. Verdicts: render OK | EMPTY | ERROR | UNREACHABLE,
14
+ // visual OK | FAIL, each with its findings. Findings use the telemetry classes (blank-data,
15
+ // zero-rows, exception, overlap, clipped, offscreen, other). It writes a JSON file the verdict
16
+ // script reads and prints one line per screen. It never decides a row's status.
17
+ //
18
+ // Modes: --base URL (a served app, a new browser) or --cdp URL (attach to an already-running
19
+ // embedded-browser desktop head over its DevTools port; screens are reached by pushState, the way
20
+ // the router does).
21
+
22
+ import { chromium } from 'playwright';
23
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
24
+ import { dirname, resolve } from 'node:path';
25
+
26
+ const argv = process.argv.slice(2);
27
+ const arg = (n, d = null) => { const i = argv.indexOf(n); return i >= 0 && i + 1 < argv.length ? argv[i + 1] : d; };
28
+ const args = (n) => argv.flatMap((a, i) => (a === n && i + 1 < argv.length ? [argv[i + 1]] : []));
29
+
30
+ const BASE = (arg('--base') || '').replace(/\/$/, '');
31
+ const CDP = arg('--cdp') || '';
32
+ const LIST = arg('--list');
33
+ const MOCKUPS = arg('--mockups', 'docs/mockups');
34
+ const WIDTHS = (arg('--widths', '1280,390')).split(',').map((w) => parseInt(w.trim(), 10)).filter(Boolean);
35
+ const OUT = arg('--json-out', 'tests/.artifacts/verify/screens.json');
36
+ const SHOTS = arg('--shots-dir', 'tests/.artifacts/verify/screens');
37
+ const LOGIN_PATH = arg('--login-path');
38
+ const USER = arg('--user'); const PASS = arg('--password');
39
+ const COOKIE = arg('--cookie'); const STORAGE = arg('--storage-state');
40
+ const SETTLE = parseInt(arg('--wait', '1500'), 10);
41
+ // how long to keep waiting for the FIRST render after the page has settled. Plenty of stacks
42
+ // paint after the network goes quiet — a server-held connection that renders once it is up, a
43
+ // client router resolving its first route, a lazily loaded bundle — so measuring at
44
+ // domcontentloaded, at networkidle or even after --wait can read an empty document and report
45
+ // every anchored control as missing while the tool's own screenshot shows them (TF-021, TfLens
46
+ // 2026-09-09). Nothing here knows which stack it is looking at: it waits for CONTENT.
47
+ const RENDER_WAIT = parseInt(arg('--render-wait', '5000'), 10);
48
+ const ATTR = arg('--testid-attr', 'data-testid');
49
+ // The banner a stack shows when its client side has fallen over. There is no neutral way to
50
+ // detect one -- every stack names its own -- so this is a LIST a project extends rather than a
51
+ // selector the framework assumes: --error-selector '.my-crash-banner', repeatable. The two
52
+ // defaults are one widely used framework's id and a plain attribute any project can put on its
53
+ // own banner. A project on neither still gets every other render check; it simply cannot be
54
+ // told about a banner nobody named.
55
+ const ERROR_SELECTORS = [...args('--error-selector'), '#blazor-error-ui', '[data-error-ui]'];
56
+
57
+ if (!BASE && !CDP) { console.error('tf-verify-screens: --base URL or --cdp URL is required'); process.exit(3); }
58
+
59
+ // ---------------------------------------------------------------- screens
60
+ let screens = [];
61
+ if (LIST) {
62
+ const l = JSON.parse(readFileSync(LIST, 'utf8'));
63
+ screens = (l.screens || []).map((s) => ({ name: s.name, route: s.route, mockup: s.mockup || '', rows: s.rows || [] }));
64
+ }
65
+ for (const s of args('--screen')) {
66
+ const m = s.match(/^([^=]+)=(.+)$/);
67
+ if (m) screens.push({ name: m[1], route: m[2], mockup: `${MOCKUPS}/${m[1].toLowerCase().replace(/[^a-z0-9]+/g, '-')}.html`, rows: [] });
68
+ }
69
+ if (!screens.length) { console.error('tf-verify-screens: no screens (--list <json> or --screen name=/route)'); process.exit(3); }
70
+
71
+ const slug = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
72
+ const anchorsOf = (mockup) => {
73
+ if (!mockup || !existsSync(mockup)) return null;
74
+ const html = readFileSync(mockup, 'utf8');
75
+ const re = new RegExp(`${ATTR}\\s*=\\s*["']([^"']+)["']`, 'g');
76
+ const out = new Set(); let m;
77
+ while ((m = re.exec(html))) out.add(m[1]);
78
+ return [...out];
79
+ };
80
+
81
+ // ---------------------------------------------------------------- browser
82
+ let browser, cdpPage = null;
83
+ if (CDP) {
84
+ try { browser = await chromium.connectOverCDP(CDP, { timeout: 15000 }); }
85
+ catch (e) { console.log(`UNREACHABLE cdp ${CDP}: ${e.message.split('\n')[0]}`); writeOut({ unreachable: true, error: e.message }); process.exit(2); }
86
+ const ctx = browser.contexts()[0];
87
+ cdpPage = ctx && ctx.pages()[0];
88
+ if (!cdpPage) { console.log(`UNREACHABLE cdp ${CDP}: no page in the attached app`); writeOut({ unreachable: true }); process.exit(2); }
89
+ } else {
90
+ browser = await chromium.launch();
91
+ }
92
+
93
+ function writeOut(extra, results = [], login = null) {
94
+ const summary = {
95
+ mode: CDP ? 'cdp' : 'base', base: BASE || CDP, widths: WIDTHS, login,
96
+ screens: results,
97
+ summary: {
98
+ screens: results.length,
99
+ render_ok: results.filter((r) => r.render === 'OK').length,
100
+ render_fail: results.filter((r) => r.render === 'EMPTY' || r.render === 'ERROR').length,
101
+ unreachable: results.filter((r) => r.render === 'UNREACHABLE').length,
102
+ visual_ok: results.filter((r) => r.visual === 'OK').length,
103
+ visual_fail: results.filter((r) => r.visual === 'FAIL').length,
104
+ },
105
+ ...extra,
106
+ };
107
+ mkdirSync(dirname(resolve(OUT)), { recursive: true });
108
+ writeFileSync(OUT, JSON.stringify(summary, null, 2));
109
+ return summary;
110
+ }
111
+
112
+ async function login(page) {
113
+ if (!LOGIN_PATH || !USER) return { attempted: false };
114
+ try {
115
+ await page.goto(BASE + LOGIN_PATH, { waitUntil: 'domcontentloaded', timeout: 30000 });
116
+ const user = page.locator(`[${ATTR}*="user" i], [${ATTR}*="email" i], input[type="email"], input[name*="user" i], input[name*="email" i], input[id*="user" i], input[id*="email" i], input[type="text"]`).first();
117
+ const pass = page.locator(`[${ATTR}*="pass" i], input[type="password"]`).first();
118
+ const btn = page.locator(`[${ATTR}*="login" i], [${ATTR}*="signin" i], [${ATTR}*="submit" i], button[type="submit"], form button, input[type="submit"]`).first();
119
+ await user.fill(USER, { timeout: 10000 });
120
+ await pass.fill(PASS || '', { timeout: 10000 });
121
+ await Promise.all([page.waitForLoadState('networkidle', { timeout: 15000 }).catch(() => {}), btn.click({ timeout: 10000 })]);
122
+ await page.waitForTimeout(SETTLE);
123
+ const url = page.url();
124
+ const still = url.startsWith(BASE + LOGIN_PATH);
125
+ return { attempted: true, ok: !still, url };
126
+ } catch (e) {
127
+ return { attempted: true, ok: false, error: e.message.split('\n')[0] };
128
+ }
129
+ }
130
+
131
+ async function contextFor(width) {
132
+ if (CDP) { await cdpPage.setViewportSize({ width, height: width < 600 ? 844 : 800 }).catch(() => {}); return { page: cdpPage, close: async () => {} }; }
133
+ const opts = { viewport: { width, height: width < 600 ? 844 : 800 } };
134
+ if (STORAGE && existsSync(STORAGE)) opts.storageState = STORAGE;
135
+ const ctx = await browser.newContext(opts);
136
+ if (COOKIE) {
137
+ const u = new URL(BASE);
138
+ await ctx.addCookies(COOKIE.split(';').map((kv) => { const [k, ...v] = kv.trim().split('='); return { name: k, value: v.join('='), domain: u.hostname, path: '/' }; }));
139
+ }
140
+ const page = await ctx.newPage();
141
+ return { page, close: () => ctx.close() };
142
+ }
143
+
144
+ async function navigate(page, route) {
145
+ if (CDP) {
146
+ try {
147
+ await page.evaluate((r) => { history.pushState({}, '', r); window.dispatchEvent(new PopStateEvent('popstate', { state: {} })); }, route);
148
+ await page.waitForTimeout(SETTLE);
149
+ return { status: 200, url: page.url() };
150
+ } catch (e) { return { status: 0, error: e.message.split('\n')[0] }; }
151
+ }
152
+ try {
153
+ const resp = await page.goto(BASE + route, { waitUntil: 'domcontentloaded', timeout: 30000 });
154
+ await page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {});
155
+ await page.waitForTimeout(SETTLE);
156
+ return { status: resp ? resp.status() : 0, url: page.url() };
157
+ } catch (e) { return { status: 0, error: e.message.split('\n')[0] }; }
158
+ }
159
+
160
+ // Wait for the screen's first render before measuring anything. Returns the milliseconds waited.
161
+ // It never decides anything: when nothing appears within RENDER_WAIT the page is graded exactly as
162
+ // it stands, so a control that is genuinely missing is still reported (TF-021).
163
+ async function waitForRender(page, anchors, attr) {
164
+ if (RENDER_WAIT <= 0) return 0;
165
+ const t0 = Date.now();
166
+ try {
167
+ // The condition names nothing about how the page is built: either a control the
168
+ // mockup anchors has appeared, or the document has text or a form control in it.
169
+ // An error banner satisfies the second, so a page that failed to start is not
170
+ // waited on to the timeout either.
171
+ await page.waitForFunction(({ anchors, attr }) => {
172
+ if (anchors && anchors.length) return anchors.some((id) => document.querySelector(`[${attr}="${CSS.escape(id)}"]`));
173
+ return (document.body && (document.body.innerText || '').trim().length > 0)
174
+ || document.querySelectorAll('input, select, textarea, img, svg, canvas, video').length > 0;
175
+ }, { anchors, attr }, { timeout: RENDER_WAIT, polling: 100 });
176
+ } catch (e) { /* nothing appeared in time: grade what is there */ }
177
+ return Date.now() - t0;
178
+ }
179
+
180
+ // what the page says about itself: anchors, tables, text, boxes
181
+ async function inspect(page, anchors, attr, errorSelectors) {
182
+ return page.evaluate(({ anchors, attr, errorSelectors }) => {
183
+ const vis = (el) => {
184
+ const cs = getComputedStyle(el);
185
+ if (cs.display === 'none' || cs.visibility === 'hidden' || cs.opacity === '0') return false;
186
+ if (el.getAttribute('aria-hidden') === 'true') return false;
187
+ const r = el.getBoundingClientRect();
188
+ if (r.width <= 1 && r.height <= 1 && cs.position === 'absolute') return false; // sr-only
189
+ return true;
190
+ };
191
+ // The rectangle an element actually PAINTS in: its own box intersected with the clip
192
+ // rectangle of every ancestor that clips (any overflow other than `visible`). A table row
193
+ // inside a horizontal scroller measures 1167px wide in a container 492px wide, so the
194
+ // unclipped box reaches x=1480 while the pixels stop at x=805 — and the card beside it, at
195
+ // x=846, was reported as being overlapped by something nothing draws (TF-021, TfLens
196
+ // 2026-09-09). Overlap and off-screen are judged on this rectangle; zero-size still uses the
197
+ // element's own box, since a control scrolled out of view is not a collapsed one.
198
+ const clipRect = (el) => {
199
+ const r = el.getBoundingClientRect();
200
+ let x1 = r.left, y1 = r.top, x2 = r.right, y2 = r.bottom, clipped = false;
201
+ for (let p = el.parentElement; p; p = p.parentElement) {
202
+ const cs = getComputedStyle(p);
203
+ const cx = cs.overflowX !== 'visible', cy = cs.overflowY !== 'visible';
204
+ if (!cx && !cy) continue;
205
+ const pr = p.getBoundingClientRect();
206
+ const nx1 = cx ? Math.max(x1, pr.left) : x1, nx2 = cx ? Math.min(x2, pr.right) : x2;
207
+ const ny1 = cy ? Math.max(y1, pr.top) : y1, ny2 = cy ? Math.min(y2, pr.bottom) : y2;
208
+ if (nx1 !== x1 || nx2 !== x2 || ny1 !== y1 || ny2 !== y2) clipped = true;
209
+ x1 = nx1; x2 = nx2; y1 = ny1; y2 = ny2;
210
+ }
211
+ return { x: x1, y: y1, w: Math.max(0, x2 - x1), h: Math.max(0, y2 - y1), clipped };
212
+ };
213
+ const sheetOk = (l) => { try { return !!l.sheet && l.sheet.cssRules.length > 0; } catch (e) { return !!l.sheet; } };
214
+ const styled = [...document.querySelectorAll('link[rel~="stylesheet"]')].some(sheetOk)
215
+ || [...document.querySelectorAll('style')].some(sheetOk);
216
+ const failedSheets = [...document.querySelectorAll('link[rel~="stylesheet"]')].filter((l) => !l.sheet).map((l) => l.getAttribute('href'));
217
+ const out = { anchors: [], tables: [], text_len: (document.body.innerText || '').trim().length, boxes: [],
218
+ controls: document.querySelectorAll('input, select, textarea, img, svg, canvas, video').length,
219
+ error_banner: (errorSelectors || []).find((sel) => {
220
+ let el = null;
221
+ try { el = document.querySelector(sel); } catch (e) { return false; } // a bad selector is not a finding
222
+ return !!el && vis(el);
223
+ }) || '',
224
+ overflow_x: document.documentElement.scrollWidth > document.documentElement.clientWidth + 2,
225
+ styled, failed_sheets: failedSheets, vw: window.innerWidth, vh: window.innerHeight };
226
+ for (const id of anchors || []) {
227
+ const el = document.querySelector(`[${attr}="${CSS.escape(id)}"]`);
228
+ if (!el) { out.anchors.push({ id, present: false }); continue; }
229
+ const tag = el.tagName.toLowerCase();
230
+ const r = el.getBoundingClientRect();
231
+ const text = (el.innerText || el.value || el.getAttribute('aria-label') || '').trim();
232
+ const filled = text.length > 0 || ['input', 'select', 'textarea', 'img', 'svg', 'canvas', 'video', 'iframe'].includes(tag) || el.querySelector('img,svg,canvas,input,select,textarea,button,a') !== null;
233
+ const c = clipRect(el);
234
+ out.anchors.push({ id, present: true, visible: vis(el), filled, tag, w: Math.round(r.width), h: Math.round(r.height), x: Math.round(r.left), y: Math.round(r.top),
235
+ cx: Math.round(c.x), cy: Math.round(c.y), cw: Math.round(c.w), ch: Math.round(c.h), clipped: c.clipped });
236
+ }
237
+ for (const t of document.querySelectorAll('table')) {
238
+ if (!vis(t)) continue;
239
+ const rows = t.querySelectorAll('tbody tr').length;
240
+ const head = t.querySelector('thead') !== null;
241
+ const cells = [...t.querySelectorAll('tbody td')].filter((c) => (c.innerText || '').trim().length > 0).length;
242
+ out.tables.push({ id: t.getAttribute(attr) || t.id || '', rows, head, filled_cells: cells });
243
+ }
244
+ const sel = `[${attr}], a, button, input, select, textarea, [role="button"], h1, h2`;
245
+ const els = [...document.querySelectorAll(sel)].filter(vis);
246
+ els.forEach((el, i) => {
247
+ const r = el.getBoundingClientRect();
248
+ const c = clipRect(el); // what is painted, not what is laid out
249
+ out.boxes.push({ i, id: el.getAttribute(attr) || '', tag: el.tagName.toLowerCase(), text: (el.innerText || el.value || '').trim().slice(0, 40),
250
+ x: c.x, y: c.y, w: c.w, h: c.h, raw_w: r.width, raw_h: r.height, clipped: c.clipped, anchored: el.hasAttribute(attr) });
251
+ });
252
+ // ancestry so a button inside its card is never an "overlap"
253
+ out.rel = [];
254
+ for (let a = 0; a < els.length; a++) for (let b = a + 1; b < els.length; b++) {
255
+ if (els[a].contains(els[b]) || els[b].contains(els[a])) out.rel.push([a, b]);
256
+ }
257
+ return out;
258
+ }, { anchors, attr, errorSelectors });
259
+ }
260
+
261
+ function grade(info, consoleErrors, width) {
262
+ const findings = [];
263
+ if (info.error_banner) findings.push({ check: 'render', class: 'exception', detail: `the application's error banner is showing (${info.error_banner})` });
264
+ // a failed fetch ("Failed to load resource … 404") is the assets check's finding, not a page error
265
+ const pageErrors = consoleErrors.filter((e) => !/^Failed to load resource/i.test(e));
266
+ for (const e of pageErrors.slice(0, 3)) findings.push({ check: 'render', class: 'exception', detail: `console error: ${e.slice(0, 120)}` });
267
+ if (info.text_len < 15 && info.controls === 0 && !info.anchors.some((a) => a.present)) findings.push({ check: 'render', class: 'blank-data', detail: `the page is blank (${info.text_len} characters of text, no control)` });
268
+ for (const a of info.anchors) {
269
+ if (!a.present) findings.push({ check: 'render', class: 'blank-data', detail: `anchored control "${a.id}" is not on the page` });
270
+ else if (a.visible && !a.filled) findings.push({ check: 'render', class: 'blank-data', detail: `anchored control "${a.id}" (${a.tag}) is empty` });
271
+ }
272
+ for (const t of info.tables) {
273
+ if (t.head && t.rows === 0) findings.push({ check: 'render', class: 'zero-rows', detail: `table ${t.id || '(unnamed)'} has a header and no rows` });
274
+ else if (t.rows > 0 && t.filled_cells === 0) findings.push({ check: 'render', class: 'blank-data', detail: `table ${t.id || '(unnamed)'} has ${t.rows} rows and every cell is blank` });
275
+ }
276
+ if (!info.styled) findings.push({ check: 'visual', class: 'other', detail: 'no stylesheet is loaded: the page is unstyled' + (info.failed_sheets.length ? ` (${info.failed_sheets.join(', ')} did not load)` : '') });
277
+ if (info.overflow_x) findings.push({ check: 'visual', class: 'offscreen', detail: `the page scrolls sideways at ${width}px` });
278
+ for (const a of info.anchors) {
279
+ if (a.present && a.visible && (a.w === 0 || a.h === 0)) findings.push({ check: 'visual', class: 'clipped', detail: `anchored control "${a.id}" has zero ${a.w === 0 ? 'width' : 'height'}` });
280
+ // judged on the painted rectangle: a control inside a scroller is reachable by scrolling
281
+ // that container, which is a layout decision, not a control that has fallen off the page
282
+ const ax = a.cx === undefined ? a.x : a.cx, aw = a.cw === undefined ? a.w : a.cw;
283
+ if (a.present && a.visible && a.w > 0 && aw > 0 && (ax + aw > info.vw + 2 || ax < -2)) findings.push({ check: 'visual', class: 'offscreen', detail: `anchored control "${a.id}" sits outside the viewport at ${width}px` });
284
+ }
285
+ const rel = new Set(info.rel.map(([a, b]) => `${a}-${b}`));
286
+ const boxes = info.boxes.filter((b) => b.w > 0 && b.h > 0);
287
+ let overlaps = 0;
288
+ for (let i = 0; i < boxes.length && overlaps < 5; i++) for (let j = i + 1; j < boxes.length && overlaps < 5; j++) {
289
+ const a = boxes[i], b = boxes[j];
290
+ if (rel.has(`${a.i}-${b.i}`)) continue;
291
+ const ix = Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x);
292
+ const iy = Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y);
293
+ if (ix <= 4 || iy <= 4) continue;
294
+ const inter = ix * iy, small = Math.min(a.w * a.h, b.w * b.h);
295
+ if (inter / small > 0.9) continue; // one lies inside the other by design (a badge on a card)
296
+ if (inter / small < 0.25) continue; // a touch, not an overlap
297
+ overlaps++;
298
+ const nm = (e) => e.id || `${e.tag} "${e.text || ''}"`;
299
+ findings.push({ check: 'visual', class: 'overlap', detail: `${nm(a)} overlaps ${nm(b)} at ${width}px` });
300
+ }
301
+ const render = findings.some((f) => f.check === 'render' && f.class === 'exception') ? 'ERROR'
302
+ : findings.some((f) => f.check === 'render') ? 'EMPTY' : 'OK';
303
+ const visual = findings.some((f) => f.check === 'visual') ? 'FAIL' : 'OK';
304
+ return { render, visual, findings };
305
+ }
306
+
307
+ // ---------------------------------------------------------------- run
308
+ mkdirSync(SHOTS, { recursive: true });
309
+ const results = [];
310
+ let loginResult = null;
311
+ let firstUnreachable = false;
312
+ for (const width of WIDTHS) {
313
+ const { page, close } = await contextFor(width);
314
+ const consoleErrors = [];
315
+ page.on('console', (m) => { if (m.type() === 'error') consoleErrors.push(m.text()); });
316
+ page.on('pageerror', (e) => consoleErrors.push(String(e.message || e)));
317
+ if (!CDP) { const l = await login(page); if (loginResult === null) loginResult = l; }
318
+ for (const s of screens) {
319
+ let r = results.find((x) => x.name === s.name);
320
+ if (!r) { r = { name: s.name, route: s.route, mockup: s.mockup, rows: s.rows, anchors: anchorsOf(s.mockup), widths: [] }; results.push(r); }
321
+ consoleErrors.length = 0;
322
+ const nav = await navigate(page, s.route);
323
+ const shot = `${SHOTS}/${slug(s.name)}-${width}.png`;
324
+ const entry = { width, url: nav.url || '', status: nav.status, screenshot: shot, console_errors: [] };
325
+ const redirectedToLogin = LOGIN_PATH && nav.url && new URL(nav.url).pathname.startsWith(LOGIN_PATH) && !s.route.startsWith(LOGIN_PATH);
326
+ if (nav.status === 0 || nav.status >= 400 || redirectedToLogin) {
327
+ entry.render = 'UNREACHABLE'; entry.visual = 'n/a';
328
+ entry.findings = [{ check: 'render', class: 'other', detail: nav.status === 0 ? `could not open ${s.route}: ${nav.error || 'no response'}` : redirectedToLogin ? `${s.route} redirected to the sign-in page (not signed in)` : `${s.route} answered HTTP ${nav.status}` }];
329
+ if (nav.status === 0 && results.length === 1 && width === WIDTHS[0]) firstUnreachable = true;
330
+ } else {
331
+ entry.render_wait_ms = await waitForRender(page, r.anchors || [], ATTR);
332
+ const info = await inspect(page, r.anchors || [], ATTR, ERROR_SELECTORS);
333
+ const g = grade(info, consoleErrors, width);
334
+ Object.assign(entry, g);
335
+ entry.console_errors = consoleErrors.slice(0, 5);
336
+ entry.anchors_present = (info.anchors || []).filter((a) => a.present).length;
337
+ }
338
+ try { await page.screenshot({ path: shot, fullPage: !CDP }); } catch (e) { entry.screenshot = ''; }
339
+ r.widths.push(entry);
340
+ }
341
+ await close();
342
+ }
343
+ for (const r of results) {
344
+ const rs = r.widths.map((w) => w.render);
345
+ r.render = rs.includes('UNREACHABLE') ? 'UNREACHABLE' : rs.includes('ERROR') ? 'ERROR' : rs.includes('EMPTY') ? 'EMPTY' : 'OK';
346
+ r.visual = r.widths.some((w) => w.visual === 'FAIL') ? 'FAIL' : r.render === 'UNREACHABLE' ? 'n/a' : 'OK';
347
+ r.anchors_n = (r.anchors || []).length;
348
+ const notes = r.widths.flatMap((w) => (w.findings || []).map((f) => `${f.detail}`)).slice(0, 4);
349
+ console.log(`${r.render === 'OK' && r.visual === 'OK' ? 'OK ' : 'FAIL'} ${r.name} (${r.route}) — render ${r.render}, visual ${r.visual}, ${r.anchors_n} anchors${r.mockup && !existsSync(r.mockup) ? ' (no mockup file)' : ''}${notes.length ? ' — ' + notes.join('; ') : ''}`);
350
+ }
351
+ const out = writeOut({}, results, loginResult);
352
+ if (loginResult && loginResult.attempted && !loginResult.ok) console.log(`LOGIN failed at ${BASE}${LOGIN_PATH}: ${loginResult.error || 'still on the sign-in page'}`);
353
+ console.log(`screens ${out.summary.screens}: render ${out.summary.render_ok} OK / ${out.summary.render_fail} failed / ${out.summary.unreachable} unreachable; visual ${out.summary.visual_ok} OK / ${out.summary.visual_fail} failed; screenshots ${SHOTS}; JSON ${OUT}`);
354
+ if (!CDP) await browser.close(); else await browser.close().catch(() => {});
355
+ process.exit(firstUnreachable && out.summary.unreachable === out.summary.screens ? 2 : (out.summary.render_fail + out.summary.visual_fail + out.summary.unreachable) ? 5 : 0);