@bongos/core 1.19.524 → 1.19.526

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.
@@ -0,0 +1,197 @@
1
+ // modules/lifecycle/tests/cascade.mjs — the cross-role cascade (task 1003549 /
2
+ // goal 1000074, the artist's loop).
3
+ //
4
+ // The mechanism's whole value is that a ship in one role can generate work in
5
+ // another WITHOUT ever putting that generation in the ship's way. So the four
6
+ // properties the header of cascade.js promises are what these pin, and three of
7
+ // them are about what must NOT happen:
8
+ //
9
+ // 1. it never blocks the ship — every failure shape resolves to a report
10
+ // 2. it is idempotent — the same ship twice files one task
11
+ // 3. it says where it came from
12
+ // 4. it files at backlog, never ready
13
+ //
14
+ // The production table ships EMPTY (R04 writes the first rule), so the rules
15
+ // below are the test's OWN declarations — which is also the point: a mechanism
16
+ // proved by a rule its consumer has not written yet is proved against the
17
+ // contract rather than against one example.
18
+ //
19
+ // DB-free: the runner takes its three database calls as injected functions.
20
+
21
+ import { strict as assert } from 'node:assert';
22
+ import path from 'node:path';
23
+ import url from 'node:url';
24
+ import { createRequire } from 'node:module';
25
+
26
+ const HERE = path.dirname(url.fileURLToPath(import.meta.url));
27
+ const require_ = createRequire(import.meta.url);
28
+ const cascade = require_(path.join(HERE, '..', 'cascade.js'));
29
+
30
+ let passed = 0;
31
+ let failed = 0;
32
+ async function test(name, fn) {
33
+ try { await fn(); passed++; console.log(` ok ${name}`); }
34
+ catch (err) { failed++; console.log(` FAIL ${name}`); console.log(` ${err.stack || err.message}`); }
35
+ }
36
+
37
+ console.log('\nlifecycle: a ship in one role generates work in another');
38
+
39
+ const shipped = (over = {}) => ({
40
+ id: '9001',
41
+ version_id: 'BONGOS-V1',
42
+ goal_id: '1000074',
43
+ title: 'R09 · Reword the hall empty states',
44
+ discipline: 'engineer',
45
+ kind: 'feature',
46
+ status: 'shipped',
47
+ ...over,
48
+ });
49
+
50
+ // A rule of the shape R04 will write: an engineer's ship becomes an artist's task.
51
+ const REVIEW_RULE = {
52
+ key: 'test-artist-review',
53
+ discipline: 'artist',
54
+ when: (t) => t.discipline === 'engineer',
55
+ title: (t) => `Review the copy that shipped in task ${t.id}`,
56
+ body: (t) => `Look at what ${t.title} put in front of a person.`,
57
+ };
58
+
59
+ const stubs = (over = {}) => {
60
+ const created = [];
61
+ return {
62
+ created,
63
+ deps: {
64
+ rules: [REVIEW_RULE],
65
+ getTask: async () => shipped(),
66
+ findTaskBySourceRef: async () => null,
67
+ createTask: async (spec) => { created.push(spec); return { id: String(9100 + created.length) }; },
68
+ ...over,
69
+ },
70
+ };
71
+ };
72
+
73
+ await test('an engineer ship files a task FOR THE ARTIST — the push the queues never had', async () => {
74
+ const { created, deps } = stubs();
75
+ const report = await cascade.runCascadesForShip({ id: '9001' }, deps);
76
+
77
+ assert.equal(report.errors.length, 0, report.errors.join(' | '));
78
+ assert.equal(created.length, 1);
79
+ const spec = created[0];
80
+ assert.equal(spec.discipline, 'artist', 'the generated work is for a DIFFERENT role than the ship');
81
+ assert.equal(report.created[0].discipline, 'artist');
82
+ assert.match(spec.title, /9001/);
83
+ });
84
+
85
+ await test('the generated task files at backlog and inherits its goal and version', async () => {
86
+ const { created, deps } = stubs();
87
+ await cascade.runCascadesForShip({ id: '9001' }, deps);
88
+ const spec = created[0];
89
+ assert.equal(spec.status, 'backlog', 'generated work is a REQUEST — auto-filing claimable work mints claims nobody asked for');
90
+ assert.equal(spec.goalId, 1000074);
91
+ assert.equal(spec.versionId, 'BONGOS-V1');
92
+ });
93
+
94
+ await test('it says where it came from, in the ref and in the prose', async () => {
95
+ const { created, deps } = stubs();
96
+ await cascade.runCascadesForShip({ id: '9001' }, deps);
97
+ const spec = created[0];
98
+ assert.equal(spec.source, 'cascade');
99
+ assert.equal(spec.sourceRef, 'cascade-test-artist-review-9001');
100
+ assert.match(spec.description, /task 9001 shipped/);
101
+ assert.match(spec.description, /test-artist-review/);
102
+ assert.match(spec.description, /Look at what/, 'the rule\'s own body survives the preamble');
103
+ // NOT a subtask: cascaded work outlives its trigger and belongs to another role.
104
+ assert.equal(spec.parentTaskId, undefined);
105
+ });
106
+
107
+ await test('the same ship twice files ONE task — a reconciler re-sweep is a no-op', async () => {
108
+ const { created, deps } = stubs();
109
+ await cascade.runCascadesForShip({ id: '9001' }, deps);
110
+ assert.equal(created.length, 1);
111
+
112
+ // Second pass: the row now exists under the same (source, source_ref).
113
+ deps.findTaskBySourceRef = async ({ source, sourceRef }) => (
114
+ source === 'cascade' && sourceRef === 'cascade-test-artist-review-9001' ? { id: '9101' } : null
115
+ );
116
+ const again = await cascade.runCascadesForShip({ id: '9001' }, deps);
117
+ assert.equal(created.length, 1, 'no second row');
118
+ assert.equal(again.created.length, 0);
119
+ assert.deepEqual(again.skipped[0], { key: 'test-artist-review', taskId: '9101', reason: 'already filed' });
120
+ });
121
+
122
+ await test('a rule that does not match files nothing, quietly', async () => {
123
+ const { created, deps } = stubs({ getTask: async () => shipped({ discipline: 'artist' }) });
124
+ const report = await cascade.runCascadesForShip({ id: '9001' }, deps);
125
+ assert.equal(created.length, 0);
126
+ assert.equal(report.errors.length, 0, 'a non-match is not a problem');
127
+ });
128
+
129
+ // --- property 1: nothing here may reach the ship ----------------------------
130
+
131
+ await test('NOTHING a cascade does can fail the ship — every failure shape resolves', async () => {
132
+ const shapes = {
133
+ 'when() throws': stubs({ rules: [{ ...REVIEW_RULE, when: () => { throw new Error('boom'); } }] }),
134
+ 'title() throws': stubs({ rules: [{ ...REVIEW_RULE, title: () => { throw new Error('boom'); } }] }),
135
+ 'createTask rejects': stubs({ createTask: async () => { throw new Error('db down'); } }),
136
+ 'findTaskBySourceRef rejects': stubs({ findTaskBySourceRef: async () => { throw new Error('db down'); } }),
137
+ 'getTask rejects': stubs({ getTask: async () => { throw new Error('db down'); } }),
138
+ 'getTask returns nothing': stubs({ getTask: async () => null }),
139
+ 'a malformed rule': stubs({ rules: [{ key: 'broken' }] }),
140
+ };
141
+ for (const [name, { created, deps }] of Object.entries(shapes)) {
142
+ const report = await cascade.runCascadesForShip({ id: '9001' }, deps);
143
+ assert.ok(report && Array.isArray(report.errors), `${name}: returned a report`);
144
+ assert.ok(report.errors.length > 0, `${name}: and said what went wrong`);
145
+ assert.equal(created.length, 0, `${name}: filed nothing`);
146
+ }
147
+
148
+ // The payload itself being junk is the same story.
149
+ for (const junk of [null, undefined, {}, { id: null }]) {
150
+ const { deps } = stubs();
151
+ const report = await cascade.runCascadesForShip(junk, deps);
152
+ assert.ok(report.errors.length > 0, `${JSON.stringify(junk)}: reported rather than threw`);
153
+ }
154
+ });
155
+
156
+ await test('one broken rule costs only its own task', async () => {
157
+ const good = { ...REVIEW_RULE, key: 'good-one' };
158
+ const bad = { ...REVIEW_RULE, key: 'bad-one', when: () => { throw new Error('boom'); } };
159
+ const { created, deps } = stubs({ rules: [bad, good] });
160
+ const report = await cascade.runCascadesForShip({ id: '9001' }, deps);
161
+ assert.equal(created.length, 1, 'the healthy rule still filed');
162
+ assert.equal(created[0].sourceRef, 'cascade-good-one-9001');
163
+ assert.equal(report.errors.length, 1);
164
+ assert.match(report.errors[0], /bad-one/);
165
+ });
166
+
167
+ // --- the declaration itself --------------------------------------------------
168
+
169
+ await test('the production table is valid, and a duplicate key is refused', () => {
170
+ assert.deepEqual(cascade.validateCascades(), [], 'the shipped CASCADES table must be well-formed');
171
+ assert.ok(Array.isArray(cascade.CASCADES));
172
+ assert.equal(Object.isFrozen(cascade.CASCADES), true);
173
+
174
+ // Two rules sharing a key would collide on source_ref and silently suppress
175
+ // one another — the failure that is invisible in production.
176
+ const errs = cascade.validateCascades([REVIEW_RULE, { ...REVIEW_RULE }]);
177
+ assert.equal(errs.length, 1);
178
+ assert.match(errs[0], /duplicate key/);
179
+
180
+ // A rule missing any required field is named, field by field.
181
+ const shape = cascade.assertRuleShape({ key: 'x' });
182
+ assert.ok(shape.some((p) => /discipline/.test(p)));
183
+ assert.ok(shape.some((p) => /when/.test(p)));
184
+ assert.ok(shape.some((p) => /title/.test(p)));
185
+ assert.ok(shape.some((p) => /body/.test(p)));
186
+ });
187
+
188
+ await test('the production table ships EMPTY, deliberately — R04 writes the first rule', () => {
189
+ // If this fails because a rule was added, that is fine: replace the assertion
190
+ // with one that pins the new rule's key and discipline. It exists so the
191
+ // emptiness is a DECISION on the record (see cascade.js's header) rather than
192
+ // something nobody noticed.
193
+ assert.equal(cascade.CASCADES.length, 0);
194
+ });
195
+
196
+ console.log(`\n${passed} passed, ${failed} failed`);
197
+ process.exit(failed ? 1 : 0);
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.524",
3
+ "version": "1.19.526",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.524",
9
+ "version": "1.19.526",
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.524",
3
+ "version": "1.19.526",
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
@@ -45,7 +45,7 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
45
45
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
46
46
  // the entry to that file. Look for a version's history there, not here.
