@techierathore/techieflow 1.2.0 → 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.
@@ -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"):
@@ -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
@@ -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).
@@ -662,7 +664,13 @@ const CLAUSES = {
662
664
  : m.token.fits && !a.token.fits
663
665
  ? `value cell is narrower than its longest unbreakable token ("${a.token.token}") — it breaks mid-token`
664
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),
665
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']);
666
674
 
667
675
  // Which clauses require having reached INSIDE a container rather than merely
668
676
  // touching its outer box. TF-011's `harness` screen graded three column CONTAINERS
@@ -800,8 +808,10 @@ function diff(mock, app, screen, width, skip = null) {
800
808
  return false;
801
809
  };
802
810
 
811
+ const sampleKeys = Object.keys(mock.index).filter((k) => mock.index[k].sample);
803
812
  for (const key of Object.keys(mock.index)) {
804
- 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];
805
815
  let a = app.index[key];
806
816
  let appKey = key;
807
817
 
@@ -829,6 +839,7 @@ function diff(mock, app, screen, width, skip = null) {
829
839
  // wrapper div and become the always-present finding TF-012 warns about — the
830
840
  // fastest way to train a reader to skim the whole report.
831
841
  if (!a) {
842
+ if (inSample && m.badge === true) continue; // a sample badge reading otherwise in the app is its data (TF-010)
832
843
  const parentKey = key.includes(' > ') ? key.slice(0, key.lastIndexOf(' > ')) : null;
833
844
  const parentPaired = parentKey ? !!app.index[parentKey] : false;
834
845
  // an icon is missing only when the paired parent carries fewer icons than the mockup's
@@ -870,6 +881,7 @@ function diff(mock, app, screen, width, skip = null) {
870
881
  compared++;
871
882
  for (const [name, fn] of Object.entries(CLAUSES)) {
872
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;
873
885
  const r = fn(m, a);
874
886
  if (r === null) continue;
875
887
  clauseCoverage[name]++;
@@ -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(() => {});
@@ -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:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@techierathore/techieflow",
3
- "version": "1.2.0",
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": {