@giamme/forge 0.8.0 → 0.10.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.
Files changed (83) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/README.md +106 -1766
  3. package/SKILL.md +22 -3
  4. package/VERSION +1 -1
  5. package/docs/README.md +26 -0
  6. package/docs/add-ons.md +33 -0
  7. package/docs/artifacts.md +84 -0
  8. package/docs/commands.md +365 -0
  9. package/docs/dataset.md +29 -0
  10. package/docs/decomposed-runs.md +310 -0
  11. package/docs/development.md +95 -0
  12. package/docs/forge-ui.md +128 -0
  13. package/docs/fractal.md +181 -0
  14. package/docs/install.md +160 -0
  15. package/docs/memory.md +156 -0
  16. package/docs/models.md +118 -0
  17. package/docs/reviews.md +245 -0
  18. package/docs/safety.md +55 -0
  19. package/docs/troubleshooting.md +54 -0
  20. package/forge +20 -0
  21. package/package.json +3 -2
  22. package/references/dataset.md +18 -1
  23. package/references/decompose.md +20 -2
  24. package/references/fractal.md +1 -1
  25. package/references/harnesses.md +18 -6
  26. package/references/solo.md +1 -1
  27. package/references/ui.md +27 -5
  28. package/scripts/forge-config.py +347 -0
  29. package/scripts/forge-control.py +645 -0
  30. package/scripts/forge-dispatch.sh +7 -1
  31. package/scripts/forge-issues.py +806 -0
  32. package/scripts/forge-lib-task.sh +234 -35
  33. package/scripts/forge-parallel-options.sh +3 -1
  34. package/scripts/forge-parallel.sh +3 -2
  35. package/scripts/forge-route.py +184 -27
  36. package/scripts/forge-runs-rm.py +840 -0
  37. package/scripts/forge-runs.py +139 -0
  38. package/scripts/forge-runtime.py +59 -6
  39. package/scripts/forge-schedule.py +227 -11
  40. package/scripts/forge-ui.py +85 -19
  41. package/scripts/forge-version.py +54 -12
  42. package/scripts/forge_dataset/__init__.py +77 -6
  43. package/scripts/forge_dataset/capture.py +5 -2
  44. package/scripts/forge_dataset/harvest.py +23 -1
  45. package/scripts/forge_dataset/importer.py +420 -18
  46. package/scripts/forge_fractal/__init__.py +2 -1
  47. package/scripts/forge_registry.py +168 -16
  48. package/ui/README.md +116 -23
  49. package/ui/THIRD_PARTY_NOTICES.md +108 -0
  50. package/ui/docs/api.md +283 -23
  51. package/ui/forge_ui/__main__.py +10 -2
  52. package/ui/forge_ui/control.py +575 -0
  53. package/ui/forge_ui/export.py +33 -9
  54. package/ui/forge_ui/fractal.py +1 -2
  55. package/ui/forge_ui/reader.py +121 -8
  56. package/ui/forge_ui/server.py +135 -5
  57. package/ui/forge_ui/static/D-DINCondensed-Bold.woff2 +0 -0
  58. package/ui/forge_ui/static/app.js +358 -131
  59. package/ui/forge_ui/static/cloud3d.js +3 -2
  60. package/ui/forge_ui/static/cloudui.js +8 -5
  61. package/ui/forge_ui/static/controls.js +619 -0
  62. package/ui/forge_ui/static/convo.js +295 -0
  63. package/ui/forge_ui/static/datatable.js +232 -0
  64. package/ui/forge_ui/static/diffview.js +29 -10
  65. package/ui/forge_ui/static/findings3d.js +134 -0
  66. package/ui/forge_ui/static/findingsview.js +412 -0
  67. package/ui/forge_ui/static/graph.js +3 -3
  68. package/ui/forge_ui/static/icons.js +94 -0
  69. package/ui/forge_ui/static/index.html +31 -13
  70. package/ui/forge_ui/static/iso.js +70 -19
  71. package/ui/forge_ui/static/leaderboard.js +44 -48
  72. package/ui/forge_ui/static/panel.js +92 -36
  73. package/ui/forge_ui/static/rundelete.js +388 -0
  74. package/ui/forge_ui/static/runmodel.js +17 -1
  75. package/ui/forge_ui/static/scene3d.js +518 -43
  76. package/ui/forge_ui/static/search.js +2 -1
  77. package/ui/forge_ui/static/settings.js +399 -0
  78. package/ui/forge_ui/static/shell.js +137 -0
  79. package/ui/forge_ui/static/stagectl.js +150 -0
  80. package/ui/forge_ui/static/style.css +1099 -469
  81. package/ui/forge_ui/static/theme.js +25 -0
  82. package/ui/forge_ui/static/uikit.js +161 -0
  83. package/ui/forge_ui/transcripts.py +608 -0
