@techierathore/techieflow 1.0.4 → 1.1.1

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 (148) hide show
  1. package/.claude/commands/TechieFlow/agents/flow-master.md +2 -0
  2. package/.claude/commands/TechieFlow/agents/verifier.md +2 -2
  3. package/.claude/commands/TechieFlow/tasks/_develop-mode.md +34 -0
  4. package/.claude/commands/TechieFlow/tasks/_owner-language.md +1 -0
  5. package/.claude/commands/TechieFlow/tasks/_status-update-gate.md +4 -3
  6. package/.claude/commands/TechieFlow/tasks/_yolo-mode.md +2 -2
  7. package/.claude/commands/TechieFlow/tasks/amend-docs.md +2 -2
  8. package/.claude/commands/TechieFlow/tasks/day1-brownfield.md +3 -3
  9. package/.claude/commands/TechieFlow/tasks/day1-greenfield.md +5 -5
  10. package/.claude/commands/TechieFlow/tasks/develop-end-to-end.md +30 -0
  11. package/.claude/commands/TechieFlow/tasks/fix-issues.md +3 -2
  12. package/.claude/commands/TechieFlow/tasks/generate-html.md +2 -2
  13. package/.claude/commands/TechieFlow/tasks/handoff-phase.md +2 -2
  14. package/.claude/commands/TechieFlow/tasks/mockups.md +2 -2
  15. package/.claude/commands/TechieFlow/tasks/productguide.md +2 -2
  16. package/.claude/commands/TechieFlow/tasks/render-workflow-docs.md +1 -1
  17. package/.claude/commands/TechieFlow/tasks/triage-and-fix.md +2 -2
  18. package/.claude/commands/TechieFlow/tasks/triage-issues.md +2 -2
  19. package/.claude/commands/TechieFlow/tasks/verify-phase.md +12 -11
  20. package/.opencode/plugin/techieflow.js +194 -14
  21. package/.tfcore/agents/flow-master.md +2 -0
  22. package/.tfcore/agents/verifier.md +2 -2
  23. package/.tfcore/core-config.yaml +2 -1
  24. package/.tfcore/hooks/block-git.sh +4 -2
  25. package/.tfcore/hooks/guard-build.sh +5 -1
  26. package/.tfcore/hooks/guard-db.sh +112 -13
  27. package/.tfcore/hooks/guard-status-html.sh +86 -2
  28. package/.tfcore/hooks/guard-verify-deps.sh +16 -1
  29. package/.tfcore/hooks/guard-verify.sh +8 -3
  30. package/.tfcore/hooks/sweep-artifacts.sh +46 -0
  31. package/.tfcore/hooks/yolo-allow.sh +27 -0
  32. package/.tfcore/standards/blazor-zoom.cs.txt +191 -0
  33. package/.tfcore/standards/coding-standards-dotnet.md +31 -3
  34. package/.tfcore/standards/owner-words.txt +23 -0
  35. package/.tfcore/tasks/_develop-mode.md +34 -0
  36. package/.tfcore/tasks/_owner-language.md +1 -0
  37. package/.tfcore/tasks/_status-update-gate.md +4 -3
  38. package/.tfcore/tasks/_yolo-mode.md +2 -2
  39. package/.tfcore/tasks/amend-docs.md +2 -2
  40. package/.tfcore/tasks/day1-brownfield.md +3 -3
  41. package/.tfcore/tasks/day1-greenfield.md +5 -5
  42. package/.tfcore/tasks/develop-end-to-end.md +30 -0
  43. package/.tfcore/tasks/fix-issues.md +3 -2
  44. package/.tfcore/tasks/generate-html.md +2 -2
  45. package/.tfcore/tasks/handoff-phase.md +2 -2
  46. package/.tfcore/tasks/mockups.md +2 -2
  47. package/.tfcore/tasks/productguide.md +2 -2
  48. package/.tfcore/tasks/render-workflow-docs.md +1 -1
  49. package/.tfcore/tasks/triage-and-fix.md +2 -2
  50. package/.tfcore/tasks/triage-issues.md +2 -2
  51. package/.tfcore/tasks/verify-phase.md +12 -11
  52. package/.tfcore/telemetry/SCHEMA.md +10 -2
  53. package/.tfcore/telemetry/install-metrics.sh +3 -2
  54. package/.tfcore/telemetry/tf-metrics.sh +38 -2
  55. package/.tfcore/templates/stack-defaults/dotnet.md +3 -1
  56. package/.tfcore/templates/v4custom/app-agents-md-tmpl.md +6 -0
  57. package/.tfcore/templates/v4custom/app-architecture-tmpl.md +5 -0
  58. package/.tfcore/templates/v4custom/app-brd-tmpl.md +7 -4
  59. package/.tfcore/templates/v4custom/app-checklist-tmpl.md +11 -0
  60. package/.tfcore/templates/v4custom/app-phase-brd-tmpl.md +94 -0
  61. package/.tfcore/templates/v4custom/app-phase-uidesign-tmpl.md +60 -0
  62. package/.tfcore/templates/v4custom/app-phases-tmpl.md +2 -1
  63. package/.tfcore/templates/v4custom/app-uidesign-tmpl.md +4 -2
  64. package/.tfcore/templates/v4custom/build-subagent-prompt.md +2 -2
  65. package/.tfcore/templates/v4custom/html-render-shell.md +6 -2
  66. package/.tfcore/utils/__pycache__/tf-devguide-list.cpython-312.pyc +0 -0
  67. package/.tfcore/utils/__pycache__/tf-doc-check.cpython-312.pyc +0 -0
  68. package/.tfcore/utils/__pycache__/tf-split-brd.cpython-312.pyc +0 -0
  69. package/.tfcore/utils/__pycache__/tf-status-facts.cpython-312.pyc +0 -0
  70. package/.tfcore/utils/__pycache__/tf-verify-list.cpython-312.pyc +0 -0
  71. package/.tfcore/utils/tf-appium.mjs +179 -0
  72. package/.tfcore/utils/tf-assets-browser.mjs +96 -0
  73. package/.tfcore/utils/tf-assets.sh +97 -12
  74. package/.tfcore/utils/tf-brd-status.py +14 -5
  75. package/.tfcore/utils/tf-build-list.py +95 -12
  76. package/.tfcore/utils/tf-build-list.sh +2 -1
  77. package/.tfcore/utils/tf-build.sh +163 -15
  78. package/.tfcore/utils/tf-checklist-edit.py +21 -3
  79. package/.tfcore/utils/tf-ci-repro.py +685 -0
  80. package/.tfcore/utils/tf-ci-repro.sh +24 -0
  81. package/.tfcore/utils/tf-day1-files.py +55 -28
  82. package/.tfcore/utils/tf-develop.sh +264 -0
  83. package/.tfcore/utils/tf-devguide-list.py +53 -24
  84. package/.tfcore/utils/tf-doc-check.py +218 -26
  85. package/.tfcore/utils/tf-doc-tests.sh +116 -0
  86. package/.tfcore/utils/tf-emit.sh +132 -45
  87. package/.tfcore/utils/tf-feedback.sh +13 -0
  88. package/.tfcore/utils/tf-fix-close.sh +87 -31
  89. package/.tfcore/utils/tf-goal.sh +12 -12
  90. package/.tfcore/utils/tf-harness.sh +9 -4
  91. package/.tfcore/utils/tf-lock.sh +29 -0
  92. package/.tfcore/utils/tf-log-miss.py +34 -8
  93. package/.tfcore/utils/tf-login.mjs +167 -0
  94. package/.tfcore/utils/tf-maccatalyst-check.sh +92 -0
  95. package/.tfcore/utils/tf-menus.mjs +401 -0
  96. package/.tfcore/utils/tf-misses-md.py +10 -4
  97. package/.tfcore/utils/tf-mockup-parity.mjs +763 -71
  98. package/.tfcore/utils/tf-mockup-parity.sh +25 -2
  99. package/.tfcore/utils/tf-owner-text.py +233 -0
  100. package/.tfcore/utils/tf-owner-text.sh +14 -0
  101. package/.tfcore/utils/tf-phase.sh +38 -4
  102. package/.tfcore/utils/tf-portable.sh +230 -0
  103. package/.tfcore/utils/tf-render-html.py +79 -5
  104. package/.tfcore/utils/tf-req-ids.py +36 -0
  105. package/.tfcore/utils/tf-selfcheck.sh +39 -6
  106. package/.tfcore/utils/tf-split-brd.py +11 -1
  107. package/.tfcore/utils/tf-stack-check.sh +3 -0
  108. package/.tfcore/utils/tf-status-evidence.sh +11 -9
  109. package/.tfcore/utils/tf-status-facts.py +160 -45
  110. package/.tfcore/utils/tf-theme.mjs +189 -0
  111. package/.tfcore/utils/tf-triage.py +74 -17
  112. package/.tfcore/utils/tf-triage.sh +2 -0
  113. package/.tfcore/utils/tf-verify-boot.sh +529 -54
  114. package/.tfcore/utils/tf-verify-emit.sh +57 -17
  115. package/.tfcore/utils/tf-verify-env.sh +33 -4
  116. package/.tfcore/utils/tf-verify-list.py +290 -24
  117. package/.tfcore/utils/tf-verify-list.sh +3 -1
  118. package/.tfcore/utils/tf-verify-native.mjs +307 -0
  119. package/.tfcore/utils/tf-verify-screens.mjs +341 -44
  120. package/.tfcore/utils/tf-verify-screens.sh +23 -5
  121. package/.tfcore/utils/tf-verify-tests.sh +310 -19
  122. package/.tfcore/utils/tf-verify-verdict.py +163 -21
  123. package/.tfcore/utils/tf-verify-zoom.mjs +193 -0
  124. package/.tfcore/utils/tf-verify-zoom.sh +29 -0
  125. package/.tfcore/utils/tf-yolo-alias.sh +84 -0
  126. package/.tfcore/utils/tf-yolo-perms.py +135 -0
  127. package/.tfcore/utils/tf-yolo-start.sh +44 -0
  128. package/.tfcore/utils/tf-yolo.sh +31 -3
  129. package/.tfcore/utils/tf_defect.py +27 -0
  130. package/.tfcore/utils/tf_feedback.py +272 -0
  131. package/.tfcore/utils/tf_roadmap.py +35 -0
  132. package/.tfcore/utils/tf_stack_check.py +93 -0
  133. package/README.md +6 -2
  134. package/docs/TechieFlow-Installation.md +5 -3
  135. package/opencode.jsonc +6 -2
  136. package/package.json +1 -1
  137. package/scripts/install.mjs +13 -2
  138. package/.tfcore/utils/__pycache__/tf-brd-status.cpython-312.pyc +0 -0
  139. package/.tfcore/utils/__pycache__/tf-codex-bind.cpython-312.pyc +0 -0
  140. package/.tfcore/utils/__pycache__/tf-codex-telemetry.cpython-312.pyc +0 -0
  141. package/.tfcore/utils/__pycache__/tf-day1-files.cpython-312.pyc +0 -0
  142. package/.tfcore/utils/__pycache__/tf-log-miss.cpython-312.pyc +0 -0
  143. package/.tfcore/utils/__pycache__/tf-misses-md.cpython-312.pyc +0 -0
  144. package/.tfcore/utils/__pycache__/tf-mockups-locate.cpython-312.pyc +0 -0
  145. package/.tfcore/utils/__pycache__/tf-opencode-audit.cpython-312.pyc +0 -0
  146. package/.tfcore/utils/__pycache__/tf-render-html.cpython-312.pyc +0 -0
  147. package/.tfcore/utils/__pycache__/tf-triage.cpython-312.pyc +0 -0
  148. package/.tfcore/utils/__pycache__/tf-verify-verdict.cpython-312.pyc +0 -0
