@techierathore/techieflow 1.0.3 → 1.1.0
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/agents/analyst.md +5 -8
- package/.claude/commands/TechieFlow/agents/architect.md +1 -10
- package/.claude/commands/TechieFlow/agents/flow-master.md +1 -0
- package/.claude/commands/TechieFlow/agents/verifier.md +1 -0
- package/.claude/commands/TechieFlow/tasks/_owner-language.md +27 -0
- package/.claude/commands/TechieFlow/tasks/_smoke-test-policy.md +1 -1
- package/.claude/commands/TechieFlow/tasks/_status-update-gate.md +4 -3
- package/.claude/commands/TechieFlow/tasks/amend-docs.md +6 -5
- package/.claude/commands/TechieFlow/tasks/build-phase.md +1 -1
- package/.claude/commands/TechieFlow/tasks/create-deep-research-prompt.md +4 -0
- package/.claude/commands/TechieFlow/tasks/create-doc.md +4 -0
- package/.claude/commands/TechieFlow/tasks/create-project-brief.md +22 -0
- package/.claude/commands/TechieFlow/tasks/day1-brownfield.md +3 -3
- package/.claude/commands/TechieFlow/tasks/day1-greenfield.md +3 -3
- package/.claude/commands/TechieFlow/tasks/facilitate-brainstorming-session.md +4 -0
- package/.claude/commands/TechieFlow/tasks/fix-issues.md +2 -2
- package/.claude/commands/TechieFlow/tasks/generate-html.md +7 -3
- package/.claude/commands/TechieFlow/tasks/handoff-phase.md +20 -18
- package/.claude/commands/TechieFlow/tasks/metrics-report.md +2 -0
- package/.claude/commands/TechieFlow/tasks/mockups.md +1 -1
- package/.claude/commands/TechieFlow/tasks/productguide.md +2 -2
- package/.claude/commands/TechieFlow/tasks/render-workflow-docs.md +1 -1
- package/.claude/commands/TechieFlow/tasks/triage-issues.md +2 -2
- package/.claude/commands/TechieFlow/tasks/verify-phase.md +1 -1
- package/.opencode/plugin/techieflow.js +181 -9
- package/.tfcore/agents/analyst.md +5 -8
- package/.tfcore/agents/architect.md +1 -10
- package/.tfcore/agents/flow-master.md +1 -0
- package/.tfcore/agents/verifier.md +1 -0
- package/.tfcore/hooks/block-git.sh +4 -2
- package/.tfcore/hooks/guard-build.sh +5 -1
- package/.tfcore/hooks/guard-db.sh +6 -4
- package/.tfcore/hooks/guard-metrics.sh +13 -1
- package/.tfcore/hooks/guard-status-html.sh +86 -2
- package/.tfcore/hooks/guard-verify-deps.sh +124 -0
- package/.tfcore/hooks/guard-verify.sh +8 -3
- package/.tfcore/hooks/sweep-artifacts.sh +46 -0
- package/.tfcore/routing.yaml +61 -0
- package/.tfcore/standards/owner-words.txt +23 -0
- package/.tfcore/tasks/_owner-language.md +27 -0
- package/.tfcore/tasks/_smoke-test-policy.md +1 -1
- package/.tfcore/tasks/_status-update-gate.md +4 -3
- package/.tfcore/tasks/amend-docs.md +6 -5
- package/.tfcore/tasks/build-phase.md +1 -1
- package/.tfcore/tasks/create-deep-research-prompt.md +4 -0
- package/.tfcore/tasks/create-doc.md +4 -0
- package/.tfcore/tasks/create-project-brief.md +22 -0
- package/.tfcore/tasks/day1-brownfield.md +3 -3
- package/.tfcore/tasks/day1-greenfield.md +3 -3
- package/.tfcore/tasks/facilitate-brainstorming-session.md +4 -0
- package/.tfcore/tasks/fix-issues.md +2 -2
- package/.tfcore/tasks/generate-html.md +7 -3
- package/.tfcore/tasks/handoff-phase.md +20 -18
- package/.tfcore/tasks/metrics-report.md +2 -0
- package/.tfcore/tasks/mockups.md +1 -1
- package/.tfcore/tasks/productguide.md +2 -2
- package/.tfcore/tasks/render-workflow-docs.md +1 -1
- package/.tfcore/tasks/triage-issues.md +2 -2
- package/.tfcore/tasks/verify-phase.md +1 -1
- package/.tfcore/telemetry/SCHEMA.md +61 -1
- package/.tfcore/telemetry/install-metrics.sh +3 -2
- package/.tfcore/telemetry/tf-metrics.sh +435 -21
- package/.tfcore/templates/v4custom/app-agents-md-tmpl.md +6 -0
- package/.tfcore/templates/v4custom/app-brd-tmpl.md +5 -3
- package/.tfcore/templates/v4custom/app-brief-tmpl.md +65 -0
- package/.tfcore/templates/v4custom/app-decision-request-tmpl.md +85 -0
- package/.tfcore/templates/v4custom/app-library-feedback-tmpl.md +86 -31
- package/.tfcore/templates/v4custom/app-phase-brd-tmpl.md +94 -0
- package/.tfcore/templates/v4custom/app-phase-uidesign-tmpl.md +60 -0
- package/.tfcore/templates/v4custom/app-uidesign-tmpl.md +4 -2
- package/.tfcore/templates/v4custom/build-subagent-prompt.md +2 -2
- package/.tfcore/templates/v4custom/html-render-shell.md +4 -2
- package/.tfcore/utils/__pycache__/tf-brd-status.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-checklist-edit.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-day1-files.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-devguide-list.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-doc-check.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-log-miss.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-misses-md.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-mockups-locate.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-opencode-audit.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-perf-grade.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-render-html.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-split-brd.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-status-facts.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-triage.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-verify-list.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-verify-verdict.cpython-312.pyc +0 -0
- package/.tfcore/utils/tf-assets-browser.mjs +96 -0
- package/.tfcore/utils/tf-assets.sh +97 -12
- package/.tfcore/utils/tf-brd-status.py +14 -5
- package/.tfcore/utils/tf-build-list.py +99 -11
- package/.tfcore/utils/tf-build-list.sh +5 -3
- package/.tfcore/utils/tf-build.sh +88 -15
- package/.tfcore/utils/tf-ci-repro.py +685 -0
- package/.tfcore/utils/tf-ci-repro.sh +24 -0
- package/.tfcore/utils/tf-devguide-list.py +47 -22
- package/.tfcore/utils/tf-doc-check.py +242 -10
- package/.tfcore/utils/tf-doc-tests.sh +79 -0
- package/.tfcore/utils/tf-emit.sh +413 -19
- package/.tfcore/utils/tf-feedback.sh +13 -0
- package/.tfcore/utils/tf-fix-close.sh +113 -31
- package/.tfcore/utils/tf-gitignore-audit.sh +50 -3
- package/.tfcore/utils/tf-goal.sh +105 -18
- package/.tfcore/utils/tf-harness.sh +9 -4
- package/.tfcore/utils/tf-lock.sh +29 -0
- package/.tfcore/utils/tf-log-miss.py +34 -8
- package/.tfcore/utils/tf-login.mjs +167 -0
- package/.tfcore/utils/tf-misses-md.py +24 -21
- package/.tfcore/utils/tf-misses-md.sh +5 -4
- package/.tfcore/utils/tf-mockup-parity.mjs +427 -63
- package/.tfcore/utils/tf-mockup-parity.sh +14 -2
- package/.tfcore/utils/tf-model-pick.sh +475 -0
- package/.tfcore/utils/tf-owner-text.py +233 -0
- package/.tfcore/utils/tf-owner-text.sh +14 -0
- package/.tfcore/utils/tf-phase.sh +33 -1
- package/.tfcore/utils/tf-portable.sh +230 -0
- package/.tfcore/utils/tf-render-html.py +80 -2
- package/.tfcore/utils/tf-routing-bind.sh +87 -1
- package/.tfcore/utils/tf-routing.sh +7 -0
- package/.tfcore/utils/tf-selfcheck.sh +356 -0
- package/.tfcore/utils/tf-split-brd.py +17 -2
- package/.tfcore/utils/tf-status-evidence.sh +11 -9
- package/.tfcore/utils/tf-status-facts.py +133 -44
- package/.tfcore/utils/tf-triage.py +62 -16
- package/.tfcore/utils/tf-triage.sh +2 -0
- package/.tfcore/utils/tf-verify-boot.sh +263 -43
- package/.tfcore/utils/tf-verify-emit.sh +20 -0
- package/.tfcore/utils/tf-verify-env.sh +33 -4
- package/.tfcore/utils/tf-verify-list.py +75 -8
- package/.tfcore/utils/tf-verify-screens.mjs +254 -43
- package/.tfcore/utils/tf-verify-screens.sh +18 -4
- package/.tfcore/utils/tf-verify-tests.sh +272 -28
- package/.tfcore/utils/tf-verify-verdict.py +59 -11
- package/.tfcore/utils/tf_defect.py +27 -0
- package/.tfcore/utils/tf_feedback.py +209 -0
- package/.tfcore/utils/tf_roadmap.py +35 -0
- package/README.md +19 -5
- package/docs/TechieFlow-Installation.md +5 -3
- package/opencode.jsonc +24 -0
- package/package.json +1 -1
- package/scripts/install.mjs +4 -0
- package/.tfcore/templates/architecture-tmpl.yaml +0 -650
- package/.tfcore/templates/brownfield-architecture-tmpl.yaml +0 -476
- package/.tfcore/templates/competitor-analysis-tmpl.yaml +0 -306
- package/.tfcore/templates/front-end-architecture-tmpl.yaml +0 -218
- package/.tfcore/templates/fullstack-architecture-tmpl.yaml +0 -823
- package/.tfcore/templates/market-research-tmpl.yaml +0 -252
- package/.tfcore/templates/project-brief-tmpl.yaml +0 -221
- package/.tfcore/utils/techieflow-doc-template.md +0 -325
|
@@ -16,7 +16,8 @@ re-logged; a null why_missed or sort on it is completed with --amend); the origi
|
|
|
16
16
|
(tf-emit.sh --origin-of); the miss record with the sentence as `what` and the sort; the checklist line
|
|
17
17
|
(the row demoted to Needs re-verify with "⚠ miss <date>: <sentence>", or a new Not Started row with
|
|
18
18
|
BRD-pending when nothing owns it; nothing when the repo has no checklist); with --fixed, a miss-fix
|
|
19
|
-
record instead of a demotion; then the run record (cmd log-miss)
|
|
19
|
+
record instead of a demotion; then the run record (cmd log-miss) — none when another command is
|
|
20
|
+
running, because that command's own record covers the time (TF-029). The emitter rewrites the readable
|
|
20
21
|
docs/<App>-Misses.md. Prints the report block. Exit 0 · 2 could not run.
|
|
21
22
|
"""
|
|
22
23
|
import datetime
|
|
@@ -91,11 +92,31 @@ def main(argv):
|
|
|
91
92
|
found_by = opt(argv, "--found-by", "owner")
|
|
92
93
|
fixed = "--fixed" in argv
|
|
93
94
|
started = opt(argv, "--started") or ""
|
|
95
|
+
now = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
96
|
+
# The command whose run record covers this time. Called inside another command (*amend-docs
|
|
97
|
+
# step 10), that command's record covers it, so this script writes none: a log-miss run over
|
|
98
|
+
# the enclosing command's window made the enclosing record overlap and be refused (TF-029).
|
|
99
|
+
# A marker older than 24 h belongs to a session that died, and is ignored as every reader does.
|
|
100
|
+
running = "log-miss"
|
|
94
101
|
if not started:
|
|
102
|
+
# The marker's own cmd and started, never its "outer" block: a command started after another
|
|
103
|
+
# one keeps that one as "outer", written first, and a first-match read took it for this one
|
|
104
|
+
# (TF-052 follow-up: log-miss after *triage-issues wrote no run record).
|
|
95
105
|
mk = subprocess.run(["bash", os.path.join(HERE, "tf-phase.sh"), "show"], capture_output=True, text=True).stdout
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
106
|
+
try:
|
|
107
|
+
marker = json.loads(mk) if mk.strip().startswith("{") else {}
|
|
108
|
+
except ValueError:
|
|
109
|
+
marker = {}
|
|
110
|
+
m_started, m_cmd = marker.get("started") or "", marker.get("cmd") or ""
|
|
111
|
+
try:
|
|
112
|
+
age = (datetime.datetime.now(datetime.timezone.utc)
|
|
113
|
+
- datetime.datetime.strptime(m_started, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=datetime.timezone.utc))
|
|
114
|
+
fresh = age < datetime.timedelta(hours=24)
|
|
115
|
+
except ValueError:
|
|
116
|
+
fresh = False
|
|
117
|
+
started = m_started if fresh else now
|
|
118
|
+
if fresh and m_cmd and m_cmd not in ("log-miss", "goal"):
|
|
119
|
+
running = m_cmd
|
|
99
120
|
|
|
100
121
|
spec = importlib.util.spec_from_file_location("tf_checklist_edit", os.path.join(HERE, "tf-checklist-edit.py"))
|
|
101
122
|
ce = importlib.util.module_from_spec(spec)
|
|
@@ -165,10 +186,14 @@ def main(argv):
|
|
|
165
186
|
fix_ok, _ = emit("misses", fr)
|
|
166
187
|
fix_line = ("closed in the same run" + (f" against run {fr['fix_run_id']}" if "fix_run_id" in fr else " (fix run unknown: costed none)")) if fix_ok else "miss-fix not written"
|
|
167
188
|
|
|
168
|
-
# 5. the run record
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
189
|
+
# 5. the run record — only when log-miss is the command running
|
|
190
|
+
if running == "log-miss":
|
|
191
|
+
run = {"kind": "run", "app": app, "cmd": "log-miss", "mode": None, "started": started, "ended": now,
|
|
192
|
+
"reqs_touched": [req] if req else [], "reqs_count": 1 if req else 0, "subagents": [], "files_written": 1 if has_cl else 0, "build_result": "not-run"}
|
|
193
|
+
emit("runs", run)
|
|
194
|
+
run_line = f"log-miss, {started} to {now}"
|
|
195
|
+
else:
|
|
196
|
+
run_line = f"none written — the running *{running}'s own record covers this time"
|
|
172
197
|
|
|
173
198
|
if not ok:
|
|
174
199
|
print(f"# Miss NOT recorded — {app}")
|
|
@@ -189,6 +214,7 @@ def main(argv):
|
|
|
189
214
|
print(f"Docs : {doc_line}; docs/{app}-Misses.md rewritten")
|
|
190
215
|
if fix_line:
|
|
191
216
|
print(f"Fix : {fix_line}")
|
|
217
|
+
print(f"Run record : {run_line}")
|
|
192
218
|
print(f"Next : *fix-issues {app}" + (f" {req}" if req and not fixed else ""))
|
|
193
219
|
return 0
|
|
194
220
|
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// TechieFlow — the one sign-in recipe the browser-driven checks share (AppManager TF-011, 2026-09-14).
|
|
2
|
+
//
|
|
3
|
+
// tf-verify-screens.mjs learned to sign in through a page's own form (TF-033, TF-044) and to reach a
|
|
4
|
+
// screen whose document answers 401 because the sign-in lives in the page, not in a cookie
|
|
5
|
+
// (AppManager TF-006). tf-mockup-parity.mjs and tf-assets took only --cookie, so on such an app they
|
|
6
|
+
// could not get past the sign-in at all. The recipe lives here so every check reaches a screen the
|
|
7
|
+
// same way, and a lesson learned in one is learned in all.
|
|
8
|
+
//
|
|
9
|
+
// signIn(page, o) -> { attempted, ok, url, attempts, error }
|
|
10
|
+
// signedOut(page, o) -> true when the page is the sign-in page
|
|
11
|
+
// navigateInApp(page, o, route) -> true when the page drew something different
|
|
12
|
+
// reach(page, o, route) -> { status, url, reached, signedOut, error }: open a route, signed in
|
|
13
|
+
//
|
|
14
|
+
// o = { base, loginPath, user, password, attr = 'data-testid', settle = 1500, renderWait = 5000 }
|
|
15
|
+
|
|
16
|
+
// The first visible, usable match, trying the selectors IN ORDER. `.first()` over one selector list
|
|
17
|
+
// takes whichever match comes first in the page, so `input[type="text"]` at the end of the list
|
|
18
|
+
// picked a header search box standing before the email field (TF-044).
|
|
19
|
+
export async function pick(scope, selectors) {
|
|
20
|
+
for (const sel of selectors) {
|
|
21
|
+
const c = scope.locator(sel).first();
|
|
22
|
+
if (await c.count() && await c.isVisible().catch(() => false)) return c;
|
|
23
|
+
}
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const LOGIN_ATTEMPTS = 4;
|
|
28
|
+
const opts = (o) => ({ attr: 'data-testid', settle: 1500, renderWait: 5000, ...o, base: (o.base || '').replace(/\/$/, '') });
|
|
29
|
+
|
|
30
|
+
export async function signIn(page, o) {
|
|
31
|
+
const { base, loginPath, user, password, attr, settle } = opts(o);
|
|
32
|
+
if (!loginPath || !user) return { attempted: false };
|
|
33
|
+
const onLogin = () => page.url().startsWith(base + loginPath);
|
|
34
|
+
try {
|
|
35
|
+
await page.goto(base + loginPath, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
|
36
|
+
await page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {});
|
|
37
|
+
// a page that draws its form a moment after it arrives has no field to pick yet
|
|
38
|
+
await page.waitForSelector('input', { state: 'visible', timeout: 15000 }).catch(() => {});
|
|
39
|
+
const INPUT = ':is(input, textarea)';
|
|
40
|
+
const USER_FIELD = [`${INPUT}[${attr}*="email" i]`, `${INPUT}[${attr}*="user" i]`, 'input[type="email"]', 'input[autocomplete="username"]',
|
|
41
|
+
'input[name*="email" i]', 'input[name*="user" i]', 'input[id*="email" i]', 'input[id*="user" i]', 'form input[type="text"]'];
|
|
42
|
+
const PASS_FIELD = ['input[type="password"]', `${INPUT}[${attr}*="pass" i]`];
|
|
43
|
+
// The button, never a field: "login-email" and "login-pass" contain "login" too, and taking the
|
|
44
|
+
// first test id with "login" in it clicked the email box, so sign-in never submitted (TF-033).
|
|
45
|
+
const BTN = `:is(button, a, [role="button"], input[type="submit"], input[type="button"])`;
|
|
46
|
+
const BUTTON = ['button[type="submit"]', 'input[type="submit"]', `${BTN}[${attr}*="submit" i]`, `${BTN}[${attr}*="signin" i]`,
|
|
47
|
+
`${BTN}[${attr}*="sign-in" i]`, `${BTN}[${attr}*="login" i]`, 'button'];
|
|
48
|
+
// A page drawn on the server first and made interactive a moment later replaces its fields when
|
|
49
|
+
// it becomes interactive: what was typed before that is gone, and a press before it does nothing.
|
|
50
|
+
// Waiting for the network proves neither, and every stack takes its own time. So the test is the
|
|
51
|
+
// result: the typed values are still in the fields when the button is pressed, and the page then
|
|
52
|
+
// leaves the sign-in address. Otherwise type again and press again. TfLens, 2026-09-12: the email
|
|
53
|
+
// read back empty the instant after it was typed, and the second attempt signed in (TF-044).
|
|
54
|
+
let why = 'the typed values did not stay in the fields';
|
|
55
|
+
for (let attempt = 1; attempt <= LOGIN_ATTEMPTS; attempt++) {
|
|
56
|
+
if (!onLogin()) break;
|
|
57
|
+
const u = await pick(page, USER_FIELD);
|
|
58
|
+
if (!u) return { attempted: true, ok: false, error: 'no user or email field found on the sign-in page' };
|
|
59
|
+
const p = await pick(page, PASS_FIELD);
|
|
60
|
+
try {
|
|
61
|
+
await u.fill(user, { timeout: 10000 });
|
|
62
|
+
if (p) await p.fill(password || '', { timeout: 10000 });
|
|
63
|
+
await page.waitForTimeout(400);
|
|
64
|
+
const kept = (await u.inputValue()) === user && (!p || (await p.inputValue()) === (password || ''));
|
|
65
|
+
if (!kept) { why = 'the typed values did not stay in the fields'; continue; }
|
|
66
|
+
const form = u.locator('xpath=ancestor::form[1]');
|
|
67
|
+
const btn = (await form.count() ? await pick(form, BUTTON) : null) || await pick(page, BUTTON);
|
|
68
|
+
if (!btn) return { attempted: true, ok: false, error: 'no sign-in button found (a submit button, or a button whose test id says submit, signin or login)' };
|
|
69
|
+
await btn.click({ timeout: 10000 });
|
|
70
|
+
await page.waitForURL((x) => !x.href.startsWith(base + loginPath), { timeout: 6000 }).catch(() => {});
|
|
71
|
+
why = 'the form was submitted with the values in place and the page stayed on the sign-in address';
|
|
72
|
+
} catch (e) {
|
|
73
|
+
if (onLogin()) why = e.message.split('\n')[0];
|
|
74
|
+
}
|
|
75
|
+
if (!onLogin()) {
|
|
76
|
+
await page.waitForLoadState('networkidle', { timeout: 15000 }).catch(() => {});
|
|
77
|
+
await page.waitForTimeout(settle);
|
|
78
|
+
return { attempted: true, ok: true, url: page.url(), attempts: attempt };
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (!onLogin()) return { attempted: true, ok: true, url: page.url(), attempts: 1 };
|
|
82
|
+
return { attempted: true, ok: false, url: page.url(), attempts: LOGIN_ATTEMPTS, error: `still on the sign-in page after ${LOGIN_ATTEMPTS} attempts: ${why}` };
|
|
83
|
+
} catch (e) {
|
|
84
|
+
return { attempted: true, ok: false, error: e.message.split('\n')[0] };
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// The page is signed out when it sits on the sign-in address, or when it draws a sign-in form: a
|
|
89
|
+
// password field WITH a button that says sign in / log in beside it. A password field alone is not
|
|
90
|
+
// the sign-in page — AppManager's signed-in "Create user" form has one for the new account, and
|
|
91
|
+
// reading it as the sign-in page graded that screen unreachable (AppManager TF-012).
|
|
92
|
+
export async function signedOut(page, o) {
|
|
93
|
+
const { loginPath, attr } = opts(o);
|
|
94
|
+
try {
|
|
95
|
+
if (loginPath && new URL(page.url()).pathname.startsWith(loginPath)) return true;
|
|
96
|
+
const pw = page.locator('input[type="password"]').first();
|
|
97
|
+
if (!(await pw.count()) || !(await pw.isVisible().catch(() => false))) return false;
|
|
98
|
+
const form = pw.locator('xpath=ancestor::form[1]');
|
|
99
|
+
const scope = (await form.count()) ? form : page;
|
|
100
|
+
const BTN = ':is(button, a, [role="button"], input[type="submit"], input[type="button"])';
|
|
101
|
+
const said = scope.locator(`${BTN}:visible`).filter({ hasText: /^\s*(sign\s*in|log\s*in|login|signin)\s*$/i }).first();
|
|
102
|
+
if (await said.count()) return true;
|
|
103
|
+
const named = scope.locator(`${BTN}[${attr}*="signin" i], ${BTN}[${attr}*="sign-in" i], ${BTN}[${attr}*="login" i]`).first();
|
|
104
|
+
return (await named.count()) > 0 && (await named.isVisible().catch(() => false));
|
|
105
|
+
} catch (e) { return true; }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Open a route from inside the page, the way its own links do, so a sign-in held by the page survives.
|
|
109
|
+
// It counts only when the page drew something different: a server-drawn page has no router to answer
|
|
110
|
+
// the event, and grading the page it was already on under another screen's name would be a false pass.
|
|
111
|
+
export async function navigateInApp(page, o, route) {
|
|
112
|
+
const { settle } = opts(o);
|
|
113
|
+
try {
|
|
114
|
+
const before = await page.evaluate(() => document.body ? document.body.innerText : '');
|
|
115
|
+
await page.evaluate((r) => { history.pushState({}, '', r); window.dispatchEvent(new PopStateEvent('popstate', { state: {} })); }, route);
|
|
116
|
+
await page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {});
|
|
117
|
+
await page.waitForTimeout(settle);
|
|
118
|
+
const after = await page.evaluate(() => document.body ? document.body.innerText : '');
|
|
119
|
+
return after !== before;
|
|
120
|
+
} catch (e) { return false; }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Wait for the page to draw something: an anchored control, text, or a form control (TF-021).
|
|
124
|
+
export async function waitForRender(page, o, anchors = []) {
|
|
125
|
+
const { attr, renderWait } = opts(o);
|
|
126
|
+
if (renderWait <= 0) return 0;
|
|
127
|
+
const t0 = Date.now();
|
|
128
|
+
try {
|
|
129
|
+
await page.waitForFunction(({ anchors, attr }) => {
|
|
130
|
+
if (anchors && anchors.length) return anchors.some((id) => document.querySelector(`[${attr}="${CSS.escape(id)}"]`));
|
|
131
|
+
return (document.body && (document.body.innerText || '').trim().length > 0)
|
|
132
|
+
|| document.querySelectorAll('input, select, textarea, img, svg, canvas, video').length > 0;
|
|
133
|
+
}, { anchors, attr }, { timeout: renderWait, polling: 100 });
|
|
134
|
+
} catch (e) { /* nothing appeared in time: grade what is there */ }
|
|
135
|
+
return Date.now() - t0;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Open a route signed in. The document of a signed-in screen may answer 401 or 403 when the sign-in
|
|
139
|
+
// lives in the page's own connection and not in a cookie (AppManager TF-006): then the page is judged
|
|
140
|
+
// by what it draws. Signed in, the screen is reached and `reached` says how. Still signed out, the
|
|
141
|
+
// tool signs in again and opens the screen from inside the page. Returns the status to grade on:
|
|
142
|
+
// 200 when the page drew the screen signed in, whatever the document answered.
|
|
143
|
+
export async function reach(page, o, route) {
|
|
144
|
+
const { base, loginPath, user, settle } = opts(o);
|
|
145
|
+
let status = 0, url = '', error = '';
|
|
146
|
+
try {
|
|
147
|
+
const resp = await page.goto(base + route, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
|
148
|
+
await page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {});
|
|
149
|
+
await page.waitForTimeout(settle);
|
|
150
|
+
status = resp ? resp.status() : 0; url = page.url();
|
|
151
|
+
} catch (e) { return { status: 0, url: '', reached: '', signedOut: false, error: e.message.split('\n')[0] }; }
|
|
152
|
+
const out = { status, document_status: status, url, reached: '', signedOut: false, error };
|
|
153
|
+
if (loginPath && user && (status === 401 || status === 403)) {
|
|
154
|
+
await waitForRender(page, o);
|
|
155
|
+
let how = `answered HTTP ${status}, then drew the screen signed in`;
|
|
156
|
+
if (await signedOut(page, o)) {
|
|
157
|
+
how = '';
|
|
158
|
+
const again = await signIn(page, o);
|
|
159
|
+
if (again.ok && await navigateInApp(page, o, route) && !(await signedOut(page, o))) how = `answered HTTP ${status}; opened from inside the page after signing in`;
|
|
160
|
+
}
|
|
161
|
+
if (how) { out.reached = how; out.status = 200; out.url = page.url(); }
|
|
162
|
+
else out.signedOut = true;
|
|
163
|
+
} else if (loginPath && url && new URL(url).pathname.startsWith(loginPath) && !route.startsWith(loginPath)) {
|
|
164
|
+
out.signedOut = true;
|
|
165
|
+
}
|
|
166
|
+
return out;
|
|
167
|
+
}
|
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
"""tf-misses-md.py — the readable miss list, docs/<App>-Misses.md (Session 5, 2026-09-07; FR-31).
|
|
3
3
|
|
|
4
|
-
python3 .tfcore/utils/tf-misses-md.py [--root <repo>] [--app <App>] [--
|
|
4
|
+
python3 .tfcore/utils/tf-misses-md.py [--root <repo>] [--app <App>] [--quiet]
|
|
5
5
|
|
|
6
6
|
Rebuilds docs/<App>-Misses.md from docs/metrics/misses.jsonl: one row per `miss` record, newest first,
|
|
7
|
-
in
|
|
7
|
+
in up to four tables (open, fixed, will not fix, withdrawn by a miss-void), each row holding the miss id, the owning row, when and
|
|
8
8
|
by whom it was found, whose gap it was (the `sort` field, folded from any miss-amend) and the `what`
|
|
9
|
-
sentence.
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
sentence. Markdown only — there is no HTML sibling: the miss log is read by agents and by the owner
|
|
10
|
+
in markdown, and tf-render-html.py refuses it (owner, 2026-09-08; an HTML copy nobody opened was
|
|
11
|
+
being re-rendered on every miss in every project). tf-emit.sh calls this after every write to the
|
|
12
|
+
misses stream, so the file is never older than the record; run it by hand once on a project whose
|
|
13
|
+
stream predates it. The file is derived: never edit it, edit nothing, log a miss.
|
|
12
14
|
Exit 0 always (telemetry has no veto); the reason for a skipped write is printed unless --quiet.
|
|
13
15
|
"""
|
|
14
16
|
import datetime
|
|
@@ -16,7 +18,6 @@ import glob
|
|
|
16
18
|
import json
|
|
17
19
|
import os
|
|
18
20
|
import re
|
|
19
|
-
import subprocess
|
|
20
21
|
import sys
|
|
21
22
|
|
|
22
23
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
@@ -107,11 +108,15 @@ def build(root, app):
|
|
|
107
108
|
if prev is None or (f.get("ts") or "") >= (prev.get("ts") or ""):
|
|
108
109
|
latest[f["miss_id"]] = f
|
|
109
110
|
|
|
111
|
+
voids = {r.get("miss_id"): r for r in recs if r.get("kind") == "miss-void"}
|
|
112
|
+
|
|
110
113
|
def state(m):
|
|
114
|
+
if m["miss_id"] in voids:
|
|
115
|
+
return "withdrawn"
|
|
111
116
|
v = (latest.get(m["miss_id"]) or {}).get("verdict_after")
|
|
112
117
|
return "fixed" if v == "Verified" else "wont-fix" if v == "wont-fix" else "open"
|
|
113
118
|
|
|
114
|
-
groups = {"open": [], "fixed": [], "wont-fix": []}
|
|
119
|
+
groups = {"open": [], "fixed": [], "wont-fix": [], "withdrawn": []}
|
|
115
120
|
for m in sorted(by_id.values(), key=lambda r: (r.get("miss_id") or "", r.get("ts") or ""), reverse=True):
|
|
116
121
|
groups[state(m)].append(m)
|
|
117
122
|
|
|
@@ -137,12 +142,12 @@ def build(root, app):
|
|
|
137
142
|
today = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d")
|
|
138
143
|
n = len(by_id)
|
|
139
144
|
lines = [f"# {app} — Misses", "", "| | |", "|---|---|", f"| App | {app} |",
|
|
140
|
-
f"| Count | {n} logged: {len(groups['open'])} open, {len(groups['fixed'])} fixed, {len(groups['wont-fix'])} will not fix |",
|
|
145
|
+
f"| Count | {n} logged: {len(groups['open'])} open, {len(groups['fixed'])} fixed, {len(groups['wont-fix'])} will not fix" + (f", {len(groups['withdrawn'])} withdrawn" if groups["withdrawn"] else "") + " |",
|
|
141
146
|
"| Source | `docs/metrics/misses.jsonl`, one row per miss record. Rewritten by `tf-misses-md.sh` on every new record. Never edit it: a wrong row is corrected by a new record. |",
|
|
142
147
|
f"| Updated | {today} |", "", SORT_LEGEND, ""]
|
|
143
148
|
if not n:
|
|
144
149
|
lines += ["No miss has been logged yet.", ""]
|
|
145
|
-
for key, title in (("open", "Open"), ("fixed", "Fixed"), ("wont-fix", "Will not fix")):
|
|
150
|
+
for key, title in (("open", "Open"), ("fixed", "Fixed"), ("wont-fix", "Will not fix"), ("withdrawn", "Withdrawn — logged in error")):
|
|
146
151
|
rows = groups[key]
|
|
147
152
|
if not rows:
|
|
148
153
|
continue
|
|
@@ -156,6 +161,8 @@ def build(root, app):
|
|
|
156
161
|
for m in rows:
|
|
157
162
|
f = latest.get(m["miss_id"]) or {}
|
|
158
163
|
closed = day(f.get("ts")) + (f" by {f['fix_cmd']}" if f.get("fix_cmd") else "")
|
|
164
|
+
if key == "withdrawn":
|
|
165
|
+
closed = f"{day(voids[m['miss_id']].get('ts'))}: {cell(voids[m['miss_id']].get('reason'))}"
|
|
159
166
|
lines.append(f"| {ident(m)} | {found(m)} | {closed} | {gap(m)} | {what(m)} |")
|
|
160
167
|
lines.append("")
|
|
161
168
|
return "\n".join(lines), n
|
|
@@ -183,24 +190,20 @@ def main(argv):
|
|
|
183
190
|
old = fh.read()
|
|
184
191
|
except FileNotFoundError:
|
|
185
192
|
pass
|
|
193
|
+
stale = out[:-3] + ".html" # left by a pre-2026-09-08 run, before the miss
|
|
194
|
+
gone = "" # log was ruled an agent document (owner)
|
|
195
|
+
if os.path.isfile(stale):
|
|
196
|
+
os.remove(stale)
|
|
197
|
+
gone = "; removed the stale HTML copy"
|
|
186
198
|
same = lambda s: re.sub(r"\| Updated \| [0-9-]+ \|", "", s) # everything but the date must match
|
|
187
|
-
if old is not None and same(old) == same(text)
|
|
199
|
+
if old is not None and same(old) == same(text):
|
|
188
200
|
if not quiet:
|
|
189
|
-
print(f"tf-misses-md: {rel} unchanged ({n} misses)")
|
|
201
|
+
print(f"tf-misses-md: {rel} unchanged ({n} misses){gone}")
|
|
190
202
|
return 0
|
|
191
203
|
with open(out, "w", encoding="utf-8", newline="\n") as fh:
|
|
192
204
|
fh.write(text)
|
|
193
|
-
html = "skipped"
|
|
194
|
-
if "--no-html" not in argv:
|
|
195
|
-
r = os.path.join(HERE, "tf-render-html.py")
|
|
196
|
-
if os.path.isfile(r):
|
|
197
|
-
try:
|
|
198
|
-
p = subprocess.run([sys.executable, r, out, "--quiet"], capture_output=True, text=True, timeout=120)
|
|
199
|
-
html = "rendered" if p.returncode == 0 else "not rendered (" + (p.stderr or p.stdout).strip().splitlines()[-1:][0][:100] + ")" if (p.stderr or p.stdout).strip() else "not rendered"
|
|
200
|
-
except Exception as e:
|
|
201
|
-
html = f"not rendered ({e})"
|
|
202
205
|
if not quiet:
|
|
203
|
-
print(f"tf-misses-md: wrote {rel} ({n} misses)
|
|
206
|
+
print(f"tf-misses-md: wrote {rel} ({n} misses){gone}")
|
|
204
207
|
except Exception as e:
|
|
205
208
|
if not quiet:
|
|
206
209
|
print(f"tf-misses-md: nothing written ({e})")
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
|
-
# tf-misses-md.sh — rebuild the readable miss list docs/<App>-Misses.md
|
|
3
|
-
#
|
|
4
|
-
# stream; run it by hand once on a
|
|
2
|
+
# tf-misses-md.sh — rebuild the readable miss list docs/<App>-Misses.md from the misses
|
|
3
|
+
# stream (Session 5, 2026-09-07; FR-31). Markdown only: the miss log is never rendered to
|
|
4
|
+
# HTML. tf-emit.sh runs it after every write to that stream; run it by hand once on a
|
|
5
|
+
# project whose stream is older than this script.
|
|
5
6
|
#
|
|
6
|
-
# bash .tfcore/utils/tf-misses-md.sh [--root <repo>] [--app <App>] [--
|
|
7
|
+
# bash .tfcore/utils/tf-misses-md.sh [--root <repo>] [--app <App>] [--quiet]
|
|
7
8
|
#
|
|
8
9
|
# Exit 0 always. Details: tf-misses-md.py.
|
|
9
10
|
set -u
|