@techierathore/techieflow 1.0.4 → 1.1.0

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 (98) hide show
  1. package/.claude/commands/TechieFlow/tasks/_owner-language.md +1 -0
  2. package/.claude/commands/TechieFlow/tasks/_status-update-gate.md +4 -3
  3. package/.claude/commands/TechieFlow/tasks/amend-docs.md +1 -1
  4. package/.claude/commands/TechieFlow/tasks/day1-brownfield.md +1 -1
  5. package/.claude/commands/TechieFlow/tasks/day1-greenfield.md +1 -1
  6. package/.claude/commands/TechieFlow/tasks/fix-issues.md +2 -2
  7. package/.claude/commands/TechieFlow/tasks/generate-html.md +2 -2
  8. package/.claude/commands/TechieFlow/tasks/handoff-phase.md +2 -2
  9. package/.claude/commands/TechieFlow/tasks/mockups.md +1 -1
  10. package/.claude/commands/TechieFlow/tasks/productguide.md +2 -2
  11. package/.claude/commands/TechieFlow/tasks/render-workflow-docs.md +1 -1
  12. package/.claude/commands/TechieFlow/tasks/triage-issues.md +2 -2
  13. package/.opencode/plugin/techieflow.js +179 -7
  14. package/.tfcore/hooks/block-git.sh +4 -2
  15. package/.tfcore/hooks/guard-build.sh +5 -1
  16. package/.tfcore/hooks/guard-db.sh +6 -4
  17. package/.tfcore/hooks/guard-status-html.sh +86 -2
  18. package/.tfcore/hooks/guard-verify.sh +8 -3
  19. package/.tfcore/hooks/sweep-artifacts.sh +46 -0
  20. package/.tfcore/standards/owner-words.txt +23 -0
  21. package/.tfcore/tasks/_owner-language.md +1 -0
  22. package/.tfcore/tasks/_status-update-gate.md +4 -3
  23. package/.tfcore/tasks/amend-docs.md +1 -1
  24. package/.tfcore/tasks/day1-brownfield.md +1 -1
  25. package/.tfcore/tasks/day1-greenfield.md +1 -1
  26. package/.tfcore/tasks/fix-issues.md +2 -2
  27. package/.tfcore/tasks/generate-html.md +2 -2
  28. package/.tfcore/tasks/handoff-phase.md +2 -2
  29. package/.tfcore/tasks/mockups.md +1 -1
  30. package/.tfcore/tasks/productguide.md +2 -2
  31. package/.tfcore/tasks/render-workflow-docs.md +1 -1
  32. package/.tfcore/tasks/triage-issues.md +2 -2
  33. package/.tfcore/telemetry/SCHEMA.md +4 -0
  34. package/.tfcore/telemetry/install-metrics.sh +3 -2
  35. package/.tfcore/telemetry/tf-metrics.sh +19 -2
  36. package/.tfcore/templates/v4custom/app-agents-md-tmpl.md +6 -0
  37. package/.tfcore/templates/v4custom/app-brd-tmpl.md +5 -3
  38. package/.tfcore/templates/v4custom/app-phase-brd-tmpl.md +94 -0
  39. package/.tfcore/templates/v4custom/app-phase-uidesign-tmpl.md +60 -0
  40. package/.tfcore/templates/v4custom/app-uidesign-tmpl.md +4 -2
  41. package/.tfcore/templates/v4custom/build-subagent-prompt.md +2 -2
  42. package/.tfcore/templates/v4custom/html-render-shell.md +4 -2
  43. package/.tfcore/utils/__pycache__/tf-devguide-list.cpython-312.pyc +0 -0
  44. package/.tfcore/utils/__pycache__/tf-doc-check.cpython-312.pyc +0 -0
  45. package/.tfcore/utils/__pycache__/tf-misses-md.cpython-312.pyc +0 -0
  46. package/.tfcore/utils/__pycache__/tf-render-html.cpython-312.pyc +0 -0
  47. package/.tfcore/utils/__pycache__/tf-split-brd.cpython-312.pyc +0 -0
  48. package/.tfcore/utils/__pycache__/tf-status-facts.cpython-312.pyc +0 -0
  49. package/.tfcore/utils/__pycache__/tf-triage.cpython-312.pyc +0 -0
  50. package/.tfcore/utils/__pycache__/tf-verify-list.cpython-312.pyc +0 -0
  51. package/.tfcore/utils/__pycache__/tf-verify-verdict.cpython-312.pyc +0 -0
  52. package/.tfcore/utils/tf-assets-browser.mjs +96 -0
  53. package/.tfcore/utils/tf-assets.sh +97 -12
  54. package/.tfcore/utils/tf-brd-status.py +14 -5
  55. package/.tfcore/utils/tf-build-list.py +80 -12
  56. package/.tfcore/utils/tf-build-list.sh +2 -1
  57. package/.tfcore/utils/tf-build.sh +88 -15
  58. package/.tfcore/utils/tf-ci-repro.py +685 -0
  59. package/.tfcore/utils/tf-ci-repro.sh +24 -0
  60. package/.tfcore/utils/tf-devguide-list.py +47 -22
  61. package/.tfcore/utils/tf-doc-check.py +136 -6
  62. package/.tfcore/utils/tf-doc-tests.sh +79 -0
  63. package/.tfcore/utils/tf-emit.sh +92 -35
  64. package/.tfcore/utils/tf-feedback.sh +13 -0
  65. package/.tfcore/utils/tf-fix-close.sh +79 -29
  66. package/.tfcore/utils/tf-goal.sh +12 -12
  67. package/.tfcore/utils/tf-harness.sh +9 -4
  68. package/.tfcore/utils/tf-lock.sh +29 -0
  69. package/.tfcore/utils/tf-log-miss.py +34 -8
  70. package/.tfcore/utils/tf-login.mjs +167 -0
  71. package/.tfcore/utils/tf-misses-md.py +10 -4
  72. package/.tfcore/utils/tf-mockup-parity.mjs +427 -63
  73. package/.tfcore/utils/tf-mockup-parity.sh +14 -2
  74. package/.tfcore/utils/tf-owner-text.py +233 -0
  75. package/.tfcore/utils/tf-owner-text.sh +14 -0
  76. package/.tfcore/utils/tf-phase.sh +15 -1
  77. package/.tfcore/utils/tf-portable.sh +230 -0
  78. package/.tfcore/utils/tf-render-html.py +60 -2
  79. package/.tfcore/utils/tf-selfcheck.sh +39 -6
  80. package/.tfcore/utils/tf-split-brd.py +11 -1
  81. package/.tfcore/utils/tf-status-evidence.sh +11 -9
  82. package/.tfcore/utils/tf-status-facts.py +72 -32
  83. package/.tfcore/utils/tf-triage.py +62 -16
  84. package/.tfcore/utils/tf-triage.sh +2 -0
  85. package/.tfcore/utils/tf-verify-boot.sh +263 -43
  86. package/.tfcore/utils/tf-verify-emit.sh +20 -0
  87. package/.tfcore/utils/tf-verify-env.sh +33 -4
  88. package/.tfcore/utils/tf-verify-list.py +75 -8
  89. package/.tfcore/utils/tf-verify-screens.mjs +171 -32
  90. package/.tfcore/utils/tf-verify-screens.sh +8 -3
  91. package/.tfcore/utils/tf-verify-tests.sh +215 -14
  92. package/.tfcore/utils/tf-verify-verdict.py +48 -9
  93. package/.tfcore/utils/tf_defect.py +27 -0
  94. package/.tfcore/utils/tf_feedback.py +209 -0
  95. package/.tfcore/utils/tf_roadmap.py +35 -0
  96. package/README.md +4 -0
  97. package/docs/TechieFlow-Installation.md +5 -3
  98. package/package.json +1 -1