@@ -70,12 +70,15 @@ DOC_NAME = re.compile(
70
70
  r"^(.+?)(?:-P(\d+))?-(BRD|Deployment-Checklist(?:-[\w]+)?|Checklist|UIDesign|DevGuide|Architecture|Coding-Standards|UsageGuide|Usage-Guide|ProductGuide|Phases)\.md$",
71
71
  re.I)
72
72
  PHASE_FORM = re.compile(r"^(\d+)\s+of\s+(\d+)$", re.I)
73
- PHASE_STATUS = {"planned", "building", "done"}
73
+ # kept-whole: a phase finished before the size cap that the owner chose to keep as one document;
74
+ # its cap and word maximum are reported as WARN, not FAIL (Lekhak TF-029)
75
+ PHASE_STATUS = {"planned", "building", "done", "kept-whole"}
74
76
  BRD_RANGE = re.compile(r"(?:BRD-)?(\d+)\s*(?:to|-|–|—)\s*(?:BRD-)?(\d+)", re.I)
75
77
 
76
78
  SIZE_NAMES = {"s": "S", "small": "S", "m": "M", "medium": "M", "l": "L", "large": "L"}
77
79
  SIZE_LONG = {"S": "Small", "M": "Medium", "L": "Large"}
78
80
  REQ_CAP = {"S": 50, "M": 100, "L": 100}
