@bongos/core 1.19.161 → 1.19.163
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 +26 -11
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +2 -0
- package/modules/ideas/completeness.js +72 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +5 -1
- package/tests/idea_completeness.mjs +98 -0
- package/tests/idea_quick_guard.mjs +142 -0
package/.bongos-core.json
CHANGED
|
@@ -2,22 +2,22 @@
|
|
|
2
2
|
"artifact": "bongos-core",
|
|
3
3
|
"manifest_schema": 1,
|
|
4
4
|
"generator": "scripts/gds/package-core.js",
|
|
5
|
-
"core_version": "1.19.
|
|
6
|
-
"core_contract": "1.19.
|
|
7
|
-
"source_commit": "
|
|
5
|
+
"core_version": "1.19.163",
|
|
6
|
+
"core_contract": "1.19.163",
|
|
7
|
+
"source_commit": "cf29eabb9874ecd380580fa762e09cf7ab412a1e",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-08-16T16:
|
|
9
|
+
"built_at": "2026-08-16T16:52:34.284Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
12
|
"docs_redacted": 305,
|
|
13
13
|
"agent_docs_stubbed": 21,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 1262,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 1588,
|
|
20
|
+
"tree_sha256": "5c874eb035676d192575fc9366d0ddb5dac5a4af849f8c7034694ddb591d421c",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/blocker-review/SKILL.md",
|
|
@@ -2662,7 +2662,7 @@
|
|
|
2662
2662
|
{
|
|
2663
2663
|
"path": "modules/dev-box/app/src/vendor/bongos-client.cjs",
|
|
2664
2664
|
"mode": "0000644",
|
|
2665
|
-
"sha256": "
|
|
2665
|
+
"sha256": "257ac54c66f7969090065a34c29e2874fa6f9d111b6e75f0510ab1a2fcb3ef17"
|
|
2666
2666
|
},
|
|
2667
2667
|
{
|
|
2668
2668
|
"path": "modules/dev-box/app/test/unit.mjs",
|
|
@@ -3454,6 +3454,11 @@
|
|
|
3454
3454
|
"mode": "0000644",
|
|
3455
3455
|
"sha256": "73349f0209190305a61e2f4d771fa466c12be4f9034b4134b60a9e4a560e1c5a"
|
|
3456
3456
|
},
|
|
3457
|
+
{
|
|
3458
|
+
"path": "modules/ideas/completeness.js",
|
|
3459
|
+
"mode": "0000644",
|
|
3460
|
+
"sha256": "84d977372c79a46f8481176b7cdc740cbaca9480af3464b7b66183aad6d6e180"
|
|
3461
|
+
},
|
|
3457
3462
|
{
|
|
3458
3463
|
"path": "modules/ideas/inbox.js",
|
|
3459
3464
|
"mode": "0000644",
|
|
@@ -4197,12 +4202,12 @@
|
|
|
4197
4202
|
{
|
|
4198
4203
|
"path": "package-lock.json",
|
|
4199
4204
|
"mode": "0000644",
|
|
4200
|
-
"sha256": "
|
|
4205
|
+
"sha256": "572ae700360b73fdbc62135f01397fd335bcb5a735d113962a8343377672268c"
|
|
4201
4206
|
},
|
|
4202
4207
|
{
|
|
4203
4208
|
"path": "package.json",
|
|
4204
4209
|
"mode": "0000644",
|
|
4205
|
-
"sha256": "
|
|
4210
|
+
"sha256": "1460066c89ad9a7db9dc62ae0b6766c5e11cc18d8268fb95a4ae8767325569c5"
|
|
4206
4211
|
},
|
|
4207
4212
|
{
|
|
4208
4213
|
"path": "public-docs/index.html",
|
|
@@ -5797,7 +5802,7 @@
|
|
|
5797
5802
|
{
|
|
5798
5803
|
"path": "src/module-api.js",
|
|
5799
5804
|
"mode": "0000644",
|
|
5800
|
-
"sha256": "
|
|
5805
|
+
"sha256": "4e7edb74be33ea3e2b126dadb6a51dce083c4df6eb8c0521c0c7f484997edbe4"
|
|
5801
5806
|
},
|
|
5802
5807
|
{
|
|
5803
5808
|
"path": "src/module-loader/catalog.js",
|
|
@@ -6904,11 +6909,21 @@
|
|
|
6904
6909
|
"mode": "0000644",
|
|
6905
6910
|
"sha256": "7d384cd84cdc93b8e7b57df291a4600a40f43346fd2a52168324ecde219f1388"
|
|
6906
6911
|
},
|
|
6912
|
+
{
|
|
6913
|
+
"path": "tests/idea_completeness.mjs",
|
|
6914
|
+
"mode": "0000644",
|
|
6915
|
+
"sha256": "e7b6af463185ea77f40d62408026c55c7fb077f18999b972564b3b5bc7656ca5"
|
|
6916
|
+
},
|
|
6907
6917
|
{
|
|
6908
6918
|
"path": "tests/idea_grades.mjs",
|
|
6909
6919
|
"mode": "0000644",
|
|
6910
6920
|
"sha256": "07cacc97990f0e775342f8b30171c87128e0acb1abd38d64c683c3f257927ebd"
|
|
6911
6921
|
},
|
|
6922
|
+
{
|
|
6923
|
+
"path": "tests/idea_quick_guard.mjs",
|
|
6924
|
+
"mode": "0000644",
|
|
6925
|
+
"sha256": "9be4414bbbbb92513a7737d4494265b24c5f5db71a67e840016cc275442df1fb"
|
|
6926
|
+
},
|
|
6912
6927
|
{
|
|
6913
6928
|
"path": "tests/idea_templates.mjs",
|
|
6914
6929
|
"mode": "0000644",
|
|
@@ -354,6 +354,8 @@ function createClient(opts = {}) {
|
|
|
354
354
|
deleteGovernanceRolesRoleKeyPermissionsPermissionKey: (args) => request("DELETE", "/governance/roles/{roleKey}/permissions/{permissionKey}", { hasBody: false }, args),
|
|
355
355
|
// PUT /governance/roles/{roleKey}/permissions/{permissionKey} — rank: archon — PUT /governance/roles/:roleKey/permissions/:permissionKey
|
|
356
356
|
putGovernanceRolesRoleKeyPermissionsPermissionKey: (args) => request("PUT", "/governance/roles/{roleKey}/permissions/{permissionKey}", { hasBody: true }, args),
|
|
357
|
+
// POST /governance/roles/{roleKey}/reset — rank: archon — POST /governance/roles/:roleKey/reset
|
|
358
|
+
postGovernanceRolesRoleKeyReset: (args) => request("POST", "/governance/roles/{roleKey}/reset", { hasBody: true }, args),
|
|
357
359
|
},
|
|
358
360
|
"grades": {
|
|
359
361
|
// GET /grades/by-builder — rank: metic+archon — GET /grades/by-builder
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// modules/ideas/completeness.js — the deterministic Full Idea completeness
|
|
2
|
+
// scorer (task 1002918, BV1.R142, goal 1000062).
|
|
3
|
+
//
|
|
4
|
+
// WHY DETERMINISTIC (the owner's call, recorded on the goal): the score feeds
|
|
5
|
+
// karma (R147), so it must be predictable, free and reproducible — the same
|
|
6
|
+
// input scores the same number on every machine, every day, with no model
|
|
7
|
+
// having a good day. An AI depth-judge is a deliberately LATER seam; nothing
|
|
8
|
+
// here calls a network or reads a clock.
|
|
9
|
+
//
|
|
10
|
+
// WHAT IT MEASURES — PRESENCE + SUBSTANCE, NOTHING SMARTER. It walks the R141
|
|
11
|
+
// template declaration (never five hard-coded names): each declared field
|
|
12
|
+
// contributes equally; a field earns its share in proportion to how much
|
|
13
|
+
// actual text it carries against the template's `substanceMin` floor, capped
|
|
14
|
+
// at 1. "Actual text" is the trimmed, whitespace-collapsed length, so a page
|
|
15
|
+
// of blank lines scores as the nothing it is. That is the whole algorithm —
|
|
16
|
+
// honest about its limit: a determined padder can beat a character counter,
|
|
17
|
+
// and catching THAT is the future AI seam (goal notes), not a cleverness this
|
|
18
|
+
// number should pretend to.
|
|
19
|
+
//
|
|
20
|
+
// NEVER THROWS INTO THE CAPTURE PATH (the task's hard requirement): malformed
|
|
21
|
+
// input — unknown template, non-object values, non-string fields — returns a
|
|
22
|
+
// null score, not an exception. A null completeness_score is a legal stored
|
|
23
|
+
// state (ideas_001_grades.sql: scoring never blocks capture), so "could not
|
|
24
|
+
// score" and "not scored yet" deliberately look the same downstream.
|
|
25
|
+
//
|
|
26
|
+
// The write side already exists (inbox.js setIdeaCompleteness, 0–100 int);
|
|
27
|
+
// capture (R143) computes-and-stores via this module, and the triage ordering
|
|
28
|
+
// (R148) reads the stored number — neither re-implements the arithmetic.
|
|
29
|
+
|
|
30
|
+
'use strict';
|
|
31
|
+
|
|
32
|
+
const { templateFor, BUILTIN_TEMPLATES } = require('./templates');
|
|
33
|
+
const { DEFAULT_TEMPLATE_KEY } = require('./inbox');
|
|
34
|
+
|
|
35
|
+
// The substance of a value: the length of its trimmed, whitespace-collapsed
|
|
36
|
+
// text. Non-strings carry no substance (validation refuses them upstream; the
|
|
37
|
+
// scorer just declines to credit them).
|
|
38
|
+
function substanceOf(value) {
|
|
39
|
+
if (typeof value !== 'string') return 0;
|
|
40
|
+
return value.trim().replace(/\s+/g, ' ').length;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Score field_values against a template. Returns
|
|
44
|
+
// { score, fields: [{ key, substance, floor, ratio }] }
|
|
45
|
+
// where score is an integer 0–100 (ready for setIdeaCompleteness) or NULL when
|
|
46
|
+
// the input cannot be scored. `fields` is the per-field breakdown in TEMPLATE
|
|
47
|
+
// ORDER (fieldOrder's contract) — the hall's live meter (R152) and the triage
|
|
48
|
+
// view (R148) render it without redoing the arithmetic.
|
|
49
|
+
function scoreFieldValues(fieldValues, { templateKey = DEFAULT_TEMPLATE_KEY, registry = BUILTIN_TEMPLATES } = {}) {
|
|
50
|
+
const template = templateFor(templateKey, registry);
|
|
51
|
+
if (!template || !template.fields.length) return { score: null, fields: [] };
|
|
52
|
+
if (!fieldValues || typeof fieldValues !== 'object' || Array.isArray(fieldValues)) {
|
|
53
|
+
return { score: null, fields: [] };
|
|
54
|
+
}
|
|
55
|
+
const fields = template.fields.map((f) => {
|
|
56
|
+
const substance = substanceOf(fieldValues[f.key]);
|
|
57
|
+
const floor = f.substanceMin > 0 ? f.substanceMin : 1;
|
|
58
|
+
return { key: f.key, substance, floor, ratio: Math.min(1, substance / floor) };
|
|
59
|
+
});
|
|
60
|
+
const mean = fields.reduce((sum, f) => sum + f.ratio, 0) / fields.length;
|
|
61
|
+
return { score: Math.round(mean * 100), fields };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// The one-number convenience for callers that only store: integer 0–100, or
|
|
65
|
+
// null when unscorable. This is what R143's capture writes through
|
|
66
|
+
// setIdeaCompleteness — and because it never throws, the capture path needs no
|
|
67
|
+
// guard around it.
|
|
68
|
+
function completenessScore(fieldValues, opts) {
|
|
69
|
+
return scoreFieldValues(fieldValues, opts).score;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
module.exports = { completenessScore, scoreFieldValues, substanceOf };
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.163",
|
|
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.163",
|
|
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.163",
|
|
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
|
@@ -749,8 +749,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
749
749
|
// landed since 1.19.159 with no explicit bump. run 31959145128. (task 1002620)
|
|
750
750
|
// 1.19.161 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
751
751
|
// landed since 1.19.160 with no explicit bump. run 31959299652. (task 1002620)
|
|
752
|
+
// 1.19.162 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
753
|
+
// landed since 1.19.161 with no explicit bump. run 31959496815. (task 1002620)
|
|
754
|
+
// 1.19.163 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
755
|
+
// landed since 1.19.162 with no explicit bump. run 31959864781. (task 1002620)
|
|
752
756
|
// ---------------------------------------------------------------------------
|
|
753
|
-
const CORE_VERSION = '1.19.
|
|
757
|
+
const CORE_VERSION = '1.19.163'; // CI auto-patch carrier (ADR 0161); see changelog above
|
|
754
758
|
|
|
755
759
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
756
760
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// tests/idea_completeness.mjs
|
|
2
|
+
//
|
|
3
|
+
// task 1002918 (BV1.R142, goal 1000062) — the deterministic completeness scorer.
|
|
4
|
+
//
|
|
5
|
+
// The three claims under guard, straight from the task's done-when:
|
|
6
|
+
// 1. SAME INPUT, SAME SCORE — determinism is what lets the number feed karma.
|
|
7
|
+
// 2. The empty / thin / padded / complete shapes score sensibly, with padding
|
|
8
|
+
// (whitespace, blank lines) earning exactly nothing.
|
|
9
|
+
// 3. IT NEVER THROWS — malformed input is a null score, because scoring must
|
|
10
|
+
// never block capture (the R140 schema's contract).
|
|
11
|
+
//
|
|
12
|
+
// Pure module, pure tests — no DB, no harness.
|
|
13
|
+
//
|
|
14
|
+
// Run: node tests/idea_completeness.mjs
|
|
15
|
+
|
|
16
|
+
import { strict as assert } from 'node:assert';
|
|
17
|
+
import { createRequire } from 'node:module';
|
|
18
|
+
|
|
19
|
+
const require = createRequire(import.meta.url);
|
|
20
|
+
const C = require('../modules/ideas/completeness.js');
|
|
21
|
+
const { BASE_TEMPLATE } = require('../modules/ideas/templates.js');
|
|
22
|
+
|
|
23
|
+
let passed = 0;
|
|
24
|
+
let failed = 0;
|
|
25
|
+
function test(name, fn) {
|
|
26
|
+
try { fn(); passed++; console.log(` ok ${name}`); }
|
|
27
|
+
catch (err) { failed++; console.error(` FAIL ${name}\n ${err.message}`); }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Five answers comfortably past every substanceMin (40 chars).
|
|
31
|
+
const COMPLETE = Object.fromEntries(
|
|
32
|
+
BASE_TEMPLATE.fields.map((f) => [f.key, `A real, substantive answer for ${f.key} that clears the declared floor easily.`]),
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
test('a complete idea scores 100, an empty one 0', () => {
|
|
36
|
+
assert.equal(C.completenessScore(COMPLETE), 100);
|
|
37
|
+
assert.equal(C.completenessScore({}), 0);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('same input, same score — byte-for-byte determinism', () => {
|
|
41
|
+
const a = C.completenessScore(COMPLETE);
|
|
42
|
+
for (let i = 0; i < 5; i++) assert.equal(C.completenessScore({ ...COMPLETE }), a);
|
|
43
|
+
assert.deepEqual(C.scoreFieldValues(COMPLETE), C.scoreFieldValues(COMPLETE));
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('a thin answer earns its proportion, not the full share', () => {
|
|
47
|
+
const oneThin = { ...COMPLETE, purpose: 'ten chars.' }; // 10 of the 40-char floor
|
|
48
|
+
const s = C.completenessScore(oneThin);
|
|
49
|
+
assert.ok(s > 80 && s < 100, `four full + one quarter-credit field ≈ 85, got ${s}`);
|
|
50
|
+
const breakdown = C.scoreFieldValues(oneThin).fields.find((f) => f.key === 'purpose');
|
|
51
|
+
assert.equal(breakdown.substance, 10);
|
|
52
|
+
assert.equal(breakdown.ratio, 0.25);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('padding earns exactly nothing: whitespace and blank lines are not substance', () => {
|
|
56
|
+
const padded = { ...COMPLETE, purpose: ' \n\n\t \n \n\n ' };
|
|
57
|
+
const b = C.scoreFieldValues(padded).fields.find((f) => f.key === 'purpose');
|
|
58
|
+
assert.equal(b.substance, 0, 'a page of blank lines is the nothing it is');
|
|
59
|
+
const spaced = { ...COMPLETE, purpose: 'a b\n\n\nc d e' }; // collapses to 9
|
|
60
|
+
const b2 = C.scoreFieldValues(spaced).fields.find((f) => f.key === 'purpose');
|
|
61
|
+
assert.equal(b2.substance, 9, 'runs of whitespace collapse to one');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('one missing field costs exactly its equal share', () => {
|
|
65
|
+
const { fit, ...four } = COMPLETE;
|
|
66
|
+
assert.equal(C.completenessScore(four), 80, 'five equal fields: one absent = -20');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('the breakdown comes back in TEMPLATE order, ready to render', () => {
|
|
70
|
+
const keys = C.scoreFieldValues(COMPLETE).fields.map((f) => f.key);
|
|
71
|
+
assert.deepEqual(keys, ['purpose', 'big_picture', 'why_had', 'why_matters', 'fit']);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('it never throws: malformed input is a null score, not an exception', () => {
|
|
75
|
+
for (const bad of [null, undefined, [], 'a string', 42]) {
|
|
76
|
+
assert.equal(C.completenessScore(bad), null, `completenessScore(${JSON.stringify(bad)})`);
|
|
77
|
+
}
|
|
78
|
+
assert.equal(C.completenessScore(COMPLETE, { templateKey: 'no-such-template' }), null);
|
|
79
|
+
// a non-string FIELD does not throw either — it just earns nothing
|
|
80
|
+
assert.equal(typeof C.completenessScore({ ...COMPLETE, purpose: 42 }), 'number');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('the scorer walks the declaration — a different template scores by ITS fields', () => {
|
|
84
|
+
const registry = {
|
|
85
|
+
'two-v1': { key: 'two-v1', label: 'Two', fields: [
|
|
86
|
+
{ key: 'a', label: 'A', required: true, substanceMin: 10 },
|
|
87
|
+
{ key: 'b', label: 'B', required: true, substanceMin: 10 },
|
|
88
|
+
] },
|
|
89
|
+
};
|
|
90
|
+
const opts = { templateKey: 'two-v1', registry };
|
|
91
|
+
assert.equal(C.completenessScore({ a: 'ten chars!', b: 'ten chars!' }, opts), 100);
|
|
92
|
+
assert.equal(C.completenessScore({ a: 'ten chars!' }, opts), 50);
|
|
93
|
+
// base fields mean nothing to it
|
|
94
|
+
assert.equal(C.completenessScore(COMPLETE, opts), 0);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
console.log(`\nidea_completeness: ${passed} passed, ${failed} failed`);
|
|
98
|
+
if (failed > 0) process.exit(1);
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// tests/idea_quick_guard.mjs
|
|
2
|
+
//
|
|
3
|
+
// task 1002926 (BV1.R150, goal 1000062) — the regression guard: THE QUICK IDEA
|
|
4
|
+
// PATH IS UNTOUCHED by the grade era.
|
|
5
|
+
//
|
|
6
|
+
// Quick Idea exists to turn a thought into a task fast (the owner's brief), and
|
|
7
|
+
// goal 1000062's C1 says it "is never silently treated as an unfinished Full
|
|
8
|
+
// Idea". This file is that promise as executable assertions, pinned from FOUR
|
|
9
|
+
// angles so any future grade/template work that taxes the quick path fails CI
|
|
10
|
+
// here, by name, instead of quietly adding friction:
|
|
11
|
+
//
|
|
12
|
+
// 1. THE CAPTURE CONTRACT — captureIdea takes the same fields it always took,
|
|
13
|
+
// title is the only requirement, and its INSERT touches idea_inbox alone:
|
|
14
|
+
// ZERO statements against ideas_grades. A quick idea is quick because no
|
|
15
|
+
// grade row exists (the R140 design), not because one was written.
|
|
16
|
+
// 2. THE ROUTE SCHEMA — POST /inbox's validator declares exactly the pre-grade
|
|
17
|
+
// field set. No grade / template_key / field_values key exists in it, so no
|
|
18
|
+
// client is asked for (or may smuggle) grade-era input on the quick path.
|
|
19
|
+
// 3. THE READ — an idea with no grade row reads back grade='quick' with null
|
|
20
|
+
// template/score, via the LEFT JOIN + COALESCE (idea_grades.mjs owns the
|
|
21
|
+
// deep version of this; here it is asserted once as part of the contract).
|
|
22
|
+
// 4. THE PROMOTE — a triage promote writes idea_inbox verdict columns only;
|
|
23
|
+
// it neither reads nor writes ideas_grades.
|
|
24
|
+
//
|
|
25
|
+
// DB-free, the tests/idea_grades.mjs way: inbox.js destructures `pool` off the
|
|
26
|
+
// doorway at require time, so patching pool.query BEFORE requiring it
|
|
27
|
+
// intercepts every statement.
|
|
28
|
+
//
|
|
29
|
+
// Run: node tests/idea_quick_guard.mjs
|
|
30
|
+
|
|
31
|
+
import { strict as assert } from 'node:assert';
|
|
32
|
+
import { createRequire } from 'node:module';
|
|
33
|
+
import fs from 'node:fs';
|
|
34
|
+
import path from 'node:path';
|
|
35
|
+
import { fileURLToPath } from 'node:url';
|
|
36
|
+
|
|
37
|
+
const require = createRequire(import.meta.url);
|
|
38
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
39
|
+
|
|
40
|
+
const api = require('../src/module-api.js');
|
|
41
|
+
|
|
42
|
+
let queries = [];
|
|
43
|
+
let nextRows = [];
|
|
44
|
+
api.pool.query = async (sql, params = []) => {
|
|
45
|
+
queries.push({ sql, params });
|
|
46
|
+
return { rows: nextRows };
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const inbox = require('../modules/ideas/inbox.js');
|
|
50
|
+
|
|
51
|
+
let passed = 0;
|
|
52
|
+
let failed = 0;
|
|
53
|
+
async function test(name, fn) {
|
|
54
|
+
queries = [];
|
|
55
|
+
nextRows = [{ id: '41', title: 'an idea', status: 'open', grade: 'quick' }];
|
|
56
|
+
try { await fn(); passed++; console.log(` ok ${name}`); }
|
|
57
|
+
catch (err) { failed++; console.error(` FAIL ${name}\n ${err.message}`); }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const grades = (q) => q.filter((x) => /ideas_grades/i.test(x.sql));
|
|
61
|
+
|
|
62
|
+
// ── 1. the capture contract ─────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
await test('capture: title alone is enough — the same minimal call as before the grade era', async () => {
|
|
65
|
+
await inbox.captureIdea({ title: 'just a thought', capturedBy: '7' });
|
|
66
|
+
assert.equal(queries.length, 1, 'one statement, the idea INSERT');
|
|
67
|
+
assert.match(queries[0].sql, /INSERT INTO idea_inbox/);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
await test('capture: writes ZERO ideas_grades statements — quick is the absence of a row', async () => {
|
|
71
|
+
await inbox.captureIdea({
|
|
72
|
+
title: 'a fully-optioned quick idea',
|
|
73
|
+
bodyMd: 'with a body',
|
|
74
|
+
kind: 'feature',
|
|
75
|
+
suggestedVersion: 'BONGOS-V1',
|
|
76
|
+
suggestedPriority: 3,
|
|
77
|
+
capturedBy: '7',
|
|
78
|
+
});
|
|
79
|
+
assert.equal(grades(queries).length, 0, 'no grade row is written or read at capture');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
await test('capture: accepts no grade-era argument — an unknown field is simply not a parameter', async () => {
|
|
83
|
+
// captureIdea's signature is destructured; passing grade-era keys must change
|
|
84
|
+
// nothing about what runs (they are not smuggled into SQL params).
|
|
85
|
+
await inbox.captureIdea({ title: 't', capturedBy: '7', grade: 'full', field_values: { purpose: 'x' } });
|
|
86
|
+
assert.equal(queries.length, 1);
|
|
87
|
+
const flat = JSON.stringify(queries[0].params);
|
|
88
|
+
assert.doesNotMatch(flat, /purpose/, 'grade-era keys never reach the INSERT params');
|
|
89
|
+
assert.equal(grades(queries).length, 0);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
await test('capture: the title requirement is the SAME refusal it always was', async () => {
|
|
93
|
+
try { await inbox.captureIdea({ capturedBy: '7' }); assert.fail('should refuse'); }
|
|
94
|
+
catch (err) { assert.equal(err.code, 'TITLE_REQUIRED'); }
|
|
95
|
+
assert.equal(queries.length, 0, 'refused before any query');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// ── 2. the route schema ─────────────────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
await test('POST /inbox declares the pre-grade field set — nothing grade-era, nothing newly required', () => {
|
|
101
|
+
const src = fs.readFileSync(path.join(__dirname, '..', 'modules', 'ideas', 'routes', 'inbox.js'), 'utf8');
|
|
102
|
+
const post = src.slice(src.indexOf("router.post('/inbox'"), src.indexOf('captureIdea('));
|
|
103
|
+
const schema = post.slice(post.indexOf('{', post.indexOf('validateOrRespond')), post.indexOf('})') + 1);
|
|
104
|
+
for (const key of ['title', 'body_md', 'kind', 'suggested_version', 'suggested_priority']) {
|
|
105
|
+
assert.ok(schema.includes(key), `the quick path still accepts ${key}`);
|
|
106
|
+
}
|
|
107
|
+
assert.doesNotMatch(schema, /grade|template|field_values|completeness/,
|
|
108
|
+
'no grade-era key in the quick capture schema');
|
|
109
|
+
const required = [...schema.matchAll(/(\w+):\s*\{[^}]*required:\s*true/g)].map((m) => m[1]);
|
|
110
|
+
assert.deepEqual(required, ['title'], 'title is the ONLY required field, as it always was');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// ── 3. the read ──────────────────────────────────────────────────────────────
|
|
114
|
+
|
|
115
|
+
await test('read: an ungraded idea comes back grade=quick with null template/score', async () => {
|
|
116
|
+
nextRows = [{ id: '41', title: 'an idea', status: 'open', grade: 'quick', template_key: null, field_values: null, completeness_score: null }];
|
|
117
|
+
const idea = await inbox.getIdea('41');
|
|
118
|
+
assert.equal(idea.grade, 'quick');
|
|
119
|
+
assert.equal(idea.template_key, null);
|
|
120
|
+
assert.equal(idea.completeness_score, null);
|
|
121
|
+
assert.match(queries[0].sql, /LEFT JOIN ideas_grades/, 'the read is a LEFT JOIN — absence reads as quick');
|
|
122
|
+
assert.match(queries[0].sql, /COALESCE\(g\.grade/, 'the COALESCE is what defaults it');
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// ── 4. the promote ───────────────────────────────────────────────────────────
|
|
126
|
+
|
|
127
|
+
await test('promote: mutates idea_inbox only — the grade table is read (one CTE), never written', async () => {
|
|
128
|
+
nextRows = [{ id: '41', title: 'an idea', status: 'promoted', promoted_to_task_id: '900' }];
|
|
129
|
+
await inbox.updateIdea('41', { status: 'promoted', promoted_to_task_id: '900' });
|
|
130
|
+
assert.ok(queries.length >= 1);
|
|
131
|
+
for (const q of queries) {
|
|
132
|
+
// Reading the grade is CORRECT here (the CTE joins it so an already-sparked
|
|
133
|
+
// idea is not misreported as quick); what the guard forbids is any MUTATION
|
|
134
|
+
// of the grade table riding along with a triage verdict.
|
|
135
|
+
assert.doesNotMatch(q.sql, /UPDATE\s+ideas_grades|INSERT\s+INTO\s+ideas_grades|DELETE\s+FROM\s+ideas_grades/i,
|
|
136
|
+
'promote must not mutate the grade table');
|
|
137
|
+
}
|
|
138
|
+
assert.ok(queries.some((q) => /UPDATE idea_inbox/i.test(q.sql)), 'the promote is the same idea_inbox UPDATE');
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
console.log(`\nidea_quick_guard: ${passed} passed, ${failed} failed`);
|
|
142
|
+
if (failed > 0) process.exit(1);
|