@techierathore/techieflow 1.1.1 → 1.2.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.
@@ -12,23 +12,25 @@ A screen passes only if all three hold, at a desktop width and a mobile width:
12
12
 
13
13
  A failure on any of the three is a defect: write it into the row's Remarks (prefix `⚠ visual:` for 2 and 3) and do not report the row done. A smoke earns a row at most `Implemented`; only an executed verify run writes `Verified`.
14
14
 
15
- The evidence of a smoke is the file `bash .tfcore/utils/tf-verify-screens.sh --screen <name>=<route> … --base <url>` writes (`tests/.artifacts/verify/screens.json`, with a screenshot per screen and width); a row is written `Implemented` only after that run, and its Remark names the file. The application is booted with `bash .tfcore/utils/tf-verify-boot.sh start`, which also drives an embedded-browser desktop head Windows head; a head the script cannot boot leaves the row below `Implemented` with the reason.
15
+ The evidence of a smoke is the file `bash .tfcore/utils/tf-verify-screens.sh --screen <name>=<route> … --base <url>` writes (`tests/.artifacts/verify/screens.json`, with a screenshot per screen and width); a row is written `Implemented` only after that run, and its Remark names the file. The application is booted with `bash .tfcore/utils/tf-verify-boot.sh start`, which also drives a Windows head; a head the script cannot boot leaves the row below `Implemented` with the reason.
16
16
 
17
17
  ## Run it yourself
18
18
 
19
- The build ladder (`.tfcore/templates/v4custom/build-invocation-ladder.md`) says how to build, run and reach the running UI on every supported host, including native and mobile heads. Follow it. A dependent service that is down is something you start, from its own configuration, in dependency order. Ask the owner to run something only after the ladder's last rung has failed, and then still run the smoke yourself once they answer.
19
+ The build ladder (`.tfcore/templates/v4custom/build-invocation-ladder.md`) says how to build, run and reach the running UI on every supported host, including native and mobile heads. Follow it. Start a dependent service that is down, from its own configuration, in dependency order. Ask the owner to run something only after the ladder's last rung has failed, and then still run the smoke yourself once they answer.
20
20
 
21
21
  ## Test users
22
22
 
23
- Resolve credentials in this order and stop at the first that works:
23
+ Take credentials from the first that works:
24
24
 
25
25
  1. The Test users table in `docs/<App>-UsageGuide.md`.
26
- 2. An existing account in the database, read with the connection string from the configuration.
26
+ 2. An existing account in the database, read with the configured connection string.
27
27
  3. Ask the owner, showing what you would create.
28
- 4. Only after a yes, create it and add it to the Test users table.
28
+ 4. Only after a yes, create it and add it to the table.
29
29
 
30
30
  Never invent a throwaway account.
31
31
 
32
+ An AppManager app skips steps 2 to 4 (stack answer Q4a): register through its register call, passwords in git-ignored secrets, never the owner's account.
33
+
32
34
  ## Say what ran
33
35
 
34
36
  Name the exact command and where it ran: which host, which rung, focused tests or the full build, your own smoke or an executed verify. A green intermediate step is not a pass of the whole.
@@ -21,7 +21,9 @@
21
21
  # the only place that decides. Outside YOLO it returns permissionDecision=ask
22
22
  # for them (same prompt the owner always had). In YOLO it stays silent and the
23
23
  # bare "Bash" allow rule lets them run — an unattended goal run never stalls on
24
- # a delete. Catastrophic `rm -rf /|~` stay in settings.json `deny` (every mode).
24
+ # a delete. A delete of the whole disk or home folder (rm -rf / , /* , ~ , ~/*)
25
+ # is refused here in every mode; settings.json deny keeps only the exact
26
+ # `rm -rf /` and `rm -rf ~` (a deny `*` matched every absolute path).
25
27
  #
26
28
  # YOLO is ON when any of: env TF_YOLO=1 · .tfcore/.session/yolo.json exists
27
29
  # (tf-yolo.sh on) · the hook payload's permission_mode is bypassPermissions/auto.
@@ -198,6 +200,20 @@ def classify_gh(args):
198
200
  found = {"read": 0, "write": 0}
199
201
  found_rm = [False]
200
202
  found_sudo = [False]
203
+ found_wipe = [False]
204
+
205
+ # A delete whose target is the whole disk or the home folder is refused in every mode. These
206
+ # lived in settings.json deny as "rm -rf /*" and "rm -rf ~/*", but a deny rule's * matches any
207
+ # text, so they also refused every delete of an absolute path (rm -rf /mnt/c/...), even in
208
+ # bypass mode (Lekhak scratch folder, 2026-10-18).
209
+ HOME_DIR = os.path.expanduser("~").rstrip("/")
210
+ WIPE = {"/", "/*", "/.", "/..", "~", "~/", "~/*", "~/.", "$HOME", "$HOME/", "$HOME/*",
211
+ "${HOME}", "${HOME}/", "${HOME}/*"}
212
+ def is_wipe(t):
213
+ t = t.strip()
214
+ if t in WIPE or re.fullmatch(r"/+\*?", t):
215
+ return True
216
+ return bool(HOME_DIR) and t.rstrip("/*") == HOME_DIR
201
217
 
202
218
  def scan(cmdline, depth=0):
203
219
  if depth > 4:
@@ -263,6 +279,8 @@ def scan(cmdline, depth=0):
263
279
  found[classify_gh(args)] += 1
264
280
  elif head in ("rm", "rmdir", "unlink", "shred"):
265
281
  found_rm[0] = True
282
+ if any(is_wipe(a) for a in args if not a.startswith("-") or a == "-"):
283
+ found_wipe[0] = True
266
284
  elif head in ("find",) and ("-delete" in args or "-exec" in args and any(basename(x) == "rm" for x in args)):
267
285
  found_rm[0] = True
268
286
 
@@ -276,7 +294,9 @@ except Exception:
276
294
  print("ALLOW")
277
295
  sys.exit(0)
278
296
 
279
- if found["write"]:
297
+ if found_wipe[0]:
298
+ print("BLOCK_WIPE")
299
+ elif found["write"]:
280
300
  print("BLOCK_WRITE")
281
301
  elif found["read"] and (strict_git or not yolo):
282
302
  print("BLOCK_READ")
@@ -290,6 +310,9 @@ VERDICT="$(TF_HOOK_INPUT="$INPUT" TF_YOLO_ON="$YOLO" python3 -c "$GIT_GUARD_PY"
290
310
  case "$VERDICT" in
291
311
  BLOCK_WRITE) block_git_msg; exit 2 ;;
292
312
  BLOCK_READ) block_read_msg; exit 2 ;;
313
+ BLOCK_WIPE)
314
+ echo "BLOCKED by TechieFlow policy: this deletes the whole disk or the home folder. Never run it, in any mode. Delete the folder you mean by its own path." >&2
315
+ exit 2 ;;
293
316
  ASK_RM)
294
317
  # Claude Code: surface the owner's usual delete/sudo prompt (hook "ask"
295
318
  # beats the bare "Bash" allow). OpenCode: its own `rm *: ask` map prompts.
@@ -43,8 +43,8 @@ if not isinstance(cmd, str) or not cmd.strip():
43
43
  WRITE_VERBS = r"\b(insert|update|delete|create|alter|drop|truncate|grant|revoke|replace|merge|upsert)\b"
44
44
  CLIENTS = r"\b(psql|pgcli|sqlite3|mysql|mariadb|sqlcmd|mongosh|mongo)\b"