@@ -0,0 +1,134 @@
1
+ /* forge-ui finding beacons: every review finding as a small mark over the stage. Its shape is its severity
2
+ (P0 spike, P1 diamond, P2 cone, P3 pin, no severity a ring), drawn like the blocks: a hairline in its --sev-*
3
+ colour over a translucent fill of the same, on a short post, its foot on the block it is about; a confirmed
4
+ finding is full size and fuller, a plausible one smaller and paler. Seen from straight above (the top-down
5
+ projection) the marks lie flat, so each keeps its silhouette. scene3d.js says where each one stands (an anchor
6
+ that follows its block as it lifts, opens or travels); this module draws them, one instanced mesh per shape
7
+ with the engine's hairline outline, and picks them. Marks stand still, so an idle stage draws no frames; one
8
+ that arrives (a new finding on a poll, one a replay reaches, one moving onto an opened task) drops in. */
9
+ export const SHAPES = ['P0', 'P1', 'P2', 'P3', 'none'];
10
+ const MAX = 512; // marks per shape
11
+ const SIZE = 18; // plan units per unit of the shapes below: a mark stands ~40 tall
12
+ const MIN_PX = 28; // a mark stands at least this tall on screen while the plate is near
13
+ const FAR_PPU = .5; // screen px per plan unit below which that floor shrinks with the plate, to half
14
+ const TALL = 2.2; // a mark's height in shape units (post and head)
15
+
16
+ export function createBeacons(E, { reduce = false } = {}) {
17
+ const { THREE, W, TS } = E;
18
+ const POST = .3; // the post's height; every head sits on it
19
+ const post = () => new THREE.CylinderGeometry(.035, .035, POST, 6).translate(0, POST / 2, 0);
20
+ const geo = {
21
+ P0: mergeTwo(THREE, new THREE.ConeGeometry(.42, 1.6, 4).translate(0, POST + .8, 0), post()),
22
+ P1: mergeTwo(THREE, new THREE.OctahedronGeometry(.62).scale(1, 1.3, 1).translate(0, POST + .8, 0), post()),
23
+ P2: mergeTwo(THREE, new THREE.ConeGeometry(.62, 1.2, 16).rotateX(Math.PI).translate(0, POST + .6, 0), post()),
24
+ P3: mergeTwo(THREE, new THREE.SphereGeometry(.5, 18, 12).translate(0, POST + .48, 0), post()),
25
+ none: mergeTwo(THREE, new THREE.TorusGeometry(.5, .12, 8, 24).translate(0, POST + .56, 0), post()),
26
+ };
27
+ const group = new THREE.Group(); group.name = 'beacons';
28
+ const meshes = {}, outlines = {};
29
+ for (const k of SHAPES) {
30
+ const m = new THREE.InstancedMesh(geo[k], new THREE.MeshBasicMaterial({ toneMapped: false, transparent: true, depthWrite: false }), MAX);
31
+ m.count = 0; m.frustumCulled = false; m.userData = { beacons: k }; m.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
32
+ m.setColorAt(0, new THREE.Color()); // allocates the per-instance colours
33
+ const o = new THREE.InstancedMesh(geo[k], E.outlineInstanced(), MAX);
34
+ o.material.side = THREE.BackSide; o.count = 0; o.frustumCulled = false; o.instanceMatrix = m.instanceMatrix; o.raycast = () => {};
35
+ group.add(m, o); meshes[k] = m; outlines[k] = o;
36
+ }
37
+ let unit = SIZE; // plan units per shape unit this frame (marks grow when far away)
38
+ let items = [], byShape = Object.fromEntries(SHAPES.map(k => [k, []])), seen = null, visible = true, hot = null, flat = false;
39
+ const FLAT = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(1, 0, 0), -Math.PI / 2); // the head toward the top of the screen
40
+ const M4 = new THREE.Matrix4(), Q = new THREE.Quaternion(), S = new THREE.Vector3(), P = new THREE.Vector3(), C = new THREE.Color();
41
+ const ez = t => 1 - Math.pow(1 - t, 3);
42
+
43
+ // items: [{key, sev: 'P0'..'P3'|null, plausible, anchor() → [x, y, z, scale?] in plan units | null (hidden), data}]
44
+ function set(next) {
45
+ const now = performance.now(), first = seen === null;
46
+ seen = seen || new Set();
47
+ items = next.map(it => ({ ...it, shape: SHAPES.includes(it.sev) ? it.sev : 'none', born: first || seen.has(it.key) || reduce ? -Infinity : now,
48
+ phase: (hash(it.key) % 628) / 100 }));
49
+ for (const it of items) seen.add(it.key);
50
+ byShape = Object.fromEntries(SHAPES.map(k => [k, []]));
51
+ for (const it of items) if (byShape[it.shape].length < MAX) byShape[it.shape].push(it);
52
+ paint();
53
+ }
54
+ // the fill is the severity's colour seen through: fuller when confirmed, paler when plausible; the hairline is the colour itself
55
+ function paint() {
56
+ const bg = E.BG_COL, light = E.theme === 'light';
57
+ for (const k of SHAPES) {
58
+ const m = meshes[k], list = byShape[k];
59
+ list.forEach((it, i) => {
60
+ C.copy(E.sevColor(it.sev || 'none')).lerp(bg, it.plausible ? .7 : .45);
61
+ if (hot === it.key) C.lerp(E.INKHI, .3);
62
+ m.setColorAt(i, C);
63
+ });
64
+ if (m.instanceColor) m.instanceColor.needsUpdate = true;
65
+ m.material.opacity = light ? .82 : .75;
66
+ outlines[k].material.uniforms.color.value.copy(E.sevColor(k));
67
+ }
68
+ }
69
+ // every frame: each mark where its anchor is; one that just arrived falls in. Returns whether anything moves.
70
+ function tick(now) {
71
+ if (!visible) { for (const k of SHAPES) meshes[k].count = outlines[k].count = 0; return false; }
72
+ let moving = false;
73
+ const ppu = Math.max(1e-6, E.pxPerUnit()), least = MIN_PX * Math.min(1, Math.max(.5, ppu / FAR_PPU)) / (ppu * TALL);
74
+ unit = Math.max(SIZE, least);
75
+ for (const k of SHAPES) {
76
+ const m = meshes[k], list = byShape[k];
77
+ let n = 0;
78
+ for (const it of list) {
79
+ const a = it.anchor();
80
+ if (!a) { it.hidden = true; continue; }
81
+ if (it.hidden && !reduce) it.born = now; // shown again: it arrives
82
+ it.hidden = false;
83
+ const age = (now - it.born) / 650, drop = age < 1 ? 1 - ez(Math.max(0, age)) : 0;
84
+ if (drop > 0) moving = true;
85
+ const sc = unit * (a[3] || 1) * (it.plausible ? .8 : 1) * (hot === it.key ? 1.2 : 1);
86
+ const lie = flat ? .7 * sc : 0; // lying flat, it floats its own thickness over the roof
87
+ P.copy(W(a[0], a[1], a[2] + drop * 60 + lie));
88
+ it.head = flat ? [a[0], a[1] - 1.15 * sc, a[2] + lie] : [a[0], a[1], a[2] + drop * 60 + 1.15 * sc]; // where its head is drawn, in plan units
89
+ Q.setFromAxisAngle(UP, it.phase); if (flat) Q.premultiply(FLAT);
90
+ S.setScalar(sc * TS);
91
+ M4.compose(P, Q, S); m.setMatrixAt(n, M4); it.slot = n; n++;
92
+ }
93
+ m.count = outlines[k].count = n; m.instanceMatrix.needsUpdate = true;
94
+ m.boundingSphere = null; // three.js keeps the first one it computes: picking needs today's
95
+ }
96
+ return moving;
97
+ }
98
+ const UP = new THREE.Vector3(0, 1, 0);
99
+ // the item under the pointer, or null
100
+ function pick(cx, cy) {
101
+ if (!visible) return null;
102
+ const h = E.pickHit(cx, cy, SHAPES.map(k => meshes[k]).filter(m => m.count));
103
+ if (!h || h.instanceId == null) return null;
104
+ const k = h.object.userData.beacons;
105
+ return byShape[k].find(it => it.slot === h.instanceId && it.anchor()) || null;
106
+ }
107
+ function setHot(key) { if (key !== hot) { hot = key; paint(); } }
108
+ E.themeHooks.add(() => paint());
109
+ return {
110
+ group, set, tick, pick, setHot, repaint: paint,
111
+ get count() { return items.length; },
112
+ get unit() { return unit; }, // what scene3d spaces marks by
113
+ get items() { return items; },
114
+ setVisible(v) { visible = !!v; group.visible = visible; },
115
+ setFlat(v) { flat = !!v; }, // the top-down projection
116
+ get flat() { return flat; },
117
+ dispose() { for (const k of SHAPES) { meshes[k].dispose(); outlines[k].dispose(); geo[k].dispose(); } },
118
+ };
119
+ }
120
+
121
+ function hash(s) { let h = 7; for (const c of String(s)) h = (h * 31 + c.charCodeAt(0)) >>> 0; return h; }
122
+ // two geometries as one (positions and normals), so the pin is one instance; one without an index (the
123
+ // octahedron) draws its vertices in order
124
+ function mergeTwo(THREE, a, b) {
125
+ const out = new THREE.BufferGeometry(), na = a.attributes.position.count, nb = b.attributes.position.count;
126
+ const cat = (x, y, n) => { const r = new Float32Array(x.length + y.length); r.set(x); r.set(y, x.length); return new THREE.BufferAttribute(r, n); };
127
+ out.setAttribute('position', cat(a.attributes.position.array, b.attributes.position.array, 3));
128
+ out.setAttribute('normal', cat(a.attributes.normal.array, b.attributes.normal.array, 3));
129
+ const order = (g, n) => g.index ? Array.from(g.index.array) : Array.from({ length: n }, (_, i) => i);
130
+ const ia = order(a, na), ib = order(b, nb).map(i => i + na);
131
+ out.setIndex(ia.concat(ib));
132
+ a.dispose(); b.dispose();
133
+ return out;
134
+ }
@@ -0,0 +1,412 @@
1
+ /* forge-ui open findings: the findings a run (or every run of its repo) left open, to dismiss or to file as GitHub issues
2
+ (window.ForgeFindings, and a CommonJS module under node:test). Route #/runs/<key>/findings.
3
+ The pure helpers (status, filters, selection rules, request URLs and bodies, merging the online `filed` data) are
4
+ tested in ui/tests/js/findingsview.test.js; render(host, ctx) is thin DOM over them. Finding and issue texts are
5
+ untrusted: they only ever enter the page as text.
6
+ ctx = { key, run? (the cached run, for the title), back (href of the run), tablePrefs?(id), saveTablePrefs?(id, prefs),
7
+ onCount?(openRows, scope) }. The write calls are ForgeUI's; without controls the page shows no write control. */
8
+ (function (root, factory) {
9
+ 'use strict';
10
+ const api = factory(root);
11
+ if (typeof module === 'object' && module.exports) module.exports = api;
12
+ else root.ForgeFindings = api;
13
+ })(typeof self !== 'undefined' ? self : this, function (root) {
14
+ 'use strict';
15
+
16
+ const MAX_FPS = 25, MAX_LABELS = 5, MAX_LABEL = 50, MAX_REASON = 500;
17
+ const list = v => Array.isArray(v) ? v : [];
18
+ const SEVERITIES = ['P0', 'P1', 'P2', 'P3'];
19
+ const ORIGINS = ['tolerated', 'review', 'plausible'];
20
+ const STATUSES = ['open', 'filed', 'fixed', 'dismissed'];
21
+
22
+ /* ---------- pure ---------- */
23
+ // open | filed (its issue is OPEN, or its state is not known yet) | fixed (CLOSED) | dismissed. A dismissal wins, so
24
+ // that an Undismiss reaches every dismissed row.
25
+ function statusOf(f) {
26
+ if (f && f.dismissed) return 'dismissed';
27
+ if (f && f.filed) return String(f.filed.state || '').toUpperCase() === 'CLOSED' ? 'fixed' : 'filed';
28
+ return 'open';
29
+ }
30
+ // filter = { status: 'open' (default) | 'all' | one of STATUSES, severity: 'all' | P0..P3, origin: 'all' | ORIGINS }
31
+ function filterRows(rows, filter) {
32
+ const f = filter || {}, status = f.status || 'open', severity = f.severity || 'all', origin = f.origin || 'all';
33
+ // `dismissed` (optional): false hides dismissed rows whatever the status; true adds them to the default 'open' view.
34
+ return list(rows).filter(r => {
35
+ const st = statusOf(r);
36
+ if (f.dismissed === false && st === 'dismissed') return false;
37
+ const byStatus = status === 'all' || st === status || (f.dismissed === true && status === 'open' && st === 'dismissed');
38
+ return byStatus && (severity === 'all' || r.severity === severity) && (origin === 'all' || r.origin === origin);
39
+ });
40
+ }
41
+ // The online listing knows which findings have an issue: its `filed` goes into the offline rows by fingerprint.
42
+ // A row only the online listing has is added; a row only the offline listing has stays as it was.
43
+ function mergeFiled(offline, online) {
44
+ const by = new Map(list(online).map(r => [r.fp, r]));
45
+ const seen = new Set();
46
+ const rows = list(offline).map(r => {
47
+ seen.add(r.fp);
48
+ const o = by.get(r.fp);
49
+ return o ? { ...r, filed: o.filed || null } : r;
50
+ });
51
+ for (const r of list(online)) if (!seen.has(r.fp)) rows.push(r);
52
+ return rows;
53
+ }
54
+ // Which rows an action takes: filing and dismissing take open rows, Undismiss takes dismissed rows.
55
+ function selectable(action, row) {
56
+ const status = statusOf(row);
57
+ return action === 'undismiss' ? status === 'dismissed' : (action === 'file' || action === 'dismiss') && status === 'open';
58
+ }
59
+ // What `selected` (a Set of fingerprints) means for one action: the fingerprints it takes, and whether the button
60
+ // is enabled (1 to 25) with the note to show when it is not.
61
+ function selection(action, rows, selected) {
62
+ const fps = list(rows).filter(r => selected.has(r.fp) && selectable(action, r)).map(r => r.fp);
63
+ const n = fps.length;
64
+ const note = n > MAX_FPS ? `${n} selected: at most ${MAX_FPS} per action` : '';
65
+ return { fps, count: n, enabled: n >= 1 && n <= MAX_FPS, note };
66
+ }
67
+ const bit = v => v ? '1' : '0';
68
+ // The GET path: `o` = { scope, offline, plausible, dismissed, only (fingerprints) }.
69
+ function issuesUrl(key, o) {
70
+ const q = [`scope=${o.scope === 'repo' ? 'repo' : 'run'}`, `offline=${bit(o.offline)}`, `plausible=${bit(o.plausible)}`];
71
+ if (o.dismissed) q.push('dismissed=1');
72
+ if (list(o.only).length) q.push('only=' + o.only.join(','));
73
+ return `/api/runs/${encodeURIComponent(key)}/issues?${q.join('&')}`;
74
+ }
75
+ const issuesPath = key => `/api/runs/${encodeURIComponent(key)}/issues`;
76
+ const dismissPath = key => issuesPath(key) + '/dismiss';
77
+ // "a, b" → {labels, error}: at most 5, each 1 to 50 characters, no control character, not starting with "-".
78
+ function parseLabels(text) {
79
+ const labels = String(text || '').split(',').map(s => s.trim()).filter(Boolean);
80
+ if (labels.length > MAX_LABELS) return { labels: [], error: `at most ${MAX_LABELS} labels` };
81
+ for (const l of labels) {
82
+ if (l.length > MAX_LABEL) return { labels: [], error: `a label is at most ${MAX_LABEL} characters` };
83
+ if (l.startsWith('-')) return { labels: [], error: 'a label cannot start with "-"' };
84
+ if (/[\u0000-\u001f\u007f]/.test(l)) return { labels: [], error: 'a label cannot hold control characters' };
85
+ }
86
+ return { labels, error: null };
87
+ }
88
+ function fileBody(scope, fps, includePlausible, labels) {
89
+ return { scope: scope === 'repo' ? 'repo' : 'run', fingerprints: list(fps).slice(), include_plausible: !!includePlausible, labels: list(labels).slice() };
90
+ }
91
+ // `reason` goes with a dismissal only; Undismiss has none.
92
+ function dismissBody(fps, reason, undo) {
93
+ const body = { fingerprints: list(fps).slice() };
94
+ if (undo) { body.undo = true; return body; }
95
+ const r = String(reason || '').trim();
96
+ if (r) body.reason = r.slice(0, MAX_REASON);
97
+ return body;
98
+ }
99
+ // The POST's `created` as lines: {kind: 'created' | 'skipped' | 'note', text, url?}
100
+ function resultLines(created) {
101
+ return list(created).map(c => {
102
+ if (c && c.url) return { kind: 'created', text: c.title || c.url, url: c.url };
103
+ if (c && c.skipped) return { kind: 'skipped', text: `${c.title || 'finding'}: skipped, ${c.skipped}`, url: c.issue || null };
104
+ return { kind: 'note', text: c && c.note ? c.note : 'no detail' };
105
+ });
106
+ }
107
+ // An untrusted URL becomes a link only when it is a plain https address.
108
+ const safeUrl = u => typeof u === 'string' && /^https:\/\/[^\s"'<>]+$/.test(u) ? u : null;
109
+
110
+ /* ---------- DOM ---------- */
111
+ function render(host, ctx) {
112
+ const UI = root.ForgeUI;
113
+ const el = (tag, text, cls) => UI.el(tag, text, cls);
114
+ const s = { scope: 'run', plausible: false, dismissed: false, filter: { status: 'open', severity: 'all', origin: 'all' },
115
+ rows: [], selected: new Set(), seq: 0, writes: false, busy: false };
116
+ const key = ctx.key;
117
+
118
+ const head = el('div', undefined, 'fnd-head');
119
+ const title = el('h1', 'Open findings', 'module-title');
120
+ const back = el('a', 'Back to the run', 'fnd-back'); back.href = ctx.back || '#/';
121
+ head.append(title, back);
122
+ const lede = el('p', undefined, 'muted fnd-lede');
123
+ const scopeBar = el('div', undefined, 'seg fnd-scope'); scopeBar.setAttribute('role', 'group'); scopeBar.setAttribute('aria-label', 'Scope');
124
+ const scopeKeys = {};
125
+ for (const [v, label] of [['run', 'This run'], ['repo', 'All runs of this repo']]) {
126
+ const b = el('button', label); b.type = 'button';
127
+ b.addEventListener('click', () => { if (s.scope === v) return; s.scope = v; s.selected.clear(); load(); });
128
+ scopeKeys[v] = b; scopeBar.append(b);
129
+ }
130
+ const lamp = el('p', undefined, 'fnd-lamp'); lamp.setAttribute('role', 'status');
131
+ const notes = el('div', undefined, 'fnd-notes muted');
132
+
133
+ // filters
134
+ const filters = el('div', undefined, 'fnd-filters');
135
+ const select = (label, options, get, set) => {
136
+ const l = el('label', undefined, 'fnd-field'), sel = el('select');
137
+ for (const [v, t] of options) { const o = el('option', t); o.value = v; sel.append(o); }
138
+ sel.value = get();
139
+ sel.addEventListener('change', () => { set(sel.value); drawTable(); });
140
+ l.append(el('span', label, 'plate'), sel);
141
+ return l;
142
+ };
143
+ const toggle = (label, get, set) => {
144
+ const l = el('label', undefined, 'fnd-toggle'), c = el('input');
145
+ c.type = 'checkbox'; c.checked = get();
146
+ c.addEventListener('change', () => { set(c.checked); load(); });
147
+ l.append(c, el('span', label));
148
+ return l;
149
+ };
150
+ filters.append(
151
+ select('Status', [['open', 'open'], ['all', 'all'], ...STATUSES.slice(1).map(x => [x, x])], () => s.filter.status, v => { s.filter.status = v; }),
152
+ select('Severity', [['all', 'all'], ...SEVERITIES.map(x => [x, x])], () => s.filter.severity, v => { s.filter.severity = v; }),
153
+ select('Origin', [['all', 'all'], ...ORIGINS.map(x => [x, x])], () => s.filter.origin, v => { s.filter.origin = v; }),
154
+ toggle('Include plausible', () => s.plausible, v => { s.plausible = v; }),
155
+ toggle('Show dismissed', () => s.dismissed, v => { s.dismissed = v; }));
156
+
157
+ // actions (only with controls)
158
+ const actions = el('div', undefined, 'fnd-actions'); actions.hidden = true;
159
+ actions.setAttribute('role', 'group'); actions.setAttribute('aria-label', 'Actions on the selected findings');
160
+ const key_ = (text, cls, fn) => { const b = el('button', text, cls); b.type = 'button'; b.addEventListener('click', fn); return b; };
161
+ const fileKey = key_('File issues', 'primary', () => openFile());
162
+ const dismissKey = key_('Dismiss', undefined, () => openDismiss());
163
+ const undismissKey = key_('Undismiss', undefined, () => doDismiss(true, ''));
164
+ const allKey = key_('Select shown open', undefined, () => {
165
+ for (const r of shown()) if (selectable('file', r) && s.selected.size < MAX_FPS) s.selected.add(r.fp);
166
+ drawTable(); syncActions();
167
+ });
168
+ const noneKey = key_('Clear selection', undefined, () => { s.selected.clear(); drawTable(); syncActions(); });
169
+ const actionNote = el('span', undefined, 'fnd-note muted'); actionNote.setAttribute('aria-live', 'polite');
170
+ actions.append(allKey, noneKey, fileKey, dismissKey, undismissKey, actionNote);
171
+ const reasonHost = el('div', undefined, 'fnd-reason');
172
+ const outcomeHost = el('div', undefined, 'fnd-outcome');
173
+ const tableHost = el('div', undefined, 'fnd-table');
174
+ const readOnly = el('p', 'read-only: findings can be read here, not filed or dismissed.', 'muted fnd-readonly'); readOnly.hidden = true;
175
+
176
+ host.replaceChildren(head, lede, scopeBar, lamp, notes, filters, readOnly, actions, reasonHost, outcomeHost, tableHost);
177
+ let posting = false; // a filing POST is running: the dialog stays open until its results are shown
178
+ const dlg = el('dialog', undefined, 'fnd-dialog'); dlg.setAttribute('aria-label', 'File issues on GitHub');
179
+ host.append(dlg);
180
+
181
+ const shown = () => filterRows(s.rows, { ...s.filter, dismissed: s.dismissed });
182
+ function syncScope() {
183
+ for (const [v, b] of Object.entries(scopeKeys)) b.setAttribute('aria-pressed', String(s.scope === v));
184
+ lede.textContent = s.scope === 'run' ? `Findings this run left unfixed${ctx.run && ctx.run.goal ? ': ' + String(ctx.run.goal).split('\n')[0] : ''}.`
185
+ : 'Findings every run of this repository left unfixed.';
186
+ }
187
+ function syncActions() {
188
+ actions.hidden = !s.writes;
189
+ if (!s.writes) return;
190
+ const f = selection('file', s.rows, s.selected), d = selection('dismiss', s.rows, s.selected), u = selection('undismiss', s.rows, s.selected);
191
+ fileKey.textContent = f.count ? `File ${f.count} issue${f.count === 1 ? '' : 's'}` : 'File issues';
192
+ fileKey.disabled = !f.enabled || s.busy; dismissKey.disabled = !d.enabled || s.busy;
193
+ undismissKey.textContent = u.count ? `Undismiss ${u.count}` : 'Undismiss'; undismissKey.disabled = !u.enabled || s.busy;
194
+ actionNote.textContent = f.note || d.note || u.note || (s.selected.size ? `${f.count} open, ${u.count} dismissed selected` : '');
195
+ }
196
+ function setLamp(cls, text) {
197
+ lamp.replaceChildren(el('i', undefined, 'lamp ' + cls), el('span', text));
198
+ }
199
+
200
+ // cells
201
+ const statusCell = r => {
202
+ const st = statusOf(r), box = el('span', undefined, 'fnd-status fnd-' + st);
203
+ box.append(el('i', undefined, 'lamp ' + (st === 'filed' ? 'fnd-amber' : st === 'fixed' ? 'st-pass' : 'lamp-off')));
204
+ if (st === 'dismissed') { box.title = r.dismissed.reason ? String(r.dismissed.reason) : 'dismissed'; box.append(el('span', 'dismissed')); return box; }
205
+ if (st === 'open') { box.append(el('span', 'open')); return box; }
206
+ const url = safeUrl(r.filed && r.filed.url), word = st === 'filed' ? 'filed' : 'fixed';
207
+ if (url) { const a = el('a', word); a.href = url; a.target = '_blank'; a.rel = 'noopener noreferrer'; a.title = 'the GitHub issue (' + String(r.filed.state || 'state unknown').toLowerCase() + ')'; box.append(a); }
208
+ else box.append(el('span', word));
209
+ return box;
210
+ };
211
+ const selectCell = r => {
212
+ const c = el('input'); c.type = 'checkbox';
213
+ c.setAttribute('aria-label', 'Select finding: ' + String(r.summary || r.fp).slice(0, 80));
214
+ c.checked = s.selected.has(r.fp);
215
+ c.disabled = !selectable('file', r) && !selectable('undismiss', r);
216
+ c.addEventListener('change', () => { if (c.checked) s.selected.add(r.fp); else s.selected.delete(r.fp); syncActions(); });
217
+ return c;
218
+ };
219
+ const summaryCell = r => {
220
+ const box = el('div', undefined, 'fnd-summary'), line = el('div', undefined, 'fnd-summary-line');
221
+ const more = el('button', undefined, 'fnd-more'); more.type = 'button';
222
+ more.setAttribute('aria-expanded', 'false'); more.setAttribute('aria-label', 'Show the full text');
223
+ more.append(el('span', '▸', 'fnd-caret'));
224
+ const detail = el('div', undefined, 'fnd-detail'); detail.hidden = true;
225
+ detail.append(el('pre', r.text || r.summary || '', 'fnd-text'));
226
+ const meta = el('dl', undefined, 'fnd-meta');
227
+ for (const [k, v] of [['Reviewer', r.reviewer], ['Commit', r.commit], ['Fingerprint', r.fp], ['Plan', r.plan_dir || r.plan],
228
+ ['Dismissed', r.dismissed ? `${r.dismissed.at || ''} ${r.dismissed.reason || ''}`.trim() : null]]) {
229
+ if (v) meta.append(el('dt', k), el('dd', v));
230
+ }
231
+ detail.append(meta);
232
+ more.addEventListener('click', () => {
233
+ const open = detail.hidden; detail.hidden = !open;
234
+ more.setAttribute('aria-expanded', String(open)); more.setAttribute('aria-label', open ? 'Hide the full text' : 'Show the full text');
235
+ more.firstChild.textContent = open ? '▾' : '▸';
236
+ });
237
+ line.append(more, el('span', r.summary || r.text || '—'));
238
+ box.append(line, detail);
239
+ return box;
240
+ };
241
+ const sevCell = r => el('span', r.severity || '—', 'chip sev-chip sev-' + (SEVERITIES.includes(r.severity) ? r.severity : 'none'));
242
+ const columns = () => {
243
+ const cols = [];
244
+ if (s.writes) cols.push({ id: 'select', label: 'Select', sortable: false, hideable: false, cell: selectCell });
245
+ cols.push(
246
+ { id: 'severity', label: 'Severity', value: r => SEVERITIES.indexOf(r.severity), cell: sevCell },
247
+ { id: 'confidence', label: 'Confidence', value: r => r.confidence, cell: r => r.confidence || '—' },
248
+ { id: 'origin', label: 'Origin', value: r => r.origin, cell: r => r.origin || '—' },
249
+ { id: 'task', label: 'Task', value: r => r.task, mono: true, cell: r => r.task || 'whole run' });
250
+ if (s.scope === 'repo') cols.push({ id: 'plan', label: 'Plan', value: r => r.plan, mono: true, cell: r => r.plan || '—' });
251
+ cols.push(
252
+ { id: 'file', label: 'File', value: r => r.file, mono: true, cell: r => r.file || '—' },
253
+ { id: 'summary', label: 'Summary', value: r => r.summary, cell: summaryCell },
254
+ { id: 'status', label: 'Status', value: r => STATUSES.indexOf(statusOf(r)), cell: statusCell });
255
+ return cols;
256
+ };
257
+ function drawTable() {
258
+ const rows = shown();
259
+ if (!rows.length) {
260
+ const total = s.rows.length;
261
+ tableHost.replaceChildren(el('p', total ? `No findings match these filters (${total} hidden).` : 'No findings. Nothing here needs fixing.', 'muted fnd-empty'));
262
+ syncActions();
263
+ return;
264
+ }
265
+ const id = 'findings-' + (s.scope === 'repo' ? 'repo' : 'run') + (s.writes ? '-w' : '');
266
+ tableHost.replaceChildren();
267
+ root.ForgeTable.render(tableHost, { id, label: 'Open findings', columns: columns(), rows, className: 'fnd-grid',
268
+ prefs: ctx.tablePrefs ? ctx.tablePrefs(id) : null, savePrefs: p => ctx.saveTablePrefs && ctx.saveTablePrefs(id, p) });
269
+ syncActions();
270
+ }
271
+
272
+ // loading: offline first (fast), then online for `filed`
273
+ const detailOf = res => res.detail || res.error || 'no answer';
274
+ async function load() {
275
+ const mine = ++s.seq;
276
+ syncScope();
277
+ setLamp('lamp-route', 'reading the findings…');
278
+ const q = { scope: s.scope, plausible: s.plausible, dismissed: s.dismissed };
279
+ const off = await UI.get(issuesUrl(key, { ...q, offline: true }));
280
+ if (mine !== s.seq) return;
281
+ if (!off.ok) {
282
+ s.rows = []; drawTable();
283
+ setLamp('st-fail', 'could not read the findings'); outcomeHost.replaceChildren(); UI.outcome(outcomeHost, off);
284
+ return;
285
+ }
286
+ s.rows = list(off.data.findings);
287
+ notes.replaceChildren(...list(off.data.notes).map(n => el('p', String(n))));
288
+ prune(); drawTable(); report();
289
+ setLamp('st-running', 'checking GitHub…');
290
+ const on = await UI.get(issuesUrl(key, { ...q, offline: false }));
291
+ if (mine !== s.seq) return;
292
+ if (!on.ok) { setLamp('st-fail', 'GitHub status unknown: ' + detailOf(on)); return; }
293
+ s.rows = mergeFiled(s.rows, list(on.data.findings));
294
+ notes.replaceChildren(...list(on.data.notes).map(n => el('p', String(n))));
295
+ prune(); drawTable(); report();
296
+ setLamp('st-pass', 'GitHub status checked');
297
+ }
298
+ function prune() {
299
+ const fps = new Set(s.rows.map(r => r.fp));
300
+ for (const fp of Array.from(s.selected)) if (!fps.has(fp)) s.selected.delete(fp);
301
+ }
302
+ function report() { if (ctx.onCount) ctx.onCount(s.rows.filter(r => statusOf(r) === 'open').length, s.scope); }
303
+
304
+ // dismiss: an optional reason, then the call
305
+ async function openDismiss() {
306
+ reasonHost.replaceChildren();
307
+ const sel = selection('dismiss', s.rows, s.selected);
308
+ if (!sel.enabled) return;
309
+ const l = el('label', undefined, 'fnd-field fnd-reason-field'), input = el('input');
310
+ input.type = 'text'; input.maxLength = MAX_REASON; input.placeholder = 'why (optional)';
311
+ l.append(el('span', `Reason for dismissing ${sel.count}`, 'plate'), input);
312
+ const go = key_('Dismiss', 'ui-danger', () => { const reason = input.value; reasonHost.replaceChildren(); doDismiss(false, reason); });
313
+ const cancel = key_('Cancel', undefined, () => { reasonHost.replaceChildren(); dismissKey.focus(); });
314
+ input.addEventListener('keydown', e => {
315
+ if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); cancel.click(); }
316
+ else if (e.key === 'Enter') { e.preventDefault(); go.click(); }
317
+ });
318
+ reasonHost.append(l, go, cancel);
319
+ input.focus();
320
+ }
321
+ async function doDismiss(undo, reason) {
322
+ const sel = selection(undo ? 'undismiss' : 'dismiss', s.rows, s.selected);
323
+ if (!sel.enabled || s.busy) return;
324
+ s.busy = true; syncActions(); outcomeHost.replaceChildren();
325
+ const res = await UI.post(dismissPath(key), dismissBody(sel.fps, reason, undo));
326
+ s.busy = false;
327
+ UI.outcome(outcomeHost, res, undo ? `restored ${sel.count}` : `dismissed ${sel.count}`);
328
+ if (res.ok) { for (const fp of sel.fps) s.selected.delete(fp); await load(); } else syncActions();
329
+ }
330
+
331
+ // filing: drafts for exactly the selection, a preview, then the POST
332
+ async function openFile() {
333
+ const sel = selection('file', s.rows, s.selected);
334
+ if (!sel.enabled || s.busy) return;
335
+ s.busy = true; syncActions(); outcomeHost.replaceChildren();
336
+ setLamp('st-running', 'preparing the issue drafts…');
337
+ const res = await UI.get(issuesUrl(key, { scope: s.scope, offline: false, plausible: s.plausible, only: sel.fps }));
338
+ s.busy = false; syncActions();
339
+ if (!res.ok) { setLamp('st-fail', 'could not prepare the drafts'); UI.outcome(outcomeHost, res); return; }
340
+ setLamp('st-pass', 'drafts ready');
341
+ preview(sel.fps, list(res.data.drafts));
342
+ }
343
+ function preview(fps, drafts) {
344
+ dlg.replaceChildren();
345
+ dlg.append(el('h2', 'File issues on GitHub'));
346
+ const warn = el('p', undefined, 'fnd-warn');
347
+ warn.append(el('i', undefined, 'lamp st-running'),
348
+ el('span', `This creates ${drafts.length} issue${drafts.length === 1 ? '' : 's'} on this run's GitHub repository, from your gh login, and cannot be undone from here.`));
349
+ dlg.append(warn);
350
+ if (!drafts.length) dlg.append(el('p', 'forge has no draft for this selection (its findings may be filed already).', 'muted'));
351
+ for (const d of drafts) {
352
+ const art = el('article', undefined, 'fnd-draft');
353
+ const h = el('h3', undefined, 'fnd-draft-title');
354
+ h.append(el('span', d.severity || '—', 'chip sev-chip sev-' + (SEVERITIES.includes(d.severity) ? d.severity : 'none')), el('span', d.title || 'untitled'));
355
+ art.append(h);
356
+ if (d.same_title) {
357
+ const sameUrl = safeUrl(d.same_title);
358
+ const p = el('p', undefined, 'fnd-same'); p.append(el('i', undefined, 'lamp fnd-amber'), el('span', 'An open issue with this title exists already: '));
359
+ if (sameUrl) { const a = el('a', sameUrl); a.href = sameUrl; a.target = '_blank'; a.rel = 'noopener noreferrer'; p.append(a); } else p.append(el('span', String(d.same_title)));
360
+ p.append(el('span', '. forge skips it.'));
361
+ art.append(p);
362
+ }
363
+ art.append(el('pre', d.body || '', 'fnd-body'));
364
+ dlg.append(art);
365
+ }
366
+ const lab = el('label', undefined, 'fnd-field'), labels = el('input');
367
+ labels.type = 'text'; labels.placeholder = 'bug, forge (optional, comma separated)';
368
+ lab.append(el('span', 'Labels', 'plate'), labels);
369
+ const labelNote = el('p', undefined, 'warn fnd-label-note'); labelNote.hidden = true;
370
+ const results = el('div', undefined, 'fnd-results');
371
+ const go = key_('File on GitHub', 'primary', async () => {
372
+ const parsed = parseLabels(labels.value);
373
+ labelNote.hidden = !parsed.error; labelNote.textContent = parsed.error || '';
374
+ if (parsed.error) return;
375
+ go.disabled = true; cancel.disabled = true; labels.disabled = true; results.replaceChildren(); posting = true;
376
+ const res = await UI.post(issuesPath(key), fileBody(s.scope, fps, s.plausible, parsed.labels));
377
+ posting = false;
378
+ if (!res.ok) { go.disabled = false; cancel.disabled = false; labels.disabled = false; UI.outcome(results, res); return; }
379
+ const lines = resultLines(res.data.created), ul = el('ul', undefined, 'fnd-created');
380
+ for (const ln of lines) {
381
+ const li = el('li', undefined, 'fnd-created-' + ln.kind), u = safeUrl(ln.url);
382
+ li.append(el('i', undefined, 'lamp ' + (ln.kind === 'created' ? 'st-pass' : 'fnd-amber')));
383
+ if (u) { const a = el('a', ln.text); a.href = u; a.target = '_blank'; a.rel = 'noopener noreferrer'; li.append(a); } else li.append(el('span', ln.text));
384
+ ul.append(li);
385
+ }
386
+ results.replaceChildren(el('p', `${lines.filter(l => l.kind === 'created').length} created`, 'fnd-done'), ul);
387
+ go.remove(); cancel.textContent = 'Close'; cancel.disabled = false;
388
+ for (const fp of fps) s.selected.delete(fp);
389
+ load();
390
+ });
391
+ const cancel = key_('Cancel', undefined, () => dlg.close());
392
+ go.disabled = !drafts.length;
393
+ const row = el('div', undefined, 'fnd-dialog-actions'); row.append(cancel, go);
394
+ dlg.append(lab, labelNote, results, row);
395
+ dlg.showModal(); (drafts.length ? go : cancel).focus();
396
+ }
397
+ dlg.addEventListener('click', e => { if (e.target === dlg && !posting) dlg.close(); });
398
+ dlg.addEventListener('cancel', e => { if (posting) e.preventDefault(); });
399
+ dlg.addEventListener('close', () => { fileKey.focus(); });
400
+
401
+ syncScope();
402
+ UI.controlsEnabled().then(on => {
403
+ s.writes = !!on; readOnly.hidden = on; syncActions();
404
+ if (s.rows.length) drawTable();
405
+ });
406
+ load();
407
+ return { reload: load };
408
+ }
409
+
410
+ return { render, statusOf, filterRows, mergeFiled, selectable, selection, issuesUrl, issuesPath, dismissPath, fileBody, dismissBody,
411
+ parseLabels, resultLines, safeUrl, MAX_FPS };
412
+ });
@@ -240,9 +240,9 @@
240
240
 
241
241
  if (t.verdict === 'PASS' || t.verdict === 'FAIL') {
242
242
  var pass = t.verdict === 'PASS';
243
- textEl(g, pass ? '✓' : '✗', {
244
- 'class': 'fg-verdict' + (pass ? ' fg-pass' : ''), x: NODE_W - 8, y: 17, 'text-anchor': 'end', 'font-size': 14, 'font-weight': 'bold'
245
- });
243
+ // a drawn tick or cross, in the verdict's colour
244
+ el('path', { 'class': 'fg-verdict' + (pass ? ' fg-pass' : ''), d: pass ? 'M' + (NODE_W - 20) + ' 12l4 4 7-8' : 'M' + (NODE_W - 19) + ' 8l8 8m0-8l-8 8',
245
+ fill: 'none', 'stroke-width': 1.8, 'stroke-linecap': 'round', 'stroke-linejoin': 'round' }, g);
246
246
  }
247
247
 
248
248
  function select() { if (typeof options.onSelect === 'function') options.onSelect(t.id); }