@@ -0,0 +1,24 @@
1
+ #!/usr/bin/env bash
2
+ # tf-ci-repro.sh — reproduce a CI job here the way a fresh runner runs it (Lekhak TF-019, 2026-09-30).
3
+ #
4
+ # bash .tfcore/utils/tf-ci-repro.sh [<workflow.yml>] [--job <id>] [--list] [--keep] [--run-setup]
5
+ #
6
+ # Run from the repository root. A developer machine hides CI failures in its package caches and its
7
+ # build output: Lekhak's CI failed with NETSDK1112 because the win-x64 runtime pack was never
8
+ # downloaded, while the same commands passed locally where the pack was already cached. A local run
9
+ # with warm caches is therefore no proof that a CI failure is fixed. This script copies the repository
10
+ # without anything .gitignore ignores, to a folder outside it, points every package cache it knows
11
+ # (NuGet, npm, yarn, pnpm, pip, Go, Gradle, Maven) at an empty folder, and runs the job's `run:` steps
12
+ # in the runner's shell, stopping at the first failure. A Windows job runs Windows-side from WSL.
13
+ # `uses:` steps, steps that need a secret and steps that only install tools on the machine are
14
+ # skipped, each with the reason. --list prints the plan and runs nothing; --keep keeps the copy.
15
+ # Prints ONE verdict line last:
16
+ # PASS job <id>: <n> run step(s) passed on a clean copy with empty caches (…) — logs <dir> exit 0
17
+ # FAIL job <id>: step <n> "<name>" failed (exit N) with empty caches — <first error> — log <file> exit 1
18
+ # NOT-RUN <why: no workflow, several jobs, a macOS job off a Mac …> exit 2
19
+ # PyYAML is used when installed; without it (a stock Mac) a built-in reader takes the workflow.
20
+ # Logs: tests/.artifacts/ci-repro/<UTC time>/step-NN.log. The first run downloads every package, so
21
+ # it takes as long as a cold CI restore.
22
+ set -uo pipefail
23
+ case "${1:-}" in -h|--help) sed -n '2,21p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; esac
24
+ exec python3 "$(dirname "${BASH_SOURCE[0]}")/tf-ci-repro.py" "$@"
@@ -50,9 +50,16 @@ def cfg(key, default):
50
50
  PRUNE = {"bin", "obj", "node_modules", ".git", ".artifacts", "OldDocs", "dist", ".tfcore", ".claude", ".opencode", "packages", "TestResults"}
51
51
 
52
52
 