45
45
  MIGRATORS = (r"\bdotnet\s+ef\s+database\s+(update|drop)\b"
46
- r"|\bdotnet\s+run\b[^|;&]*--project\s+\S*(db|migration|migrations)\b"
47
- r"|\bdbup\b|\bflyway\s+(migrate|clean|undo)\b|\bliquibase\s+(update|rollback|drop-all)\b"
46
+ r"|\bdotnet\s+run\b[^|;&]*--project\s+\S*(db|dbup|migration|migrations)\b"
47
+ r"|\bflyway\s+(migrate|clean|undo)\b|\bliquibase\s+(update|rollback|drop-all)\b"
48
48
  r"|\balembic\s+(upgrade|downgrade)\b|\bprisma\s+(migrate|db\s+push)\b|\bknex\s+migrate\b"
49
49
  r"|\brails\s+db:(migrate|reset|drop|schema:load)\b|\bsequelize\s+db:migrate\b|\bgoose\s+(up|down|reset)\b")
50
50
  DROP_DB = r"\b(dropdb|drop\s+database|mongo(sh)?\b[^|;&]*dropDatabase)\b"
@@ -75,6 +75,10 @@ def phase():
75
75
  SEG = (r"(?:^|[;&|(`\n]|\$\()\s*(?:sudo\s+(?:-\S+\s+)*)?(?:env\s+(?:\S+=\S*\s+)*)?"
76
76
  r"(?:[\w./\\-]*\bexec\s+(?:-\S+\s+)*\S+\s+)?")
77
77
  CLIENT_AT = SEG + r"(?:\S*/)?" + CLIENTS
78
+ # DbUp is a library: it runs as the command word of a segment (`dbup upgrade`, `./App.DbUp.exe`,
79
+ # `dotnet App.DbUp.dll`) or through `dotnet run --project …DbUp`. A bare word "dbup" anywhere blocked a
80
+ # miss sentence naming DbUp and every grep for it (Chatur TF-014).
81
+ MIGRATORS += r"|" + SEG + r"(?:dotnet\s+)?(?:\S*[/\\.])?dbup(?:-cli)?\b"
78
82
  INTERP = r"(?:python3?|py|node|deno|bun|ruby|perl|pwsh|powershell(?:\.exe)?|tsx|ts-node)"
79
83
  INTERP_AT = SEG + r"(?:\S*/)?" + INTERP + r"\b"
80
84
  DB_LIBS = (r"\bsqlite3\b|better-sqlite3|\bsql\.js\b|node:sqlite|\bpsycopg|\bpymysql\b|mysql\.connector|\bmysql2\b"
@@ -171,7 +175,14 @@ if direct_write or re.search(DROP_DB, low):
171
175
  "is fine. Owner rule 2026-09-05.", file=sys.stderr)
172
176
  sys.exit(2)
173
177
 
174
- if re.search(MIGRATORS, low):
178
+ # a quoted argument is a mention (a miss sentence, a search pattern), unless it is the code a shell runs
179
+ # (`bash -c "…"`) or the project `dotnet run --project` builds (Chatur TF-014)
180
+ def _mention(m):
181
+ keep = re.search(r"(?:\s-c|\s/c|\s-command|--project|-p)\s*=?\s*$", low[:m.start()])
182
+ return m.group(0) if keep else '""'
183
+
184
+
185
+ if re.search(MIGRATORS, re.sub(r"\"(?:[^\"\\]|\\.)*\"|'[^']*'", _mention, low)):
175
186
  ph = phase()
176
187
  # triage-and-fix step 3 is fix-issues steps 2 to 5 under its own marker (AppManager TF-027)
177
188
  if ph in ("build-phase", "fix-issues", "triage-and-fix"):
@@ -149,7 +149,11 @@ if written_this_session:
149
149
  app = os.path.basename(cl)[: -len("-Checklist.md")]
150
150
  brd = os.path.join(root, "docs", f"{app}-BRD.md")
151
151
  if os.path.isfile(brd) and os.path.getmtime(cl) > os.path.getmtime(brd):
152
- problems.append(f"docs/{app}-BRD.md is older than docs/{app}-Checklist.md. Run: bash .tfcore/utils/tf-brd-status.sh {app}")
152
+ # the stem of a phase file is "<App>-P<n>": the command takes the app and --phase n
153
+ # (Lekhak TF-034, TechieRag TF-007)
154
+ m = re.match(r"^(.+)-P(\d+)$", app)
155
+ cmd = f"{m.group(1)} --phase {m.group(2)}" if m else app
156
+ problems.append(f"docs/{app}-BRD.md is older than docs/{app}-Checklist.md. Run: bash .tfcore/utils/tf-brd-status.sh {cmd}")
153
157
  except Exception:
154
158
  pass
155
159
 
@@ -12,23 +12,25 @@ A screen passes only if all three hold, at a desktop width and a mobile width:
12
12
 
13
13
  A failure on any of the three is a defect: write it into the row's Remarks (prefix `⚠ visual:` for 2 and 3) and do not report the row done. A smoke earns a row at most `Implemented`; only an executed verify run writes `Verified`.
14
14
 
15
- The evidence of a smoke is the file `bash .tfcore/utils/tf-verify-screens.sh --screen <name>=<route> … --base <url>` writes (`tests/.artifacts/verify/screens.json`, with a screenshot per screen and width); a row is written `Implemented` only after that run, and its Remark names the file. The application is booted with `bash .tfcore/utils/tf-verify-boot.sh start`, which also drives an embedded-browser desktop head Windows head; a head the script cannot boot leaves the row below `Implemented` with the reason.
15
+ The evidence of a smoke is the file `bash .tfcore/utils/tf-verify-screens.sh --screen <name>=<route> … --base <url>` writes (`tests/.artifacts/verify/screens.json`, with a screenshot per screen and width); a row is written `Implemented` only after that run, and its Remark names the file. The application is booted with `bash .tfcore/utils/tf-verify-boot.sh start`, which also drives a Windows head; a head the script cannot boot leaves the row below `Implemented` with the reason.
16
16
 
17
17
  ## Run it yourself
18
18
 
19
- The build ladder (`.tfcore/templates/v4custom/build-invocation-ladder.md`) says how to build, run and reach the running UI on every supported host, including native and mobile heads. Follow it. A dependent service that is down is something you start, from its own configuration, in dependency order. Ask the owner to run something only after the ladder's last rung has failed, and then still run the smoke yourself once they answer.
19
+ The build ladder (`.tfcore/templates/v4custom/build-invocation-ladder.md`) says how to build, run and reach the running UI on every supported host, including native and mobile heads. Follow it. Start a dependent service that is down, from its own configuration, in dependency order. Ask the owner to run something only after the ladder's last rung has failed, and then still run the smoke yourself once they answer.
20
20
 
21
21
  ## Test users
22
22
 
23
- Resolve credentials in this order and stop at the first that works:
23
+ Take credentials from the first that works:
24
24
 
25
25
  1. The Test users table in `docs/<App>-UsageGuide.md`.
26
- 2. An existing account in the database, read with the connection string from the configuration.
26
+ 2. An existing account in the database, read with the configured connection string.
27
27
  3. Ask the owner, showing what you would create.
28
- 4. Only after a yes, create it and add it to the Test users table.
28
+ 4. Only after a yes, create it and add it to the table.
29
29
 
30
30
  Never invent a throwaway account.
31
31
 
