@techierathore/techieflow 1.0.3 → 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 (150) 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 +27 -0
  6. package/.claude/commands/TechieFlow/tasks/_smoke-test-policy.md +1 -1
  7. package/.claude/commands/TechieFlow/tasks/_status-update-gate.md +4 -3
  8. package/.claude/commands/TechieFlow/tasks/amend-docs.md +6 -5
  9. package/.claude/commands/TechieFlow/tasks/build-phase.md +1 -1
  10. package/.claude/commands/TechieFlow/tasks/create-deep-research-prompt.md +4 -0
  11. package/.claude/commands/TechieFlow/tasks/create-doc.md +4 -0
  12. package/.claude/commands/TechieFlow/tasks/create-project-brief.md +22 -0
  13. package/.claude/commands/TechieFlow/tasks/day1-brownfield.md +3 -3
  14. package/.claude/commands/TechieFlow/tasks/day1-greenfield.md +3 -3
  15. package/.claude/commands/TechieFlow/tasks/facilitate-brainstorming-session.md +4 -0
  16. package/.claude/commands/TechieFlow/tasks/fix-issues.md +2 -2
  17. package/.claude/commands/TechieFlow/tasks/generate-html.md +7 -3
  18. package/.claude/commands/TechieFlow/tasks/handoff-phase.md +20 -18
  19. package/.claude/commands/TechieFlow/tasks/metrics-report.md +2 -0
  20. package/.claude/commands/TechieFlow/tasks/mockups.md +1 -1
  21. package/.claude/commands/TechieFlow/tasks/productguide.md +2 -2
  22. package/.claude/commands/TechieFlow/tasks/render-workflow-docs.md +1 -1
  23. package/.claude/commands/TechieFlow/tasks/triage-issues.md +2 -2
  24. package/.claude/commands/TechieFlow/tasks/verify-phase.md +1 -1
  25. package/.opencode/plugin/techieflow.js +181 -9
  26. package/.tfcore/agents/analyst.md +5 -8
  27. package/.tfcore/agents/architect.md +1 -10
  28. package/.tfcore/agents/flow-master.md +1 -0
  29. package/.tfcore/agents/verifier.md +1 -0
  30. package/.tfcore/hooks/block-git.sh +4 -2
  31. package/.tfcore/hooks/guard-build.sh +5 -1
  32. package/.tfcore/hooks/guard-db.sh +6 -4
  33. package/.tfcore/hooks/guard-metrics.sh +13 -1
  34. package/.tfcore/hooks/guard-status-html.sh +86 -2
  35. package/.tfcore/hooks/guard-verify-deps.sh +124 -0
  36. package/.tfcore/hooks/guard-verify.sh +8 -3
  37. package/.tfcore/hooks/sweep-artifacts.sh +46 -0
  38. package/.tfcore/routing.yaml +61 -0
  39. package/.tfcore/standards/owner-words.txt +23 -0
  40. package/.tfcore/tasks/_owner-language.md +27 -0
  41. package/.tfcore/tasks/_smoke-test-policy.md +1 -1
  42. package/.tfcore/tasks/_status-update-gate.md +4 -3
  43. package/.tfcore/tasks/amend-docs.md +6 -5
  44. package/.tfcore/tasks/build-phase.md +1 -1
  45. package/.tfcore/tasks/create-deep-research-prompt.md +4 -0
  46. package/.tfcore/tasks/create-doc.md +4 -0
  47. package/.tfcore/tasks/create-project-brief.md +22 -0
  48. package/.tfcore/tasks/day1-brownfield.md +3 -3
  49. package/.tfcore/tasks/day1-greenfield.md +3 -3
  50. package/.tfcore/tasks/facilitate-brainstorming-session.md +4 -0
  51. package/.tfcore/tasks/fix-issues.md +2 -2
  52. package/.tfcore/tasks/generate-html.md +7 -3
  53. package/.tfcore/tasks/handoff-phase.md +20 -18
  54. package/.tfcore/tasks/metrics-report.md +2 -0
  55. package/.tfcore/tasks/mockups.md +1 -1
  56. package/.tfcore/tasks/productguide.md +2 -2
  57. package/.tfcore/tasks/render-workflow-docs.md +1 -1
  58. package/.tfcore/tasks/triage-issues.md +2 -2
  59. package/.tfcore/tasks/verify-phase.md +1 -1
  60. package/.tfcore/telemetry/SCHEMA.md +61 -1
  61. package/.tfcore/telemetry/install-metrics.sh +3 -2
  62. package/.tfcore/telemetry/tf-metrics.sh +435 -21
  63. package/.tfcore/templates/v4custom/app-agents-md-tmpl.md +6 -0
  64. package/.tfcore/templates/v4custom/app-brd-tmpl.md +5 -3
  65. package/.tfcore/templates/v4custom/app-brief-tmpl.md +65 -0
  66. package/.tfcore/templates/v4custom/app-decision-request-tmpl.md +85 -0
  67. package/.tfcore/templates/v4custom/app-library-feedback-tmpl.md +86 -31
  68. package/.tfcore/templates/v4custom/app-phase-brd-tmpl.md +94 -0
  69. package/.tfcore/templates/v4custom/app-phase-uidesign-tmpl.md +60 -0
  70. package/.tfcore/templates/v4custom/app-uidesign-tmpl.md +4 -2
  71. package/.tfcore/templates/v4custom/build-subagent-prompt.md +2 -2
  72. package/.tfcore/templates/v4custom/html-render-shell.md +4 -2
  73. package/.tfcore/utils/__pycache__/tf-brd-status.cpython-312.pyc +0 -0
  74. package/.tfcore/utils/__pycache__/tf-checklist-edit.cpython-312.pyc +0 -0
  75. package/.tfcore/utils/__pycache__/tf-day1-files.cpython-312.pyc +0 -0
  76. package/.tfcore/utils/__pycache__/tf-devguide-list.cpython-312.pyc +0 -0
  77. package/.tfcore/utils/__pycache__/tf-doc-check.cpython-312.pyc +0 -0
  78. package/.tfcore/utils/__pycache__/tf-log-miss.cpython-312.pyc +0 -0
  79. package/.tfcore/utils/__pycache__/tf-misses-md.cpython-312.pyc +0 -0
  80. package/.tfcore/utils/__pycache__/tf-mockups-locate.cpython-312.pyc +0 -0
  81. package/.tfcore/utils/__pycache__/tf-opencode-audit.cpython-312.pyc +0 -0
  82. package/.tfcore/utils/__pycache__/tf-perf-grade.cpython-312.pyc +0 -0
  83. package/.tfcore/utils/__pycache__/tf-render-html.cpython-312.pyc +0 -0
  84. package/.tfcore/utils/__pycache__/tf-split-brd.cpython-312.pyc +0 -0
  85. package/.tfcore/utils/__pycache__/tf-status-facts.cpython-312.pyc +0 -0
  86. package/.tfcore/utils/__pycache__/tf-triage.cpython-312.pyc +0 -0
  87. package/.tfcore/utils/__pycache__/tf-verify-list.cpython-312.pyc +0 -0
  88. package/.tfcore/utils/__pycache__/tf-verify-verdict.cpython-312.pyc +0 -0
  89. package/.tfcore/utils/tf-assets-browser.mjs +96 -0
  90. package/.tfcore/utils/tf-assets.sh +97 -12
  91. package/.tfcore/utils/tf-brd-status.py +14 -5
  92. package/.tfcore/utils/tf-build-list.py +99 -11
  93. package/.tfcore/utils/tf-build-list.sh +5 -3
  94. package/.tfcore/utils/tf-build.sh +88 -15
  95. package/.tfcore/utils/tf-ci-repro.py +685 -0
  96. package/.tfcore/utils/tf-ci-repro.sh +24 -0
  97. package/.tfcore/utils/tf-devguide-list.py +47 -22
  98. package/.tfcore/utils/tf-doc-check.py +242 -10
  99. package/.tfcore/utils/tf-doc-tests.sh +79 -0
  100. package/.tfcore/utils/tf-emit.sh +413 -19
  101. package/.tfcore/utils/tf-feedback.sh +13 -0
  102. package/.tfcore/utils/tf-fix-close.sh +113 -31
  103. package/.tfcore/utils/tf-gitignore-audit.sh +50 -3
  104. package/.tfcore/utils/tf-goal.sh +105 -18
  105. package/.tfcore/utils/tf-harness.sh +9 -4
  106. package/.tfcore/utils/tf-lock.sh +29 -0
  107. package/.tfcore/utils/tf-log-miss.py +34 -8
  108. package/.tfcore/utils/tf-login.mjs +167 -0
  109. package/.tfcore/utils/tf-misses-md.py +24 -21
  110. package/.tfcore/utils/tf-misses-md.sh +5 -4
  111. package/.tfcore/utils/tf-mockup-parity.mjs +427 -63
  112. package/.tfcore/utils/tf-mockup-parity.sh +14 -2
  113. package/.tfcore/utils/tf-model-pick.sh +475 -0
  114. package/.tfcore/utils/tf-owner-text.py +233 -0
  115. package/.tfcore/utils/tf-owner-text.sh +14 -0
  116. package/.tfcore/utils/tf-phase.sh +33 -1
  117. package/.tfcore/utils/tf-portable.sh +230 -0
  118. package/.tfcore/utils/tf-render-html.py +80 -2
  119. package/.tfcore/utils/tf-routing-bind.sh +87 -1
  120. package/.tfcore/utils/tf-routing.sh +7 -0
  121. package/.tfcore/utils/tf-selfcheck.sh +356 -0
  122. package/.tfcore/utils/tf-split-brd.py +17 -2
  123. package/.tfcore/utils/tf-status-evidence.sh +11 -9
  124. package/.tfcore/utils/tf-status-facts.py +133 -44
  125. package/.tfcore/utils/tf-triage.py +62 -16
  126. package/.tfcore/utils/tf-triage.sh +2 -0
  127. package/.tfcore/utils/tf-verify-boot.sh +263 -43
  128. package/.tfcore/utils/tf-verify-emit.sh +20 -0
  129. package/.tfcore/utils/tf-verify-env.sh +33 -4
  130. package/.tfcore/utils/tf-verify-list.py +75 -8
  131. package/.tfcore/utils/tf-verify-screens.mjs +254 -43
  132. package/.tfcore/utils/tf-verify-screens.sh +18 -4
  133. package/.tfcore/utils/tf-verify-tests.sh +272 -28
  134. package/.tfcore/utils/tf-verify-verdict.py +59 -11
  135. package/.tfcore/utils/tf_defect.py +27 -0
  136. package/.tfcore/utils/tf_feedback.py +209 -0
  137. package/.tfcore/utils/tf_roadmap.py +35 -0
  138. package/README.md +19 -5
  139. package/docs/TechieFlow-Installation.md +5 -3
  140. package/opencode.jsonc +24 -0
  141. package/package.json +1 -1
  142. package/scripts/install.mjs +4 -0
  143. package/.tfcore/templates/architecture-tmpl.yaml +0 -650
  144. package/.tfcore/templates/brownfield-architecture-tmpl.yaml +0 -476
  145. package/.tfcore/templates/competitor-analysis-tmpl.yaml +0 -306
  146. package/.tfcore/templates/front-end-architecture-tmpl.yaml +0 -218
  147. package/.tfcore/templates/fullstack-architecture-tmpl.yaml +0 -823
  148. package/.tfcore/templates/market-research-tmpl.yaml +0 -252
  149. package/.tfcore/templates/project-brief-tmpl.yaml +0 -221
  150. package/.tfcore/utils/techieflow-doc-template.md +0 -325