81
+ SCREEN_CAP = {"S": 10, "M": 20, "L": 20} # Large: per phase
79
82
 
80
83
  CHECKLIST_HEADER = "| ID | Requirement | Status | % | Remarks | Details |"
81
84
  STATUS_VALUES = {
@@ -95,9 +98,10 @@ NAMES_SCREEN = re.compile(r"\b(on|opens?|from|in)\b", re.I)
95
98
 
96
99
 
97
100
  def acceptance_words(line: str) -> int:
98
- """Words in an acceptance line, without the "Acceptance:" label and without a perf-budget tail."""
101
+ """Words in an acceptance line, without the "Acceptance:" label and without a perf-budget or
102
+ zoom-check tail (the markers the verifier reads)."""
99
103
  t = re.sub(r"^\s*[-*]\s*\*?Acceptance:?\*?:?\s*", "", line)
100
- t = re.split(r"perf-budget:", t, flags=re.I)[0]
104
+ t = re.split(r"perf-budget:|zoom-check:", t, flags=re.I)[0]
101
105
  return len(re.findall(r"\S+", t))
102
106
 
103
107
 
@@ -301,6 +305,12 @@ def detect_kind(path: str):
301
305
  name = os.path.basename(path).lower()
302
306
  if re.search(r"-deployment-checklist(-[\w]+)?\.md$", name):
303
307
  return "deployment-checklist", "app-deployment-checklist-tmpl.md"
308
+ # phase 2 onward of a Large project: still a BRD to every cross-check, but it carries only
309
+ # its own screens and requirements and points back at phase 1 for the rest (TF-024)
310
+ if re.search(r"-p\d+-brd\.md$", name):
311
+ return "brd", "app-phase-brd-tmpl.md"
312
+ if re.search(r"-p\d+-uidesign\.md$", name):
313
+ return "uidesign", "app-phase-uidesign-tmpl.md"
304
314
  for suffix, doc, tmpl in DOC_KINDS:
305
315
  if name.endswith(suffix):
306
316
  return doc, tmpl
@@ -324,7 +334,7 @@ def read_core_config(root: str) -> dict:
324
334
  if os.path.exists(p):
325
335
  with open(p, encoding="utf-8") as fh:
326
336
  for line in fh:
327
- m = re.match(r"^(appSize|appKind|appPhase):\s*(\S+)", line)
337
+ m = re.match(r"^(appSize|appKind|appPhase):\s*(\S+)", line) or re.match(r"^\s+(project_type):\s*(\S+)", line)
328
338
  if m and m.group(2) not in ("null", "~", "''", '""'):
329
339
  out[m.group(1)] = m.group(2).strip("'\"")
330
340
  return out
@@ -338,6 +348,35 @@ def split_name(path: str):
338
348
  return m.group(1), int(m.group(2) or 1)
339
349
 
340
350
 
351
+ def foreign_prefix(path: str):
352
+ """The name's app when it is not an app of its folder: docs/AppManager-api-usage-guide.md beside
353
+ docs/AppManager-BRD.md is an API reference, not a UsageGuide (AppManager TF-024). A folder
354
+ holding no BRD or checklist anchors nothing, so a lone document is checked by its name."""
355
+ app, _ = split_name(path)
356
+ folder = os.path.dirname(os.path.abspath(path))
357
+ apps = {m.group(1).lower() for m in (re.match(r"(.+?)-(BRD|Checklist)\.md$", f, re.I) for f in os.listdir(folder))
358
+ if m and not re.search(r"-P\d+$|-Deployment$", m.group(1), re.I)}
359
+ return app if app and apps and app.lower() not in apps else None
360
+
361
+
362
+ def phase_status(path: str, app: str, phase: int):
363
+ """The Status cell of this phase's row in docs/{App}-Phases.md beside it, lower-cased; None when
364
+ there is no Phases document or no row for the phase."""
365
+ p = os.path.join(os.path.dirname(path), f"{app}-Phases.md")
366
+ if not app or not os.path.isfile(p):
367
+ return None
368
+ with open(p, encoding="utf-8") as fh:
369
+ txt = strip_noise(fh.read())
370
+ for header, rows in tables_in(txt):
371
+ low = [h.lower() for h in header]
372
+ if "phase" in low and "status" in low:
373
+ ip, ist = low.index("phase"), low.index("status")
374
+ for r in rows:
375
+ if len(r) > max(ip, ist) and r[ip].strip("`* ") == str(phase):
376
+ return r[ist].strip("`* ").lower()
377
+ return None
378
+
379
+
341
380
  def phase_file(app: str, phase: int, suffix: str) -> str:
342
381
  return f"{app}-{suffix}.md" if phase <= 1 else f"{app}-P{phase}-{suffix}.md"
343
382
 
@@ -386,6 +425,10 @@ def check_document(path: str, rep: Report, cli_size=None, root=None):
386
425
  if not doc:
387
426
  rep.warn(path, "not a TechieFlow document name; skipped")
388
427
  return None
428
+ foreign = foreign_prefix(path)
429
+ if foreign:
430
+ 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')
431
+ return None
389
432
  try:
390
433
  schema = load_schema(tmpl)
391
434
  except (OSError, ValueError) as e:
@@ -428,6 +471,10 @@ def check_document(path: str, rep: Report, cli_size=None, root=None):
428
471
  rep.fail(rel, f'a phase-{phase} file needs the header row "Phase | {phase} of <m>"')
429
472
  elif os.path.exists(phases_doc):
430
473
  rep.fail(rel, f'the project has phases ({os.path.relpath(phases_doc, root)}); this file needs the header row "Phase | 1 of <m>"')
474
+ # a phase the owner kept whole past the cap: its size findings are WARN, never a gate failure (Lekhak TF-029)
475
+ kept = doc in PHASED_DOCS and bool(app) and phase_status(path, app, phase) == "kept-whole"
476
+ size_finding = rep.warn if kept else rep.fail
477
+ kept_why = f"; phase {phase} is kept-whole in {app}-Phases.md, so this is reported, not blocking" if kept else ""
431
478
 
432
479
  # 2. sections: strangers, order, presence
433
480
  present = [(norm_heading(h), h, txt) for h, txt in split_sections(clean, 2) if h is not None]
@@ -471,10 +518,18 @@ def check_document(path: str, rep: Report, cli_size=None, root=None):
471
518
  total = word_count(body)
472
519
  if size in schema.budget:
473
520
  target, mx = schema.budget[size]
521
+ # The budget holds the size's screen cap of entries. Each entry past it brings its own
522
+ # per-entry target and maximum, which already bound it (AppManager TF-023: 54 screens).
523
+ extra, why = 0, ""
524
+ if schema.entries and schema.per_entry:
525
+ sec = section_text(present, schema.entries[0]) or ""
526
+ extra = max(0, sum(1 for h, _t in split_sections(sec, 3) if h) - SCREEN_CAP[size])
527
+ target, mx = target + extra * schema.per_entry[0], mx + extra * schema.per_entry[1]
528
+ why = f", with {extra} entries past the cap of {SCREEN_CAP[size]}" if extra else ""
474
529
  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")
530
+ size_finding(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{kept_why}")
476
531
  elif total > target:
477
- rep.warn(rel, f"{total:,} words; the {SIZE_LONG[size]} target is {target:,} (maximum {mx:,})")
532
+ rep.warn(rel, f"{total:,} words; the {SIZE_LONG[size]} target is {target:,} (maximum {mx:,}{why})")
478
533
  for key, h, txt in present:
479
534
  d = schema.declared(key)
480
535
  if d and d[2]:
@@ -534,7 +589,7 @@ def check_document(path: str, rep: Report, cli_size=None, root=None):
534
589
  # 5. document rules
535
590
  ctx = dict(doc=doc, body=body, clean=clean, nocomment=nocomment, present=present, header=header,
536
591
  size=size, kind=kind, root=root, path=path, rel=rel, entry_names=entry_names,
537
- app=app, phase=phase)
592
+ app=app, phase=phase, size_finding=size_finding, kept_why=kept_why)
538
593
  for rule in schema.rules:
539
594
  if not rule.startswith("entry-"):
540
595
  check_doc_rule(rule, ctx, rep)
@@ -623,7 +678,7 @@ def check_doc_rule(rule, c, rep):
623
678
  if dupes:
624
679
  rep.fail(rel, "duplicate requirement ids: " + ", ".join(f"BRD-{d}" for d in sorted(dupes, key=int)))
625
680
  if len(seen) > REQ_CAP[size]:
626
- rep.fail(rel, f"{len(seen)} requirements; the {SIZE_LONG[size]} cap is {REQ_CAP[size]}. Split into phases (each phase its own BRD) instead of growing this one")
681
+ c["size_finding"](rel, f"{len(seen)} requirements; the {SIZE_LONG[size]} cap is {REQ_CAP[size]}. Split into phases (each phase its own BRD) instead of growing this one{c['kept_why']}")
627
682
  c["brd_ids"] = seen
628
683
  req = section_text(present, "Requirements") or ""
629
684
  cur = None
@@ -638,6 +693,22 @@ def check_doc_rule(rule, c, rep):
638
693
  elif n_acc > ACC_TARGET:
639
694
  rep.warn(rel, f"BRD-{cur} acceptance line is {n_acc} words; the target is {ACC_TARGET} (maximum {ACC_MAX})")
640
695
 
696
+ elif rule == "phase-pointer":
697
+ # a phase BRD leaves scope, users, the whole-app non-functionals, constraints and risks to
698
+ # phase 1, and a phase UIDesign the design system and the click-through flow; this section is
699
+ # how a reader finds them, so it must actually lead to the phase-1 document of the same kind
700
+ txt = section_text(present, "Where the rest lives")
701
+ if txt is None or not c.get("app"):
702
+ return
703
+ want = phase_file(c["app"], 1, PHASED_SUFFIX[c["doc"]])
704
+ hits = [l for l in re.findall(r"\]\(([^)\s]+)\)", txt)
705
+ if os.path.basename(l.split("#", 1)[0]).lower() == want.lower()]
706
+ if not hits:
707
+ rep.fail(rel, f'the "Where the rest lives" section does not link to the phase-1 document ({want}), '
708
+ f'where everything the whole application shares lives')
709
+ elif not any(resolve(root, path, l) for l in hits):
710
+ rep.fail(rel, f'the "Where the rest lives" link to {hits[0]} points at a file that does not exist')
711
+
641
712
  elif rule == "mockup-links":
642
713
  for l in sorted(links_to(clean, "mockups")):
643
714
  if not resolve(root, path, l):
@@ -662,6 +733,7 @@ def check_doc_rule(rule, c, rep):
662
733
  continue
663
734
  names.append(r[sc])
664
735
  c["brd_screens"] = names
736
+ check_named_screens(c, rep)
665
737
 
666
738
  elif rule == "stack-table":
667
739
  txt = section_text(present, "Stack decisions")
@@ -863,6 +935,26 @@ def check_doc_rule(rule, c, rep):
863
935
  if not re.search(r"(?i)\bblocking\b|\bnothing is blocked\b", txt):
864
936
  rep.fail(rel, 'the Summary must say how many entries are blocking right now, '
865
937
  'or "Nothing is blocked" — it is the first thing the owner reads')
938
+ # ... and its counts must be the entries' own. TfLens's read "20 entries, 8 open" over 27
939
+ # entries with 7 fixed upstream, and a TrBlazeUI file "all 28 open, none fixed upstream"
940
+ # under the library's own reply fixing 24 of them (MISS-TechieFlow-20260911-04).
941
+ sys.path.insert(0, SELF_DIR)
942
+ import tf_feedback
943
+ es = tf_feedback.entries(path)
944
+ n = {s: [e["id"] for e in es if e["state"] == s] for s in ("open", "fixed", "closed")}
945
+ truth = (f"{len(es)} entries: {len(n['open'])} open, {len(n['fixed'])} fixed upstream and not yet "
946
+ f"re-checked, {len(n['closed'])} closed")
947
+ head = re.split(r"(?m)^####\s", txt)[0]
948
+ said_total = re.search(r"(?i)\b(\d+)\s+entries\b", head)
949
+ said_open = re.search(r"(?i)\b(?:all\s+)?(\d+)\s+(?:filed and\s+)?open\b", head)
950
+ wrong = ((said_total and int(said_total.group(1)) != len(es))
951
+ or (said_open and int(said_open.group(1)) != len(n["open"])))
952
+ if wrong:
953
+ rep.fail(rel, f"the Summary's counts are not the entries' — the file holds {truth}; "
954
+ f"rewrite it from: bash .tfcore/utils/tf-feedback.sh {os.path.basename(path).split('-')[0]}")
955
+ elif n["fixed"] and not re.search(r"(?i)fixed upstream|re-?check", head):
956
+ rep.fail(rel, f"{len(n['fixed'])} entries are fixed upstream and not yet re-checked here "
957
+ f"({', '.join(n['fixed'][:5])}{' …' if len(n['fixed']) > 5 else ''}); the Summary must say so")
866
958
 
867
959
  elif rule == "paste-back-block":
868
960
  # `present` is built from the prose-only copy, which drops fenced blocks — and a
@@ -919,7 +1011,7 @@ def check_doc_rule(rule, c, rep):
919
1011
  rep.fail(rel, f"phase {n} BRD range runs backwards ({x} to {y})")
920
1012
  st = r[ist].strip("`* ").lower()
921
1013
  if st not in PHASE_STATUS:
922
- rep.fail(rel, f'phase {n} status "{r[ist]}" must be planned, building or done')
1014
+ rep.fail(rel, f'phase {n} status "{r[ist]}" must be planned, building, done or kept-whole')
923
1015
  rows_out.append(dict(n=n, name=r[iname].strip("`* "), screens=screens, lo=lo, hi=hi, ranges=ranges, status=st))
924
1016
  nums = [p["n"] for p in rows_out]
925
1017
  if nums != list(range(1, len(nums) + 1)):
@@ -966,15 +1058,25 @@ def check_checklist(c, rep):
966
1058
  row_status[rid] = status.strip("`* ").lower()
967
1059
  if not re.fullmatch(r"REQ-(UI|FN|RAG|NFR)-\d{3}", rid):
968
1060
  rep.fail(rel, f'id "{rid}" is not REQ-UI/FN/RAG/NFR- plus three digits')
1061
+ removed = " A removed row reads N/A with % 0, and the date goes in Remarks"
969
1062
  if status.strip("`* ").lower() not in STATUS_VALUES:
970
- rep.fail(rel, f'{rid} status "{status}" is not one of the fixed values')
1063
+ rep.fail(rel, f'{rid} status "{status}" is not one of the fixed values.'
1064
+ + (removed if status.strip("`* ").lower().startswith("n/a") else ""))
971
1065
  if pct.strip("`* ").rstrip("%").strip() not in ("0", "25", "50", "75", "100"):
972
- rep.fail(rel, f'{rid} % "{pct}" must be 0, 25, 50, 75 or 100')
1066
+ rep.fail(rel, f'{rid} % "{pct}" must be 0, 25, 50, 75 or 100.' + removed)
973
1067
  n = len(re.findall(r"\S+", remarks))
974
1068
  if n > 60:
975
1069
  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):
