@bongos/core 1.19.375 → 1.19.376
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 +47 -32
- 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 +5 -1
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/api/openapi.json +76 -5
- package/docs/api-reference.md +4 -3
- package/docs/copy-inventory.md +128 -127
- package/docs/copy-registry.json +175 -156
- package/migrations/core_227_task_created_by.sql +83 -0
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +2 -0
- package/modules/hall-ui/public/goals-lib.js +5 -5
- package/modules/hall-ui/public/goals-page.js +54 -23
- package/modules/hall-ui/public/goals-render.js +15 -0
- package/modules/hall-ui/public/task-lib.js +29 -0
- package/modules/hall-ui/public/work.js +117 -5
- package/modules/ideas/inbox.js +4 -0
- package/modules/lifecycle/db-tasks.js +61 -5
- package/modules/lifecycle/goal-rollup.js +118 -0
- package/modules/lifecycle/routes/goals.js +26 -0
- package/modules/lifecycle/routes/task-write-routes.js +3 -0
- package/modules/lifecycle/routes/tasks.js +47 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +3 -1
- package/tests/goal_sort_staleness.mjs +286 -0
- package/tests/goal_subtitle.mjs +6 -1
- package/tests/goals_lib.mjs +16 -4
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// modules/lifecycle/goal-rollup.js — per-goal task rollups, computed in Postgres
|
|
4
|
+
// instead of in the browser (task 1003278, goal 1000072, criterion
|
|
5
|
+
// legibility-sort-and-staleness).
|
|
6
|
+
//
|
|
7
|
+
// THE MEASUREMENT THAT FORCED THIS. The Goals page derived each goal's readiness
|
|
8
|
+
// counters, its last-activity timestamp and (task 1003278) its craft set by
|
|
9
|
+
// FETCHING EVERY TASK IN THE VERSION — `GET /tasks?version=BONGOS-V1&include=deps`
|
|
10
|
+
// — and reducing them client-side. On the real corpus that request is
|
|
11
|
+
// **5,229,455 bytes for ~1,400 tasks**, measured 2026-08-25, to produce roughly
|
|
12
|
+
// forty small counter objects. The criterion's own words: "~1,357 tasks on
|
|
13
|
+
// BONGOS-V1 alone. A view that does not survive that number is not a solution."
|
|
14
|
+
// It did not survive it; it merely did not fall over.
|
|
15
|
+
//
|
|
16
|
+
// So the reduction moves to where the rows already are. One query per version
|
|
17
|
+
// returns one row per goal, and the payload stops scaling with the task count.
|
|
18
|
+
//
|
|
19
|
+
// THE BUCKETS ARE THE CLIENT'S, VERBATIM. goals-lib.js `readinessOf` is the
|
|
20
|
+
// definition of record and this SQL mirrors it exactly — done (shipped |
|
|
21
|
+
// confirmed | completed) · cut (abandoned | dropped, excluded from the total the
|
|
22
|
+
// way migration 017 keeps them out of version_progress) · active · ready ·
|
|
23
|
+
// blocked (backlog with >=1 unshipped dependency) · backlog. A divergence here
|
|
24
|
+
// would show a person different numbers depending on which surface they opened,
|
|
25
|
+
// so tests/goal_sort_staleness.mjs pins the two against each other.
|
|
26
|
+
//
|
|
27
|
+
// LAST ACTIVITY is `max(updated_at, shipped_at)` — the same "any task activity
|
|
28
|
+
// counts" rule the client uses (task 1002965, the owner's call), now expressed
|
|
29
|
+
// once in SQL and once in task-lib.js because the browser still needs it for
|
|
30
|
+
// rows it holds itself.
|
|
31
|
+
|
|
32
|
+
const api = require('../../src/module-api');
|
|
33
|
+
const { pool } = api;
|
|
34
|
+
|
|
35
|
+
// The status vocabularies, kept beside the SQL that uses them so a reader can
|
|
36
|
+
// check them against goals-lib.js in one glance.
|
|
37
|
+
const DONE_STATUSES = ['shipped', 'confirmed', 'completed'];
|
|
38
|
+
const CUT_STATUSES = ['abandoned', 'dropped'];
|
|
39
|
+
|
|
40
|
+
// goalRollups — one row per goal that has at least one task in `versionId`.
|
|
41
|
+
// Returns [{ goal_id, done, active, ready, blocked, backlog, cut, total,
|
|
42
|
+
// last_activity_at, disciplines[], next_ready_id, next_ready_title }].
|
|
43
|
+
//
|
|
44
|
+
// `blocked` needs the dependency edges, so the CTE resolves each backlog task's
|
|
45
|
+
// unshipped-dependency count once rather than per row. deps.pool honors the #538
|
|
46
|
+
// test seam (NODE_ENV==='test' only), like every other helper in this module.
|
|
47
|
+
// VISIBILITY. A rollup is a COUNT, and counts on a private goal are not the
|
|
48
|
+
// secret — its prose is (ADR 0112's leak rule: a non-member must not learn a
|
|
49
|
+
// private goal's title or scope). But `next_ready_title` is a real task title
|
|
50
|
+
// off a real goal, so this returns it ONLY to a member or an Archon. Everyone
|
|
51
|
+
// else gets the counters and a null title: the board still renders the ring and
|
|
52
|
+
// the readiness line, and the "Claim next" affordance simply has no name to show
|
|
53
|
+
// — which is correct, because they cannot claim it either (task 1003137's
|
|
54
|
+
// membership gate). Raised in review; the pre-existing GET /goals listing has
|
|
55
|
+
// the same unfiltered shape, and tightening THAT is its own task, not something
|
|
56
|
+
// to smuggle in here.
|
|
57
|
+
async function goalRollups({ versionId = null, viewerId = null, viewerIsArchon = false } = {}, deps = {}) {
|
|
58
|
+
const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
|
|
59
|
+
const { rows } = await activePool.query(
|
|
60
|
+
`WITH scoped AS (
|
|
61
|
+
SELECT t.id, t.goal_id, t.status, t.title, t.priority, t.discipline,
|
|
62
|
+
GREATEST(COALESCE(t.updated_at, 'epoch'::timestamptz),
|
|
63
|
+
COALESCE(t.shipped_at, 'epoch'::timestamptz)) AS activity_at,
|
|
64
|
+
EXISTS (
|
|
65
|
+
SELECT 1 FROM dependencies d
|
|
66
|
+
JOIN tasks dep ON dep.id = d.to_id
|
|
67
|
+
WHERE d.from_kind = 'task' AND d.from_id = t.id
|
|
68
|
+
AND d.to_kind = 'task' AND dep.status <> 'shipped'
|
|
69
|
+
) AS has_unshipped_dep
|
|
70
|
+
FROM tasks t
|
|
71
|
+
WHERE t.goal_id IS NOT NULL
|
|
72
|
+
AND ($1::text IS NULL OR t.version_id = $1)
|
|
73
|
+
), bucketed AS (
|
|
74
|
+
SELECT s.*,
|
|
75
|
+
CASE
|
|
76
|
+
WHEN s.status = ANY($2::text[]) THEN 'done'
|
|
77
|
+
WHEN s.status = ANY($3::text[]) THEN 'cut'
|
|
78
|
+
WHEN s.status = 'active' THEN 'active'
|
|
79
|
+
WHEN s.status = 'ready' THEN 'ready'
|
|
80
|
+
WHEN s.status = 'backlog' AND s.has_unshipped_dep THEN 'blocked'
|
|
81
|
+
ELSE 'backlog'
|
|
82
|
+
END AS bucket
|
|
83
|
+
FROM scoped s
|
|
84
|
+
), next_ready AS (
|
|
85
|
+
-- The goal's next claimable task, picked the way the board orders work:
|
|
86
|
+
-- priority first (NULLS LAST), then oldest id. DISTINCT ON gives one row
|
|
87
|
+
-- per goal without a second round trip.
|
|
88
|
+
SELECT DISTINCT ON (goal_id) goal_id, id, title
|
|
89
|
+
FROM bucketed
|
|
90
|
+
WHERE bucket = 'ready'
|
|
91
|
+
ORDER BY goal_id, priority NULLS LAST, id
|
|
92
|
+
)
|
|
93
|
+
SELECT b.goal_id::text AS goal_id,
|
|
94
|
+
COUNT(*) FILTER (WHERE b.bucket = 'done')::int AS done,
|
|
95
|
+
COUNT(*) FILTER (WHERE b.bucket = 'active')::int AS active,
|
|
96
|
+
COUNT(*) FILTER (WHERE b.bucket = 'ready')::int AS ready,
|
|
97
|
+
COUNT(*) FILTER (WHERE b.bucket = 'blocked')::int AS blocked,
|
|
98
|
+
COUNT(*) FILTER (WHERE b.bucket = 'backlog')::int AS backlog,
|
|
99
|
+
COUNT(*) FILTER (WHERE b.bucket = 'cut')::int AS cut,
|
|
100
|
+
COUNT(*) FILTER (WHERE b.bucket <> 'cut')::int AS total,
|
|
101
|
+
MAX(b.activity_at) AS last_activity_at,
|
|
102
|
+
ARRAY_AGG(DISTINCT COALESCE(b.discipline, 'unclassified')) AS disciplines,
|
|
103
|
+
MAX(nr.id)::text AS next_ready_id,
|
|
104
|
+
CASE WHEN bool_or(g.visibility IS DISTINCT FROM 'private')
|
|
105
|
+
OR $4::boolean
|
|
106
|
+
OR bool_or(gm.builder_id IS NOT NULL)
|
|
107
|
+
THEN MAX(nr.title) END AS next_ready_title
|
|
108
|
+
FROM bucketed b
|
|
109
|
+
JOIN goals g ON g.id = b.goal_id
|
|
110
|
+
LEFT JOIN next_ready nr ON nr.goal_id = b.goal_id
|
|
111
|
+
LEFT JOIN goal_members gm ON gm.goal_id = b.goal_id AND gm.builder_id = $5::bigint
|
|
112
|
+
GROUP BY b.goal_id`,
|
|
113
|
+
[versionId, DONE_STATUSES, CUT_STATUSES, viewerIsArchon === true, viewerId]
|
|
114
|
+
);
|
|
115
|
+
return rows;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
module.exports = { goalRollups, DONE_STATUSES, CUT_STATUSES };
|
|
@@ -29,6 +29,7 @@ const hierarchy = require('../hierarchy');
|
|
|
29
29
|
const goalSuggest = require('../goal-suggest.js'); // task 1003062 — pre-creation goal suggester
|
|
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
|
+
const goalRollup = require('../goal-rollup.js'); // task 1003278 — per-goal rollups computed in SQL, not in the browser
|
|
32
33
|
const { suggestCriteria } = require('../criterion-suggest');
|
|
33
34
|
const { classifyTaskKind, VALID_KINDS, VALID_DISCIPLINES, TASK_FIELD_BOUNDS } = require('../task-classifier');
|
|
34
35
|
const { validateOrRespond, parseId, LIMITS } = api;
|
|
@@ -145,6 +146,29 @@ module.exports = function buildGoalsRouter() {
|
|
|
145
146
|
}
|
|
146
147
|
});
|
|
147
148
|
|
|
149
|
+
// GET /goals/rollups?version= — one small row per goal: readiness counters, the
|
|
150
|
+
// last-activity timestamp, the craft set, and the next claimable task
|
|
151
|
+
// (task 1003278). It replaces the Goals page's whole-version task fetch, which
|
|
152
|
+
// measured 5,229,455 bytes for ~1,400 tasks on 2026-08-25 to produce ~40 counter
|
|
153
|
+
// objects. Registered BEFORE /goals/:id or Express routes 'rollups' to the param.
|
|
154
|
+
// rank: any-builder — the same read as the goal board it feeds.
|
|
155
|
+
router.get('/goals/rollups', auth.requireBuilder, async (req, res) => {
|
|
156
|
+
try {
|
|
157
|
+
// The viewer decides whether a PRIVATE goal's next-ready task TITLE comes
|
|
158
|
+
// back (counters always do — a count is not the secret, the prose is;
|
|
159
|
+
// ADR 0112). Identity from the authenticated request, never the query.
|
|
160
|
+
const rollups = await goalRollup.goalRollups({
|
|
161
|
+
versionId: req.query.version || null,
|
|
162
|
+
viewerId: req.builder.id,
|
|
163
|
+
viewerIsArchon: req.builder.rank === 'archon',
|
|
164
|
+
});
|
|
165
|
+
return res.json({ rollups });
|
|
166
|
+
} catch (err) {
|
|
167
|
+
log.error('[gds] GET /goals/rollups', err);
|
|
168
|
+
return res.fail('rollups_failed', { status: 500, message: 'internal error' });
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
|
|
148
172
|
// GET /goals?version=&status= — list goals (read).
|
|
149
173
|
// rank: any-builder — viewing the goal board.
|
|
150
174
|
router.get('/goals', auth.requireBuilder, async (req, res) => {
|
|
@@ -1150,6 +1174,8 @@ module.exports = function buildGoalsRouter() {
|
|
|
1150
1174
|
discipline,
|
|
1151
1175
|
source: 'goal-authored',
|
|
1152
1176
|
goalId: goal.id,
|
|
1177
|
+
// task 1003278 — attribution from the authenticated request (never the body).
|
|
1178
|
+
createdBy: req.builder.id,
|
|
1153
1179
|
});
|
|
1154
1180
|
|
|
1155
1181
|
// Attach the sub-task parent (validated above; attachParent re-checks the
|
|
@@ -195,6 +195,9 @@ module.exports = function registerTaskWriteRoutes(router) {
|
|
|
195
195
|
requiresRank: body.requires_rank ?? null,
|
|
196
196
|
moduleKey: body.module_key ?? null,
|
|
197
197
|
goalId: body.goal_id ?? null,
|
|
198
|
+
// task 1003278 — attribution, from the AUTHENTICATED request, never the
|
|
199
|
+
// body: who created a task is a fact about the caller, not an input.
|
|
200
|
+
createdBy: req.builder.id,
|
|
198
201
|
});
|
|
199
202
|
// task 438: link resolved criteria (source='planning' — task-create is the
|
|
200
203
|
// planning vector). The ids were version-checked above, so this is a single
|
|
@@ -71,6 +71,25 @@ module.exports = function buildTasksRouter() {
|
|
|
71
71
|
const router = express.Router();
|
|
72
72
|
|
|
73
73
|
// rank: any-builder — reads (filtered task list); preserved per CRITICAL §4.
|
|
74
|
+
// task 1003278 — shared by GET /tasks and GET /tasks/claimable, so the two
|
|
75
|
+
// surfaces cannot drift on what a valid creator id or age window is. Returns
|
|
76
|
+
// undefined (absent), a number, or { bad } for the caller to turn into a 400.
|
|
77
|
+
const posInt = (raw, code, max) => {
|
|
78
|
+
if (raw === undefined || raw === '') return undefined;
|
|
79
|
+
const n = Number(raw);
|
|
80
|
+
if (!Number.isInteger(n) || n <= 0 || (max && n > max)) return { bad: code };
|
|
81
|
+
return n;
|
|
82
|
+
};
|
|
83
|
+
// 'none' is a REAL value here — the unattributed set ("what has no owner"), not
|
|
84
|
+
// a missing filter. Anything else must be a positive builder id.
|
|
85
|
+
function parseCreatedBy(raw) {
|
|
86
|
+
if (raw === undefined || raw === '') return undefined;
|
|
87
|
+
if (raw === 'none') return 'none';
|
|
88
|
+
const n = Number(raw);
|
|
89
|
+
return (Number.isInteger(n) && n > 0) ? n : { bad: 'bad_created_by' };
|
|
90
|
+
}
|
|
91
|
+
const CREATED_BY_MSG = "created_by must be a positive builder id, or 'none' for the unattributed.";
|
|
92
|
+
|
|
74
93
|
router.get('/tasks', auth.requireBuilder, async (req, res) => {
|
|
75
94
|
const { version, status, kind, discipline, goal_id } = req.query;
|
|
76
95
|
if (kind && !VALID_KINDS.has(kind)) {
|
|
@@ -88,7 +107,25 @@ module.exports = function buildTasksRouter() {
|
|
|
88
107
|
return res.fail('bad_goal_id', { status: 400, message: 'goal_id must be a positive integer.' });
|
|
89
108
|
}
|
|
90
109
|
}
|
|
91
|
-
|
|
110
|
+
// task 1003278 — created_by / stale_days / limit / offset, so a board can ask
|
|
111
|
+
// the server a narrow question instead of reducing the whole table in the
|
|
112
|
+
// browser. All four are optional; omitting them is exactly today's behaviour.
|
|
113
|
+
const { created_by: createdByRaw, stale_days: staleRaw, limit: limitRaw, offset: offsetRaw } = req.query;
|
|
114
|
+
const createdBy = parseCreatedBy(createdByRaw);
|
|
115
|
+
if (createdBy && createdBy.bad) return res.fail('bad_created_by', { status: 400, message: CREATED_BY_MSG });
|
|
116
|
+
const staleDays = posInt(staleRaw, 'bad_stale_days', 3650);
|
|
117
|
+
if (staleDays && staleDays.bad) return res.fail('bad_stale_days', { status: 400, message: 'stale_days must be a positive integer number of days.' });
|
|
118
|
+
// The cap is the point of the parameter: an unbounded limit would be the
|
|
119
|
+
// whole-table read this axis exists to stop.
|
|
120
|
+
const limit = posInt(limitRaw, 'bad_limit', 1000);
|
|
121
|
+
if (limit && limit.bad) return res.fail('bad_limit', { status: 400, message: 'limit must be a positive integer up to 1000.' });
|
|
122
|
+
const offset = posInt(offsetRaw, 'bad_offset');
|
|
123
|
+
if (offset && offset.bad) return res.fail('bad_offset', { status: 400, message: 'offset must be a positive integer.' });
|
|
124
|
+
|
|
125
|
+
const tasks = await db.listTasks({
|
|
126
|
+
versionId: version, status, kind, discipline, goalId,
|
|
127
|
+
createdBy, staleDays, limit, offset,
|
|
128
|
+
});
|
|
92
129
|
// ?include=deps (hall redesign task 1002439) — enrich each task with its
|
|
93
130
|
// dependency summary (`blocked_by[]` with per-target satisfied state, and a
|
|
94
131
|
// `blocks` count) so the Goals rollup + Tasks dependency view can paint
|
|
@@ -117,12 +154,21 @@ module.exports = function buildTasksRouter() {
|
|
|
117
154
|
if (fit && !VALID_FITS.has(fit)) {
|
|
118
155
|
return res.fail('bad_fit', 400, { valid: Array.from(VALID_FITS) });
|
|
119
156
|
}
|
|
157
|
+
// task 1003278 — the creator + staleness axes, so the Work Board narrows the
|
|
158
|
+
// feed at the SOURCE instead of downloading it and reducing it in the browser.
|
|
159
|
+
// Same validators as GET /tasks: one vocabulary, two surfaces.
|
|
160
|
+
const claimCreatedBy = parseCreatedBy(req.query.created_by);
|
|
161
|
+
if (claimCreatedBy && claimCreatedBy.bad) return res.fail('bad_created_by', { status: 400, message: CREATED_BY_MSG });
|
|
162
|
+
const claimStaleDays = posInt(req.query.stale_days, 'bad_stale_days', 3650);
|
|
163
|
+
if (claimStaleDays && claimStaleDays.bad) return res.fail('bad_stale_days', { status: 400, message: 'stale_days must be a positive integer number of days.' });
|
|
120
164
|
const [claimable, active] = await Promise.all([
|
|
121
165
|
db.listClaimableTasks({
|
|
122
166
|
versionId: version || null,
|
|
123
167
|
builderId: req.builder.id,
|
|
124
168
|
discipline: discipline || null,
|
|
125
169
|
fit: fit || null,
|
|
170
|
+
createdBy: claimCreatedBy ?? null,
|
|
171
|
+
staleDays: claimStaleDays ?? null,
|
|
126
172
|
}),
|
|
127
173
|
db.listActiveClaims(),
|
|
128
174
|
]);
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.376",
|
|
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.376",
|
|
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.376",
|
|
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/src/module-api.js
CHANGED
|
@@ -1190,8 +1190,10 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
1190
1190
|
// ADR 0161 §2 is filed as an idea from task 1003044.
|
|
1191
1191
|
// 1.19.375 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1192
1192
|
// landed since 1.19.374 with no explicit bump. run 32909701598. (task 1002620)
|
|
1193
|
+
// 1.19.376 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1194
|
+
// landed since 1.19.375 with no explicit bump. run 32910331206. (task 1002620)
|
|
1193
1195
|
// ---------------------------------------------------------------------------
|
|
1194
|
-
const CORE_VERSION = '1.19.
|
|
1196
|
+
const CORE_VERSION = '1.19.376'; // CI auto-patch carrier (ADR 0161); see changelog above
|
|
1195
1197
|
|
|
1196
1198
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
1197
1199
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
// tests/goal_sort_staleness.mjs — find your own work, and see what is rotting
|
|
2
|
+
// (task 1003278, goal 1000072, criterion legibility-sort-and-staleness).
|
|
3
|
+
//
|
|
4
|
+
// Three halves. (1) The shared vocabulary: ONE activity clock and ONE staleness
|
|
5
|
+
// predicate in task-lib.js, consumed by both tiers — pinned by executing them.
|
|
6
|
+
// (2) Attribution: tasks.created_by is stamped from the authenticated request on
|
|
7
|
+
// every human create-vector and NEVER backfilled or body-supplied. (3) THE SCALE
|
|
8
|
+
// CHECK, which the criterion makes the acceptance test ("~1,357 tasks on
|
|
9
|
+
// BONGOS-V1 alone. A view that does not survive that number is not a solution"):
|
|
10
|
+
// the board's filter+sort pipeline is EXECUTED over a synthetic corpus of that
|
|
11
|
+
// size and measured, not asserted about.
|
|
12
|
+
//
|
|
13
|
+
// Run: node tests/goal_sort_staleness.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 read = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8');
|
|
24
|
+
const taskLib = require(path.join(ROOT, 'modules/hall-ui/public/task-lib.js'));
|
|
25
|
+
const WORK_SRC = read('modules/hall-ui/public/work.js');
|
|
26
|
+
const RENDER_SRC = read('modules/hall-ui/public/goals-render.js');
|
|
27
|
+
const GOALS_LIB_SRC = read('modules/hall-ui/public/goals-lib.js');
|
|
28
|
+
const MIGRATION = read('migrations/core_227_task_created_by.sql');
|
|
29
|
+
const DB_TASKS = read('modules/lifecycle/db-tasks.js');
|
|
30
|
+
|
|
31
|
+
const { test, summary } = makeRunner();
|
|
32
|
+
const DAY = 24 * 60 * 60 * 1000;
|
|
33
|
+
|
|
34
|
+
// ---- the shared vocabulary --------------------------------------------------
|
|
35
|
+
|
|
36
|
+
await test('one activity clock: updated_at ∨ shipped_at, whichever is later', () => {
|
|
37
|
+
const { taskActivityMs, msOf } = taskLib;
|
|
38
|
+
const t = { updated_at: '2026-08-01T00:00:00Z', shipped_at: '2026-08-20T00:00:00Z' };
|
|
39
|
+
assert.equal(taskActivityMs(t), msOf('2026-08-20T00:00:00Z'),
|
|
40
|
+
'shipped_at folded in — a ship path can stamp it without touching updated_at (task 1002965)');
|
|
41
|
+
assert.equal(taskActivityMs({}), 0, 'no readable timestamp reads as epoch — the stalest possible');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
await test('isStale: past the bucket is stale, inside it is not, and undated rows are STALE', () => {
|
|
45
|
+
const { isStale } = taskLib;
|
|
46
|
+
const now = Date.parse('2026-08-25T00:00:00Z');
|
|
47
|
+
const at = (daysAgo) => now - daysAgo * DAY;
|
|
48
|
+
assert.equal(isStale(at(31), '30', now), true);
|
|
49
|
+
assert.equal(isStale(at(29), '30', now), false);
|
|
50
|
+
assert.equal(isStale(at(29), '14', now), true);
|
|
51
|
+
assert.equal(isStale(at(5), '', now), true, "'' = no filter — everything passes");
|
|
52
|
+
assert.equal(isStale(0, '60', now), true,
|
|
53
|
+
'a row nobody can date is exactly what a rot-hunt must not hide');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
await test('goals-lib DELEGATES to task-lib — one definition, not a synced pair', () => {
|
|
57
|
+
assert.match(GOALS_LIB_SRC, /const \{ msOf, taskActivityMs \} = window\.OTBTaskLib;/,
|
|
58
|
+
'the private copies were removed; a drift here would mean two meanings of "touched"');
|
|
59
|
+
assert.equal(/Date\.parse/.test(GOALS_LIB_SRC), false, 'no residual private clock');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
await test('both tiers offer the SAME staleness buckets', () => {
|
|
63
|
+
assert.match(WORK_SRC, /\{ id: 'f-stale', label: 'Age', options: STALE_CHOICES \}/);
|
|
64
|
+
assert.match(read('modules/hall-ui/public/goals-page.js'), /\{ id: 'f-goal-stale', label: 'Age', options: window\.OTBTaskLib\.STALE_CHOICES \}/);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
await test("the goals page's stale FILTER and its 'recently active' SORT read one clock", () => {
|
|
68
|
+
const body = RENDER_SRC.slice(RENDER_SRC.indexOf('function filteredGoals'));
|
|
69
|
+
assert.match(body.slice(0, 2400), /isStale\(goalActivityMs\(g\), staleDays\)/,
|
|
70
|
+
'the filter reads goalActivityMs — the same function the active/stale sorts compare on');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// ---- attribution ------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
await test('created_by comes from the AUTHENTICATED request, never the body', () => {
|
|
76
|
+
// task-write-routes stamps req.builder.id; the body validator must NOT accept a
|
|
77
|
+
// created_by key — who created a task is a fact about the caller, not an input.
|
|
78
|
+
const routes = read('modules/lifecycle/routes/task-write-routes.js');
|
|
79
|
+
assert.match(routes, /createdBy: req\.builder\.id/);
|
|
80
|
+
assert.equal(/created_by: \{/.test(routes), false, 'not a validatable body field');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
await test('the goal-authored and idea-route vectors attribute too', () => {
|
|
84
|
+
assert.match(read('modules/lifecycle/routes/goals.js'), /createdBy: req\.builder\.id/);
|
|
85
|
+
assert.match(read('modules/ideas/inbox.js'), /createdBy: capturedBy \?\? null/,
|
|
86
|
+
'routing an idea attributes the task to the person whose idea it was');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
await test('history is never backfilled — a guessed attribution is a confident lie', () => {
|
|
90
|
+
assert.match(MIGRATION, /ADD COLUMN IF NOT EXISTS created_by bigint REFERENCES builders\(id\)/);
|
|
91
|
+
assert.equal(/UPDATE tasks\s+SET created_by/i.test(MIGRATION), false,
|
|
92
|
+
'NULL = created before attribution existed; the goals.created_by convention');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
await test('the t.* views are rebuilt WITH the column — the core_190 lesson', () => {
|
|
96
|
+
// A Postgres view pins `SELECT t.*` at CREATE time; without the rebuild the
|
|
97
|
+
// Creator filter would read undefined off the claim feed for every row — the
|
|
98
|
+
// exact silent bug core_190 existed to repair.
|
|
99
|
+
assert.match(MIGRATION, /DROP VIEW IF EXISTS claimable_tasks;/);
|
|
100
|
+
assert.match(MIGRATION, /DROP VIEW IF EXISTS tasks_session_fit;/);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
await test('createTask appends createdBy at the end of the bind list (the goals convention)', () => {
|
|
104
|
+
assert.match(DB_TASKS, /goal_id, created_by, promoted_at\)/,
|
|
105
|
+
'new columns go on the end so pinned bind orders survive');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// ---- the mixed option form is supported, not a latent render bug ------------
|
|
109
|
+
|
|
110
|
+
await test('a filter control may mix string and {value,label} options — the kit normalises', () => {
|
|
111
|
+
// Raised in review as "will likely fail to render". It does not: hall-kit.js
|
|
112
|
+
// declares `options?: [{value,label}|string]` and normalises through optVal /
|
|
113
|
+
// optLabel, and the pre-existing f-discipline and f-goal-status controls have
|
|
114
|
+
// always used the mixed form. Pinned here so the question is settled in code.
|
|
115
|
+
const kit = read('modules/hall-ui/public/hall-kit.js');
|
|
116
|
+
assert.match(kit, /function optVal\(o\) \{ return typeof o === 'string' \? o : o\.value; \}/);
|
|
117
|
+
assert.match(kit, /function optLabel\(o\) \{ return typeof o === 'string' \? o : o\.label; \}/);
|
|
118
|
+
assert.match(WORK_SRC, /id: 'f-discipline',[\s\S]{0,200}'engineer', 'artist', 'ideator', 'ui', 'unclassified'/,
|
|
119
|
+
'the mixed form predates this task on the very same board');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// ---- the payload, which is where the scale actually bit ---------------------
|
|
123
|
+
|
|
124
|
+
await test('the Goals page no longer downloads the version to count it', () => {
|
|
125
|
+
// MEASURED on the real corpus, 2026-08-25: GET /tasks?version=BONGOS-V1&include=deps
|
|
126
|
+
// returned 5,229,455 bytes for ~1,400 tasks to produce ~40 counter objects.
|
|
127
|
+
// GET /goals/rollups returns one small row per goal instead.
|
|
128
|
+
const page = read('modules/hall-ui/public/goals-page.js');
|
|
129
|
+
const body = page.slice(page.indexOf('async function loadRollups'), page.indexOf('/* ---------------- list view'));
|
|
130
|
+
assert.match(body, /getJSON\(`\$\{API\}\/goals\/rollups\?version=/);
|
|
131
|
+
assert.equal(/tasks\?version=/.test(body), false,
|
|
132
|
+
'not even as a fallback — a fallback that quietly re-downloads 5 MB is the bug wearing a safety belt');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
await test('the Backlog read is BOUNDED — it was SELECT * FROM tasks WHERE status=backlog', () => {
|
|
136
|
+
assert.match(WORK_SRC, /const BACKLOG_LIMIT = \d+;/);
|
|
137
|
+
assert.match(WORK_SRC, /tasks\?status=backlog&limit=\$\{BACKLOG_LIMIT\}/);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
await test('the server offers the axes, so a board can ask a narrow question', () => {
|
|
141
|
+
const routes = read('modules/lifecycle/routes/tasks.js');
|
|
142
|
+
for (const p of ['created_by', 'stale_days', 'limit', 'offset']) {
|
|
143
|
+
assert.ok(routes.includes(p), `GET /tasks must accept ${p}`);
|
|
144
|
+
}
|
|
145
|
+
assert.match(routes, /limit must be a positive integer up to 1000/,
|
|
146
|
+
'an uncapped limit would be the whole-table read this axis exists to stop');
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
await test("'none' selects the unattributed server-side, not a missing filter", () => {
|
|
150
|
+
const dbTasks = read('modules/lifecycle/db-tasks.js');
|
|
151
|
+
assert.match(dbTasks, /if \(createdBy === 'none'\) \{\s*\n\s*where\.push\('created_by IS NULL'\);/);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
await test('the SQL rollup buckets MIRROR goals-lib readinessOf exactly', () => {
|
|
155
|
+
// A divergence here shows a person different numbers depending on which
|
|
156
|
+
// surface they opened, which is the opposite of what this goal is for.
|
|
157
|
+
const rollup = read('modules/lifecycle/goal-rollup.js');
|
|
158
|
+
const lib = GOALS_LIB_SRC;
|
|
159
|
+
// done + cut vocabularies
|
|
160
|
+
assert.match(rollup, /const DONE_STATUSES = \['shipped', 'confirmed', 'completed'\]/);
|
|
161
|
+
assert.match(lib, /DONE_STATUSES = new Set\(\['completed', 'confirmed', 'shipped'\]\)/);
|
|
162
|
+
assert.match(rollup, /const CUT_STATUSES = \['abandoned', 'dropped'\]/);
|
|
163
|
+
assert.match(lib, /CUT_STATUSES = new Set\(\['abandoned', 'dropped'\]\)/);
|
|
164
|
+
// blocked = backlog with >=1 unshipped dependency, both sides
|
|
165
|
+
assert.match(rollup, /WHEN s\.status = 'backlog' AND s\.has_unshipped_dep THEN 'blocked'/);
|
|
166
|
+
assert.match(lib, /s === 'backlog' && \(t\.blocked_by \|\| \[\]\)\.some\(\(d\) => !d\.satisfied\)/);
|
|
167
|
+
// cut is excluded from the total on both sides (migration 017's rule)
|
|
168
|
+
assert.match(rollup, /FILTER \(WHERE b\.bucket <> 'cut'\)::int\s+AS total/);
|
|
169
|
+
assert.match(read('modules/hall-ui/public/goals-page.js'), /total: row\.total \|\| 0/);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
await test('the rollup activity clock is the SAME max(updated_at, shipped_at)', () => {
|
|
173
|
+
const rollup = read('modules/lifecycle/goal-rollup.js');
|
|
174
|
+
assert.match(rollup, /GREATEST\(COALESCE\(t\.updated_at, 'epoch'::timestamptz\),\s*\n?\s*COALESCE\(t\.shipped_at, 'epoch'::timestamptz\)\)/);
|
|
175
|
+
assert.match(read('modules/lifecycle/db-tasks.js'), /GREATEST\(COALESCE\(updated_at, 'epoch'::timestamptz\), COALESCE\(shipped_at, 'epoch'::timestamptz\)\)/,
|
|
176
|
+
"the server's stale_days filter reads the same clock as the rollup and the clients");
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
// ---- the board asks the SERVER the narrow question --------------------------
|
|
180
|
+
|
|
181
|
+
await test('Creator and Age are sent to the server, on BOTH board feeds', () => {
|
|
182
|
+
assert.match(WORK_SRC, /function serverFilterQuery\(\)/);
|
|
183
|
+
assert.match(WORK_SRC, /tasks\/claimable\$\{serverFilterQuery\(\)\}/,
|
|
184
|
+
'the claimable feed narrows at the source, not in the browser');
|
|
185
|
+
assert.match(WORK_SRC, /tasks\?status=backlog&limit=\$\{BACKLOG_LIMIT\}\$\{q\}/,
|
|
186
|
+
'and so does the backlog read');
|
|
187
|
+
const routes = read('modules/lifecycle/routes/tasks.js');
|
|
188
|
+
const claimable = routes.slice(routes.indexOf("router.get('/tasks/claimable'"));
|
|
189
|
+
assert.match(claimable.slice(0, 2500), /createdBy: claimCreatedBy \?\? null/);
|
|
190
|
+
assert.match(claimable.slice(0, 2500), /staleDays: claimStaleDays \?\? null/);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
await test('a server-side filter change RE-FETCHES; every other filter stays instant', () => {
|
|
194
|
+
// makeFilterBar hands onChange every value, not the changed id (hall-kit.js),
|
|
195
|
+
// so the decision is made by comparing the server-facing query — which is the
|
|
196
|
+
// direct question anyway, and means a control absent from that query can never
|
|
197
|
+
// cost a round-trip.
|
|
198
|
+
assert.match(WORK_SRC, /let lastServerFilterQuery = '';/);
|
|
199
|
+
assert.match(WORK_SRC, /if \(q !== lastServerFilterQuery\) \{[\s\S]{0,200}refreshLive\(\);\s*\n\s*loadBacklog\(\);/);
|
|
200
|
+
const kit = read('modules/hall-ui/public/hall-kit.js');
|
|
201
|
+
assert.match(kit, /if \(onChange\) onChange\(values\(\)\);/,
|
|
202
|
+
'the contract this decision depends on — if it ever hands the changed id instead, revisit');
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
await test('one staleness rule, four expressions, none of them drifting', () => {
|
|
206
|
+
const clock = /GREATEST\(COALESCE\((?:v\.|t\.)?updated_at, 'epoch'::timestamptz\),\s*\n?\s*COALESCE\((?:v\.|t\.)?shipped_at, 'epoch'::timestamptz\)\)/;
|
|
207
|
+
const dbTasks = read('modules/lifecycle/db-tasks.js');
|
|
208
|
+
assert.match(dbTasks, clock, 'listTasks');
|
|
209
|
+
assert.ok((dbTasks.match(new RegExp(clock.source, 'g')) || []).length >= 2, 'and listClaimableTasks');
|
|
210
|
+
assert.match(read('modules/lifecycle/goal-rollup.js'), clock, 'the goal rollup');
|
|
211
|
+
assert.equal(taskLib.taskActivityMs({ updated_at: '2026-01-01T00:00:00Z', shipped_at: '2026-02-01T00:00:00Z' }),
|
|
212
|
+
Date.parse('2026-02-01T00:00:00Z'), 'and the browser');
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
// ---- privacy -----------------------------------------------------------------
|
|
216
|
+
|
|
217
|
+
await test('a rollup never hands a private goal\'s task title to a non-member', () => {
|
|
218
|
+
// Counts on a private goal are not the secret — its prose is (ADR 0112's leak
|
|
219
|
+
// rule). next_ready_title is a real task title, so it is gated on membership
|
|
220
|
+
// or Archon; the counters still come back, so the board renders the ring and
|
|
221
|
+
// the readiness line either way.
|
|
222
|
+
const rollup = read('modules/lifecycle/goal-rollup.js');
|
|
223
|
+
assert.match(rollup, /CASE WHEN bool_or\(g\.visibility IS DISTINCT FROM 'private'\)/);
|
|
224
|
+
assert.match(rollup, /bool_or\(gm\.builder_id IS NOT NULL\)/, 'membership is what unlocks it');
|
|
225
|
+
assert.match(rollup, /THEN MAX\(nr\.title\) END\s+AS next_ready_title/);
|
|
226
|
+
const routes = read('modules/lifecycle/routes/goals.js');
|
|
227
|
+
assert.match(routes, /viewerId: req\.builder\.id/, 'identity from the request, never the query');
|
|
228
|
+
assert.match(routes, /viewerIsArchon: req\.builder\.rank === 'archon'/);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
await test('limit and offset are BOUND, not interpolated', () => {
|
|
232
|
+
const dbTasks = read('modules/lifecycle/db-tasks.js');
|
|
233
|
+
assert.match(dbTasks, /tail \+= ` LIMIT \$\$\{params\.length\}`/);
|
|
234
|
+
assert.equal(/LIMIT \$\{Number\(limit\)\}/.test(dbTasks), false,
|
|
235
|
+
'a query builder that is only safe because of who calls it is one careless caller from unsafe');
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
// ---- the scale check: EXECUTED, not asserted --------------------------------
|
|
239
|
+
|
|
240
|
+
await test('the board pipeline survives the real corpus size (~1,400 rows), measured', () => {
|
|
241
|
+
// Reproduce the board's per-render work — filter (creator + staleness + the
|
|
242
|
+
// existing axes) then sort — over a corpus the size of BONGOS-V1's, and hold it
|
|
243
|
+
// to a budget. The budget is generous (well under one frame's headroom on this
|
|
244
|
+
// CI box) because the point is the ORDER of the cost, not a microbenchmark: a
|
|
245
|
+
// rewrite that makes this quadratic or fetch-per-row breaks the assert.
|
|
246
|
+
const { taskActivityMs, isStale } = taskLib;
|
|
247
|
+
const now = Date.now();
|
|
248
|
+
const corpus = Array.from({ length: 1400 }, (_, i) => ({
|
|
249
|
+
id: i + 1,
|
|
250
|
+
title: `Task number ${i} of the corpus`,
|
|
251
|
+
version_id: 'BONGOS-V1',
|
|
252
|
+
kind: ['feature', 'bug', 'infra'][i % 3],
|
|
253
|
+
discipline: ['engineer', 'artist', 'ideator'][i % 3],
|
|
254
|
+
created_by: i % 5 === 0 ? null : String(1000000 + (i % 7)),
|
|
255
|
+
goal_id: String(1000000 + (i % 40)),
|
|
256
|
+
updated_at: new Date(now - (i % 90) * DAY).toISOString(),
|
|
257
|
+
shipped_at: null,
|
|
258
|
+
credits_reward: i % 50,
|
|
259
|
+
est_minutes: 30 + (i % 120),
|
|
260
|
+
priority: (i % 5) + 1,
|
|
261
|
+
newcomer_friendly: i % 11 === 0,
|
|
262
|
+
}));
|
|
263
|
+
const t0 = process.hrtime.bigint();
|
|
264
|
+
let visible;
|
|
265
|
+
for (let pass = 0; pass < 10; pass++) {
|
|
266
|
+
visible = corpus
|
|
267
|
+
.filter((t) => String(t.created_by) === '1000003' || t.created_by == null)
|
|
268
|
+
.filter((t) => isStale(taskActivityMs(t), '30', now))
|
|
269
|
+
.sort((a, b) => taskActivityMs(a) - taskActivityMs(b));
|
|
270
|
+
}
|
|
271
|
+
const perPassMs = Number(process.hrtime.bigint() - t0) / 1e6 / 10;
|
|
272
|
+
assert.ok(visible.length > 0, 'sanity: the synthetic filters match something');
|
|
273
|
+
assert.ok(perPassMs < 50, `one filter+sort pass over 1,400 rows took ${perPassMs.toFixed(2)}ms — the view no longer survives the corpus`);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
await test('the Tasks board still loads the corpus in a bounded number of requests', () => {
|
|
277
|
+
// The board's data story: claimable + backlog + one /goals read. The new axes
|
|
278
|
+
// must not have added a per-row or per-goal fetch. Count the getJSON call
|
|
279
|
+
// sites that run at boot — the creator labels ride ONE directory read.
|
|
280
|
+
const boots = WORK_SRC.match(/getJSON\(`\$\{API\}\/(tasks\/claimable|tasks\?status=backlog|goals|builders\/directory)`\)/g) || [];
|
|
281
|
+
assert.ok(boots.length <= 5, `bounded boot reads — got ${boots.length}`);
|
|
282
|
+
assert.equal(/getJSON\(`\$\{API\}\/tasks\/\$\{t\.id\}/.test(WORK_SRC.slice(WORK_SRC.indexOf('populateCreatorFilter'))), false,
|
|
283
|
+
'no per-task fetch crept into the new filter');
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
summary();
|
package/tests/goal_subtitle.mjs
CHANGED
|
@@ -154,8 +154,13 @@ test("Claude's prose is a CLOSED disclosure, not visible body text", () => {
|
|
|
154
154
|
const el = () => ({ textContent: null, hidden: null, open: null });
|
|
155
155
|
function applyProse(goal) {
|
|
156
156
|
const lib = read('modules', 'hall-ui', 'public', 'goals-lib.js');
|
|
157
|
+
const taskLib = read('modules', 'hall-ui', 'public', 'task-lib.js');
|
|
157
158
|
const els = { sub: el(), desc: el(), claude: el() };
|
|
158
|
-
|
|
159
|
+
// task 1003278: goals-lib takes its activity clock from window.OTBTaskLib (one
|
|
160
|
+
// meaning of "touched" across both tiers), so the sibling loads first — the
|
|
161
|
+
// order goals.html uses. Loading the REAL one, not a stub: a stub that always
|
|
162
|
+
// agrees is exactly what a shared definition must not be tested against.
|
|
163
|
+
const fn = new Function('window', `${taskLib}; ${lib}; return window.OTBGoalsLib.applyGoalProse;`)({ OTB: { escapeHtml: (x) => x } });
|
|
159
164
|
fn(els, goal);
|
|
160
165
|
return els;
|
|
161
166
|
}
|
package/tests/goals_lib.mjs
CHANGED
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
// tests/goals_lib.mjs — the Goals page's extracted pure helpers (goals-lib.js,
|
|
2
|
-
// task 1003135). Pins (a) that the lib loads
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
2
|
+
// task 1003135). Pins (a) that the lib loads with no dependency on the page's
|
|
3
|
+
// IIFE state — the whole reason it could move out of the oversized
|
|
4
|
+
// goals-page.js — and (b) the behaviour of the helpers the page destructures.
|
|
5
|
+
//
|
|
6
|
+
// task 1003278: "no page state" is NOT "no siblings". The lib already read
|
|
7
|
+
// window.OTB.escapeHtml (subtitleAttr), and its activity clock now comes from
|
|
8
|
+
// window.OTBTaskLib, because the criterion legibility-sort-and-staleness needs
|
|
9
|
+
// ONE meaning of "touched" across both tiers — a synced pair of definitions is
|
|
10
|
+
// the thing it is trying to prevent. So the harness loads the sibling libs the
|
|
11
|
+
// way goals.html does (task-lib before goals-lib) and the property under test
|
|
12
|
+
// stays what it always was: no reach into page state.
|
|
6
13
|
//
|
|
7
14
|
// Run: node tests/goals_lib.mjs
|
|
8
15
|
|
|
@@ -15,17 +22,22 @@ import { makeRunner } from './helpers.mjs';
|
|
|
15
22
|
|
|
16
23
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
17
24
|
const SRC = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/goals-lib.js'), 'utf8');
|
|
25
|
+
const TASK_LIB_SRC = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/task-lib.js'), 'utf8');
|
|
18
26
|
const { test, summary } = makeRunner();
|
|
19
27
|
|
|
20
28
|
const windowObj = {};
|
|
21
29
|
const sandbox = { window: windowObj, Set, Map, Math, Number, String, Array, Object, Date, JSON };
|
|
22
30
|
sandbox.globalThis = sandbox;
|
|
23
31
|
vm.createContext(sandbox);
|
|
32
|
+
// The REAL sibling, in load order — stubbing it would let the shared activity
|
|
33
|
+
// clock rot behind a fake that always agrees.
|
|
34
|
+
vm.runInContext(TASK_LIB_SRC, sandbox, { filename: 'task-lib.js' });
|
|
24
35
|
vm.runInContext(SRC, sandbox, { filename: 'goals-lib.js' });
|
|
25
36
|
const lib = windowObj.OTBGoalsLib;
|
|
26
37
|
|
|
27
38
|
await test('loads standalone in a bare window (no page-state dependency) and exports the full surface', () => {
|
|
28
39
|
assert.ok(lib, 'window.OTBGoalsLib must be set');
|
|
40
|
+
assert.equal('state' in sandbox, false, 'the lib must not reach for page state');
|
|
29
41
|
for (const k of ['DONE_STATUSES', 'CUT_STATUSES', 'isTodoStatus', 'ring', 'memberName',
|
|
30
42
|
'readinessOf', 'EMPTY_ROLLUP', 'goalRollLine', 'matchesTodoSearch', 'msOf', 'taskActivityMs']) {
|
|
31
43
|
assert.ok(k in lib, `missing export: ${k}`);
|