32
+ An AppManager app skips steps 2 to 4 (stack answer Q4a): register through its register call, passwords in git-ignored secrets, never the owner's account.
33
+
32
34
  ## Say what ran
33
35
 
34
36
  Name the exact command and where it ran: which host, which rung, focused tests or the full build, your own smoke or an executed verify. A green intermediate step is not a pass of the whole.
@@ -17,6 +17,7 @@
17
17
  | Q2 | Secrets in development | Project user secrets (`dotnet user-secrets`), visible to the developer, never committed. `appsettings.Development.json` is committed and therefore never holds a real secret. A `secrets.example.json` in the project lists every key the application reads. | No |
18
18
  | Q3 | Database | PostgreSQL, running in Docker, for development and production. Docker is always running on the development machine because WSL, where the harness runs, depends on it. If PostgreSQL is unreachable the container is stopped: the agent starts it and continues. Only if no PostgreSQL container exists at all does the agent stop and ask the owner for connection details. It never substitutes another engine and never creates its own database container. | No, unless no container exists |
19
19
  | Q4 | Authentication | Two choices only: **AppManager**, the author's shared platform for identity, roles, licences and subscriptions, which counts as part of the application and not as an external integration; or a different mechanism named by the owner. | **Yes**: "AppManager, or something else?" |
20
+ | Q4a | Test users of an AppManager app | An app that signs in through AppManager registers its own test users on AppManager, through AppManager's register call (`POST /AuthSvc/register`, or `device-register` for a device-bound app), under the app's own application id: one for the owner's walkthrough (`{app}-owner@…`), a second user (`{app}-second@…`), and one for unattended runs (`{app}-yolo-tester@…`). A script in the project does it (for example `tests/verify/register-test-users.mjs`): it generates each password and writes it only to the project's git-ignored secrets (user secrets, or a git-ignored `.env.local`). The UsageGuide's Test users table lists the three, names where each password is kept, and never holds a password. An agent never asks for, stores or uses the owner's personal AppManager account. `tf-doc-check.sh` holds the UsageGuide to this. | No |
20
21
  | Q5 | Logging | Serilog, always, with file logging wired at startup before anything else can fail. | No |
21
22
  | Q6 | Tests | xUnit, always. A test project exists from day-1. | No |
22
23
  | Q7 | Layout and naming | `src/` and `tests/` at the root. The primary executable project is named exactly `<App>`; `<App>.App` is banned. Secondary heads take a descriptive suffix (`<App>.Api`, `<App>.Desktop`). | No |
@@ -32,6 +33,7 @@ Required packages: `TrBlazeUI.Components` — for a project with a Blazor or MAU
32
33
  ## Notes
33
34
 
34
35
  - Q4 is asked on purpose even with this answer set, because the choice differs per product.
36
+ - Q4a follows from an AppManager answer to Q4 and is never asked (owner, 2026-10-18).
35
37
  - Q3's "start the container, never create one" replaces the behaviour that produced TfLens's own database container: an agent that cannot reach the expected database restarts it or asks, and never builds an alternative.
36
38
  - Q11.5 replaces the `database` folder of loose migration scripts that TfLens accumulated.
37
39
  - Q11.3 is a standing framework convention restated as a stack rule so that it is recorded per project and its violations can be counted.
@@ -13,13 +13,16 @@ entries: How to test, screen by screen |
13
13
  per-entry: 90 120
14
14
  budget: S 2500 3500 | M 4000 6000 | L 4000 6000
15
15
  rule: test-users-table
16
+ rule: appmanager-test-users
16
17
  rule: execution-code
17
18
  rule: entry-steps
18
19
  rule: entry-expected
19
20
  -->
20
21
  <!-- Authoring notes (agent only; never visible text).
21
22
  The owner's test plan. Every agent (smoke, verify) and the owner's UAT use the SAME test users and
22
- the SAME walkthrough; nobody invents accounts. One `###` per screen in navigation order, at most
23
+ the SAME walkthrough; nobody invents accounts. An app that signs in through AppManager lists its own three users
24
+ (owner walkthrough, second user, unattended runs), registered through AppManager's register call, each password named by
25
+ where the git-ignored secrets keep it, never written here; never the owner's personal AppManager account. One `###` per screen in navigation order, at most
23
26
  120 words: who to sign in as, numbered steps, the expected result, the REQ ids covered.
24
27
  The Execution guide is copy-pasteable commands, one per line, no narrative. Hosting and production
25
28
  deployment are not here; they are in the Deployment Checklist after UAT.
@@ -5,7 +5,8 @@
5
5
 
6
6
  On a Large project the pair is the phase being worked: --phase N, else appPhase in
7
7
  .tfcore/core-config.yaml (phase 1 is docs/<App>-BRD.md and -Checklist.md, phase n is
8
- docs/<App>-Pn-BRD.md and docs/<App>-Pn-Checklist.md).
8
+ docs/<App>-Pn-BRD.md and docs/<App>-Pn-Checklist.md). A name that carries the phase, as the file
9
+ stem does (<App>-P3), is read as <App> --phase 3.
9
10
 
10
11
  Reads docs/<App>-Checklist.md: every REQ row of the Requirements Status table, and
11
12
  the "## Page: …" section its detail entry sits under, which names the screen.
@@ -127,10 +128,18 @@ def main(argv):
127
128
  render = "--no-render" not in argv
128
129
  root = os.getcwd()
129
130
  phase = None
131
+ # A name that already carries the phase, as the file stem reads ("Lekhak-P3"), is the app and that
132
+ # phase: adding appPhase again looked for docs/Lekhak-P3-P3-Checklist.md (Lekhak TF-034,
133
+ # TechieRag TF-007).
134
+ m = re.match(r"^(.+)-P(\d+)$", app)
135
+ if m:
136
+ app, phase = m.group(1), int(m.group(2))
130
137
  if "--phase" in argv:
131
138
  i = argv.index("--phase")
132
139
  if i + 1 >= len(argv) or not argv[i + 1].isdigit() or int(argv[i + 1]) < 1:
133
140
  die("--phase needs a number from 1 upwards")
141
+ if phase is not None and phase != int(argv[i + 1]):
142
+ die(f"{argv[1]} names phase {phase} but --phase says {argv[i + 1]}")
134
143
  phase = int(argv[i + 1])
135
144
  if phase is None:
136
145
  cfg = os.path.join(root, ".tfcore", "core-config.yaml")
@@ -201,6 +201,31 @@ cross_side() { # side: clear what another side built with its own paths in it, t
201
201
  done
202
202
  [[ $cleared -eq 1 ]] && echo "note the scoped stylesheets and static asset lists were built on the other side of this machine; cleared so the $1 build writes them itself"
203
203
  }
