@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
@@ -48,13 +48,18 @@ _detect() {
48
48
  fi
49
49
  local k
50
50
  for k in $(compgen -v | grep '^OPENCODE' 2>/dev/null); do printf 'opencode'; return; done
51
- # /proc ancestry walk (Linux/WSL); bounded
51
+ # ancestry walk, bounded: /proc on Linux/WSL, ps where there is no /proc (macOS)
52
52
  local pid=$PPID name ppid seen=0 stat
53
53
  while [[ -n "$pid" && "$pid" -gt 1 && $seen -lt 12 ]]; do
54
54
  seen=$((seen + 1))
55
- stat="$(cat /proc/$pid/stat 2>/dev/null)" || break
56
- name="${stat#*(}"; name="${name%%)*}"
57
- ppid="$(printf '%s' "${stat##*) }" | awk '{print $2}')"
55
+ if [[ -d /proc/self ]]; then
56
+ stat="$(cat /proc/$pid/stat 2>/dev/null)" || break
57
+ name="${stat#*(}"; name="${name%%)*}"
58
+ ppid="$(printf '%s' "${stat##*) }" | awk '{print $2}')"
59
+ else
60
+ ppid="$(ps -o ppid= -p "$pid" 2>/dev/null | tr -d ' ')"; [[ -n "$ppid" ]] || break
61
+ name="$(ps -o comm= -p "$pid" 2>/dev/null)"; name="${name##*/}"
62
+ fi
58
63
  case "$name" in
59
64
  *opencode*) printf 'opencode'; return ;;
60
65
  *claude*) printf 'claude-code'; return ;;
@@ -0,0 +1,29 @@
1
+ # tf-lock.sh — one build or browser check at a time per repository. Sourced, not run.
2
+ # source "$HERE/tf-lock.sh"; tf_take_lock <label> waits for the lock, then holds it until exit
3
+ # A directory, because mkdir is atomic on every drive this runs on. A lock whose process has gone
4
+ # is taken over. A script started by the holder (tf-verify-tests.sh running tf-build.sh) shares it
5
+ # through TF_REPO_LOCK_PID. TF_BUILD_LOCK_MAX seconds to wait (1800). (TF-043, TfLens TF-048)
6
+ TF_LOCK="tests/.artifacts/build/.lock"
7
+ tf_release_lock() { [[ "$(cut -d' ' -f1 "$TF_LOCK/owner" 2>/dev/null)" == "$$" ]] && rm -rf "$TF_LOCK"; }
8
+ tf_take_lock() {
9
+ local label="${1:-}" waited=0 max="${TF_BUILD_LOCK_MAX:-1800}" said=0 owner opid ohost
10
+ if [[ -n "${TF_REPO_LOCK_PID:-}" ]] && kill -0 "$TF_REPO_LOCK_PID" 2>/dev/null; then return 0; fi
11
+ mkdir -p "$(dirname "$TF_LOCK")"
12
+ while ! mkdir "$TF_LOCK" 2>/dev/null; do
13
+ owner="$(cat "$TF_LOCK/owner" 2>/dev/null)"; opid="$(cut -d' ' -f1 <<<"$owner")"; ohost="$(cut -d' ' -f2 <<<"$owner")"
14
+ if [[ -z "$owner" ]]; then
15
+ [[ -n "$(find "$TF_LOCK" -maxdepth 0 -mmin +1 2>/dev/null)" ]] && { rm -rf "$TF_LOCK"; continue; }
16
+ elif [[ "$ohost" == "$(hostname)" ]] && ! kill -0 "$opid" 2>/dev/null; then
17
+ rm -rf "$TF_LOCK"; continue
18
+ fi
19
+ [[ $said -eq 0 ]] && { echo "wait another build or browser check is running in this repository (${owner:-starting}); this one starts when it finishes" >&2; said=1; }
20
+ if [[ $waited -ge $max ]]; then
21
+ echo "NOT-RUN another build or browser check held this repository for $((max / 60)) minutes ($owner). If nothing is running, remove $TF_LOCK and run again. Not a code error"
22
+ exit 2
23
+ fi
24
+ sleep 3; waited=$((waited + 3))
25
+ done
26
+ echo "$$ $(hostname) $label $(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$TF_LOCK/owner"
27
+ export TF_REPO_LOCK_PID=$$
28
+ trap tf_release_lock EXIT
29
+ }
@@ -16,7 +16,8 @@ re-logged; a null why_missed or sort on it is completed with --amend); the origi
16
16
  (tf-emit.sh --origin-of); the miss record with the sentence as `what` and the sort; the checklist line
