@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.
Files changed (95) hide show
  1. package/.claude/commands/TechieFlow/agents/analyst.md +5 -8
  2. package/.claude/commands/TechieFlow/agents/architect.md +1 -10
  3. package/.claude/commands/TechieFlow/agents/flow-master.md +1 -0
  4. package/.claude/commands/TechieFlow/agents/verifier.md +1 -0
  5. package/.claude/commands/TechieFlow/tasks/_owner-language.md +26 -0
  6. package/.claude/commands/TechieFlow/tasks/_smoke-test-policy.md +1 -1
  7. package/.claude/commands/TechieFlow/tasks/amend-docs.md +6 -5
  8. package/.claude/commands/TechieFlow/tasks/build-phase.md +1 -1
  9. package/.claude/commands/TechieFlow/tasks/create-deep-research-prompt.md +4 -0
  10. package/.claude/commands/TechieFlow/tasks/create-doc.md +4 -0
  11. package/.claude/commands/TechieFlow/tasks/create-project-brief.md +22 -0
  12. package/.claude/commands/TechieFlow/tasks/day1-brownfield.md +2 -2
  13. package/.claude/commands/TechieFlow/tasks/day1-greenfield.md +2 -2
  14. package/.claude/commands/TechieFlow/tasks/facilitate-brainstorming-session.md +4 -0
  15. package/.claude/commands/TechieFlow/tasks/generate-html.md +5 -1
  16. package/.claude/commands/TechieFlow/tasks/handoff-phase.md +18 -16
  17. package/.claude/commands/TechieFlow/tasks/metrics-report.md +2 -0
  18. package/.claude/commands/TechieFlow/tasks/verify-phase.md +1 -1
  19. package/.opencode/plugin/techieflow.js +2 -2
  20. package/.tfcore/agents/analyst.md +5 -8
  21. package/.tfcore/agents/architect.md +1 -10
  22. package/.tfcore/agents/flow-master.md +1 -0
  23. package/.tfcore/agents/verifier.md +1 -0
  24. package/.tfcore/hooks/guard-metrics.sh +13 -1
  25. package/.tfcore/hooks/guard-verify-deps.sh +124 -0
  26. package/.tfcore/routing.yaml +61 -0
  27. package/.tfcore/tasks/_owner-language.md +26 -0
  28. package/.tfcore/tasks/_smoke-test-policy.md +1 -1
  29. package/.tfcore/tasks/amend-docs.md +6 -5
  30. package/.tfcore/tasks/build-phase.md +1 -1
  31. package/.tfcore/tasks/create-deep-research-prompt.md +4 -0
  32. package/.tfcore/tasks/create-doc.md +4 -0
  33. package/.tfcore/tasks/create-project-brief.md +22 -0
  34. package/.tfcore/tasks/day1-brownfield.md +2 -2
  35. package/.tfcore/tasks/day1-greenfield.md +2 -2
  36. package/.tfcore/tasks/facilitate-brainstorming-session.md +4 -0
  37. package/.tfcore/tasks/generate-html.md +5 -1
  38. package/.tfcore/tasks/handoff-phase.md +18 -16
  39. package/.tfcore/tasks/metrics-report.md +2 -0
  40. package/.tfcore/tasks/verify-phase.md +1 -1
  41. package/.tfcore/telemetry/SCHEMA.md +57 -1
  42. package/.tfcore/telemetry/tf-metrics.sh +416 -19
  43. package/.tfcore/templates/v4custom/app-brief-tmpl.md +65 -0
  44. package/.tfcore/templates/v4custom/app-decision-request-tmpl.md +85 -0
  45. package/.tfcore/templates/v4custom/app-library-feedback-tmpl.md +86 -31
  46. package/.tfcore/templates/v4custom/html-render-shell.md +1 -1
  47. package/.tfcore/utils/__pycache__/tf-brd-status.cpython-312.pyc +0 -0
  48. package/.tfcore/utils/__pycache__/tf-checklist-edit.cpython-312.pyc +0 -0
  49. package/.tfcore/utils/__pycache__/tf-day1-files.cpython-312.pyc +0 -0
  50. package/.tfcore/utils/__pycache__/tf-devguide-list.cpython-312.pyc +0 -0
  51. package/.tfcore/utils/__pycache__/tf-doc-check.cpython-312.pyc +0 -0
  52. package/.tfcore/utils/__pycache__/tf-log-miss.cpython-312.pyc +0 -0
  53. package/.tfcore/utils/__pycache__/tf-misses-md.cpython-312.pyc +0 -0
  54. package/.tfcore/utils/__pycache__/tf-mockups-locate.cpython-312.pyc +0 -0
  55. package/.tfcore/utils/__pycache__/tf-opencode-audit.cpython-312.pyc +0 -0
  56. package/.tfcore/utils/__pycache__/tf-perf-grade.cpython-312.pyc +0 -0
  57. package/.tfcore/utils/__pycache__/tf-render-html.cpython-312.pyc +0 -0
  58. package/.tfcore/utils/__pycache__/tf-split-brd.cpython-312.pyc +0 -0
  59. package/.tfcore/utils/__pycache__/tf-status-facts.cpython-312.pyc +0 -0
  60. package/.tfcore/utils/__pycache__/tf-triage.cpython-312.pyc +0 -0
  61. package/.tfcore/utils/__pycache__/tf-verify-list.cpython-312.pyc +0 -0
  62. package/.tfcore/utils/__pycache__/tf-verify-verdict.cpython-312.pyc +0 -0
  63. package/.tfcore/utils/tf-build-list.py +23 -3
  64. package/.tfcore/utils/tf-build-list.sh +3 -2
  65. package/.tfcore/utils/tf-doc-check.py +106 -4
  66. package/.tfcore/utils/tf-emit.sh +353 -16
  67. package/.tfcore/utils/tf-fix-close.sh +34 -2
  68. package/.tfcore/utils/tf-gitignore-audit.sh +50 -3
  69. package/.tfcore/utils/tf-goal.sh +93 -6
  70. package/.tfcore/utils/tf-misses-md.py +14 -17
  71. package/.tfcore/utils/tf-misses-md.sh +5 -4
  72. package/.tfcore/utils/tf-model-pick.sh +475 -0
  73. package/.tfcore/utils/tf-phase.sh +18 -0
  74. package/.tfcore/utils/tf-render-html.py +20 -0
  75. package/.tfcore/utils/tf-routing-bind.sh +87 -1
  76. package/.tfcore/utils/tf-routing.sh +7 -0
  77. package/.tfcore/utils/tf-selfcheck.sh +323 -0
  78. package/.tfcore/utils/tf-split-brd.py +6 -1
  79. package/.tfcore/utils/tf-status-facts.py +61 -12
  80. package/.tfcore/utils/tf-verify-screens.mjs +85 -13
  81. package/.tfcore/utils/tf-verify-screens.sh +12 -3
  82. package/.tfcore/utils/tf-verify-tests.sh +60 -17
  83. package/.tfcore/utils/tf-verify-verdict.py +11 -2
  84. package/README.md +15 -5
  85. package/opencode.jsonc +24 -0
  86. package/package.json +1 -1
  87. package/scripts/install.mjs +4 -0
  88. package/.tfcore/templates/architecture-tmpl.yaml +0 -650
  89. package/.tfcore/templates/brownfield-architecture-tmpl.yaml +0 -476
  90. package/.tfcore/templates/competitor-analysis-tmpl.yaml +0 -306
  91. package/.tfcore/templates/front-end-architecture-tmpl.yaml +0 -218
  92. package/.tfcore/templates/fullstack-architecture-tmpl.yaml +0 -823
  93. package/.tfcore/templates/market-research-tmpl.yaml +0 -252
  94. package/.tfcore/templates/project-brief-tmpl.yaml +0 -221
  95. 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
