@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
@@ -2,7 +2,9 @@
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
@@ -11,30 +13,151 @@
11
13
  # NOT-TESTED when every test carrying its id was SKIPPED, and absent when no test carries its id.
12
14
  # A skipped clause is a third outcome, never a failure: `test.skip(!SEEDED, …)` says the state does
13
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.
14
23
  # Exit 0 ran (whatever the results) · 2 nothing could run.
15
24
  set -uo pipefail
16
25
  HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
17
- 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
18
28
  while [[ $# -gt 0 ]]; do
19
29
  case "$1" in
20
30
  --base) BASE="${2:-}"; shift 2 ;;
21
31
  --target) TARGET="${2:-}"; shift 2 ;;
22
32
  --no-browser) BROWSER=0; shift ;;
23
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 ;;
24
39
  --json-out) OUT="${2:-}"; shift 2 ;;
25
- -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 ;;
26
41
  *) echo "tf-verify-tests: unknown argument $1" >&2; exit 2 ;;
27
42
  esac
28
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"
29
50
  mkdir -p "$(dirname "$OUT")" tests/.artifacts/verify
30
- 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=""
31
121
  ran_any=0
32
122
 
33
123
  if [[ $BROWSER -eq 1 ]]; then
34
124
  if ls tests/verify/*.spec.* >/dev/null 2>&1 || ls tests/verify/**/*.spec.* >/dev/null 2>&1; then
35
- if node -e "require.resolve('@playwright/test')" >/dev/null 2>&1; then
36
- BASE_URL="$BASE" PLAYWRIGHT_JSON_OUTPUT_NAME="$PWJSON" npx playwright test --reporter=json > "$PWLOG" 2>&1
37
- 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
38
161
  else
39
162
  echo "browser tests: @playwright/test is not installed here (bash .tfcore/utils/tf-verify-env.sh); skipped"; rm -f "$PWJSON"
40
163
  fi
@@ -44,17 +167,61 @@ if [[ $BROWSER -eq 1 ]]; then
44
167
  else rm -f "$PWJSON"; fi
45
168
 
46
169
  if [[ $UNIT -eq 1 ]]; then