17
17
  (the row demoted to Needs re-verify with "⚠ miss <date>: <sentence>", or a new Not Started row with
18
18
  BRD-pending when nothing owns it; nothing when the repo has no checklist); with --fixed, a miss-fix
19
- record instead of a demotion; then the run record (cmd log-miss). The emitter rewrites the readable
19
+ record instead of a demotion; then the run record (cmd log-miss) — none when another command is
20
+ running, because that command's own record covers the time (TF-029). The emitter rewrites the readable
20
21
  docs/<App>-Misses.md. Prints the report block. Exit 0 · 2 could not run.
21
22
  """
22
23
  import datetime
@@ -91,11 +92,31 @@ def main(argv):
91
92
  found_by = opt(argv, "--found-by", "owner")
92
93
  fixed = "--fixed" in argv
93
94
  started = opt(argv, "--started") or ""
95
+ now = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
96
+ # The command whose run record covers this time. Called inside another command (*amend-docs
97
+ # step 10), that command's record covers it, so this script writes none: a log-miss run over
98
+ # the enclosing command's window made the enclosing record overlap and be refused (TF-029).
99
+ # A marker older than 24 h belongs to a session that died, and is ignored as every reader does.
100
+ running = "log-miss"
94
101
  if not started:
102
+ # The marker's own cmd and started, never its "outer" block: a command started after another
103
+ # one keeps that one as "outer", written first, and a first-match read took it for this one
104
+ # (TF-052 follow-up: log-miss after *triage-issues wrote no run record).
95
105
  mk = subprocess.run(["bash", os.path.join(HERE, "tf-phase.sh"), "show"], capture_output=True, text=True).stdout
96
- m = re.search(r'"started":"([^"]*)"', mk)
97
- started = m.group(1) if m else datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
98
- now = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
106
+ try:
107
+ marker = json.loads(mk) if mk.strip().startswith("{") else {}
108
+ except ValueError:
109
+ marker = {}
110
+ m_started, m_cmd = marker.get("started") or "", marker.get("cmd") or ""
111
+ try:
112
+ age = (datetime.datetime.now(datetime.timezone.utc)
113
+ - datetime.datetime.strptime(m_started, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=datetime.timezone.utc))
114
+ fresh = age < datetime.timedelta(hours=24)
115
+ except ValueError:
116
+ fresh = False
117
+ started = m_started if fresh else now
118
+ if fresh and m_cmd and m_cmd not in ("log-miss", "goal"):
119
+ running = m_cmd
99
120
 
100
121
  spec = importlib.util.spec_from_file_location("tf_checklist_edit", os.path.join(HERE, "tf-checklist-edit.py"))
101
122
  ce = importlib.util.module_from_spec(spec)
@@ -165,10 +186,14 @@ def main(argv):
165
186
  fix_ok, _ = emit("misses", fr)
166
187
  fix_line = ("closed in the same run" + (f" against run {fr['fix_run_id']}" if "fix_run_id" in fr else " (fix run unknown: costed none)")) if fix_ok else "miss-fix not written"
167
188
 
168
- # 5. the run record
169
- run = {"kind": "run", "app": app, "cmd": "log-miss", "mode": None, "started": started, "ended": now,
170
- "reqs_touched": [req] if req else [], "reqs_count": 1 if req else 0, "subagents": [], "files_written": 1 if has_cl else 0, "build_result": "not-run"}
171
- emit("runs", run)
189
+ # 5. the run record — only when log-miss is the command running
190
+ if running == "log-miss":
191
+ run = {"kind": "run", "app": app, "cmd": "log-miss", "mode": None, "started": started, "ended": now,
192
+ "reqs_touched": [req] if req else [], "reqs_count": 1 if req else 0, "subagents": [], "files_written": 1 if has_cl else 0, "build_result": "not-run"}
193
+ emit("runs", run)
194
+ run_line = f"log-miss, {started} to {now}"
195
+ else:
196
+ run_line = f"none written — the running *{running}'s own record covers this time"
172
197
 
173
198
  if not ok:
174
199
  print(f"# Miss NOT recorded — {app}")
@@ -189,6 +214,7 @@ def main(argv):
189
214
  print(f"Docs : {doc_line}; docs/{app}-Misses.md rewritten")
190
215
  if fix_line:
191
216
  print(f"Fix : {fix_line}")
217
+ print(f"Run record : {run_line}")
192
218
  print(f"Next : *fix-issues {app}" + (f" {req}" if req and not fixed else ""))
193
219
  return 0
194
220
 
@@ -0,0 +1,167 @@
1
+ // TechieFlow — the one sign-in recipe the browser-driven checks share (AppManager TF-011, 2026-09-14).
2
+ //
3
+ // tf-verify-screens.mjs learned to sign in through a page's own form (TF-033, TF-044) and to reach a
4
+ // screen whose document answers 401 because the sign-in lives in the page, not in a cookie
5
+ // (AppManager TF-006). tf-mockup-parity.mjs and tf-assets took only --cookie, so on such an app they
6
+ // could not get past the sign-in at all. The recipe lives here so every check reaches a screen the
7
+ // same way, and a lesson learned in one is learned in all.
8
+ //
9
+ // signIn(page, o) -> { attempted, ok, url, attempts, error }
10
+ // signedOut(page, o) -> true when the page is the sign-in page
11
+ // navigateInApp(page, o, route) -> true when the page drew something different
12
+ // reach(page, o, route) -> { status, url, reached, signedOut, error }: open a route, signed in
13
+ //
14
+ // o = { base, loginPath, user, password, attr = 'data-testid', settle = 1500, renderWait = 5000 }
15
+
16
+ // The first visible, usable match, trying the selectors IN ORDER. `.first()` over one selector list
17
+ // takes whichever match comes first in the page, so `input[type="text"]` at the end of the list
18
+ // picked a header search box standing before the email field (TF-044).
19
+ export async function pick(scope, selectors) {
20
+ for (const sel of selectors) {
21
+ const c = scope.locator(sel).first();
22
+ if (await c.count() && await c.isVisible().catch(() => false)) return c;
23
+ }
24
+ return null;
25
+ }
26
+
27
+ const LOGIN_ATTEMPTS = 4;
28
+ const opts = (o) => ({ attr: 'data-testid', settle: 1500, renderWait: 5000, ...o, base: (o.base || '').replace(/\/$/, '') });
29
+
30
+ export async function signIn(page, o) {
31
+ const { base, loginPath, user, password, attr, settle } = opts(o);
32
+ if (!loginPath || !user) return { attempted: false };
33
+ const onLogin = () => page.url().startsWith(base + loginPath);
34
+ try {
35
+ await page.goto(base + loginPath, { waitUntil: 'domcontentloaded', timeout: 30000 });
36
+ await page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {});
37
+ // a page that draws its form a moment after it arrives has no field to pick yet
38
+ await page.waitForSelector('input', { state: 'visible', timeout: 15000 }).catch(() => {});
39
+ const INPUT = ':is(input, textarea)';
40
+ const USER_FIELD = [`${INPUT}[${attr}*="email" i]`, `${INPUT}[${attr}*="user" i]`, 'input[type="email"]', 'input[autocomplete="username"]',
41
+ 'input[name*="email" i]', 'input[name*="user" i]', 'input[id*="email" i]', 'input[id*="user" i]', 'form input[type="text"]'];
42
+ const PASS_FIELD = ['input[type="password"]', `${INPUT}[${attr}*="pass" i]`];
43
+ // The button, never a field: "login-email" and "login-pass" contain "login" too, and taking the
44
+ // first test id with "login" in it clicked the email box, so sign-in never submitted (TF-033).
45
+ const BTN = `:is(button, a, [role="button"], input[type="submit"], input[type="button"])`;
46
+ const BUTTON = ['button[type="submit"]', 'input[type="submit"]', `${BTN}[${attr}*="submit" i]`, `${BTN}[${attr}*="signin" i]`,
47
+ `${BTN}[${attr}*="sign-in" i]`, `${BTN}[${attr}*="login" i]`, 'button'];
48
+ // A page drawn on the server first and made interactive a moment later replaces its fields when
49
+ // it becomes interactive: what was typed before that is gone, and a press before it does nothing.
50
+ // Waiting for the network proves neither, and every stack takes its own time. So the test is the
51
+ // result: the typed values are still in the fields when the button is pressed, and the page then
52
+ // leaves the sign-in address. Otherwise type again and press again. TfLens, 2026-09-12: the email
53
+ // read back empty the instant after it was typed, and the second attempt signed in (TF-044).
54
+ let why = 'the typed values did not stay in the fields';
55
+ for (let attempt = 1; attempt <= LOGIN_ATTEMPTS; attempt++) {
56
+ if (!onLogin()) break;
57
+ const u = await pick(page, USER_FIELD);
58
+ if (!u) return { attempted: true, ok: false, error: 'no user or email field found on the sign-in page' };
59
+ const p = await pick(page, PASS_FIELD);
60
+ try {
61
+ await u.fill(user, { timeout: 10000 });
62
+ if (p) await p.fill(password || '', { timeout: 10000 });
63
+ await page.waitForTimeout(400);
64
+ const kept = (await u.inputValue()) === user && (!p || (await p.inputValue()) === (password || ''));
65
+ if (!kept) { why = 'the typed values did not stay in the fields'; continue; }
66
+ const form = u.locator('xpath=ancestor::form[1]');
67
+ const btn = (await form.count() ? await pick(form, BUTTON) : null) || await pick(page, BUTTON);
68
+ if (!btn) return { attempted: true, ok: false, error: 'no sign-in button found (a submit button, or a button whose test id says submit, signin or login)' };
69
+ await btn.click({ timeout: 10000 });
70
+ await page.waitForURL((x) => !x.href.startsWith(base + loginPath), { timeout: 6000 }).catch(() => {});
71
+ why = 'the form was submitted with the values in place and the page stayed on the sign-in address';
72
+ } catch (e) {
73
+ if (onLogin()) why = e.message.split('\n')[0];
74
+ }
75
+ if (!onLogin()) {
76
+ await page.waitForLoadState('networkidle', { timeout: 15000 }).catch(() => {});
77
+ await page.waitForTimeout(settle);
78
+ return { attempted: true, ok: true, url: page.url(), attempts: attempt };
79
+ }
80
+ }
81
+ if (!onLogin()) return { attempted: true, ok: true, url: page.url(), attempts: 1 };
82
+ return { attempted: true, ok: false, url: page.url(), attempts: LOGIN_ATTEMPTS, error: `still on the sign-in page after ${LOGIN_ATTEMPTS} attempts: ${why}` };
83
+ } catch (e) {
84
+ return { attempted: true, ok: false, error: e.message.split('\n')[0] };
85
+ }
86
+ }
87
+
88
+ // The page is signed out when it sits on the sign-in address, or when it draws a sign-in form: a
89
+ // password field WITH a button that says sign in / log in beside it. A password field alone is not
90
+ // the sign-in page — AppManager's signed-in "Create user" form has one for the new account, and
91
+ // reading it as the sign-in page graded that screen unreachable (AppManager TF-012).
92
+ export async function signedOut(page, o) {
93
+ const { loginPath, attr } = opts(o);
94
+ try {
95
+ if (loginPath && new URL(page.url()).pathname.startsWith(loginPath)) return true;
96
+ const pw = page.locator('input[type="password"]').first();
97
+ if (!(await pw.count()) || !(await pw.isVisible().catch(() => false))) return false;
98
+ const form = pw.locator('xpath=ancestor::form[1]');
99
+ const scope = (await form.count()) ? form : page;
100
+ const BTN = ':is(button, a, [role="button"], input[type="submit"], input[type="button"])';
101
+ const said = scope.locator(`${BTN}:visible`).filter({ hasText: /^\s*(sign\s*in|log\s*in|login|signin)\s*$/i }).first();
102
+ if (await said.count()) return true;
103
+ const named = scope.locator(`${BTN}[${attr}*="signin" i], ${BTN}[${attr}*="sign-in" i], ${BTN}[${attr}*="login" i]`).first();
104
+ return (await named.count()) > 0 && (await named.isVisible().catch(() => false));
105
+ } catch (e) { return true; }
106
+ }
107
+
108
+ // Open a route from inside the page, the way its own links do, so a sign-in held by the page survives.
109
+ // It counts only when the page drew something different: a server-drawn page has no router to answer
110
+ // the event, and grading the page it was already on under another screen's name would be a false pass.
111
+ export async function navigateInApp(page, o, route) {
112
+ const { settle } = opts(o);
113
+ try {
114
+ const before = await page.evaluate(() => document.body ? document.body.innerText : '');
115
+ await page.evaluate((r) => { history.pushState({}, '', r); window.dispatchEvent(new PopStateEvent('popstate', { state: {} })); }, route);
116
+ await page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {});
117
+ await page.waitForTimeout(settle);
118
+ const after = await page.evaluate(() => document.body ? document.body.innerText : '');
119
+ return after !== before;
120
+ } catch (e) { return false; }
121
+ }
122
+
123
+ // Wait for the page to draw something: an anchored control, text, or a form control (TF-021).
124
+ export async function waitForRender(page, o, anchors = []) {
125
+ const { attr, renderWait } = opts(o);
126
+ if (renderWait <= 0) return 0;
127
+ const t0 = Date.now();
128
+ try {
129
+ await page.waitForFunction(({ anchors, attr }) => {
130
+ if (anchors && anchors.length) return anchors.some((id) => document.querySelector(`[${attr}="${CSS.escape(id)}"]`));
131
+ return (document.body && (document.body.innerText || '').trim().length > 0)
132
+ || document.querySelectorAll('input, select, textarea, img, svg, canvas, video').length > 0;
133
+ }, { anchors, attr }, { timeout: renderWait, polling: 100 });
134
+ } catch (e) { /* nothing appeared in time: grade what is there */ }
135
+ return Date.now() - t0;
136
+ }
137
+
138
+ // Open a route signed in. The document of a signed-in screen may answer 401 or 403 when the sign-in
139
+ // lives in the page's own connection and not in a cookie (AppManager TF-006): then the page is judged
140
+ // by what it draws. Signed in, the screen is reached and `reached` says how. Still signed out, the
141
+ // tool signs in again and opens the screen from inside the page. Returns the status to grade on:
142
+ // 200 when the page drew the screen signed in, whatever the document answered.
143
+ export async function reach(page, o, route) {
144
+ const { base, loginPath, user, settle } = opts(o);
145
+ let status = 0, url = '', error = '';
146
+ try {
147
+ const resp = await page.goto(base + route, { waitUntil: 'domcontentloaded', timeout: 30000 });
148
+ await page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {});
149
+ await page.waitForTimeout(settle);
150
+ status = resp ? resp.status() : 0; url = page.url();
151
+ } catch (e) { return { status: 0, url: '', reached: '', signedOut: false, error: e.message.split('\n')[0] }; }
152
+ const out = { status, document_status: status, url, reached: '', signedOut: false, error };
153
+ if (loginPath && user && (status === 401 || status === 403)) {
154
+ await waitForRender(page, o);
155
+ let how = `answered HTTP ${status}, then drew the screen signed in`;
156
+ if (await signedOut(page, o)) {
157
+ how = '';
158
+ const again = await signIn(page, o);
159
+ if (again.ok && await navigateInApp(page, o, route) && !(await signedOut(page, o))) how = `answered HTTP ${status}; opened from inside the page after signing in`;
160
+ }
161
+ if (how) { out.reached = how; out.status = 200; out.url = page.url(); }
162
+ else out.signedOut = true;
163
+ } else if (loginPath && url && new URL(url).pathname.startsWith(loginPath) && !route.startsWith(loginPath)) {
164
+ out.signedOut = true;
165
+ }
166
+ return out;
167
+ }
@@ -0,0 +1,92 @@
1
+ #!/usr/bin/env bash
2
+ # tf-maccatalyst-check.sh — the Mac Catalyst rules a MAUI project must meet before its Mac app can
3
+ # start (coding-standards-dotnet.md §9; 2026-10-07). Reads files only: runs the same on Windows (WSL,
4
+ # Git Bash), Linux and a Mac, so a team without a Mac finds these before the Mac build does.
5
+ #
6
+ # bash .tfcore/utils/tf-maccatalyst-check.sh [<csproj> ...] (default: every project with a
7
+ # net*-maccatalyst target)
8
+ #
9
+ # FAIL the scene manifest: Platforms/MacCatalyst/Info.plist has no UIApplicationSceneManifest, or
10
+ # names a scene delegate no class in the project registers as a MauiUISceneDelegate. On
11
+ # macOS 27 such an app quits at its first window (the stock `dotnet new maui` template has none).
12
+ # WARN the keychain: an entitlements file asks for keychain-access-groups and the project names no
13
+ # provisioning profile and no Debug entitlements file without it. macOS refuses to start a
14
+ # locally signed (ad hoc) build that asks for it ("Launch failed", POSIX 163).
15
+ # WARN zoom (§9b): a project drawing its screens in a BlazorWebView, Mac or Windows head, whose code has
16
+ # no zoom wiring for that head: Mac, WKWebView.PageZoom and a "Zoom In" menu command; Windows,
17
+ # WebView2's IsZoomControlEnabled set by the app; either, the level kept through Preferences. Copy
18
+ # .tfcore/standards/blazor-zoom.cs.txt. A WARN, so tf-verify-boot.sh still boots such a head.
19
+ # Prints one line per finding and an OK line per clean project. A Windows-only MAUI project is read
20
+ # for the zoom rule alone.
21
+ # Exit 0 no FAIL (WARNs allowed) · 1 a FAIL · 3 usage.
22
+ set -u
23
+ case "${1:-}" in -h|--help) sed -n '2,22p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; esac
24
+
25
+ PROJECTS=("$@")
26
+ if [[ ${#PROJECTS[@]} -eq 0 ]]; then
27
+ while IFS= read -r p; do PROJECTS+=("$p"); done < <(
28
+ find . -maxdepth 5 -name '*.csproj' -not -path '*/bin/*' -not -path '*/obj/*' -not -path '*/node_modules/*' \
29
+ -not -path './tests/.artifacts/*' 2>/dev/null | sed 's#^\./##' | sort)
30
+ fi
31
+ fails=0; seen=0
32
+ for P in ${PROJECTS[@]+"${PROJECTS[@]}"}; do
33
+ [[ -f "$P" ]] || { echo "tf-maccatalyst-check: $P does not exist" >&2; exit 3; }
34
+ grep -qiE '<UseMaui>[[:space:]]*true|Microsoft\.NET\.Sdk\.Maui' "$P" || continue
35
+ mac=0; win=0
36
+ grep -qE 'net[0-9.]+-maccatalyst' "$P" && mac=1
37
+ grep -qE 'net[0-9.]+-windows' "$P" && win=1
38
+ [[ $mac -eq 1 || $win -eq 1 ]] || continue
39
+ seen=$((seen+1)); D="$(dirname "$P")"; M="$D/Platforms/MacCatalyst"; found=0
40
+ # --- zoom in a Blazor Hybrid head (§9b): read before the Mac-only rules, which a Windows-only project skips
41
+ CODE=(-rqs --include='*.cs' --exclude-dir=bin --exclude-dir=obj)
42
+ if grep "${CODE[@]}" --include='*.xaml' -E '<BlazorWebView|new BlazorWebView' "$D" \
43
+ || grep -qs 'Microsoft.AspNetCore.Components.WebView.Maui' "$P"; then
44
+ heads=()
45
+ if [[ $mac -eq 1 ]] && ! { grep "${CODE[@]}" 'PageZoom' "$D" && grep "${CODE[@]}" '"Zoom In"' "$D"; }; then heads+=("Mac (no WKWebView.PageZoom and View › Zoom In)"); fi
46
+ if [[ $win -eq 1 ]] && ! grep "${CODE[@]}" 'IsZoomControlEnabled' "$D"; then heads+=("Windows (WebView2's zoom not handled)"); fi
47
+ if [[ ${#heads[@]} -eq 0 ]] && ! grep -rlis --include='*.cs' --exclude-dir=bin --exclude-dir=obj 'zoom' "$D" | xargs grep -ls 'Preferences' >/dev/null 2>&1; then heads+=("the level is not kept in Preferences"); fi
48
+ if [[ ${#heads[@]} -gt 0 ]]; then
49
+ echo "WARN $P: a Blazor Hybrid head without OS zoom: $(IFS=';'; echo "${heads[*]}" | sed 's/;/; /g'). Cmd/Ctrl+=, -, 0 must zoom 50% to 300% and keep the level; copy .tfcore/standards/blazor-zoom.cs.txt (coding-standards-dotnet.md §9b)"
50
+ found=1
51
+ fi
52
+ fi
53
+ if [[ $mac -eq 0 ]]; then
54
+ [[ $found -eq 0 ]] && echo "OK $P: zoom wired (no Mac Catalyst target)"
55
+ continue
56
+ fi
57
+ # --- the scene manifest and its delegate
58
+ PL="$M/Info.plist"
59
+ if [[ ! -f "$PL" ]]; then
60
+ echo "FAIL $P: $PL is missing"; fails=$((fails+1)); found=1
61
+ elif ! grep -q 'UIApplicationSceneManifest' "$PL"; then
62
+ echo "FAIL $P: $PL has no UIApplicationSceneManifest; on macOS 27 the app quits at its first window. Add the manifest and a SceneDelegate : MauiUISceneDelegate (coding-standards-dotnet.md §9)"
63
+ fails=$((fails+1)); found=1
64
+ else
65
+ # the delegate the manifest names: the <string> after UISceneDelegateClassName
66
+ DEL="$(tr -d '\n\r' < "$PL" | grep -oE '<key>UISceneDelegateClassName</key>[[:space:]]*<string>[^<]+' | head -1 | sed 's/.*<string>//')"
67
+ if [[ -n "$DEL" ]] && ! grep -rqsE "Register\(\"$DEL\"\)" "$D" --include='*.cs'; then
68
+ echo "FAIL $P: Info.plist names the scene delegate $DEL, and no class in the project carries [Register(\"$DEL\")] (a class deriving from MauiUISceneDelegate)"
69
+ fails=$((fails+1)); found=1
70
+ elif [[ -n "$DEL" ]] && ! grep -rqs 'MauiUISceneDelegate' "$D" --include='*.cs'; then
71
+ echo "FAIL $P: [Register(\"$DEL\")] is there, but no class derives from MauiUISceneDelegate"
72
+ fails=$((fails+1)); found=1
73
+ fi
74
+ fi
75
+ # --- the keychain permission on a locally signed build
76
+ ENTS=()
77
+ while IFS= read -r e; do [[ -n "$e" ]] && ENTS+=("$D/$(tr '\\' '/' <<<"$e")"); done < <(
78
+ grep -oE '<CodesignEntitlements[^>]*>[^<]+' "$P" | sed 's/<CodesignEntitlements[^>]*>//') # a Condition= one too
79
+ [[ ${#ENTS[@]} -eq 0 && -f "$M/Entitlements.plist" ]] && ENTS=("$M/Entitlements.plist")
80
+ with=0; without=0
81
+ for e in ${ENTS[@]+"${ENTS[@]}"}; do
82
+ [[ -f "$e" ]] || continue
83
+ if grep -q 'keychain-access-groups' "$e"; then with=1; else without=1; fi
84
+ done
85
+ if [[ $with -eq 1 && $without -eq 0 ]] && ! grep -q '<CodesignProvisioningProfile>' "$P"; then
86
+ echo "WARN $P: the entitlements ask for keychain-access-groups and the project names no provisioning profile; macOS will not start a locally signed build that asks for it. Sign with a development certificate and profile, or give Debug builds an entitlements file without it (coding-standards-dotnet.md §9)"
87
+ found=1
88
+ fi
89
+ [[ $found -eq 0 ]] && echo "OK $P: scene manifest and delegate in place; no keychain permission a local build cannot have; zoom wired or not a Blazor Hybrid head"
90
+ done
91
+ [[ $seen -eq 0 ]] && echo "NONE no MAUI project with a Mac Catalyst or Windows target"
92
+ [[ $fails -eq 0 ]]