@techierathore/techieflow 1.1.0 → 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 (98) 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/_yolo-mode.md +2 -2
  5. package/.claude/commands/TechieFlow/tasks/amend-docs.md +1 -1
  6. package/.claude/commands/TechieFlow/tasks/day1-brownfield.md +2 -2
  7. package/.claude/commands/TechieFlow/tasks/day1-greenfield.md +4 -4
  8. package/.claude/commands/TechieFlow/tasks/develop-end-to-end.md +30 -0
  9. package/.claude/commands/TechieFlow/tasks/fix-issues.md +1 -0
  10. package/.claude/commands/TechieFlow/tasks/mockups.md +1 -1
  11. package/.claude/commands/TechieFlow/tasks/triage-and-fix.md +2 -2
  12. package/.claude/commands/TechieFlow/tasks/verify-phase.md +12 -11
  13. package/.opencode/plugin/techieflow.js +15 -7
  14. package/.tfcore/agents/flow-master.md +2 -0
  15. package/.tfcore/agents/verifier.md +2 -2
  16. package/.tfcore/core-config.yaml +2 -1
  17. package/.tfcore/hooks/guard-db.sh +106 -9
  18. package/.tfcore/hooks/guard-verify-deps.sh +16 -1
  19. package/.tfcore/hooks/yolo-allow.sh +27 -0
  20. package/.tfcore/standards/blazor-zoom.cs.txt +191 -0
  21. package/.tfcore/standards/coding-standards-dotnet.md +31 -3
  22. package/.tfcore/tasks/_develop-mode.md +34 -0
  23. package/.tfcore/tasks/_yolo-mode.md +2 -2
  24. package/.tfcore/tasks/amend-docs.md +1 -1
  25. package/.tfcore/tasks/day1-brownfield.md +2 -2
  26. package/.tfcore/tasks/day1-greenfield.md +4 -4
  27. package/.tfcore/tasks/develop-end-to-end.md +30 -0
  28. package/.tfcore/tasks/fix-issues.md +1 -0
  29. package/.tfcore/tasks/mockups.md +1 -1
  30. package/.tfcore/tasks/triage-and-fix.md +2 -2
  31. package/.tfcore/tasks/verify-phase.md +12 -11
  32. package/.tfcore/telemetry/SCHEMA.md +6 -2
  33. package/.tfcore/telemetry/tf-metrics.sh +19 -0
  34. package/.tfcore/templates/stack-defaults/dotnet.md +3 -1
  35. package/.tfcore/templates/v4custom/app-architecture-tmpl.md +5 -0
  36. package/.tfcore/templates/v4custom/app-brd-tmpl.md +2 -1
  37. package/.tfcore/templates/v4custom/app-checklist-tmpl.md +11 -0
  38. package/.tfcore/templates/v4custom/app-phases-tmpl.md +2 -1
  39. package/.tfcore/templates/v4custom/html-render-shell.md +2 -0
  40. package/.tfcore/utils/__pycache__/tf-devguide-list.cpython-312.pyc +0 -0
  41. package/.tfcore/utils/__pycache__/tf-doc-check.cpython-312.pyc +0 -0
  42. package/.tfcore/utils/__pycache__/tf-split-brd.cpython-312.pyc +0 -0
  43. package/.tfcore/utils/__pycache__/tf-status-facts.cpython-312.pyc +0 -0
  44. package/.tfcore/utils/__pycache__/tf-verify-list.cpython-312.pyc +0 -0
  45. package/.tfcore/utils/tf-appium.mjs +179 -0
  46. package/.tfcore/utils/tf-build-list.py +16 -1
  47. package/.tfcore/utils/tf-build.sh +75 -0
  48. package/.tfcore/utils/tf-checklist-edit.py +21 -3
  49. package/.tfcore/utils/tf-day1-files.py +55 -28
  50. package/.tfcore/utils/tf-develop.sh +264 -0
  51. package/.tfcore/utils/tf-devguide-list.py +6 -2
  52. package/.tfcore/utils/tf-doc-check.py +83 -21
  53. package/.tfcore/utils/tf-doc-tests.sh +40 -3
  54. package/.tfcore/utils/tf-emit.sh +40 -10
  55. package/.tfcore/utils/tf-fix-close.sh +13 -7
  56. package/.tfcore/utils/tf-maccatalyst-check.sh +92 -0
  57. package/.tfcore/utils/tf-menus.mjs +401 -0
  58. package/.tfcore/utils/tf-mockup-parity.mjs +355 -27
  59. package/.tfcore/utils/tf-mockup-parity.sh +14 -3
  60. package/.tfcore/utils/tf-phase.sh +24 -4
  61. package/.tfcore/utils/tf-render-html.py +19 -3
  62. package/.tfcore/utils/tf-req-ids.py +36 -0
  63. package/.tfcore/utils/tf-stack-check.sh +3 -0
  64. package/.tfcore/utils/tf-status-facts.py +90 -15
  65. package/.tfcore/utils/tf-theme.mjs +189 -0
  66. package/.tfcore/utils/tf-triage.py +12 -1
  67. package/.tfcore/utils/tf-verify-boot.sh +274 -19
  68. package/.tfcore/utils/tf-verify-emit.sh +37 -17
  69. package/.tfcore/utils/tf-verify-list.py +219 -20
  70. package/.tfcore/utils/tf-verify-list.sh +3 -1
  71. package/.tfcore/utils/tf-verify-native.mjs +307 -0
  72. package/.tfcore/utils/tf-verify-screens.mjs +178 -20
  73. package/.tfcore/utils/tf-verify-screens.sh +17 -4
  74. package/.tfcore/utils/tf-verify-tests.sh +107 -17
  75. package/.tfcore/utils/tf-verify-verdict.py +118 -15
  76. package/.tfcore/utils/tf-verify-zoom.mjs +193 -0
  77. package/.tfcore/utils/tf-verify-zoom.sh +29 -0
  78. package/.tfcore/utils/tf-yolo-alias.sh +84 -0
  79. package/.tfcore/utils/tf-yolo-perms.py +135 -0
  80. package/.tfcore/utils/tf-yolo-start.sh +44 -0
  81. package/.tfcore/utils/tf-yolo.sh +31 -3
  82. package/.tfcore/utils/tf_feedback.py +79 -16
  83. package/.tfcore/utils/tf_stack_check.py +93 -0
  84. package/README.md +2 -2
  85. package/opencode.jsonc +6 -2
  86. package/package.json +1 -1
  87. package/scripts/install.mjs +13 -2
  88. package/.tfcore/utils/__pycache__/tf-brd-status.cpython-312.pyc +0 -0
  89. package/.tfcore/utils/__pycache__/tf-codex-bind.cpython-312.pyc +0 -0
  90. package/.tfcore/utils/__pycache__/tf-codex-telemetry.cpython-312.pyc +0 -0
  91. package/.tfcore/utils/__pycache__/tf-day1-files.cpython-312.pyc +0 -0
  92. package/.tfcore/utils/__pycache__/tf-log-miss.cpython-312.pyc +0 -0
  93. package/.tfcore/utils/__pycache__/tf-misses-md.cpython-312.pyc +0 -0
  94. package/.tfcore/utils/__pycache__/tf-mockups-locate.cpython-312.pyc +0 -0
  95. package/.tfcore/utils/__pycache__/tf-opencode-audit.cpython-312.pyc +0 -0
  96. package/.tfcore/utils/__pycache__/tf-render-html.cpython-312.pyc +0 -0
  97. package/.tfcore/utils/__pycache__/tf-triage.cpython-312.pyc +0 -0
  98. package/.tfcore/utils/__pycache__/tf-verify-verdict.cpython-312.pyc +0 -0