53
+ # The folders a project keeps its code in, in the order tf-verify-boot.sh looks (Lekhak TF-014: Lekhak's
54
+ # is source/, and --update counted only src/).
55
+ CODE_ROOTS = ("src", "source")
56
+
57
+
53
58
  def walk(root, exts, skip_samples=False):
54
59
  for d, dirs, files in os.walk(root):
55
- dirs[:] = [x for x in dirs if x not in PRUNE] # prune in place: never descend into build output
60
+ # prune in place: never descend into build output, nor into any hidden folder (.tfbuild, .vs,
61
+ # .playwright …), which holds tool output, never a page (Lekhak TF-014)
62
+ dirs[:] = [x for x in dirs if x not in PRUNE and not x.startswith(".")]
56
63
  dd = d.replace("\\", "/") + "/"
57
64
  if any(s in dd for s in SKIP):
58
65
  continue
@@ -60,20 +67,28 @@ def walk(root, exts, skip_samples=False):
60
67
  continue
61
68
  for f in files:
62
69
  if f.lower().endswith(exts):
63
- yield os.path.join(d, f).replace("\\", "/").lstrip("./")
70
+ p = os.path.join(d, f).replace("\\", "/")
71
+ # only a leading "./" goes: lstrip("./") also ate the dot of a hidden folder, and
72
+ # .tfbuild/… became tfbuild/…, which does not exist (Lekhak TF-014)
73
+ yield p[2:] if p.startswith("./") else p
74
+
75
+
76
+ def code_files(exts, skip_samples=True):
77
+ """Every file with these extensions under the project's code folders (src/, source/)."""
78
+ for r in CODE_ROOTS:
79
+ if os.path.isdir(r):
80
+ yield from walk(r, exts, skip_samples=skip_samples)
64
81
 
65
82
 
66
83
  def roles_from_usageguide(app):
67
- p = os.path.join("docs", f"{app}-UsageGuide.md")
68
- roles = []
69
- if os.path.isfile(p):
70
- m = re.search(r"(?ms)^##\s+(?:\d+[.)]\s*)?Test users[^\n]*\n(.*?)(?=^## |\Z)", read(p))
71
- if m:
72
- for line in m.group(1).splitlines():
73
- c = [x.strip() for x in line.strip().strip("|").split("|")]
74
- if len(c) >= 4 and c[0].strip("`* ").isdigit():
75
- roles.append((c[1].strip("`* "), c[3].strip("`* ")))
76
- return roles
84
+ # the guide may be {App}-UsageGuide.md or {App}-Usage-Guide.md, its heading ## or ###, and its
85
+ # table the template's numbered shape or one read by its header names (AppManager TF-009):
86
+ # the same reader tf-verify-list.py uses
87
+ import importlib.util
88
+ spec = importlib.util.spec_from_file_location("tf_verify_list", os.path.join(os.path.dirname(os.path.abspath(__file__)), "tf-verify-list.py"))
89
+ vl = importlib.util.module_from_spec(spec)
90
+ spec.loader.exec_module(vl)
91
+ return [(u["user"], u["role"]) for u in vl.test_users(vl.usage_guide(app))]
77
92
 
78
93
 
79
94
  def screens_from_uidesign(app, phase):
@@ -88,9 +103,9 @@ def screens_from_uidesign(app, phase):
88
103
 
89
104
  def routes_in_code():
90
105
  found = []
91
- for f in walk(".", (".razor", ".cshtml", ".cs", ".ts", ".tsx", ".js", ".jsx", ".vue")):
92
- if "/" not in f:
93
- continue # a top-level file is never a page
106
+ for f in walk(".", (".razor", ".cshtml", ".cs", ".ts", ".tsx", ".js", ".jsx", ".vue"), skip_samples=True):
107
+ if "/" not in f or re.search(r"\.(spec|test)\.\w+$", f):
108
+ continue # a top-level file is never a page; a test's `path:` is a screenshot, not a route (TfLens TF-050)
94
109
  txt = read(f)
95
110
  for ext_re, pat, what in ROUTE_PATTERNS:
96
111
  if re.search(ext_re, f, re.I):
@@ -141,7 +156,7 @@ def main(argv):
141
156
  kind = argv[argv.index("--kind") + 1].lower() # override for a project whose config lacks it
142
157
  sub = "app"
143
158
  if kind.startswith("lib"):
144
- razors = [f for f in walk("src", (".razor",), skip_samples=True)] if os.path.isdir("src") else []
159
+ razors = list(code_files((".razor",)))
145
160
  sub = "ui-library" if razors else "service-library"
146
161
  print(f"# tf-devguide-list — {app} — kind {sub} — phase {phase}")
147
162
  print()
@@ -184,9 +199,9 @@ def main(argv):
184
199
  for k in sorted(endpoints):
185
200
  print(f"- {k} — {', '.join(sorted(set(endpoints[k]))[:2])}")
186
201
  elif sub == "ui-library":
187
- comps = [f for f in walk("src", (".razor",), skip_samples=True)]
202
+ comps = list(code_files((".razor",)))
188
203
  if not comps:
189
- print("NOTHING: no component under src/. Build first.")
204
+ print("NOTHING: no component under src/ or source/. Build first.")
190
205
  return 0
191
206
  samples = sample_files((".razor", ".cshtml", ".html"))
192
207
  stxt = {f: read(f) for f in samples}
@@ -196,9 +211,9 @@ def main(argv):
196
211
  shown = [s for s, t in stxt.items() if re.search(rf"<{re.escape(name)}\b", t)]
197
212
  print(f"- {os.path.dirname(f).split('/')[-1]} / {name} — {f} — " + (f"shown in {shown[0]}" + (f" (+{len(shown)-1})" if len(shown) > 1 else "") if shown else "NO SAMPLE shows it: a sample gap to log"))
198
213
  else:
199
- files = [f for f in walk("src", (".cs", ".ts", ".py", ".go", ".java"), skip_samples=True)]
214
+ files = list(code_files((".cs", ".ts", ".py", ".go", ".java")))
200
215
  if not files:
201
- print("NOTHING: no source under src/. Build first.")
216
+ print("NOTHING: no source under src/ or source/. Build first.")
202
217
  return 0
203
218
  items = []
204
219
  for f in files:
@@ -222,6 +237,16 @@ def main(argv):
222
237
  print()
223
238
  if ver is None:
224
239
  print(f"## --update: no existing guide at {gp} (or no \"Verified on\"); everything is new")
240
+ # a guide kept under another name or folder (Lekhak TF-014: docs/devguides/, one file per
241
+ # role) is named, so it is read and carried over, never silently rewritten from scratch
242
+ import glob
243
+ base = os.path.basename(gp)[:-len(".md")]
244
+ others = sorted(f for f in glob.glob(os.path.join("docs", "**", base + "*.md"), recursive=True)
245
+ if os.path.normpath(f) != os.path.normpath(gp))
246
+ if others:
247
+ print(f"- {len(others)} guide file(s) under another name: " + ", ".join(f.replace(os.sep, "/") for f in others[:8])
248
+ + (" …" if len(others) > 8 else ""))
249
+ print(f" read them and carry every entry that still matches the code into {gp}; do not start from nothing")
225
250
  else:
226
251
  print(f"## --update: existing guide {gp}, Verified on {ver}, {len(entries)} entries")
227
252
  try:
@@ -230,8 +255,8 @@ def main(argv):
230
255
  except Exception:
231
256
  cutoff = None
232
257
  if cutoff:
233
- changed = [f for f in walk(".", (".razor", ".cshtml", ".cs", ".ts", ".tsx", ".js", ".py"), skip_samples=False)
234
- if f.startswith(("src/",)) and os.path.getmtime(f) > cutoff]
258
+ changed = [f for f in code_files((".razor", ".cshtml", ".cs", ".ts", ".tsx", ".js", ".py"), skip_samples=False)
259
+ if os.path.getmtime(f) > cutoff]
235
260
  print(f"- {len(changed)} source file(s) changed since then; remap the entries they serve, keep the rest verbatim")
236
261
  for f in changed[:30]:
237
262
  print(f" - {f}")