- m = re.match(r"^\s*[-*]\s*\*\*(BRD-\d+)\*\*\s*[—:-]+\s*(.*)$", lines[i])
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
- ledger = os.path.join(root, "docs", ".last-verify.json")
83
- if not os.path.isfile(ledger):
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
- ui = [r for r in built if r["id"].startswith("REQ-UI")]
191
- scope = "ui" if len(ui) == len(built) else ("functional" if not ui else "all")
192
- return ("Verify", f"{len(built)} to verify, {q}",
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
- f"{len(built)} rows are built and not verified: {ids(built)}")
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 Blazor error; no console error.
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 a running Blazor Hybrid
15
- // app's WebView2 over its DevTools port; screens are reached by pushState, the way the router does).
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
- blazor_error: !!document.querySelector('#blazor-error-ui') && vis(document.querySelector('#blazor-error-ui')),
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
- 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) });
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: r.left, y: r.top, w: r.width, h: r.height, anchored: el.hasAttribute(attr) });
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.blazor_error) findings.push({ check: 'render', class: 'exception', detail: 'the Blazor error bar is showing' });
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
- if (a.present && a.visible && a.w > 0 && (a.x + a.w > info.vw + 2 || a.x < -2)) findings.push({ check: 'visual', class: 'offscreen', detail: `anchored control "${a.id}" sits outside the viewport at ${width}px` });
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
- const info = await inspect(page, r.anchors || [], ATTR);
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);