@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
|
@@ -145,6 +145,16 @@ const MODULE_GLOBS = {
|
|
|
145
145
|
'modules/ideas/',
|
|
146
146
|
'scripts/gds/capture.js', 'scripts/gds/triage.js', 'scripts/gds/render-ideas.js',
|
|
147
147
|
],
|
|
148
|
+
// Role sub-specialities (task 1003855, ADR 0296). Carved into modules/specialities/
|
|
149
|
+
// but never given a key here, so every file it owns — module.json, db.js, routes,
|
|
150
|
+
// its two migrations — resolved to NO module territory: a goal scoped to
|
|
151
|
+
// `specialities` was denied out_of_scope for touching the module it is named after
|
|
152
|
+
// (found by task 1004132). Every OTHER on-disk module owns its own directory; this
|
|
153
|
+
// was the only one that did not, and checkEveryModuleDirIsScoped in
|
|
154
|
+
// scripts/gds/scope-map-guard.js now fails CI if a new carve repeats it.
|
|
155
|
+
specialities: [
|
|
156
|
+
'modules/specialities/',
|
|
157
|
+
],
|
|
148
158
|
// --- builder prefs (wandering/skill/sound/render) + builder-needs ---
|
|
149
159
|
// Carved into modules/builder-settings/ (BV1.R80 / ADR 0093 §1): the five pref
|
|
150
160
|
// libs + builder-needs + their db slice + the `/me/*-prefs` routes + the
|
|
@@ -521,6 +531,62 @@ const SHARED_GLOBS = Object.freeze(
|
|
|
521
531
|
SHARED_GLOBS_RAW.filter((g) => !touchesOverlap([g], PROTECTED_GLOBS)),
|
|
522
532
|
);
|
|
523
533
|
|
|
534
|
+
// ── Roots no module glob touches, and why (task 1004132) ────────────────────────
|
|
535
|
+
//
|
|
536
|
+
// This is the RECORDED DECISION behind "the core is owned by nobody", not a note
|
|
537
|
+
// about it. `checkNoNewUnownedRoot` in scripts/gds/scope-map-guard.js reads this
|
|
538
|
+
// map and fails CI BOTH ways: a tracked root that appears here-less is new unowned
|
|
539
|
+
// territory, and an entry whose root became owned (or stopped existing) is stale.
|
|
540
|
+
//
|
|
541
|
+
// WHY THESE ARE ALLOWED TO BE UNOWNED. Per ADR 0086 a goal grants authority
|
|
542
|
+
// BOUNDED to module territory, and `POST /tasks` stays Archon-only for ungoverned
|
|
543
|
+
// creation. So a path with no module owner is not homeless — it is reachable only
|
|
544
|
+
// through the Archon door, which is the safety model, not a gap in it. What must
|
|
545
|
+
// not happen is that set GROWING silently, which is what this freezes.
|
|
546
|
+
//
|
|
547
|
+
// SHRINK-ONLY, like the fitness ratchets. ADR 0086 §4 expects the map to
|
|
548
|
+
// "auto-tighten as ADR 0083 moves code into modules/<key>/" — so an entry leaving
|
|
549
|
+
// this list is the system working. Adding one needs a stated reason, here, in the
|
|
550
|
+
// same commit that adds the root.
|
|
551
|
+
//
|
|
552
|
+
// A `review` reason is an HONEST placeholder, not an endorsement: it marks a root
|
|
553
|
+
// that probably does have a natural owner but whose assignment is its own decision.
|
|
554
|
+
// Assigning nineteen roots in one pass is the one-at-a-time churn task 1004132 was
|
|
555
|
+
// filed to avoid; freezing the set and naming the arguable ones is the cheap half.
|
|
556
|
+
const UNOWNED_ROOTS = Object.freeze({
|
|
557
|
+
// Generated packaging manifests — written by package-core.js, never hand-edited.
|
|
558
|
+
'.bongos-core.json': 'generated package manifest (scripts/gds/package-core.js)',
|
|
559
|
+
'.cloudbongos-core.json': 'generated package manifest (scripts/gds/package-core.js)',
|
|
560
|
+
// Container build + repo-level dotfiles. review: devsecops already owns the
|
|
561
|
+
// neighbouring .npmrc / .gitattributes / package.json, so these plausibly belong
|
|
562
|
+
// to it — but that is a scope-wall change and wants its own decision.
|
|
563
|
+
'.dockerignore': 'review: container build, plausibly devsecops',
|
|
564
|
+
'Dockerfile': 'review: container build, plausibly devsecops',
|
|
565
|
+
'docker-compose.yml': 'review: container build, plausibly devsecops',
|
|
566
|
+
'docker-entrypoint.sh': 'review: container build, plausibly devsecops',
|
|
567
|
+
'.gitignore': 'review: repo-level dotfile, plausibly devsecops',
|
|
568
|
+
'.env.local.example': 'review: repo-level dotfile, plausibly devsecops',
|
|
569
|
+
// Design tooling checked into the tree. review: plausibly ui-design.
|
|
570
|
+
'.impeccable/': 'review: design tooling, plausibly ui-design',
|
|
571
|
+
// Governance + legal. Deliberately not methodology: methodology owns docs/, and
|
|
572
|
+
// these are the project's constitutional documents rather than its handbook.
|
|
573
|
+
'LICENSE': 'project legal text',
|
|
574
|
+
'CODE_OF_CONDUCT.md': 'project governance text',
|
|
575
|
+
'CONTRIBUTING.md': 'project governance text',
|
|
576
|
+
'GOVERNANCE.md': 'project governance text',
|
|
577
|
+
// Top-level product/design statements, read by humans before any module matters.
|
|
578
|
+
'README.md': 'repo front door',
|
|
579
|
+
'DESIGN.md': 'top-level design statement',
|
|
580
|
+
'PRODUCT.md': 'top-level product statement',
|
|
581
|
+
// The core itself. bin/ is the CLI entrypoint and clients/ is the GENERATED API
|
|
582
|
+
// client (scripts/gds/gen-api-client.js); public-docs/ is the published site.
|
|
583
|
+
// These are core, and core is Archon territory by ADR 0086 — the primary case
|
|
584
|
+
// this list exists to record.
|
|
585
|
+
'bin/': 'core CLI entrypoint — Archon territory (ADR 0086)',
|
|
586
|
+
'clients/': 'generated API client (scripts/gds/gen-api-client.js)',
|
|
587
|
+
'public-docs/': 'published docs site — core',
|
|
588
|
+
});
|
|
589
|
+
|
|
524
590
|
// A CLI ENTRYPOINT — a single executable script under scripts/, e.g.
|
|
525
591
|
// 'scripts/gds/setup.js'. Structural, not a list: it is a path test, so no roster
|
|
526
592
|
// is hand-maintained here and nothing has to be kept in step with PROTECTED_GLOBS.
|
|
@@ -676,6 +742,7 @@ function moduleDriftFiles(moduleKey, files) {
|
|
|
676
742
|
}
|
|
677
743
|
|
|
678
744
|
module.exports = {
|
|
745
|
+
UNOWNED_ROOTS,
|
|
679
746
|
MODULE_GLOBS,
|
|
680
747
|
MODULE_SCOPE_MAP,
|
|
681
748
|
SHARED_GLOBS,
|
|
@@ -1163,13 +1163,16 @@ function mountInternalSurfaces(app) {
|
|
|
1163
1163
|
// because the page reads the VIEWER's own trail and nobody else's — the id it
|
|
1164
1164
|
// asks for comes from GET /me, never from the URL, so a rank could protect
|
|
1165
1165
|
// nothing here that the session does not already.
|
|
1166
|
+
// task 1003479 (goal 1000089): `goal-map` — the map of which goals wait on which,
|
|
1167
|
+
// reached from Goals. It reads GET /goals/graph, which is `requireBuilder`, so a
|
|
1168
|
+
// page gated any lower would show a signed-out visitor an empty shell.
|
|
1166
1169
|
// task 1004105 (BV2.R26): `sky` — THE SKY, the ambient view over the same
|
|
1167
1170
|
// trail the room lists — joins that tier for exactly the same reasons. It is
|
|
1168
1171
|
// the SAME read (GET /inbox/by-builder/:id with the id from GET /me), drawn
|
|
1169
1172
|
// as a map instead of a table, so a floor above sign-in would protect on one
|
|
1170
1173
|
// page what it admits on the other.
|
|
1171
1174
|
const SIGNEDIN_BUILDERS_PAGE_RE =
|
|
1172
|
-
/^\/builders\/(?:work|settings|atlas|modules|goals|roadmap|copy-desk|studio|ideas|thinking|sky|collab|task|idea|primer(?:\.md)?|diagrams)(?:\.html)?\/?$/;
|
|
1175
|
+
/^\/builders\/(?:work|settings|atlas|modules|goals|goal-map|roadmap|copy-desk|studio|ideas|thinking|sky|collab|task|idea|primer(?:\.md)?|diagrams)(?:\.html)?\/?$/;
|
|
1173
1176
|
// The diagram image assets under /builders/diagrams/ ride the same tier as the
|
|
1174
1177
|
// page that embeds them.
|
|
1175
1178
|
const SIGNEDIN_BUILDERS_PREFIX_RE = /^\/builders\/diagrams\//;
|
package/src/module-api.js
CHANGED
|
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
71
71
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
72
72
|
// the entry to that file. Look for a version's history there, not here.
|
|
73
73
|
// ---------------------------------------------------------------------------
|
|
74
|
-
const CORE_VERSION = '1.19.
|
|
74
|
+
const CORE_VERSION = '1.19.947'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
75
75
|
|
|
76
76
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
77
77
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
// tests/goal_graph.mjs — GET /goals/graph, the data under the goal map (task
|
|
2
|
+
// 1003479, goal 1000089, ADR 0227 §6 item 3 + §10.4).
|
|
3
|
+
//
|
|
4
|
+
// The endpoint is an AGGREGATE: one node per goal, one edge per ordered pair of
|
|
5
|
+
// goals whose tasks depend on each other. What this file pins is the part a
|
|
6
|
+
// reader cannot see from the picture — that the shape is right (a count of goals
|
|
7
|
+
// is not a count of task edges), that a private goal is redacted rather than
|
|
8
|
+
// dropped, that no task id or title can leave the query, and that the identity
|
|
9
|
+
// deciding all of it comes from the authenticated request.
|
|
10
|
+
//
|
|
11
|
+
// DB-free: the pure shaper is run directly, the SQL is run against a recording
|
|
12
|
+
// fake pool, and the handler against stub collaborators (the goal-category.js
|
|
13
|
+
// handler-factory precedent).
|
|
14
|
+
//
|
|
15
|
+
// Run: node tests/goal_graph.mjs
|
|
16
|
+
process.env.NODE_ENV = 'test';
|
|
17
|
+
import assert from 'node:assert/strict';
|
|
18
|
+
import fs from 'node:fs';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
import { fileURLToPath } from 'node:url';
|
|
21
|
+
import { createRequire } from 'node:module';
|
|
22
|
+
import { makeRunner } from './helpers.mjs';
|
|
23
|
+
|
|
24
|
+
const require = createRequire(import.meta.url);
|
|
25
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
26
|
+
const { shapeGraph, goalGraphRows, goalGraph, goalGraphHandler } = require(path.join(ROOT, 'modules/lifecycle/goal-graph.js'));
|
|
27
|
+
const { DONE_STATUSES, CUT_STATUSES } = require(path.join(ROOT, 'modules/lifecycle/task-status.js'));
|
|
28
|
+
const GOALS_ROUTE = fs.readFileSync(path.join(ROOT, 'modules/lifecycle/routes/goals.js'), 'utf8');
|
|
29
|
+
const GRAPH_SRC = fs.readFileSync(path.join(ROOT, 'modules/lifecycle/goal-graph.js'), 'utf8');
|
|
30
|
+
|
|
31
|
+
const { test, summary } = makeRunner();
|
|
32
|
+
|
|
33
|
+
const CATS = new Map([['7', { id: 7, slug: 'security', label: 'Security' }]]);
|
|
34
|
+
const g = (id, extra = {}) => ({ goal_id: String(id), title: `Goal ${id}`, status: 'open', version_id: 'V1', category_id: null, visible: true, task_total: 4, task_done: 1, ...extra });
|
|
35
|
+
const e = (from, to, count = 1) => ({ from_goal_id: String(from), to_goal_id: String(to), count });
|
|
36
|
+
|
|
37
|
+
// ---- shapeGraph --------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
await test('an edge means "from waits on to", and each node counts DISTINCT goals, not task edges', () => {
|
|
40
|
+
const out = shapeGraph({ goals: [g(1), g(2), g(3)], edges: [e(1, 2, 9), e(1, 3, 1), e(3, 2, 2)] });
|
|
41
|
+
const by = Object.fromEntries(out.nodes.map((n) => [n.goal_id, n]));
|
|
42
|
+
assert.deepEqual(out.edges, [{ from: '1', to: '2', count: 9 }, { from: '1', to: '3', count: 1 }, { from: '3', to: '2', count: 2 }]);
|
|
43
|
+
assert.equal(by['1'].depends_on, 2, 'goal 1 waits on two goals, though ten task edges carry it');
|
|
44
|
+
assert.equal(by['2'].depended_on_by, 2);
|
|
45
|
+
assert.equal(by['2'].depends_on, 0);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
await test('counts split connected from isolated, and the two add up to the goals', () => {
|
|
49
|
+
const out = shapeGraph({ goals: [g(1), g(2), g(3), g(4)], edges: [e(1, 2)] });
|
|
50
|
+
assert.deepEqual(out.counts, { goals: 4, connected: 2, isolated: 2, edges: 1 });
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
await test('an edge to a goal that is not a node is dropped, not drawn to nowhere; self and duplicate edges too', () => {
|
|
54
|
+
const out = shapeGraph({ goals: [g(1), g(2)], edges: [e(1, 99), e(2, 2), e(1, 2, 3), e(1, 2, 3)] });
|
|
55
|
+
assert.deepEqual(out.edges, [{ from: '1', to: '2', count: 3 }]);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
await test('a category is attached as {id, slug, label}; an uncategorised goal carries null (legal, ADR 0192)', () => {
|
|
59
|
+
const out = shapeGraph({ goals: [g(1, { category_id: 7 }), g(2)], edges: [] }, CATS);
|
|
60
|
+
assert.deepEqual(out.nodes[0].category, { id: '7', slug: 'security', label: 'Security' });
|
|
61
|
+
assert.equal(out.nodes[1].category, null);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
await test('a PRIVATE goal the viewer cannot see stays a node (so neighbours are not falsely isolated) but loses title and category', () => {
|
|
65
|
+
const out = shapeGraph({ goals: [g(1, { visible: false, category_id: 7, title: 'Secret roadmap' }), g(2)], edges: [e(2, 1)] }, CATS);
|
|
66
|
+
const hidden = out.nodes[0];
|
|
67
|
+
assert.equal(hidden.title, null);
|
|
68
|
+
assert.equal(hidden.category, null);
|
|
69
|
+
assert.equal(hidden.task_total, 4, 'counts are not the secret — the prose is (ADR 0112)');
|
|
70
|
+
assert.equal(hidden.depended_on_by, 1, 'and its edges still count, or the structure would lie');
|
|
71
|
+
assert.equal(JSON.stringify(out).includes('Secret roadmap'), false, 'the title is nowhere in the payload');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
await test('an empty corpus is a valid, empty graph', () => {
|
|
75
|
+
assert.deepEqual(shapeGraph({}), { nodes: [], edges: [], counts: { goals: 0, connected: 0, isolated: 0, edges: 0 } });
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// ---- the SQL, against a recording pool ----------------------------------------
|
|
79
|
+
|
|
80
|
+
function fakePool(goalRows = [], edgeRows = []) {
|
|
81
|
+
const calls = [];
|
|
82
|
+
return {
|
|
83
|
+
calls,
|
|
84
|
+
async query(sql, params) {
|
|
85
|
+
calls.push({ sql, params });
|
|
86
|
+
return { rows: /FROM dependencies/.test(sql) ? edgeRows : goalRows };
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
await test('the viewer, the Archon flag and the shared status buckets ride as bind parameters — never interpolated', async () => {
|
|
92
|
+
const pool = fakePool();
|
|
93
|
+
await goalGraphRows({ versionId: 'V1', viewerId: 42, viewerIsArchon: true }, { pool });
|
|
94
|
+
const [goalsQ] = pool.calls;
|
|
95
|
+
assert.deepEqual(goalsQ.params, ['V1', true, 42, CUT_STATUSES, DONE_STATUSES]);
|
|
96
|
+
assert.equal(/\b42\b/.test(goalsQ.sql), false, 'no viewer id in the SQL text');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
await test('only an explicit archon === true widens visibility (a truthy string is not an Archon)', async () => {
|
|
100
|
+
const pool = fakePool();
|
|
101
|
+
await goalGraphRows({ viewerId: 1, viewerIsArchon: 'yes' }, { pool });
|
|
102
|
+
assert.equal(pool.calls[0].params[1], false);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
await test('a repeated ?version= (parsed as an array by the query string) is flattened before it binds — never a driver-level 500', async () => {
|
|
106
|
+
const pool = fakePool();
|
|
107
|
+
await goalGraphRows({ versionId: ['V1', 'V2'] }, { pool });
|
|
108
|
+
assert.equal(pool.calls[0].params[0], 'V1');
|
|
109
|
+
assert.equal(pool.calls[1].params[0], 'V1');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
await test('the edge query keeps only task→task dependencies that CROSS a goal boundary', async () => {
|
|
113
|
+
const pool = fakePool();
|
|
114
|
+
await goalGraphRows({}, { pool });
|
|
115
|
+
const sql = pool.calls[1].sql;
|
|
116
|
+
assert.match(sql, /d\.from_kind = 'task'/);
|
|
117
|
+
assert.match(sql, /d\.to_kind\s+= 'task'/);
|
|
118
|
+
assert.match(sql, /ft\.goal_id <> tt\.goal_id/, 'a dependency inside one goal belongs to the per-goal forest, not this map');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
await test('NO TASK IDENTITY leaves either query — nothing selects a task id or a task title', async () => {
|
|
122
|
+
const pool = fakePool();
|
|
123
|
+
await goalGraphRows({}, { pool });
|
|
124
|
+
for (const { sql } of pool.calls) {
|
|
125
|
+
const select = sql.slice(sql.lastIndexOf('SELECT'), sql.indexOf('FROM', sql.lastIndexOf('SELECT')));
|
|
126
|
+
assert.equal(/\bt\.(id|title)\b|\b(ft|tt)\.(id|title)\b/.test(select), false, `a task column is selected: ${select}`);
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
await test('the payload is the shaped graph, categories included, from one fake read', async () => {
|
|
131
|
+
const pool = fakePool([{ goal_id: '1', title: 'A', status: 'open', version_id: 'V1', category_id: 7, visible: true, task_total: 3, task_done: 3 },
|
|
132
|
+
{ goal_id: '2', title: 'B', status: 'open', version_id: 'V1', category_id: null, visible: true, task_total: 2, task_done: 0 }],
|
|
133
|
+
[{ from_goal_id: '2', to_goal_id: '1', count: 5 }]);
|
|
134
|
+
const out = await goalGraph({ viewerId: 1 }, { pool, categoryIndex: async () => CATS });
|
|
135
|
+
assert.equal(out.nodes[0].category.label, 'Security');
|
|
136
|
+
assert.deepEqual(out.edges, [{ from: '2', to: '1', count: 5 }]);
|
|
137
|
+
assert.equal(out.counts.connected, 2);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// ---- the handler ---------------------------------------------------------------
|
|
141
|
+
|
|
142
|
+
function run(handler, req) {
|
|
143
|
+
const res = { body: null, failed: null, json(b) { this.body = b; return this; }, fail(code, o) { this.failed = { code, o }; return this; } };
|
|
144
|
+
return handler(req, res).then(() => res);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
await test('the handler reads identity from the authenticated request, never the query', async () => {
|
|
148
|
+
let seen = null;
|
|
149
|
+
const h = goalGraphHandler({ read: async (o) => { seen = o; return { nodes: [], edges: [], counts: {} }; }, log: { error() {} } });
|
|
150
|
+
const res = await run(h, { builder: { id: 7, rank: 'metic' }, query: { version: 'V2', viewerId: '999', viewerIsArchon: 'true' } });
|
|
151
|
+
assert.deepEqual(seen, { versionId: 'V2', viewerId: 7, viewerIsArchon: false });
|
|
152
|
+
assert.ok(res.body);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
await test('an Archon request widens visibility; no version means every version', async () => {
|
|
156
|
+
let seen = null;
|
|
157
|
+
const h = goalGraphHandler({ read: async (o) => { seen = o; return {}; }, log: { error() {} } });
|
|
158
|
+
await run(h, { builder: { id: 1, rank: 'archon' }, query: {} });
|
|
159
|
+
assert.deepEqual(seen, { versionId: null, viewerId: 1, viewerIsArchon: true });
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
await test('a failed read is a named 500, never a stack trace', async () => {
|
|
163
|
+
const errors = [];
|
|
164
|
+
const h = goalGraphHandler({ read: async () => { throw new Error('db down'); }, log: { error: (...a) => errors.push(a) } });
|
|
165
|
+
const res = await run(h, { builder: { id: 1, rank: 'metic' }, query: {} });
|
|
166
|
+
assert.equal(res.failed.code, 'graph_failed');
|
|
167
|
+
assert.equal(res.failed.o.status, 500);
|
|
168
|
+
assert.equal(errors.length, 1);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
// ---- mounting --------------------------------------------------------------------
|
|
172
|
+
|
|
173
|
+
await test('/goals/graph is mounted for any signed-in builder, BEFORE /goals/:id (or Express reads "graph" as an id)', () => {
|
|
174
|
+
const graphAt = GOALS_ROUTE.indexOf("router.get('/goals/graph'");
|
|
175
|
+
const idAt = GOALS_ROUTE.indexOf("router.get('/goals/:id'");
|
|
176
|
+
assert.ok(graphAt > -1, 'the route is registered');
|
|
177
|
+
assert.ok(graphAt < idAt, 'literal before param');
|
|
178
|
+
assert.match(GOALS_ROUTE.slice(graphAt, graphAt + 120), /auth\.requireBuilder/);
|
|
179
|
+
assert.match(GOALS_ROUTE.slice(Math.max(0, graphAt - 200), graphAt), /rank: any-builder/, 'the route-rank scanner needs its declaration');
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
await test('the module never branches on a category — it is a label here (ADR 0192)', () => {
|
|
183
|
+
const code = GRAPH_SRC.replace(/^\s*\/\/.*$/gm, '');
|
|
184
|
+
assert.equal(/\bif\s*\([^)]*category/i.test(code), false);
|
|
185
|
+
// Each WHERE runs to the next GROUP BY / ORDER BY / closing CTE paren — a
|
|
186
|
+
// category may be SELECTed for the label, and must never appear in a filter.
|
|
187
|
+
const wheres = code.split(/\bWHERE\b/).slice(1).map((s) => s.split(/GROUP BY|ORDER BY|\n\s*\)/)[0]);
|
|
188
|
+
assert.ok(wheres.length >= 2, 'sanity: the queries have WHERE clauses to check');
|
|
189
|
+
for (const w of wheres) assert.equal(/category/i.test(w), false, `a query filters on category: ${w}`);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
summary();
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
// tests/goal_map_lib.mjs — the goal map's model and layout, EXECUTED (task
|
|
2
|
+
// 1003479, goal 1000089, ADR 0227 §6 item 3 + the §10.4 amendment).
|
|
3
|
+
//
|
|
4
|
+
// goal-map-lib.js is pure, so this runs the real code over the real harness
|
|
5
|
+
// fixture and over a synthetic corpus built to the SHAPE the ADR measured on the
|
|
6
|
+
// live database (70 goals, 34 of them with no edge, the rest in one large cluster
|
|
7
|
+
// and a scatter of small ones). What is pinned is what a reader would otherwise
|
|
8
|
+
// have to trust the picture about: that no goal is drawn twice or on top of
|
|
9
|
+
// another, that a goal always sits to the right of what it waits on, that the
|
|
10
|
+
// stand-alone goals never reach the canvas, that the same data always gives the
|
|
11
|
+
// same picture, and that every fact the picture carries is also a table cell.
|
|
12
|
+
//
|
|
13
|
+
// Run: node tests/goal_map_lib.mjs
|
|
14
|
+
import assert from 'node:assert/strict';
|
|
15
|
+
import fs from 'node:fs';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
import { fileURLToPath } from 'node:url';
|
|
18
|
+
import { createRequire } from 'node:module';
|
|
19
|
+
import { makeRunner } from './helpers.mjs';
|
|
20
|
+
|
|
21
|
+
const require = createRequire(import.meta.url);
|
|
22
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
23
|
+
const LIB_PATH = path.join(ROOT, 'modules/hall-ui/public/goal-map-lib.js');
|
|
24
|
+
const L = require(LIB_PATH);
|
|
25
|
+
const FIXTURE = JSON.parse(fs.readFileSync(path.join(ROOT, 'scripts/hall-preview/fixtures/goals__graph.json'), 'utf8'));
|
|
26
|
+
|
|
27
|
+
const { test, summary } = makeRunner();
|
|
28
|
+
|
|
29
|
+
const node = (id, extra = {}) => ({ goal_id: String(id), title: `Goal ${id}`, status: 'open', version_id: 'V1', category: null, task_total: 5, task_done: 2, ...extra });
|
|
30
|
+
const edge = (from, to, count = 1) => ({ from: String(from), to: String(to), count });
|
|
31
|
+
|
|
32
|
+
function overlaps(a, b) {
|
|
33
|
+
return a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h;
|
|
34
|
+
}
|
|
35
|
+
function assertSound(a, lay, label) {
|
|
36
|
+
const ids = lay.nodes.map((n) => n.id);
|
|
37
|
+
assert.equal(new Set(ids).size, ids.length, `${label}: no goal is drawn twice`);
|
|
38
|
+
assert.equal(ids.length, a.connected, `${label}: exactly the connected goals are drawn`);
|
|
39
|
+
for (let i = 0; i < lay.nodes.length; i++) {
|
|
40
|
+
for (let j = i + 1; j < lay.nodes.length; j++) {
|
|
41
|
+
assert.equal(overlaps(lay.nodes[i], lay.nodes[j]), false, `${label}: ${lay.nodes[i].id} overlaps ${lay.nodes[j].id}`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
for (const n of lay.nodes) {
|
|
45
|
+
assert.ok(n.x >= 0 && n.y >= 0, `${label}: ${n.id} is on the canvas`);
|
|
46
|
+
assert.ok(n.x + n.w <= lay.width && n.y + n.h <= lay.height, `${label}: ${n.id} fits inside the svg's own size`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// A deterministic synthetic corpus with the measured shape: `clusterSizes` goals
|
|
51
|
+
// wired into connected clusters (each a chain plus a few extra edges) and `alone`
|
|
52
|
+
// goals with no edge. A tiny LCG, so the test is the same on every machine.
|
|
53
|
+
function synth(clusterSizes, alone) {
|
|
54
|
+
let seed = 20260919;
|
|
55
|
+
const rnd = () => { seed = (seed * 1664525 + 1013904223) % 4294967296; return seed / 4294967296; };
|
|
56
|
+
const nodes = []; const edges = []; let id = 1000;
|
|
57
|
+
for (const size of clusterSizes) {
|
|
58
|
+
const ids = [];
|
|
59
|
+
for (let i = 0; i < size; i++) { ids.push(++id); nodes.push(node(id, { category: rnd() < 0.5 ? { id: '1', slug: 'security', label: 'Security' } : null })); }
|
|
60
|
+
for (let i = 1; i < size; i++) edges.push(edge(ids[i], ids[Math.floor(rnd() * i)], 1 + Math.floor(rnd() * 4)));
|
|
61
|
+
for (let k = 0; k < Math.floor(size / 3); k++) {
|
|
62
|
+
const a = ids[Math.floor(rnd() * size)]; const b = ids[Math.floor(rnd() * size)];
|
|
63
|
+
if (a !== b && !edges.some((e) => e.from === String(a) && e.to === String(b))) edges.push(edge(a, b));
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
for (let i = 0; i < alone; i++) nodes.push(node(++id));
|
|
67
|
+
return { nodes, edges };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ---- structure -----------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
await test('the goals split into connected and stand-alone, and the two add up', () => {
|
|
73
|
+
const a = L.analyse({ nodes: [node(1), node(2), node(3), node(4)], edges: [edge(1, 2)] });
|
|
74
|
+
assert.equal(a.connected, 2);
|
|
75
|
+
assert.deepEqual(a.isolated, ['3', '4']);
|
|
76
|
+
assert.equal(a.connected + a.isolated.length, a.order.length);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
await test('clusters are found, largest first, and every connected goal knows its cluster', () => {
|
|
80
|
+
const a = L.analyse({ nodes: [1, 2, 3, 4, 5, 6, 7].map((i) => node(i)), edges: [edge(1, 2), edge(3, 4), edge(4, 5), edge(5, 6)] });
|
|
81
|
+
assert.deepEqual(a.clusters.map((c) => c.length), [4, 2]);
|
|
82
|
+
assert.equal(a.clusterOf['3'].index, 1);
|
|
83
|
+
assert.equal(a.clusterOf['3'].size, 4);
|
|
84
|
+
assert.equal(a.clusterOf['1'].index, 2);
|
|
85
|
+
assert.equal(a.clusterOf['7'], undefined, 'a stand-alone goal belongs to no cluster');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
await test('an edge to an unknown goal, a self edge and a duplicate are ignored, not drawn to nowhere', () => {
|
|
89
|
+
const a = L.analyse({ nodes: [node(1), node(2)], edges: [edge(1, 99), edge(2, 2), edge(1, 2, 3), edge(1, 2, 3)] });
|
|
90
|
+
assert.equal(a.edges.length, 1);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// ---- layout --------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
await test('a goal sits to the RIGHT of every goal it waits on (dependencies read left to right)', () => {
|
|
96
|
+
const a = L.analyse({ nodes: [1, 2, 3, 4].map((i) => node(i)), edges: [edge(2, 1), edge(3, 2), edge(4, 2)] });
|
|
97
|
+
const lay = L.layout(a);
|
|
98
|
+
const x = Object.fromEntries(lay.nodes.map((n) => [n.id, n.x]));
|
|
99
|
+
for (const e of a.edges) assert.ok(x[e.to] < x[e.from], `${e.from} waits on ${e.to}, so must be right of it`);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
await test('stand-alone goals are never drawn — they are a list, not dots on an empty canvas', () => {
|
|
103
|
+
const a = L.analyse({ nodes: [node(1), node(2), node(3)], edges: [edge(1, 2)] });
|
|
104
|
+
const lay = L.layout(a);
|
|
105
|
+
assert.deepEqual(lay.nodes.map((n) => n.id).sort(), ['1', '2']);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
await test('a loop (two goals each waiting on the other) terminates and places both', () => {
|
|
109
|
+
const a = L.analyse({ nodes: [node(1), node(2), node(3)], edges: [edge(1, 2), edge(2, 1), edge(3, 1)] });
|
|
110
|
+
const lay = L.layout(a);
|
|
111
|
+
assert.equal(lay.nodes.length, 3);
|
|
112
|
+
assertSound(a, lay, 'loop');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
await test('the same data always gives the same picture (no random seed, no drift between visits)', () => {
|
|
116
|
+
const p = synth([9, 4, 3], 6);
|
|
117
|
+
assert.deepEqual(L.layout(L.analyse(p)), L.layout(L.analyse(p)));
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
await test('every edge path starts at its dependency and ends at its dependent', () => {
|
|
121
|
+
const a = L.analyse({ nodes: [node(1), node(2)], edges: [edge(2, 1, 4)] });
|
|
122
|
+
const lay = L.layout(a);
|
|
123
|
+
const by = Object.fromEntries(lay.nodes.map((n) => [n.id, n]));
|
|
124
|
+
const [e] = lay.edges;
|
|
125
|
+
assert.ok(e.d.startsWith(`M${by['1'].x + lay.nodeW} ${by['1'].y + lay.nodeH / 2}`), 'leaves the dependency’s right edge');
|
|
126
|
+
assert.ok(e.d.endsWith(`${by['2'].x} ${by['2'].y + lay.nodeH / 2}`), 'arrives at the dependent’s left edge');
|
|
127
|
+
assert.ok(e.width >= 1 && e.width <= 3);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
await test('the harness fixture lays out soundly — one large cluster, pairs, a loop and a private goal', () => {
|
|
131
|
+
const a = L.analyse(FIXTURE);
|
|
132
|
+
const lay = L.layout(a);
|
|
133
|
+
assertSound(a, lay, 'fixture');
|
|
134
|
+
assert.ok(a.clusters.length >= 4 && a.clusters[0].length > a.clusters[1].length, 'reads as one cluster and a scatter');
|
|
135
|
+
assert.ok(a.isolated.length > 0);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
await test('THE SCALE CHECK: the measured shape (70 goals, 34 alone, one big cluster) lays out soundly and fast', () => {
|
|
139
|
+
const p = synth([17, 6, 4, 3, 2, 2, 2], 34); // 36 connected + 34 alone = the measured 70
|
|
140
|
+
assert.equal(p.nodes.length, 70);
|
|
141
|
+
const a = L.analyse(p);
|
|
142
|
+
assert.equal(a.isolated.length, 34);
|
|
143
|
+
const t0 = Date.now();
|
|
144
|
+
const lay = L.layout(a);
|
|
145
|
+
const ms = Date.now() - t0;
|
|
146
|
+
assertSound(a, lay, 'scale');
|
|
147
|
+
assert.ok(ms < 500, `laid out in ${ms}ms`);
|
|
148
|
+
// The page scrolls the svg INSIDE its own box, so a wide picture is fine — but
|
|
149
|
+
// it must not be absurd: the largest cluster is a handful of columns, not a line.
|
|
150
|
+
assert.ok(lay.width < 3200, `width ${lay.width}`);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// ---- categories and the table ---------------------------------------------------
|
|
154
|
+
|
|
155
|
+
await test('a category keeps its colour by alphabetical order of the labels present, and repeats past eight', () => {
|
|
156
|
+
const cats = ['Zeta', 'Alpha', 'Mid'].map((label, i) => ({ id: String(i), slug: label.toLowerCase(), label }));
|
|
157
|
+
const a = L.analyse({ nodes: [node(1, { category: cats[0] }), node(2, { category: cats[1] }), node(3, { category: cats[2] }), node(4)], edges: [] });
|
|
158
|
+
const c = L.categories(a);
|
|
159
|
+
assert.deepEqual(c.list.map((x) => x.label), ['Alpha', 'Mid', 'Zeta']);
|
|
160
|
+
assert.deepEqual(c.list.map((x) => x.colour), [0, 1, 2]);
|
|
161
|
+
assert.equal(c.uncategorised, 1);
|
|
162
|
+
assert.equal(L.colourOf(a.byId['4'], c), 'none', 'uncategorised is legal and wears no colour');
|
|
163
|
+
const many = Array.from({ length: 10 }, (_, i) => ({ id: String(i), slug: `c${String(i).padStart(2, '0')}`, label: `C${String(i).padStart(2, '0')}` }));
|
|
164
|
+
const big = L.categories(L.analyse({ nodes: many.map((m, i) => node(i + 1, { category: m })), edges: [] }));
|
|
165
|
+
assert.equal(big.list[8].colour, 0, 'the ninth wraps — the WORD on the node still says which it is');
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
await test('the table has one row per goal and carries every fact the picture does, both directions by name', () => {
|
|
169
|
+
const a = L.analyse({ nodes: [node(1, { title: 'Base', task_total: 9, task_done: 4, category: { id: '1', slug: 's', label: 'Security' } }), node(2, { title: 'Top' }), node(3, { title: 'Alone' })], edges: [edge(2, 1, 6)] });
|
|
170
|
+
const rows = L.tableRows(a);
|
|
171
|
+
assert.equal(rows.length, 3, 'stand-alone goals are rows too — the table is the whole map');
|
|
172
|
+
const base = rows.find((r) => r.id === '1');
|
|
173
|
+
const top = rows.find((r) => r.id === '2');
|
|
174
|
+
assert.equal(base.category, 'Security');
|
|
175
|
+
assert.equal(base.done + '/' + base.total, '4/9');
|
|
176
|
+
assert.deepEqual(top.waits, [{ id: '1', title: 'Base', count: 6 }]);
|
|
177
|
+
assert.deepEqual(base.needed, [{ id: '2', title: 'Top', count: 6 }]);
|
|
178
|
+
assert.equal(top.clusterLabel, 'Cluster 1 · 2 goals');
|
|
179
|
+
assert.equal(rows.find((r) => r.id === '3').clusterLabel, 'Stands alone');
|
|
180
|
+
assert.equal(rows[rows.length - 1].id, '3', 'stand-alone goals sort last');
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
await test('a private goal is named as one, in the table and in a neighbour’s dependency list', () => {
|
|
184
|
+
const a = L.analyse({ nodes: [node(1, { title: null }), node(2)], edges: [edge(2, 1)] });
|
|
185
|
+
const rows = L.tableRows(a);
|
|
186
|
+
assert.equal(rows.find((r) => r.id === '1').title, 'A private goal');
|
|
187
|
+
assert.equal(rows.find((r) => r.id === '1').isPrivate, true);
|
|
188
|
+
assert.equal(rows.find((r) => r.id === '2').waits[0].title, 'A private goal');
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
await test('the split is STATED, with the measured numbers only', () => {
|
|
192
|
+
const a = L.analyse({ nodes: [1, 2, 3, 4, 5].map((i) => node(i)), edges: [edge(1, 2), edge(2, 3)] });
|
|
193
|
+
const d = L.describe(a);
|
|
194
|
+
assert.match(d.summary, /5 goals carry work/);
|
|
195
|
+
assert.match(d.summary, /3 of them are tied to another goal/);
|
|
196
|
+
assert.match(d.summary, /the other 2 stand alone/);
|
|
197
|
+
assert.match(d.summary, /form 1 cluster, the largest with 3 goals/);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
await test('no edges at all is said plainly and the table is called the whole map (ADR 0227 §3: the right amount of map may be none)', () => {
|
|
201
|
+
const d = L.describe(L.analyse({ nodes: [node(1), node(2)], edges: [] }));
|
|
202
|
+
assert.match(d.graph, /no graph to draw — the table below is the whole map/);
|
|
203
|
+
assert.match(L.describe(L.analyse({ nodes: [], edges: [] })).summary, /No goal carries any work/);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
// ---- purity -----------------------------------------------------------------------
|
|
207
|
+
|
|
208
|
+
await test('the lib is pure and cannot reach a task: no DOM, no fetch, no task input', () => {
|
|
209
|
+
const src = fs.readFileSync(LIB_PATH, 'utf8').replace(/^\s*\/\/.*$/gm, '');
|
|
210
|
+
assert.equal(/\bdocument\b|\bfetch\b|\bwindow\b|XMLHttpRequest/.test(src.replace(/root\.OTBGoalMapLib/, '')), false);
|
|
211
|
+
assert.equal(/task_id|\/tasks\b|\.tasks\b/.test(src), false, 'view A/D need task rows; this file has nowhere to put one (ADR 0227 §7)');
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
summary();
|