@bongos/core 1.19.945 → 1.19.947
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/.bongos-core.json +73 -28
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +2 -0
- package/clients/bongos-client/index.cjs +2 -0
- package/clients/bongos-client/index.d.ts +2 -0
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/api/openapi.json +32 -1
- package/docs/api-reference.md +3 -2
- package/docs/copy-inventory.md +37 -19
- package/docs/copy-registry.json +209 -26
- package/docs/module-api-changelog.md +4 -0
- package/modules/hall-ui/public/goal-map-lib.js +356 -0
- package/modules/hall-ui/public/goal-map.css +117 -0
- package/modules/hall-ui/public/goal-map.html +68 -0
- package/modules/hall-ui/public/goal-map.js +209 -0
- package/modules/hall-ui/public/goal-map.states.json +14 -0
- package/modules/hall-ui/public/goals.html +3 -0
- package/modules/lifecycle/goal-graph.js +176 -0
- package/modules/lifecycle/routes/goals.js +4 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/fitness.js +1 -1
- package/scripts/gds/scope-map-guard.js +181 -1
- package/src/bongos/module-scope-map.js +67 -0
- package/src/bongos/serve-internal.js +4 -1
- package/src/module-api.js +1 -1
- package/tests/goal_graph.mjs +192 -0
- package/tests/goal_map_lib.mjs +214 -0
- package/tests/goal_map_page.mjs +236 -0
- package/tests/hall_page_gate_map.mjs +3 -0
- package/tests/scope_map_guard.mjs +101 -1
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
// goal-map.js — the goal map page (task 1003479, goal 1000089; ADR 0227 §6 item 3
|
|
2
|
+
// and its §10.4 amendment). One read, GET /goals/graph, three surfaces from it:
|
|
3
|
+
// 1. the map — the goals tied to another goal by a dependency, drawn in layers;
|
|
4
|
+
// 2. the stand-alone list — the goals that have no edge, which §10.4 says belong
|
|
5
|
+
// in a list and not as dots on a canvas that has nothing to say about them;
|
|
6
|
+
// 3. the table — EVERY goal, with both directions of each dependency by name.
|
|
7
|
+
//
|
|
8
|
+
// THE TABLE IS THE PAGE AND THE MAP IS THE ENHANCEMENT (ADR 0227 §5). Every fact
|
|
9
|
+
// the picture carries is a cell in the table, on screen by default and not behind
|
|
10
|
+
// a "view as table" toggle. The picture is therefore a `role="img"` figure with a
|
|
11
|
+
// caption that says where the same facts are, and its nodes are NOT links or
|
|
12
|
+
// focus stops — a keyboard or screen-reader reader gets each goal, once, from the
|
|
13
|
+
// table, rather than thirty-six hidden interactive elements from a drawing.
|
|
14
|
+
//
|
|
15
|
+
// THERE IS NO CONTROL that changes what is drawn (ADR 0227 §7): no "show every
|
|
16
|
+
// task", no "densify". The only input is the aggregate the endpoint returns,
|
|
17
|
+
// which carries no task identity, so views A and D are unreachable by
|
|
18
|
+
// construction and not by a flag that happens to be off.
|
|
19
|
+
//
|
|
20
|
+
// The layout is goal-map-lib.js (pure, run by tests/goal_map_lib.mjs). This file
|
|
21
|
+
// draws what it returns, with createElementNS and textContent — the atlas's
|
|
22
|
+
// idiom — so a goal title (builder-authored text) never becomes markup.
|
|
23
|
+
(() => {
|
|
24
|
+
'use strict';
|
|
25
|
+
|
|
26
|
+
const API = '/api/bongos';
|
|
27
|
+
const SVGNS = 'http://www.w3.org/2000/svg';
|
|
28
|
+
const kit = window.OTBKit;
|
|
29
|
+
const lib = window.OTBGoalMapLib;
|
|
30
|
+
const { escapeHtml } = window.OTB;
|
|
31
|
+
const $ = (id) => document.getElementById(id);
|
|
32
|
+
|
|
33
|
+
const hallBase = () => (location.hostname.startsWith('builders.') ? '' : '/builders');
|
|
34
|
+
const goalHref = (id) => `${hallBase()}/goals#/goal/${encodeURIComponent(id)}`;
|
|
35
|
+
|
|
36
|
+
function svgEl(tag, attrs, parent) {
|
|
37
|
+
const el = document.createElementNS(SVGNS, tag);
|
|
38
|
+
Object.keys(attrs || {}).forEach((k) => el.setAttribute(k, attrs[k]));
|
|
39
|
+
if (parent) parent.appendChild(el);
|
|
40
|
+
return el;
|
|
41
|
+
}
|
|
42
|
+
function text(parent, cls, x, y, content) {
|
|
43
|
+
const t = svgEl('text', { class: cls, x, y }, parent);
|
|
44
|
+
t.textContent = content;
|
|
45
|
+
return t;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ---- the map -----------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
function drawGraph(a, lay, cats) {
|
|
51
|
+
const svg = svgEl('svg', {
|
|
52
|
+
class: 'gm-svg', viewBox: `0 0 ${lay.width} ${lay.height}`, width: lay.width, height: lay.height,
|
|
53
|
+
role: 'img', 'aria-labelledby': 'gm-cap', focusable: 'false',
|
|
54
|
+
});
|
|
55
|
+
const defs = svgEl('defs', {}, svg);
|
|
56
|
+
const marker = svgEl('marker', { id: 'gm-arrow', viewBox: '0 0 10 10', refX: 9, refY: 5, markerWidth: 7, markerHeight: 7, orient: 'auto' }, defs);
|
|
57
|
+
svgEl('path', { class: 'gm-arrowhead', d: 'M0 0 L10 5 L0 10 z' }, marker);
|
|
58
|
+
|
|
59
|
+
const edges = svgEl('g', { class: 'gm-edges' }, svg);
|
|
60
|
+
lay.edges.forEach((e) => {
|
|
61
|
+
const path = svgEl('path', { class: 'gm-edge', d: e.d, 'stroke-width': e.width, 'marker-end': 'url(#gm-arrow)' }, edges);
|
|
62
|
+
const dependent = lib.displayTitle(a.byId[e.from]);
|
|
63
|
+
const dependency = lib.displayTitle(a.byId[e.to]);
|
|
64
|
+
svgEl('title', {}, path).textContent = `${dependent} waits on ${dependency} — ${lib.plural(e.count, 'task')}`;
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// The node's WEIGHT (ADR 0227 §6 item 3: "node weight = task count"), as a
|
|
68
|
+
// fraction of the busiest goal ACTUALLY DRAWN — a scale that means something
|
|
69
|
+
// on this map, not on the whole corpus most of which never appears here.
|
|
70
|
+
const maxTotal = lay.nodes.reduce((m, p) => Math.max(m, a.byId[p.id].task_total || 0), 0);
|
|
71
|
+
|
|
72
|
+
const nodes = svgEl('g', { class: 'gm-nodes' }, svg);
|
|
73
|
+
lay.nodes.forEach((p) => {
|
|
74
|
+
const n = a.byId[p.id];
|
|
75
|
+
const g = svgEl('g', { class: `gm-node gm-c${lib.colourOf(n, cats)}`, transform: `translate(${p.x} ${p.y})` }, nodes);
|
|
76
|
+
const cat = n.category ? n.category.label : 'No category';
|
|
77
|
+
svgEl('title', {}, g).textContent = `${lib.displayTitle(n)} — ${cat} — ${n.task_done} of ${n.task_total} tasks done`
|
|
78
|
+
+ ` — waits on ${lib.plural(n.depends_on || 0, 'goal')}, needed by ${lib.plural(n.depended_on_by || 0, 'goal')}`;
|
|
79
|
+
svgEl('rect', { class: 'gm-card', width: p.w, height: p.h, rx: 8 }, g);
|
|
80
|
+
svgEl('rect', { class: 'gm-stripe', width: 6, height: p.h, rx: 3 }, g);
|
|
81
|
+
text(g, 'gm-name', 16, 22, lib.clip(lib.displayTitle(n), lib.TITLE_CHARS));
|
|
82
|
+
text(g, 'gm-cat', 16, 38, lib.clip(cat, lib.SUB_CHARS));
|
|
83
|
+
text(g, 'gm-tasks', 16, 53, `${n.task_done} of ${lib.plural(n.task_total, 'task')} done`);
|
|
84
|
+
const trackW = p.w - 32;
|
|
85
|
+
svgEl('rect', { class: 'gm-weight-track', x: 16, y: 60, width: trackW, height: 3, rx: 1.5 }, g);
|
|
86
|
+
const frac = lib.weightOf(n.task_total, maxTotal);
|
|
87
|
+
if (frac > 0) svgEl('rect', { class: 'gm-weight-fill', x: 16, y: 60, width: Math.max(3, trackW * frac), height: 3, rx: 1.5 }, g);
|
|
88
|
+
});
|
|
89
|
+
return svg;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function renderGraph(a, cats, described) {
|
|
93
|
+
const host = $('gm-graph');
|
|
94
|
+
if (!a.connected) {
|
|
95
|
+
host.innerHTML = `<p class="scroll__lede">${escapeHtml(described.graph || described.summary)}</p>`;
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
host.innerHTML = `
|
|
99
|
+
<figure class="gm-figure">
|
|
100
|
+
<figcaption id="gm-cap" class="scroll__lede">
|
|
101
|
+
An arrow runs from the goal that has to ship first to the goal that waits on it, and a thicker line means more
|
|
102
|
+
tasks are waiting. Every goal in this map, and every dependency, is also in the table below.
|
|
103
|
+
</figcaption>
|
|
104
|
+
<div class="gm-scroll" id="gm-scroll" tabindex="0" role="region" aria-label="Goal map — scrolls sideways inside this box"></div>
|
|
105
|
+
</figure>`;
|
|
106
|
+
$('gm-scroll').appendChild(drawGraph(a, lib.layout(a), cats));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function renderLegend(cats) {
|
|
110
|
+
const host = $('gm-legend');
|
|
111
|
+
if (!cats.list.length && !cats.uncategorised) { host.hidden = true; return; }
|
|
112
|
+
const swatch = (cls) => `<span class="gm-swatch ${cls}" aria-hidden="true"></span>`;
|
|
113
|
+
host.innerHTML = cats.list.map((c) => `<li>${swatch(`gm-c${c.colour}`)}${escapeHtml(c.label)} <span class="gm-n">${c.count}</span></li>`).join('')
|
|
114
|
+
+ (cats.uncategorised ? `<li>${swatch('gm-cnone')}No category <span class="gm-n">${cats.uncategorised}</span></li>` : '');
|
|
115
|
+
host.hidden = false;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ---- the stand-alone goals ------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
function renderAlone(a) {
|
|
121
|
+
const section = $('gm-alone-scroll');
|
|
122
|
+
if (!a.isolated.length || !a.connected) { section.hidden = true; return; }
|
|
123
|
+
$('gm-alone-lede').textContent = `${lib.plural(a.isolated.length, 'goal')} ${a.isolated.length === 1 ? 'has' : 'have'} no task that waits on another goal’s work, `
|
|
124
|
+
+ 'and none that another goal waits on, so there is nothing to draw between them and another goal. They are listed here instead of drawn as dots.';
|
|
125
|
+
$('gm-alone').innerHTML = a.isolated.map((id) => {
|
|
126
|
+
const n = a.byId[id];
|
|
127
|
+
const label = lib.displayTitle(n);
|
|
128
|
+
const facts = (n.category ? `${escapeHtml(n.category.label)} · ` : '') + lib.plural(n.task_total, 'task');
|
|
129
|
+
const inner = `<span>${escapeHtml(label)}</span><span class="gm-n">${facts}</span>`;
|
|
130
|
+
return n.title
|
|
131
|
+
? `<li><a class="fact-chip gm-chip" href="${goalHref(id)}">${inner}</a></li>`
|
|
132
|
+
: `<li><span class="fact-chip gm-chip">${inner}</span></li>`;
|
|
133
|
+
}).join('');
|
|
134
|
+
section.hidden = false;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ---- the table (primary) ----------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
const depList = (list) => (list.length
|
|
140
|
+
? `<ul class="gm-deps">${list.map((x) => `<li>${escapeHtml(x.title)} <span class="gm-n">· ${lib.plural(x.count, 'task')}</span></li>`).join('')}</ul>`
|
|
141
|
+
: '<span class="gm-none">—</span>');
|
|
142
|
+
|
|
143
|
+
function rowHtml(r) {
|
|
144
|
+
const title = r.isPrivate
|
|
145
|
+
? `<span class="gm-private">${escapeHtml(r.title)}</span>`
|
|
146
|
+
: `<a class="lb-name" href="${goalHref(r.id)}">${escapeHtml(r.title)}</a>`;
|
|
147
|
+
return `<tr>
|
|
148
|
+
<td>${title}</td>
|
|
149
|
+
<td>${r.category ? escapeHtml(r.category) : '<span class="gm-none">—</span>'}</td>
|
|
150
|
+
<td class="ledger__num">${r.done} of ${r.total}</td>
|
|
151
|
+
<td>${depList(r.waits)}</td>
|
|
152
|
+
<td>${depList(r.needed)}</td>
|
|
153
|
+
<td>${escapeHtml(r.clusterLabel)}</td>
|
|
154
|
+
<td>${escapeHtml(r.version)}</td>
|
|
155
|
+
</tr>`;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function renderTable(a) {
|
|
159
|
+
const section = $('gm-table-scroll');
|
|
160
|
+
const rows = lib.tableRows(a);
|
|
161
|
+
section.hidden = false;
|
|
162
|
+
const pager = kit.makePager('gm', 'hall-goal-map-per-page', { defaultPerPage: 100 });
|
|
163
|
+
const ledger = kit.makeLedger({
|
|
164
|
+
mount: $('gm-table'),
|
|
165
|
+
columns: ['Goal', 'Category', { label: 'Tasks done', class: 'ledger__num' }, 'Waits on', 'Needed by', 'Cluster', 'Version'],
|
|
166
|
+
rowHtml,
|
|
167
|
+
emptyMsg: 'No goal carries any work yet.',
|
|
168
|
+
pager,
|
|
169
|
+
});
|
|
170
|
+
ledger.render(rows);
|
|
171
|
+
const table = $('gm-table').querySelector('table');
|
|
172
|
+
if (table) table.setAttribute('aria-label', 'Every goal, with what it waits on and what waits on it');
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ---- boot ---------------------------------------------------------------------------
|
|
176
|
+
|
|
177
|
+
function notice(msg) {
|
|
178
|
+
$('gm-sub').textContent = msg;
|
|
179
|
+
$('gm-graph').innerHTML = `<p class="note">${escapeHtml(msg)}</p>`;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function boot() {
|
|
183
|
+
let payload;
|
|
184
|
+
try {
|
|
185
|
+
const res = await fetch(`${API}/goals/graph`, { credentials: 'same-origin', headers: { Accept: 'application/json' } });
|
|
186
|
+
if (res.status === 401) { window.location.assign(`${API}/auth/web/start`); return; }
|
|
187
|
+
if (!res.ok) throw new Error(`graph → ${res.status}`);
|
|
188
|
+
payload = await res.json();
|
|
189
|
+
} catch (err) {
|
|
190
|
+
console.error('[goal-map] load failed', err);
|
|
191
|
+
notice('Couldn’t load the goal map right now.');
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
const a = lib.analyse(payload);
|
|
195
|
+
const cats = lib.categories(a);
|
|
196
|
+
const described = lib.describe(a);
|
|
197
|
+
$('gm-sub').textContent = described.summary;
|
|
198
|
+
if (!a.order.length) {
|
|
199
|
+
$('gm-graph').innerHTML = kit.emptyStateHtml(described.summary);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
renderGraph(a, cats, described);
|
|
203
|
+
renderLegend(cats);
|
|
204
|
+
renderAlone(a);
|
|
205
|
+
renderTable(a);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
boot();
|
|
209
|
+
})();
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_": "THE GOAL MAP (task 1003479, goal 1000089; ADR 0227 §6 item 3) through modules/ui-design/kit/render.js. The page reads ONE authenticated aggregate, GET /goals/graph, so against the kit's own stub it renders its signed-out / failed-read state — a state the floors must hold in and is rendered here deliberately. To look at the populated map (the layered picture, the stand-alone chips, the table), run it against the hall-preview harness, whose goals__graph.json fixture carries one large cluster, a triple, pairs, a mutual pair (a loop) and a private goal beside the stand-alone majority:\n\n node scripts/hall-preview/server.js --port 4620 --fixture-me\n node modules/ui-design/kit/render.js --states modules/hall-ui/public/goal-map.states.json --base-in http://localhost:4620 --base-out http://localhost:4620",
|
|
3
|
+
"page": "/builders/goal-map",
|
|
4
|
+
"surface": "hall-ui",
|
|
5
|
+
"stub": { "prefix": "/builders/" },
|
|
6
|
+
"modeQuery": false,
|
|
7
|
+
"ignoreRequests": ["/api/(gds|bongos)/"],
|
|
8
|
+
"ignoreConsole": ["/api/(gds|bongos)/", "\\b401\\b", "\\b404\\b", "Unauthorized", "Not Found", "load failed"],
|
|
9
|
+
"states": {
|
|
10
|
+
"map": { "auth": true, "actions": [["wait", 1200]], "expect": { "visible": ["#gm-graph"] } },
|
|
11
|
+
"out": { "auth": false, "actions": [["wait", 600]] }
|
|
12
|
+
},
|
|
13
|
+
"reducedMotion": { "auth": true, "actions": [["wait", 1200]] }
|
|
14
|
+
}
|
|
@@ -42,6 +42,9 @@
|
|
|
42
42
|
<div class="page-head page-head--board">
|
|
43
43
|
<h1 class="page-head__h">Goals</h1>
|
|
44
44
|
<p class="page-head__sub" id="goals-sub">Loading goals…</p>
|
|
45
|
+
<!-- task 1003479: the goal map has no nav slot of its own (the sky's rule —
|
|
46
|
+
a map that is reachable but not load-bearing), so this is its door. -->
|
|
47
|
+
<p class="page-head__sub"><a href="/builders/goal-map">See which goals wait on each other →</a></p>
|
|
45
48
|
</div>
|
|
46
49
|
|
|
47
50
|
<main id="hall-main">
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// modules/lifecycle/goal-graph.js — the goal graph's data: one node per goal, one
|
|
4
|
+
// edge per pair of goals whose tasks depend on each other (task 1003479, goal
|
|
5
|
+
// 1000089, ADR 0227 §6 item 3 and its §10.4 amendment).
|
|
6
|
+
//
|
|
7
|
+
// WHY THIS IS AN AGGREGATE AND NOT A TASK LIST. ADR 0227 refused a whole-corpus
|
|
8
|
+
// task map: 3,253 tasks are confetti, and the readable thing is the ~70-goal
|
|
9
|
+
// graph. So the reduction happens in Postgres — task counts per goal (the same
|
|
10
|
+
// buckets goal-rollup.js uses, so the two boards cannot show different numbers)
|
|
11
|
+
// and a COUNT of task-level dependencies that cross a goal boundary — and the
|
|
12
|
+
// payload is a few kilobytes however many tasks exist. Nothing in this file ever
|
|
13
|
+
// returns a task id or a task title.
|
|
14
|
+
//
|
|
15
|
+
// THE EDGE DIRECTION IS "WAITS ON". An edge {from, to, count} says `count` tasks
|
|
16
|
+
// in goal `from` depend on tasks in goal `to`. Only edges between two DIFFERENT
|
|
17
|
+
// goals are kept: a dependency inside one goal is that goal's own business and
|
|
18
|
+
// belongs to the per-goal forest (task 1003480), not to the map of goals.
|
|
19
|
+
//
|
|
20
|
+
// VISIBILITY (ADR 0112's leak rule). A private goal's prose is the secret, not
|
|
21
|
+
// its existence or its counts, so a viewer who is neither a member nor an Archon
|
|
22
|
+
// still gets the node — its task counts and its edges — but `title` and
|
|
23
|
+
// `category` come back null. The client draws that as "a private goal" rather
|
|
24
|
+
// than dropping it, because dropping a node would silently turn its neighbours
|
|
25
|
+
// into isolated dots and misstate the very structure this view exists to show.
|
|
26
|
+
//
|
|
27
|
+
// A CATEGORY IS A LABEL HERE AND NOTHING MORE (ADR 0192): it is decorated onto
|
|
28
|
+
// the node for the reader and no query, filter or edge below reads it.
|
|
29
|
+
|
|
30
|
+
const api = require('../../src/module-api');
|
|
31
|
+
const { pool } = api;
|
|
32
|
+
const goalCategory = require('./goal-category');
|
|
33
|
+
const { DONE_STATUSES, CUT_STATUSES } = require('./task-status');
|
|
34
|
+
|
|
35
|
+
// goalGraphRows — the two raw reads. Returns { goals, edges }: `goals` one row per
|
|
36
|
+
// goal that carries at least one task, `edges` one row per ordered pair of goals.
|
|
37
|
+
// deps.pool honours the test seam (NODE_ENV==='test' only), like every helper here.
|
|
38
|
+
async function goalGraphRows({ versionId = null, viewerId = null, viewerIsArchon = false } = {}, deps = {}) {
|
|
39
|
+
const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
|
|
40
|
+
// A repeated ?version= parses as an array; binding that against a ::text
|
|
41
|
+
// parameter is a driver-level 500, not a clean refusal, so it is flattened to
|
|
42
|
+
// its first value before it ever reaches the query (GET /goals has the same
|
|
43
|
+
// gap; fixing it there is a separate task).
|
|
44
|
+
const version = Array.isArray(versionId) ? (versionId[0] ?? null) : versionId;
|
|
45
|
+
const [goals, edges] = await Promise.all([
|
|
46
|
+
activePool.query(
|
|
47
|
+
// The outer JOIN tasks already requires at least one task per goal, so
|
|
48
|
+
// there is no separate EXISTS to filter goals with none.
|
|
49
|
+
`SELECT g.id::text AS goal_id,
|
|
50
|
+
g.title, g.status, g.version_id, g.category_id,
|
|
51
|
+
(g.visibility IS DISTINCT FROM 'private'
|
|
52
|
+
OR $2::boolean
|
|
53
|
+
OR bool_or(gm.builder_id IS NOT NULL)) AS visible,
|
|
54
|
+
COUNT(*) FILTER (WHERE t.status <> ALL($4::text[]))::int AS task_total,
|
|
55
|
+
COUNT(*) FILTER (WHERE t.status = ANY($5::text[]))::int AS task_done
|
|
56
|
+
FROM goals g
|
|
57
|
+
JOIN tasks t ON t.goal_id = g.id
|
|
58
|
+
LEFT JOIN goal_members gm ON gm.goal_id = g.id AND gm.builder_id = $3::bigint
|
|
59
|
+
WHERE ($1::text IS NULL OR g.version_id = $1)
|
|
60
|
+
GROUP BY g.id, g.title, g.status, g.version_id, g.category_id
|
|
61
|
+
ORDER BY g.version_id, g.sort_order, g.id`,
|
|
62
|
+
[version, viewerIsArchon === true, viewerId, CUT_STATUSES, DONE_STATUSES]
|
|
63
|
+
),
|
|
64
|
+
activePool.query(
|
|
65
|
+
`SELECT ft.goal_id::text AS from_goal_id,
|
|
66
|
+
tt.goal_id::text AS to_goal_id,
|
|
67
|
+
COUNT(*)::int AS count
|
|
68
|
+
FROM dependencies d
|
|
69
|
+
JOIN tasks ft ON d.from_kind = 'task' AND ft.id = d.from_id
|
|
70
|
+
JOIN tasks tt ON d.to_kind = 'task' AND tt.id = d.to_id
|
|
71
|
+
JOIN goals fg ON fg.id = ft.goal_id
|
|
72
|
+
JOIN goals tg ON tg.id = tt.goal_id
|
|
73
|
+
WHERE ft.goal_id <> tt.goal_id
|
|
74
|
+
AND ($1::text IS NULL OR (fg.version_id = $1 AND tg.version_id = $1))
|
|
75
|
+
GROUP BY ft.goal_id, tt.goal_id
|
|
76
|
+
ORDER BY ft.goal_id, tt.goal_id`,
|
|
77
|
+
[version]
|
|
78
|
+
),
|
|
79
|
+
]);
|
|
80
|
+
return { goals: goals.rows, edges: edges.rows };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// shapeGraph — the wire shape. Pure, so a test pins it with no database.
|
|
84
|
+
//
|
|
85
|
+
// nodes: [{ goal_id, title, status, version_id, category, task_total, task_done,
|
|
86
|
+
// depends_on, depended_on_by }]
|
|
87
|
+
// edges: [{ from, to, count }] — `from` waits on `to`
|
|
88
|
+
// counts: { goals, connected, isolated, edges }
|
|
89
|
+
//
|
|
90
|
+
// `depends_on` / `depended_on_by` are DISTINCT GOAL counts, not task counts: the
|
|
91
|
+
// table this feeds says "waits on 3 goals", and a reader adding up task edges to
|
|
92
|
+
// find out how many goals are involved would get the wrong number.
|
|
93
|
+
//
|
|
94
|
+
// An edge whose endpoint is not among the nodes is DROPPED, not drawn to nowhere.
|
|
95
|
+
// That happens legitimately when a version filter scopes the goals but a stale
|
|
96
|
+
// row lingers, and an edge with a missing end is a line pointing at nothing.
|
|
97
|
+
function shapeGraph({ goals = [], edges = [] } = {}, categoryById = new Map()) {
|
|
98
|
+
const known = new Set(goals.map((g) => String(g.goal_id)));
|
|
99
|
+
const kept = [];
|
|
100
|
+
const seen = new Set();
|
|
101
|
+
for (const e of edges) {
|
|
102
|
+
const from = String(e.from_goal_id);
|
|
103
|
+
const to = String(e.to_goal_id);
|
|
104
|
+
if (from === to || !known.has(from) || !known.has(to)) continue;
|
|
105
|
+
const key = `${from}>${to}`;
|
|
106
|
+
if (seen.has(key)) continue;
|
|
107
|
+
seen.add(key);
|
|
108
|
+
kept.push({ from, to, count: Number(e.count) || 0 });
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const waitsOn = new Map();
|
|
112
|
+
const blocks = new Map();
|
|
113
|
+
for (const e of kept) {
|
|
114
|
+
if (!waitsOn.has(e.from)) waitsOn.set(e.from, new Set());
|
|
115
|
+
waitsOn.get(e.from).add(e.to);
|
|
116
|
+
if (!blocks.has(e.to)) blocks.set(e.to, new Set());
|
|
117
|
+
blocks.get(e.to).add(e.from);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const nodes = goals.map((g) => {
|
|
121
|
+
const id = String(g.goal_id);
|
|
122
|
+
const visible = g.visible !== false;
|
|
123
|
+
const cat = visible && g.category_id != null ? categoryById.get(String(g.category_id)) : null;
|
|
124
|
+
return {
|
|
125
|
+
goal_id: id,
|
|
126
|
+
title: visible ? g.title : null,
|
|
127
|
+
status: g.status,
|
|
128
|
+
version_id: g.version_id,
|
|
129
|
+
category: cat ? { id: String(cat.id), slug: cat.slug, label: cat.label } : null,
|
|
130
|
+
task_total: Number(g.task_total) || 0,
|
|
131
|
+
task_done: Number(g.task_done) || 0,
|
|
132
|
+
depends_on: (waitsOn.get(id) || new Set()).size,
|
|
133
|
+
depended_on_by: (blocks.get(id) || new Set()).size,
|
|
134
|
+
};
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
const connected = nodes.filter((n) => n.depends_on + n.depended_on_by > 0).length;
|
|
138
|
+
return {
|
|
139
|
+
nodes,
|
|
140
|
+
edges: kept,
|
|
141
|
+
counts: { goals: nodes.length, connected, isolated: nodes.length - connected, edges: kept.length },
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// goalGraph — read + shape. `categories` is injectable so the whole path runs in a
|
|
146
|
+
// test with a fake pool and no category table.
|
|
147
|
+
async function goalGraph(opts = {}, deps = {}) {
|
|
148
|
+
const [rows, byId] = await Promise.all([
|
|
149
|
+
goalGraphRows(opts, deps),
|
|
150
|
+
(deps.categoryIndex || goalCategory.categoryIndex)(deps),
|
|
151
|
+
]);
|
|
152
|
+
return shapeGraph(rows, byId);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// goalGraphHandler — GET /goals/graph. A factory, so a test drives it with a stub
|
|
156
|
+
// read and no database (the goal-category.js handler precedent). Identity comes
|
|
157
|
+
// from the authenticated request, never the query: the viewer decides whether a
|
|
158
|
+
// private goal's title comes back, and a caller-supplied id would let anyone ask
|
|
159
|
+
// as somebody else.
|
|
160
|
+
function goalGraphHandler({ read = goalGraph, log = console } = {}) {
|
|
161
|
+
return async function getGoalGraph(req, res) {
|
|
162
|
+
try {
|
|
163
|
+
const graph = await read({
|
|
164
|
+
versionId: (req.query && req.query.version) || null,
|
|
165
|
+
viewerId: req.builder.id,
|
|
166
|
+
viewerIsArchon: req.builder.rank === 'archon',
|
|
167
|
+
});
|
|
168
|
+
return res.json(graph);
|
|
169
|
+
} catch (err) {
|
|
170
|
+
log.error('[gds] GET /goals/graph', err);
|
|
171
|
+
return res.fail('graph_failed', { status: 500, message: 'internal error' });
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
module.exports = { goalGraph, goalGraphRows, shapeGraph, goalGraphHandler };
|
|
@@ -30,6 +30,7 @@ const goalSuggest = require('../goal-suggest.js'); // task 1003062 — pre-creat
|
|
|
30
30
|
const goalEdits = require('../goal-edits.js'); // task 1003102 — goal self-edit handlers
|
|
31
31
|
const goalCategory = require('../goal-category.js'); // task 1003275 — the ADVISORY human category (gates nothing)
|
|
32
32
|
const goalRollup = require('../goal-rollup.js'); // task 1003278 — per-goal rollups computed in SQL, not in the browser
|
|
33
|
+
const goalGraph = require('../goal-graph.js'); // task 1003479 — the goal graph: nodes + cross-goal edges, aggregated in SQL
|
|
33
34
|
const { suggestCriteria } = require('../criterion-suggest');
|
|
34
35
|
const { classifyTaskKind, VALID_KINDS, WRITABLE_DISCIPLINES, resolveWriteDiscipline, TASK_FIELD_BOUNDS } = require('../task-classifier');
|
|
35
36
|
const { validateOrRespond, parseId, LIMITS } = api;
|
|
@@ -210,6 +211,9 @@ module.exports = function buildGoalsRouter() {
|
|
|
210
211
|
}
|
|
211
212
|
});
|
|
212
213
|
|
|
214
|
+
// rank: any-builder — GET /goals/graph, the goal board's map (task 1003479, ADR 0227); before /goals/:id.
|
|
215
|
+
router.get('/goals/graph', auth.requireBuilder, goalGraph.goalGraphHandler({ log }));
|
|
216
|
+
|
|
213
217
|
// GET /goals?version=&status= — list goals (read).
|
|
214
218
|
// rank: any-builder — viewing the goal board.
|
|
215
219
|
router.get('/goals', auth.requireBuilder, async (req, res) => {
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.947",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.947",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.947",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
package/scripts/gds/fitness.js
CHANGED
|
@@ -1277,7 +1277,7 @@ const CHECKS = [
|
|
|
1277
1277
|
// one (ADR 0289). Rationale + the seam→consumer table live in that file.
|
|
1278
1278
|
require('./seam-wiring-guard.js').checkSeamWiring,
|
|
1279
1279
|
require('./home-isolation-guard.js').checkHomeIsolation, // Check 34 — task 1003763: a test that sets HOME for a child without USERPROFILE isolates NOTHING on win32, so it silently stops testing what its comment says. Three tasks fixed this one site at a time. Rationale in that file.
|
|
1280
|
-
require('./scope-map-guard.js')
|
|
1280
|
+
...['checkDevsecopsOwnsItsChecks', 'checkEveryModuleDirIsScoped', 'checkNoNewUnownedRoot'].map((k) => require('./scope-map-guard.js')[k]), // Check 35 — task 1003949: a check file no MODULE_SCOPE_MAP key owns cannot be authorized by the goal scoped to own it, and the list drifts once per new check. Check 36 — task 1004132: the same defect one level up, a carved module whose own directory no key claims (modules/specialities/ shipped that way). Sharing the line per the ratchet convention above. Rationale in that file.
|
|
1281
1281
|
require('./adr-namespace.js').checkAdrNumbering, require('./exec-path-guard.js').checkExecPathsStatic, // Check 32 — task 1003371 / B50: what the server EXECUTES, out of whose tree — the blind spot all three 2026-08-29 CRITICALs shared. Rationale + reviewed exemptions live in that file. Sharing Check 31's line (the 19c/19d + 23/24 precedent above): fitness.js is AT the 1,500 ratchet and a new check cannot afford a line until task 1003825 buys it back.
|
|
1282
1282
|
];
|
|
1283
1283
|
|
|
@@ -117,4 +117,184 @@ function checkDevsecopsOwnsItsChecks() {
|
|
|
117
117
|
+ 'unless CI says otherwise (task 1003949).',
|
|
118
118
|
};
|
|
119
119
|
}
|
|
120
|
-
module
|
|
120
|
+
// PURE — every module directory on disk, by key. A module is a modules/<key>/ dir
|
|
121
|
+
// carrying module.json (the ADR 0083 manifest), which is the same structural test
|
|
122
|
+
// the loader's discoverModules() uses: a key is what has a manifest, never a
|
|
123
|
+
// hand-kept roster. Exported so the scan is testable against a fixture tree —
|
|
124
|
+
// an empty result here looks identical to a clean repo, which is the failure the
|
|
125
|
+
// sibling check above exists to refuse.
|
|
126
|
+
function moduleDirsOnDisk(modulesRoot) {
|
|
127
|
+
let entries = [];
|
|
128
|
+
try { entries = fs.readdirSync(modulesRoot, { withFileTypes: true }); } catch { return []; }
|
|
129
|
+
return entries
|
|
130
|
+
.filter((e) => e.isDirectory() && fs.existsSync(path.join(modulesRoot, e.name, 'module.json')))
|
|
131
|
+
.map((e) => e.name)
|
|
132
|
+
.sort();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// A carved module whose own directory no MODULE_SCOPE_MAP key claims (task 1004132).
|
|
136
|
+
//
|
|
137
|
+
// THE DRIFT THIS STOPS, and it is the sibling of the check above rather than a new
|
|
138
|
+
// idea: modules/specialities/ shipped a full module — manifest, db slice, routes, two
|
|
139
|
+
// migrations — with no key here at all. scopeSubset() therefore resolved every file in
|
|
140
|
+
// it to no territory, so a goal scoped to `specialities` was refused out_of_scope for
|
|
141
|
+
// touching the module it is named after. It was the ONLY on-disk module in that state,
|
|
142
|
+
// which is exactly why nobody noticed: 21 of 22 were fine.
|
|
143
|
+
//
|
|
144
|
+
// The rule is deliberately narrow. It does NOT require every repo path to have an
|
|
145
|
+
// owner — src/, scripts/ and bin/ are unowned ON PURPOSE (ADR 0086: a goal grants
|
|
146
|
+
// authority BOUNDED to module territory, and POST /tasks stays Archon-only for
|
|
147
|
+
// ungoverned creation), so a broader "every root is owned" check would encode the
|
|
148
|
+
// opposite of the design. What must hold is narrower and absolute: if a directory
|
|
149
|
+
// declares itself a module, a goal must be able to be scoped to it.
|
|
150
|
+
function checkEveryModuleDirIsScoped() {
|
|
151
|
+
const name = 'every carved module owns its own directory (task 1004132)';
|
|
152
|
+
const dirs = moduleDirsOnDisk(path.join(REPO_ROOT, 'modules'));
|
|
153
|
+
|
|
154
|
+
// Same anti-vacuity rule as the check above: this repo HAS modules, so an empty
|
|
155
|
+
// scan means the manifest test broke, not that the tree is clean.
|
|
156
|
+
if (dirs.length === 0) {
|
|
157
|
+
return {
|
|
158
|
+
name,
|
|
159
|
+
ok: false,
|
|
160
|
+
hardFail: true,
|
|
161
|
+
violations: ['modules/: found NO directory carrying a module.json. That is a broken scan, '
|
|
162
|
+
+ 'not a clean result — moduleDirsOnDisk() in scope-map-guard.js needs looking at.'],
|
|
163
|
+
warnings: [],
|
|
164
|
+
note: 'the scan produced an empty set, which cannot be right.',
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const { MODULE_SCOPE_MAP } = require('../../src/bongos/module-scope-map.js');
|
|
169
|
+
const globsOf = (v) => (Array.isArray(v) ? v : (v && v.globs) || []);
|
|
170
|
+
const claimed = new Set();
|
|
171
|
+
for (const v of Object.values(MODULE_SCOPE_MAP)) {
|
|
172
|
+
// A key may claim another module's dir (a cross-cutting owner), so collect every
|
|
173
|
+
// modules/<key>/ glob from EVERY key rather than only from the same-named one.
|
|
174
|
+
for (const g of globsOf(v)) {
|
|
175
|
+
const m = /^modules\/([^/]+)\/$/.exec(g);
|
|
176
|
+
if (m) claimed.add(m[1]);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const unscoped = dirs.filter((k) => !claimed.has(k));
|
|
181
|
+
const violations = unscoped.map((k) => `modules/${k}/ carries a module.json but no MODULE_SCOPE_MAP `
|
|
182
|
+
+ `key claims it — scopeSubset() resolves the whole module to no territory, so a goal scoped to `
|
|
183
|
+
+ `'${k}' is denied out_of_scope for touching the module it is named after. Add a '${k}' key `
|
|
184
|
+
+ `owning 'modules/${k}/'.`);
|
|
185
|
+
|
|
186
|
+
return {
|
|
187
|
+
name,
|
|
188
|
+
ok: unscoped.length === 0,
|
|
189
|
+
hardFail: unscoped.length > 0,
|
|
190
|
+
violations,
|
|
191
|
+
warnings: [],
|
|
192
|
+
note: `${dirs.length} carved module(s) on disk, each claimed by a MODULE_SCOPE_MAP key.`,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// PURE — every tracked top-level root, as `name` or `name/`. Exported so the scan
|
|
197
|
+
// is testable against a fixture list rather than only against this repo.
|
|
198
|
+
function trackedRoots(files) {
|
|
199
|
+
const out = new Set();
|
|
200
|
+
for (const f of files || []) {
|
|
201
|
+
if (!f) continue;
|
|
202
|
+
const i = f.indexOf('/');
|
|
203
|
+
out.add(i === -1 ? f : `${f.slice(0, i)}/`);
|
|
204
|
+
}
|
|
205
|
+
return [...out].sort();
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// PURE — does ANY glob reach into this root? A root is "carved" the moment one
|
|
209
|
+
// does; src/ and scripts/ are partially owned and that is the normal in-progress
|
|
210
|
+
// state ADR 0086 §4 describes ("auto-tightens as ADR 0083 moves code into
|
|
211
|
+
// modules/<key>/"). What this answers is the narrower question: is this root
|
|
212
|
+
// territory the scope map has never heard of at all?
|
|
213
|
+
function rootIsTouched(root, globs) {
|
|
214
|
+
return (globs || []).some((g) => g === root || g.startsWith(root) || root.startsWith(g));
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// New unowned territory, and stale entries in the record of the old (task 1004132).
|
|
218
|
+
//
|
|
219
|
+
// THE GAP THIS CLOSES, and it is the one the grade round caught me leaving open.
|
|
220
|
+
// I had argued that because the core is unowned BY DESIGN (ADR 0086: authority is
|
|
221
|
+
// bounded to module territory, POST /tasks stays Archon-only for ungoverned
|
|
222
|
+
// creation), no general check should exist. That is half right and wholly
|
|
223
|
+
// insufficient: the design says WHICH roots may be unowned, it does not stop the
|
|
224
|
+
// set growing. Without this, a new top-level directory lands tomorrow, no module
|
|
225
|
+
// claims it, and nothing anywhere fails — the exact shape that let
|
|
226
|
+
// modules/specialities/ sit unowned for its whole life.
|
|
227
|
+
//
|
|
228
|
+
// So the decision is recorded as DATA (UNOWNED_ROOTS in module-scope-map.js) and
|
|
229
|
+
// this check enforces it in BOTH directions, the discipline
|
|
230
|
+
// tests/host_topology_skips.mjs uses for its own allowlist:
|
|
231
|
+
// - a root that is neither carved nor listed -> new territory, fail
|
|
232
|
+
// - a listed root that is now carved or gone -> stale entry, fail
|
|
233
|
+
// A one-directional check would let the list rot into a rubber stamp.
|
|
234
|
+
function checkNoNewUnownedRoot(fileList) {
|
|
235
|
+
const name = 'no new unowned top-level root (task 1004132)';
|
|
236
|
+
let files = fileList;
|
|
237
|
+
if (!files) {
|
|
238
|
+
files = require('node:child_process')
|
|
239
|
+
.execFileSync('git', ['ls-files'], { cwd: REPO_ROOT, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 })
|
|
240
|
+
.trim().split('\n').filter(Boolean);
|
|
241
|
+
}
|
|
242
|
+
const roots = trackedRoots(files);
|
|
243
|
+
|
|
244
|
+
// Same anti-vacuity rule as its two siblings: this repo HAS tracked files, so an
|
|
245
|
+
// empty scan is a broken `git ls-files`, not a clean tree.
|
|
246
|
+
if (roots.length === 0) {
|
|
247
|
+
return {
|
|
248
|
+
name,
|
|
249
|
+
ok: false,
|
|
250
|
+
hardFail: true,
|
|
251
|
+
violations: ['git ls-files returned NO tracked files. That is a broken scan, not a clean '
|
|
252
|
+
+ 'result — trackedRoots() in scope-map-guard.js needs looking at.'],
|
|
253
|
+
warnings: [],
|
|
254
|
+
note: 'the scan produced an empty set, which cannot be right.',
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const map = require('../../src/bongos/module-scope-map.js');
|
|
259
|
+
const globs = [...map.globsForModules(map.moduleKeys()), ...map.SHARED_GLOBS];
|
|
260
|
+
const listed = map.UNOWNED_ROOTS || {};
|
|
261
|
+
|
|
262
|
+
const violations = [];
|
|
263
|
+
for (const r of roots) {
|
|
264
|
+
if (rootIsTouched(r, globs)) {
|
|
265
|
+
if (Object.prototype.hasOwnProperty.call(listed, r)) {
|
|
266
|
+
violations.push(`'${r}' is listed in UNOWNED_ROOTS but a module glob now reaches it — the `
|
|
267
|
+
+ 'entry is stale. Delete it; a root leaving that list is the map tightening as intended.');
|
|
268
|
+
}
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
if (!Object.prototype.hasOwnProperty.call(listed, r)) {
|
|
272
|
+
violations.push(`'${r}' is tracked but NO module glob and no SHARED_GLOBS entry reaches it, and `
|
|
273
|
+
+ 'it is not in UNOWNED_ROOTS. New unowned territory: no goal can ever authorize a task that '
|
|
274
|
+
+ `touches it. Give it an owning module, or add it to UNOWNED_ROOTS in module-scope-map.js `
|
|
275
|
+
+ 'with the reason it has none.');
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
for (const r of Object.keys(listed)) {
|
|
279
|
+
if (!roots.includes(r)) {
|
|
280
|
+
violations.push(`UNOWNED_ROOTS lists '${r}', which is not a tracked root any more — stale entry, `
|
|
281
|
+
+ 'delete it so the list keeps meaning something.');
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
return {
|
|
286
|
+
name,
|
|
287
|
+
ok: violations.length === 0,
|
|
288
|
+
hardFail: violations.length > 0,
|
|
289
|
+
violations,
|
|
290
|
+
warnings: [],
|
|
291
|
+
note: `${roots.length} tracked root(s); ${Object.keys(listed).length} recorded as deliberately `
|
|
292
|
+
+ 'unowned (ADR 0086 — core is Archon territory). Shrink-only: an entry may leave, not appear silently.',
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
module.exports = {
|
|
297
|
+
checkDevsecopsOwnsItsChecks, siblingRequires, unownedBy, FITNESS,
|
|
298
|
+
checkEveryModuleDirIsScoped, moduleDirsOnDisk,
|
|
299
|
+
checkNoNewUnownedRoot, trackedRoots, rootIsTouched,
|
|
300
|
+
};
|