@@ -1015,16 +1015,34 @@ def _window_opencode(t0, t1):
1015
1015
  return None
1016
1016
  dbp = ptr.get("db_path")
1017
1017
  sid = ptr.get("session_id")
1018
- if not dbp or not sid or not os.path.isfile(dbp):
1018
+ if not dbp or not os.path.isfile(dbp):
1019
1019
  return None
1020
1020
  try:
1021
1021
  import sqlite3
1022
1022
  db = sqlite3.connect("file:%s?mode=ro" % dbp, uri=True)
1023
- pairs = db.execute("SELECT id, parent_id FROM session").fetchall()
1023
+ # OpenCode 2 keeps sessions in session_v2 and messages in session_message; the old tables
1024
+ # stopped on 2026-09-27, so every OpenCode record since read tokens_scope "none". Both are read.
1025
+ tables = {r[0] for r in db.execute("SELECT name FROM sqlite_master WHERE type='table'")}
1026
+ pairs = db.execute("SELECT id, parent_id FROM session").fetchall() if "session" in tables else []
1027
+ if "session_v2" in tables:
1028
+ pairs += db.execute("SELECT id, parent_id FROM session_v2").fetchall()
1024
1029
  kids = {}
1025
1030
  for i, p in pairs:
1026
1031
  kids.setdefault(p, []).append(i)
