@bongos/core 1.19.591 → 1.19.593
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 +72 -42
- package/.claude/skills/planning-session/SKILL.md +2 -2
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +3 -1
- package/clients/bongos-client/index.cjs +3 -1
- package/clients/bongos-client/index.d.ts +6 -2
- package/clients/bongos-client/index.mjs +3 -1
- package/docs/adr/0157-archon-is-rank-and-identity-only.md +2 -0
- package/docs/api/openapi.json +103 -7
- package/docs/api-reference.md +4 -3
- package/docs/copy-inventory.md +135 -116
- package/docs/copy-registry.json +319 -138
- package/docs/module-api-changelog.md +4 -0
- package/migrations/core_233_versions_one_planning_idx.sql +47 -0
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +3 -1
- package/modules/government/catalog.js +20 -11
- package/modules/government/migrations/government_012_version_create_archon.sql +45 -0
- package/modules/hall-ui/public/goals-render.js +182 -6
- package/modules/hall-ui/public/goals.css +51 -0
- package/modules/hall-ui/public/roadmap.css +5 -0
- package/modules/hall-ui/public/roadmap.js +25 -0
- package/modules/hall-ui/public/task-detail.js +22 -11
- package/modules/lifecycle/db-goals.js +34 -2
- package/modules/lifecycle/db-versions.js +195 -3
- package/modules/lifecycle/db.js +4 -1
- package/modules/lifecycle/routes/goals.js +15 -1
- package/modules/lifecycle/routes/version-route-authz.js +171 -1
- package/modules/lifecycle/routes/versions.js +130 -10
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/triage.js +60 -26
- package/src/module-api.js +1 -1
- package/tests/goal_archive_hall.mjs +198 -0
- package/tests/government_require_permission.mjs +7 -3
- package/tests/government_seed.mjs +101 -9
- package/tests/one_planning_version.mjs +25 -8
- package/tests/promote_goal_id.mjs +85 -30
- package/tests/publish_reconciler.mjs +35 -5
- package/tests/task_detail_ui.mjs +36 -9
- package/tests/version_build_slot.mjs +157 -0
- package/tests/version_close_route.mjs +261 -0
- package/tests/version_override_visibility.mjs +176 -0
|
@@ -74,6 +74,53 @@ function grantBlocksForRole(role) {
|
|
|
74
74
|
return blocks;
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
+
// REVOCATIONS (BV1.R21, task 1003608). The union model below was append-only, and
|
|
78
|
+
// the guard said in advance what would happen if that stopped being true: "A
|
|
79
|
+
// future migration that REVOKES (raising a floor) would make the union overstate
|
|
80
|
+
// what the DB holds." `government_012_version_create_archon.sql` is the first one
|
|
81
|
+
// — ADR 0250 §6 raises `version.create` back to the Archon floor, reversing the
|
|
82
|
+
// ADR 0157 delegation — so the model now subtracts.
|
|
83
|
+
//
|
|
84
|
+
// Matches BOTH table spellings on purpose. The old revoke-detector watched only
|
|
85
|
+
// `governance_role_permissions`, but government_001_rename_from_governance renamed
|
|
86
|
+
// it to `government_rank_permissions` with no shim, so a DELETE written against
|
|
87
|
+
// the CURRENT table name would have sailed straight past the tripwire that exists
|
|
88
|
+
// to catch exactly this. A guard that only recognises the historical spelling is
|
|
89
|
+
// not a guard.
|
|
90
|
+
const REVOKE_RE =
|
|
91
|
+
/DELETE\s+FROM\s+govern(?:ance_role|ment_rank)_permissions\s+WHERE\s+(?:rank_key|role_key)\s*=\s*'([^']+)'\s+AND\s+permission_key\s*=\s*'([^']+)'/gi;
|
|
92
|
+
|
|
93
|
+
function revokeBlocksForRole(role) {
|
|
94
|
+
const blocks = [];
|
|
95
|
+
for (const [file, sql] of grantSqlByFile) {
|
|
96
|
+
const code = sql.replace(/--[^\n]*/g, '');
|
|
97
|
+
for (const m of code.matchAll(new RegExp(REVOKE_RE.source, 'gi'))) {
|
|
98
|
+
if (m[1] === role) blocks.push({ file, key: m[2] });
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return blocks;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// The EFFECTIVE grant set: walk the migrations in filename order, adding each
|
|
105
|
+
// file's grants and removing its revocations, so a key granted early and revoked
|
|
106
|
+
// later ends up absent — and one revoked and then deliberately re-granted ends up
|
|
107
|
+
// present. A set-difference over the whole union would get the first case right
|
|
108
|
+
// and the second silently wrong.
|
|
109
|
+
function effectiveKeysForRole(role) {
|
|
110
|
+
const grantRe = new RegExp(`SELECT '${role}', unnest\\(ARRAY\\[([\\s\\S]*?)\\]\\)`, 'g');
|
|
111
|
+
const held = new Set();
|
|
112
|
+
for (const [, sql] of grantSqlByFile) {
|
|
113
|
+
const code = sql.replace(/--[^\n]*/g, '');
|
|
114
|
+
for (const m of code.matchAll(grantRe)) {
|
|
115
|
+
for (const k of [...m[1].matchAll(/'([^']+)'/g)].map((x) => x[1])) held.add(RENAMED_KEYS.get(k) || k);
|
|
116
|
+
}
|
|
117
|
+
for (const m of code.matchAll(new RegExp(REVOKE_RE.source, 'gi'))) {
|
|
118
|
+
if (m[1] === role) held.delete(RENAMED_KEYS.get(m[2]) || m[2]);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return held;
|
|
122
|
+
}
|
|
123
|
+
|
|
77
124
|
test('the union of every grant migration matches RANK_SEED exactly (no drift)', () => {
|
|
78
125
|
for (const role of RANK_ORDER) {
|
|
79
126
|
const blocks = grantBlocksForRole(role);
|
|
@@ -82,26 +129,64 @@ test('the union of every grant migration matches RANK_SEED exactly (no drift)',
|
|
|
82
129
|
assert.equal(b.keys.length, new Set(b.keys).size, `${role}: duplicate permission in the ${b.file} array`);
|
|
83
130
|
}
|
|
84
131
|
const granted = applyRenames(blocks.flatMap((b) => b.keys));
|
|
132
|
+
// The EFFECTIVE set — grants minus revocations, applied in file order. Since
|
|
133
|
+
// BV1.R21 (task 1003608) this is no longer the same thing as the union of the
|
|
134
|
+
// INSERTs: `version.create` is granted to metic by governance_007 and taken
|
|
135
|
+
// back by government_012.
|
|
136
|
+
const effective = effectiveKeysForRole(role);
|
|
85
137
|
// set-equality (order in SQL is illustrative; the grant set is what matters)
|
|
86
|
-
assert.deepEqual(
|
|
138
|
+
assert.deepEqual(effective, new Set(RANK_SEED[role]),
|
|
87
139
|
`${role}: grant migrations drift from catalog RANK_SEED`);
|
|
88
140
|
// no key re-granted in a LATER migration (a no-op INSERT that muddies the ledger)
|
|
89
141
|
assert.equal(granted.length, new Set(granted).size, `${role}: the same permission is granted twice across migrations`);
|
|
90
|
-
assert.equal(
|
|
142
|
+
assert.equal(effective.size, RANK_SEED[role].length, `${role}: grant count mismatch`);
|
|
91
143
|
}
|
|
92
144
|
});
|
|
93
145
|
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
|
|
146
|
+
// This test used to assert that NO migration revokes, with the note: "teach
|
|
147
|
+
// grantBlocksForRole to subtract before allowing this". BV1.R21 (task 1003608) is
|
|
148
|
+
// the first revocation and the subtraction is now taught, so the tripwire becomes
|
|
149
|
+
// its own follow-through: every DELETE in a grant migration must be in the exact
|
|
150
|
+
// shape effectiveKeysForRole can read. A revoke the model cannot parse is worse
|
|
151
|
+
// than one it forbids — it would leave the union overstating the DB while every
|
|
152
|
+
// test stayed green, which is the failure the original note was written to avoid.
|
|
153
|
+
test('every revoking migration is in the shape the union model subtracts', () => {
|
|
98
154
|
for (const [file, sql] of grantSqlByFile) {
|
|
99
155
|
const code = sql.replace(/--[^\n]*/g, '');
|
|
100
|
-
|
|
101
|
-
|
|
156
|
+
// Only DELETEs against the PERMISSION tables matter here. A migration may
|
|
157
|
+
// legitimately delete from the ASSIGNMENT tables — governance_005 re-syncs
|
|
158
|
+
// `governance_builder_roles`, which is who holds a rank, not what a rank
|
|
159
|
+
// holds — and that has nothing to do with the grant union.
|
|
160
|
+
const deletes = [...code.matchAll(/DELETE\s+FROM\s+(govern(?:ance_role|ment_rank)_permissions)\b/gi)];
|
|
161
|
+
if (deletes.length === 0) continue;
|
|
162
|
+
const parsed = [...code.matchAll(new RegExp(REVOKE_RE.source, 'gi'))];
|
|
163
|
+
assert.equal(parsed.length, deletes.length,
|
|
164
|
+
`${file}: ${deletes.length} DELETE(s) but only ${parsed.length} parseable by REVOKE_RE — `
|
|
165
|
+
+ 'the union model would silently overstate the grant set. Match the '
|
|
166
|
+
+ "`DELETE FROM government_rank_permissions WHERE rank_key = '…' AND permission_key = '…'` shape.");
|
|
167
|
+
for (const m of parsed) {
|
|
168
|
+
assert.ok(RANK_ORDER.includes(m[1]), `${file}: revokes from unknown rank '${m[1]}'`);
|
|
169
|
+
}
|
|
102
170
|
}
|
|
103
171
|
});
|
|
104
172
|
|
|
173
|
+
test('a revocation actually removes the key from the effective set', () => {
|
|
174
|
+
// The specific fact BV1.R21 turned on, asserted directly rather than only as a
|
|
175
|
+
// consequence of the drift comparison: governance_007 grants `version.create`
|
|
176
|
+
// to metic, government_012 takes it back, and the effective set must not hold
|
|
177
|
+
// it — while archon, which was granted it by governance_006, keeps it.
|
|
178
|
+
assert.equal(effectiveKeysForRole('metic').has('version.create'), false,
|
|
179
|
+
'ADR 0250 §6 raised version.create to the archon floor — a metic grant row would make that floor decorative');
|
|
180
|
+
assert.equal(effectiveKeysForRole('archon').has('version.create'), true,
|
|
181
|
+
'archon must still hold it, or POST /versions is unreachable by anyone');
|
|
182
|
+
// …and the raw union still CONTAINS it, which is the whole reason the model
|
|
183
|
+
// needed to change: if this ever stops being true the revoke migration was
|
|
184
|
+
// edited away rather than superseded.
|
|
185
|
+
const rawUnion = applyRenames(grantBlocksForRole('metic').flatMap((b) => b.keys));
|
|
186
|
+
assert.ok(rawUnion.includes('version.create'),
|
|
187
|
+
'the historical grant must remain in governance_007 — migrations are run-once and are never rewritten');
|
|
188
|
+
});
|
|
189
|
+
|
|
105
190
|
test('the seed inserts exactly the four seeded rank-roles', () => {
|
|
106
191
|
const roleRows = [...seedSql.matchAll(/\('(xenos|thetes|metic|archon)',\s*'[^']+',/g)].map((m) => m[1]);
|
|
107
192
|
assert.deepEqual(new Set(roleRows), new Set(RANK_ORDER));
|
|
@@ -140,7 +225,14 @@ test('later grant migrations are idempotent + transactional', () => {
|
|
|
140
225
|
const code = stripComments(sql);
|
|
141
226
|
const inserts = code.match(/INSERT INTO/g) || [];
|
|
142
227
|
const onConflict = code.match(/ON CONFLICT/g) || [];
|
|
143
|
-
|
|
228
|
+
const revokes = [...code.matchAll(new RegExp(REVOKE_RE.source, 'gi'))];
|
|
229
|
+
// A migration must DO something to the grant set — but since BV1.R21 (task
|
|
230
|
+
// 1003608) "something" includes taking a permission away, not only adding
|
|
231
|
+
// one. A REVOKE-only migration inserts nothing by design and needs no ON
|
|
232
|
+
// CONFLICT: deleting a row that is not there affects zero rows and raises
|
|
233
|
+
// nothing, so it is already naturally re-runnable.
|
|
234
|
+
assert.ok(inserts.length > 0 || revokes.length > 0,
|
|
235
|
+
`${file}: a grant migration must INSERT or REVOKE something`);
|
|
144
236
|
assert.equal(inserts.length, onConflict.length, `${file}: every INSERT must be ON CONFLICT DO NOTHING`);
|
|
145
237
|
assert.ok(/BEGIN;/.test(code) && /COMMIT;/.test(code), `${file}: must be transactional`);
|
|
146
238
|
}
|
|
@@ -141,14 +141,31 @@ test('the default status is planning, so an unqualified create hits the gate', (
|
|
|
141
141
|
assert.match(ROUTE, /const status = body\.status \|\| 'planning';/);
|
|
142
142
|
});
|
|
143
143
|
|
|
144
|
-
test('the
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
//
|
|
148
|
-
//
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
144
|
+
test('the planning race is now BACKED, and the comment says so honestly', () => {
|
|
145
|
+
// WHAT THIS TEST USED TO ASSERT, AND WHY IT FLIPPED. Until BV1.R19 (task
|
|
146
|
+
// 1003606) this pinned the words "RACE, ACCEPTED AND UNMITIGATED", because an
|
|
147
|
+
// earlier draft of the comment claimed the race was "left to a partial unique
|
|
148
|
+
// index" when no such index existed — a future reader taking that at face
|
|
149
|
+
// value would have believed two concurrent creates could not both slip
|
|
150
|
+
// through. R19 shipped the index (migration core_233), so the old wording is
|
|
151
|
+
// now the dishonest one. The TEST'S PURPOSE is unchanged: the comment must
|
|
152
|
+
// describe the mitigation that actually exists, no more and no less.
|
|
153
|
+
assert.match(ROUTE, /THE RACE IS CLOSED/);
|
|
154
|
+
assert.match(ROUTE, /versions_one_planning_idx/,
|
|
155
|
+
'the comment must name the index that backs it, so the claim is checkable');
|
|
156
|
+
assert.equal(/RACE, ACCEPTED AND UNMITIGATED/.test(ROUTE), false,
|
|
157
|
+
'the planning race is mitigated as of R19 — the old wording would now under-claim');
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test('the BUILDING half is still unmitigated, and the comment does not pretend otherwise', () => {
|
|
161
|
+
// The other half of the same honesty rule, and the one that matters now. R19
|
|
162
|
+
// could only ship ONE of ADR 0263 §9's two indexes: the live instance has two
|
|
163
|
+
// `building` versions, so `versions_one_building_idx` cannot be created until
|
|
164
|
+
// task 1003614 (R27) folds them. The building slot is therefore guarded by a
|
|
165
|
+
// check-then-insert with nothing behind it — exactly the state the planning
|
|
166
|
+
// slot was in before — and a reader who assumed symmetry would be wrong.
|
|
167
|
+
assert.match(ROUTE, /NOT BACKED BY AN INDEX YET/);
|
|
168
|
+
assert.match(ROUTE, /1003614/, 'the comment must name the task that makes the index legal');
|
|
152
169
|
});
|
|
153
170
|
|
|
154
171
|
// ---- the rule is one half of a pair ----------------------------------------
|
|
@@ -122,29 +122,61 @@ await test('BEHAVIOUR: a non-ok or empty response is "none", distinct from "unav
|
|
|
122
122
|
assert.equal(notOk.source, 'none', 'the two are reported differently because the operator advice differs');
|
|
123
123
|
});
|
|
124
124
|
|
|
125
|
-
await test('BEHAVIOUR: blank ACCEPTS the default
|
|
126
|
-
assert.deepEqual(triage.applyGoalAnswer(1000071, ''), { goalId: 1000071, warn: null });
|
|
127
|
-
assert.deepEqual(triage.applyGoalAnswer(1000071, ' '), { goalId: 1000071, warn: null },
|
|
125
|
+
await test('BEHAVIOUR: blank ACCEPTS the default when there is one', () => {
|
|
126
|
+
assert.deepEqual(triage.applyGoalAnswer(1000071, ''), { ok: true, goalId: 1000071, warn: null });
|
|
127
|
+
assert.deepEqual(triage.applyGoalAnswer(1000071, ' '), { ok: true, goalId: 1000071, warn: null },
|
|
128
128
|
'whitespace is a bare Enter, not a decision');
|
|
129
|
-
assert.deepEqual(triage.applyGoalAnswer(1000071, '
|
|
130
|
-
assert.deepEqual(triage.applyGoalAnswer(1000071, 'none'), { goalId: null, warn: null });
|
|
131
|
-
assert.deepEqual(triage.applyGoalAnswer(1000071, 'NONE'), { goalId: null, warn: null });
|
|
132
|
-
assert.deepEqual(triage.applyGoalAnswer(1000071, '1000058'), { goalId: 1000058, warn: null },
|
|
129
|
+
assert.deepEqual(triage.applyGoalAnswer(1000071, '1000058'), { ok: true, goalId: 1000058, warn: null },
|
|
133
130
|
'an explicit id overrides the default');
|
|
134
131
|
});
|
|
135
132
|
|
|
136
|
-
|
|
133
|
+
// BV1.R22 (task 1003609). These three used to resolve to `goalId: null`, which was
|
|
134
|
+
// fine while a goal-less create fell into the version's "<version> — general"
|
|
135
|
+
// catch-all. R11 (task 1003598) deleted that bucket and made goal_id required, so
|
|
136
|
+
// null is now a server 400 arriving AFTER the operator has typed a title, a
|
|
137
|
+
// description and a priority — and losing all of it. Each is a local refusal now.
|
|
138
|
+
|
|
139
|
+
await test('BEHAVIOUR: "-"/"none" no longer opt out — the bucket they opted into is gone', () => {
|
|
140
|
+
for (const decline of ['-', 'none', 'NONE']) {
|
|
141
|
+
const r = triage.applyGoalAnswer(1000071, decline);
|
|
142
|
+
assert.equal(r.ok, false, `"${decline}" must not resolve`);
|
|
143
|
+
assert.equal(r.goalId, null);
|
|
144
|
+
assert.match(r.warn, /no longer exists|must name a real goal/,
|
|
145
|
+
`"${decline}" must say the bucket is gone, not merely refuse`);
|
|
146
|
+
assert.doesNotMatch(r.warn, /falling back/, 'nothing falls back any more');
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
await test('BEHAVIOUR: blank with NO default refuses rather than sending null', () => {
|
|
151
|
+
const r = triage.applyGoalAnswer(null, '');
|
|
152
|
+
assert.equal(r.ok, false, 'no default and no answer is not a decision');
|
|
153
|
+
assert.match(r.warn, /a goal is required/);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
await test('BEHAVIOUR: junk REFUSES rather than downgrading a typo into a decision', () => {
|
|
137
157
|
for (const junk of ['abc', '12.5', '-4', '0', 'goal 7']) {
|
|
138
158
|
const r = triage.applyGoalAnswer(1000071, junk);
|
|
139
|
-
assert.equal(r.
|
|
140
|
-
assert.
|
|
159
|
+
assert.equal(r.ok, false, `"${junk}" must not resolve to a goal`);
|
|
160
|
+
assert.equal(r.goalId, null);
|
|
161
|
+
assert.match(r.warn, /not a goal id/, `"${junk}" must name the problem`);
|
|
162
|
+
assert.doesNotMatch(r.warn, /general bucket/, 'and must not offer a bucket that no longer exists');
|
|
141
163
|
}
|
|
142
164
|
// Number('') is 0 and Number(' ') is 0 — both would silently become goal 0
|
|
143
165
|
// without the blank check above running first. Guarding the pair together.
|
|
144
166
|
assert.equal(triage.applyGoalAnswer(9, '').goalId, 9);
|
|
145
167
|
});
|
|
146
168
|
|
|
147
|
-
await test('BEHAVIOUR: createTask
|
|
169
|
+
await test('BEHAVIOUR: createTask refuses to post without a goal, at its own call site', async () => {
|
|
170
|
+
// The prompt loop re-asks until it has a real id, so the only route here with
|
|
171
|
+
// null is a NEW call site that forgot — which should fail at its author rather
|
|
172
|
+
// than as a 400 in production.
|
|
173
|
+
await assert.rejects(
|
|
174
|
+
() => triage.createTask({ versionId: 'BV1', title: 't', description: '', priority: 3, sourceRef: null }),
|
|
175
|
+
/goal_id is required/,
|
|
176
|
+
);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
await test('BEHAVIOUR: createTask puts goal_id on the body, and REFUSES when it is null', async () => {
|
|
148
180
|
const seen = [];
|
|
149
181
|
const fakeClient = {
|
|
150
182
|
tasks: { postTasks: async (req) => { seen.push(req.body); return { ok: true, data: { task: { id: 7 } } }; } },
|
|
@@ -157,12 +189,18 @@ await test('BEHAVIOUR: createTask puts goal_id on the body, and omits it entirel
|
|
|
157
189
|
// object cannot reach it — re-require in a fresh registry instead.
|
|
158
190
|
const fresh = freshTriage();
|
|
159
191
|
await fresh.createTask({ versionId: 'BV1', title: 't', description: 'd', priority: 3, sourceRef: 'idea:1', goalId: 1000071 });
|
|
160
|
-
await fresh.createTask({ versionId: 'BV1', title: 't', description: 'd', priority: 3, sourceRef: 'idea:2' });
|
|
161
192
|
assert.equal(seen[0].goal_id, 1000071, 'the goal must reach the server');
|
|
162
|
-
assert.ok(!('goal_id' in seen[1]),
|
|
163
|
-
'and opting out must send NO goal_id key — the server\'s catch-all only applies on absent');
|
|
164
193
|
assert.equal(seen[0].source, 'idea_inbox', 'provenance still travels');
|
|
165
194
|
assert.equal(seen[0].status, 'backlog');
|
|
195
|
+
// BV1.R22 (task 1003609): omitting the goal used to send no goal_id key and let
|
|
196
|
+
// the server file it in the version catch-all. R11 deleted that catch-all, so a
|
|
197
|
+
// goal-less call is now a caller bug — and it must fail HERE, not as a 400 after
|
|
198
|
+
// the operator has typed everything.
|
|
199
|
+
await assert.rejects(
|
|
200
|
+
() => fresh.createTask({ versionId: 'BV1', title: 't', description: 'd', priority: 3, sourceRef: 'idea:2' }),
|
|
201
|
+
/goal_id is required/,
|
|
202
|
+
);
|
|
203
|
+
assert.equal(seen.length, 1, 'and nothing was posted for the goal-less call');
|
|
166
204
|
} finally {
|
|
167
205
|
cliLib.cliClient = realCliClient;
|
|
168
206
|
}
|
|
@@ -192,8 +230,10 @@ await test('BEHAVIOUR: a failed create throws with the status, it does not retur
|
|
|
192
230
|
// ---- 4. both paths send goal_id -------------------------------------------
|
|
193
231
|
|
|
194
232
|
await test('BOTH PATHS: the CLI createTask and the hall payload each send goal_id', () => {
|
|
195
|
-
assert.match(TRIAGE, /
|
|
233
|
+
assert.match(TRIAGE, /body\.goal_id = Number\(goalId\);/,
|
|
196
234
|
'CLI: goal_id must reach the create body');
|
|
235
|
+
assert.doesNotMatch(TRIAGE, /if \(goalId != null\) body\.goal_id/,
|
|
236
|
+
'CLI: and unconditionally — a conditional send is what made a goal-less create look survivable');
|
|
197
237
|
assert.match(TRIAGE, /async function createTask\(\{[^}]*goalId/,
|
|
198
238
|
'CLI: createTask must accept it rather than ignore the caller');
|
|
199
239
|
assert.match(HALL, /payload\.goal_id = Number\(form\.goal\.value\)/,
|
|
@@ -234,31 +274,46 @@ await test('the suggester default shows score + matched terms so it can be judge
|
|
|
234
274
|
|
|
235
275
|
// ---- 2. opting out is explicit ------------------------------------------
|
|
236
276
|
|
|
237
|
-
|
|
277
|
+
// BV1.R22 (task 1003609) removed the opt-out from BOTH surfaces, because the
|
|
278
|
+
// bucket it opted into no longer exists. These three tests pinned the affordance;
|
|
279
|
+
// they now pin its ABSENCE, which is the assertion that actually protects the
|
|
280
|
+
// invariant — a re-added opt-out would silently start producing 400s again.
|
|
281
|
+
|
|
282
|
+
await test('OPT-OUT: the CLI prompt RE-ASKS instead of offering a bucket that is gone', () => {
|
|
238
283
|
const branch = TRIAGE.slice(TRIAGE.indexOf('const gd = await resolveGoalDefault'), TRIAGE.indexOf('const task = await createTask'));
|
|
239
|
-
assert.
|
|
284
|
+
assert.doesNotMatch(branch, /general bucket/, 'there is no general bucket to offer');
|
|
240
285
|
assert.match(branch, /applyGoalAnswer\(gd\.goalId, goalAns\)/,
|
|
241
286
|
'the branch must delegate to the tested helper, not re-implement the parsing inline');
|
|
242
|
-
assert.match(branch, /picked\.
|
|
287
|
+
assert.match(branch, /picked\.ok/, 'and act on its verdict');
|
|
288
|
+
assert.match(branch, /picked\.warn/, 'and surface its reason');
|
|
289
|
+
assert.match(branch, /for \(;;\)/, 'a refusal re-asks rather than falling through');
|
|
290
|
+
assert.match(branch, /'s'/, "and skipping stays available, so one unplaceable idea cannot wedge the run");
|
|
243
291
|
assert.match(branch, /resolveGoalDefault\(\{ \.\.\.idea, title \}, versionId\)/,
|
|
244
292
|
'the suggester must see the title the operator just confirmed, not the filed one');
|
|
245
|
-
// The semantics themselves are covered by the BEHAVIOUR block above — this
|
|
246
|
-
// only pins that main() is actually wired to them.
|
|
247
293
|
});
|
|
248
294
|
|
|
249
|
-
await test('OPT-OUT: the hall
|
|
295
|
+
await test('OPT-OUT: the hall offers no empty goal, and refuses one before it posts', () => {
|
|
250
296
|
const fn = HALL.slice(HALL.indexOf('async function goalOptionsFor'), HALL.indexOf('async function renderPromote'));
|
|
251
|
-
//
|
|
252
|
-
// the
|
|
253
|
-
|
|
254
|
-
assert.
|
|
255
|
-
assert.
|
|
256
|
-
|
|
297
|
+
// Scoped to the emitted <option> markup: the code comment above it explains WHY
|
|
298
|
+
// the bucket is gone and must be allowed to name it.
|
|
299
|
+
const optionMarkup = fn.split('\n').filter((l) => !l.trim().startsWith('//')).join('\n');
|
|
300
|
+
assert.doesNotMatch(optionMarkup, /general bucket/, 'the bucket is gone from the label too');
|
|
301
|
+
assert.ok(!/— no goal —/.test(fn), 'and no misleading "no goal" option came back');
|
|
302
|
+
// What remains when nothing is suggested must be a PLACEHOLDER, not a value:
|
|
303
|
+
// `disabled` is what stops a blank-looking select from being submittable.
|
|
304
|
+
assert.match(fn, /selected disabled/, 'the empty row is an unsubmittable placeholder');
|
|
305
|
+
const submit = HALL.slice(HALL.indexOf('async function submitPromote'), HALL.indexOf('function renderMerge'));
|
|
306
|
+
assert.match(submit, /form\.goal\.value === ''/, 'and the submit refuses an empty goal');
|
|
307
|
+
assert.match(submit, /payload\.goal_id = Number\(form\.goal\.value\)/,
|
|
308
|
+
'goal_id is sent unconditionally now, not behind an if');
|
|
309
|
+
assert.doesNotMatch(submit, /if \(form\.goal && form\.goal\.value !== ''\) payload\.goal_id/,
|
|
310
|
+
'the conditional send is what made an empty select look survivable');
|
|
257
311
|
});
|
|
258
312
|
|
|
259
|
-
await test('the CLI result line names the goal
|
|
260
|
-
assert.match(TRIAGE, /goal \$\{goalId\}
|
|
261
|
-
'where it landed must be visible in the output
|
|
313
|
+
await test('the CLI result line names the goal it landed in', () => {
|
|
314
|
+
assert.match(TRIAGE, /P\$\{priority\}, goal \$\{goalId\}/,
|
|
315
|
+
'where it landed must be visible in the output');
|
|
316
|
+
assert.doesNotMatch(TRIAGE, /→ general bucket/, 'and there is no second way for it to land');
|
|
262
317
|
});
|
|
263
318
|
|
|
264
319
|
// ---- 3. degrades, never blocks -----------------------------------------
|
|
@@ -1651,13 +1651,43 @@ test('vector 2: shipped_at is stamped ONLY by shipTask, and shipTask requires co
|
|
|
1651
1651
|
// task 1003204: the lifecycle data layer is db.js + db-*.js now — read the family.
|
|
1652
1652
|
const dbSrc = lifecycleDbSource();
|
|
1653
1653
|
|
|
1654
|
-
// Every statement that writes shipped_at, ignoring SELECT projections
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1654
|
+
// Every statement that writes a TASK's shipped_at, ignoring SELECT projections
|
|
1655
|
+
// and comments.
|
|
1656
|
+
//
|
|
1657
|
+
// THE TABLE MATTERS, and this guard learned that the hard way (BV1.R12, task
|
|
1658
|
+
// 1003599). `versions` has a `shipped_at` too, and the version-close route
|
|
1659
|
+
// stamps it — a legitimate write on a different table that has nothing to do
|
|
1660
|
+
// with the grader-bypass vector. A bare /shipped_at = now()/ over the whole
|
|
1661
|
+
// lifecycle db family counted it as a second TASK stamp site and reddened main.
|
|
1662
|
+
//
|
|
1663
|
+
// Widening the count to 2 would have been the wrong repair: it buys silence
|
|
1664
|
+
// now and re-opens the hole the moment a third site appears anywhere. What this
|
|
1665
|
+
// guard is actually about is stated in its own comment above — "no other write
|
|
1666
|
+
// path can stamp shipped_at ON A TASK" — so the fix is to say `tasks`, not to
|
|
1667
|
+
// raise the number. Each match is attributed to the nearest preceding UPDATE
|
|
1668
|
+
// target, and only `tasks` counts.
|
|
1669
|
+
const stamps = [...dbSrc.matchAll(/shipped_at\s*=\s*now\(\)/g)];
|
|
1670
|
+
const taskStamps = stamps.filter((m) => {
|
|
1671
|
+
const before = dbSrc.slice(0, m.index);
|
|
1672
|
+
const lastUpdate = before.lastIndexOf('UPDATE ');
|
|
1673
|
+
if (lastUpdate === -1) return false;
|
|
1674
|
+
return /^UPDATE\s+tasks\b/.test(before.slice(lastUpdate));
|
|
1675
|
+
});
|
|
1676
|
+
assert.equal(taskStamps.length, 1,
|
|
1677
|
+
`a task's shipped_at is written in ${taskStamps.length} places — a second stamp site is how the grader-bypass vector reopens`);
|
|
1678
|
+
// And the attribution itself must be working: if the regex above ever stopped
|
|
1679
|
+
// matching the real site, `taskStamps` would be 0 and the assert would still be
|
|
1680
|
+
// meaningful — but a silent 0 total would mean the family moved and this guard
|
|
1681
|
+
// is now watching nothing at all.
|
|
1682
|
+
assert.ok(stamps.length >= 1, 'no shipped_at stamp found anywhere — the lifecycle db family moved and this guard is blind');
|
|
1658
1683
|
|
|
1659
1684
|
// …and that one site is guarded on the confirmed state.
|
|
1660
|
-
|
|
1685
|
+
// Anchored on the ATTRIBUTED task stamp, not `indexOf` — with two shipped_at
|
|
1686
|
+
// sites in the family, "the first one in file order" is whichever file the
|
|
1687
|
+
// carve happens to concatenate first, and that is not a property anyone is
|
|
1688
|
+
// maintaining. The versions stamp has no `status = 'confirmed'` guard and
|
|
1689
|
+
// should not: it is a different table with a different rule.
|
|
1690
|
+
const stampIdx = taskStamps[0].index;
|
|
1661
1691
|
const window = dbSrc.slice(stampIdx, stampIdx + 500);
|
|
1662
1692
|
assert.match(window, /WHERE id = \$1 AND status = 'confirmed'/,
|
|
1663
1693
|
'the stamp must refuse any task not already confirmed — that is what stops a confirm-time ship');
|
package/tests/task_detail_ui.mjs
CHANGED
|
@@ -259,7 +259,7 @@ await test('opening Promote loads open versions only, and preselects the idea\'s
|
|
|
259
259
|
assert.equal(vsel.value, 'BV2', 'the idea\'s suggested_version is honoured');
|
|
260
260
|
});
|
|
261
261
|
|
|
262
|
-
await test('the goal picker leads with the STORED hint, then the suggester
|
|
262
|
+
await test('the goal picker leads with the STORED hint, then the suggester — and offers NO opt-out', async () => {
|
|
263
263
|
const h = await boot();
|
|
264
264
|
await h.clickGo('promote');
|
|
265
265
|
const opts = h.goalSelect().querySelectorAll('option');
|
|
@@ -268,7 +268,14 @@ await test('the goal picker leads with the STORED hint, then the suggester, then
|
|
|
268
268
|
assert.match(opts[0].textContent, /carried from the filing/);
|
|
269
269
|
assert.equal(opts[1].attrs.value, '1000062', 'the suggester fills in below it');
|
|
270
270
|
assert.match(opts[1].textContent, /matched: promote, merge/, 'with the evidence for judging it');
|
|
271
|
-
|
|
271
|
+
// BV1.R22 (task 1003609) DELETED the trailing opt-out, and this assertion
|
|
272
|
+
// flipped with it. R11 (task 1003598) removed db.createTask's catch-all
|
|
273
|
+
// fallback and made goal_id required, so an empty selection stopped meaning
|
|
274
|
+
// "file it in the version's general bucket" and started meaning a server 400
|
|
275
|
+
// landing on a builder who had already filled the form. A choice that always
|
|
276
|
+
// fails is worse than no choice.
|
|
277
|
+
assert.equal(opts.filter((o) => o.attrs.value === '').length, 0,
|
|
278
|
+
'no empty-valued option may be offered once a goal is required — it could only ever 400');
|
|
272
279
|
});
|
|
273
280
|
|
|
274
281
|
await test('with no stored hint the top suggestion is preselected instead', async () => {
|
|
@@ -277,12 +284,19 @@ await test('with no stored hint the top suggestion is preselected instead', asyn
|
|
|
277
284
|
assert.equal(h.goalSelect().value, '1000062', 'the suggester supplies the default when nothing was named');
|
|
278
285
|
});
|
|
279
286
|
|
|
280
|
-
await test('a suggester outage degrades to
|
|
287
|
+
await test('a suggester outage degrades to a DISABLED placeholder that says what to do', async () => {
|
|
281
288
|
const h = await boot({ suggestThrows: true, idea: { ...DEFAULT_IDEA, suggested_goal_id: null } });
|
|
282
289
|
await h.clickGo('promote');
|
|
283
290
|
const opts = h.goalSelect().querySelectorAll('option');
|
|
284
|
-
assert.equal(opts.length, 1, 'only the
|
|
285
|
-
|
|
291
|
+
assert.equal(opts.length, 1, 'only the placeholder remains');
|
|
292
|
+
// Before BV1.R22 this row was a submittable opt-out and this test said so. It
|
|
293
|
+
// is now a PLACEHOLDER, not a value: `disabled`, so the select can never be
|
|
294
|
+
// blank-but-valid-looking, and the text tells the builder where to go instead.
|
|
295
|
+
// The old assertion passed either way, because it never checked `disabled` —
|
|
296
|
+
// which is exactly how a contract change hides inside a green test.
|
|
297
|
+
assert.ok('disabled' in opts[0].attrs, 'the placeholder must not be submittable');
|
|
298
|
+
assert.match(opts[0].textContent, /open the goal board and pick one/,
|
|
299
|
+
'a builder with no suggestion is told what to do, not handed a choice that fails');
|
|
286
300
|
});
|
|
287
301
|
|
|
288
302
|
await test('promote with a goal chosen POSTs goal_id, then marks the idea promoted', async () => {
|
|
@@ -320,14 +334,27 @@ await test('the title survives escaping — what the form renders is what gets c
|
|
|
320
334
|
'quotes and angle brackets must round-trip through the value attribute unmangled');
|
|
321
335
|
});
|
|
322
336
|
|
|
323
|
-
await test('
|
|
337
|
+
await test('there is no way to promote WITHOUT a goal any more', async () => {
|
|
338
|
+
// THIS TEST'S PREMISE INVERTED at BV1.R22 (task 1003609). It used to assert
|
|
339
|
+
// that clearing the picker sent no `goal_id` key, because an absent key meant
|
|
340
|
+
// "the version's general bucket". R11 (task 1003598) deleted that bucket and
|
|
341
|
+
// made `goal_id` required, so the behaviour it pinned is now a 400 — and the
|
|
342
|
+
// test itself crashed with a TypeError once the option it selected stopped
|
|
343
|
+
// existing, which is what surfaced this.
|
|
344
|
+
//
|
|
345
|
+
// What replaces it is the invariant that actually matters now: the form cannot
|
|
346
|
+
// be made to submit without a goal. Deleting the test outright would leave that
|
|
347
|
+
// unguarded.
|
|
324
348
|
const h = await boot();
|
|
325
349
|
await h.clickGo('promote');
|
|
326
|
-
h.goalSelect().
|
|
350
|
+
const opts = h.goalSelect().querySelectorAll('option');
|
|
351
|
+
const submittableBlank = opts.filter((o) => o.attrs.value === '' && !('disabled' in o.attrs));
|
|
352
|
+
assert.equal(submittableBlank.length, 0, 'no blank option may be both offered and submittable');
|
|
353
|
+
// And the picker's default is a real goal, so an untouched form is already valid.
|
|
354
|
+
assert.ok(h.goalSelect().value, 'the picker opens on a real goal, never on nothing');
|
|
327
355
|
await h.submitForm('promote');
|
|
328
356
|
const body = h.requestsTo('POST', /\/tasks$/)[0].body;
|
|
329
|
-
assert.ok(
|
|
330
|
-
'the version\'s general bucket applies only on an ABSENT key — sending null or "" would be a different create');
|
|
357
|
+
assert.ok('goal_id' in body && body.goal_id, 'every promote now names a goal');
|
|
331
358
|
});
|
|
332
359
|
|
|
333
360
|
await test('an empty title is refused in the browser, before a doomed request', async () => {
|