1070
+ # text in backticks is quoted — a test's title or message the verdict script copied — not an agent's
1071
+ # claim about a file; an unclosed span (a Remark cut at its word cap) counts as quoted to its end
1072
+ # (Lekhak TF-012)
1073
+ said = re.sub(r"`[^`]*(?:`|$)", " ", remarks)
1074
+ if NOT_PRESENT.search(said) and not NAMES_PATH.search(remarks):
977
1075
  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")
1076
+ if row_status[rid] == "n/a":
1077
+ # a removed or reserved row is kept for traceability; it has no screen, mockup or BRD item to
1078
+ # point at, so the detail-entry rules do not apply to it (Lekhak TF-028)
1079
+ continue
978
1080
  m = re.search(r"\(#([^)]+)\)", details)
979
1081
  if not m:
980
1082
  rep.fail(rel, f"{rid} Details cell has no link to its detail entry")
@@ -983,7 +1085,7 @@ def check_checklist(c, rep):
983
1085
  if not ids:
984
1086
  rep.fail(rel, "the Requirements Status table has no REQ- rows")
985
1087
  if len(ids) > REQ_CAP[size]:
986
- rep.fail(rel, f"{len(ids)} rows; the {SIZE_LONG[size]} cap is {REQ_CAP[size]}. Split into phases instead of growing this checklist")
1088
+ c["size_finding"](rel, f"{len(ids)} rows; the {SIZE_LONG[size]} cap is {REQ_CAP[size]}. Split into phases instead of growing this checklist{c['kept_why']}")
987
1089
  dupes = sorted({i for i in ids if ids.count(i) > 1})
