@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.
- package/.claude/commands/TechieFlow/tasks/_smoke-test-policy.md +7 -5
- package/.tfcore/hooks/block-git.sh +25 -2
- package/.tfcore/hooks/guard-db.sh +14 -3
- package/.tfcore/hooks/guard-status-html.sh +5 -1
- package/.tfcore/tasks/_smoke-test-policy.md +7 -5
- package/.tfcore/templates/stack-defaults/dotnet.md +2 -0
- package/.tfcore/templates/v4custom/app-usageguide-tmpl.md +4 -1
- package/.tfcore/utils/__pycache__/tf-checklist-edit.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-doc-check.cpython-312.pyc +0 -0
- package/.tfcore/utils/tf-brd-status.py +10 -1
- package/.tfcore/utils/tf-build.sh +25 -0
- package/.tfcore/utils/tf-checklist-edit.py +8 -1
- package/.tfcore/utils/tf-doc-check.py +41 -0
- package/.tfcore/utils/tf-mockup-parity.mjs +20 -3
- package/.tfcore/utils/tf-owner-text.py +27 -5
- package/.tfcore/utils/tf-theme.mjs +12 -0
- package/.tfcore/utils/tf-triage.py +18 -3
- package/.tfcore/utils/tf-verify-boot.sh +24 -4
- package/.tfcore/utils/tf-verify-screens.mjs +6 -0
- package/.tfcore/utils/tf-verify-verdict.py +28 -4
- package/.tfcore/utils/tf_feedback.py +7 -2
- package/package.json +1 -1
- package/scripts/install.mjs +1 -1
|
@@ -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
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
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"|\
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
Binary file
|
|
Binary file
|
|
@@ -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
|
-
|
|
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
|
|
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),
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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,
|
|
17
|
-
|
|
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"
|
|
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
|
|
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
|
-
|
|
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]}"
|
|
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-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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.
|
|
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": {
|
package/scripts/install.mjs
CHANGED
|
@@ -313,7 +313,7 @@ const canonicalSettings = String.raw`{
|
|
|
313
313
|
],
|
|
314
314
|
"ask": [],
|
|
315
315
|
"deny": [
|
|
316
|
-
"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*)",
|