1027
- tree, queue = {sid}, [sid]
1032
+ # The roots: the pointer's session, and every root session OpenCode 2 recorded in THIS project's
1033
+ # folder. Each *develop-end-to-end phase runs in a new session and the plugin's pointer kept the
1034
+ # first one, so the later phases read "none" (2026-10-08). Messages are still taken only inside
1035
+ # the record's own window.
1036
+ roots = {sid} if sid else set()
1037
+ if "session_v2" in tables:
1038
+ here = os.path.realpath(root)
1039
+ for i, p_, dr in db.execute("SELECT id, parent_id, directory FROM session_v2"):
1040
+ if not p_ and dr and os.path.realpath(dr) == here:
1041
+ roots.add(i)
1042
+ if not roots:
1043
+ db.close()
1044
+ return None
1045
+ tree, queue = set(roots), list(roots)
1028
1046
  while queue:
1029
1047
  for c in kids.get(queue.pop(), []):
1030
1048
  if c not in tree:
@@ -1036,15 +1054,20 @@ def _window_opencode(t0, t1):
1036
1054
  # Same rule as the Claude branch: a CHILD session counts as a subagent run
1037
1055
  # for THIS window only if it produced output inside it.
1038
1056
  child_out = {}
1039
- q = "SELECT session_id, data FROM message WHERE time_created BETWEEN ? AND ?"
1040
- for msid, data in db.execute(q, (t0, t1)):
1057
+ rows = []
1058
+ if "message" in tables:
1059
+ rows += db.execute("SELECT session_id, data FROM message WHERE time_created BETWEEN ? AND ?", (t0, t1)).fetchall()
1060
+ if "session_message" in tables:
1061
+ rows += [(a, b) for a, b in db.execute(
1062
+ "SELECT session_id, data FROM session_message WHERE type = 'assistant' AND time_created BETWEEN ? AND ?", (t0, t1))]
1063
+ for msid, data in rows:
1041
1064
  if msid not in tree:
1042
1065
  continue
1043
1066
  try:
1044
1067
  d = json.loads(data)
1045
1068
  except Exception:
1046
1069
  continue
1047
- if d.get("role") != "assistant":
1070
+ if d.get("role") not in (None, "assistant"):
1048
1071
  continue
1049
1072
  t = d.get("tokens") or {}
1050
1073
  cache = t.get("cache") or {}
@@ -1054,9 +1077,11 @@ def _window_opencode(t0, t1):
1054
1077
  tot["cr"] += cache.get("read") or 0
1055
1078
  tot["cw"] += cache.get("write") or 0
