@bongos/core 1.19.160 → 1.19.162
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 +25 -10
- package/modules/ideas/templates.js +137 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +5 -1
- package/tests/idea_quick_guard.mjs +142 -0
- package/tests/idea_templates.mjs +139 -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.162",
|
|
6
|
+
"core_contract": "1.19.162",
|
|
7
|
+
"source_commit": "1db6240e60bde4c45db842d783f1a109da3684e5",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-08-16T16:
|
|
9
|
+
"built_at": "2026-08-16T16:44:59.964Z",
|
|
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": 1260,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 1586,
|
|
20
|
+
"tree_sha256": "b21ff15e6dc8e250c96a99b03f62b79b69a99a870c2aca7d2db4bbc8a1e1f0f9",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/blocker-review/SKILL.md",
|
|
@@ -3479,6 +3479,11 @@
|
|
|
3479
3479
|
"mode": "0000644",
|
|
3480
3480
|
"sha256": "6c57608d1eae4159e967f9b471b20cb4c518c2b4acdf9d5ab88cad2afaf7c197"
|
|
3481
3481
|
},
|
|
3482
|
+
{
|
|
3483
|
+
"path": "modules/ideas/templates.js",
|
|
3484
|
+
"mode": "0000644",
|
|
3485
|
+
"sha256": "d2816db988207f9f2c9305971ebca9bcd87d62e594facaea115d9f6db4b5d9f0"
|
|
3486
|
+
},
|
|
3482
3487
|
{
|
|
3483
3488
|
"path": "modules/lifecycle/CLAUDE.md",
|
|
3484
3489
|
"mode": "0000644",
|
|
@@ -4192,12 +4197,12 @@
|
|
|
4192
4197
|
{
|
|
4193
4198
|
"path": "package-lock.json",
|
|
4194
4199
|
"mode": "0000644",
|
|
4195
|
-
"sha256": "
|
|
4200
|
+
"sha256": "0fa4ce8b1bb49791c487d1b90ac7f0ddea7577cf4c14db1b6366ccdccaed06ad"
|
|
4196
4201
|
},
|
|
4197
4202
|
{
|
|
4198
4203
|
"path": "package.json",
|
|
4199
4204
|
"mode": "0000644",
|
|
4200
|
-
"sha256": "
|
|
4205
|
+
"sha256": "33e29cccdddaea282c452b7296e2be6ec3fd06d61b6a8d35c60a98aa86d61bf2"
|
|
4201
4206
|
},
|
|
4202
4207
|
{
|
|
4203
4208
|
"path": "public-docs/index.html",
|
|
@@ -5792,7 +5797,7 @@
|
|
|
5792
5797
|
{
|
|
5793
5798
|
"path": "src/module-api.js",
|
|
5794
5799
|
"mode": "0000644",
|
|
5795
|
-
"sha256": "
|
|
5800
|
+
"sha256": "6e7fcf60af8a8abcc9e1a193c5ecf49fed08c9b410c9e3a1dd031f406bf1dea3"
|
|
5796
5801
|
},
|
|
5797
5802
|
{
|
|
5798
5803
|
"path": "src/module-loader/catalog.js",
|
|
@@ -6904,6 +6909,16 @@
|
|
|
6904
6909
|
"mode": "0000644",
|
|
6905
6910
|
"sha256": "07cacc97990f0e775342f8b30171c87128e0acb1abd38d64c683c3f257927ebd"
|
|
6906
6911
|
},
|
|
6912
|
+
{
|
|
6913
|
+
"path": "tests/idea_quick_guard.mjs",
|
|
6914
|
+
"mode": "0000644",
|
|
6915
|
+
"sha256": "9be4414bbbbb92513a7737d4494265b24c5f5db71a67e840016cc275442df1fb"
|
|
6916
|
+
},
|
|
6917
|
+
{
|
|
6918
|
+
"path": "tests/idea_templates.mjs",
|
|
6919
|
+
"mode": "0000644",
|
|
6920
|
+
"sha256": "3360dcfcccd6ada6375124108a2570d84172977046aa47e1704d4be303ad41f3"
|
|
6921
|
+
},
|
|
6907
6922
|
{
|
|
6908
6923
|
"path": "tests/idea_triage.mjs",
|
|
6909
6924
|
"mode": "0000644",
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// modules/ideas/templates.js — the Full Idea template declarations (task 1002917,
|
|
2
|
+
// BV1.R141, goal 1000062).
|
|
3
|
+
//
|
|
4
|
+
// THIS FILE IS WHAT 'base-v1' MEANS. ideas_001_grades.sql stores `field_values`
|
|
5
|
+
// jsonb against a `template_key` and deliberately declares nothing about shape —
|
|
6
|
+
// its header says "R141 declares what 'base-v1' means". This is that declaration:
|
|
7
|
+
// the five base fields, in the owner's own wording from the Holy Workshop Trinity
|
|
8
|
+
// brief, in the order they display.
|
|
9
|
+
//
|
|
10
|
+
// THE TEMPLATE IS DATA, READ BY BOTH VALIDATION AND SCORING. Capture (R143)
|
|
11
|
+
// validates a submitted `field_values` against a template with
|
|
12
|
+
// `validateFieldValues`; the deterministic completeness scorer (R142) walks the
|
|
13
|
+
// same declaration (`fields`, each field's `substanceMin`) instead of hard-coding
|
|
14
|
+
// five names of its own. Swapping the template changes what both accept — no
|
|
15
|
+
// migration, no code change in either consumer. That property is the task's
|
|
16
|
+
// done-when, and tests/idea_templates.mjs proves it by injecting a different
|
|
17
|
+
// registry and watching validation follow it.
|
|
18
|
+
//
|
|
19
|
+
// DISPLAY ORDER COMES FROM HERE, NEVER FROM THE ROW. jsonb does not preserve key
|
|
20
|
+
// order (the schema comment calls this out), so any renderer that iterates
|
|
21
|
+
// Object.keys(field_values) shows the five fields in whatever order Postgres
|
|
22
|
+
// chose. `fieldOrder(template)` is the one legal source of order.
|
|
23
|
+
//
|
|
24
|
+
// THE PER-PROJECT SEAM, NOT THE FEATURE (owner philosophy, recorded on goal
|
|
25
|
+
// 1000062: the best possible simple base, already designed to be altered).
|
|
26
|
+
// Every function takes an optional `registry`, defaulting to the built-in one.
|
|
27
|
+
// A project-defined template (R156, a spike — deliberately NOT built here) will
|
|
28
|
+
// resolve its own registry and pass it through; nothing below cares where a
|
|
29
|
+
// registry came from. No customization UI, no config plumbing — just the seam.
|
|
30
|
+
//
|
|
31
|
+
// Same-module import: DEFAULT_TEMPLATE_KEY and the payload cap live in inbox.js
|
|
32
|
+
// (declared there for the route validators, mirroring the DB constraints) — one
|
|
33
|
+
// source, so this file can never drift from what the store accepts.
|
|
34
|
+
|
|
35
|
+
'use strict';
|
|
36
|
+
|
|
37
|
+
const { DEFAULT_TEMPLATE_KEY, FIELD_VALUES_MAX } = require('./inbox');
|
|
38
|
+
|
|
39
|
+
// The five base fields, verbatim from the owner's brief. `key` is the stable
|
|
40
|
+
// snake_case identity `field_values` is stored under (renaming a label is free;
|
|
41
|
+
// renaming a key is a data migration — that is why they differ). `substanceMin`
|
|
42
|
+
// is the character floor the R142 scorer treats as "this field was actually
|
|
43
|
+
// answered, not padded past" — declared here as data so the scorer stays
|
|
44
|
+
// template-agnostic; validation deliberately does NOT enforce it (a thin answer
|
|
45
|
+
// is a scoring problem, not a capture error — scoring never blocks capture).
|
|
46
|
+
const BASE_TEMPLATE = Object.freeze({
|
|
47
|
+
key: 'base-v1',
|
|
48
|
+
label: 'Full Idea',
|
|
49
|
+
fields: Object.freeze([
|
|
50
|
+
Object.freeze({ key: 'purpose', label: 'Purpose of Idea', required: true, substanceMin: 40 }),
|
|
51
|
+
Object.freeze({ key: 'big_picture', label: 'What does the Big Picture look like', required: true, substanceMin: 40 }),
|
|
52
|
+
Object.freeze({ key: 'why_had', label: 'Why did you have the Idea', required: true, substanceMin: 40 }),
|
|
53
|
+
Object.freeze({ key: 'why_matters', label: 'Why does the Idea Matter', required: true, substanceMin: 40 }),
|
|
54
|
+
Object.freeze({ key: 'fit', label: 'How does the Idea Fit into the big picture of the project', required: true, substanceMin: 40 }),
|
|
55
|
+
]),
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// The built-in registry. One entry today; a second built-in template would be
|
|
59
|
+
// added here. Frozen so a consumer can never mutate the base declaration into
|
|
60
|
+
// a de-facto fork — a different shape is a different registry (the seam above).
|
|
61
|
+
const BUILTIN_TEMPLATES = Object.freeze({
|
|
62
|
+
[BASE_TEMPLATE.key]: BASE_TEMPLATE,
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// Sanity pin, not a runtime branch: the store's default key and this file's
|
|
66
|
+
// base declaration are two statements of the same fact, and requiring them to
|
|
67
|
+
// agree at load time turns a rename-drift into a boot failure instead of a
|
|
68
|
+
// silent "every new row validates against a template that does not exist".
|
|
69
|
+
if (BASE_TEMPLATE.key !== DEFAULT_TEMPLATE_KEY) {
|
|
70
|
+
throw new Error(`templates: BASE_TEMPLATE.key (${BASE_TEMPLATE.key}) !== inbox DEFAULT_TEMPLATE_KEY (${DEFAULT_TEMPLATE_KEY})`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Resolve a template by key. Null for an unknown key — the caller decides
|
|
74
|
+
// whether that is a 400 (capture) or a render-as-raw fallback (display).
|
|
75
|
+
function templateFor(key, registry = BUILTIN_TEMPLATES) {
|
|
76
|
+
if (typeof key !== 'string' || !key) return null;
|
|
77
|
+
return registry[key] || null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// The one legal source of field display order (see header). Returns key order
|
|
81
|
+
// as DECLARED, never as stored.
|
|
82
|
+
function fieldOrder(template) {
|
|
83
|
+
return template.fields.map((f) => f.key);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Validate a submitted field_values object against a template. PURE — no DB, no
|
|
87
|
+
// clock — so capture (R143) can 400 with named fields before any query runs, and
|
|
88
|
+
// the tests exercise every path without a harness.
|
|
89
|
+
//
|
|
90
|
+
// Returns { ok, template, errors } where each error is { field, reason }:
|
|
91
|
+
// unknown_template — templateKey resolves to nothing in the registry
|
|
92
|
+
// not_an_object — field_values is not a plain object
|
|
93
|
+
// unknown_field — a key the template does not declare (refused, never
|
|
94
|
+
// silently stored: an unknown key in jsonb is invisible
|
|
95
|
+
// debt the scorer and renderer would each trip on later)
|
|
96
|
+
// not_a_string — a declared field carrying a non-string value
|
|
97
|
+
// missing_required — a required field absent or blank (whitespace = blank)
|
|
98
|
+
// too_large — serialized payload over the store's byte cap (mirrored
|
|
99
|
+
// from inbox.js so the refusal is a named 400 here rather
|
|
100
|
+
// than a constraint-violation 500 there)
|
|
101
|
+
//
|
|
102
|
+
// Substance (substanceMin) is deliberately NOT validated — thin answers score
|
|
103
|
+
// low (R142); they do not fail capture.
|
|
104
|
+
function validateFieldValues(fieldValues, { templateKey = DEFAULT_TEMPLATE_KEY, registry = BUILTIN_TEMPLATES } = {}) {
|
|
105
|
+
const template = templateFor(templateKey, registry);
|
|
106
|
+
if (!template) return { ok: false, template: null, errors: [{ field: null, reason: 'unknown_template' }] };
|
|
107
|
+
if (!fieldValues || typeof fieldValues !== 'object' || Array.isArray(fieldValues)) {
|
|
108
|
+
return { ok: false, template, errors: [{ field: null, reason: 'not_an_object' }] };
|
|
109
|
+
}
|
|
110
|
+
const errors = [];
|
|
111
|
+
const declared = new Set(fieldOrder(template));
|
|
112
|
+
for (const key of Object.keys(fieldValues)) {
|
|
113
|
+
if (!declared.has(key)) errors.push({ field: key, reason: 'unknown_field' });
|
|
114
|
+
else if (typeof fieldValues[key] !== 'string') errors.push({ field: key, reason: 'not_a_string' });
|
|
115
|
+
}
|
|
116
|
+
for (const f of template.fields) {
|
|
117
|
+
if (!f.required) continue;
|
|
118
|
+
const v = fieldValues[f.key];
|
|
119
|
+
if (typeof v !== 'string' || v.trim() === '') {
|
|
120
|
+
// a non-string already carries not_a_string above; don't double-report it
|
|
121
|
+
if (typeof v !== 'string' && v !== undefined) continue;
|
|
122
|
+
errors.push({ field: f.key, reason: 'missing_required' });
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
if (errors.length === 0 && Buffer.byteLength(JSON.stringify(fieldValues), 'utf8') > FIELD_VALUES_MAX) {
|
|
126
|
+
errors.push({ field: null, reason: 'too_large' });
|
|
127
|
+
}
|
|
128
|
+
return { ok: errors.length === 0, template, errors };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
module.exports = {
|
|
132
|
+
BASE_TEMPLATE,
|
|
133
|
+
BUILTIN_TEMPLATES,
|
|
134
|
+
templateFor,
|
|
135
|
+
fieldOrder,
|
|
136
|
+
validateFieldValues,
|
|
137
|
+
};
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.162",
|
|
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.162",
|
|
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.162",
|
|
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
|
@@ -747,8 +747,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
747
747
|
// landed since 1.19.158 with no explicit bump. run 31958999646. (task 1002620)
|
|
748
748
|
// 1.19.160 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
749
749
|
// landed since 1.19.159 with no explicit bump. run 31959145128. (task 1002620)
|
|
750
|
+
// 1.19.161 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
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)
|
|
750
754
|
// ---------------------------------------------------------------------------
|
|
751
|
-
const CORE_VERSION = '1.19.
|
|
755
|
+
const CORE_VERSION = '1.19.162'; // CI auto-patch carrier (ADR 0161); see changelog above
|
|
752
756
|
|
|
753
757
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
754
758
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -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);
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// tests/idea_templates.mjs
|
|
2
|
+
//
|
|
3
|
+
// task 1002917 (BV1.R141, goal 1000062) — the Full Idea base template.
|
|
4
|
+
//
|
|
5
|
+
// The claim these tests protect: THE TEMPLATE IS DATA. Validation follows the
|
|
6
|
+
// declaration, order comes from the declaration, and swapping the registry
|
|
7
|
+
// changes what is accepted with no code change — that is the task's done-when,
|
|
8
|
+
// and the "swap" test at the bottom is the proof (the R156 per-project seam).
|
|
9
|
+
//
|
|
10
|
+
// Pure module — no DB, no doorway, no harness. inbox.js is required transitively
|
|
11
|
+
// (templates.js reads DEFAULT_TEMPLATE_KEY / FIELD_VALUES_MAX from it), and it
|
|
12
|
+
// destructures `pool` off the doorway at require time without querying, so a
|
|
13
|
+
// bare require is safe here the same way tests/idea_grades.mjs relies on.
|
|
14
|
+
//
|
|
15
|
+
// Run: node tests/idea_templates.mjs
|
|
16
|
+
|
|
17
|
+
import { strict as assert } from 'node:assert';
|
|
18
|
+
import { createRequire } from 'node:module';
|
|
19
|
+
|
|
20
|
+
const require = createRequire(import.meta.url);
|
|
21
|
+
const T = require('../modules/ideas/templates.js');
|
|
22
|
+
const { DEFAULT_TEMPLATE_KEY } = require('../modules/ideas/inbox.js');
|
|
23
|
+
|
|
24
|
+
let passed = 0;
|
|
25
|
+
let failed = 0;
|
|
26
|
+
function test(name, fn) {
|
|
27
|
+
try { fn(); passed++; console.log(` ok ${name}`); }
|
|
28
|
+
catch (err) { failed++; console.error(` FAIL ${name}\n ${err.message}`); }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// A complete, well-formed base payload — reused across tests.
|
|
32
|
+
const FULL = {
|
|
33
|
+
purpose: 'A concrete purpose, written out.',
|
|
34
|
+
big_picture: 'What the world looks like once this exists.',
|
|
35
|
+
why_had: 'The observation that sparked it.',
|
|
36
|
+
why_matters: 'Why anyone should spend hours on it.',
|
|
37
|
+
fit: 'Where it sits in the project arc.',
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
// ── the declaration ─────────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
test('base-v1 declares exactly the five owner fields, in brief order', () => {
|
|
43
|
+
assert.equal(T.BASE_TEMPLATE.key, DEFAULT_TEMPLATE_KEY, 'the base key IS the store default');
|
|
44
|
+
assert.deepEqual(T.fieldOrder(T.BASE_TEMPLATE),
|
|
45
|
+
['purpose', 'big_picture', 'why_had', 'why_matters', 'fit']);
|
|
46
|
+
for (const f of T.BASE_TEMPLATE.fields) {
|
|
47
|
+
assert.ok(f.label && f.required === true, `${f.key} carries a label and is required`);
|
|
48
|
+
assert.ok(Number.isInteger(f.substanceMin) && f.substanceMin > 0,
|
|
49
|
+
`${f.key} declares the substance floor the R142 scorer reads`);
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('the declaration is frozen — a consumer cannot fork it in place', () => {
|
|
54
|
+
assert.throws(() => { T.BASE_TEMPLATE.fields.push({ key: 'rogue' }); });
|
|
55
|
+
assert.throws(() => { 'use strict'; T.BUILTIN_TEMPLATES['base-v1'] = null; });
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('templateFor: base key resolves, unknown/blank/non-string do not', () => {
|
|
59
|
+
assert.equal(T.templateFor('base-v1'), T.BASE_TEMPLATE);
|
|
60
|
+
assert.equal(T.templateFor('no-such-template'), null);
|
|
61
|
+
assert.equal(T.templateFor(''), null);
|
|
62
|
+
assert.equal(T.templateFor(null), null);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// ── validation follows the declaration ──────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
test('a complete five-field payload validates', () => {
|
|
68
|
+
const r = T.validateFieldValues(FULL);
|
|
69
|
+
assert.equal(r.ok, true);
|
|
70
|
+
assert.deepEqual(r.errors, []);
|
|
71
|
+
assert.equal(r.template, T.BASE_TEMPLATE);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('an unknown field is refused, never silently stored', () => {
|
|
75
|
+
const r = T.validateFieldValues({ ...FULL, budget: 'not a base field' });
|
|
76
|
+
assert.equal(r.ok, false);
|
|
77
|
+
assert.deepEqual(r.errors, [{ field: 'budget', reason: 'unknown_field' }]);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('a missing or blank required field is named, per field', () => {
|
|
81
|
+
const { fit, ...four } = FULL;
|
|
82
|
+
let r = T.validateFieldValues(four);
|
|
83
|
+
assert.deepEqual(r.errors, [{ field: 'fit', reason: 'missing_required' }]);
|
|
84
|
+
r = T.validateFieldValues({ ...FULL, purpose: ' ' });
|
|
85
|
+
assert.deepEqual(r.errors, [{ field: 'purpose', reason: 'missing_required' }]);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('a non-string value is refused once, not double-reported', () => {
|
|
89
|
+
const r = T.validateFieldValues({ ...FULL, why_had: 42 });
|
|
90
|
+
assert.deepEqual(r.errors, [{ field: 'why_had', reason: 'not_a_string' }]);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('a non-object payload and an unknown template are structured refusals', () => {
|
|
94
|
+
assert.deepEqual(T.validateFieldValues(null).errors, [{ field: null, reason: 'not_an_object' }]);
|
|
95
|
+
assert.deepEqual(T.validateFieldValues([]).errors, [{ field: null, reason: 'not_an_object' }]);
|
|
96
|
+
const r = T.validateFieldValues(FULL, { templateKey: 'nope' });
|
|
97
|
+
assert.deepEqual(r.errors, [{ field: null, reason: 'unknown_template' }]);
|
|
98
|
+
assert.equal(r.template, null);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('a payload over the store byte cap is a named refusal, not a DB 500', () => {
|
|
102
|
+
const r = T.validateFieldValues({ ...FULL, purpose: 'x'.repeat(40000) });
|
|
103
|
+
assert.deepEqual(r.errors, [{ field: null, reason: 'too_large' }]);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('substance is scored, not gated: a thin answer still validates', () => {
|
|
107
|
+
const thin = Object.fromEntries(Object.keys(FULL).map((k) => [k, 'ok']));
|
|
108
|
+
assert.equal(T.validateFieldValues(thin).ok, true,
|
|
109
|
+
'scoring never blocks capture — substanceMin is the scorer\'s, not the validator\'s');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// ── the done-when: swap the registry, behaviour follows (the R156 seam) ─────
|
|
113
|
+
|
|
114
|
+
test('a different registry changes what is accepted — no code change', () => {
|
|
115
|
+
const custom = {
|
|
116
|
+
'econ-v1': {
|
|
117
|
+
key: 'econ-v1',
|
|
118
|
+
label: 'Economics Idea',
|
|
119
|
+
fields: [
|
|
120
|
+
{ key: 'market', label: 'The market', required: true, substanceMin: 20 },
|
|
121
|
+
{ key: 'unit_econ', label: 'Unit economics', required: false, substanceMin: 20 },
|
|
122
|
+
],
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
const opts = { templateKey: 'econ-v1', registry: custom };
|
|
126
|
+
// the custom shape validates…
|
|
127
|
+
assert.equal(T.validateFieldValues({ market: 'builders paying for modules' }, opts).ok, true);
|
|
128
|
+
// …the BASE fields are now the unknown ones…
|
|
129
|
+
const r = T.validateFieldValues({ purpose: 'base field' }, opts);
|
|
130
|
+
assert.equal(r.ok, false);
|
|
131
|
+
assert.ok(r.errors.some((e) => e.field === 'purpose' && e.reason === 'unknown_field'));
|
|
132
|
+
// …and order comes from the new declaration
|
|
133
|
+
assert.deepEqual(T.fieldOrder(custom['econ-v1']), ['market', 'unit_econ']);
|
|
134
|
+
// the built-in registry is untouched by any of it
|
|
135
|
+
assert.equal(T.validateFieldValues(FULL).ok, true);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
console.log(`\nidea_templates: ${passed} passed, ${failed} failed`);
|
|
139
|
+
if (failed > 0) process.exit(1);
|