@@ -4,19 +4,33 @@
4
4
  # bash .tfcore/utils/tf-verify-screens.sh --list tests/.artifacts/verify/list.json --base http://localhost:5099 \
5
5
  # [--login-path /login --user ravi --password '…'] [--cookie 'k=v'] [--storage-state file]
6
6
  # bash .tfcore/utils/tf-verify-screens.sh --screen home=/ --screen posts=/posts --base URL # a smoke: changed screens only
7
- # bash .tfcore/utils/tf-verify-screens.sh --list … --cdp http://172.18.144.1:9223 # a Blazor Hybrid Windows head
7
+ # bash .tfcore/utils/tf-verify-screens.sh --list … --cdp http://172.18.144.1:9223 # an embedded-browser desktop head
8
+ # … --error-selector '.my-crash-banner' # the banner THIS stack shows when its client side falls over
9
+ # # (repeatable; defaults cover one framework's id and [data-error-ui])
10
+ # … --route-value ProviderId=21 # a sample for /x/{ProviderId:long} (repeatable; or "route_values"
11
+ # # in the list); a screen whose route still needs one is SKIPped
12
+ # A mockup control marked data-tf-state="<state>" (on it or a box around it) belongs to another state
13
+ # of the screen: it is not owed on the first view.
8
14
  #
9
15
  # For every screen, at 1280 and 390 px: every control the mockup anchors is present and shows
10
16
  # something, no header-only table, no blank page, no Blazor or console error (render); nothing
11
17
  # overlaps, nothing has zero size or sits off-screen, no sideways scroll, a stylesheet is loaded
12
- # (visual). Saves a screenshot per screen and width under tests/.artifacts/verify/screens/ and
18
+ # (visual). Overlap and off-screen are measured on the rectangle an element paints in, clipped by
19
+ # every scrolling ancestor, and the page is read only after its first render (--render-wait,
20
+ # default 5000 ms). Saves a screenshot per screen and width under tests/.artifacts/verify/screens/ and
13
21
  # writes tests/.artifacts/verify/screens.json; that JSON is the evidence a smoke names before a
14
22
  # row is written Implemented, and what the verdict script reads. It decides no row's status.
15
23
  # Exit 0 every screen OK · 5 a screen failed · 2 the app was unreachable · 4 Playwright missing.
16
24
  set -uo pipefail
17
25
  HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
18
- case "${1:-}" in -h|--help|"") sed -n '2,18p' "$0" | sed 's/^# \{0,1\}//'; exit "${1:+0}${1:-3}" ;; esac
26
+ # `--help` is a request and exits 0; no argument at all is a mistake and exits 3. (The one-line
27
+ # form this replaces computed "0--help" as its exit code and printed a bash error under the help.)
28
+ case "${1:-}" in
29
+ -h|--help) sed -n '2,23p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
30
+ "") sed -n '2,23p' "$0" | sed 's/^# \{0,1\}//'; exit 3 ;;
31
+ esac
19
32
  command -v node >/dev/null 2>&1 || { echo "tf-verify-screens: node is required (bash .tfcore/utils/tf-verify-env.sh)" >&2; exit 4; }
20
33
  node -e "import('playwright').then(()=>process.exit(0)).catch(()=>process.exit(1))" 2>/dev/null || {
21
34
  echo "tf-verify-screens: the 'playwright' package is not resolvable from this folder; run bash .tfcore/utils/tf-verify-env.sh" >&2; exit 4; }