1056
1079
  cost += d.get("cost") or 0
1057
- if msid != sid and o:
1080
+ if msid not in roots and o:
1058
1081
  child_out[msid] = child_out.get(msid, 0) + o
1059
- m = (d.get("providerID", "") + "/" if d.get("providerID") else "") + (d.get("modelID") or "")
1082
+ mo = d.get("model") if isinstance(d.get("model"), dict) else {}
1083
+ prov = d.get("providerID") or mo.get("providerID") or ""
1084
+ m = (prov + "/" if prov else "") + (d.get("modelID") or mo.get("id") or "")
1060
1085
  if m:
1061
1086
  models[m] = models.get(m, 0) + o
1062
1087
  db.close()
@@ -1463,7 +1488,7 @@ _ALLOWED = {
1463
1488
  "routed", "tokens_in", "tokens_out", "tokens_cache_read", "tokens_cache_write",
1464
1489
  "cost_usd", "tokens_scope", "attempt", "subagent_runs", "tokens_out_subagents",
1465
1490
  "model_tokens_out", "session_id", "usage_start_event_ts", "usage_end_event_ts",
1466
- "billing_mode", "cost_source", "fallback_from"},
1491
+ "billing_mode", "cost_source", "fallback_from", "app_phase"},
1467
1492
  # A correction, not a deletion: it names a run record that is wrong by the pair every
1468
1493
  # run carries (`cmd` + `started`) and says why, so every figure can skip it while the
1469
1494
  # record itself stays where it is. Written by --void-run, which refuses one that names
@@ -1474,7 +1499,7 @@ _ALLOWED = {
1474
1499
  "tier", "tier_model", "model", "models", "tokens_in", "tokens_out", "tokens_cache_read",
1475
1500
  "tokens_cache_write", "cost_usd", "tokens_scope", "model_tokens_out", "routed",
1476
1501
  "subagent_runs", "tokens_out_subagents", "session_id",
1477
- "billing_mode", "cost_source", "fallback_from"},
1502
+ "billing_mode", "cost_source", "fallback_from", "app_phase"},
1478
1503
  ("sessions", "session"): {"session_id", "model", "duration_s", "input_tokens", "output_tokens",
1479
1504
  "cache_read_tokens", "cache_creation_tokens", "cost_usd", "children_sessions"},
1480
1505
  ("commits", "commit"): {"sha", "files", "insertions", "deletions", "subject_prefix", "branch"},
@@ -1519,6 +1544,11 @@ def _shape_ok(rec):
1519
1544
  if kind == "run" and rec.get("build_result") not in (None, "pass", "fail", "not-run"):
1520
1545
  sys.stdout.write("tf-emit: REFUSED — build_result must be pass, fail or not-run (SCHEMA.md §2); got %r. Put the detail in the checklist Remarks or Known blockers, not here. Nothing was appended.\n" % (rec.get("build_result"),))
1521
1546
  return False
1547
+ # one REQ's verdict, never a suite note: Lekhak hand-wrote four {"gate":"verify-suite","result":…}
1548
+ # records with neither, and the report counted each as a failure (TF-020)
1549
+ if kind == "gate" and not rec.get("backfilled") and not (rec.get("req_id") and rec.get("verdict")):
1550
+ sys.stdout.write("tf-emit: REFUSED — a gate record is one REQ's verdict and needs both req_id and verdict (SCHEMA.md §3). A suite result (unit tests, the whole browser run) belongs in the run record's notes, not here. Nothing was appended.\n")
1551
+ return False
1522
1552
  if kind == "miss" and not rec.get("miss_id"):
1523
1553
  sys.stdout.write("tf-emit: REFUSED — a miss record needs a miss_id; get one with: bash .tfcore/utils/tf-emit.sh --next-miss-id. Nothing was appended.\n")
1524
1554
  return False
@@ -3,6 +3,7 @@
3
3
  #
