@techierathore/techieflow 1.0.3 → 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.
- package/.claude/commands/TechieFlow/agents/analyst.md +5 -8
- package/.claude/commands/TechieFlow/agents/architect.md +1 -10
- package/.claude/commands/TechieFlow/agents/flow-master.md +1 -0
- package/.claude/commands/TechieFlow/agents/verifier.md +1 -0
- package/.claude/commands/TechieFlow/tasks/_owner-language.md +26 -0
- package/.claude/commands/TechieFlow/tasks/_smoke-test-policy.md +1 -1
- package/.claude/commands/TechieFlow/tasks/amend-docs.md +6 -5
- package/.claude/commands/TechieFlow/tasks/build-phase.md +1 -1
- package/.claude/commands/TechieFlow/tasks/create-deep-research-prompt.md +4 -0
- package/.claude/commands/TechieFlow/tasks/create-doc.md +4 -0
- package/.claude/commands/TechieFlow/tasks/create-project-brief.md +22 -0
- package/.claude/commands/TechieFlow/tasks/day1-brownfield.md +2 -2
- package/.claude/commands/TechieFlow/tasks/day1-greenfield.md +2 -2
- package/.claude/commands/TechieFlow/tasks/facilitate-brainstorming-session.md +4 -0
- package/.claude/commands/TechieFlow/tasks/generate-html.md +5 -1
- package/.claude/commands/TechieFlow/tasks/handoff-phase.md +18 -16
- package/.claude/commands/TechieFlow/tasks/metrics-report.md +2 -0
- package/.claude/commands/TechieFlow/tasks/verify-phase.md +1 -1
- package/.opencode/plugin/techieflow.js +2 -2
- package/.tfcore/agents/analyst.md +5 -8
- package/.tfcore/agents/architect.md +1 -10
- package/.tfcore/agents/flow-master.md +1 -0
- package/.tfcore/agents/verifier.md +1 -0
- package/.tfcore/hooks/guard-metrics.sh +13 -1
- package/.tfcore/hooks/guard-verify-deps.sh +124 -0
- package/.tfcore/routing.yaml +61 -0
- package/.tfcore/tasks/_owner-language.md +26 -0
- package/.tfcore/tasks/_smoke-test-policy.md +1 -1
- package/.tfcore/tasks/amend-docs.md +6 -5
- package/.tfcore/tasks/build-phase.md +1 -1
- package/.tfcore/tasks/create-deep-research-prompt.md +4 -0
- package/.tfcore/tasks/create-doc.md +4 -0
- package/.tfcore/tasks/create-project-brief.md +22 -0
- package/.tfcore/tasks/day1-brownfield.md +2 -2
- package/.tfcore/tasks/day1-greenfield.md +2 -2
- package/.tfcore/tasks/facilitate-brainstorming-session.md +4 -0
- package/.tfcore/tasks/generate-html.md +5 -1
- package/.tfcore/tasks/handoff-phase.md +18 -16
- package/.tfcore/tasks/metrics-report.md +2 -0
- package/.tfcore/tasks/verify-phase.md +1 -1
- package/.tfcore/telemetry/SCHEMA.md +57 -1
- package/.tfcore/telemetry/tf-metrics.sh +416 -19
- package/.tfcore/templates/v4custom/app-brief-tmpl.md +65 -0
- package/.tfcore/templates/v4custom/app-decision-request-tmpl.md +85 -0
- package/.tfcore/templates/v4custom/app-library-feedback-tmpl.md +86 -31
- package/.tfcore/templates/v4custom/html-render-shell.md +1 -1
- package/.tfcore/utils/__pycache__/tf-brd-status.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-checklist-edit.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-day1-files.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-devguide-list.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-doc-check.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-log-miss.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-misses-md.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-mockups-locate.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-opencode-audit.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-perf-grade.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-render-html.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-split-brd.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-status-facts.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-triage.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-verify-list.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-verify-verdict.cpython-312.pyc +0 -0
- package/.tfcore/utils/tf-build-list.py +23 -3
- package/.tfcore/utils/tf-build-list.sh +3 -2
- package/.tfcore/utils/tf-doc-check.py +106 -4
- package/.tfcore/utils/tf-emit.sh +353 -16
- package/.tfcore/utils/tf-fix-close.sh +34 -2
- package/.tfcore/utils/tf-gitignore-audit.sh +50 -3
- package/.tfcore/utils/tf-goal.sh +93 -6
- package/.tfcore/utils/tf-misses-md.py +14 -17
- package/.tfcore/utils/tf-misses-md.sh +5 -4
- package/.tfcore/utils/tf-model-pick.sh +475 -0
- package/.tfcore/utils/tf-phase.sh +18 -0
- package/.tfcore/utils/tf-render-html.py +20 -0
- package/.tfcore/utils/tf-routing-bind.sh +87 -1
- package/.tfcore/utils/tf-routing.sh +7 -0
- package/.tfcore/utils/tf-selfcheck.sh +323 -0
- package/.tfcore/utils/tf-split-brd.py +6 -1
- package/.tfcore/utils/tf-status-facts.py +61 -12
- package/.tfcore/utils/tf-verify-screens.mjs +85 -13
- package/.tfcore/utils/tf-verify-screens.sh +12 -3
- package/.tfcore/utils/tf-verify-tests.sh +60 -17
- package/.tfcore/utils/tf-verify-verdict.py +11 -2
- package/README.md +15 -5
- package/opencode.jsonc +24 -0
- package/package.json +1 -1
- package/scripts/install.mjs +4 -0
- package/.tfcore/templates/architecture-tmpl.yaml +0 -650
- package/.tfcore/templates/brownfield-architecture-tmpl.yaml +0 -476
- package/.tfcore/templates/competitor-analysis-tmpl.yaml +0 -306
- package/.tfcore/templates/front-end-architecture-tmpl.yaml +0 -218
- package/.tfcore/templates/fullstack-architecture-tmpl.yaml +0 -823
- package/.tfcore/templates/market-research-tmpl.yaml +0 -252
- package/.tfcore/templates/project-brief-tmpl.yaml +0 -221
- package/.tfcore/utils/techieflow-doc-template.md +0 -325
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# TechieFlow — the framework's own scripts, run against THIS project's real files.
|
|
3
|
+
#
|
|
4
|
+
# bash .tfcore/utils/tf-selfcheck.sh [<repo>] [--report]
|
|
5
|
+
#
|
|
6
|
+
# WHY IT EXISTS. Nine framework defects reached TfLens in three weeks. Every one was
|
|
7
|
+
# found mid-build, in the application's repository, and could only be fixed in the
|
|
8
|
+
# framework's — so each cost a switch between repos, a feedback entry, a maintenance
|
|
9
|
+
# sitting and a re-deploy. The defects were not exotic: a checklist with a row that
|
|
10
|
+
# could never clear, a BRD carrying the anchors its own link checker demands, a metrics
|
|
11
|
+
# file holding the framework's own verdicts. They were simply never run against a real
|
|
12
|
+
# project's real files before the project ran them (MISS-TechieFlow-20260909-01).
|
|
13
|
+
#
|
|
14
|
+
# So this runs them here, first, and says which are broken ON THIS PROJECT. A FAIL is a
|
|
15
|
+
# FRAMEWORK defect, never the project's: nothing here grades the application.
|
|
16
|
+
#
|
|
17
|
+
# Run it before a build pass, or any time. It writes nothing, boots nothing, and never
|
|
18
|
+
# runs git. Python 3 standard library only.
|
|
19
|
+
#
|
|
20
|
+
# --report prints a ready-to-paste entry for the project's framework-feedback file, so
|
|
21
|
+
# filing costs one paste instead of a page.
|
|
22
|
+
#
|
|
23
|
+
# EXIT: 0 every framework script works here · 1 at least one is broken · 3 cannot run.
|
|
24
|
+
|
|
25
|
+
set -uo pipefail
|
|
26
|
+
REPO="."; REPORT=0
|
|
27
|
+
while [[ $# -gt 0 ]]; do
|
|
28
|
+
case "$1" in
|
|
29
|
+
--report) REPORT=1; shift ;;
|
|
30
|
+
-h|--help) sed -n '2,23p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
|
31
|
+
*) REPO="$1"; shift ;;
|
|
32
|
+
esac
|
|
33
|
+
done
|
|
34
|
+
[[ -d "$REPO" ]] || { echo "tf-selfcheck: not a directory: $REPO" >&2; exit 3; }
|
|
35
|
+
command -v python3 >/dev/null 2>&1 || { echo "tf-selfcheck: python3 required" >&2; exit 3; }
|
|
36
|
+
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
37
|
+
|
|
38
|
+
TF_REPO="$REPO" TF_UTILS="$HERE" TF_REPORT="$REPORT" python3 - <<'PY'
|
|
39
|
+
import json, os, re, subprocess, sys
|
|
40
|
+
|
|
41
|
+
REPO = os.path.abspath(os.environ["TF_REPO"])
|
|
42
|
+
UTILS = os.environ["TF_UTILS"]
|
|
43
|
+
TELEM = os.path.join(os.path.dirname(UTILS), "telemetry")
|
|
44
|
+
REPORT = os.environ["TF_REPORT"] == "1"
|
|
45
|
+
findings = [] # (script, what is wrong, the one-line fix hint)
|
|
46
|
+
lines = []
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def ok(script, msg):
|
|
50
|
+
lines.append("ok %-20s %s" % (script, msg))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def broken(script, msg, hint=""):
|
|
54
|
+
lines.append("FAIL %-20s %s" % (script, msg))
|
|
55
|
+
findings.append((script, msg, hint))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def skip(script, msg):
|
|
59
|
+
lines.append("-- %-20s %s" % (script, msg))
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def run(cmd, cwd=REPO, timeout=120):
|
|
63
|
+
try:
|
|
64
|
+
p = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, timeout=timeout)
|
|
65
|
+
return p.returncode, (p.stdout or "") + (p.stderr or "")
|
|
66
|
+
except Exception as e:
|
|
67
|
+
return 99, str(e)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def app_names():
|
|
71
|
+
"""Every application this repo holds a checklist for. The checklist is the framework's
|
|
72
|
+
single source of truth, so its name is the app's name — no config value is trusted,
|
|
73
|
+
because core-config.yaml is rsynced between projects and is the thing most likely to
|
|
74
|
+
be stale (the same reason tf-gitignore-audit detects the stack from the tree)."""
|
|
75
|
+
d = os.path.join(REPO, "docs")
|
|
76
|
+
out = []
|
|
77
|
+
if os.path.isdir(d):
|
|
78
|
+
for f in sorted(os.listdir(d)):
|
|
79
|
+
m = re.match(r"^(.+?)(?:-P(\d+))?-Checklist\.md$", f)
|
|
80
|
+
if m and "Deployment" not in f:
|
|
81
|
+
out.append((m.group(1), int(m.group(2) or 1), os.path.join(d, f)))
|
|
82
|
+
return out
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# ---- 1. the deployed copy is intact ----------------------------------------------------
|
|
86
|
+
# A framework script that will not even parse is the cheapest possible thing to catch and
|
|
87
|
+
# the most expensive to meet mid-build.
|
|
88
|
+
# Parsed in-process and the shells checked in ONE bash, not one subprocess per file:
|
|
89
|
+
# 54 scripts meant 54 process launches and ten seconds, which is enough to make a check
|
|
90
|
+
# that should run at the top of every command feel like a tax and get skipped.
|
|
91
|
+
bad_parse, shells = [], []
|
|
92
|
+
for folder in (UTILS, TELEM):
|
|
93
|
+
if not os.path.isdir(folder):
|
|
94
|
+
continue
|
|
95
|
+
for f in sorted(os.listdir(folder)):
|
|
96
|
+
full = os.path.join(folder, f)
|
|
97
|
+
if f.endswith(".py"):
|
|
98
|
+
try:
|
|
99
|
+
import ast
|
|
100
|
+
ast.parse(open(full, encoding="utf-8", errors="replace").read(), filename=full)
|
|
101
|
+
except SyntaxError:
|
|
102
|
+
bad_parse.append(f)
|
|
103
|
+
elif f.endswith(".sh"):
|
|
104
|
+
shells.append(full)
|
|
105
|
+
if shells:
|
|
106
|
+
script = "for f in " + " ".join('"%s"' % x for x in shells) + \
|
|
107
|
+
'; do bash -n "$f" 2>/dev/null || echo "$f"; done'
|
|
108
|
+
rc, out = run(["bash", "-c", script])
|
|
109
|
+
bad_parse += [os.path.basename(x) for x in out.split() if x.strip()]
|
|
110
|
+
if bad_parse:
|
|
111
|
+
broken("deployed copy", "%d script(s) do not parse: %s" % (len(bad_parse), ", ".join(bad_parse[:6])),
|
|
112
|
+
"re-run update-framework.sh; if it persists the framework shipped a broken script")
|
|
113
|
+
else:
|
|
114
|
+
ok("deployed copy", "every framework script parses")
|
|
115
|
+
|
|
116
|
+
# ---- 2. the working list accounts for every checklist row ------------------------------
|
|
117
|
+
# TF-019: a row can sit at `Needs re-verify` forever, which pinned the mode and dropped
|
|
118
|
+
# every not-started row out of the list AND out of the counts, so a build pass could report
|
|
119
|
+
# a phase finished with rows at 0%. The arithmetic is the check: every row lands in exactly
|
|
120
|
+
# one of open / terminal / Blocked.
|
|
121
|
+
apps = app_names()
|
|
122
|
+
if not apps:
|
|
123
|
+
skip("tf-build-list", "no checklist in docs/ yet")
|
|
124
|
+
for app, phase, path in apps:
|
|
125
|
+
rc, out = run(["bash", os.path.join(UTILS, "tf-build-list.sh"), app] +
|
|
126
|
+
([] if phase <= 1 else ["--phase", str(phase)]))
|
|
127
|
+
label = "%s p%d" % (app, phase)
|
|
128
|
+
if rc not in (0, 2):
|
|
129
|
+
broken("tf-build-list", "%s: exited %d" % (label, rc), out.strip().splitlines()[0][:120] if out.strip() else "")
|
|
130
|
+
continue
|
|
131
|
+
m = re.search(r"Mode: (\w+) — (\d+) row\(s\) to build; (\d+) terminal, (\d+) Blocked, (\d+) total", out)
|
|
132
|
+
if not m:
|
|
133
|
+
if "has no REQ rows" in out:
|
|
134
|
+
skip("tf-build-list", "%s: checklist has no requirement rows yet" % label)
|
|
135
|
+
else:
|
|
136
|
+
broken("tf-build-list", "%s: no counts line to check" % label,
|
|
137
|
+
"the output shape changed; the arithmetic can no longer be verified")
|
|
138
|
+
continue
|
|
139
|
+
mode, work, term, blocked, total = m.group(1), *(int(x) for x in m.groups()[1:])
|
|
140
|
+
if work + term + blocked != total:
|
|
141
|
+
broken("tf-build-list",
|
|
142
|
+
"%s: %d rows are in no category (%d open + %d terminal + %d blocked ≠ %d total)"
|
|
143
|
+
% (label, total - work - term - blocked, work, term, blocked, total),
|
|
144
|
+
"a build pass trusting this list would leave those rows at 0%")
|
|
145
|
+
else:
|
|
146
|
+
ok("tf-build-list", "%s: %d rows, all accounted for (%s)" % (label, total, mode))
|
|
147
|
+
|
|
148
|
+
# ---- 3. the BRD this project actually wrote can be read --------------------------------
|
|
149
|
+
# TF-017: the ledger regex required **BRD-N** immediately after the bullet, and a real BRD
|
|
150
|
+
# carries an <a id> anchor there because §9 cross-links need one. It matched 0 of 179 items.
|
|
151
|
+
sys.path.insert(0, UTILS)
|
|
152
|
+
for app, phase, _cl in apps:
|
|
153
|
+
brd = os.path.join(REPO, "docs", "%s-BRD.md" % app if phase <= 1 else "%s-P%d-BRD.md" % (app, phase))
|
|
154
|
+
if not os.path.isfile(brd):
|
|
155
|
+
continue
|
|
156
|
+
text = open(brd, encoding="utf-8", errors="replace").read()
|
|
157
|
+
# A struck-through item (~~**BRD-3**~~ *(removed …)*) is deliberately not a live
|
|
158
|
+
# requirement, and the ledger is right to skip it. Counting it as unread would make
|
|
159
|
+
# this check cry wolf on every mature BRD -- and a check that cries wolf is the one
|
|
160
|
+
# that gets skimmed (TF-012 failed 8 of 10 screens identically and trained exactly that).
|
|
161
|
+
# UNIQUE ids, not occurrences: an id named again in a cross-reference is the same
|
|
162
|
+
# requirement, and counting mentions made every BRD look short by however many times
|
|
163
|
+
# it referred to itself.
|
|
164
|
+
written_ids = {m.group(1) for m in re.finditer(r"\*\*(BRD-\d+)\*\*", text)
|
|
165
|
+
if not re.search(r"~~\s*$", text[max(0, m.start() - 3):m.start()])}
|
|
166
|
+
written = len(written_ids)
|
|
167
|
+
try:
|
|
168
|
+
import importlib.util
|
|
169
|
+
spec = importlib.util.spec_from_file_location("_sb", os.path.join(UTILS, "tf-split-brd.py"))
|
|
170
|
+
mod = importlib.util.module_from_spec(spec)
|
|
171
|
+
spec.loader.exec_module(mod)
|
|
172
|
+
read = len({(i["id"] if isinstance(i, dict) else i[0]) for i in mod.ledger(text)})
|
|
173
|
+
except Exception as e:
|
|
174
|
+
broken("tf-split-brd", "%s: could not read the BRD at all (%s)" % (app, type(e).__name__),
|
|
175
|
+
"*amend-docs --add-missing cannot append checklist rows")
|
|
176
|
+
continue
|
|
177
|
+
if written and read < written:
|
|
178
|
+
broken("tf-split-brd", "%s: reads %d of %d BRD items" % (app, read, written),
|
|
179
|
+
"new requirements must be added to the checklist by hand until this is fixed")
|
|
180
|
+
elif written:
|
|
181
|
+
ok("tf-split-brd", "%s: reads all %d BRD items" % (app, written))
|
|
182
|
+
|
|
183
|
+
# ---- 4. the streams this project's figures are built on --------------------------------
|
|
184
|
+
# TF-015: a run whose `ended` precedes its `started` was stored with a negative or invented
|
|
185
|
+
# duration, and the two readers of the stream then disagreed about the same record.
|
|
186
|
+
runs_p = os.path.join(REPO, "docs", "metrics", "runs.jsonl")
|
|
187
|
+
if not os.path.isfile(runs_p):
|
|
188
|
+
skip("runs.jsonl", "no telemetry in this project yet")
|
|
189
|
+
else:
|
|
190
|
+
neg = impossible = total_r = badline = 0
|
|
191
|
+
for l in open(runs_p, encoding="utf-8", errors="replace"):
|
|
192
|
+
l = l.strip()
|
|
193
|
+
if not l:
|
|
194
|
+
continue
|
|
195
|
+
try:
|
|
196
|
+
r = json.loads(l)
|
|
197
|
+
except Exception:
|
|
198
|
+
badline += 1
|
|
199
|
+
continue
|
|
200
|
+
total_r += 1
|
|
201
|
+
d = r.get("duration_s")
|
|
202
|
+
if isinstance(d, (int, float)) and d < 0:
|
|
203
|
+
neg += 1
|
|
204
|
+
s, e = r.get("started"), r.get("ended")
|
|
205
|
+
if isinstance(s, str) and isinstance(e, str) and e < s:
|
|
206
|
+
impossible += 1
|
|
207
|
+
if badline:
|
|
208
|
+
broken("runs.jsonl", "%d line(s) are not valid JSON" % badline, "those records are invisible to every figure")
|
|
209
|
+
if impossible or neg:
|
|
210
|
+
# NOT a failure. The emitter refuses these now and the reader discards them and
|
|
211
|
+
# prints how many -- so nothing is wrong with the framework here, and nothing can
|
|
212
|
+
# be done about the records either: the stream is append-only and the true start
|
|
213
|
+
# times are gone. Reported once as history, because calling it broken on every
|
|
214
|
+
# command for the life of the project is how a check earns being skimmed.
|
|
215
|
+
skip("runs.jsonl", "%d old record(s) end before they start — discarded from every "
|
|
216
|
+
"duration figure, and the report says so" % impossible)
|
|
217
|
+
elif total_r:
|
|
218
|
+
ok("runs.jsonl", "%d run record(s), none impossible" % total_r)
|
|
219
|
+
|
|
220
|
+
# ---- 5. the framework's own verdicts are not counted as this project's -----------------
|
|
221
|
+
# TF-020: an FR verdict grades a framework requirement, not a screen, and pooling it into
|
|
222
|
+
# an application segment raises that segment's first-pass rate with nothing on the output
|
|
223
|
+
# to show it happened. SCHEMA §3 states the rule; the rollup has to apply it.
|
|
224
|
+
gates_p = os.path.join(REPO, "docs", "metrics", "gates.jsonl")
|
|
225
|
+
if not os.path.isfile(gates_p):
|
|
226
|
+
skip("tf-metrics", "no gate records in this project yet")
|
|
227
|
+
else:
|
|
228
|
+
has_fr = False
|
|
229
|
+
for l in open(gates_p, encoding="utf-8", errors="replace"):
|
|
230
|
+
if '"req_class"' in l and '"FR"' in l:
|
|
231
|
+
has_fr = True
|
|
232
|
+
break
|
|
233
|
+
rc, out = run(["bash", os.path.join(TELEM, "tf-metrics.sh"), "--rollup", REPO, "--json"])
|
|
234
|
+
try:
|
|
235
|
+
data = json.loads(out)
|
|
236
|
+
except Exception:
|
|
237
|
+
broken("tf-metrics", "--rollup did not return readable JSON (exit %d)" % rc,
|
|
238
|
+
"no figure this project publishes can be checked against the reference")
|
|
239
|
+
data = None
|
|
240
|
+
if data is not None:
|
|
241
|
+
segs = data.get("live") or {}
|
|
242
|
+
if has_fr and "framework-requirement" not in segs:
|
|
243
|
+
broken("tf-metrics", "framework verdicts are pooled into %s" % ", ".join(sorted(segs) or ["nothing"]),
|
|
244
|
+
"this project's first-pass rate is overstated")
|
|
245
|
+
else:
|
|
246
|
+
ok("tf-metrics", "rollup reads; %d segment(s), framework verdicts separate" % len(segs))
|
|
247
|
+
|
|
248
|
+
# ---- 6. amendments to the miss log still fold -------------------------------------------
|
|
249
|
+
# TF-006 and TF-018: a field added after a record was written can only be completed by an
|
|
250
|
+
# amendment, and an amendment the reader will not fold is a fact silently lost.
|
|
251
|
+
miss_p = os.path.join(REPO, "docs", "metrics", "misses.jsonl")
|
|
252
|
+
if not os.path.isfile(miss_p):
|
|
253
|
+
skip("misses.jsonl", "no miss records in this project yet")
|
|
254
|
+
else:
|
|
255
|
+
ids, amends, orphan = set(), 0, 0
|
|
256
|
+
for l in open(miss_p, encoding="utf-8", errors="replace"):
|
|
257
|
+
l = l.strip()
|
|
258
|
+
if not l:
|
|
259
|
+
continue
|
|
260
|
+
try:
|
|
261
|
+
r = json.loads(l)
|
|
262
|
+
except Exception:
|
|
263
|
+
continue
|
|
264
|
+
if r.get("kind") == "miss":
|
|
265
|
+
ids.add(r.get("miss_id"))
|
|
266
|
+
elif r.get("kind") == "miss-amend":
|
|
267
|
+
amends += 1
|
|
268
|
+
if r.get("miss_id") not in ids:
|
|
269
|
+
orphan += 1
|
|
270
|
+
if orphan:
|
|
271
|
+
broken("misses.jsonl", "%d amendment(s) name a miss that is not on this stream" % orphan,
|
|
272
|
+
"the information in them is not reaching any figure")
|
|
273
|
+
else:
|
|
274
|
+
ok("misses.jsonl", "%d miss(es), %d amendment(s), none orphaned" % (len(ids), amends))
|
|
275
|
+
|
|
276
|
+
# ---- 7. the checks that read this project's documents run at all -----------------------
|
|
277
|
+
for name, cmd in (("tf-doc-check", ["bash", os.path.join(UTILS, "tf-doc-check.sh")] +
|
|
278
|
+
(["--app", apps[0][0]] if apps else []) + ["--warn"]),
|
|
279
|
+
("tf-gitignore-audit", ["bash", os.path.join(UTILS, "tf-gitignore-audit.sh"), REPO, "--dry-run"])):
|
|
280
|
+
if name == "tf-doc-check" and not apps:
|
|
281
|
+
skip(name, "no application documents yet")
|
|
282
|
+
continue
|
|
283
|
+
rc, out = run(cmd)
|
|
284
|
+
if rc in (99,) or "Traceback" in out:
|
|
285
|
+
broken(name, "crashed on this project's files",
|
|
286
|
+
(out.strip().splitlines() or [""])[-1][:120])
|
|
287
|
+
else:
|
|
288
|
+
first = next((l for l in out.splitlines() if l.strip()), "")
|
|
289
|
+
ok(name, "runs (exit %d)" % rc)
|
|
290
|
+
|
|
291
|
+
# ---- output -----------------------------------------------------------------------------
|
|
292
|
+
proj = os.path.basename(REPO.rstrip(os.sep))
|
|
293
|
+
print("# tf-selfcheck — %s — the framework's own scripts against this project's real files" % proj)
|
|
294
|
+
for l in lines:
|
|
295
|
+
print(l)
|
|
296
|
+
print()
|
|
297
|
+
if not findings:
|
|
298
|
+
print("Every framework script works on this project. Nothing to file.")
|
|
299
|
+
raise SystemExit(0)
|
|
300
|
+
|
|
301
|
+
print("%d framework defect(s) found. These are the FRAMEWORK's, not this project's —" % len(findings))
|
|
302
|
+
print("nothing here graded the application. Do not work around them silently: file them,")
|
|
303
|
+
print("and carry on (a framework defect blocks a row only when it stops the work).")
|
|
304
|
+
if REPORT:
|
|
305
|
+
print()
|
|
306
|
+
print("--- paste into docs/%s-TechieFlow-Feedback.md ---" % proj)
|
|
307
|
+
for i, (script, msg, hint) in enumerate(findings, 1):
|
|
308
|
+
print()
|
|
309
|
+
print("## TF-xxx — %s: %s" % (script, msg))
|
|
310
|
+
print()
|
|
311
|
+
print("- **Severity:** major")
|
|
312
|
+
print("- **Blocks:** no")
|
|
313
|
+
print("- **Repro:** `bash .tfcore/utils/tf-selfcheck.sh` in this repository")
|
|
314
|
+
print("- **Expected:** the script works on this project's files")
|
|
315
|
+
print("- **Actual:** %s" % msg)
|
|
316
|
+
print("- **Encountered in:** tf-selfcheck, %s" % proj)
|
|
317
|
+
if hint:
|
|
318
|
+
print("- **Consequence:** %s" % hint)
|
|
319
|
+
print("- **Not fixable here** — `.tfcore/` is framework-owned and is replaced on the next update.")
|
|
320
|
+
else:
|
|
321
|
+
print("Run again with --report for entries you can paste into the feedback file.")
|
|
322
|
+
raise SystemExit(1)
|
|
323
|
+
PY
|
|
@@ -85,7 +85,12 @@ def ledger(text):
|
|
|
85
85
|
lines = text.splitlines()
|
|
86
86
|
i = 0
|
|
87
87
|
while i < len(lines):
|
|
88
|
-
|
|
88
|
+
# The anchor is not decoration: a BRD's §9 screen inventory links to each item as
|
|
89
|
+
# [BRD-21](#brd-21), markdown generates no id for bold text inside a list item, and
|
|
90
|
+
# tf-doc-check refuses a broken link -- so a real BRD carries `<a id="brd-N"></a>`
|
|
91
|
+
# here and this regex matched NONE of TfLens's 179 items. tests/regression tf_017.
|
|
92
|
+
m = re.match(r"^\s*[-*]\s*(?:<a id=[\"\']?[^\"\'>]*[\"\']?\s*>\s*</a>\s*)?"
|
|
93
|
+
r"\*\*(BRD-\d+)\*\*\s*[—:-]+\s*(.*)$", lines[i])
|
|
89
94
|
if m and m.group(1) not in nfr_ids:
|
|
90
95
|
rid, rest = m.group(1), m.group(2)
|
|
91
96
|
title = re.split(r"\s*(?:\.\s+\*|\s\*Screen|\s·\s\*|\s\*Mockup)", rest, 1)[0].strip().rstrip(".")
|
|
@@ -77,14 +77,44 @@ def ladder_rank(status):
|
|
|
77
77
|
return LADDER.index(status) if status in LADDER else 0
|
|
78
78
|
|
|
79
79
|
|
|
80
|
+
def ledger(root):
|
|
81
|
+
"""docs/.last-verify.json as a dict, or {} when there is none.
|
|
82
|
+
|
|
83
|
+
The verifier writes it with `json.dump(..., indent=1)`, so it is ONE pretty-printed
|
|
84
|
+
object spanning many lines. This reader used to take `splitlines()[-1]` — written for
|
|
85
|
+
an append-per-line ledger that no version of the verifier has ever produced — so it
|
|
86
|
+
parsed the closing brace, threw, and every project reported `last_verified_build:
|
|
87
|
+
not-run` however many verify runs it had behind it (TfLens: 69 of 73 rows Verified and
|
|
88
|
+
a full ledger, reported as never verified). Whole file first, last line second for a
|
|
89
|
+
legacy JSONL ledger. tests/regression/run.sh tf_ledger."""
|
|
90
|
+
path = os.path.join(root, "docs", ".last-verify.json")
|
|
91
|
+
if not os.path.isfile(path):
|
|
92
|
+
return {}
|
|
93
|
+
text = read(path).strip()
|
|
94
|
+
for candidate in (text, text.splitlines()[-1] if text else ""):
|
|
95
|
+
try:
|
|
96
|
+
data = json.loads(candidate)
|
|
97
|
+
if isinstance(data, dict):
|
|
98
|
+
return data
|
|
99
|
+
except Exception:
|
|
100
|
+
continue
|
|
101
|
+
return {}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def ledger_verdicts(root):
|
|
105
|
+
"""{REQ id: verdict} from the last verify run, or {} when none has run.
|
|
106
|
+
|
|
107
|
+
The verifier already writes every row's verdict into the ledger. Reading it here is
|
|
108
|
+
what lets the status line tell "built, waiting for a verify" apart from "verified as
|
|
109
|
+
far as it can be, and the rest needs a decision only the owner can make"."""
|
|
110
|
+
rows = ledger(root).get("rows")
|
|
111
|
+
return rows if isinstance(rows, dict) else {}
|
|
112
|
+
|
|
113
|
+
|
|
80
114
|
def last_verify(root, app):
|
|
81
115
|
"""(date, build_result) from docs/.last-verify.json and gates.jsonl; ('', 'not-run') if none."""
|
|
82
|
-
|
|
83
|
-
if not
|
|
84
|
-
return "", "not-run"
|
|
85
|
-
try:
|
|
86
|
-
data = json.loads(read(ledger).strip().splitlines()[-1])
|
|
87
|
-
except Exception:
|
|
116
|
+
data = ledger(root)
|
|
117
|
+
if not data:
|
|
88
118
|
return "", "not-run"
|
|
89
119
|
date = str(data.get("date", ""))[:10]
|
|
90
120
|
result = "PASS"
|
|
@@ -161,8 +191,10 @@ def phase_row(root, app, phase):
|
|
|
161
191
|
return name, total
|
|
162
192
|
|
|
163
193
|
|
|
164
|
-
def next_command(app, rows, log_rows, phase=1):
|
|
194
|
+
def next_command(app, rows, log_rows, phase=1, verdicts=None):
|
|
165
195
|
"""Returns (phase, qualifier, cc_line, oc_line, reason)."""
|
|
196
|
+
verdicts = verdicts or {}
|
|
197
|
+
|
|
166
198
|
def ids(lst):
|
|
167
199
|
return ", ".join(r["id"] for r in lst[:6]) + (" …" if len(lst) > 6 else "")
|
|
168
200
|
|
|
@@ -187,11 +219,28 @@ def next_command(app, rows, log_rows, phase=1):
|
|
|
187
219
|
f"{CC}flow-master *build-phase {app}", f"{OC}flow-master *build-phase {app}",
|
|
188
220
|
f"{len(unbuilt)} rows are not built yet: {ids(unbuilt)}")
|
|
189
221
|
if built:
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
222
|
+
# A row the last verify could not MEASURE is not a row waiting for another verify.
|
|
223
|
+
# Its tests declined — the state they need does not exist in this environment — so
|
|
224
|
+
# running the verifier again produces the same NOT-TESTED and the project sits in a
|
|
225
|
+
# loop that looks like progress. Say what it actually needs: data, a changed
|
|
226
|
+
# acceptance line, or the row marked N/A. Only the owner can choose. FR-69's sibling
|
|
227
|
+
# case; the verdicts come from the ledger the verifier already writes.
|
|
228
|
+
unmeasured = [r for r in built if verdicts.get(r["id"]) == "NOT-TESTED"]
|
|
229
|
+
waiting = [r for r in built if r not in unmeasured]
|
|
230
|
+
if unmeasured and not waiting:
|
|
231
|
+
line = (f"(owner) {len(unmeasured)} row(s) cannot be measured here — create the data, "
|
|
232
|
+
f"change the acceptance line, or mark the row N/A: {ids(unmeasured)}")
|
|
233
|
+
return ("Verify", f"{len(unmeasured)} not measurable, {q}", line, line,
|
|
234
|
+
f"every test carrying these ids was skipped, so another verify run changes nothing: {ids(unmeasured)}")
|
|
235
|
+
ui = [r for r in waiting if r["id"].startswith("REQ-UI")]
|
|
236
|
+
scope = "ui" if len(ui) == len(waiting) else ("functional" if not ui else "all")
|
|
237
|
+
reason = f"{len(waiting)} rows are built and not verified: {ids(waiting)}"
|
|
238
|
+
if unmeasured:
|
|
239
|
+
reason += (f"; {len(unmeasured)} more could not be measured (every test skipped) "
|
|
240
|
+
f"and need the owner: {ids(unmeasured)}")
|
|
241
|
+
return ("Verify", f"{len(waiting)} to verify, {q}",
|
|
193
242
|
f"{CC}verifier *verify {scope} {app}", f"{OC}flow-verifier *verify {scope} {app}",
|
|
194
|
-
|
|
243
|
+
reason)
|
|
195
244
|
if owner:
|
|
196
245
|
if handoff_ran(log_rows):
|
|
197
246
|
line = f"(owner-run) docs/{app}-UsageGuide.md — {ids(owner)}"
|
|
@@ -234,7 +283,7 @@ def main(argv):
|
|
|
234
283
|
st_text = read(st_path) if os.path.isfile(st_path) else ""
|
|
235
284
|
log_rows = existing_log_rows(st_text)
|
|
236
285
|
vdate, vresult = last_verify(root, app)
|
|
237
|
-
phase, qual, cc, oc, reason = next_command(app, rows, log_rows, phase_n)
|
|
286
|
+
phase, qual, cc, oc, reason = next_command(app, rows, log_rows, phase_n, ledger_verdicts(root))
|
|
238
287
|
pname, ptotal = phase_row(root, app, phase_n)
|
|
239
288
|
if ptotal or phase_n > 1:
|
|
240
289
|
tag = f"Phase {phase_n} of {ptotal or '?'}" + (f" ({pname})" if pname else "")
|
|
@@ -3,16 +3,21 @@
|
|
|
3
3
|
//
|
|
4
4
|
// Two of the seven verify checks, over one browser session:
|
|
5
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
|
|
6
|
+
// table with a header and no rows; no blank page; no error banner; no console error.
|
|
7
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.
|
|
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.
|
|
9
13
|
// At every width it saves a full-page screenshot. Verdicts: render OK | EMPTY | ERROR | UNREACHABLE,
|
|
10
14
|
// visual OK | FAIL, each with its findings. Findings use the telemetry classes (blank-data,
|
|
11
15
|
// zero-rows, exception, overlap, clipped, offscreen, other). It writes a JSON file the verdict
|
|
12
16
|
// script reads and prints one line per screen. It never decides a row's status.
|
|
13
17
|
//
|
|
14
|
-
// Modes: --base URL (a served app, a new browser) or --cdp URL (attach to
|
|
15
|
-
//
|
|
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).
|
|
16
21
|
|
|
17
22
|
import { chromium } from 'playwright';
|
|
18
23
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
@@ -33,7 +38,21 @@ const LOGIN_PATH = arg('--login-path');
|
|
|
33
38
|
const USER = arg('--user'); const PASS = arg('--password');
|
|
34
39
|
const COOKIE = arg('--cookie'); const STORAGE = arg('--storage-state');
|
|
35
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);
|
|
36
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]'];
|
|
37
56
|
|
|
38
57
|
if (!BASE && !CDP) { console.error('tf-verify-screens: --base URL or --cdp URL is required'); process.exit(3); }
|
|
39
58
|
|
|
@@ -138,9 +157,29 @@ async function navigate(page, route) {
|
|
|
138
157
|
} catch (e) { return { status: 0, error: e.message.split('\n')[0] }; }
|
|
139
158
|
}
|
|
140
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
|
+
|
|
141
180
|
// what the page says about itself: anchors, tables, text, boxes
|
|
142
|
-
async function inspect(page, anchors, attr) {
|
|
143
|
-
return page.evaluate(({ anchors, attr }) => {
|
|
181
|
+
async function inspect(page, anchors, attr, errorSelectors) {
|
|
182
|
+
return page.evaluate(({ anchors, attr, errorSelectors }) => {
|
|
144
183
|
const vis = (el) => {
|
|
145
184
|
const cs = getComputedStyle(el);
|
|
146
185
|
if (cs.display === 'none' || cs.visibility === 'hidden' || cs.opacity === '0') return false;
|
|
@@ -149,13 +188,39 @@ async function inspect(page, anchors, attr) {
|
|
|
149
188
|
if (r.width <= 1 && r.height <= 1 && cs.position === 'absolute') return false; // sr-only
|
|
150
189
|
return true;
|
|
151
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
|
+
};
|
|
152
213
|
const sheetOk = (l) => { try { return !!l.sheet && l.sheet.cssRules.length > 0; } catch (e) { return !!l.sheet; } };
|
|
153
214
|
const styled = [...document.querySelectorAll('link[rel~="stylesheet"]')].some(sheetOk)
|
|
154
215
|
|| [...document.querySelectorAll('style')].some(sheetOk);
|
|
155
216
|
const failedSheets = [...document.querySelectorAll('link[rel~="stylesheet"]')].filter((l) => !l.sheet).map((l) => l.getAttribute('href'));
|
|
156
217
|
const out = { anchors: [], tables: [], text_len: (document.body.innerText || '').trim().length, boxes: [],
|
|
157
218
|
controls: document.querySelectorAll('input, select, textarea, img, svg, canvas, video').length,
|
|
158
|
-
|
|
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
|
+
}) || '',
|
|
159
224
|
overflow_x: document.documentElement.scrollWidth > document.documentElement.clientWidth + 2,
|
|
160
225
|
styled, failed_sheets: failedSheets, vw: window.innerWidth, vh: window.innerHeight };
|
|
161
226
|
for (const id of anchors || []) {
|
|
@@ -165,7 +230,9 @@ async function inspect(page, anchors, attr) {
|
|
|
165
230
|
const r = el.getBoundingClientRect();
|
|
166
231
|
const text = (el.innerText || el.value || el.getAttribute('aria-label') || '').trim();
|
|
167
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;
|
|
168
|
-
|
|
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 });
|
|
169
236
|
}
|
|
170
237
|
for (const t of document.querySelectorAll('table')) {
|
|
171
238
|
if (!vis(t)) continue;
|
|
@@ -178,8 +245,9 @@ async function inspect(page, anchors, attr) {
|
|
|
178
245
|
const els = [...document.querySelectorAll(sel)].filter(vis);
|
|
179
246
|
els.forEach((el, i) => {
|
|
180
247
|
const r = el.getBoundingClientRect();
|
|
248
|
+
const c = clipRect(el); // what is painted, not what is laid out
|
|
181
249
|
out.boxes.push({ i, id: el.getAttribute(attr) || '', tag: el.tagName.toLowerCase(), text: (el.innerText || el.value || '').trim().slice(0, 40),
|
|
182
|
-
x:
|
|
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) });
|
|
183
251
|
});
|
|
184
252
|
// ancestry so a button inside its card is never an "overlap"
|
|
185
253
|
out.rel = [];
|
|
@@ -187,12 +255,12 @@ async function inspect(page, anchors, attr) {
|
|
|
187
255
|
if (els[a].contains(els[b]) || els[b].contains(els[a])) out.rel.push([a, b]);
|
|
188
256
|
}
|
|
189
257
|
return out;
|
|
190
|
-
}, { anchors, attr });
|
|
258
|
+
}, { anchors, attr, errorSelectors });
|
|
191
259
|
}
|
|
192
260
|
|
|
193
261
|
function grade(info, consoleErrors, width) {
|
|
194
262
|
const findings = [];
|
|
195
|
-
if (info.
|
|
263
|
+
if (info.error_banner) findings.push({ check: 'render', class: 'exception', detail: `the application's error banner is showing (${info.error_banner})` });
|
|
196
264
|
// a failed fetch ("Failed to load resource … 404") is the assets check's finding, not a page error
|
|
197
265
|
const pageErrors = consoleErrors.filter((e) => !/^Failed to load resource/i.test(e));
|
|
198
266
|
for (const e of pageErrors.slice(0, 3)) findings.push({ check: 'render', class: 'exception', detail: `console error: ${e.slice(0, 120)}` });
|
|
@@ -209,7 +277,10 @@ function grade(info, consoleErrors, width) {
|
|
|
209
277
|
if (info.overflow_x) findings.push({ check: 'visual', class: 'offscreen', detail: `the page scrolls sideways at ${width}px` });
|
|
210
278
|
for (const a of info.anchors) {
|
|
211
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'}` });
|
|
212
|
-
|
|
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` });
|
|
213
284
|
}
|
|
214
285
|
const rel = new Set(info.rel.map(([a, b]) => `${a}-${b}`));
|
|
215
286
|
const boxes = info.boxes.filter((b) => b.w > 0 && b.h > 0);
|
|
@@ -257,7 +328,8 @@ for (const width of WIDTHS) {
|
|
|
257
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}` }];
|
|
258
329
|
if (nav.status === 0 && results.length === 1 && width === WIDTHS[0]) firstUnreachable = true;
|
|
259
330
|
} else {
|
|
260
|
-
|
|
331
|
+
entry.render_wait_ms = await waitForRender(page, r.anchors || [], ATTR);
|
|
332
|
+
const info = await inspect(page, r.anchors || [], ATTR, ERROR_SELECTORS);
|
|
261
333
|
const g = grade(info, consoleErrors, width);
|
|
262
334
|
Object.assign(entry, g);
|
|
263
335
|
entry.console_errors = consoleErrors.slice(0, 5);
|