204
+ # Lekhak TF-036: on WSL a target with no MAUI head starts on the WSL rungs. A test project inside a solution
205
+ # that has a MAUI head is built Windows-side with that solution, and ~/.dotnet/dotnet then could not write
206
+ # the obj/ Windows owns (MSB3491): tf-doc-tests.sh said NOT-RUN where `tf-build.sh test` on the solution
207
+ # had just passed. Such a target starts on the side its solution builds on, the Windows side. Outside such
208
+ # a solution, a target whose obj/ (or a referenced project's) was last built Windows-side starts there.
209
+ # The record alone is not enough: a failed WSL attempt writes it before it fails.
210
+ if [[ "$PLATFORM" == "wsl" && $maui -eq 0 && "$TARGET" == *.csproj ]]; then
211
+ won=""; tname="$(basename "$TARGET")"
212
+ for sln in *.slnx *.sln; do
213
+ [[ -f "$sln" ]] && grep -q "$tname" "$sln" || continue
214
+ while IFS= read -r p; do
215
+ [[ -f "$p" ]] && grep -qiE '<UseMaui>[[:space:]]*true|Microsoft\.NET\.Sdk\.Maui|net[0-9.]+-(android|ios|maccatalyst|windows10)' "$p" \
216
+ && { won="$sln has a MAUI head, so it builds on the Windows side"; break 2; }
217
+ done < <(grep -oE '"[^"]+\.csproj"|Path="[^"]+\.csproj"' "$sln" | sed 's/^Path=//' | tr -d '"' | tr '\\' '/')
218
+ done
219
+ if [[ -z "$won" ]]; then
220
+ for d in ${pdirs[@]+"${pdirs[@]}"}; do
221
+ [[ "$(cat "$d/obj/.tf-build-side" 2>/dev/null)" == windows ]] && { won="$d/obj was last built on the Windows side"; break; }
222
+ done
223
+ fi
224
+ if [[ -n "$won" ]]; then
225
+ rungs=("cmd.exe" "winrun" "powershell.exe" "dotnet" "$HOME/.dotnet/dotnet")
226
+ echo "note $won; this $MODE starts there too (rungs: cmd.exe, winrun, powershell.exe, then the WSL ones)"
227
+ fi
228
+ fi
204
229
  WRONG_RUNG='NETSDK1178|Microsoft\.(iOS|Android|MacCatalyst|tvOS)\.Sdk|Workload ID|not recognized|WindowsAppSDK|command not found|No such file or directory|is not recognized as an internal or external command|The term .* is not recognized|workload.*not installed|Inadequate permissions'
205
230
 
206
231
  # A Mac Catalyst build after Platforms/MacCatalyst/Info.plist (or an .entitlements file) changed: the
@@ -86,6 +86,13 @@ def siblings(path):
86
86
  return [os.path.join(d, n) for n in sorted(names) if n != f and re.match(rf"^{app}(?:-P\d+)?-Checklist\.md$", n)]
87
87
 
88
88
 
89
+ def streams(path):
90
+ """the telemetry streams beside the checklists: an id a miss or gate record carries is taken even
91
+ after its row was renamed, since a record cannot be amended (TechieRag TF-005)"""
92
+ d = os.path.join(os.path.dirname(path) or ".", "metrics")
93
+ return [os.path.join(d, n) for n in ("gates.jsonl", "misses.jsonl", "runs.jsonl") if os.path.isfile(os.path.join(d, n))]
94
+
95
+
89
96
  def next_id(text, prefix, others=()):
90
97
  """one past the highest id of that prefix in this checklist AND every other checklist of the app:
91
98
  counting this one alone gave REQ-UI-055 twice, once in phase 1 and once in phase 3 (Chatur TF-011)"""