4
4
  # bash .tfcore/utils/tf-fix-close.sh <App> [--started <ISO>] [--reqs REQ-UI-009,REQ-FN-014]
5
5
  # [--subagents trblazeui,builder] [--build pass|fail|not-run] [--files N]
6
+ # [--cmd triage-and-fix] # the command the run record is filed under
6
7
  # bash .tfcore/utils/tf-fix-close.sh <App> --misses MISS-App-20260901-06,MISS-App-20260830-03 \
7
8
  # --fix-cmd amend-docs [--verdict Verified]
8
9
  #
@@ -24,13 +25,16 @@
24
25
  # re-verify). A row no verify graded gets no miss-fix and is listed. A row with no open miss is listed,
25
26
  # not invented: a fix on a defect nobody logged is a triage gap, so open it first with tf-triage.sh
26
27
  # demote or tf-log-miss.sh, then re-run this.
28
+ # --cmd names the command the run record is filed under (default fix-issues): *triage-and-fix passes
29
+ # --cmd triage-and-fix, so its run is counted as itself and no second record over the same window is
30
+ # needed, which the overlap rule refused (TrBlazeUI TF-004). The miss-fix records keep fix_cmd fix-issues.
27
31
  # --reqs defaults to every row those verifies graded. The start defaults to the fix's marker, or its
28
32
  # "outer" entry when a chained verify replaced it. Telemetry never blocks: refusals are printed, exit 0.
29
33
  set -u
30
34
  HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
31
35
  [[ $# -ge 1 && "$1" != "-h" && "$1" != "--help" ]] || { sed -n '2,17p' "$0" | sed 's/^# \{0,1\}//'; exit 3; }
32
36
  APP="$1"; shift
33
- STARTED=""; REQS=""; SUBS=""; BUILD="pass"; FILES=""; MISSES=""; FIXCMD="fix-issues"; VERDICT=""
37
+ STARTED=""; REQS=""; SUBS=""; BUILD="pass"; FILES=""; MISSES=""; FIXCMD="fix-issues"; VERDICT=""; RUNCMD="fix-issues"
34
38
  while [[ $# -gt 0 ]]; do
35
39
  case "$1" in
36
40
  --started) STARTED="${2:-}"; shift 2 ;;
@@ -41,6 +45,7 @@ while [[ $# -gt 0 ]]; do
41
45
  --misses) MISSES="${2:-}"; shift 2 ;;
42
46
  --fix-cmd) FIXCMD="${2:-}"; shift 2 ;;
43
47
  --verdict) VERDICT="${2:-}"; shift 2 ;;
48
+ --cmd) RUNCMD="${2:-}"; shift 2 ;;
44
49
  *) echo "tf-fix-close: unknown argument $1" >&2; exit 3 ;;
45
50
  esac
46
51
  done
@@ -51,9 +56,10 @@ except Exception: m={}
51
56
  o=m.get('outer') or {}
52
57
  print(m.get('started','') if m.get('cmd')!='verify-phase' or not o.get('started') else o['started'])" 2>/dev/null)"
53
58
  TF_APP="$APP" TF_STARTED="$STARTED" TF_REQS="$REQS" TF_SUBS="$SUBS" TF_BUILD="$BUILD" TF_FILES="$FILES" \
54
- TF_MISSES="$MISSES" TF_FIXCMD="$FIXCMD" TF_VERDICT="$VERDICT" TF_EMIT="$HERE/tf-emit.sh" python3 - <<'PY'
59
+ TF_MISSES="$MISSES" TF_FIXCMD="$FIXCMD" TF_RUNCMD="$RUNCMD" TF_VERDICT="$VERDICT" TF_EMIT="$HERE/tf-emit.sh" python3 - <<'PY'
55
60
  import datetime, json, os, subprocess
56
61
  EMIT = os.environ["TF_EMIT"]; app = os.environ["TF_APP"]; started = os.environ["TF_STARTED"]
