@techierathore/techieflow 1.0.4 → 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/commands/TechieFlow/agents/flow-master.md +2 -0
- package/.claude/commands/TechieFlow/agents/verifier.md +2 -2
- package/.claude/commands/TechieFlow/tasks/_develop-mode.md +34 -0
- package/.claude/commands/TechieFlow/tasks/_owner-language.md +1 -0
- package/.claude/commands/TechieFlow/tasks/_status-update-gate.md +4 -3
- package/.claude/commands/TechieFlow/tasks/_yolo-mode.md +2 -2
- package/.claude/commands/TechieFlow/tasks/amend-docs.md +2 -2
- package/.claude/commands/TechieFlow/tasks/day1-brownfield.md +3 -3
- package/.claude/commands/TechieFlow/tasks/day1-greenfield.md +5 -5
- package/.claude/commands/TechieFlow/tasks/develop-end-to-end.md +30 -0
- package/.claude/commands/TechieFlow/tasks/fix-issues.md +3 -2
- package/.claude/commands/TechieFlow/tasks/generate-html.md +2 -2
- package/.claude/commands/TechieFlow/tasks/handoff-phase.md +2 -2
- package/.claude/commands/TechieFlow/tasks/mockups.md +2 -2
- 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-and-fix.md +2 -2
- package/.claude/commands/TechieFlow/tasks/triage-issues.md +2 -2
- package/.claude/commands/TechieFlow/tasks/verify-phase.md +12 -11
- package/.opencode/plugin/techieflow.js +194 -14
- package/.tfcore/agents/flow-master.md +2 -0
- package/.tfcore/agents/verifier.md +2 -2
- package/.tfcore/core-config.yaml +2 -1
- package/.tfcore/hooks/block-git.sh +4 -2
- package/.tfcore/hooks/guard-build.sh +5 -1
- package/.tfcore/hooks/guard-db.sh +112 -13
- package/.tfcore/hooks/guard-status-html.sh +86 -2
- package/.tfcore/hooks/guard-verify-deps.sh +16 -1
- package/.tfcore/hooks/guard-verify.sh +8 -3
- package/.tfcore/hooks/sweep-artifacts.sh +46 -0
- package/.tfcore/hooks/yolo-allow.sh +27 -0
- package/.tfcore/standards/blazor-zoom.cs.txt +191 -0
- package/.tfcore/standards/coding-standards-dotnet.md +31 -3
- package/.tfcore/standards/owner-words.txt +23 -0
- package/.tfcore/tasks/_develop-mode.md +34 -0
- package/.tfcore/tasks/_owner-language.md +1 -0
- package/.tfcore/tasks/_status-update-gate.md +4 -3
- package/.tfcore/tasks/_yolo-mode.md +2 -2
- package/.tfcore/tasks/amend-docs.md +2 -2
- package/.tfcore/tasks/day1-brownfield.md +3 -3
- package/.tfcore/tasks/day1-greenfield.md +5 -5
- package/.tfcore/tasks/develop-end-to-end.md +30 -0
- package/.tfcore/tasks/fix-issues.md +3 -2
- package/.tfcore/tasks/generate-html.md +2 -2
- package/.tfcore/tasks/handoff-phase.md +2 -2
- package/.tfcore/tasks/mockups.md +2 -2
- package/.tfcore/tasks/productguide.md +2 -2
- package/.tfcore/tasks/render-workflow-docs.md +1 -1
- package/.tfcore/tasks/triage-and-fix.md +2 -2
- package/.tfcore/tasks/triage-issues.md +2 -2
- package/.tfcore/tasks/verify-phase.md +12 -11
- package/.tfcore/telemetry/SCHEMA.md +10 -2
- package/.tfcore/telemetry/install-metrics.sh +3 -2
- package/.tfcore/telemetry/tf-metrics.sh +38 -2
- package/.tfcore/templates/stack-defaults/dotnet.md +3 -1
- package/.tfcore/templates/v4custom/app-agents-md-tmpl.md +6 -0
- package/.tfcore/templates/v4custom/app-architecture-tmpl.md +5 -0
- package/.tfcore/templates/v4custom/app-brd-tmpl.md +7 -4
- package/.tfcore/templates/v4custom/app-checklist-tmpl.md +11 -0
- 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-phases-tmpl.md +2 -1
- 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 +6 -2
- 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-split-brd.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-status-facts.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-verify-list.cpython-312.pyc +0 -0
- package/.tfcore/utils/tf-appium.mjs +179 -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 +95 -12
- package/.tfcore/utils/tf-build-list.sh +2 -1
- package/.tfcore/utils/tf-build.sh +163 -15
- package/.tfcore/utils/tf-checklist-edit.py +21 -3
- package/.tfcore/utils/tf-ci-repro.py +685 -0
- package/.tfcore/utils/tf-ci-repro.sh +24 -0
- package/.tfcore/utils/tf-day1-files.py +55 -28
- package/.tfcore/utils/tf-develop.sh +264 -0
- package/.tfcore/utils/tf-devguide-list.py +53 -24
- package/.tfcore/utils/tf-doc-check.py +218 -26
- package/.tfcore/utils/tf-doc-tests.sh +116 -0
- package/.tfcore/utils/tf-emit.sh +132 -45
- package/.tfcore/utils/tf-feedback.sh +13 -0
- package/.tfcore/utils/tf-fix-close.sh +87 -31
- package/.tfcore/utils/tf-goal.sh +12 -12
- 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-maccatalyst-check.sh +92 -0
- package/.tfcore/utils/tf-menus.mjs +401 -0
- package/.tfcore/utils/tf-misses-md.py +10 -4
- package/.tfcore/utils/tf-mockup-parity.mjs +763 -71
- package/.tfcore/utils/tf-mockup-parity.sh +25 -2
- package/.tfcore/utils/tf-owner-text.py +233 -0
- package/.tfcore/utils/tf-owner-text.sh +14 -0
- package/.tfcore/utils/tf-phase.sh +38 -4
- package/.tfcore/utils/tf-portable.sh +230 -0
- package/.tfcore/utils/tf-render-html.py +79 -5
- package/.tfcore/utils/tf-req-ids.py +36 -0
- package/.tfcore/utils/tf-selfcheck.sh +39 -6
- package/.tfcore/utils/tf-split-brd.py +11 -1
- package/.tfcore/utils/tf-stack-check.sh +3 -0
- package/.tfcore/utils/tf-status-evidence.sh +11 -9
- package/.tfcore/utils/tf-status-facts.py +160 -45
- package/.tfcore/utils/tf-theme.mjs +189 -0
- package/.tfcore/utils/tf-triage.py +74 -17
- package/.tfcore/utils/tf-triage.sh +2 -0
- package/.tfcore/utils/tf-verify-boot.sh +529 -54
- package/.tfcore/utils/tf-verify-emit.sh +57 -17
- package/.tfcore/utils/tf-verify-env.sh +33 -4
- package/.tfcore/utils/tf-verify-list.py +290 -24
- package/.tfcore/utils/tf-verify-list.sh +3 -1
- package/.tfcore/utils/tf-verify-native.mjs +307 -0
- package/.tfcore/utils/tf-verify-screens.mjs +341 -44
- package/.tfcore/utils/tf-verify-screens.sh +23 -5
- package/.tfcore/utils/tf-verify-tests.sh +310 -19
- package/.tfcore/utils/tf-verify-verdict.py +163 -21
- package/.tfcore/utils/tf-verify-zoom.mjs +193 -0
- package/.tfcore/utils/tf-verify-zoom.sh +29 -0
- package/.tfcore/utils/tf-yolo-alias.sh +84 -0
- package/.tfcore/utils/tf-yolo-perms.py +135 -0
- package/.tfcore/utils/tf-yolo-start.sh +44 -0
- package/.tfcore/utils/tf-yolo.sh +31 -3
- package/.tfcore/utils/tf_defect.py +27 -0
- package/.tfcore/utils/tf_feedback.py +272 -0
- package/.tfcore/utils/tf_roadmap.py +35 -0
- package/.tfcore/utils/tf_stack_check.py +93 -0
- package/README.md +6 -2
- package/docs/TechieFlow-Installation.md +5 -3
- package/opencode.jsonc +6 -2
- package/package.json +1 -1
- package/scripts/install.mjs +13 -2
- package/.tfcore/utils/__pycache__/tf-brd-status.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-codex-bind.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-codex-telemetry.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-day1-files.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-render-html.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-triage.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-verify-verdict.cpython-312.pyc +0 -0
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
// tf-menus.mjs — open every menu, click every item, and say which screens a click reaches (Chatur TF-011).
|
|
2
|
+
// Imported by tf-verify-screens.mjs and tf-mockup-parity.mjs; not run on its own.
|
|
3
|
+
//
|
|
4
|
+
// Both gates used to load each route directly and compare the closed page with its mockup. A closed
|
|
5
|
+
// menu bar matches its mockup, so File, Edit and View holding only "Nothing to do yet" passed, and
|
|
6
|
+
// Settings, Prerequisites and Repository passed though no click in the desktop app opened them.
|
|
7
|
+
//
|
|
8
|
+
// The mockup says what the menus are: a box marked data-tf-state="popup-open" is a menu or drop-down
|
|
9
|
+
// drawn open, and the nearest box around it with a data-testid is what opens it. For each one the app
|
|
10
|
+
// draws, the walk opens it and grades it against the mockup:
|
|
11
|
+
// - an item the mockup draws and the app lacks;
|
|
12
|
+
// - a menu whose every item is disabled where the mockup draws one enabled (a single disabled item
|
|
13
|
+
// is the state the app is in, no file open, and is only noted in the JSON);
|
|
14
|
+
// - placeholder text ("Nothing to do yet", "Coming soon", "Not built yet", "TODO", and any
|
|
15
|
+
// --placeholder-text a project adds) the mockup does not itself draw;
|
|
16
|
+
// - an enabled item that does nothing when clicked: no address, window, dialog, zoom, text or theme
|
|
17
|
+
// changed. A choice that is already the state (Light, in a light app) is clicked again after a
|
|
18
|
+
// sibling has moved the state, and the theme the walk changed is put back (Sevak TF-006).
|
|
19
|
+
// An item the mockup marks data-tf-sample is sample data and is not owed by its label; a pick-list (a
|
|
20
|
+
// select's options) is checked but not clicked; a trigger disabled in this state is passed over. A menu inside
|
|
21
|
+
// a box marked as another state (data-tf-state="mockup-only" …) is not owed at all. An item whose
|
|
22
|
+
// label ends the session or the work (Exit, Quit, Sign out, Log out, Close …, Delete, Remove, and any
|
|
23
|
+
// --no-click pattern) is checked but never clicked. A mockup that marks no menu still has the app's own menus opened: every
|
|
24
|
+
// [aria-haspopup="menu"|"true"] and every trigger in a [role=menubar], graded on placeholder text and on
|
|
25
|
+
// items that do nothing. A popup that is not a menu (a popover, role="group") is the box the trigger's
|
|
26
|
+
// aria-controls names.
|
|
27
|
+
// A menu item the mockup links to another mockup (<a href="prerequisites.html">) is the way into that
|
|
28
|
+
// screen. reachability() says which screens had such a way in and were reached by none of those clicks.
|
|
29
|
+
|
|
30
|
+
export const PLACEHOLDERS = ['Nothing to do yet', 'Coming soon', 'Not built yet', 'Not implemented yet', 'TODO'];
|
|
31
|
+
export const NO_CLICK = ['exit', 'quit', 'sign out', 'log out', 'logout', 'close', 'delete', 'remove', 'uninstall'];
|
|
32
|
+
|
|
33
|
+
// "Nothing to do yet" in any case; "TODO" only in capitals, so a board's "To do" column is not one
|
|
34
|
+
export const placeholderRe = (extra = []) => [...PLACEHOLDERS, ...extra].map((p) =>
|
|
35
|
+
new RegExp(`(^|[^A-Za-z])${p.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}($|[^A-Za-z])`, p === p.toUpperCase() ? '' : 'i'));
|
|
36
|
+
|
|
37
|
+
export const norm = (s) => String(s || '').toLowerCase().replace(/[^\p{L}\p{N}]+/gu, ' ').trim();
|
|
38
|
+
// the same item: one label is the other plus at most two short words (a shortcut: "Settings… Ctrl+,"),
|
|
39
|
+
// so "Run" is not "Run queue…"
|
|
40
|
+
const sameLabel = (a, b) => {
|
|
41
|
+
const [x, y] = [norm(a), norm(b)].sort((p, q) => p.length - q.length);
|
|
42
|
+
if (x.length < 2) return false;
|
|
43
|
+
if (x === y) return true;
|
|
44
|
+
if (!y.startsWith(x + ' ')) return false;
|
|
45
|
+
const extra = y.slice(x.length + 1).split(' ');
|
|
46
|
+
return extra.length <= 2 && extra.every((w) => w.length <= 4);
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
// --------------------------------------------------------------- the mockup's menus (in the page)
|
|
50
|
+
const READ_MOCK = (attr) => {
|
|
51
|
+
const ITEM = 'button, a[href], [role^="menuitem"], [role="option"]';
|
|
52
|
+
const label = (el) => {
|
|
53
|
+
const c = el.cloneNode(true);
|
|
54
|
+
c.querySelectorAll('.sub, kbd, .tick, [class*="shortcut"], svg, [data-shortcut]').forEach((x) => x.remove());
|
|
55
|
+
return (c.textContent || '').replace(/\s+/g, ' ').trim();
|
|
56
|
+
};
|
|
57
|
+
const out = [];
|
|
58
|
+
for (const p of document.querySelectorAll('[data-tf-state="popup-open"]')) {
|
|
59
|
+
if (p.parentElement && p.parentElement.closest('[data-tf-state="popup-open"]')) continue; // a submenu
|
|
60
|
+
if (p.parentElement && p.parentElement.closest('[data-tf-state]:not([data-tf-state="popup-open"]):not([data-tf-state="sample-data"]),[data-state-testid]')) continue;
|
|
61
|
+
const wrap = p.parentElement && p.parentElement.closest(`[${attr}]`);
|
|
62
|
+
const btn = wrap && [...wrap.querySelectorAll('button, [role="button"], [role="menuitem"]')].find((b) => !p.contains(b));
|
|
63
|
+
const items = [...p.querySelectorAll(ITEM)]
|
|
64
|
+
.filter((el) => !el.parentElement.closest(ITEM) || el.parentElement.closest(ITEM) === el)
|
|
65
|
+
.filter((el) => { const sub = el.closest('[data-tf-state="popup-open"]'); return sub === p; })
|
|
66
|
+
.map((el) => ({ label: label(el), disabled: !!el.disabled || el.getAttribute('aria-disabled') === 'true',
|
|
67
|
+
href: el.tagName === 'A' ? (el.getAttribute('href') || '') : '',
|
|
68
|
+
sample: !!el.closest('[data-tf-sample],[data-tf-state="sample-data"]') }))
|
|
69
|
+
.filter((x) => x.label);
|
|
70
|
+
out.push({ popup: p.getAttribute(attr) || '', trigger: wrap ? wrap.getAttribute(attr) : '', trigger_text: btn ? label(btn) : '',
|
|
71
|
+
text: (p.textContent || '').replace(/\s+/g, ' ').trim(), items });
|
|
72
|
+
}
|
|
73
|
+
return out;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
export async function mockupMenus(page, attr = 'data-testid') {
|
|
77
|
+
try { return await page.evaluate(READ_MOCK, attr); } catch (e) { return []; }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// --------------------------------------------------------------- the app's side (in the page)
|
|
81
|
+
const MENU_SEL = '[role="menu"], [role="listbox"], [role="menubar"] [data-state="open"], [data-tf-menu-open]';
|
|
82
|
+
const APP_ITEMS = ({ popupId, attr, controls }) => {
|
|
83
|
+
const ITEM = '[role^="menuitem"], [role="option"], button, a[href]';
|
|
84
|
+
const vis = (el) => { const r = el.getBoundingClientRect(); const s = getComputedStyle(el); return r.width > 0 && r.height > 0 && s.visibility !== 'hidden' && s.display !== 'none'; };
|
|
85
|
+
// the popup by its name when the app carries it; any open menu only when it does not (the walk closes
|
|
86
|
+
// every menu before it opens one, so an open one is the one just opened)
|
|
87
|
+
let box = popupId ? document.querySelector(`[${attr}="${CSS.escape(popupId)}"]`) : null;
|
|
88
|
+
// a popover opened by an aria-haspopup trigger is no menu: it is the box the trigger's aria-controls names
|
|
89
|
+
if ((!box || !vis(box)) && controls) { const c = document.getElementById(controls); if (c && vis(c)) { box = c; c.setAttribute('data-tf-walk-popup', ''); } }
|
|
90
|
+
if (!box) box = [...document.querySelectorAll('[role="menu"], [role="listbox"]')].filter(vis).pop() || null;
|
|
91
|
+
if (!box || !vis(box)) return null;
|
|
92
|
+
document.querySelectorAll('[data-tf-walk]').forEach((el) => el.removeAttribute('data-tf-walk')); // an earlier menu's marks
|
|
93
|
+
const text = (el) => (el.innerText || el.textContent || '').replace(/\s+/g, ' ').trim();
|
|
94
|
+
const items = [...box.querySelectorAll(ITEM)].filter(vis)
|
|
95
|
+
.filter((el) => { const up = el.parentElement && el.parentElement.closest(ITEM); return !up || !box.contains(up) || up === box; })
|
|
96
|
+
.map((el, i) => {
|
|
97
|
+
el.setAttribute('data-tf-walk', String(i));
|
|
98
|
+
// the value already chosen does nothing when chosen again, so it is never "an item that does nothing"
|
|
99
|
+
const checked = el.getAttribute('aria-checked') === 'true' || el.getAttribute('aria-selected') === 'true' || el.getAttribute('data-state') === 'checked'
|
|
100
|
+
|| !!el.querySelector('svg[class*="check"], [data-icon="check"], [data-lucide="check"]') || /✓|✔/.test(el.textContent || '');
|
|
101
|
+
// disabled by attribute, or by taking no pointer events at all (TrBlazeUI's menu items set only
|
|
102
|
+
// pointer-events:none and half opacity, with no aria-disabled)
|
|
103
|
+
return { i, label: text(el), disabled: el.matches(':disabled') || el.getAttribute('aria-disabled') === 'true' || el.hasAttribute('data-disabled')
|
|
104
|
+
|| getComputedStyle(el).pointerEvents === 'none',
|
|
105
|
+
checkbox: /checkbox/.test(el.getAttribute('role') || '') || (el.hasAttribute('aria-checked') && !/radio/.test(el.getAttribute('role') || '')), checked };
|
|
106
|
+
});
|
|
107
|
+
const choice = box.getAttribute('role') === 'listbox' || (items.length > 0 && [...box.querySelectorAll('[data-tf-walk]')].every((e) => e.getAttribute('role') === 'option'));
|
|
108
|
+
return { found_by: box.getAttribute(attr) === popupId ? 'testid' : 'role', text: text(box), items, choice };
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
// What a click can change, menus left out: the address, the text a reader sees, open dialogs, the zoom,
|
|
112
|
+
// the theme (light/dark classes and data-*theme attributes on <html> and <body>, and what they paint).
|
|
113
|
+
const SIGNATURE = (menuSel) => {
|
|
114
|
+
const inMenu = (n) => { const e = n.nodeType === 1 ? n : n.parentElement; return !!(e && e.closest(menuSel + ', [data-tf-walk-popup]')); };
|
|
115
|
+
const parts = [];
|
|
116
|
+
const w = document.createTreeWalker(document.body || document.documentElement, NodeFilter.SHOW_TEXT);
|
|
117
|
+
for (let n = w.nextNode(); n; n = w.nextNode()) {
|
|
118
|
+
const t = n.textContent.trim();
|
|
119
|
+
if (!t || inMenu(n)) continue;
|
|
120
|
+
const e = n.parentElement;
|
|
121
|
+
if (e && e.checkVisibility && !e.checkVisibility({ visibilityProperty: true, opacityProperty: true })) continue;
|
|
122
|
+
parts.push(t);
|
|
123
|
+
}
|
|
124
|
+
let h = 0; const s = parts.join('\u0001');
|
|
125
|
+
for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0;
|
|
126
|
+
const dialogs = [...document.querySelectorAll('[role="dialog"], [role="alertdialog"], dialog[open]')]
|
|
127
|
+
.filter((d) => { const r = d.getBoundingClientRect(); return r.width > 0 && r.height > 0; }).length;
|
|
128
|
+
const ids = [...document.querySelectorAll('[data-testid]')].filter((e) => { const r = e.getBoundingClientRect(); return r.width > 0 && r.height > 0 && !inMenu(e); })
|
|
129
|
+
.map((e) => e.getAttribute('data-testid')).slice(0, 400);
|
|
130
|
+
const MODE = /^(?:(?:theme|mode|scheme|color)[-_])?(?:dark|light)(?:[-_](?:theme|mode|scheme))?$/i;
|
|
131
|
+
const look = [document.documentElement, document.body].filter(Boolean).map((el) => {
|
|
132
|
+
const cs = getComputedStyle(el);
|
|
133
|
+
return [[...el.classList].filter((c) => MODE.test(c)).sort().join(' '),
|
|
134
|
+
[...el.attributes].filter((a) => /^data-.*(theme|mode|scheme)/i.test(a.name)).map((a) => `${a.name}=${a.value}`).sort().join(' '),
|
|
135
|
+
cs.backgroundColor, cs.colorScheme].join('|');
|
|
136
|
+
}).join('||');
|
|
137
|
+
return { url: location.href, text: h, len: s.length, dialogs, zoom: `${window.devicePixelRatio}|${getComputedStyle(document.documentElement).zoom || ''}|${document.body ? document.body.style.zoom : ''}`, look, ids };
|
|
138
|
+
};
|
|
139
|
+
const changed = (a, b) => a.url !== b.url || a.text !== b.text || a.dialogs !== b.dialogs || a.zoom !== b.zoom || a.look !== b.look;
|
|
140
|
+
const effectOf = (before, after) => !before || !after ? 'unknown' : before.url !== after.url ? 'navigated' : before.dialogs !== after.dialogs ? 'opened a dialog'
|
|
141
|
+
: before.zoom !== after.zoom ? 'zoomed' : before.look !== after.look ? 'changed the theme' : before.text !== after.text ? 'changed the page' : 'nothing';
|
|
142
|
+
|
|
143
|
+
// A point on plain text, outside every menu, list, dialog and control, to click a menu shut.
|
|
144
|
+
const NEUTRAL = () => {
|
|
145
|
+
const CTRL = 'a, button, input, select, textarea, label, summary, [role="button"], [role="menuitem"], [role="tab"], [role="link"], [role="option"], [role="combobox"], [role="menu"], [role="listbox"], [role="dialog"], [onclick], [tabindex]';
|
|
146
|
+
const W = window.innerWidth, H = window.innerHeight;
|
|
147
|
+
for (let y = 0.15; y < 1; y += 0.1) for (let x = 0.05; x < 1; x += 0.1) {
|
|
148
|
+
const e = document.elementFromPoint(W * x, H * y);
|
|
149
|
+
if (!e || e === document.documentElement || e.closest(CTRL)) continue;
|
|
150
|
+
return { x: Math.round(W * x), y: Math.round(H * y) };
|
|
151
|
+
}
|
|
152
|
+
return null;
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
// --------------------------------------------------------------- the walk
|
|
156
|
+
// page: the app tab on a screen that draws these menus. o.back(): reopens that screen as it was.
|
|
157
|
+
// o.pages(): every app tab/window open now (a desktop head's new window, a web app's new tab).
|
|
158
|
+
// Returns { menus: [...what each menu held and did...], findings: [{detail, class, popup}], clicks: [...] }
|
|
159
|
+
export async function walkMenus(page, mockMenus, o = {}) {
|
|
160
|
+
const attr = o.attr || 'data-testid';
|
|
161
|
+
const settle = o.settle ?? 1200;
|
|
162
|
+
const pr = placeholderRe(o.placeholders || []);
|
|
163
|
+
const noClick = [...NO_CLICK, ...(o.noClick || [])].map((x) => norm(x));
|
|
164
|
+
const pagesNow = o.pages || (async () => page.context().pages());
|
|
165
|
+
const findings = [], clicks = [], menus = [];
|
|
166
|
+
// o.mockText: the mockup's own text, so a placeholder the design itself draws is not held against the app
|
|
167
|
+
const placeholderIn = (t) => pr.find((re) => re.test(t) && !re.test(o.mockText || ''));
|
|
168
|
+
const says = (m) => m.trigger_text || m.trigger || m.popup || 'a menu';
|
|
169
|
+
|
|
170
|
+
let list = (mockMenus || []).filter((m) => m.items.length);
|
|
171
|
+
const fromMockup = list.length > 0;
|
|
172
|
+
if (!fromMockup) {
|
|
173
|
+
// the app's own menus, when the mockup marks none
|
|
174
|
+
const found = await page.evaluate(({ attr }) => {
|
|
175
|
+
const vis = (el) => { const r = el.getBoundingClientRect(); return r.width > 0 && r.height > 0; };
|
|
176
|
+
const t = [...document.querySelectorAll('[aria-haspopup="menu"], [aria-haspopup="true"], [role="menubar"] [role="menuitem"], [role="menubar"] button')].filter(vis);
|
|
177
|
+
return t.map((el, i) => { el.setAttribute('data-tf-walk-trigger', String(i)); return { trigger: '', trigger_walk: String(i), trigger_text: (el.innerText || el.getAttribute('aria-label') || '').trim().slice(0, 40), popup: '', items: [] }; });
|
|
178
|
+
}, { attr }).catch(() => []);
|
|
179
|
+
list = found;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Opens one menu. Returns its items, or why not: { missing } (the render check reports a missing
|
|
183
|
+
// control; a screen in another state, with no project open, draws no such trigger), { disabled } (a
|
|
184
|
+
// trigger disabled in this state: not a finding), { opened: false }. When the trigger is gone because the
|
|
185
|
+
// last click moved the screen on, the screen is reopened once.
|
|
186
|
+
const findTrigger = async (m) => {
|
|
187
|
+
let trig = m.trigger_walk ? page.locator(`[data-tf-walk-trigger="${m.trigger_walk}"]`) : m.trigger ? page.locator(`[${attr}="${m.trigger}"]`) : null;
|
|
188
|
+
if (!m.trigger && !m.trigger_walk && m.trigger_text) trig = page.getByRole('menuitem', { name: m.trigger_text, exact: true }).or(page.getByRole('button', { name: m.trigger_text, exact: true }));
|
|
189
|
+
return trig && (await trig.count()) ? trig.first() : null;
|
|
190
|
+
};
|
|
191
|
+
// Escape does not close every menu bar (TrBlazeUI's stays open, and so does it on a click in an empty
|
|
192
|
+
// corner), so: Escape, then a click on plain text outside any menu or control, then the trigger
|
|
193
|
+
// again, and last the screen reopened, until no menu or list is open
|
|
194
|
+
const anyOpen = () => page.evaluate(() => [...document.querySelectorAll('[role="menu"], [role="listbox"], [data-tf-walk-popup]')]
|
|
195
|
+
.some((e) => { const r = e.getBoundingClientRect(); const c = getComputedStyle(e); return r.width > 0 && r.height > 0 && c.visibility !== 'hidden'; })).catch(() => false);
|
|
196
|
+
const shut = async (m) => {
|
|
197
|
+
for (let k = 0; k < 3 && await anyOpen(); k++) {
|
|
198
|
+
if (k === 0) await page.keyboard.press('Escape').catch(() => {});
|
|
199
|
+
else if (k === 1) { const pt = await page.evaluate(NEUTRAL).catch(() => null); if (pt) await page.mouse.click(pt.x, pt.y).catch(() => {}); }
|
|
200
|
+
else if (m) { const t = await findTrigger(m); if (t) await t.click({ timeout: 2000 }).catch(() => {}); }
|
|
201
|
+
await page.waitForTimeout(200);
|
|
202
|
+
}
|
|
203
|
+
// one that will not close (a TrBlazeUI menu after an item that did nothing) goes with the screen
|
|
204
|
+
if (o.back && await anyOpen()) await o.back();
|
|
205
|
+
};
|
|
206
|
+
const open = async (m, retry = true) => {
|
|
207
|
+
await shut(m);
|
|
208
|
+
let trig = await findTrigger(m);
|
|
209
|
+
if (!trig && retry && o.back) { await o.back(); trig = await findTrigger(m); }
|
|
210
|
+
if (!trig) return { missing: true };
|
|
211
|
+
const inner = trig.locator('button, [role="button"], [role="menuitem"], [role="combobox"]').first();
|
|
212
|
+
const tries = [trig];
|
|
213
|
+
if (await inner.count()) tries.push(inner);
|
|
214
|
+
// a trigger that is itself disabled, or whose only button is, cannot be opened in this state
|
|
215
|
+
const off = async (l) => l.evaluate((e) => e.matches(':disabled') || e.getAttribute('aria-disabled') === 'true').catch(() => false);
|
|
216
|
+
if (await off(trig) || (tries.length > 1 && await off(inner))) return { disabled: true };
|
|
217
|
+
// a trigger clicked before the page is interactive, or toggled open and shut by one click, is
|
|
218
|
+
// clicked again: three times at most
|
|
219
|
+
for (const t of [...tries, tries[0], tries[0]]) {
|
|
220
|
+
await t.click({ timeout: 3000 }).catch(() => {});
|
|
221
|
+
await page.waitForTimeout(250);
|
|
222
|
+
m.controls = (await t.getAttribute('aria-controls').catch(() => null)) || (await trig.getAttribute('aria-controls').catch(() => null)) || m.controls || '';
|
|
223
|
+
for (let k = 0; k < 8; k++) {
|
|
224
|
+
const items = await page.evaluate(APP_ITEMS, { popupId: m.popup, attr, controls: m.controls }).catch(() => null);
|
|
225
|
+
if (items && items.items.length) return items;
|
|
226
|
+
await page.waitForTimeout(150);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return { opened: false };
|
|
230
|
+
};
|
|
231
|
+
// the item with that label in the menu as it is drawn now: a menu that redraws itself when it opens
|
|
232
|
+
// (Blazor re-rendering on the trigger's pointerdown) has dropped the marks the first reading put on it
|
|
233
|
+
const current = async (m, label) => {
|
|
234
|
+
await page.waitForTimeout(200);
|
|
235
|
+
const now = await page.evaluate(APP_ITEMS, { popupId: m.popup, attr, controls: m.controls }).catch(() => null);
|
|
236
|
+
return now && now.items.find((x) => x.label === label);
|
|
237
|
+
};
|
|
238
|
+
// one item clicked from a freshly opened menu, for the second look and the putting back below
|
|
239
|
+
const press = async (m, label) => {
|
|
240
|
+
const again = await open(m);
|
|
241
|
+
const fresh = again.items && await current(m, label);
|
|
242
|
+
if (!fresh || fresh.disabled) { await shut(m); return 'menu did not reopen'; }
|
|
243
|
+
const before = await page.evaluate(SIGNATURE, MENU_SEL).catch(() => null);
|
|
244
|
+
await page.locator(`[data-tf-walk="${fresh.i}"]`).first().click({ timeout: 3000 }).catch(() => {});
|
|
245
|
+
let after = null;
|
|
246
|
+
for (let t = 0; t < settle; t += 150) {
|
|
247
|
+
await page.waitForTimeout(150);
|
|
248
|
+
after = await page.evaluate(SIGNATURE, MENU_SEL).catch(() => null);
|
|
249
|
+
if (before && after && changed(before, after)) break;
|
|
250
|
+
}
|
|
251
|
+
await page.keyboard.press('Escape').catch(() => {});
|
|
252
|
+
await shut(m);
|
|
253
|
+
return effectOf(before, after);
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
for (const m of list) {
|
|
257
|
+
const name = says(m);
|
|
258
|
+
const got = await open(m);
|
|
259
|
+
const rec = { popup: m.popup, trigger: m.trigger || m.trigger_text, mockup_items: m.items.length, items: [] };
|
|
260
|
+
menus.push(rec);
|
|
261
|
+
if (got.missing) { rec.result = 'no trigger on the page (the render check reports a missing control)'; continue; }
|
|
262
|
+
if (got.disabled) { rec.result = 'trigger disabled in this state'; continue; }
|
|
263
|
+
if (got.opened === false) {
|
|
264
|
+
findings.push({ class: 'menu', popup: m.popup, detail: `the menu "${name}" opens nothing when clicked` });
|
|
265
|
+
rec.result = 'did not open'; continue;
|
|
266
|
+
}
|
|
267
|
+
rec.items = got.items.map((x) => ({ label: x.label, disabled: x.disabled }));
|
|
268
|
+
const ph = placeholderIn(got.text);
|
|
269
|
+
if (ph) findings.push({ class: 'placeholder', popup: m.popup, detail: `the menu "${name}" shows placeholder text "${got.text.match(ph)[0].trim()}"` });
|
|
270
|
+
// what the mockup owes. One item disabled is the state the app is in (no file open, nothing to
|
|
271
|
+
// redo), so it is noted; a menu whose every item is disabled where the mockup draws one enabled is
|
|
272
|
+
// a menu that does nothing, which is a finding.
|
|
273
|
+
const offVsMock = [];
|
|
274
|
+
for (const want of m.items) {
|
|
275
|
+
if (want.sample) continue;
|
|
276
|
+
const have = got.items.find((x) => sameLabel(x.label, want.label));
|
|
277
|
+
// drawn as a heading in the menu rather than an item ("Recent projects" over the list) is drawn
|
|
278
|
+
if (!have && norm(want.label).length >= 6 && ` ${norm(got.text)} `.includes(` ${norm(want.label)} `)) continue;
|
|
279
|
+
if (!have) findings.push({ class: 'menu', popup: m.popup, detail: `the menu "${name}" has no "${want.label}" item, which the mockup draws` });
|
|
280
|
+
else if (have.disabled && !want.disabled) offVsMock.push(have.label);
|
|
281
|
+
}
|
|
282
|
+
if (offVsMock.length) rec.disabled_where_mockup_enabled = offVsMock;
|
|
283
|
+
if (got.items.every((x) => x.disabled) && m.items.some((x) => !x.disabled))
|
|
284
|
+
findings.push({ class: 'menu', popup: m.popup, detail: `every item of the menu "${name}" is disabled; the mockup draws ${m.items.filter((x) => !x.disabled).length} of them enabled` });
|
|
285
|
+
// a pick-list (a select's options) chooses a value; its items are the data, not commands to click
|
|
286
|
+
if (got.choice) { rec.result = 'pick-list: items checked, not clicked'; await shut(m); continue; }
|
|
287
|
+
const startLook = ((await page.evaluate(SIGNATURE, MENU_SEL).catch(() => null)) || {}).look;
|
|
288
|
+
const mine = [];
|
|
289
|
+
// click every enabled item, one at a time, from a freshly opened menu
|
|
290
|
+
for (const item of got.items) {
|
|
291
|
+
const want = m.items.find((x) => sameLabel(item.label, x.label));
|
|
292
|
+
const c = { popup: m.popup, menu: name, label: item.label, href: want ? want.href : '', disabled: item.disabled };
|
|
293
|
+
clicks.push(c); mine.push({ c, item });
|
|
294
|
+
if (item.disabled) { c.effect = 'disabled'; continue; }
|
|
295
|
+
if (noClick.some((n) => norm(item.label) === n || norm(item.label).startsWith(n + ' '))) { c.effect = 'not clicked (ends the session or deletes)'; continue; }
|
|
296
|
+
const again = await open(m);
|
|
297
|
+
const fresh = again.items && await current(m, item.label);
|
|
298
|
+
if (!fresh) { c.effect = 'menu did not reopen'; continue; }
|
|
299
|
+
if (fresh.disabled) { c.effect = 'disabled'; await shut(m); continue; }
|
|
300
|
+
const before = await page.evaluate(SIGNATURE, MENU_SEL).catch(() => null);
|
|
301
|
+
const pagesBefore = new Set(await pagesNow());
|
|
302
|
+
const events = [];
|
|
303
|
+
const onPage = () => events.push('window'); const onDl = () => events.push('download'); const onFc = () => events.push('file chooser'); const onDg = (d) => { events.push('dialog'); d.dismiss().catch(() => {}); };
|
|
304
|
+
page.context().on('page', onPage); page.on('download', onDl); page.on('filechooser', onFc); page.on('dialog', onDg);
|
|
305
|
+
await page.locator(`[data-tf-walk="${fresh.i}"]`).first().click({ timeout: 3000 }).catch((e) => { c.error = e.message.split('\n')[0]; });
|
|
306
|
+
let after = null, newPages = [];
|
|
307
|
+
for (let t = 0; t < settle; t += 150) {
|
|
308
|
+
await page.waitForTimeout(150);
|
|
309
|
+
newPages = (await pagesNow()).filter((p) => !pagesBefore.has(p));
|
|
310
|
+
after = await page.evaluate(SIGNATURE, MENU_SEL).catch(() => null);
|
|
311
|
+
if (newPages.length || events.length || (before && after && changed(before, after))) break;
|
|
312
|
+
}
|
|
313
|
+
page.context().off('page', onPage); page.off('download', onDl); page.off('filechooser', onFc); page.off('dialog', onDg);
|
|
314
|
+
// where it landed: a new window or tab, or this one
|
|
315
|
+
let landed = null;
|
|
316
|
+
for (const np of newPages) {
|
|
317
|
+
await np.waitForLoadState('domcontentloaded', { timeout: 8000 }).catch(() => {});
|
|
318
|
+
await np.waitForTimeout(settle).catch(() => {});
|
|
319
|
+
landed = { url: np.url(), ids: (await np.evaluate(SIGNATURE, MENU_SEL).catch(() => ({ ids: [] }))).ids, window: true };
|
|
320
|
+
}
|
|
321
|
+
if (!landed && after) landed = { url: after.url, ids: after.ids, window: false };
|
|
322
|
+
c.landed = landed ? { url: landed.url, window: landed.window, ids: landed.ids } : null;
|
|
323
|
+
c.ids_before = before ? before.ids : [];
|
|
324
|
+
c.effect = c.error ? 'could not be clicked' : newPages.length ? 'opened a window' : events.length ? events.join(', ') : effectOf(before, after);
|
|
325
|
+
if (c.error) findings.push({ class: 'menu', popup: m.popup, detail: `"${name} › ${item.label}" could not be clicked (${c.error.slice(0, 80)})` });
|
|
326
|
+
// put the app back: a new window closed (a web tab only; a desktop head's window is the owner's),
|
|
327
|
+
// a dialog dismissed, a toggled item toggled back, the screen reopened when the address moved
|
|
328
|
+
if (!o.keepWindows) for (const np of newPages) await np.close().catch(() => {});
|
|
329
|
+
await page.keyboard.press('Escape').catch(() => {}); // a dialog the item opened
|
|
330
|
+
await shut(m);
|
|
331
|
+
if (item.checkbox && c.effect !== 'nothing') {
|
|
332
|
+
const back = await open(m);
|
|
333
|
+
const b = back.items && await current(m, item.label);
|
|
334
|
+
if (b) { await page.locator(`[data-tf-walk="${b.i}"]`).first().click({ timeout: 3000 }).catch(() => {}); await page.waitForTimeout(300); }
|
|
335
|
+
await shut(m);
|
|
336
|
+
}
|
|
337
|
+
const now = await page.evaluate(SIGNATURE, MENU_SEL).catch(() => null);
|
|
338
|
+
if (o.back && (!now || !before || now.url !== before.url || now.dialogs > before.dialogs)) await o.back();
|
|
339
|
+
}
|
|
340
|
+
// a choice that was already the state (Light in a light app) shows nothing: when a sibling moved the
|
|
341
|
+
// theme or the page in place, that sibling is clicked first and the choice looked at again
|
|
342
|
+
const donor = mine.find((x) => !x.item.checkbox && /^changed the (theme|page)$/.test(x.c.effect || ''));
|
|
343
|
+
for (const { c, item } of mine) {
|
|
344
|
+
if (c.effect !== 'nothing' || item.checked) continue;
|
|
345
|
+
if (donor && donor.item.label !== item.label && !/^menu did not reopen/.test(await press(m, donor.item.label))) {
|
|
346
|
+
const second = await press(m, item.label);
|
|
347
|
+
if (second !== 'nothing' && !/^menu did not reopen|^unknown/.test(second)) { c.effect = `${second} (once "${donor.item.label}" had moved the state)`; continue; }
|
|
348
|
+
}
|
|
349
|
+
findings.push({ class: 'menu', popup: m.popup, detail: `"${name} › ${item.label}" does nothing when clicked` });
|
|
350
|
+
}
|
|
351
|
+
// the theme put back as the walk found it: an attached desktop head is the viewer's app
|
|
352
|
+
if (startLook) {
|
|
353
|
+
const themed = mine.filter((x) => /^changed the theme/.test(x.c.effect || '')).map((x) => x.item);
|
|
354
|
+
const order = [...themed.filter((x) => x.checked), ...themed.filter((x) => !x.checked)];
|
|
355
|
+
for (const it of order) {
|
|
356
|
+
if (((await page.evaluate(SIGNATURE, MENU_SEL).catch(() => null)) || {}).look === startLook) break;
|
|
357
|
+
await press(m, it.label);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
await shut(m);
|
|
361
|
+
}
|
|
362
|
+
return { from_mockup: fromMockup, menus, findings, clicks };
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// Text a placeholder screen shows: the deny list, less what the mockup itself draws.
|
|
366
|
+
export async function placeholdersOnPage(page, mockText = '', extra = []) {
|
|
367
|
+
const text = await page.evaluate(() => (document.body ? document.body.innerText : '') || '').catch(() => '');
|
|
368
|
+
return placeholderRe(extra).filter((re) => re.test(text) && !re.test(mockText)).map((re) => text.match(re)[0].replace(/^[^A-Za-z]|[^A-Za-z]$/g, '').trim());
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// --------------------------------------------------------------- the way into each screen
|
|
372
|
+
// screens: [{ name, route, mockup, anchors }]. clicks: from every walk. A screen has a way in when a
|
|
373
|
+
// menu item the mockup draws links to its mockup file. It is reached when one of those clicks landed on
|
|
374
|
+
// its route, or on a page showing at least half of its anchors that were not showing before the click.
|
|
375
|
+
// A screen whose every way in was left unclicked (Sign out) is not judged.
|
|
376
|
+
export function reachability(screens, clicks) {
|
|
377
|
+
const base = (p) => String(p || '').split(/[?#]/)[0].split('/').pop();
|
|
378
|
+
const routeRe = (r) => new RegExp('^' + String(r || '').split('?')[0].replace(/[.*+?^$()|[\]\\]/g, '\\$&').replace(/\\?\{[^}]*\}/g, '[^/]+').replace(/\/$/, '') + '/?$', 'i');
|
|
379
|
+
const out = {};
|
|
380
|
+
for (const s of screens) {
|
|
381
|
+
if (!s.mockup) continue;
|
|
382
|
+
const ways = clicks.filter((c) => c.href && base(c.href) === base(s.mockup));
|
|
383
|
+
if (!ways.length) continue;
|
|
384
|
+
const tried = ways.filter((c) => c.effect && !/^not clicked|^menu did not reopen/.test(c.effect));
|
|
385
|
+
if (!tried.length) continue;
|
|
386
|
+
const re = routeRe(s.route_pattern || s.route);
|
|
387
|
+
const want = (s.anchors || []).filter((a) => !(s.state_only || []).includes(a));
|
|
388
|
+
const hit = tried.find((c) => {
|
|
389
|
+
if (!c.landed) return false;
|
|
390
|
+
let path = '';
|
|
391
|
+
try { path = new URL(c.landed.url).pathname; } catch (e) { path = ''; }
|
|
392
|
+
if (path && re.test(path) && c.effect !== 'nothing') return true;
|
|
393
|
+
if (!want.length) return false;
|
|
394
|
+
const fresh = want.filter((a) => (c.landed.ids || []).includes(a) && !(c.landed.window ? [] : c.ids_before || []).includes(a));
|
|
395
|
+
return fresh.length >= Math.ceil(want.length / 2);
|
|
396
|
+
});
|
|
397
|
+
out[s.name] = hit ? { reached: true, by: `${hit.menu} › ${hit.label}` }
|
|
398
|
+
: { reached: false, tried: tried.map((c) => `${c.menu} › ${c.label} (${c.effect}${c.landed ? `, at ${(() => { try { return new URL(c.landed.url).pathname; } catch (e) { return c.landed.url; } })()}` : ''})`) };
|
|
399
|
+
}
|
|
400
|
+
return out;
|
|
401
|
+
}
|
|
@@ -4,7 +4,7 @@
|
|
|
4
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
9
|
sentence. Markdown only — there is no HTML sibling: the miss log is read by agents and by the owner
|
|
10
10
|
in markdown, and tf-render-html.py refuses it (owner, 2026-09-08; an HTML copy nobody opened was
|
|
@@ -108,11 +108,15 @@ def build(root, app):
|
|
|
108
108
|
if prev is None or (f.get("ts") or "") >= (prev.get("ts") or ""):
|
|
109
109
|
latest[f["miss_id"]] = f
|
|
110
110
|
|
|
111
|
+
voids = {r.get("miss_id"): r for r in recs if r.get("kind") == "miss-void"}
|
|
112
|
+
|
|
111
113
|
def state(m):
|
|
114
|
+
if m["miss_id"] in voids:
|
|
115
|
+
return "withdrawn"
|
|
112
116
|
v = (latest.get(m["miss_id"]) or {}).get("verdict_after")
|
|
113
117
|
return "fixed" if v == "Verified" else "wont-fix" if v == "wont-fix" else "open"
|
|
114
118
|
|
|
115
|
-
groups = {"open": [], "fixed": [], "wont-fix": []}
|
|
119
|
+
groups = {"open": [], "fixed": [], "wont-fix": [], "withdrawn": []}
|
|
116
120
|
for m in sorted(by_id.values(), key=lambda r: (r.get("miss_id") or "", r.get("ts") or ""), reverse=True):
|
|
117
121
|
groups[state(m)].append(m)
|
|
118
122
|
|
|
@@ -138,12 +142,12 @@ def build(root, app):
|
|
|
138
142
|
today = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d")
|
|
139
143
|
n = len(by_id)
|
|
140
144
|
lines = [f"# {app} — Misses", "", "| | |", "|---|---|", f"| App | {app} |",
|
|
141
|
-
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 "") + " |",
|
|
142
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. |",
|
|
143
147
|
f"| Updated | {today} |", "", SORT_LEGEND, ""]
|
|
144
148
|
if not n:
|
|
145
149
|
lines += ["No miss has been logged yet.", ""]
|
|
146
|
-
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")):
|
|
147
151
|
rows = groups[key]
|
|
148
152
|
if not rows:
|
|
149
153
|
continue
|
|
@@ -157,6 +161,8 @@ def build(root, app):
|
|
|
157
161
|
for m in rows:
|
|
158
162
|
f = latest.get(m["miss_id"]) or {}
|
|
159
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'))}"
|
|
160
166
|
lines.append(f"| {ident(m)} | {found(m)} | {closed} | {gap(m)} | {what(m)} |")
|
|
161
167
|
lines.append("")
|
|
162
168
|
return "\n".join(lines), n
|