22
- exec node "$HERE/tf-verify-screens.mjs" "$@"
35
+ source "$HERE/tf-lock.sh"; tf_take_lock verify-screens # one browser check at a time (TF-048)
36
+ node "$HERE/tf-verify-screens.mjs" "$@"
@@ -2,37 +2,162 @@
2
2
  # tf-verify-tests.sh — run the acceptance tests and map them to rows (Sitting 4c, 2026-09-06).
3
3
  #
4
4
  # bash .tfcore/utils/tf-verify-tests.sh [--base URL] [--target <sln|csproj>] [--no-browser] [--no-unit]
5
+ # [--shard N/M] [--spec <file or glob>]… [--list <list.json>] [--all-specs]
5
6
  # [--json-out tests/.artifacts/verify/tests.json]
7
+ # bash .tfcore/utils/tf-verify-tests.sh --merge <tests-a.json> <tests-b.json>… [--json-out tests/.artifacts/verify/tests.json]
6
8
  #
7
9
  # Browser tests: `npx playwright test` over tests/verify/ (the JSON reporter). Unit tests:
8
10
  # `tf-build.sh test` with normal console verbosity. A test belongs to a row when its name contains
9
- # the row's id (REQ-UI-004 …). A row is PASS when every test carrying its id passed, FAIL when one
10
- # failed (the reason and the screenshot, when the reporter kept one, are recorded), and absent
11
- # when no test carries its id. Writes tests/.artifacts/verify/tests.json for the verdict script.
11
+ # the row's id (REQ-UI-004 …). A row is PASS when a test carrying its id passed and none failed,
12
+ # FAIL when one failed (the reason and the screenshot, when the reporter kept one, are recorded),
13
+ # NOT-TESTED when every test carrying its id was SKIPPED, and absent when no test carries its id.
14
+ # A skipped clause is a third outcome, never a failure: `test.skip(!SEEDED, …)` says the state does
15
+ # not exist in the data, which is not a defect (TF-022). Writes tests/.artifacts/verify/tests.json.
16
+ # A suite too long for one command runs in parts (AppManager TF-013): `--shard N/M` runs Playwright's
17
+ # shard N of M (browser only; run the unit tests once with --no-browser), `--spec` names files, and
18
+ # each part writes its own playwright-<part>.json and tests-<part>.json. `--merge` then combines the
19
+ # parts' rows and totals into one tests.json; a later run of the same test stands for it (Lekhak TF-007).
20
+ # When tf-verify-list.sh was given a list of ids, the browser run keeps only the tests carrying them
21
+ # (`--grep`), and is skipped when no row has a screen and no browser test names one (Lekhak TF-017).
22
+ # `--list` names another list.json; `--all-specs` runs every browser test whatever the scope.
12
23
  # Exit 0 ran (whatever the results) · 2 nothing could run.
13
24
  set -uo pipefail
14
25
  HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
15
- BASE=""; TARGET=""; BROWSER=1; UNIT=1; OUT="tests/.artifacts/verify/tests.json"
26
+ source "$HERE/tf-portable.sh" # tf_read_lines, for bash 3.2 on a stock Mac
27
+ BASE=""; TARGET=""; BROWSER=1; UNIT=1; OUT=""; SHARD=""; SPECS=(); MERGE=(); LIST="tests/.artifacts/verify/list.json"; ALLSPECS=0
16
28
  while [[ $# -gt 0 ]]; do
17
29
  case "$1" in
18
30
  --base) BASE="${2:-}"; shift 2 ;;
19
31
  --target) TARGET="${2:-}"; shift 2 ;;
20
32
  --no-browser) BROWSER=0; shift ;;
21
33
  --no-unit) UNIT=0; shift ;;
34
+ --shard) SHARD="${2:-}"; UNIT=0; shift 2 ;;
35
+ --spec) SPECS+=("${2:-}"); shift 2 ;;
36
+ --list) LIST="${2:-}"; shift 2 ;;
37
+ --all-specs) ALLSPECS=1; shift ;;
38
+ --merge) shift; while [[ $# -gt 0 && "$1" != --* ]]; do MERGE+=("$1"); shift; done ;;
22
39
  --json-out) OUT="${2:-}"; shift 2 ;;
23
- -h|--help) sed -n '2,13p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
40
+ -h|--help) sed -n '2,22p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
24
41
  *) echo "tf-verify-tests: unknown argument $1" >&2; exit 2 ;;
25
42
  esac
26
43
  done
44
+ PART=""
45
+ if [[ -n "$SHARD" ]]; then
46
+ [[ "$SHARD" =~ ^([1-9][0-9]*)/([1-9][0-9]*)$ ]] || { echo "tf-verify-tests: --shard takes N/M, such as 2/4" >&2; exit 2; }
47
+ PART="${BASH_REMATCH[1]}of${BASH_REMATCH[2]}"
48
+ fi
49
+ [[ -n "$OUT" ]] || OUT="tests/.artifacts/verify/tests${PART:+-$PART}.json"
27
50
  mkdir -p "$(dirname "$OUT")" tests/.artifacts/verify