@@ -99,7 +106,7 @@ def add_row(path, prefix, title, acceptance, section=None, remark=None, status="
99
106
  """append a row to the Requirements Status table and a detail entry under `section`
100
107
  (a new section at the end when it does not exist); returns the new id"""
101
108
  text = read(path)
102
- rid = next_id(text, prefix, siblings(path))
109
+ rid = next_id(text, prefix, siblings(path) + streams(path))
103
110
  nl = "\r\n" if "\r\n" in text else "\n"
104
111
  lines = text.split(nl)
105
112
  # the table: after the last REQ row
@@ -30,6 +30,7 @@ Schema grammar (one `key: value` per line inside the comment):
30
30
  from __future__ import annotations
31
31
 
32
32
  import argparse
33
+ import glob
33
34
  import json
34
35
  import os
35
36
  import re
@@ -412,6 +413,17 @@ def resolve_kind(header, brd_header, cfg) -> str:
412
413
  return "library" if raw.startswith("lib") else "app"
413
414
 
414
415
 
416
+ def signs_in_through_appmanager(root, app):
417
+ """the Architecture's authentication row (Q4, Auth…, Identity) names AppManager"""
418
+ for f in glob.glob(os.path.join(root or ".", "docs", f"{app}-Architecture.md")):
419
+ for _h, rows in tables_in(open(f, encoding="utf-8", errors="replace").read()):
420
+ for r in rows:
421
+ head = " ".join(r[:2]).lower()
422
+ if re.search(r"\bq4\b|auth|identity|sign-in", head) and re.search(r"(?i)app ?manager", " ".join(r[1:3])):
423
+ return True
424
+ return False
425
+
426
+
415
427
  def section_text(present, name):
416
428
  key = norm_heading(name)
417
429
  return next((txt for k, _h, txt in present if key_matches(key, k)), None)
@@ -799,6 +811,35 @@ def check_doc_rule(rule, c, rep):
799
811
  if txt is not None and not has_table_with(txt, "user", "role"):
800
812
  rep.fail(rel, 'the "Test users" section needs a table with User and Role columns')
801
813
 
814
+ elif rule == "appmanager-test-users":
815
+ # An app that signs in through AppManager has its own three test users there, registered through
816
+ # AppManager's register call, each password kept in the git-ignored secrets and never written in
817
+ # the guide; never the owner's personal account (stack answer Q4a, owner 2026-10-18).
818
+ txt = section_text(present, "Test users")
819
+ app = c.get("app")
820
+ if txt is None or c["kind"] != "app" or not app or app.lower() == "appmanager" or not signs_in_through_appmanager(c["root"], app):
821
+ return
822
+ users = []
823
+ for hdr, rows in tables_in(txt):
824
+ low = [h.lower() for h in hdr]
825
+ if not any("user" in h for h in low):
826
+ continue
827
+ pw = next((i for i, h in enumerate(low) if "password" in h), None)
828
+ users += [(r, r[pw] if pw is not None and pw < len(r) else "") for r in rows if "@" in " ".join(r)]
829
+ whole = lambda r: " ".join(r).lower()
830
+ want = (("owner", r"owner|account holder"), ("second user", r"second"), ("unattended-run user", r"unattended|yolo"))
831
+ missing = [n for n, pat in want if not any(re.search(pat, whole(r)) for r, _p in users)]
832
+ if missing:
833
+ rep.fail(rel, f'{app} signs in through AppManager: the Test users table lists no {", no ".join(missing)} '
834
+ f'(stack answer Q4a: owner walkthrough, second user, unattended runs, registered through AppManager\'s register call)')
835
+ for r, cell in users:
836
+ if not re.search(r"(?i)secret|\.env|credential|keychain", cell):
837
+ who = re.search(r"[\w.+-]+@[\w-]+(?:\.[\w-]+)*", " ".join(r)).group(0)
838
+ rep.fail(rel, f'test user {who}: the Password cell names no git-ignored secret; keep the password in the project\'s '
839
+ f'secrets and name the key here, never the password (stack answer Q4a)')
840
+ if users and not re.search(r"(?i)regist", txt):
841
+ rep.fail(rel, "the Test users section does not say the users were registered through AppManager's register call (stack answer Q4a)")
842
+
802
843
  elif rule == "next-command-blocks":
803
844
  seg = re.search(r"(?ms)^## Next command to run\s*\n(.*?)(?=^## |\Z)", nocomment)
804
845
  if not seg:
@@ -246,7 +246,9 @@ const PROBE = (wanted = []) => {
246
246
  if (!c) return null;
247
247
  const { r, g, b } = c;
248
248
  const mx = Math.max(r, g, b), mn = Math.min(r, g, b);
249
- if (mx - mn < 28) return 'neutral';
249
+ // a grey with a tint is still a grey: Sevak's dark nav text #a6adc5 (spread 31, saturation 21%) read as
250
+ // accent beside the app's near-white, once TF-007 had the mockup read settled and not mid-fade
251
+ if (mx - mn < 28 || (mx - mn) / (255 - Math.abs(mx + mn - 255)) < 0.25) return 'neutral';
250
252
  // By hue, never by one channel. "Red is highest, so green above 120 is a warning" put a deep
251
253
  // amber (180, 83, 9) in `negative` beside a light amber in `warning` — one warning tone on two
252
254
  // sides of the line, reported as a colour difference on TfLens /misses (TF-045).
@@ -275,7 +277,12 @@ const PROBE = (wanted = []) => {
275
277
  // reads every other border property but not this one keeps missing that whole
276
278
  // vocabulary. Quantised to the three values that carry meaning rather than the
277
279
  // full CSS keyword set, so the clause stays as noise-free as the colour buckets.
280
+ // A checkbox, switch or radio is not compared by its border (Lekhak TF-035): a native
281
+ // <input type="checkbox"> computes border-style none because the browser draws its box, while a
282
+ // library's role="checkbox" draws the same box as a CSS border. Same control, so not applicable.
283
+ const TOGGLE = 'input[type="checkbox"],input[type="radio"],[role="checkbox"],[role="switch"],[role="radio"]';
278
284
  const strokeOf = (el) => {
285
+ if (el.matches(TOGGLE)) return null;
279
286
  const cs = getComputedStyle(el);
280
287
  const q = (s) => (s === 'none' || s === 'hidden' ? 'none'
281
288
  : s === 'dashed' || s === 'dotted' ? 'dashed' : 'solid');
@@ -465,7 +472,7 @@ const PROBE = (wanted = []) => {
465
472
  // the badge and the visible border one element away, for the clauses to accept (TF-047)
466
473
  near: {
467
474
  badge: near.some((n) => chromeOn(n)?.badge === true),
468
- stroke: near.map(strokeOf).find((s) => s.visible) || null,
475
+ stroke: near.map(strokeOf).find((s) => s && s.visible) || null,
469
476
  },
470
477
  tag: el.tagName.toLowerCase(),
471
478
  text: ownText(el).trim().replace(/\s+/g, ' ').slice(0, 60),
@@ -657,7 +664,13 @@ const CLAUSES = {
657
664
  : m.token.fits && !a.token.fits
658
665
  ? `value cell is narrower than its longest unbreakable token ("${a.token.token}") — it breaks mid-token`
659
666
  : false),
667
+ // Sevak TF-010: a sample box the app draws is graded on being there with a size, nothing it says
668
+ size: (m, a) => (!m.in_sample ? null
669
+ : m.w > 0 && m.h > 0 && (!(a.w > 0) || !(a.h > 0)) ? `the mockup draws this sample box ${m.w}×${m.h}; the app draws it ${a.w ?? 0}×${a.h ?? 0}` : false),
660
670
  };
671
+ // Sevak TF-010: inside a box the mockup marks data-tf-sample, what the sample says is the app's real data
672
+ // ("Yes, it can run" against this PC's truthful "No"), so its colour, its text and its badge are not graded
673
+ const SAMPLE_SKIP = new Set(['color', 'badge', 'wrap', 'token']);
661
674
 
662
675
  // Which clauses require having reached INSIDE a container rather than merely
663
676
  // touching its outer box. TF-011's `harness` screen graded three column CONTAINERS
@@ -795,8 +808,10 @@ function diff(mock, app, screen, width, skip = null) {
795
808
  return false;
796
809
  };
797
810
 
811
+ const sampleKeys = Object.keys(mock.index).filter((k) => mock.index[k].sample);
798
812
  for (const key of Object.keys(mock.index)) {
799
- const m = mock.index[key];
813
+ const inSample = sampleKeys.some((k) => key === k || key.startsWith(k + ' > '));
814
+ const m = inSample ? { ...mock.index[key], in_sample: true } : mock.index[key];
800
815
  let a = app.index[key];
801
816
  let appKey = key;
802
817
 
@@ -824,6 +839,7 @@ function diff(mock, app, screen, width, skip = null) {
824
839
  // wrapper div and become the always-present finding TF-012 warns about — the
825
840
  // fastest way to train a reader to skim the whole report.
826
841
  if (!a) {
842
+ if (inSample && m.badge === true) continue; // a sample badge reading otherwise in the app is its data (TF-010)
827
843
  const parentKey = key.includes(' > ') ? key.slice(0, key.lastIndexOf(' > ')) : null;
828
844
  const parentPaired = parentKey ? !!app.index[parentKey] : false;
829
845
  // an icon is missing only when the paired parent carries fewer icons than the mockup's
@@ -865,6 +881,7 @@ function diff(mock, app, screen, width, skip = null) {
865
881
  compared++;
866
882
  for (const [name, fn] of Object.entries(CLAUSES)) {
867
883
  if (skip && skip.has(name)) continue; // a clause the mockup says nothing about in this theme (TF-024)
884
+ if (inSample && SAMPLE_SKIP.has(name)) continue;
868
885
  const r = fn(m, a);
869
886
  if (r === null) continue;
870
887
  clauseCoverage[name]++;
@@ -59,13 +59,31 @@ def load_words(root):
59
59
 
60
60
 
61
61
  def upstream_state(root):
62
- """Every entry id in the project's feedback files -> (state, where), from the one reader
63
- every script uses (tf_feedback.py), so this check and PROJECT-STATUS can never disagree."""
62
+ """Every entry id in the project's feedback files -> [(state, where, names)], one per file, from
63
+ the one reader every script uses (tf_feedback.py), so this check and PROJECT-STATUS can never disagree."""
64
64
  sys.path.insert(0, HERE)
65
65
  import tf_feedback
66
66
  return tf_feedback.state_map(root)
67
67
 
68
68
 
69
+ def resolve(rid, cands, unit, text):
70
+ """The feedback file an id in this unit means (TrBlazeUI TF-006). One file holding it: that one.
71
+ Several: the files whose project the unit names ("Lekhak TR-002"), else the files the whole text
72
+ names. -> (state, where), or (None, the files) when it stays several that disagree."""
73
+ if len(cands) == 1:
74
+ return cands[0][:2]
75
+ for txt in (unit, text):
76
+ # a name every candidate file shares (the library's own) says nothing about which one
77
+ shared = set.intersection(*(set(c[2]) for c in cands))
78
+ hit = [c for c in cands if any(n not in shared and re.search(rf"(?<![\w-]){re.escape(n)}(?![\w-])", txt) for n in c[2])]
79
+ if hit:
80
+ cands = hit
81
+ break
82
+ if len({c[0] for c in cands}) == 1:
83
+ return cands[0][:2]
84
+ return None, ", ".join(c[1].split(",")[0] for c in cands)
85
+
86
+
69
87
  def split_text(text):
70
88
  """-> (prose lines [(n, line)], the text inside code blocks)."""
71
89
  prose, fenced, fence = [], [], None
@@ -142,9 +160,13 @@ def check(text, label, root, final=False, words=None, ups=None):
142
160
  for n, txt, header in us:
143
161
  for rid in dict.fromkeys(ANY_ID.findall(txt)):
144
162
  if rid in ups:
145
- mentions.setdefault(rid, []).append((n, txt, header))
146
- for rid, ms in sorted(mentions.items(), key=lambda kv: kv[1][0][0]):
147
- st, where = ups[rid]
163
+ st, where = resolve(rid, ups[rid], txt, text)
164
+ if st is None:
165
+ fail(n, f'{rid} is an entry in several feedback files ({where}); name whose it is with the id, '
166
+ f'as in "{ups[rid][0][2][0]} {rid}"')
167
+ continue
168
+ mentions.setdefault((rid, st, where), []).append((n, txt, header))
169
+ for (rid, st, where), ms in sorted(mentions.items(), key=lambda kv: kv[1][0][0]):
148
170
  if st in ("fixed", "closed"):
149
171
  # read the sentence that names it, or its table row: "TF-901 is filed. X is fixed." is wrong
150
172
  said = lambda txt, h: [txt] if h is not None else [s for s in re.split(r"(?<=[.!?])\s+", txt) if rid in s]
@@ -72,11 +72,23 @@ const RESTORE = (before) => {
72
72
  }
73
73
  };
74
74
 
75
+ const SETTLE_ANIMATIONS = () => {
76
+ let n = 0;
77
+ for (const a of document.getAnimations()) { // getAnimations() flushes style, so a fade the change just started is in it
78
+ try { if (Number.isFinite(a.effect?.getComputedTiming().endTime)) { a.finish(); n++; } } catch (e) { /* not finishable */ }
79
+ }
80
+ return n;
81
+ };
82
+
75
83
  // Draws the page in `want` and returns the function that puts it back.
76
84
  export async function useTheme(page, want, settle = 350) {
77
85
  await page.emulateMedia({ colorScheme: want }).catch(() => {});
78
86
  const before = await page.evaluate(FORCE, want).catch(() => null);
79
87
  if (settle) await page.waitForTimeout(settle);
88
+ // Sevak TF-007: a theme change starts the page's CSS fades (`transition: all .12s`), and the mockup,
89
+ // read with no settle, gave its light colour in dark mode. Every fade and finite animation is run to
90
+ // its end, so what is read is the colour the page settles on; a spinner (infinite) is left running.
91
+ await page.evaluate(SETTLE_ANIMATIONS).catch(() => {});
80
92
  return async () => {
81
93
  if (before) await page.evaluate(RESTORE, before).catch(() => {});
82
94
  await page.emulateMedia({ colorScheme: null }).catch(() => {});
@@ -13,8 +13,10 @@ row in whichever docs/<App>-Checklist.md or docs/<App>-P<N>-Checklist.md holds i
13
13
 
14
14
  demote the row goes to Needs re-verify (% capped at 75) with a dated Remark holding the symptom in the
15
15
  reporter's words, the evidence path and the kind.
16
- new a defect no row covers: the next free id in the prefix, a Not Started row, a detail entry with the
17
- acceptance line and the marker BRD-pending (*amend-docs gives it its BRD item).
16
+ new a defect no row covers: the next free id in the prefix, across every checklist of the app and the
17
+ telemetry streams, a Not Started row, a detail entry with the acceptance line and the marker
18
+ BRD-pending (*amend-docs gives it its BRD item). Without --prefix: FN, or the one prefix a
19
+ checklist with no FN row uses; with several such prefixes it asks for --prefix.
18
20
  note a dated remark on the nearest row, no status change (could not reproduce).
19
21
  close what triage owes the telemetry, from the actions above (tests/.artifacts/verify/triage.json):
20
22
  one gate record per demoted or new row with gate "escaped" (no check caught it), one miss per row
@@ -31,6 +33,7 @@ import datetime
31
33
  import importlib.util
32
34
  import json
33
35
  import os
36
+ import re
34
37
  import subprocess
35
38
  import sys
36
39
 
@@ -176,7 +179,19 @@ def main(argv):
176
179
  die("new needs \"<title>\" \"<acceptance>\"")
177
180
  title, acc = argv[3], argv[4]
178
181
  cl = checklist(app, argv, ce)
179
- prefix = opt(argv, "--prefix", "FN").upper(); section = opt(argv, "--section"); ev = opt(argv, "--evidence")
182
+ prefix = opt(argv, "--prefix")
183
+ if prefix is None:
184
+ # FN unless this checklist has no FN row: then the one prefix it uses is the default, and
185
+ # several are asked for (TechieRag TF-005: phase 3 is all RAG, and FN was a slip)
186
+ used = sorted(set(re.findall(r"(?m)^\|\s*REQ-([A-Z]+)-\d{3,}", ce.read(cl))))
187
+ if not used or "FN" in used:
188
+ prefix = "FN"
189
+ elif len(used) == 1:
190
+ prefix = used[0]
191
+ print(f"note: every row of {cl} is REQ-{prefix}-…, so the new row is one too; pass --prefix to choose another")
192
+ else:
193
+ die(f"{cl} has no REQ-FN rows; name the prefix: --prefix {'|'.join(used)}")
194
+ prefix = prefix.upper(); section = opt(argv, "--section"); ev = opt(argv, "--evidence")
180
195
  src = opt(argv, "--source", "owner"); mockup = opt(argv, "--mockup")
181
196
  if prefix == "UI" and not mockup and cfg_project_type() not in ("library", "docs"): # TrBlazeUI TF-003
182
197
  print("note: a UI row needs a mockup link; pass --mockup docs/mockups/<screen>.html or the checker will flag it")
@@ -13,7 +13,8 @@
13
13
  # side that built it, reading its settings from the project folder as `dotnet run` does.
14
14
  # Builders side by side never serve each other's half-written build (TF-043). A standalone
15
15
  # WebAssembly project is still started with `dotnet run`. Prints BOOTED mode=base url=….
16
- # windows a MAUI Blazor Hybrid head (UseMaui + a net*-windows target): started Windows-side with
16
+ # windows a MAUI Blazor Hybrid head (UseMaui + a net*-windows target + a BlazorWebView; a plain MAUI
17
+ # app is refused at once, NONE kind=no-webview, TechieRag TF-006): started Windows-side with
17
18
  # WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS=--remote-debugging-port=9222, the DevTools port
18
19
  # relayed to every interface by tf-cdp-relay.ps1, and reached from WSL over CDP. Prints
19
20
  # BOOTED mode=cdp url=http://<host>:9223. Proven on MyDiary, 2026-09-06.
@@ -85,6 +86,13 @@ find_projects() { # prints csproj paths under src/, source/, the root and one le
85
86
  }
86
87
  is_web() { grep -qE 'Microsoft\.NET\.Sdk\.Web|Microsoft\.NET\.Sdk\.BlazorWebAssembly' "$1"; }
87
88
  is_maui_win() { grep -qiE '<UseMaui>[[:space:]]*true|Microsoft\.NET\.Sdk\.Maui' "$1" && grep -qE 'net[0-9.]+-windows' "$1"; }
89
+ # A Windows head is driven over its web view's DevTools port, so only a Blazor Hybrid app has one. A
90
+ # plain MAUI app (TechieRag's samples/TechieRag.Probe) was picked as the windows head and waited the
91
+ # full 300 s for a port it can never open (TechieRag TF-006).
92
+ is_hybrid() {
93
+ grep -qE 'Microsoft\.AspNetCore\.Components\.WebView\.Maui' "$1" && return 0
94
+ grep -rqlE 'AddMauiBlazorWebView|BlazorWebView' --include='*.cs' --include='*.xaml' --exclude-dir=bin --exclude-dir=obj "$(dirname "$1")" 2>/dev/null
95
+ }
88
96
  is_maui_mac() { grep -qiE '<UseMaui>[[:space:]]*true|Microsoft\.NET\.Sdk\.Maui' "$1" && grep -qE 'net[0-9.]+-maccatalyst' "$1"; }
89
97
 
90
98
  poll_http() { # url seconds pid-to-watch(optional) -> 0 when it answers
@@ -285,7 +293,11 @@ fi
285
293
  # ---- pick the project and the head ------------------------------------------------------
286
294
  tf_read_lines ALL < <(find_projects)
287
295
  WEB=(); WIN=(); MAC=()
288
- for p in ${ALL[@]+"${ALL[@]}"}; do is_web "$p" && WEB+=("$p"); is_maui_win "$p" && WIN+=("$p"); is_maui_mac "$p" && MAC+=("$p"); done
296
+ WIN_NATIVE=()
297
+ for p in ${ALL[@]+"${ALL[@]}"}; do
298
+ is_web "$p" && WEB+=("$p"); is_maui_mac "$p" && MAC+=("$p")
299
+ if is_maui_win "$p"; then if is_hybrid "$p"; then WIN+=("$p"); else WIN_NATIVE+=("$p"); fi; fi
300
+ done
289
301
  # a Mac Catalyst head is picked unasked only on a Mac, the one host that runs it
290
302
  [[ "$PLATFORM" == "macos" ]] || MAC=()
291
303
  # Several web projects: the one that serves screens. The first in sorted order booted AppManager's
@@ -325,9 +337,14 @@ if [[ -n "$PROJECT" ]]; then
325
337
  elif [[ -z "$HEAD" ]]; then
326
338
  if [[ ${#WEB[@]} -gt 0 ]]; then HEAD=web; pick_web
327
339
  elif [[ ${#MAC[@]} -gt 0 ]]; then HEAD=maccatalyst; PROJECT="${MAC[0]}"
328
- elif [[ ${#WIN[@]} -gt 0 ]]; then HEAD=windows; PROJECT="${WIN[0]}"; fi
340
+ elif [[ ${#WIN[@]} -gt 0 ]]; then HEAD=windows; PROJECT="${WIN[0]}"
341
+ elif [[ ${#WIN_NATIVE[@]} -gt 0 ]]; then HEAD=windows; PROJECT="${WIN_NATIVE[0]}"; fi
329
342
  else
330
- case "$HEAD" in web) pick_web ;; windows) PROJECT="${WIN[0]:-}" ;; maccatalyst) PROJECT="${MAC[0]:-}" ;; esac
343
+ case "$HEAD" in web) pick_web ;; windows) PROJECT="${WIN[0]:-${WIN_NATIVE[0]:-}}" ;; maccatalyst) PROJECT="${MAC[0]:-}" ;; esac
344
+ fi
345
+ if [[ "$HEAD" == windows && -n "$PROJECT" ]] && ! is_hybrid "$PROJECT"; then
346
+ [[ $DRYRUN -eq 1 ]] || write_state windows none "" "" "" "$PROJECT" "a MAUI app with no BlazorWebView: no web view, not drivable over CDP" no-webview "$PLATFORM"
347
+ echo "NONE head=windows kind=no-webview reason=$PROJECT is a MAUI app with no BlazorWebView (no AddMauiBlazorWebView, no <BlazorWebView>): it has no web view, so it is not drivable over CDP; its rows are graded by their tests"; exit 2
331
348
  fi
332
349
  if [[ $DRYRUN -eq 1 ]]; then echo "PICK head=${HEAD:-none} project=${PROJECT:-none}"; exit 0; fi
333
350
  case "$HEAD" in
@@ -714,6 +731,9 @@ MINE="$(comm -13 <(printf '%s\n' "$BEFORE_IDS") <(win_ids) | tr '\n' ' ')"
714
731
  if [[ -z "$URL" ]]; then
715
732
  pkill -P "$APP_PID" 2>/dev/null; kill "$APP_PID" "$RELAY_PID" 2>/dev/null
716
733
  for w in $MINE; do taskkill.exe /F /T /PID "$w" >/dev/null 2>&1; done
734
+ # the relay runs Windows-side and outlives its WSL parent: a second try then failed "Only one usage
735
+ # of each socket address" (TechieRag TF-006)
736
+ powershell.exe -NoProfile -Command "Get-NetTCPConnection -LocalPort $RELAY_PORT -State Listen -ErrorAction SilentlyContinue | ForEach-Object { Stop-Process -Id \$_.OwningProcess -Force -ErrorAction SilentlyContinue }" >/dev/null 2>&1
717
737
  write_state windows none "" "" "cmd.exe /c dotnet run -f $TFM" "$PROJECT" "the DevTools port $RELAY_PORT never answered within 300 s" host "$PLATFORM"
718
738
  echo "NONE head=windows kind=host reason=the app's DevTools port never answered on ${HOSTS[*]}:$RELAY_PORT within 300 s (log $LOG, relay $DIR/relay.log)"; exit 2
719
739
  fi
@@ -366,8 +366,14 @@ async function inspect(page, anchors, attr, errorSelectors) {
366
366
  return !/^(transparent|rgba\(0, 0, 0, 0\))$/.test(cs.backgroundColor) || cs.backgroundImage !== 'none'
367
367
  || ['Top', 'Right', 'Bottom', 'Left'].some((s) => parseFloat(cs[`border${s}Width`]) > 0 && cs[`border${s}Style`] !== 'none');
368
368
  };
369
+ // A checkbox, switch or radio drawn by a library (TrBlazeUI: <button role="checkbox"
370
+ // aria-checked="false">, no text, no child while unticked) paints its own box, the way a native
371
+ // <input type="checkbox"> does (Lekhak TF-035). Counted filled when it paints; an unpainted one
372
+ // with no size is still empty.
373
+ const toggle = '[role="checkbox"],[role="switch"],[role="radio"],[aria-checked]';
369
374
  const filled = text.length > 0 || ['input', 'select', 'textarea', 'img', 'svg', 'canvas', 'video', 'iframe', 'progress', 'meter'].includes(tag)
370
375
  || el.matches(bar) || el.querySelector(`img,svg,canvas,input,select,textarea,button,a,${bar}`) !== null
376
+ || (el.matches(toggle) && paints(el)) || [...el.querySelectorAll(toggle)].some(paints)
371
377
  || [...el.children].some(paints);
372
378
  const c = clipRect(el);
373
379
  out.anchors.push({ id, present: true, visible: vis(el), filled, tag, w: Math.round(r.width), h: Math.round(r.height), x: Math.round(r.left), y: Math.round(r.top),
@@ -24,7 +24,8 @@ For each row, the seven checks in order, and the first that fails is the verdict
24
24
  Verdicts: PASS · FAIL (acceptance) · BUILD-FAIL · RENDER-FAIL · ASSET-FAIL · VISUAL-FAIL · MOCKUP-FAIL ·
25
25
  PERF-FAIL · NOT-TESTED (no test carried the id, or every test carrying it was skipped; the screen
26
26
  checks passed or did not run) ·
27
- NOT-OBSERVABLE (an NFR row with no budget and no unit test) · NOT-DRIVEN (its screen was never driven:
27
+ NOT-OBSERVABLE (an NFR row with no budget and no unit test) · NOT-BUILT (a Not Started row whose only
28
+ failing check is its route answering 404: it keeps Not Started and emits no miss, Chatur TF-013) · NOT-DRIVEN (its screen was never driven:
28
29
  the head could not be booted, or has no driver) · DEFECT-OPEN (every check passed, but the old Remarks
29
30
  carry a defect no verify can see, tf_defect.py: the row goes to Needs re-verify, and so to a fix, with
30
31
  the defect kept; AppManager TF-021). A row is Verified only on PASS. NOT-* rows keep their
@@ -174,8 +175,16 @@ def main(argv):
174
175
  # it. It is neither a pass nor a defect, so it grades exactly like a row with no test at
175
176
  # all — never Verified, no gate record (TF-022). `t` is kept for the note.
176
177
  skipped_only = bool(t) and t.get("result") == "NOT-TESTED"
178
+ # Sevak TF-009: a row whose unit tests passed while its on-app test was skipped (Sevak TF-004)
179
+ # was written "no unit test carrying its id"; the tests ran, they just do not count
180
+ unit_only = ""
181
+ if skipped_only and t.get("passed"):
182
+ why = re.sub(r"^on-app test skipped [(](.*)[)]; a unit test.*$", r"\1", t.get("reason") or "")
183
+ unit_only = f"{t['passed']} unit test(s) named {rid} passed but do not count while its on-app test was skipped" \
184
+ + (f": {why}" if why else "")
177
185
  if skipped_only:
178
- notes.append(f"acceptance not measured ({len(t.get('skipped') or [])} test(s) skipped, none ran)")
186
+ if not unit_only:
187
+ notes.append(f"acceptance not measured ({len(t.get('skipped') or [])} test(s) skipped, none ran)")
179
188
  t = None
180
189
  # the OS zoom keys (coding-standards-dotnet.md §9b): a row whose acceptance carries "zoom-check: keys"
181
190
  # or "zoom-check: kept" is graded by tf-verify-zoom.sh --json-out zoom/<REQ>.json, which stands as its
@@ -201,6 +210,7 @@ def main(argv):
201
210
  if t:
202
211
  checks.append(("acceptance", True, t["result"] != "PASS", "FAIL", "assert-fail", quoted(t.get("reason")) or "test failed", t.get("screenshot") or ""))
203
212
  # 3 render
213
+ bad = [] # Chatur TF-015: an UNREACHABLE first screen left it unset for the Mac note below
204
214
  if scr:
205
215
  if scr.get("render") == "UNREACHABLE":
206
216
  f = next((x for w in scr["widths"] for x in w.get("findings", [])), {})
@@ -280,12 +290,25 @@ def main(argv):
280
290
  notes.append(f"perf not measured: {g['reason']}")
281
291
 
282
292
  ran = [c[0] for c in checks if c[1]]
283
- first = next((c for c in checks if c[1] and c[2]), None)
293
+ failed = [c for c in checks if c[1] and c[2]]
294
+ first = failed[0] if failed else None
284
295
  verdict, cls, detail, evidence = (first[3], first[4], first[5], first[6]) if first else (None, None, "", "")
296
+ # Chatur TF-013: a Not Started row whose only failing check is its route answering 404 (the mockup
297
+ # comparison could not open it either, for the same reason) was never built; it is not a defect to
298
+ # re-verify, so it keeps Not Started and emits no miss
299
+ if verdict == "RENDER-FAIL" and all(re.search(r"HTTP 404\b", c[5] or "") for c in failed) \
300
+ and scr and scr.get("render") == "UNREACHABLE" \
301
+ and (r.get("status") or prior).strip("`* ").lower().startswith("not started") \
302
+ and all(re.search(r"answered HTTP 404\b", f.get("detail", "")) for w in scr["widths"] for f in w.get("findings", [])[:1]):
303
+ verdict, cls, evidence = "NOT-BUILT", None, ""
304
+ detail = f"{detail}: the screen is not built yet, nothing to re-verify"
285
305
 
286
306
  # the honest non-verdicts
287
307
  if verdict is None:
288
- if r["class"] == "NFR" and not r.get("perf_budget") and not t:
308
+ if unit_only:
309
+ verdict = "NOT-TESTED"
310
+ detail = unit_only + (f"; screen {r['screen']} renders and looks right{nonames}" if driven else "")
311
+ elif r["class"] == "NFR" and not r.get("perf_budget") and not t:
289
312
  verdict = "NOT-OBSERVABLE"
290
313
  detail = "NFR row with no perf-budget and no unit test carrying its id"
291
314
  elif not t and r.get("screen") and not driven:
@@ -338,6 +361,7 @@ def main(argv):
338
361
  marks = {"FAIL": "⚠ acceptance", "BUILD-FAIL": "⚠ build", "RENDER-FAIL": "⚠ render", "ASSET-FAIL": "⚠ assets",
339
362
  "VISUAL-FAIL": "⚠ visual", "MOCKUP-FAIL": "⚠ mockup-parity", "PERF-FAIL": "⚠ perf",
340
363
  "NOT-TESTED": "not verified", "NOT-OBSERVABLE": "not observable", "NOT-DRIVEN": "not verified", "PASS": "PASS",
364
+ "NOT-BUILT": "not built",
341
365
  "DEFECT-OPEN": "checks pass, defect open"}
342
366
  remark = f"{today} verify: {marks[verdict]} — {detail}"
343
367
  if evidence:
@@ -195,12 +195,17 @@ def upstream_of(path, app=None):
195
195
 
196
196
 
197
197
  def state_map(root):
198
- """Every entry id across the project's feedback files -> (state, "<file>, <date>")."""
198
+ """Every entry id across the project's feedback files -> [(state, "<file>, <date>", names)], one
199
+ item per file holding it. Every consumer numbers its own entries from 1, so a library with nine
200
+ consumer files can hold nine TR-002s (TrBlazeUI TF-006); `names` are the two project names in the
201
+ file's name (Lekhak and TrBlazeUI in Lekhak-TrBlazeUI-Feedback.md), which a line names to say whose."""
199
202
  out = {}
200
203
  for f in files(root):
201
204
  rel = os.path.relpath(f, root).replace(os.sep, "/")
205
+ names = tuple(os.path.basename(f)[: -len("-Feedback.md")].split("-"))
202
206
  for e in entries(f):
203
- out[e["id"]] = (e["state"], f"{rel}" + (f", Resolution status {e['since']}" if e["since"] else ""))
207
+ out.setdefault(e["id"], []).append(
208
+ (e["state"], f"{rel}" + (f", Resolution status {e['since']}" if e["since"] else ""), names))
204
209
  return out
205
210
 
206
211
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@techierathore/techieflow",
3
- "version": "1.1.1",
3
+ "version": "1.2.1",
4
4
  "description": "TechieFlow: a spec-driven development framework for AI coding agents, for Claude Code and OpenCode. One command installs it into any project.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -313,7 +313,7 @@ const canonicalSettings = String.raw`{
313
313
  ],
314
314
  "ask": [],
315
315
  "deny": [
316
- "Bash(rm -rf /)", "Bash(rm -rf /*)", "Bash(rm -rf ~)", "Bash(rm -rf ~/*)",
316
+ "Bash(rm -rf /)", "Bash(rm -rf ~)",
317
317
  "Bash(git commit*)", "Bash(git push*)", "Bash(git add*)", "Bash(git rm*)",
318
318
  "Bash(git mv*)", "Bash(git reset*)", "Bash(git checkout*)", "Bash(git switch*)",
319
319
  "Bash(git restore*)", "Bash(git merge*)", "Bash(git rebase*)", "Bash(git cherry-pick*)",