47
47
  // ---------------------------------------------------------------------------
48
- const CORE_VERSION = '1.19.524'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
48
+ const CORE_VERSION = '1.19.526'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
49
49
 
50
50
  // A namespaced logger so a module's log lines are attributable + consistent.
51
51
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -97,10 +97,67 @@ test('the page NEVER sends a craft — matching is the server\'s job', () => {
97
97
  });
98
98
 
99
99
  test('a craft request and a by-name request read differently', () => {
100
+ // `addressedTo` is GONE (task 1003274) — it named the addressee only when the
101
+ // viewer WAS the addressee, so the asker's own list rendered anonymously, which
102
+ // is the whole owner report. directionLine replaced it on both axes and had to
103
+ // take the tonal split with it, which is what this now asserts.
100
104
  const L = lib();
101
- assert.match(L.addressedTo({ needs_craft: 'artist' }, 1), /artists/);
102
- assert.match(L.addressedTo({ needs_builder_id: 7 }, 7), /by name/);
103
- assert.match(L.addressedTo({ needs_builder_id: 7 }, 9), /one person/);
105
+ assert.equal(L.addressedTo, undefined, 'the superseded helper is not left exported');
106
+ assert.match(L.directionLine({ needs_craft: 'artist' }, 'out'), /artists/);
107
+ assert.match(L.directionLine({ needs_builder_id: 7, needs_builder_name: 'Ada' }, 'out'), /by name/);
108
+ });
109
+
110
+ // ── DIRECTION: the owner's report (task 1003274) ────────────────────────────
111
+ //
112
+ // "the ask went to me, not Lars it seems... It says Lars- as your asks but I am
113
+ // not Lars." Nothing was misrouted: the owner was reading their OWN outgoing ask,
114
+ // addressed to Lars, under "Your asks" — and the row said neither who it went to
115
+ // nor which way it pointed. The label read "asked of one person".
116
+ //
117
+ // The reason the old code could not name them is worth keeping: it named the
118
+ // addressee only when the VIEWER was the addressee, so the single most common
119
+ // case for that branch — the asker reading their own list — was the one case it
120
+ // rendered anonymously.
121
+
122
+ test('THE REPORTED CASE: your own by-name ask names the person you asked', () => {
123
+ const L = lib();
124
+ const req = { needs_builder_id: 3, needs_builder_name: 'Lars', requested_by: 1000025 };
125
+ const line = L.directionLine(req, 'out');
126
+ assert.match(line, /Lars/, 'the addressee is named — this is the whole bug');
127
+ assert.match(line, /^You asked/, 'and it says which way it points');
128
+ });
129
+
130
+ test('DIRECTION cannot be mistaken: outgoing and incoming read as opposites', () => {
131
+ const L = lib();
132
+ const out = L.directionLine({ needs_builder_id: 3, needs_builder_name: 'Lars' }, 'out');
133
+ const inc = L.directionLine({ needs_builder_id: 9, requested_by_name: 'Lars' }, 'in');
134
+ assert.match(out, /^You asked Lars by name$/);
135
+ assert.match(inc, /^Lars asked you by name$/);
136
+ });
137
+
138
+ test('direction comes from the LIST, not from comparing ids', () => {
139
+ // The two server reads already answer the question by construction
140
+ // (/help-requests/mine vs /for-me), so directionLine takes no viewer id. That
141
+ // is what makes the asker's own list impossible to render anonymously — there
142
+ // is no comparison left to get wrong.
143
+ const L = lib();
144
+ assert.equal(L.directionLine.length, 2, 'req + dir, and no viewerId');
145
+ });
146
+
147
+ test('the craft tone survives — a craft ask is not a personal one', () => {
148
+ // ADR 0187 §3's split, which the fix was told to keep: "asked of builders" is a
149
+ // call anyone of that craft may take; a by-name ask is personal and says so.
150
+ const L = lib();
151
+ assert.match(L.directionLine({ needs_craft: 'artist' }, 'out'), /^You asked artists$/);
152
+ assert.match(L.directionLine({ needs_craft: 'artist', requested_by_name: 'Lars' }, 'in'),
153
+ /^Lars asked artists$/, 'incoming craft asks say the craft, not "you"');
154
+ });
155
+
156
+ test('an unnamed addressee still says which way it points', () => {
157
+ // An older server sends no join. Losing the name must not lose the direction.
158
+ const L = lib();
159
+ assert.match(L.directionLine({ needs_builder_id: 3 }, 'out'), /^You asked one person$/);
160
+ assert.match(L.directionLine({ needs_builder_id: 3 }, 'in'), /^Someone asked you by name$/);
104
161
  });
