@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 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.160",
6
- "core_contract": "1.19.160",
7
- "source_commit": "30a9b26d4946ec965c3f824300f7f0774b052539",
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:37:59.155Z",
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": 1257,
14
+ "functional_verbatim": 1260,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 1583,
20
- "tree_sha256": "a47310780a37f194eeb9bc6d5e77952a1b12491e808bf11bc405f8ab5f791d94",
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": "07169b36ebfa7bfa8f5e82a5d26e5e0153fd25e580b36337ae82c4d06836dc76"
4200
+ "sha256": "0fa4ce8b1bb49791c487d1b90ac7f0ddea7577cf4c14db1b6366ccdccaed06ad"
4196
4201
  },
4197
4202
  {
4198
4203
  "path": "package.json",
4199
4204
  "mode": "0000644",
4200
- "sha256": "e825caac09e1d5a492da849b7271c8b57dd444a9376fbd9d98d4282e63be7d63"
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": "b02e25984c3843ac48bdc709b4582de616c7ba3c0f6c6c1274361a042c8862bc"
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.160",
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.160",
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.160",
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.160'; // CI auto-patch carrier (ADR 0161); see changelog above
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);