@@ -76,6 +76,7 @@ BRD_RANGE = re.compile(r"(?:BRD-)?(\d+)\s*(?:to|-|–|—)\s*(?:BRD-)?(\d+)", re
76
76
  SIZE_NAMES = {"s": "S", "small": "S", "m": "M", "medium": "M", "l": "L", "large": "L"}
77
77
  SIZE_LONG = {"S": "Small", "M": "Medium", "L": "Large"}
78
78
  REQ_CAP = {"S": 50, "M": 100, "L": 100}
79
+ SCREEN_CAP = {"S": 10, "M": 20, "L": 20} # Large: per phase
79
80
 
80
81
  CHECKLIST_HEADER = "| ID | Requirement | Status | % | Remarks | Details |"
81
82
  STATUS_VALUES = {
@@ -301,6 +302,12 @@ def detect_kind(path: str):
301
302
  name = os.path.basename(path).lower()
302
303
  if re.search(r"-deployment-checklist(-[\w]+)?\.md$", name):
303
304
  return "deployment-checklist", "app-deployment-checklist-tmpl.md"
305
+ # phase 2 onward of a Large project: still a BRD to every cross-check, but it carries only
306
+ # its own screens and requirements and points back at phase 1 for the rest (TF-024)
307
+ if re.search(r"-p\d+-brd\.md$", name):
308
+ return "brd", "app-phase-brd-tmpl.md"
309
+ if re.search(r"-p\d+-uidesign\.md$", name):
310
+ return "uidesign", "app-phase-uidesign-tmpl.md"
304
311
  for suffix, doc, tmpl in DOC_KINDS:
305
312
  if name.endswith(suffix):
306
313
  return doc, tmpl
@@ -338,6 +345,17 @@ def split_name(path: str):
338
345
  return m.group(1), int(m.group(2) or 1)
339
346
 
340
347
 
348
+ def foreign_prefix(path: str):
349
+ """The name's app when it is not an app of its folder: docs/AppManager-api-usage-guide.md beside
350
+ docs/AppManager-BRD.md is an API reference, not a UsageGuide (AppManager TF-024). A folder
351
+ holding no BRD or checklist anchors nothing, so a lone document is checked by its name."""
352
+ app, _ = split_name(path)
353
+ folder = os.path.dirname(os.path.abspath(path))
354
+ apps = {m.group(1).lower() for m in (re.match(r"(.+?)-(BRD|Checklist)\.md$", f, re.I) for f in os.listdir(folder))
355
+ if m and not re.search(r"-P\d+$|-Deployment$", m.group(1), re.I)}
356
+ return app if app and apps and app.lower() not in apps else None
357
+
358
+
341
359
  def phase_file(app: str, phase: int, suffix: str) -> str:
342
360
  return f"{app}-{suffix}.md" if phase <= 1 else f"{app}-P{phase}-{suffix}.md"
343
361
 
@@ -386,6 +404,10 @@ def check_document(path: str, rep: Report, cli_size=None, root=None):
386
404
  if not doc:
387
405
  rep.warn(path, "not a TechieFlow document name; skipped")
388
406
  return None
407
+ foreign = foreign_prefix(path)
408
+ if foreign:
409
+ rep.warn(os.path.relpath(path, root or find_root(path)), f'"{foreign}" is not an app here (no {foreign}-BRD.md or -Checklist.md beside it); not a TechieFlow document, skipped')
410
+ return None
389
411
  try:
390
412
  schema = load_schema(tmpl)
391
413
  except (OSError, ValueError) as e:
@@ -471,10 +493,18 @@ def check_document(path: str, rep: Report, cli_size=None, root=None):
471
493
  total = word_count(body)
472
494
  if size in schema.budget:
473
495
  target, mx = schema.budget[size]
496
+ # The budget holds the size's screen cap of entries. Each entry past it brings its own
497
+ # per-entry target and maximum, which already bound it (AppManager TF-023: 54 screens).
498
+ extra, why = 0, ""
499
+ if schema.entries and schema.per_entry:
500
+ sec = section_text(present, schema.entries[0]) or ""
501
+ extra = max(0, sum(1 for h, _t in split_sections(sec, 3) if h) - SCREEN_CAP[size])
502
+ target, mx = target + extra * schema.per_entry[0], mx + extra * schema.per_entry[1]
503
+ why = f", with {extra} entries past the cap of {SCREEN_CAP[size]}" if extra else ""
474
504
  if total > mx:
475
- rep.fail(rel, f"{total:,} words; the {SIZE_LONG[size]} maximum is {mx:,} (target {target:,}). Shorten prose; never drop a screen, field or requirement to fit")
505
+ rep.fail(rel, f"{total:,} words; the {SIZE_LONG[size]} maximum is {mx:,} (target {target:,}{why}). Shorten prose; never drop a screen, field or requirement to fit")
476
506
  elif total > target:
477
- rep.warn(rel, f"{total:,} words; the {SIZE_LONG[size]} target is {target:,} (maximum {mx:,})")
507
+ rep.warn(rel, f"{total:,} words; the {SIZE_LONG[size]} target is {target:,} (maximum {mx:,}{why})")
478
508
  for key, h, txt in present:
479
509
  d = schema.declared(key)
480
510
  if d and d[2]:
@@ -638,6 +668,22 @@ def check_doc_rule(rule, c, rep):
638
668
  elif n_acc > ACC_TARGET:
639
669
  rep.warn(rel, f"BRD-{cur} acceptance line is {n_acc} words; the target is {ACC_TARGET} (maximum {ACC_MAX})")
640
670
 
671
+ elif rule == "phase-pointer":
672
+ # a phase BRD leaves scope, users, the whole-app non-functionals, constraints and risks to
673
+ # phase 1, and a phase UIDesign the design system and the click-through flow; this section is
674
+ # how a reader finds them, so it must actually lead to the phase-1 document of the same kind
675
+ txt = section_text(present, "Where the rest lives")
676
+ if txt is None or not c.get("app"):
677
+ return
678
+ want = phase_file(c["app"], 1, PHASED_SUFFIX[c["doc"]])
679
+ hits = [l for l in re.findall(r"\]\(([^)\s]+)\)", txt)
680
+ if os.path.basename(l.split("#", 1)[0]).lower() == want.lower()]
681
+ if not hits:
682
+ rep.fail(rel, f'the "Where the rest lives" section does not link to the phase-1 document ({want}), '
683
+ f'where everything the whole application shares lives')
684
+ elif not any(resolve(root, path, l) for l in hits):
685
+ rep.fail(rel, f'the "Where the rest lives" link to {hits[0]} points at a file that does not exist')
686
+
641
687
  elif rule == "mockup-links":
642
688
  for l in sorted(links_to(clean, "mockups")):
643
689
  if not resolve(root, path, l):
@@ -662,6 +708,7 @@ def check_doc_rule(rule, c, rep):
662
708
  continue
663
709
  names.append(r[sc])
664
710
  c["brd_screens"] = names
711
+ check_named_screens(c, rep)
665
712
 
666
713
  elif rule == "stack-table":
667
714
  txt = section_text(present, "Stack decisions")
@@ -863,6 +910,26 @@ def check_doc_rule(rule, c, rep):
863
910
  if not re.search(r"(?i)\bblocking\b|\bnothing is blocked\b", txt):
864
911
  rep.fail(rel, 'the Summary must say how many entries are blocking right now, '
865
912
  'or "Nothing is blocked" — it is the first thing the owner reads')
913
+ # ... and its counts must be the entries' own. TfLens's read "20 entries, 8 open" over 27
914
+ # entries with 7 fixed upstream, and a TrBlazeUI file "all 28 open, none fixed upstream"
915
+ # under the library's own reply fixing 24 of them (MISS-TechieFlow-20260911-04).
916
+ sys.path.insert(0, SELF_DIR)
917
+ import tf_feedback
918
+ es = tf_feedback.entries(path)
919
+ n = {s: [e["id"] for e in es if e["state"] == s] for s in ("open", "fixed", "closed")}
920
+ truth = (f"{len(es)} entries: {len(n['open'])} open, {len(n['fixed'])} fixed upstream and not yet "
921
+ f"re-checked, {len(n['closed'])} closed")
922
+ head = re.split(r"(?m)^####\s", txt)[0]
923
+ said_total = re.search(r"(?i)\b(\d+)\s+entries\b", head)
924
+ said_open = re.search(r"(?i)\b(?:all\s+)?(\d+)\s+(?:filed and\s+)?open\b", head)
925
+ wrong = ((said_total and int(said_total.group(1)) != len(es))
926
+ or (said_open and int(said_open.group(1)) != len(n["open"])))
927
+ if wrong:
928
+ rep.fail(rel, f"the Summary's counts are not the entries' — the file holds {truth}; "
929
+ f"rewrite it from: bash .tfcore/utils/tf-feedback.sh {os.path.basename(path).split('-')[0]}")
930
+ elif n["fixed"] and not re.search(r"(?i)fixed upstream|re-?check", head):
931
+ rep.fail(rel, f"{len(n['fixed'])} entries are fixed upstream and not yet re-checked here "
932
+ f"({', '.join(n['fixed'][:5])}{' …' if len(n['fixed']) > 5 else ''}); the Summary must say so")
866
933
 
867
934
  elif rule == "paste-back-block":
868
935
  # `present` is built from the prose-only copy, which drops fenced blocks — and a
@@ -973,7 +1040,11 @@ def check_checklist(c, rep):
973
1040
  n = len(re.findall(r"\S+", remarks))
974
1041
  if n > 60:
975
1042
  rep.fail(rel, f"{rid} Remarks is {n} words; at most 60, current state only (history lives in the telemetry streams)")
976
- if NOT_PRESENT.search(remarks) and not NAMES_PATH.search(remarks):
1043
+ # text in backticks is quoted — a test's title or message the verdict script copied — not an agent's
1044
+ # claim about a file; an unclosed span (a Remark cut at its word cap) counts as quoted to its end
1045
+ # (Lekhak TF-012)
1046
+ said = re.sub(r"`[^`]*(?:`|$)", " ", remarks)
1047
+ if NOT_PRESENT.search(said) and not NAMES_PATH.search(remarks):
977
1048
  rep.fail(rel, f"{rid} Remarks says something is not present without naming the path that was tried; search tools skip .tfcore/, so read the literal path first")
978
1049
  m = re.search(r"\(#([^)]+)\)", details)
979
1050
  if not m:
@@ -996,8 +1067,15 @@ def check_checklist(c, rep):
996
1067
  if not m:
997
1068
  continue # reported above through the Details link
998
1069
  pos = m.start()
999
- nxt = re.search(r"\n\s*(?:[-*]\s*)?<a id=|\n## |\n### ", body[pos + 1:]) # an entry is a list item: the dash precedes the anchor (miss 21)
1000
- entry = body[pos: pos + 1 + nxt.start()] if nxt else body[pos:]
1070
+ # A "### Page: …" heading written straight under the entry's own anchor belongs to the entry.
1071
+ # Stopping at the first "### " cut such an entry off before its content: three false failures
1072
+ # each, and 24 AppManager entries never really checked (TF-002). The next one still ends it.
1073
+ start = pos + 1
1074
+ own = re.match(r"<a id=[^>]*>\s*</a>[^\n]*\n(?:[ \t]*\n)*[ \t]*### [^\n]*", body[pos:])
1075
+ if own:
1076
+ start = pos + own.end()
1077
+ nxt = re.search(r"\n\s*(?:[-*]\s*)?<a id=|\n## |\n### ", body[start:]) # an entry is a list item: the dash precedes the anchor (miss 21)
1078
+ entry = body[pos: start + nxt.start()] if nxt else body[pos:]
1001
1079
  acc = [l for l in entry.splitlines() if ACCEPT_LINE.search(l)]
1002
1080
  if len(acc) != 1:
1003
1081
  rep.fail(rel, f"{rid} must have exactly one acceptance line; found {len(acc)}")
@@ -1039,6 +1117,48 @@ def _screen_key(s):
1039
1117
  return re.sub(r"\s+", " ", s).strip().lower()
1040
1118
 
1041
1119
 
1120
+ # A requirement names its screen in the template's "*Screen:* <name>" field, or in running text as
1121
+ # "**<name>** screen". Either must have a row in some phase's Screens and flow table: the row is what
1122
+ # brings the UI design entry and the mockup after it (TF-030).
1123
+ SCREEN_FIELD = re.compile(r"\*{1,2}Screen:?\*{1,2}:?\s*([^·*|\n]+)")
1124
+ SCREEN_BOLD = re.compile(r"\*\*([^*\n]{2,60})\*\*[ -]screens?\b", re.I)
1125
+ NOT_A_NAME = {"every", "any", "each", "one", "this", "that", "the", "a", "new", "same", "other", "whole", "single", "no"}
1126
+
1127
+
1128
+ def _table_screen_keys(txt):
1129
+ """Every name in the Screen column of a Screens and flow section, dialogs included."""
1130
+ out = set()
1131
+ for header, rows in tables_in(txt or ""):
1132
+ low = [h.lower() for h in header]
1133
+ if "screen" in low and "route" in low:
1134
+ sc = low.index("screen")
1135
+ out |= {_screen_key(r[sc]) for r in rows if len(r) > sc and r[sc] and not r[sc].startswith("{")}
1136
+ return out
1137
+
1138
+
1139
+ def check_named_screens(c, rep):
1140
+ known = _table_screen_keys(section_text(c["present"], "Screens and flow"))
1141
+ if c.get("app"): # a phase BRD may name a screen another phase's BRD owns
1142
+ for f in sorted(os.listdir(os.path.dirname(c["path"]))):
1143
+ if f != os.path.basename(c["path"]) and re.match(rf"^{re.escape(c['app'])}-(P\d+-)?BRD\.md$", f):
1144
+ with open(os.path.join(os.path.dirname(c["path"]), f), encoding="utf-8") as fh:
1145
+ other = [(norm_heading(h), h, t) for h, t in split_sections(strip_noise(fh.read()), 2) if h is not None]
1146
+ known |= _table_screen_keys(section_text(other, "Screens and flow"))
1147
+ cur, told = None, set()
1148
+ for line in (section_text(c["present"], "Requirements") or "").splitlines():
1149
+ m = re.search(r"\*\*BRD-(\d+)\*\*", line)
1150
+ if m:
1151
+ cur = m.group(1)
1152
+ for name in [x.group(1) for x in SCREEN_FIELD.finditer(line)] + [x.group(1) for x in SCREEN_BOLD.finditer(line)]:
1153
+ key = _screen_key(name)
1154
+ if not key or name.strip().startswith("{") or key in NOT_A_NAME or key in known or key in told:
1155
+ continue
1156
+ told.add(key)
1157
+ where = f"BRD-{cur}" if cur else "a requirement"
1158
+ rep.fail(c["rel"], f'{where} names the screen "{name.strip()}", which has no row in any Screens and flow table; '
1159
+ f'add the row, then its UI design entry and its mockup, before it is built')
1160
+
1161
+
1042
1162
  def cross_checks(ctxs: dict, rep: Report, root: str):
1043
1163
  """ctxs is keyed by (doc, phase). Single documents sit at phase 1."""
1044
1164
  phases = sorted({p for (_d, p) in ctxs})
@@ -1092,9 +1212,19 @@ def cross_phase_checks(ctxs, rep, root, phases):
1092
1212
  rep.fail(cls[p]["rel"], f"{i} is also in phase {seen[i]}'s checklist; REQ ids run on across phases and are never reused")
1093
1213
  seen.setdefault(i, p)
1094
1214
 
1215
+ if not ph and app:
1216
+ # It may simply not have been named on the command line. Read it from disk for this rule and
1217
+ # throw its own findings away, since they belong to a run that names it: saying "the Phases
1218
+ # document does not exist" about a file that is there invites writing over it (TF-041).
1219
+ disk = os.path.join(root, "docs", f"{app}-Phases.md")
1220
+ if os.path.isfile(disk):
1221
+ try:
1222
+ ph = check_document(disk, Report(False), None, root)
1223
+ except Exception:
1224
+ ph = None
1095
1225
  if not ph:
1096
1226
  if len(phases) > 1 and app:
1097
- rep.fail(f"docs/{app}-Phases.md", f"phase files exist ({', '.join(f'P{p}' for p in phases if p > 1)}) but the Phases document does not; write it from app-phases-tmpl.md")
1227
+ rep.fail(f"docs/{app}-Phases.md", f"phase files exist ({', '.join(f'P{p}' for p in phases if p > 1)}) but no Phases document is on disk; write it from app-phases-tmpl.md")
1098
1228
  return
1099
1229
  rows = ph.get("phases") or []
1100
1230
  if not rows:
@@ -0,0 +1,79 @@
1
+ #!/usr/bin/env bash
2
+ # tf-doc-tests.sh — run the unit tests when a test reads a document this command changed (Lekhak TF-016, 2026-09-30).
3
+ #
4
+ # bash .tfcore/utils/tf-doc-tests.sh <doc> [<doc>…] [--target <sln|csproj>]
5
+ #
6
+ # A project may guard a document with a unit test: Lekhak's VerificationRuleDocTests reads
7
+ # docs/Lekhak-UsageGuide.md and fails when the REQ-NFR-042 paragraph is gone. *amend-docs removed that
8
+ # paragraph, ran no test, closed green, and only CI noticed. This script looks for test source files
9
+ # (a path containing "test") that name one of the documents, by file name or by name without ".md".
10
+ # When one does, it runs the unit tests through tf-build.sh test. Prints ONE line:
11
+ # NONE no test reads <docs>; nothing to run exit 0
12
+ # PASS <n> test file(s) read <docs> (<files>); the unit tests pass — <build line> exit 0
13
+ # FAIL <n> test file(s) read <docs> (<files>); the unit tests fail — <build line> exit 1
14
+ # NOT-RUN the tests could not be run — <reason> exit 2
15
+ # A FAIL means the phase is not closed: a test guards text this command changed.
16
+ set -uo pipefail
17
+ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
18
+ source "$HERE/tf-portable.sh" # tf_read_lines, for bash 3.2 on a stock Mac
19
+ DOCS=(); TARGET=""
20
+ while [[ $# -gt 0 ]]; do
21
+ case "$1" in
22
+ --target) TARGET="${2:-}"; shift 2 ;;
23
+ -h|--help) sed -n '2,15p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
24
+ *) DOCS+=("$1"); shift ;;
25
+ esac
26
+ done
27
+ [[ ${#DOCS[@]} -gt 0 ]] || { echo "tf-doc-tests: name at least one document" >&2; exit 2; }
28
+
29
+ READERS="$(python3 - "${DOCS[@]}" <<'PY'
30
+ import os, sys
31
+ names = set()
32
+ for d in sys.argv[1:]:
33
+ b = os.path.basename(d)
34
+ names.add(b)
35
+ stem = os.path.splitext(b)[0]
36
+ if len(stem) >= 6: # "README" alone would match half the code; a real document name is longer
37
+ names.add(stem)
38
+ EXT = (".cs", ".fs", ".vb", ".ts", ".tsx", ".js", ".mjs", ".py", ".java", ".kt", ".go", ".rs", ".rb", ".swift", ".dart", ".php")
39
+ SKIP = {"node_modules", "bin", "obj", ".git", ".tfcore", ".claude", ".opencode", "docs", ".artifacts"}
40
+ hits = []
41
+ for root, dirs, files in os.walk("."):
42
+ dirs[:] = [x for x in dirs if x not in SKIP and not (x.startswith(".") and x != ".")]
43
+ for fn in files:
44
+ p = os.path.join(root, fn)[2:]
45
+ if not fn.endswith(EXT) or "test" not in p.lower():
46
+ continue
47
+ try:
48
+ t = open(p, encoding="utf-8", errors="replace").read()
49
+ except OSError:
50
+ continue
51
+ if any(n in t for n in names):
52
+ hits.append(p)
53
+ print("\n".join(sorted(hits)))
54
+ PY
55
+ )"
56
+ if [[ -z "$READERS" ]]; then
57
+ echo "NONE no test reads ${DOCS[*]}; nothing to run"
58
+ exit 0
59
+ fi
60
+ N="$(wc -l <<<"$READERS" | tr -d ' ')"; LIST="$(tr '\n' ',' <<<"$READERS" | sed 's/,$//; s/,/, /g')"
61
+
62
+ # the same target choice as tf-verify-tests.sh: a root solution, else the only test project under tests/
63
+ ROOTSLN="$(compgen -G '*.sln'; compgen -G '*.slnx'; compgen -G '*.csproj')"
64
+ tf_read_lines TESTPROJ < <(find tests -name '*.csproj' -not -path 'tests/.artifacts/*' -not -path '*/bin/*' -not -path '*/obj/*' -not -path '*/node_modules/*' 2>/dev/null | sort)
65
+ [[ -z "$TARGET" && -z "$ROOTSLN" && ${#TESTPROJ[@]} -eq 1 ]] && TARGET="${TESTPROJ[0]}"
66
+ if [[ -z "$TARGET" && -z "$ROOTSLN" ]]; then
67
+ if [[ ${#TESTPROJ[@]} -gt 1 ]]; then
68
+ echo "NOT-RUN $N test file(s) read ${DOCS[*]} ($LIST), but there is no solution here and ${#TESTPROJ[@]} test projects; name one with --target"
69
+ else
70
+ echo "NOT-RUN $N test file(s) read ${DOCS[*]} ($LIST), but no .NET solution or test project is here; run the project's own unit tests and report them"
71
+ fi
72
+ exit 2
73
+ fi
74
+ LINE="$(bash "$HERE/tf-build.sh" test ${TARGET:+"$TARGET"} 2>&1 | grep -m1 -E '^(PASS|FAIL|NOT-RUN)' || true)"
75
+ case "$LINE" in
76
+ PASS*) echo "PASS $N test file(s) read ${DOCS[*]} ($LIST); the unit tests pass — $LINE"; exit 0 ;;
77
+ FAIL*) echo "FAIL $N test file(s) read ${DOCS[*]} ($LIST); the unit tests fail — $LINE"; exit 1 ;;
78
+ *) echo "NOT-RUN the unit tests could not be run — ${LINE:-tf-build.sh printed no verdict}"; exit 2 ;;
79
+ esac