105
162
 
106
163
  test('context links to the task or goal, and to neither when absent', () => {
@@ -77,8 +77,25 @@ test('collab renders WHO IS ASKING, as a door to their profile', () => {
77
77
  assert.equal(L.askerOf({ requested_by: 92 }), 'builder 92', 'an older server\'s bare id is still a name, never nothing');
78
78
  const js = read('collab.js');
79
79
  assert.match(js, /L\.askerOf\(req\)/, 'the row asks the lib who is asking');
80
- assert.match(js, /\/builders\/profile\?id=\$\{escapeHtml\(String\(req\.requested_by\)\)\}/, 'and links the hall\'s per-builder profile');
81
- assert.match(js, /asked by \$\{who\}/, 'as a fact on the row');
80
+ // The link is built by personLink(id, name) now rather than inline per row —
81
+ // task 1003274 made BOTH people on a row a door (the asker on an incoming row,
82
+ // the addressee on your own), so the href moved to one helper taking an id.
83
+ assert.match(js, /\/builders\/profile\?id=\$\{escapeHtml\(String\(id\)\)\}/,
84
+ 'and links the hall\'s per-builder profile');
85
+ // The wording is L.directionLine's, not this file's: task 1003274 replaced
86
+ // "asked by X" with a sentence that also says WHICH WAY the ask points, because
87
+ // the owner read their own outgoing ask as an incoming one. The sentence is
88
+ // asserted in tests/collab_page.mjs where the lib can be executed.
89
+ assert.match(js, /L\.directionLine\(req, dir\)/, 'as a fact on the row');
90
+ assert.ok(!/asked by \$\{who\}/.test(js), 'the direction-less wording is gone');
91
+
92
+ // The link is spliced with a REPLACER FUNCTION. As a replacement STRING,
93
+ // `$&`/`$$`/"$`"/`$'` inside it are special to String.prototype.replace — and
94
+ // the name it carries is builder-chosen. Measured: a display_name of `$&Bob`
95
+ // rendered as `$&Bobamp;Bob`. Not an injection (both halves are escaped),
96
+ // but a way to corrupt your own row by picking a name.
97
+ assert.match(js, /\.replace\(escapeHtml\(name\), \(\) => link\)/,
98
+ 'the replacement is a function, so $-sequences in a name are literal');
82
99
  });
83
100
 
84
101
  test('the server names the people on a row through a join, and the writes stay bare', () => {
@@ -115,7 +132,9 @@ test('"asked of you" reads in runs — by name first, then each craft', () => {
115
132
  const L = lib();
116
133
  assert.equal(L.groupOf({ needs_builder_id: 95 }).id, 'you');
117
134
  assert.equal(L.groupOf({ needs_craft: 'artist' }).label, 'asked of artists');
118
- assert.match(L.addressedTo({ needs_builder_id: 7, needs_builder_name: 'Preview Alpha' }, 9), /Preview Alpha by name/, 'a named addressee is named');
135
+ // task 1003274: the naming moved to directionLine, which also says WHICH WAY
136
+ // the ask points — the half the owner's report was actually about.
137
+ assert.match(L.directionLine({ needs_builder_id: 7, needs_builder_name: 'Preview Alpha' }, 'out'), /Preview Alpha by name/, 'a named addressee is named');
119
138
  const js = read('collab.js');
120
139
  assert.match(js, /ov-group__head/, 'each run has the family\'s group head');
121
140
  assert.match(js, /Number\(b\.id === 'you'\) - Number\(a\.id === 'you'\)/, 'and the personal run sorts first');