988
1090
  if dupes:
989
1091
  rep.fail(rel, "duplicate rows: " + ", ".join(dupes))
@@ -996,8 +1098,18 @@ def check_checklist(c, rep):
996
1098
  if not m:
997
1099
  continue # reported above through the Details link
998
1100
  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:]
1101
+ # A "### Page: …" heading written straight under the entry's own anchor belongs to the entry.
1102
+ # Stopping at the first "### " cut such an entry off before its content: three false failures
1103
+ # each, and 24 AppManager entries never really checked (TF-002). The next one still ends it.
1104
+ start = pos + 1
1105
+ own = re.match(r"<a id=[^>]*>\s*</a>[^\n]*\n(?:[ \t]*\n)*[ \t]*### [^\n]*", body[pos:])
1106
+ if own:
1107
+ start = pos + own.end()
1108
+ 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)
1109
+ entry = body[pos: start + nxt.start()] if nxt else body[pos:]
1110
+ if row_status.get(rid) == "n/a":
1111
+ brd_refs.update(re.findall(r"BRD-(\d+)", entry)) # a retired BRD item still has its row
1112
+ continue
1001
1113
  acc = [l for l in entry.splitlines() if ACCEPT_LINE.search(l)]
1002
1114
  if len(acc) != 1:
1003
1115
  rep.fail(rel, f"{rid} must have exactly one acceptance line; found {len(acc)}")