28
- PWJSON="tests/.artifacts/verify/playwright.json"; PWLOG="tests/.artifacts/verify/playwright.log"; UNITLOG=""; UNITLINE=""
51
+
52
+ # ---- --merge: the parts into one tests.json, no test run ------------------------------------------
53
+ if [[ ${#MERGE[@]} -gt 0 ]]; then
54
+ TF_OUT="$OUT" python3 - "${MERGE[@]}" <<'PY'
55
+ import json, os, sys
56
+ # Lekhak TF-007. The parts are read oldest first (by the time each ran, else in the order given), and
57
+ # a later run of the SAME test stands for it: a test that failed because its service was not started,
58
+ # run again once it was, clears the row. A later skip never replaces a run that happened. A part
59
+ # written before tests carried their own outcome counts each of its tests as the row's result.
60
+ loaded = []
61
+ for i, p in enumerate(sys.argv[1:]):
62
+ try:
63
+ loaded.append((json.load(open(p, encoding="utf-8")), i, p))
64
+ except Exception as e:
65
+ print(f"merge: {p} unreadable ({str(e)[:80]}); skipped")
66
+ loaded.sort(key=lambda x: (x[0].get("ran_at") or "", x[1]) if all(y[0].get("ran_at") for y in loaded) else (x[1],))
67
+ reqs, browser, unit, parts = {}, {"ran": False, "passed": 0, "failed": 0, "skipped": 0, "tests": 0}, {"ran": False, "line": ""}, []
68
+ outs, sources, superseded = {}, {}, 0
69
+ for d, _i, p in loaded:
70
+ parts.append(p)
71
+ for rid, r in d.get("reqs", {}).items():
72
+ oc = r.get("outcomes")
73
+ if oc is None: # an older part: every test it lists carries the row's result
74
+ res = {"PASS": "pass", "FAIL": "fail"}.get(r.get("result"), "skip")
75
+ oc = {t: {"outcome": res, "reason": r.get("reason", "") if res == "fail" else "", "screenshot": r.get("screenshot", "")} for t in r.get("tests", [])}
76
+ oc.update({t: {"outcome": "skip", "reason": r.get("reason", ""), "screenshot": ""} for t in r.get("skipped", [])})
77
+ o = outs.setdefault(rid, {})
78
+ for name, rec in oc.items():
79
+ old = o.get(name)
80
+ if old and rec["outcome"] == "skip" and old["outcome"] != "skip":
81
+ continue
82
+ if old and old["outcome"] != rec["outcome"]:
83
+ superseded += 1
84
+ o[name] = rec
85
+ src = sources.get(rid)
86
+ sources[rid] = r.get("source", "") if src in (None, r.get("source", "")) else "browser+unit"
87
+ b = d.get("browser", {})
88
+ if b.get("ran"):
89
+ browser["ran"] = True
90
+ for k in ("passed", "failed", "skipped", "tests"):
91
+ browser[k] += b.get(k, 0)
92
+ u = d.get("unit", {})
93
+ if u.get("ran") and not unit["ran"]:
94
+ unit = u
95
+ for rid, o in outs.items():
96
+ fails = [(n, x) for n, x in o.items() if x["outcome"] == "fail"]
97
+ npass = sum(1 for x in o.values() if x["outcome"] == "pass")
98
+ skips = [(n, x) for n, x in o.items() if x["outcome"] == "skip"]
99
+ result = "FAIL" if fails else "PASS" if npass else "NOT-TESTED"
100
+ reason = fails[0][1]["reason"] if fails else (skips[0][1]["reason"] if skips and not npass else "")
101
+ reqs[rid] = {"result": result, "source": sources.get(rid, ""), "tests": [n for n, x in o.items() if x["outcome"] != "skip"],
102
+ "skipped": [n for n, _ in skips], "passed": npass, "failed": len(fails), "reason": reason,
103
+ "screenshot": fails[0][1].get("screenshot", "") if fails else "", "outcomes": o}
104
+ json.dump({"reqs": reqs, "browser": browser, "unit": unit, "merged_from": parts, "superseded": superseded},
105
+ open(os.environ["TF_OUT"], "w"), indent=1)
106
+ p = sum(1 for r in reqs.values() if r["result"] == "PASS"); f = sum(1 for r in reqs.values() if r["result"] == "FAIL")
107
+ ns = sum(1 for r in reqs.values() if r["result"] == "NOT-TESTED")
108
+ print(f"merged {len(parts)} part(s): rows with a test: {len(reqs)} — {p} PASS, {f} FAIL, {ns} NOT-TESTED (every clause skipped)"
109
+ + (f" (browser {browser['passed']}/{browser['tests']} tests passed, {browser['skipped']} skipped)" if browser["ran"] else "")
110
+ + (f"; {superseded} test(s) ran again in a later part, and the later run stands" if superseded else ""))
111
+ for rid, r in sorted(reqs.items()):
112
+ if r["result"] == "FAIL":
113
+ print(f" FAIL {rid} — {r['reason']}" + (f" — {r['screenshot']}" if r["screenshot"] else ""))
114
+ print(f"JSON: {os.environ['TF_OUT']}")
115
+ PY
116
+ exit 0
117
+ fi
118
+
119
+ source "$HERE/tf-lock.sh"; tf_take_lock "verify-tests${PART:+ $PART}" # one browser check at a time (TF-048)
120
+ PWJSON="tests/.artifacts/verify/playwright${PART:+-$PART}.json"; PWLOG="tests/.artifacts/verify/playwright${PART:+-$PART}.log"; UNITLOG=""; UNITLINE=""
29
121
  ran_any=0
30
122
 
31
123
  if [[ $BROWSER -eq 1 ]]; then
32
124
  if ls tests/verify/*.spec.* >/dev/null 2>&1 || ls tests/verify/**/*.spec.* >/dev/null 2>&1; then
33
- if node -e "require.resolve('@playwright/test')" >/dev/null 2>&1; then
34
- BASE_URL="$BASE" PLAYWRIGHT_JSON_OUTPUT_NAME="$PWJSON" npx playwright test --reporter=json > "$PWLOG" 2>&1
35
- echo "browser tests: ran (log $PWLOG)"; ran_any=1
125
+ # --base reaches the tests only as BASE_URL, which Playwright never reads by itself. When neither
126
+ # the config nor a spec reads it, every test opens the config's own address, where an older build
127
+ # may still be running and pass in the new one's name (TfLens TF-031, 2026-09-11): refuse instead.
128
+ if [[ -n "$BASE" ]] && ! grep -qs "BASE_URL" playwright.config.* tests/verify/*.spec.* tests/verify/**/*.spec.*; then
129
+ echo "browser tests: NOT RUN — nothing reads BASE_URL, so the tests would open another address than --base $BASE; run bash .tfcore/utils/tf-verify-env.sh, which makes playwright.config.ts read it"
130
+ rm -f "$PWJSON"
131
+ elif node -e "require.resolve('@playwright/test')" >/dev/null 2>&1; then
132
+ PWARGS=(--reporter=json); [[ -n "$SHARD" ]] && PWARGS+=("--shard=$SHARD")
133
+ # (the block below ends in SCOPEPY, not PY: tests/regression lifts the PY blocks out verbatim)
134
+ # Lekhak TF-017: a verify of a list of ids runs only the browser tests carrying those ids, and
135
+ # none when no row in scope has a screen and no file under tests/verify/ names one of them. A
136
+ # verify of one no-screen NFR row ran the whole suite and was stopped at 30 minutes. ui,
137
+ # functional and all run everything; so do --spec, --all-specs and a run without list.json.
138
+ SCOPE=""
139
+ [[ $ALLSPECS -eq 0 && ${#SPECS[@]} -eq 0 && -f "$LIST" ]] && SCOPE="$(python3 - "$LIST" <<'SCOPEPY'
140
+ import json, sys
141
+ try:
142
+ d = json.load(open(sys.argv[1], encoding="utf-8"))
143
+ except Exception:
144
+ sys.exit(0)
145
+ if str(d.get("scope", "")).strip().lower() in ("", "ui", "functional", "all"):
146
+ sys.exit(0)
147
+ rows = d.get("rows", [])
148
+ ids = [r["id"] for r in rows if r.get("id")]
149
+ if ids:
150
+ print(("screen " if any(r.get("screen") for r in rows) else "none ") + "|".join(ids))
151
+ SCOPEPY
152
+ )"
153
+ if [[ "$SCOPE" == none\ * ]] && ! grep -rqsE "${SCOPE#none }" tests/verify; then
154
+ echo "browser tests: skipped — no row in scope (${SCOPE#none }) has a screen and no file under tests/verify/ names one; its tests are unit tests (--all-specs runs every spec anyway)"
155
+ rm -f "$PWJSON"
156
+ else
157
+ [[ -n "$SCOPE" ]] && PWARGS+=(--grep "${SCOPE#* }")
158
+ BASE_URL="$BASE" PLAYWRIGHT_JSON_OUTPUT_NAME="$PWJSON" npx playwright test "${PWARGS[@]}" ${SPECS[@]+"${SPECS[@]}"} > "$PWLOG" 2>&1
159
+ echo "browser tests: ran${SHARD:+ shard $SHARD}${SPECS:+ (${#SPECS[@]} spec argument(s))}${SCOPE:+ only the tests carrying ${SCOPE#* } (the scope in $LIST)} (log $PWLOG)"; ran_any=1
160
+ fi
36
161
  else
37
162
  echo "browser tests: @playwright/test is not installed here (bash .tfcore/utils/tf-verify-env.sh); skipped"; rm -f "$PWJSON"
38
163
  fi
@@ -42,29 +167,96 @@ if [[ $BROWSER -eq 1 ]]; then
42
167
  else rm -f "$PWJSON"; fi
43
168
 
44
169
  if [[ $UNIT -eq 1 ]]; then
45
- if [[ -n "$TARGET" ]] || ls *.sln *.slnx >/dev/null 2>&1 || ls tests/*/*.csproj >/dev/null 2>&1; then
46
- UNITLINE="$(bash "$HERE/tf-build.sh" test ${TARGET:+"$TARGET"} -- --logger "console;verbosity=normal" 2>&1 | head -1)"
170
+ # Each pattern on its own: `ls *.sln *.slnx` fails when either matches nothing, so a .slnx-only
171
+ # solution read as none; and a test project may sit at any depth under tests/, not one folder down
172
+ # (AppManager TF-007: tests/unit/AppManager.UnitTests/). tf-build.sh finds a root solution itself;
173
+ # without one it is handed the only test project, and several are named rather than guessed between.
174
+ ROOTSLN="$(compgen -G '*.sln'; compgen -G '*.slnx'; compgen -G '*.csproj')"
175
+ 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)
176
+ [[ -z "$TARGET" && -z "$ROOTSLN" && ${#TESTPROJ[@]} -eq 1 ]] && TARGET="${TESTPROJ[0]}"
177
+ if [[ -z "$TARGET" && -z "$ROOTSLN" && ${#TESTPROJ[@]} -gt 1 ]]; then
178
+ echo "unit tests: NOT RUN — no solution here and ${#TESTPROJ[@]} test projects under tests/ (${TESTPROJ[*]}); name one with --target"
179
+ elif [[ -n "$TARGET" || -n "$ROOTSLN" ]]; then
180
+ # the VERDICT line, not the first line: tf-build.sh may print a note before it, and a note read
181
+ # as the verdict left 975 passing TfLens tests recorded as never run (TF-037)
182
+ # A test project on Microsoft.Testing.Platform ignores --logger and prints no line per test, so no
183
+ # unit test ever reached a row (AppManager TF-005, xunit.v3, 286 tests). Such a project is asked for
184
+ # a TRX report, the per-test record both platforms write, through the property `dotnet test` passes
185
+ # to it. One switch serves every project or none is passed: an unknown switch stops a test app.
186
+ MTP="$(python3 - <<'PY'
187
+ import os, re
188
+ flags, mtp = set(), 0
189
+ for root, dirs, files in os.walk("."):
190
+ dirs[:] = [d for d in dirs if d not in ("bin", "obj", "node_modules", ".git") and not (root == "./tests" and d == ".artifacts")]
191
+ for fn in files:
192
+ if not fn.endswith(".csproj"):
193
+ continue
194
+ t = open(os.path.join(root, fn), encoding="utf-8", errors="replace").read()
195
+ if not re.search(r"<(TestingPlatformDotnetTestSupport|UseMicrosoftTestingPlatformRunner|EnableMSTestRunner|EnableNUnitRunner)>\s*true", t, re.I) and 'Sdk="MSTest.Sdk' not in t:
196
+ continue
197
+ mtp += 1
198
+ if re.search(r'Include="xunit\.v3"', t, re.I):
199
+ flags.add("--report-xunit-trx")
200
+ elif re.search(r'Include="Microsoft\.Testing\.Extensions\.TrxReport"|Sdk="MSTest\.Sdk', t, re.I):
201
+ flags.add("--report-trx")
202
+ else:
203
+ flags.add("?")
204
+ print(next(iter(flags)) if mtp and len(flags) == 1 and "?" not in flags else ("mixed" if mtp else ""))
205
+ PY
206
+ )"
207
+ UARGS=(--logger "console;verbosity=normal")
208
+ case "$MTP" in
209
+ --report-*) UARGS+=("-p:TestingPlatformCommandLineArguments=$MTP") ;;
210
+ mixed) echo "unit tests: note the Testing Platform projects here take different report switches, so none was passed; their tests are not mapped to rows" ;;
211
+ esac
212
+ STAMP="$(mktemp)"
213
+ UNITLINE="$(bash "$HERE/tf-build.sh" test ${TARGET:+"$TARGET"} -- "${UARGS[@]}" 2>&1 | grep -m1 -E '^(PASS|FAIL|NOT-RUN)' || true)"
47
214
  UNITLOG="$(sed -n 's/.*log \(tests\/\.artifacts\/build\/[^ ;]*\).*/\1/p' <<<"$UNITLINE" | head -1)"
215
+ TRX="$(find . -name '*.trx' -newer "$STAMP" -not -path '*/node_modules/*' 2>/dev/null)"; rm -f "$STAMP"
48
216
  echo "unit tests: $UNITLINE"; [[ "$UNITLINE" != NOT-RUN* ]] && ran_any=1
49
217
  else
50
218
  echo "unit tests: no solution or test project found; skipped"
51
219
  fi
52
220
  fi
53
221
 
54
- TF_PWJSON="$PWJSON" TF_UNITLOG="$UNITLOG" TF_UNITLINE="$UNITLINE" TF_OUT="$OUT" python3 - <<'PY'
222
+ TF_PWJSON="$PWJSON" TF_UNITLOG="$UNITLOG" TF_UNITLINE="$UNITLINE" TF_TRX="${TRX:-}" TF_OUT="$OUT" python3 - <<'PY'
55
223
  import json, os, re
224
+ import xml.etree.ElementTree as ET
56
225
  ID = re.compile(r"(REQ-(?:UI|FN|NFR|RAG)-\d{3})", re.I)
57
- reqs, browser, unit = {}, {"ran": False, "passed": 0, "failed": 0, "tests": 0}, {"ran": False, "line": os.environ.get("TF_UNITLINE", "")}
226
+ reqs = {}
227
+ browser = {"ran": False, "passed": 0, "failed": 0, "skipped": 0, "tests": 0}
228
+ unit = {"ran": False, "line": os.environ.get("TF_UNITLINE", "")}
58
229
 
59
- def add(rid, ok, name, source, reason="", shot=""):
60
- r = reqs.setdefault(rid.upper(), {"result": "PASS", "source": source, "tests": [], "reason": "", "screenshot": ""})
61
- r["tests"].append(name)
62
- if not ok:
63
- r["result"] = "FAIL"
230
+ def add(rid, outcome, name, source, reason="", shot=""):
231
+ """Record one test against a row. `outcome` is "pass", "fail" or "skip".
232
+
233
+ A SKIPPED clause is the third outcome and neither of the other two. A conditional
234
+ `test.skip(!SEEDED, "needs the seeded dataset")` says the state does not exist in the
235
+ data yet: that is not a pass, and it is not a defect either. Counting it as a failure
236
+ put FAIL on rows no code could clear and sent build-phase back into FIX mode against
237
+ them (TF-022, TfLens 2026-09-09). A row whose clauses were ALL skipped ends
238
+ NOT-TESTED, which the verdict script reads as not measured — never Verified, never a
239
+ defect. tests/regression/run.sh tf_022."""
240
+ r = reqs.setdefault(rid.upper(), {"result": "NOT-TESTED", "source": source, "tests": [],
241
+ "skipped": [], "passed": 0, "failed": 0,
242
+ "reason": "", "screenshot": "", "outcomes": {}})
243
+ # each test's own outcome, so a merge can let a later run of the same test stand (Lekhak TF-007)
244
+ r["outcomes"][name] = {"outcome": outcome, "reason": (reason or "")[:200], "screenshot": shot}
245
+ if outcome == "skip":
246
+ r["skipped"].append(name)
64
247
  if not r["reason"]:
248
+ r["reason"] = (reason or "the test was skipped")[:200]
249
+ return
250
+ r["tests"].append(name)
251
+ if outcome == "pass":
252
+ r["passed"] += 1
253
+ else:
254
+ if r["result"] != "FAIL": # the first real failure owns the reason
65
255
  r["reason"] = reason[:200]
256
+ r["failed"] += 1
66
257
  if shot and not r["screenshot"]:
67
258
  r["screenshot"] = shot
259
+ r["result"] = "FAIL" if r["failed"] else "PASS"
68
260
 
69
261
  pw = os.environ.get("TF_PWJSON", "")
70
262
  if pw and os.path.isfile(pw):
@@ -78,11 +270,21 @@ if pw and os.path.isfile(pw):
78
270
  res = t.get("results", [])
79
271
  last = res[-1] if res else {}
80
272
  status = last.get("status", t.get("status", "unexpected"))
81
- ok = status in ("passed", "expected") or t.get("status") == "expected"
273
+ # skipped is read FIRST: it is neither a pass nor a failure (TF-022)
274
+ if status == "skipped" or t.get("status") == "skipped":
275
+ outcome = "skip"
276
+ elif status in ("passed", "expected") or t.get("status") == "expected":
277
+ outcome = "pass"
278
+ else:
279
+ outcome = "fail"
82
280
  browser["tests"] += 1
83
- browser["passed" if ok else "failed"] += 1
281
+ browser[{"pass": "passed", "fail": "failed", "skip": "skipped"}[outcome]] += 1
84
282
  err = ""
85
- if last.get("error"):
283
+ if outcome == "skip":
284
+ why = [a.get("description") for a in (t.get("annotations") or [])
285
+ if a.get("type") == "skip" and a.get("description")]
286
+ err = "skipped" + (": " + str(why[0]) if why else "")
287
+ elif last.get("error"):
86
288
  err = re.sub(r"\x1b\[[0-9;]*m", "", str(last["error"].get("message", "")).split("\n")[0])
87
289
  shot = ""
88
290
  for a in last.get("attachments", []):
@@ -91,7 +293,7 @@ if pw and os.path.isfile(pw):
91
293
  shot = shot[shot.find("tests/"):] if "tests/" in shot else shot
92
294
  break
93
295
  for rid in set(ID.findall(title)):
94
- add(rid, ok, title.strip(), "browser", err, shot)
296
+ add(rid, outcome, title.strip(), "browser", err, shot)
95
297
  for s in suite.get("suites", []):
96
298
  walk(s, path + [s.get("title", "")] if s.get("title") and not s["title"].endswith(".ts") and not s["title"].endswith(".js") and not s["title"].endswith(".mjs") else path)
97
299
  for s in data.get("suites", []):
@@ -99,25 +301,67 @@ if pw and os.path.isfile(pw):
99
301
  except Exception as e:
100
302
  browser["error"] = str(e)
101
303
 
304
+ seen = set() # one test read from both a TRX report and the console is counted once
305
+ for trx in [p for p in os.environ.get("TF_TRX", "").splitlines() if p.strip()]:
306
+ try:
307
+ tree = ET.parse(trx).getroot()
308
+ except Exception as e:
309
+ unit.setdefault("trx_unreadable", []).append(f"{trx}: {str(e)[:80]}")
310
+ continue
311
+ unit["ran"] = True
312
+ unit.setdefault("trx", []).append(trx)
313
+ for el in tree.iter():
314
+ if not el.tag.endswith("UnitTestResult"):
315
+ continue
316
+ name = (el.get("testName") or "").strip()
317
+ oc = (el.get("outcome") or "").lower() # Passed / Failed / NotExecuted …, any case (TF-005)
318
+ outcome = "pass" if oc == "passed" else "skip" if oc in ("notexecuted", "inconclusive", "pending", "notrunnable", "disconnected") else "fail"
319
+ if (name, outcome) in seen:
320
+ continue
321
+ seen.add((name, outcome))
322
+ msg = next(((m.text or "").strip() for m in el.iter() if m.tag.endswith("Message") and (m.text or "").strip()), "")
323
+ for rid in set(ID.findall(name)):
324
+ add(rid, outcome, name, "unit",
325
+ ("unit test failed: " + (msg.splitlines()[0] if msg else name))[:200] if outcome == "fail"
326
+ else ("unit test skipped: " + name[:120]) if outcome == "skip" else "")
327
+
102
328
  ul = os.environ.get("TF_UNITLOG", "")
103
329
  if ul and os.path.isfile(ul):
104
330
  unit["ran"] = True
105
331
  for line in open(ul, encoding="utf-8", errors="replace"):
106
- m = re.match(r"\s*(Passed|Failed|Skipped)\s+(\S.*?)\s*(\[[^\]]*\])?\s*$", line)
107
- if not m or m.group(1) == "Skipped":
332
+ # any case, and a "(133ms)" tail: the Testing Platform's own log writes "failed REQ-NFR-007 … (133ms)" (TF-005)
333
+ m = re.match(r"\s*(passed|failed|skipped)\s+(\S.*?)\s*(?:\[[^\]]*\]|\(\d+(?:\.\d+)?\s*m?s\))?\s*$", line, re.I)
334
+ if not m:
108
335
  continue
336
+ # a skipped unit test is recorded as skipped, not dropped: the row then reads
337
+ # NOT-TESTED with the evidence, rather than looking as if no test exists (TF-022)
338
+ outcome = {"passed": "pass", "failed": "fail", "skipped": "skip"}[m.group(1).lower()]
109
339
  name = m.group(2).strip()
340
+ if (name, outcome) in seen:
341
+ continue
342
+ seen.add((name, outcome))
343
+ # a pass has no reason: every non-failure was once written "unit test skipped" (Lekhak TF-018)
110
344
  for rid in set(ID.findall(name)):
111
- add(rid, m.group(1) == "Passed", name, "unit", "unit test failed: " + name[:120])
345
+ add(rid, outcome, name, "unit",
346
+ "unit test failed: " + name[:120] if outcome == "fail"
347
+ else "unit test skipped: " + name[:120] if outcome == "skip" else "")
112
348
  unit["passed"] = sum(1 for r in reqs.values() if r["source"] == "unit" and r["result"] == "PASS")
113
349
  unit["failed"] = sum(1 for r in reqs.values() if r["source"] == "unit" and r["result"] == "FAIL")
350
+ unit["not_tested"] = sum(1 for r in reqs.values() if r["source"] == "unit" and r["result"] == "NOT-TESTED")
114
351
 
115
- json.dump({"reqs": reqs, "browser": browser, "unit": unit}, open(os.environ["TF_OUT"], "w"), indent=1)
116
- p = sum(1 for r in reqs.values() if r["result"] == "PASS"); f = len(reqs) - p
117
- print(f"rows with a test: {len(reqs)} — {p} PASS, {f} FAIL" + (f" (browser {browser['passed']}/{browser['tests']} tests passed)" if browser["ran"] else ""))
352
+ import datetime
353
+ ran_at = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
354
+ json.dump({"reqs": reqs, "browser": browser, "unit": unit, "ran_at": ran_at}, open(os.environ["TF_OUT"], "w"), indent=1)
355
+ p = sum(1 for r in reqs.values() if r["result"] == "PASS")
356
+ f = sum(1 for r in reqs.values() if r["result"] == "FAIL")
357
+ ns = sum(1 for r in reqs.values() if r["result"] == "NOT-TESTED")
358
+ print(f"rows with a test: {len(reqs)} — {p} PASS, {f} FAIL, {ns} NOT-TESTED (every clause skipped)"
359
+ + (f" (browser {browser['passed']}/{browser['tests']} tests passed, {browser['skipped']} skipped)" if browser["ran"] else ""))
118
360
  for rid, r in sorted(reqs.items()):
119
361
  if r["result"] == "FAIL":
120
362
  print(f" FAIL {rid} — {r['reason']}" + (f" — {r['screenshot']}" if r["screenshot"] else ""))
363
+ elif r["result"] == "NOT-TESTED":
364
+ print(f" NOT-TESTED {rid} — {len(r['skipped'])} test(s) skipped, none ran — {r['reason']}")
121
365
  print(f"JSON: {os.environ['TF_OUT']}")
122
366
  PY
123
367
  [[ $ran_any -eq 1 ]] && exit 0 || exit 2
@@ -19,9 +19,12 @@ For each row, the seven checks in order, and the first that fails is the verdict
19
19
  (standards has no script yet and is never listed as run)
20
20
 
21
21
  Verdicts: PASS · FAIL (acceptance) · BUILD-FAIL · RENDER-FAIL · ASSET-FAIL · VISUAL-FAIL · MOCKUP-FAIL ·
22
- PERF-FAIL · NOT-TESTED (no test carried the id; the screen checks passed or did not run) ·
22
+ PERF-FAIL · NOT-TESTED (no test carried the id, or every test carrying it was skipped; the screen
23
+ checks passed or did not run) ·
23
24
  NOT-OBSERVABLE (an NFR row with no budget and no unit test) · NOT-DRIVEN (its screen was never driven:
24
- the head could not be booted, or has no driver). A row is Verified only on PASS. NOT-* rows keep their
25
+ the head could not be booted, or has no driver) · DEFECT-OPEN (every check passed, but the old Remarks
26
+ carry a defect no verify can see, tf_defect.py: the row goes to Needs re-verify, and so to a fix, with
27
+ the defect kept; AppManager TF-021). A row is Verified only on PASS. NOT-* rows keep their
25
28
  status and get a Remark saying so; nothing is ever written as a static-only pass.
26
29
 
27
30
  Writes docs/.last-verify.json (the ledger the verify hook reads: date, app, scope, booted, the checks
@@ -37,6 +40,8 @@ import re
37
40
  import sys
38
41
 
39
42
  HERE = os.path.dirname(os.path.abspath(__file__))
43
+ sys.path.insert(0, HERE)
44
+ import tf_defect # noqa: E402
40
45
  FAILING = {"BUILD-FAIL": "build", "FAIL": "acceptance", "RENDER-FAIL": "render", "ASSET-FAIL": "assets",
41
46
  "VISUAL-FAIL": "visual", "MOCKUP-FAIL": "mockup-parity", "PERF-FAIL": "perf"}
42
47
  STATUS = {"PASS": "Verified", "FAIL": "FAIL", "BUILD-FAIL": "FAIL", "RENDER-FAIL": "Needs re-verify", "ASSET-FAIL": "Needs re-verify",
@@ -70,6 +75,14 @@ def norm_route(r):
70
75
  return r or "/"
71
76
 
72
77
 
78
+ def quoted(s, n=None):
79
+ """A test's own words — its title or its failure message — in backticks, so the Remark shows them as
80
+ quoted and tf-doc-check reads them as quoted: a title saying "Found/Not found" failed the checker's
81
+ not-present rule on every verify (Lekhak TF-012)."""
82
+ s = re.sub(r"\s+", " ", (s or "").replace("`", "'").replace("|", "/")).strip()
83
+ return f"`{s[:n] if n else s}`" if s else ""
84
+
85
+
73
86
  def words(s, n=60):
74
87
  w = s.split()
75
88
  return s if len(w) <= n else " ".join(w[:n]) + "…" # the ellipsis rides on the last word: the checker counts words, and a bare "…" was a 61st (miss 29)
@@ -91,6 +104,8 @@ def main(argv):
91
104
  tests_ran = os.path.isfile(os.path.join(d, "tests.json"))
92
105
  screens = load(os.path.join(d, "screens.json")) or {}
93
106
  scr_by_name = {s["name"]: s for s in screens.get("screens", [])}
107
+ # screens tf-verify-screens did not drive because their route needs a sample value (Lekhak TF-005)
108
+ scr_skipped = {s["name"]: s for s in screens.get("skipped", []) or []}
94
109
  assets = load(os.path.join(d, "assets.json")) or {}
95
110
  pages = {norm_route(p.get("path")): p for p in assets.get("pages", [])}
96
111
  parity = load(os.path.join(d, "parity.json")) or {}
@@ -114,6 +129,13 @@ def main(argv):
114
129
  scr = scr_by_name.get(r.get("screen") or "")
115
130
  driven = bool(scr) and scr.get("render") != "UNREACHABLE"
116
131
  t = tests.get(rid)
132
+ # A row whose every clause was SKIPPED is NOT-TESTED: the acceptance gate did not run on
133
+ # it. It is neither a pass nor a defect, so it grades exactly like a row with no test at
134
+ # all — never Verified, no gate record (TF-022). `t` is kept for the note.
135
+ skipped_only = bool(t) and t.get("result") == "NOT-TESTED"
136
+ if skipped_only:
137
+ notes.append(f"acceptance not measured ({len(t.get('skipped') or [])} test(s) skipped, none ran)")
138
+ t = None
117
139
  # every check on its own: (name, ran, failed, verdict, class, detail, evidence); the first failure wins
118
140
  checks = []
119
141
 
@@ -121,7 +143,7 @@ def main(argv):
121
143
  checks.append(("build", booted or build_failed, build_failed, "BUILD-FAIL", "build-error", boot.get("reason", "build failed"), ""))
122
144
  # 2 acceptance
123
145
  if t:
124
- checks.append(("acceptance", True, t["result"] != "PASS", "FAIL", "assert-fail", t.get("reason") or "test failed", t.get("screenshot") or ""))
146
+ checks.append(("acceptance", True, t["result"] != "PASS", "FAIL", "assert-fail", quoted(t.get("reason")) or "test failed", t.get("screenshot") or ""))
125
147
  # 3 render
126
148
  if scr:
127
149
  if scr.get("render") == "UNREACHABLE":
@@ -184,17 +206,21 @@ def main(argv):
184
206
  detail = "NFR row with no perf-budget and no unit test carrying its id"
185
207
  elif not t and r.get("screen") and not driven:
186
208
  verdict = "NOT-DRIVEN"
209
+ sk = scr_skipped.get(r["screen"])
187
210
  detail = (f"{boot.get('head', 'the app')} not driven: {boot.get('reason')}" if not booted
211
+ else f"screen {r['screen']} was not driven: its route {sk['route']} needs --route-value "
212
+ + ", ".join(f"{n}=<id>" for n in sk.get("needs", [])) if sk
188
213
  else f"screen {r['screen']} was not driven in this run")
189
214
  elif not t:
190
215
  verdict = "NOT-TESTED"
191
- detail = f"no test named {rid} ran" + (f"; screen {r['screen']} renders and looks right" if driven else "")
216
+ detail = (f"every test named {rid} was skipped" if skipped_only else f"no test named {rid} ran") \
217
+ + (f"; screen {r['screen']} renders and looks right" if driven else "")
192
218
  elif r.get("screen") and not driven and not booted:
193
219
  verdict = "NOT-DRIVEN"
194
220
  detail = f"test passed but {boot.get('head', 'the app')} was not driven: {boot.get('reason')}"
195
221
  else:
196
222
  verdict = "PASS"
197
- parts = [f"test {t['tests'][0][:60]}" if t and t.get("tests") else "test passed"]
223
+ parts = [f"test {quoted(t['tests'][0], 60)}" if t and t.get("tests") else "test passed"]
198
224
  if driven:
199
225
  parts.append(f"{r['screen']} renders and looks right @{'/'.join(str(w['width']) for w in scr['widths'])}")
200
226
  if "mockup-parity" in ran:
@@ -203,22 +229,29 @@ def main(argv):
203
229
  parts.append("within its speed budget")
204
230
  detail = "; ".join(parts)
205
231
 
206
- status = STATUS.get(verdict, prior)
232
+ # A defect found by the guide, the owner's testing or production is not in the acceptance line, so
233
+ # a pass cannot clear it and a rewrite must not erase it (TF-021). tests/regression/run.sh am_021.
234
+ kept = tf_defect.unseen(r.get("remarks"))
235
+ if kept and verdict == "PASS":
236
+ verdict = "DEFECT-OPEN"
237
+ status = STATUS.get(verdict, "Needs re-verify" if verdict == "DEFECT-OPEN" else prior)
207
238
  pct = r.get("pct")
208
239
  if verdict == "PASS":
209
240
  pct = 100
210
- elif verdict in ("RENDER-FAIL", "ASSET-FAIL", "VISUAL-FAIL", "MOCKUP-FAIL", "PERF-FAIL"):
241
+ elif verdict in ("DEFECT-OPEN", "RENDER-FAIL", "ASSET-FAIL", "VISUAL-FAIL", "MOCKUP-FAIL", "PERF-FAIL"):
211
242
  pct = min(pct if pct is not None else 75, 75)
212
243
  gate = FAILING.get(verdict)
213
244
  marks = {"FAIL": "⚠ acceptance", "BUILD-FAIL": "⚠ build", "RENDER-FAIL": "⚠ render", "ASSET-FAIL": "⚠ assets",
214
245
  "VISUAL-FAIL": "⚠ visual", "MOCKUP-FAIL": "⚠ mockup-parity", "PERF-FAIL": "⚠ perf",
215
- "NOT-TESTED": "not verified", "NOT-OBSERVABLE": "not observable", "NOT-DRIVEN": "not verified", "PASS": "PASS"}
246
+ "NOT-TESTED": "not verified", "NOT-OBSERVABLE": "not observable", "NOT-DRIVEN": "not verified", "PASS": "PASS",
247
+ "DEFECT-OPEN": "checks pass, defect open"}
216
248
  remark = f"{today} verify: {marks[verdict]} — {detail}"
217
249
  if evidence:
218
250
  remark += f" ({evidence})"
219
251
  if notes:
220
252
  remark += "; " + "; ".join(notes[:2])
221
- remark = words(remark.replace("|", "/"), 60)
253
+ carried = words(" ".join(kept).replace("|", "/"), 30) if kept else ""
254
+ remark = words(remark.replace("|", "/"), 60 - len(carried.split())) + (f" {carried}" if carried else "")
222
255
  emit = verdict == "PASS" or gate is not None
223
256
  row = {"id": rid, "class": r["class"], "verdict": verdict, "gate": gate, "gates_run": ran, "failure_class": cls if gate else None,
224
257
  "prior_verdict": prior or None, "status": status, "pct": pct, "remark": remark, "screen": r.get("screen"),
@@ -231,8 +264,23 @@ def main(argv):
231
264
  for c in ("build", "acceptance", "render", "assets", "visual", "mockup-parity", "perf"):
232
265
  if any(c in x["gates_run"] for x in rows_out):
233
266
  checks.append(c)
267
+ # Lekhak TF-007: a targeted verify rewrote the ledger with its own rows only (395 became 1), so every
268
+ # other row lost its last verdict. The ledger is kept row by row: a row this run graded is replaced,
269
+ # with the date and run it was graded in; the rest keep theirs. A scope-all run starts it afresh.
270
+ # The verify guard unlocks a Verified only for a row graded today, and fix-close reads only the rows
271
+ # graded since its fix began, by these dates.
272
+ all_rows, row_dates, row_runs = dict(ledger_rows), {k: today for k in ledger_rows}, {k: started for k in ledger_rows}
273
+ prev = load(os.path.join("docs", ".last-verify.json")) if os.path.isfile(os.path.join("docs", ".last-verify.json")) else None
274
+ if lst.get("scope", "all") != "all" and isinstance(prev, dict) and prev.get("app") in (None, app) \
275
+ and isinstance(prev.get("rows"), dict) and prev.get("mode") != "reconcile":
276
+ for k, v in prev["rows"].items():
277
+ if k not in all_rows:
278
+ all_rows[k] = v
279
+ row_dates[k] = (prev.get("row_dates") or {}).get(k, prev.get("date", ""))
280
+ row_runs[k] = (prev.get("row_runs") or {}).get(k, prev.get("run_id", ""))
234
281
  ledger = {"date": today, "app": app, "scope": lst.get("scope", "all"), "booted": boot_text, "gates": checks,
235
- "evidence": d, "run_id": started, "rows": ledger_rows}
282
+ "evidence": d, "run_id": started, "rows": all_rows, "row_dates": row_dates, "row_runs": row_runs,
283
+ "this_run": sorted(ledger_rows)}
236
284
  os.makedirs("docs", exist_ok=True)
237
285
  with open(os.path.join("docs", ".last-verify.json"), "w", encoding="utf-8") as f:
238
286
  json.dump(ledger, f, indent=1)
@@ -274,7 +322,7 @@ def main(argv):
274
322
  for t in table:
275
323
  print("| " + " | ".join(str(x).replace("|", "/") for x in t) + " |")
276
324
  print()
277
- print(f"Ledger: docs/.last-verify.json ({len(ledger_rows)} rows). Verdicts: {os.path.join(d, 'verdicts.json')}."
325
+ print(f"Ledger: docs/.last-verify.json ({len(ledger_rows)} rows graded now, {len(all_rows)} in all). Verdicts: {os.path.join(d, 'verdicts.json')}."
278
326
  + (f" Checklist: {applied} row(s) rewritten in {lst.get('checklist')}." if apply else " Checklist untouched (no --apply)."))
279
327
  return 0
280
328
 
@@ -0,0 +1,27 @@
1
+ """tf_defect.py — the defect marks the framework's own writers put in a checklist row's Remarks.
2
+
3
+ devguide.md writes `⚠ DevGuide`, tf-checklist-edit's demote `⚠ UAT bug` / `⚠ prod bug` / `⚠ miss`,
4
+ tf-verify-verdict one mark per failing check, the smoke policy `⚠ visual`, security findings
5
+ `⚠ SECURITY`. A bare ⚠ is not a defect: hand-written remarks use it for "not verifiable here".
6
+
7
+ Imported by tf-status-facts.py (TF-019), tf-build-list.py (TF-020) and tf-verify-verdict.py (TF-021).
8
+ tests/regression/run.sh am_019, am_020, am_021.
9
+ """
10
+ import re
11
+
12
+ # found by a verify check: a new verify grades it again, so its verdict replaces the old mark
13
+ VERIFY = r"acceptance|build|render|assets|visual|mockup-parity|perf"
14
+ # found by another step (guide, owner's testing, production, a miss, a review): no verify sees it
15
+ OTHER = r"DevGuide|UAT bug|prod bug|miss \d{4}-|SECURITY"
16
+ DEFECT = re.compile(rf"⚠\s*(?:{OTHER}|{VERIFY})\b")
17
+ UNSEEN = re.compile(rf"⚠\s*(?:{OTHER})\b")
18
+
19
+
20
+ def clauses(remarks, pattern=DEFECT):
21
+ """The defect clauses of a Remarks cell: each runs from its ⚠ mark to the next ⚠ or the end."""
22
+ return [c.strip().rstrip(";").strip() for c in re.split(r"(?=⚠)", remarks or "") if pattern.match(c)]
23
+
24
+
25
+ def unseen(remarks):
26
+ """The clauses a verify cannot see, so a verify must neither erase them nor pass over them."""
27
+ return clauses(remarks, UNSEEN)