47
- if [[ -n "$TARGET" ]] || ls *.sln *.slnx >/dev/null 2>&1 || ls tests/*/*.csproj >/dev/null 2>&1; then
48
- 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)"
49
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"
50
216
  echo "unit tests: $UNITLINE"; [[ "$UNITLINE" != NOT-RUN* ]] && ran_any=1
51
217
  else
52
218
  echo "unit tests: no solution or test project found; skipped"
53
219
  fi
54
220
  fi
55
221
 
56
- 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'
57
223
  import json, os, re
224
+ import xml.etree.ElementTree as ET
58
225
  ID = re.compile(r"(REQ-(?:UI|FN|NFR|RAG)-\d{3})", re.I)
59
226
  reqs = {}
60
227
  browser = {"ran": False, "passed": 0, "failed": 0, "skipped": 0, "tests": 0}
@@ -72,7 +239,9 @@ def add(rid, outcome, name, source, reason="", shot=""):
72
239
  defect. tests/regression/run.sh tf_022."""
73
240
  r = reqs.setdefault(rid.upper(), {"result": "NOT-TESTED", "source": source, "tests": [],
74
241
  "skipped": [], "passed": 0, "failed": 0,
75
- "reason": "", "screenshot": ""})
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}
76
245
  if outcome == "skip":
77
246
  r["skipped"].append(name)
78
247
  if not r["reason"]:
@@ -132,25 +301,57 @@ if pw and os.path.isfile(pw):
132
301
  except Exception as e:
133
302
  browser["error"] = str(e)
134
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
+
135
328
  ul = os.environ.get("TF_UNITLOG", "")
136
329
  if ul and os.path.isfile(ul):
137
330
  unit["ran"] = True
138
331
  for line in open(ul, encoding="utf-8", errors="replace"):
139
- m = re.match(r"\s*(Passed|Failed|Skipped)\s+(\S.*?)\s*(\[[^\]]*\])?\s*$", line)
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)
140
334
  if not m:
141
335
  continue
142
336
  # a skipped unit test is recorded as skipped, not dropped: the row then reads
143
337
  # NOT-TESTED with the evidence, rather than looking as if no test exists (TF-022)
144
- outcome = {"Passed": "pass", "Failed": "fail", "Skipped": "skip"}[m.group(1)]
338
+ outcome = {"passed": "pass", "failed": "fail", "skipped": "skip"}[m.group(1).lower()]
145
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)
146
344
  for rid in set(ID.findall(name)):
147
345
  add(rid, outcome, name, "unit",
148
- ("unit test failed: " if outcome == "fail" else "unit test skipped: ") + name[:120])
346
+ "unit test failed: " + name[:120] if outcome == "fail"
347
+ else "unit test skipped: " + name[:120] if outcome == "skip" else "")
149
348
  unit["passed"] = sum(1 for r in reqs.values() if r["source"] == "unit" and r["result"] == "PASS")
150
349
  unit["failed"] = sum(1 for r in reqs.values() if r["source"] == "unit" and r["result"] == "FAIL")
151
350
  unit["not_tested"] = sum(1 for r in reqs.values() if r["source"] == "unit" and r["result"] == "NOT-TESTED")
152
351
 
153
- json.dump({"reqs": reqs, "browser": browser, "unit": unit}, open(os.environ["TF_OUT"], "w"), indent=1)
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)
154
355
  p = sum(1 for r in reqs.values() if r["result"] == "PASS")
155
356
  f = sum(1 for r in reqs.values() if r["result"] == "FAIL")
156
357
  ns = sum(1 for r in reqs.values() if r["result"] == "NOT-TESTED")
@@ -22,7 +22,9 @@ Verdicts: PASS · FAIL (acceptance) · BUILD-FAIL · RENDER-FAIL · ASSET-FAIL
22
22
  PERF-FAIL · NOT-TESTED (no test carried the id, or every test carrying it was skipped; the screen
23
23
  checks passed or did not run) ·
24
24
  NOT-OBSERVABLE (an NFR row with no budget and no unit test) · NOT-DRIVEN (its screen was never driven:
25
- 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
26
28
  status and get a Remark saying so; nothing is ever written as a static-only pass.
27
29
 
28
30
  Writes docs/.last-verify.json (the ledger the verify hook reads: date, app, scope, booted, the checks
@@ -38,6 +40,8 @@ import re
38
40
  import sys
39
41
 
40
42
  HERE = os.path.dirname(os.path.abspath(__file__))
43
+ sys.path.insert(0, HERE)
44
+ import tf_defect # noqa: E402
41
45
  FAILING = {"BUILD-FAIL": "build", "FAIL": "acceptance", "RENDER-FAIL": "render", "ASSET-FAIL": "assets",
42
46
  "VISUAL-FAIL": "visual", "MOCKUP-FAIL": "mockup-parity", "PERF-FAIL": "perf"}
43
47
  STATUS = {"PASS": "Verified", "FAIL": "FAIL", "BUILD-FAIL": "FAIL", "RENDER-FAIL": "Needs re-verify", "ASSET-FAIL": "Needs re-verify",
@@ -71,6 +75,14 @@ def norm_route(r):
71
75
  return r or "/"
72
76
 
73
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
+
74
86
  def words(s, n=60):
75
87
  w = s.split()
76
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)
@@ -92,6 +104,8 @@ def main(argv):
92
104
  tests_ran = os.path.isfile(os.path.join(d, "tests.json"))
93
105
  screens = load(os.path.join(d, "screens.json")) or {}
94
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 []}
95
109
  assets = load(os.path.join(d, "assets.json")) or {}
96
110
  pages = {norm_route(p.get("path")): p for p in assets.get("pages", [])}
97
111
  parity = load(os.path.join(d, "parity.json")) or {}
@@ -129,7 +143,7 @@ def main(argv):
129
143
  checks.append(("build", booted or build_failed, build_failed, "BUILD-FAIL", "build-error", boot.get("reason", "build failed"), ""))
130
144
  # 2 acceptance
131
145
  if t:
132
- 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 ""))
133
147
  # 3 render
134
148
  if scr:
135
149
  if scr.get("render") == "UNREACHABLE":
@@ -192,7 +206,10 @@ def main(argv):
192
206
  detail = "NFR row with no perf-budget and no unit test carrying its id"
193
207
  elif not t and r.get("screen") and not driven:
194
208
  verdict = "NOT-DRIVEN"
209
+ sk = scr_skipped.get(r["screen"])
195
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
196
213
  else f"screen {r['screen']} was not driven in this run")
197
214
  elif not t:
198
215
  verdict = "NOT-TESTED"
@@ -203,7 +220,7 @@ def main(argv):
203
220
  detail = f"test passed but {boot.get('head', 'the app')} was not driven: {boot.get('reason')}"
204
221
  else:
205
222
  verdict = "PASS"
206
- 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"]
207
224
  if driven:
208
225
  parts.append(f"{r['screen']} renders and looks right @{'/'.join(str(w['width']) for w in scr['widths'])}")
209
226
  if "mockup-parity" in ran:
@@ -212,22 +229,29 @@ def main(argv):
212
229
  parts.append("within its speed budget")
213
230
  detail = "; ".join(parts)
214
231
 
215
- 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)
216
238
  pct = r.get("pct")
217
239
  if verdict == "PASS":
218
240
  pct = 100
219
- 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"):
220
242
  pct = min(pct if pct is not None else 75, 75)
221
243
  gate = FAILING.get(verdict)
222
244
  marks = {"FAIL": "⚠ acceptance", "BUILD-FAIL": "⚠ build", "RENDER-FAIL": "⚠ render", "ASSET-FAIL": "⚠ assets",
223
245
  "VISUAL-FAIL": "⚠ visual", "MOCKUP-FAIL": "⚠ mockup-parity", "PERF-FAIL": "⚠ perf",
224
- "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"}
225
248
  remark = f"{today} verify: {marks[verdict]} — {detail}"
226
249
  if evidence:
227
250
  remark += f" ({evidence})"
228
251
  if notes:
229
252
  remark += "; " + "; ".join(notes[:2])
230
- 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 "")
231
255
  emit = verdict == "PASS" or gate is not None
232
256
  row = {"id": rid, "class": r["class"], "verdict": verdict, "gate": gate, "gates_run": ran, "failure_class": cls if gate else None,
233
257
  "prior_verdict": prior or None, "status": status, "pct": pct, "remark": remark, "screen": r.get("screen"),
@@ -240,8 +264,23 @@ def main(argv):
240
264
  for c in ("build", "acceptance", "render", "assets", "visual", "mockup-parity", "perf"):
241
265
  if any(c in x["gates_run"] for x in rows_out):
242
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", ""))
243
281
  ledger = {"date": today, "app": app, "scope": lst.get("scope", "all"), "booted": boot_text, "gates": checks,
244
- "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)}
245
284
  os.makedirs("docs", exist_ok=True)
246
285
  with open(os.path.join("docs", ".last-verify.json"), "w", encoding="utf-8") as f:
247
286
  json.dump(ledger, f, indent=1)
@@ -283,7 +322,7 @@ def main(argv):
283
322
  for t in table:
284
323
  print("| " + " | ".join(str(x).replace("|", "/") for x in t) + " |")
285
324
  print()
286
- 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')}."
287
326
  + (f" Checklist: {applied} row(s) rewritten in {lst.get('checklist')}." if apply else " Checklist untouched (no --apply)."))
288
327
  return 0
289
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)