@bongos/core 1.19.841 → 1.19.843

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,163 @@
1
+ // tests/task_discipline_required.mjs — task 1002988: every task row carries a craft.
2
+ //
3
+ // The thing under test is mostly an ABSENCE: that 'unclassified' can no longer be
4
+ // written, by any vector, and that the DB constraint no longer accepts it. A suite
5
+ // that only proved the four valid values work would have passed before this task and
6
+ // will pass again once the guard rots, so each block below pins the refusal.
7
+ import assert from 'node:assert/strict';
8
+ import { test } from 'node:test';
9
+ import { createRequire } from 'node:module';
10
+ import fs from 'node:fs';
11
+ import path from 'node:path';
12
+ import { fileURLToPath } from 'node:url';
13
+
14
+ const require = createRequire(import.meta.url);
15
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
16
+ const classifier = require(path.join(ROOT, 'modules', 'lifecycle', 'task-classifier.js'));
17
+ const { resolveWriteDiscipline, WRITABLE_DISCIPLINES, VALID_DISCIPLINES } = classifier;
18
+
19
+ // --- the writable set -------------------------------------------------------
20
+
21
+ test('the writable set is the four crafts — ui included, unclassified excluded', () => {
22
+ // FOUR, not three. The task was written 2026-08-16 naming three; ADR 0272
23
+ // (2026-09-09) keeps `ui` as a module-contributed discipline and the owner
24
+ // reconfirmed it on 2026-09-17 when abandoning task 1002987. Narrowing to three
25
+ // would break live ui rows — the backfill found 3 of them by classification alone.
26
+ assert.deepEqual([...WRITABLE_DISCIPLINES].sort(), ['artist', 'engineer', 'ideator', 'ui']);
27
+ assert.ok(!WRITABLE_DISCIPLINES.has('unclassified'), 'unclassified is a triage state, not a craft');
28
+ });
29
+
30
+ test('the READ set still carries unclassified, so old rows and filters resolve', () => {
31
+ // The two sets are deliberately different. Collapsing them would break the
32
+ // ?discipline=unclassified query the backfill itself reads through.
33
+ assert.ok(VALID_DISCIPLINES.has('unclassified'));
34
+ });
35
+
36
+ // --- resolveWriteDiscipline -------------------------------------------------
37
+
38
+ test('an explicit valid discipline is honoured verbatim', () => {
39
+ for (const d of WRITABLE_DISCIPLINES) {
40
+ assert.deepEqual(resolveWriteDiscipline({ discipline: d, title: 'anything' }), { discipline: d });
41
+ }
42
+ });
43
+
44
+ test('an omitted discipline is INFERRED, and reports that it was', () => {
45
+ const r = resolveWriteDiscipline({ title: 'Refactor the database migration script' });
46
+ assert.equal(r.discipline, 'engineer');
47
+ assert.equal(r.inferred, true, 'the caller must be able to tell a guess from a statement');
48
+ assert.ok(r.confidence >= 0.5);
49
+ });
50
+
51
+ test('an explicit "unclassified" is treated as no answer at all, not as a value', () => {
52
+ // modules/ideas/inbox.js sent this literal string on every promotion. If it were
53
+ // honoured as a value, the create path would still mint the rows this task removes.
54
+ const r = resolveWriteDiscipline({ discipline: 'unclassified', title: 'Refactor the database migration script' });
55
+ assert.equal(r.discipline, 'engineer');
56
+ assert.equal(r.inferred, true);
57
+ });
58
+
59
+ test('an unclassifiable task is REFUSED, not filed under a filler value', () => {
60
+ const r = resolveWriteDiscipline({ title: 'asdf' });
61
+ assert.equal(r.discipline, undefined);
62
+ assert.equal(r.error, 'bad_discipline');
63
+ assert.equal(r.unclassifiable, true, 'the caller must be able to say WHY, to write a useful message');
64
+ });
65
+
66
+ test('a bogus discipline is refused, and is NOT reported as unclassifiable', () => {
67
+ const r = resolveWriteDiscipline({ discipline: 'wizard', title: 'Refactor the database migration script' });
68
+ assert.equal(r.error, 'bad_discipline');
69
+ assert.ok(!r.unclassifiable, 'the text was fine — the caller named a craft that does not exist');
70
+ });
71
+
72
+ // --- createTask: the single chokepoint --------------------------------------
73
+ // Every create vector passes through here, including the ones that never touch an
74
+ // HTTP route (idea promotion, copy-desk, newcomer-restock, kickoff-seed, cascade).
75
+ // Fixing only the routes would have left those minting role-less rows.
76
+
77
+ function recordingPool(captured) {
78
+ return {
79
+ query: async (text, params) => {
80
+ if (/^\s*INSERT INTO tasks/i.test(text)) {
81
+ captured.push({ text, params });
82
+ return { rows: [{ id: 1, discipline: params[params.findIndex(() => false)] }] };
83
+ }
84
+ return { rows: [{ id: 1 }] };
85
+ },
86
+ };
87
+ }
88
+
89
+ test('createTask REFUSES a task whose craft it cannot infer', async () => {
90
+ process.env.NODE_ENV = 'test';
91
+ const { createTask } = require(path.join(ROOT, 'modules', 'lifecycle', 'db-tasks.js'));
92
+ const captured = [];
93
+ await assert.rejects(
94
+ () => createTask({ versionId: 'V1', title: 'asdf', goalId: 1 }, { pool: recordingPool(captured) }),
95
+ (e) => e.code === 'BAD_DISCIPLINE' && e.unclassifiable === true,
96
+ 'an unclassifiable create must throw, not default',
97
+ );
98
+ assert.equal(captured.length, 0, 'nothing may be inserted when the craft is unknown');
99
+ });
100
+
101
+ test('createTask no longer has an "unclassified" default to fall back on', () => {
102
+ // The default was the whole mechanism: 1,189 rows accumulated because it was quiet.
103
+ // Pinned as source text because the behavioural test above can only prove the
104
+ // refusal, not that the default is gone rather than merely unreachable.
105
+ const src = fs.readFileSync(path.join(ROOT, 'modules', 'lifecycle', 'db-tasks.js'), 'utf8');
106
+ assert.ok(!/^\s*discipline = 'unclassified',\s*$/m.test(src),
107
+ "createTask must not default discipline to 'unclassified'");
108
+ });
109
+
110
+ // --- the migration: the absence is the thing under test ---------------------
111
+
112
+ test('core_247 narrows the CHECK to the four crafts and REJECTS unclassified', () => {
113
+ const sql = fs.readFileSync(path.join(ROOT, 'migrations', 'core_247_discipline_required.sql'), 'utf8');
114
+ const m = sql.match(/CHECK \(discipline IN \(([^)]*)\)\)/);
115
+ assert.ok(m, 'the migration must re-add a discipline CHECK constraint');
116
+ const accepted = m[1].split(',').map((s) => s.trim().replace(/'/g, '')).sort();
117
+ assert.deepEqual(accepted, ['artist', 'engineer', 'ideator', 'ui'],
118
+ 'the accepted set must match WRITABLE_DISCIPLINES exactly');
119
+ assert.ok(!accepted.includes('unclassified'), 'the whole point: the constraint rejects it');
120
+ // The net that makes the migration safe on a checkout where nobody ran the script.
121
+ assert.match(sql, /UPDATE tasks SET discipline = 'engineer'[\s\S]*?WHERE discipline = 'unclassified'/,
122
+ 'the migration must resolve residual rows itself, not assume a script was run');
123
+ });
124
+
125
+ test('the migration constraint and the code agree on the accepted set', () => {
126
+ // Two places state the vocabulary; a drift between them is a 500 at write time
127
+ // (code accepts a value the DB rejects) or dead validation (the reverse).
128
+ const sql = fs.readFileSync(path.join(ROOT, 'migrations', 'core_247_discipline_required.sql'), 'utf8');
129
+ const accepted = sql.match(/CHECK \(discipline IN \(([^)]*)\)\)/)[1]
130
+ .split(',').map((s) => s.trim().replace(/'/g, '')).sort();
131
+ assert.deepEqual(accepted, [...WRITABLE_DISCIPLINES].sort());
132
+ });
133
+
134
+ // --- the create vectors that must keep working ------------------------------
135
+ // Acceptance 6: prove it per path, not in aggregate.
136
+
137
+ test('every direct createTask call site names a discipline or resolves one', () => {
138
+ const sites = [
139
+ ['modules/ideas/inbox.js', /discipline: \(lifecycle\.resolveWriteDiscipline/],
140
+ ['modules/copy-desk/routes/copy-desk.js', /discipline: 'artist'/],
141
+ ['modules/onboarding/newcomer-restock.js', /^\s*discipline,\s*$/m],
142
+ ['modules/lifecycle/kickoff-seed.js', /discipline: t\.discipline \|\| 'engineer'/],
143
+ ['modules/lifecycle/cascade.js', /discipline: rule\.discipline/],
144
+ ['scripts/gds/triage.js', /discipline: resolveWriteDiscipline\(/],
145
+ ];
146
+ for (const [rel, re] of sites) {
147
+ const src = fs.readFileSync(path.join(ROOT, rel), 'utf8');
148
+ assert.match(src, re, `${rel} must supply a discipline — createTask now throws without one`);
149
+ }
150
+ });
151
+
152
+ test('init.js no longer clobbers its own discipline default with undefined', () => {
153
+ // The spread ran AFTER `t.discipline || 'engineer'`, so `discipline: undefined` in
154
+ // the extras object reset it to nothing — which is why a freshly scaffolded
155
+ // instance was born with role-less starter tasks.
156
+ // Comment lines are stripped first: the fix's own explanation names the old
157
+ // expression, and a scan that cannot tell code from prose would either fail here
158
+ // forever or be "fixed" by deleting the explanation.
159
+ const src = fs.readFileSync(path.join(ROOT, 'scripts', 'gds', 'init.js'), 'utf8')
160
+ .split('\n').filter((l) => !/^\s*(\/\/|\*|\/\*)/.test(l)).join('\n');
161
+ assert.ok(!/discipline: undefined/.test(src), 'a later spread must not undo the default');
162
+ assert.match(src, /discipline: t\.discipline \|\| 'engineer'/);
163
+ });
@@ -47,13 +47,13 @@ const permissivePool = {
47
47
 
48
48
  test('a create with no goalId THROWS instead of quietly filing into a bucket', async () => {
49
49
  await assert.rejects(
50
- () => createTask({ versionId: 'BONGOS-V1', title: 't' }, { pool: permissivePool }),
50
+ () => createTask({ versionId: 'BONGOS-V1', title: 't', discipline: 'engineer' }, { pool: permissivePool }),
51
51
  /goalId is required/,
52
52
  'the old behaviour was to succeed and say nothing');
53
53
  });
54
54
 
55
55
  test('the error tells the caller both ways out', async () => {
56
- const err = await createTask({ versionId: 'BONGOS-V1', title: 't' }, { pool: permissivePool })
56
+ const err = await createTask({ versionId: 'BONGOS-V1', title: 't', discipline: 'engineer' }, { pool: permissivePool })
57
57
  .then(() => null, (e) => e);
58
58
  assert.ok(err, 'must reject');
59
59
  assert.match(err.message, /BV1\.R11/, 'names the rule, so the reason is findable');
@@ -63,7 +63,7 @@ test('the error tells the caller both ways out', async () => {
63
63
  test('only a literal true opts in — a truthy value still throws', async () => {
64
64
  for (const v of [1, 'yes', {}, 'true']) {
65
65
  await assert.rejects(
66
- () => createTask({ versionId: 'BONGOS-V1', title: 't', allowCatchAll: v }, { pool: permissivePool }),
66
+ () => createTask({ versionId: 'BONGOS-V1', title: 't', discipline: 'engineer', allowCatchAll: v }, { pool: permissivePool }),
67
67
  /goalId is required/,
68
68
  `${JSON.stringify(v)} must not open the hatch`);
69
69
  }
@@ -73,7 +73,7 @@ test('naming a goal needs no flag at all', async () => {
73
73
  // The ordinary path must stay ordinary: a caller with a goal never learns this
74
74
  // parameter exists.
75
75
  await createTask(
76
- { versionId: 'BONGOS-V1', title: 't', goalId: 77 },
76
+ { versionId: 'BONGOS-V1', title: 't', discipline: 'engineer', goalId: 77 },
77
77
  { pool: permissivePool, client: permissivePool },
78
78
  );
79
79
  });