@@ -1022,7 +1134,10 @@ def check_checklist(c, rep):
1022
1134
  brd_refs.update(refs)
1023
1135
  if rid.startswith("REQ-UI"):
1024
1136
  links = links_to(entry, "mockups")
1025
- if not links:
1137
+ # a library or docs project (metrics.project_type, or kind: library) has no screens and no
1138
+ # docs/mockups/: its UI rows map to components, so a missing link is not a finding there; a
1139
+ # link it does carry must still resolve (TrBlazeUI TF-003)
1140
+ if not links and c.get("kind") == "app" and read_core_config(root).get("project_type", "app").lower() not in ("library", "docs"):
1026
1141
  rep.fail(rel, f"{rid} is a UI row without a mockup link")
1027
1142
  for l in sorted(links):
1028
1143
  if not resolve(root, path, l):
@@ -1039,6 +1154,48 @@ def _screen_key(s):
1039
1154
  return re.sub(r"\s+", " ", s).strip().lower()
1040
1155
 
1041
1156
 
1157
+ # A requirement names its screen in the template's "*Screen:* <name>" field, or in running text as
1158
+ # "**<name>** screen". Either must have a row in some phase's Screens and flow table: the row is what
1159
+ # brings the UI design entry and the mockup after it (TF-030).
1160
+ SCREEN_FIELD = re.compile(r"\*{1,2}Screen:?\*{1,2}:?\s*([^·*|\n]+)")
1161
+ SCREEN_BOLD = re.compile(r"\*\*([^*\n]{2,60})\*\*[ -]screens?\b", re.I)
1162
+ NOT_A_NAME = {"every", "any", "each", "one", "this", "that", "the", "a", "new", "same", "other", "whole", "single", "no"}
1163
+
1164
+
1165
+ def _table_screen_keys(txt):
1166
+ """Every name in the Screen column of a Screens and flow section, dialogs included."""
1167
+ out = set()
1168
+ for header, rows in tables_in(txt or ""):
1169
+ low = [h.lower() for h in header]
1170
+ if "screen" in low and "route" in low:
1171
+ sc = low.index("screen")
1172
+ out |= {_screen_key(r[sc]) for r in rows if len(r) > sc and r[sc] and not r[sc].startswith("{")}
1173
+ return out
1174
+
1175
+
1176
+ def check_named_screens(c, rep):
1177
+ known = _table_screen_keys(section_text(c["present"], "Screens and flow"))
1178
+ if c.get("app"): # a phase BRD may name a screen another phase's BRD owns
1179
+ for f in sorted(os.listdir(os.path.dirname(c["path"]))):
1180
+ if f != os.path.basename(c["path"]) and re.match(rf"^{re.escape(c['app'])}-(P\d+-)?BRD\.md$", f):
1181
+ with open(os.path.join(os.path.dirname(c["path"]), f), encoding="utf-8") as fh:
1182
+ other = [(norm_heading(h), h, t) for h, t in split_sections(strip_noise(fh.read()), 2) if h is not None]
1183
+ known |= _table_screen_keys(section_text(other, "Screens and flow"))
1184
+ cur, told = None, set()
1185
+ for line in (section_text(c["present"], "Requirements") or "").splitlines():
1186
+ m = re.search(r"\*\*BRD-(\d+)\*\*", line)
1187
+ if m:
1188
+ cur = m.group(1)
1189
+ for name in [x.group(1) for x in SCREEN_FIELD.finditer(line)] + [x.group(1) for x in SCREEN_BOLD.finditer(line)]:
1190
+ key = _screen_key(name)
1191
+ if not key or name.strip().startswith("{") or key in NOT_A_NAME or key in known or key in told:
1192
+ continue
1193
+ told.add(key)
1194
+ where = f"BRD-{cur}" if cur else "a requirement"
1195
+ rep.fail(c["rel"], f'{where} names the screen "{name.strip()}", which has no row in any Screens and flow table; '
1196
+ f'add the row, then its UI design entry and its mockup, before it is built')
1197
+
1198
+
1042
1199
  def cross_checks(ctxs: dict, rep: Report, root: str):
1043
1200
  """ctxs is keyed by (doc, phase). Single documents sit at phase 1."""
1044
1201
  phases = sorted({p for (_d, p) in ctxs})
@@ -1092,9 +1249,19 @@ def cross_phase_checks(ctxs, rep, root, phases):
1092
1249
  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
1250
  seen.setdefault(i, p)
1094
1251
 
1252
+ if not ph and app:
1253
+ # It may simply not have been named on the command line. Read it from disk for this rule and
1254
+ # throw its own findings away, since they belong to a run that names it: saying "the Phases
1255
+ # document does not exist" about a file that is there invites writing over it (TF-041).
1256
+ disk = os.path.join(root, "docs", f"{app}-Phases.md")
1257
+ if os.path.isfile(disk):
1258
+ try:
1259
+ ph = check_document(disk, Report(False), None, root)
1260
+ except Exception:
1261
+ ph = None
1095
1262
  if not ph:
1096
1263
  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")
1264
+ 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
1265
  return
1099
1266
  rows = ph.get("phases") or []
1100
1267
  if not rows:
@@ -1145,14 +1312,18 @@ NAV_ITEM = re.compile(r"""<(div|li|span|button)\b[^>]*\bclass\s*=\s*["'][^"']*(?
1145
1312
 
1146
1313
 
1147
1314
  def _mock_target(mock_dir: str, t: str):
1148
- """Resolve a link the way a browser opening docs/mockups/<file> would. Returns the file
1149
- name when it lands on an existing file inside the mockup folder, else None."""
1315
+ """Resolve a link the way a browser opening docs/mockups/<file> would. Returns the path
1316
+ relative to the mockup folder when it lands on an existing file inside it, at any depth
1317
+ (Lekhak TF-008: index.html linking to admin/connection-settings.html opens, and was
1318
+ failed), else None. A leading slash, or a path that leaves docs/mockups/, is still None."""
1150
1319
  if re.match(r"^(https?:|mailto:|tel:|javascript:|data:)", t, re.I) or t.startswith("/"):
1151
1320
  return None
1321
+ t = re.split(r"[?#]", t, maxsplit=1)[0]
1322
+ base = os.path.normpath(mock_dir)
1152
1323
  p = os.path.normpath(os.path.join(mock_dir, t))
1153
- if os.path.dirname(p) != os.path.normpath(mock_dir) or not os.path.isfile(p):
1324
+ if not (p == base or p.startswith(base + os.sep)) or not os.path.isfile(p):
1154
1325
  return None
1155
- return os.path.basename(p)
1326
+ return os.path.relpath(p, base).replace(os.sep, "/")
1156
1327
 
1157
1328
 
1158
1329
  def check_mockups(root: str, rep: Report):
@@ -1185,7 +1356,8 @@ def check_mockups(root: str, rep: Report):
1185
1356
  continue
1186
1357
  name = _mock_target(mock_dir, t)
1187
1358
  if name is None:
1188
- hint = "write the file name alone, not a folder or a leading slash" if ("/" in t) else "the file does not exist"
1359
+ hint = ("a leading slash opens from the disk's root; write the path from docs/mockups/" if t.startswith("/")
1360
+ else "the file does not exist inside docs/mockups/")
1189
1361
  rep.fail(rel, f'links to "{t}", which does not open from docs/mockups/ ({hint})')
1190
1362
  else:
1191
1363
  targets.append(name)
@@ -1290,6 +1462,7 @@ def main(argv=None):
1290
1462
  if a.app:
1291
1463
  root = root or os.getcwd()
1292
1464
  files += app_files(root, a.app)
1465
+ files = list(dict.fromkeys(os.path.abspath(f) for f in files)) # a file given and listed by --app once
1293
1466
  if not files:
1294
1467
  print(f"tf-doc-check: no documents for app {a.app} under {root}", file=sys.stderr)
1295
1468
  return 2
@@ -1322,9 +1495,16 @@ def main(argv=None):
1322
1495
  base = json.load(fh) or {}
1323
1496
  except Exception:
1324
1497
  base = {}
1325
- for f in files:
1326
- r = os.path.relpath(f, root)
1327
- base[r] = sorted({l.split(": ", 1)[1] for l in rep.lines if l.startswith("FAIL ") and l.split(": ", 1)[0].endswith(" " + r)})
1498
+ # every finding, under whatever it names: the mockup-folder and cross-document checks name
1499
+ # docs/mockups/index.html or another document, not one of the files given, and were never
1500
+ # recorded, so ~60 old findings read as new on every *amend-docs (Lekhak TF-023)
1501
+ new = {os.path.relpath(f, root): set() for f in files}
1502
+ for l in rep.lines:
1503
+ if l.startswith("FAIL ") and ": " in l:
1504
+ head, msg = l.split(": ", 1)
1505
+ new.setdefault(head[len("FAIL "):], set()).add(msg)
1506
+ for r, msgs in new.items():
1507
+ base[r] = sorted(msgs)
1328
1508
  os.makedirs(os.path.dirname(base_path), exist_ok=True)
1329
1509
  with open(base_path, "w", encoding="utf-8") as fh:
1330
1510
  json.dump(base, fh, indent=1)
@@ -1338,11 +1518,23 @@ def main(argv=None):
1338
1518
  with open(base_path, encoding="utf-8") as fh:
1339
1519
  base = json.load(fh) or {}
1340
1520
  relined = []
1521
+ # A size finding carries its count ("20,677 words; the Small maximum is 8,000"), so any
1522
+ # edit to an over-limit document, a cut included, changed the text and read as new
1523
+ # (Lekhak TF-023). The same finding with a count no larger than the old one is old;
1524
+ # a document that grew past its maximum still FAILs.
1525
+ def _shape(m):
1526
+ return re.sub(r"\d[\d,]*", "#", m)
1527
+ def _first(m):
1528
+ n = re.search(r"\d[\d,]*", m)
1529
+ return int(n.group(0).replace(",", "")) if n else None
1341
1530
  for l in rep.lines:
1342
1531
  if l.startswith("FAIL "):
1343
1532
  head, msg = l.split(": ", 1)
1344
1533
  r = head[len("FAIL "):]
1345
- if msg in set(base.get(r, [])):
1534
+ was = base.get(r, [])
1535
+ same = msg in set(was) or (re.match(r"^\d[\d,]* (words|lines|rows|requirements|KB|screens)", msg) and any(
1536
+ _shape(o) == _shape(msg) and _first(o) is not None and _first(msg) <= _first(o) for o in was))
1537
+ if same:
1346
1538
  relined.append("OLD " + l[5:])
1347
1539
  rep.fails -= 1
1348
1540
  old += 1
@@ -0,0 +1,116 @@
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 test projects holding those files, each on its own through tf-build.sh
11
+ # test (TechieRag TF-004), or the solution's tests one project at a time. Prints ONE line:
12
+ # NONE no test reads <docs>; nothing to run exit 0
13
+ # PASS <n> test file(s) read <docs> (<files>); the unit tests pass — <build line> exit 0
14
+ # FAIL <n> test file(s) read <docs> (<files>); the unit tests fail — <build line> exit 1
15
+ # NOT-RUN the tests could not be run — <reason> exit 2
16
+ # A FAIL means the phase is not closed: a test guards text this command changed.
17
+ set -uo pipefail
18
+ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
19
+ source "$HERE/tf-portable.sh" # tf_read_lines, for bash 3.2 on a stock Mac
20
+ DOCS=(); TARGET=""
21
+ while [[ $# -gt 0 ]]; do
22
+ case "$1" in
23
+ --target) TARGET="${2:-}"; shift 2 ;;
24
+ -h|--help) sed -n '2,16p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
25
+ *) DOCS+=("$1"); shift ;;
26
+ esac
27
+ done
28
+ [[ ${#DOCS[@]} -gt 0 ]] || { echo "tf-doc-tests: name at least one document" >&2; exit 2; }
29
+
30
+ READERS="$(python3 - "${DOCS[@]}" <<'PY'
31
+ import os, sys
32
+ names = set()
33
+ for d in sys.argv[1:]:
34
+ b = os.path.basename(d)
35
+ names.add(b)
36
+ stem = os.path.splitext(b)[0]
37
+ if len(stem) >= 6: # "README" alone would match half the code; a real document name is longer
38
+ names.add(stem)
39
+ EXT = (".cs", ".fs", ".vb", ".ts", ".tsx", ".js", ".mjs", ".py", ".java", ".kt", ".go", ".rs", ".rb", ".swift", ".dart", ".php")
40
+ SKIP = {"node_modules", "bin", "obj", ".git", ".tfcore", ".claude", ".opencode", "docs", ".artifacts"}
41
+ hits = []
42
+ for root, dirs, files in os.walk("."):
43
+ dirs[:] = [x for x in dirs if x not in SKIP and not (x.startswith(".") and x != ".")]
44
+ for fn in files:
45
+ p = os.path.join(root, fn)[2:]
46
+ if not fn.endswith(EXT) or "test" not in p.lower():
47
+ continue
48
+ try:
49
+ t = open(p, encoding="utf-8", errors="replace").read()
50
+ except OSError:
51
+ continue
52
+ if any(n in t for n in names):
53
+ hits.append(p)
54
+ print("\n".join(sorted(hits)))
55
+ PY
56
+ )"
57
+ if [[ -z "$READERS" ]]; then
58
+ echo "NONE no test reads ${DOCS[*]}; nothing to run"
59
+ exit 0
60
+ fi
61
+ N="$(wc -l <<<"$READERS" | tr -d ' ')"; LIST="$(tr '\n' ',' <<<"$READERS" | sed 's/,$//; s/,/, /g')"
62
+
63
+ # the same target choice as tf-verify-tests.sh: a root solution, else the only test project under tests/
64
+ ROOTSLN="$(compgen -G '*.sln'; compgen -G '*.slnx'; compgen -G '*.csproj')"
65
+ 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)
66
+ [[ -z "$TARGET" && -z "$ROOTSLN" && ${#TESTPROJ[@]} -eq 1 ]] && TARGET="${TESTPROJ[0]}"
67
+ if [[ -z "$TARGET" && -z "$ROOTSLN" ]]; then
68
+ if [[ ${#TESTPROJ[@]} -gt 1 ]]; then
69
+ echo "NOT-RUN $N test file(s) read ${DOCS[*]} ($LIST), but there is no solution here and ${#TESTPROJ[@]} test projects; name one with --target"
70
+ else
71
+ 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"
72
+ fi
73
+ exit 2
74
+ fi
75
+ # Only the test projects that hold a reader, each on its own (TechieRag TF-004): the whole solution at
76
+ # once let the local-model tests take the memory, and TechieRag.Tests failed with "Cannot allocate
77
+ # memory" on a test that passes alone. A reader in no .NET test project (a browser spec) falls back to
78
+ # the solution run, which tf-build.sh also splits into one test project at a time.
79
+ OWNERS="$(TF_READERS="$READERS" python3 - <<'PY'
80
+ import glob, os, sys
81
+ out = []
82
+ for r in os.environ.get("TF_READERS", "").split("\n"):
83
+ if not r.strip():
84
+ continue
85
+ d = os.path.dirname(r)
86
+ while d and d not in (".", "/"):
87
+ projs = glob.glob(os.path.join(d, "*.csproj"))
88
+ if projs:
89
+ p = projs[0]
90
+ if p not in out:
91
+ out.append(p)
92
+ break
93
+ d = os.path.dirname(d)
94
+ print("\n".join(out))
95
+ PY
96
+ )"
97
+ if [[ -n "$OWNERS" && -z "$TARGET" ]]; then
98
+ fails=(); notrun=(); passed=0; vl=()
99
+ while IFS= read -r p; do
100
+ [[ -z "$p" ]] && continue
101
+ v="$(bash "$HERE/tf-build.sh" test "$p" 2>&1 | grep -m1 -E '^(PASS|FAIL|NOT-RUN)' || true)"
102
+ vl+=("$(basename "$p" .csproj): ${v:-no verdict}")
103
+ case "$v" in PASS*) passed=$((passed+1)) ;; FAIL*) fails+=("$(basename "$p" .csproj)") ;; *) notrun+=("$(basename "$p" .csproj)") ;; esac
104
+ done <<<"$OWNERS"
105
+ k="$(grep -c . <<<"$OWNERS")"
106
+ if [[ ${#fails[@]} -gt 0 ]]; then LINE="FAIL ${fails[*]} failed ($k test project(s) holding a reader, each run on its own) — ${vl[*]}"
107
+ elif [[ ${#notrun[@]} -gt 0 ]]; then LINE="NOT-RUN ${notrun[*]} could not run — ${vl[*]}"
108
+ else LINE="PASS $k test project(s) holding a reader, each run on its own — ${vl[*]}"; fi
109
+ else
110
+ LINE="$(bash "$HERE/tf-build.sh" test ${TARGET:+"$TARGET"} 2>&1 | grep -m1 -E '^(PASS|FAIL|NOT-RUN)' || true)"
111
+ fi
112
+ case "$LINE" in
113
+ PASS*) echo "PASS $N test file(s) read ${DOCS[*]} ($LIST); the unit tests pass — $LINE"; exit 0 ;;
114
+ FAIL*) echo "FAIL $N test file(s) read ${DOCS[*]} ($LIST); the unit tests fail — $LINE"; exit 1 ;;
115
+ *) echo "NOT-RUN the unit tests could not be run — ${LINE:-tf-build.sh printed no verdict}"; exit 2 ;;
116
+ esac