62
+ cmd = os.environ.get("TF_RUNCMD") or "fix-issues"
57
63
  now = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
58
64
  def q(*a): return subprocess.run(["bash", EMIT, *a], capture_output=True, text=True).stdout.strip()
59
65
  def emit(stream, rec):
@@ -121,7 +127,7 @@ inner, voided = [], set()
121
127
  for r in jsonl("runs.jsonl"):
122
128
  if r.get("kind") == "run-void":
123
129
  voided.add((r.get("cmd"), r.get("started")))
124
- elif r.get("kind", "run") == "run" and r.get("app") == app and r.get("cmd") != "fix-issues" and not r.get("backfilled") \
130
+ elif r.get("kind", "run") == "run" and r.get("app") == app and r.get("cmd") not in ("fix-issues", cmd) and not r.get("backfilled") \
125
131
  and started and (r.get("started") or "") >= started and (r.get("ended") or "") and r["ended"] <= now:
126
132
  inner.append((r["started"], r["ended"], r.get("cmd")))
127
133
  inner = sorted(w for w in inner if (w[2], w[0]) not in voided)
@@ -130,11 +136,11 @@ for s, e, _c in inner:
130
136
  if t and s > t: gaps.append((t, s))
131
137
  t = max(t, e) if t else e
132
138
  if not started or not gaps or t < now: gaps.append((t, now))
133
- existing = {r.get("started") for r in jsonl("runs.jsonl") if r.get("kind", "run") == "run" and r.get("cmd") == "fix-issues"}
139
+ existing = {r.get("started") for r in jsonl("runs.jsonl") if r.get("kind", "run") == "run" and r.get("cmd") == cmd}
134
140
  wrote = had = 0
135
141
  for i, (s, e) in enumerate(gaps):
136
142
  first = i == 0
137
- run = {"kind": "run", "app": app, "cmd": "fix-issues", "mode": "fix", "ended": e, "reqs_touched": reqs if first else [],
143
+ run = {"kind": "run", "app": app, "cmd": cmd, "mode": "fix", "ended": e, "reqs_touched": reqs if first else [],
138
144
  "reqs_count": len(reqs) if first else 0, "subagents": subs if first else [],
139
145
  "files_written": (int(files) if files.isdigit() else len(reqs)) if first else 0, "build_result": os.environ["TF_BUILD"]}
140
146
  if s: run["started"] = s
@@ -143,7 +149,7 @@ for i, (s, e) in enumerate(gaps):
143
149
  had += 1; continue
144
150
  wrote += emit("runs", run)
145
151
  if inner:
146
- print(f"fix-issues: recorded around {len(inner)} chained run(s) ({', '.join(sorted({c for _s, _e, c in inner}))}) in {len(gaps)} segment(s)")
152
+ print(f"{cmd}: recorded around {len(inner)} chained run(s) ({', '.join(sorted({c for _s, _e, c in inner}))}) in {len(gaps)} segment(s)")
147
153
  closed, none, unverified = 0, [], []
148
154
  for rid in reqs:
149
155
  if rid not in rows:
@@ -158,7 +164,7 @@ for rid in reqs:
158
164
  if started: rec["fix_run_id"] = started
159
165
  if emit("misses", rec): closed += 1
160
166
  state = "already there" if had == len(gaps) else "written" if wrote + had == len(gaps) else "not written"
161
- print(f"fix-issues: run record {state}; {closed} miss-fix record(s) with the verifier's verdict"
167
+ print(f"{cmd}: run record {state}; {closed} miss-fix record(s) with the verifier's verdict"
162
168
  + (f"; no open miss on {', '.join(none)} (log it first, then re-run)" if none else "")
163
169
  + (f"; no verify graded {', '.join(unverified)} during this fix, so its miss stays open with no miss-fix" if unverified else ""))
164
170
  PY
@@ -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 ]]