@bongos/core 1.20.5 → 1.20.7

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.
Files changed (58) hide show
  1. package/.bongos-core.json +113 -53
  2. package/.claude/skills/goal-review/SKILL.md +16 -24
  3. package/.claude/skills/goal-uat/SKILL.md +84 -0
  4. package/clients/bongos-client/README.md +1 -1
  5. package/clients/bongos-client/bongos-client.global.js +12 -0
  6. package/clients/bongos-client/index.cjs +12 -0
  7. package/clients/bongos-client/index.d.ts +18 -1
  8. package/clients/bongos-client/index.mjs +12 -0
  9. package/docs/adr/0183-criteria-close-themselves.md +1 -1
  10. package/docs/adr/0310-a-speciality-offers-skills-and-the-adopter-chooses-them.md +1 -1
  11. package/docs/adr/0351-a-criterion-closes-on-a-uat.md +73 -0
  12. package/docs/adr/README.md +28 -0
  13. package/docs/api/openapi.json +385 -5
  14. package/docs/api-reference.md +14 -4
  15. package/docs/architecture.md +7 -0
  16. package/docs/copy-inventory.md +22 -22
  17. package/docs/copy-registry.json +23 -23
  18. package/docs/file-map.md +2 -1
  19. package/docs/module-api-changelog.md +5 -1
  20. package/docs/page-readings.json +3 -3
  21. package/modules/hall-ui/public/goals-page.js +8 -3
  22. package/modules/hall-ui/public/tweak-editor.css +4 -7
  23. package/modules/hall-ui/public/tweak-editor.html +3 -3
  24. package/modules/hall-ui/public/tweak-editor.js +3 -0
  25. package/modules/lifecycle/criterion-uat-db.js +267 -0
  26. package/modules/lifecycle/criterion-uat.js +303 -0
  27. package/modules/lifecycle/db-goals.js +25 -12
  28. package/modules/lifecycle/done-when.js +84 -17
  29. package/modules/lifecycle/migrations/lifecycle_015_criterion_uat.sql +73 -0
  30. package/modules/lifecycle/module.json +1 -0
  31. package/modules/lifecycle/routes/criterion-uat.js +169 -0
  32. package/modules/lifecycle/routes/done-when.js +25 -1
  33. package/modules/npm-release/module.json +3 -1
  34. package/modules/npm-release/routes/task-where.js +18 -1
  35. package/modules/npm-release/work.js +38 -7
  36. package/modules/specialities/routes/specialities.js +8 -1
  37. package/modules/specialities/specialities.js +26 -1
  38. package/package-lock.json +2 -2
  39. package/package.json +1 -1
  40. package/release-notes.json +24 -0
  41. package/scripts/gds/cli-lib.js +4 -1
  42. package/scripts/gds/find-untested.js +371 -0
  43. package/scripts/gds/fitness-checks-write-validation.js +4 -0
  44. package/scripts/gds/newcomer-floor.templates.json +1 -1
  45. package/scripts/gds/status.js +14 -3
  46. package/scripts/gds/uat.js +120 -0
  47. package/src/bongos/route-rank-check.js +9 -0
  48. package/src/module-api.js +1 -1
  49. package/tests/auto_satisfy_criteria.mjs +4 -2
  50. package/tests/criterion_uat.mjs +467 -0
  51. package/tests/criterion_uat_routes.mjs +232 -0
  52. package/tests/find_untested.mjs +137 -0
  53. package/tests/fitness.mjs +3 -1
  54. package/tests/goal_achievement.mjs +4 -2
  55. package/tests/goal_routes.mjs +4 -2
  56. package/tests/ideator_full_idea_shapes_space_proof.mjs +3 -2
  57. package/tests/npm_release_where.mjs +18 -2
  58. package/tests/speciality_session_skills.mjs +150 -0
@@ -204,7 +204,23 @@ function canAdopt(speciality, viewer) {
204
204
  // characters and a session cannot carry that; what the contract carries is the
205
205
  // speciality's terms plus an inventory of what it knows, so the agent knows to
206
206
  // GO AND SEARCH rather than believing the subject is undocumented.
207
- function describeForApi(active, { documentCount = 0, learningCount = 0, selfAuthored = true, authorLabel = null } = {}) {
207
+ // The one sentence naming the enabled skills, or null — SILENT when nothing is on
208
+ // (the common state after part 2), never an empty list. Only a skill still both
209
+ // OFFERED and (when `installed` is known) INSTALLED is named: the PUT checked
210
+ // both, but an author can shrink the offer and a deploy can drop a skill later,
211
+ // and advising a session to reach for a skill it cannot find is noise.
212
+ function enabledSkillsLine(active, installed = null) {
213
+ const enabled = Array.isArray(active && active.enabled_skills) ? active.enabled_skills : [];
214
+ const offered = new Set(Array.isArray(active && active.skills) ? active.skills : []);
215
+ const names = [...new Set(enabled)].filter((n) => typeof n === 'string' && SKILL_NAME_RE.test(n)
216
+ && offered.has(n) && (!installed || installed.has(n)));
217
+ if (!names.length) return null;
218
+ return `In this speciality the builder chose to lean on these skills: ${names.map((n) => `/${n}`).join(', ')}. `
219
+ + 'Reach for them when the work calls for one. This is advice, not permission — '
220
+ + 'each still runs only if the builder\'s rank allows it.';
221
+ }
222
+
223
+ function describeForApi(active, { documentCount = 0, learningCount = 0, selfAuthored = true, authorLabel = null, installed = null } = {}) {
208
224
  if (!active) return null;
209
225
  const parts = [];
210
226
  // Conditional, not unconditional: an unnamed speciality has nothing to announce,
@@ -243,6 +259,15 @@ function describeForApi(active, { documentCount = 0, learningCount = 0, selfAuth
243
259
  );
244
260
  }
245
261
 
262
+ // THE ENABLED SKILLS (task 1004074, ADR 0310 part 3). What THIS builder turned
263
+ // on in the walkthrough, so the speciality changes what the session reaches for.
264
+ // After the provenance fence on purpose: the list is the adopter's own choice,
265
+ // not the author's prose. Advice only — naming a skill grants nothing, and the
266
+ // line says so, because a session told "use X" by a builder who cannot run X
267
+ // should hit the rank gate, not believe the speciality opened it.
268
+ const skillsLine = enabledSkillsLine(active, installed);
269
+ if (skillsLine) parts.push(skillsLine);
270
+
246
271
  const contract = parts.filter(Boolean).join('\n');
247
272
  // inject:false for an empty contract, matching how the wandering knob stays
248
273
  // silent on its permissive levels rather than spending context saying nothing.
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.20.5",
3
+ "version": "1.20.7",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.20.5",
9
+ "version": "1.20.7",
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.20.5",
3
+ "version": "1.20.7",
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",
@@ -8066,9 +8066,33 @@
8066
8066
  }
8067
8067
  ],
8068
8068
  "1.20.5": [
8069
+ {
8070
+ "id": "1002456",
8071
+ "text": "The list of decision records now says plainly which 20 numbers are shared by two documents, and tells everyone to cite a record by its file name so nobody opens the wrong one."
8072
+ },
8073
+ {
8074
+ "id": "1004394",
8075
+ "text": "Code tidy-up in the tweak editor: an out-of-date comment fixed. Nothing changes for artists."
8076
+ },
8077
+ {
8078
+ "id": "1004074",
8079
+ "text": "The skills you switch on for a speciality now follow you into your work sessions. Your assistant is told which ones you chose to lean on, and only in sessions for that craft. Switch nothing on and nothing extra is said. It is"
8080
+ },
8069
8081
  {
8070
8082
  "id": "1004391",
8071
8083
  "text": "Wrote down a rule: when several linked tasks are claimed together, whoever claims them must finish them in order. Nothing enforces it yet, by the owner's choice."
8072
8084
  }
8085
+ ],
8086
+ "1.20.6": [
8087
+ {
8088
+ "id": "1004392",
8089
+ "text": "A goal's checklist item no longer ticks itself off just because some linked work shipped. Once the work is done it now shows \"Awaiting UAT\", and it closes only when someone who did not build it tries it on the live site and up"
8090
+ }
8091
+ ],
8092
+ "1.20.7": [
8093
+ {
8094
+ "id": "1002458",
8095
+ "text": "The starter task that asks a new builder to add a test now finds its own targets from the current code, so it no longer goes out of date every time someone completes it."
8096
+ }
8073
8097
  ]
8074
8098
  }
@@ -680,10 +680,13 @@ async function apiCall(method, urlPath, body = null, opts = {}) {
680
680
  // account billing, so identity is not an auth boundary. anthropic_user_id is
681
681
  // still kept in the session as cosmetic attribution; it is simply not
682
682
  // transmitted as a header.
683
+ // opts.rawBody (a Buffer) + opts.contentType send bytes instead of JSON — a
684
+ // UAT recording upload (task 1004392), whose route reads a raw video body.
685
+ if (opts.rawBody) headers['Content-Type'] = opts.contentType || 'application/octet-stream';
683
686
  const res = await dispatchedFetch(`${base}${urlPath}`, {
684
687
  method,
685
688
  headers,
686
- body: body ? JSON.stringify(body) : null,
689
+ body: opts.rawBody ? opts.rawBody : (body ? JSON.stringify(body) : null),
687
690
  dispatcher: noKeepAliveDispatcher(),
688
691
  });
689
692
  const text = await res.text();
@@ -0,0 +1,371 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ // scripts/gds/find-untested.js — list source modules that no test names, for the
4
+ // newcomer "add a unit test" chore (task 1002458, from idea 1000574).
5
+ //
6
+ // WHY. The evergreen newcomer task `unit-test-pass` (scripts/gds/newcomer-floor.templates.json)
7
+ // used to carry a HARDCODED list of "untested" helpers. Every newcomer who completed it made
8
+ // the list a little more wrong, and a blanket rename broke three of its five paths outright.
9
+ // This script is the METHOD instead of the list: run it and it names candidates from the live
10
+ // tree, so the task cannot decay.
11
+ //
12
+ // THE DEFINITION (decided at claim time; recorded on the task). A module is a candidate when
13
+ // NO TEST FILE NAMES IT — no test requires/imports it, builds its path with path.join, or
14
+ // spawns it. That is the "no dedicated test" definition. The stricter one, "unreachable from
15
+ // any test at all", is reported alongside as the `reach` column rather than used as the
16
+ // filter: measured in this repo it leaves only a handful of modules, most of them DB pollers,
17
+ // so filtering on it would hand newcomers an empty list. `--unreachable` applies it anyway.
18
+ //
19
+ // THE TWO NAIVE SCANS THIS AVOIDS (both measured before building):
20
+ // - Matching on BASENAME ("does settings.js appear in any test?") falsely drops common-word
21
+ // modules — quality.js, settings.js, hierarchy.js match dozens of unrelated tests. So a
22
+ // reference only counts when it resolves to the module's full repo-relative path.
23
+ // - Scanning only literal `require('…')` misses the ~65 tests that reach their module
24
+ // through `require(path.join(ROOT, 'scripts', 'gds', 'x.js'))`, a `const GOV = path.join(…)`
25
+ // prefix, or a spawned `node scripts/gds/x.js`; that scan wrongly flagged the well-tested
26
+ // memory-map.js. So every path.join/resolve call is evaluated (through the file's own
27
+ // path constants) and every path-shaped string literal counts.
28
+ //
29
+ // PURITY IS A HEURISTIC, and the output says so. A module is marked impure when it, or a
30
+ // module it statically requires, reaches Postgres (`pg` or a db*.js module), a subprocess or
31
+ // the network — the propagation is what catches a helper that reaches the database through a
32
+ // module doorway rather than a literal `pg` require. Filesystem use is shown but does not
33
+ // exclude: a module may read files in its CLI half and still export pure helpers. A newcomer
34
+ // still checks the helper they pick.
35
+
36
+ const fs = require('node:fs');
37
+ const path = require('node:path');
38
+
39
+ const ROOT = path.resolve(__dirname, '..', '..');
40
+
41
+ // Where candidate modules live, and the directories never scanned for them.
42
+ const SOURCE_ROOTS = ['scripts', 'src', 'modules'];
43
+ const SKIP_DIRS = new Set(['node_modules', '.git', 'migrations', 'fixtures', 'vendor', 'public']);
44
+ const CODE_EXT = /\.(?:c|m)?js$/;
45
+
46
+ // ---- pure helpers ----------------------------------------------------------
47
+
48
+ /** Is this repo-relative path a test file? A file under any `tests/` directory, or a
49
+ * `*.test.js` beside its module (scripts/gds/newcomer-floor.test.js is one). */
50
+ function isTestPath(rel) {
51
+ const p = String(rel).split(path.sep).join('/');
52
+ return /(^|\/)tests\//.test(p) || /\.test\.(?:c|m)?js$/.test(p);
53
+ }
54
+
55
+ /** Normalize a path to repo-relative posix form, clamping any climb above the root to the
56
+ * root itself. Returns '' for the root. */
57
+ function normRel(p) {
58
+ const out = [];
59
+ for (const seg of String(p).split(/[\\/]+/)) {
60
+ if (!seg || seg === '.') continue;
61
+ if (seg === '..') out.pop();
62
+ else out.push(seg);
63
+ }
64
+ return out.join('/');
65
+ }
66
+
67
+ /** Directory of a repo-relative file path ('' at the root). */
68
+ function dirOf(rel) {
69
+ const i = rel.lastIndexOf('/');
70
+ return i < 0 ? '' : rel.slice(0, i);
71
+ }
72
+
73
+ // A single argument of a path.join/resolve call, evaluated to a repo-relative string, or
74
+ // null when it cannot be known. `first` lets an unknown leading identifier stand for the
75
+ // repo root — a `ROOT`/`REPO` constant imported from a helper is the overwhelming case.
76
+ function evalArg(arg, env, fileDir, first) {
77
+ const a = arg.trim();
78
+ const lit = /^(['"`])((?:\\.|(?!\1)[^\\])*)\1$/.exec(a);
79
+ if (lit) return lit[2].includes('${') ? null : lit[2];
80
+ if (a === '__dirname' || a === 'HERE' || a === '__here') return '/' + fileDir;
81
+ if (Object.prototype.hasOwnProperty.call(env, a)) return env[a] == null ? null : '/' + env[a];
82
+ if (first && /^[A-Za-z_$][\w$]*$/.test(a)) return '/';
83
+ return null;
84
+ }
85
+
86
+ // Evaluate the argument text of one path.join/resolve call to a repo-relative path.
87
+ function evalJoin(argText, env, fileDir) {
88
+ const args = argText.split(',');
89
+ let acc = null;
90
+ for (let i = 0; i < args.length; i++) {
91
+ const v = evalArg(args[i], env, fileDir, i === 0);
92
+ if (v == null) return null;
93
+ if (v.startsWith('/')) acc = v.slice(1);
94
+ else acc = acc == null ? v : acc + '/' + v;
95
+ }
96
+ return acc == null ? null : normRel(acc);
97
+ }
98
+
99
+ const JOIN_RE = /path\.(?:join|resolve)\s*\(([^()]*)\)/g;
100
+ const CONST_JOIN_RE = /(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*path\.(?:join|resolve)\s*\(([^()]*)\)/g;
101
+ const CONST_URL_RE = /(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:path\.dirname\s*\(\s*)?fileURLToPath\s*\(\s*(?:new URL\s*\(\s*(['"])([^'"]*)\2\s*,\s*)?import\.meta\.url/g;
102
+ const URL_RE = /new URL\s*\(\s*(['"])([^'"]+)\1\s*,\s*import\.meta\.url\s*\)/g;
103
+ const STRING_RE = /(['"`])((?:\\.|(?!\1)[^\\\n])*)\1/g;
104
+
105
+ /** Every repo-relative path a source text names: evaluated path.join/resolve calls (through
106
+ * the file's own path constants), `new URL(…, import.meta.url)`, and path-shaped string
107
+ * literals (relative ones resolved from the file's directory). Pure — it reads no files.
108
+ * This is deliberately BROAD: it is how a test's reach is measured, and a test reaches its
109
+ * module through any of these shapes. */
110
+ function extractPathRefs(src, fileRel) {
111
+ const text = String(src);
112
+ const fileDir = dirOf(normRel(fileRel));
113
+ const env = {};
114
+ const refs = new Set();
115
+ let m;
116
+ CONST_URL_RE.lastIndex = 0;
117
+ while ((m = CONST_URL_RE.exec(text))) {
118
+ // fileURLToPath(import.meta.url) is the file itself; with dirname or a relative URL it
119
+ // is a directory. Either way the directory is the useful prefix.
120
+ env[m[1]] = m[3] ? normRel(fileDir + '/' + m[3]) : fileDir;
121
+ }
122
+ CONST_JOIN_RE.lastIndex = 0;
123
+ while ((m = CONST_JOIN_RE.exec(text))) env[m[1]] = evalJoin(m[2], env, fileDir);
124
+ JOIN_RE.lastIndex = 0;
125
+ while ((m = JOIN_RE.exec(text))) {
126
+ const v = evalJoin(m[1], env, fileDir);
127
+ if (v) refs.add(v);
128
+ }
129
+ URL_RE.lastIndex = 0;
130
+ while ((m = URL_RE.exec(text))) refs.add(normRel(fileDir + '/' + m[2]));
131
+ STRING_RE.lastIndex = 0;
132
+ while ((m = STRING_RE.exec(text))) {
133
+ const s = m[2];
134
+ if (!s || s.includes('${') || !/\.(?:c|m)?js$|\//.test(s) || /\s/.test(s) || /^[a-z]+:/i.test(s)) continue;
135
+ refs.add(s.startsWith('.') ? normRel(fileDir + '/' + s) : normRel(s));
136
+ }
137
+ return refs;
138
+ }
139
+
140
+ const REQUIRE_RE = /(?:require|import)\s*\(\s*(['"])([^'"]+)\1\s*\)|\bfrom\s+(['"])([^'"]+)\3/g;
141
+ const REQUIRE_JOIN_RE = /require\s*\(\s*path\.(?:join|resolve)\s*\(([^()]*)\)\s*\)/g;
142
+
143
+ /** The module specifiers a SOURCE file statically loads: bare package names as-is, and
144
+ * in-repo paths (relative, or built with path.join from __dirname) as repo-relative paths
145
+ * WITHOUT extension resolution. Narrower than extractPathRefs on purpose — this is the
146
+ * require graph, and a path merely mentioned in a comment is not an edge. */
147
+ function extractRequires(src, fileRel) {
148
+ const text = String(src);
149
+ const fileDir = dirOf(normRel(fileRel));
150
+ const env = {};
151
+ const out = { local: new Set(), packages: new Set() };
152
+ let m;
153
+ CONST_JOIN_RE.lastIndex = 0;
154
+ while ((m = CONST_JOIN_RE.exec(text))) env[m[1]] = evalJoin(m[2], env, fileDir);
155
+ REQUIRE_RE.lastIndex = 0;
156
+ while ((m = REQUIRE_RE.exec(text))) {
157
+ const spec = m[2] || m[4];
158
+ if (spec.startsWith('.')) out.local.add(normRel(fileDir + '/' + spec));
159
+ else out.packages.add(spec.replace(/^node:/, '').split('/')[0]);
160
+ }
161
+ REQUIRE_JOIN_RE.lastIndex = 0;
162
+ while ((m = REQUIRE_JOIN_RE.exec(text))) {
163
+ const v = evalJoin(m[1], env, fileDir);
164
+ if (v) out.local.add(v);
165
+ }
166
+ return out;
167
+ }
168
+
169
+ /** Resolve a reference against the set of known module paths the way Node would for a
170
+ * local specifier: exact, then with .js/.mjs/.cjs, then as a directory's index.js.
171
+ * Returns the module path or null. Full-path matching is what keeps a common-word
172
+ * basename like settings.js from matching an unrelated module. */
173
+ function resolveRef(ref, modules) {
174
+ if (!ref) return null;
175
+ for (const c of [ref, ref + '.js', ref + '.mjs', ref + '.cjs', ref + '/index.js']) {
176
+ if (modules.has(c)) return c;
177
+ }
178
+ return null;
179
+ }
180
+
181
+ /** Does this source export anything a test could call? CommonJS or ESM. */
182
+ function hasExports(src) {
183
+ return /\bmodule\.exports\b|\bexports\.[A-Za-z_$]|^\s*export\s/m.test(String(src));
184
+ }
185
+
186
+ /** Rough count of exported names — a size hint for picking a newcomer-sized module. */
187
+ function countExports(src) {
188
+ const text = String(src);
189
+ const names = new Set();
190
+ let m;
191
+ const objRe = /module\.exports\s*=\s*\{([\s\S]*?)\};?/g;
192
+ while ((m = objRe.exec(text))) {
193
+ for (const part of m[1].split(',')) {
194
+ const k = /^\s*(?:\.\.\.)?([A-Za-z_$][\w$]*)/.exec(part.replace(/\/\/.*$/gm, ''));
195
+ if (k) names.add(k[1]);
196
+ }
197
+ }
198
+ const dotRe = /\b(?:module\.)?exports\.([A-Za-z_$][\w$]*)\s*=/g;
199
+ while ((m = dotRe.exec(text))) names.add(m[1]);
200
+ const esmRe = /^\s*export\s+(?:async\s+)?(?:function\*?|const|let|class)\s+([A-Za-z_$][\w$]*)/gm;
201
+ while ((m = esmRe.exec(text))) names.add(m[1]);
202
+ return names.size;
203
+ }
204
+
205
+ const NETWORK_PKGS = new Set(['http', 'https', 'net', 'tls', 'undici', 'ws', 'dgram']);
206
+
207
+ /** The impurity markers a single source shows on its own: 'postgres', 'subprocess',
208
+ * 'network' (hard — the module cannot be tested DB-free and offline) and 'filesystem'
209
+ * (soft). `requires` is extractRequires' output for the same text. */
210
+ function ownImpurity(src, requires) {
211
+ const text = String(src);
212
+ const hard = new Set();
213
+ const pk = requires.packages;
214
+ if (pk.has('pg')) hard.add('postgres');
215
+ if (pk.has('child_process')) hard.add('subprocess');
216
+ for (const p of pk) if (NETWORK_PKGS.has(p)) hard.add('network');
217
+ if (/(?:^|[^\w.])fetch\s*\(/.test(text)) hard.add('network');
218
+ for (const r of requires.local) if (/(?:^|\/|-)db(?:-[\w-]+)?(?:\.js)?$/.test(r)) hard.add('postgres');
219
+ return { hard, fs: pk.has('fs') || pk.has('fs/promises') };
220
+ }
221
+
222
+ /** Rank candidates for a newcomer: unreachable before indirectly-covered, pure before
223
+ * filesystem-touching, then smallest first (a newcomer-sized module). */
224
+ function rankCandidates(rows) {
225
+ return rows.slice().sort((a, b) =>
226
+ (a.reach === 'unreachable' ? 0 : 1) - (b.reach === 'unreachable' ? 0 : 1)
227
+ || (a.fs ? 1 : 0) - (b.fs ? 1 : 0)
228
+ || a.lines - b.lines
229
+ || a.path.localeCompare(b.path));
230
+ }
231
+
232
+ // ---- tree scan -------------------------------------------------------------
233
+
234
+ function walk(relDir, out) {
235
+ let entries;
236
+ try { entries = fs.readdirSync(path.join(ROOT, relDir), { withFileTypes: true }); } catch { return; }
237
+ for (const e of entries) {
238
+ const rel = relDir ? relDir + '/' + e.name : e.name;
239
+ if (e.isDirectory()) {
240
+ if (!SKIP_DIRS.has(e.name) && !e.name.startsWith('public-') && !e.name.startsWith('.')) walk(rel, out);
241
+ } else if (CODE_EXT.test(e.name)) out.push(rel);
242
+ }
243
+ }
244
+
245
+ /** Scan the live tree and return every exported source module with its coverage, reach and
246
+ * purity: [{ path, lines, exports, tested, reach, impure: [..], fs }]. `tested` is true
247
+ * when some test file names the module directly; `reach` is 'direct' | 'indirect' |
248
+ * 'unreachable' (indirect = only loaded through another module a test names). */
249
+ function scanTree() {
250
+ const files = [];
251
+ for (const r of SOURCE_ROOTS) walk(r, files);
252
+ walk('tests', files);
253
+ const tests = files.filter((f) => isTestPath(f) && !f.includes('/fixtures/'));
254
+ const sources = files.filter((f) => !isTestPath(f) && !f.includes('/fixtures/'));
255
+ const text = new Map();
256
+ for (const f of files) text.set(f, fs.readFileSync(path.join(ROOT, f), 'utf8'));
257
+ const modules = new Set(sources);
258
+
259
+ // The require graph and each module's own impurity.
260
+ const edges = new Map();
261
+ const own = new Map();
262
+ for (const f of sources) {
263
+ const req = extractRequires(text.get(f), f);
264
+ const local = new Set();
265
+ for (const r of req.local) { const t = resolveRef(r, modules); if (t) local.add(t); }
266
+ edges.set(f, local);
267
+ own.set(f, ownImpurity(text.get(f), req));
268
+ }
269
+
270
+ // Hard impurity propagates along the require graph (the "module doorway" case).
271
+ const impure = new Map();
272
+ function impurityOf(f, seen) {
273
+ if (impure.has(f)) return impure.get(f);
274
+ if (seen.has(f)) return own.get(f).hard;
275
+ seen.add(f);
276
+ const acc = new Set(own.get(f).hard);
277
+ for (const d of edges.get(f)) for (const x of impurityOf(d, seen)) acc.add(x);
278
+ seen.delete(f);
279
+ impure.set(f, acc);
280
+ return acc;
281
+ }
282
+
283
+ // Direct test references, then everything reachable from them.
284
+ const direct = new Set();
285
+ for (const t of tests) {
286
+ for (const r of extractPathRefs(text.get(t), t)) {
287
+ const hit = resolveRef(r, modules);
288
+ if (hit) direct.add(hit);
289
+ }
290
+ const req = extractRequires(text.get(t), t);
291
+ for (const r of req.local) { const hit = resolveRef(r, modules); if (hit) direct.add(hit); }
292
+ }
293
+ const reached = new Set(direct);
294
+ const queue = [...direct];
295
+ while (queue.length) {
296
+ for (const d of edges.get(queue.pop()) || []) if (!reached.has(d)) { reached.add(d); queue.push(d); }
297
+ }
298
+
299
+ const rows = [];
300
+ for (const f of sources) {
301
+ const src = text.get(f);
302
+ if (!hasExports(src)) continue;
303
+ rows.push({
304
+ path: f,
305
+ lines: src.split('\n').length,
306
+ exports: countExports(src),
307
+ tested: direct.has(f),
308
+ reach: direct.has(f) ? 'direct' : reached.has(f) ? 'indirect' : 'unreachable',
309
+ impure: [...impurityOf(f, new Set())].sort(),
310
+ fs: own.get(f).fs,
311
+ });
312
+ }
313
+ return rows;
314
+ }
315
+
316
+ // ---- CLI -------------------------------------------------------------------
317
+
318
+ const USAGE = `usage: node scripts/gds/find-untested.js [--unreachable] [--all] [--limit N] [--json]
319
+
320
+ Lists source modules (scripts/, src/, modules/) that NO test file names, for the newcomer
321
+ "add a unit test" task. Candidates come first when no test reaches them at all, then when
322
+ they look pure, then smallest first.
323
+
324
+ --unreachable only modules no test reaches even indirectly (the strict definition)
325
+ --all include modules that look impure (Postgres, subprocess, network)
326
+ --limit N how many to print (default 25; 0 = all)
327
+ --json machine-readable rows
328
+
329
+ Purity is a heuristic: check the helper you pick really is DB-free before testing it.`;
330
+
331
+ function main(argv) {
332
+ if (argv.includes('--help') || argv.includes('-h')) { console.log(USAGE); return 0; }
333
+ const li = argv.indexOf('--limit');
334
+ const limit = li >= 0 ? parseInt(argv[li + 1], 10) : 25;
335
+ if (li >= 0 && !(limit >= 0)) { console.error('find-untested: --limit needs a number'); return 2; }
336
+ const all = scanTree();
337
+ let rows = all.filter((r) => !r.tested);
338
+ if (argv.includes('--unreachable')) rows = rows.filter((r) => r.reach === 'unreachable');
339
+ if (!argv.includes('--all')) rows = rows.filter((r) => r.impure.length === 0);
340
+ rows = rankCandidates(rows);
341
+ const shown = limit ? rows.slice(0, limit) : rows;
342
+ if (argv.includes('--json')) { console.log(JSON.stringify(shown, null, 2)); return 0; }
343
+ const untested = all.filter((r) => !r.tested).length;
344
+ console.log(`find-untested: ${all.length} exported modules scanned; ${untested} have no test naming them; `
345
+ + `${rows.length} match the filters.`);
346
+ if (!shown.length) { console.log(' (none — try --all, or strengthen a thin existing test instead)'); return 0; }
347
+ const w = Math.max(...shown.map((r) => r.path.length));
348
+ console.log(` ${'module'.padEnd(w)} lines exports reach notes`);
349
+ for (const r of shown) {
350
+ const notes = [r.impure.length ? 'impure: ' + r.impure.join('+') : 'looks pure', r.fs ? 'uses fs' : ''].filter(Boolean).join(', ');
351
+ console.log(` ${r.path.padEnd(w)} ${String(r.lines).padStart(5)} ${String(r.exports).padStart(7)} ${r.reach.padEnd(11)} ${notes}`);
352
+ }
353
+ if (rows.length > shown.length) console.log(` … ${rows.length - shown.length} more (--limit 0 for all)`);
354
+ return 0;
355
+ }
356
+
357
+ if (require.main === module) process.exitCode = main(process.argv.slice(2));
358
+
359
+ module.exports = {
360
+ isTestPath,
361
+ normRel,
362
+ extractPathRefs,
363
+ extractRequires,
364
+ resolveRef,
365
+ hasExports,
366
+ countExports,
367
+ ownImpurity,
368
+ rankCandidates,
369
+ scanTree,
370
+ main,
371
+ };
@@ -50,6 +50,10 @@ const WRITE_VALIDATION_EXEMPT = new Set([
50
50
  // the route's express.raw parser and read by module-artifact.verifyModuleArtifact, which
51
51
  // recomputes every hash and refuses anything that is not a well-formed module version.
52
52
  'POST /store/modules/:key/versions',
53
+ // Not a JSON body: the raw UAT recording (task 1004392, ADR 0351), capped by the
54
+ // route's express.raw parser and read by criterion-uat.screenRecording, which
55
+ // refuses anything that is not a bounded mp4/webm whose bytes match its type.
56
+ 'POST /done-when/:criterionId/uat/recording',
53
57
  ]);
54
58
 
55
59
  // KNOWN GAPS, NOT EXEMPTIONS (task 1002551 / BV1.R109b → tracked by task 1002552).
@@ -10,7 +10,7 @@
10
10
  "credits_reward": 15,
11
11
  "floor": 2,
12
12
  "title": "Add a unit test for an untested pure exported helper (assert its real behaviour)",
13
- "description": "## What you're doing\n\nThe repo's safety net is its **unit suite** — every `tests/*.mjs` file, run by `scripts/gds/run-unit-tests.js` in CI on every PR. New pure tests are **auto-discovered** (there is no allowlist to edit), so the moment you add one it joins the gate. Your first ship as an engineer: pick ONE exported helper that has thin or no test coverage and add a focused, DB-free unit test for it.\n\nIt is identical for every newcomer and **always valuable** — coverage is never \"done\", the codebase has hundreds of small pure helpers, and a good test both guards behaviour and teaches you how a corner of the system works. It is also **safe to auto-ship**: you add ONE file under `tests/`, which is not a protected surface, so a clean test merges on green with no owner approval.\n\n## How to pick a target (a PURE helper)\n\nPick a **pure** function — deterministic, with no database, network, filesystem, or live-server dependency — so the test runs standalone. Good hunting grounds (look for an exported function with little or no matching `tests/` coverage):\n- `src/bongos/path-match.js` (`touchesOverlap`, `matchOne`), `src/bongos/llm-pricing.js` (`familyFor`, `canonicalizeModelId`), `modules/lifecycle/task-classifier.js`, `modules/builder-settings/render-prefs.js`, `modules/builder-settings/wandering-prefs.js`.\n- pure parsing/format helpers exported from `scripts/gds/*.js`.\n\nCheck `tests/` first: if a helper already has a thorough test file, add a missing case to a DIFFERENT helper, or pick another module — don't duplicate existing coverage.\n\n## How to write it (mirror an existing test — there is no framework)\n\nThese tests are plain Node scripts using `node:assert`; there is no test runner. Open a small existing one as your model — for example `tests/criterion_ref_parse.mjs` or `tests/task_classifier_vocab.mjs` — and copy the shape:\n1. `import` / `require` the helper from its module.\n2. Assert **several** cases, including at least one **edge / boundary** case (empty input, null, a tricky value).\n3. Print a one-line success message at the end (an assert that throws exits non-zero, which is how the gate sees a failure).\n\nName the file `tests/<helper-or-module>.mjs`.\n\n## Verify + ship\n\nRun BOTH and confirm both are green:\n- `node tests/<your-file>.mjs` — your test alone, exits 0.\n- `node scripts/gds/run-unit-tests.js` — the whole gate, with your test auto-included.\n\nThen `/builder-ship`. The ship notes must name the helper and file you tested.\n\n## Stay safe (off-limits)\n\nAdd exactly ONE new file under `tests/`. Do NOT edit `scripts/gds/run-unit-tests.js` (it auto-discovers your test, and it is a protected file you may not touch), and do NOT change the helper you are testing. Your test must be **DB-free**: anything needing Postgres, the network, or a live server belongs in the integration gate and will fail standalone here. Touch no permission, pipeline, migration, or infra file.",
13
+ "description": "## What you're doing\n\nThe repo's safety net is its **unit suite** — every `tests/*.mjs` file, run by `scripts/gds/run-unit-tests.js` in CI on every PR. New pure tests are **auto-discovered** (there is no allowlist to edit), so the moment you add one it joins the gate. Your first ship as an engineer: pick ONE exported helper that has thin or no test coverage and add a focused, DB-free unit test for it.\n\nIt is identical for every newcomer and **always valuable** — coverage is never \"done\", the codebase has hundreds of small pure helpers, and a good test both guards behaviour and teaches you how a corner of the system works. It is also **safe to auto-ship**: you add ONE file under `tests/`, which is not a protected surface, so a clean test merges on green with no owner approval.\n\n## How to pick a target (a PURE helper)\n\nPick a **pure** function — deterministic, with no database, network, filesystem, or live-server dependency — so the test runs standalone. Do not hunt by hand: run the finder, which reads the live tree every time (so this task never goes stale the way a written list does):\n\n```\nnode scripts/gds/find-untested.js\n```\n\nIt lists modules that **no test file names**, best candidates first — ones no test reaches at all, then ones that look pure, then the smallest. Its purity column is a heuristic: open the module and confirm the helper you pick really needs no database, network or files. If the list is empty, run it with `--all` and pick a module that still exports a helper you can test DB-free.\n\n## How to write it (mirror an existing test — there is no framework)\n\nThese tests are plain Node scripts using `node:assert`; there is no test runner. Open a small existing one as your model — for example `tests/criterion_ref_parse.mjs` or `tests/task_classifier_vocab.mjs` — and copy the shape:\n1. `import` / `require` the helper from its module.\n2. Assert **several** cases, including at least one **edge / boundary** case (empty input, null, a tricky value).\n3. Print a one-line success message at the end (an assert that throws exits non-zero, which is how the gate sees a failure).\n\nName the file `tests/<helper-or-module>.mjs`.\n\n## Verify + ship\n\nRun BOTH and confirm both are green:\n- `node tests/<your-file>.mjs` — your test alone, exits 0.\n- `node scripts/gds/run-unit-tests.js` — the whole gate, with your test auto-included.\n\nThen `/builder-ship`. The ship notes must name the helper and file you tested.\n\n## Stay safe (off-limits)\n\nAdd exactly ONE new file under `tests/`. Do NOT edit `scripts/gds/run-unit-tests.js` (it auto-discovers your test, and it is a protected file you may not touch), and do NOT change the helper you are testing. Your test must be **DB-free**: anything needing Postgres, the network, or a live server belongs in the integration gate and will fail standalone here. Touch no permission, pipeline, migration, or infra file.",
14
14
  "done_when": [
15
15
  "A NEW tests/<name>.mjs file was added (it did not exist before) that unit-tests a PURE exported helper — no Postgres, network, filesystem, or live server.",
16
16
  "`node tests/<name>.mjs` exits 0 and the test makes real assertions about the helper's behaviour — at least two cases including one edge/boundary case — not a trivial always-true assertion.",
@@ -37,6 +37,17 @@
37
37
  const { API_BASE, cliClient, assertInstanceMatch, cliExit } = require('./cli-lib');
38
38
  const { resolveEnv } = require('../../src/instance-config');
39
39
  const { resolveDefaultVersion } = require('./version-select.js');
40
+ // task 1004392 (ADR 0351): the criterion's UAT state, in the same words the
41
+ // server and /goal-uat use. Dependency-free, so this public CLI pays nothing.
42
+ const { stateLabel } = require('../../modules/lifecycle/criterion-uat.js');
43
+
44
+ // One criterion's state as a short phrase. A server older than the UAT rule
45
+ // sends no uat_state, and then the old satisfied/not answer is the truth.
46
+ function criterionStateText(c) {
47
+ if (!c.uat_state) return c.satisfied ? 'satisfied ✓' : 'not satisfied';
48
+ const label = stateLabel(c.uat_state);
49
+ return c.satisfied ? `${label} ✓` : label;
50
+ }
40
51
  const {
41
52
  looksLikeVersionId,
42
53
  resolveCriterionIn,
@@ -270,7 +281,7 @@ function renderStatusWidget(data, criterion) {
270
281
  return `${STATUS_WIDGET_STYLE}
271
282
  <h2 class="st-sr">${sesc(data.version_id)} criterion ${sesc(criterionLabel(c))} ${c.satisfied ? 'is satisfied' : 'is not yet satisfied'}: ${c.counts.shipped} of ${c.counts.total} gating tasks shipped.</h2>
272
283
  <div class="st-card">
273
- <div class="st-head"><span class="st-title"><i class="ti ti-checklist" aria-hidden="true"></i>${sesc(data.version_id)} · ${sesc(criterionLabel(c))}</span><span class="st-meta">${c.satisfied ? '<span class="st-ok">satisfied</span>' : 'not satisfied'} · ${c.counts.shipped}/${c.counts.total} shipped</span></div>
284
+ <div class="st-head"><span class="st-title"><i class="ti ti-checklist" aria-hidden="true"></i>${sesc(data.version_id)} · ${sesc(criterionLabel(c))}</span><span class="st-meta">${c.satisfied ? `<span class="st-ok">${sesc(criterionStateText(c))}</span>` : sesc(criterionStateText(c))} · ${c.counts.shipped}/${c.counts.total} shipped</span></div>
274
285
  <div class="st-crit">${sesc(c.criterion_md.replace(/\*\*/g, '').slice(0, 160))}</div>
275
286
  ${body}
276
287
  <div class="st-foot">remaining: ${sesc(remainingSummary(c.remaining))}${claimHint}</div>
@@ -314,7 +325,7 @@ function renderStatusMarkdown(data, criterion) {
314
325
  const lines = [];
315
326
  if (criterion) {
316
327
  const c = criterion;
317
- lines.push(`**/status · ${data.version_id} · ${criterionLabel(c)}** — ${c.satisfied ? 'satisfied ✓' : 'not satisfied'}`);
328
+ lines.push(`**/status · ${data.version_id} · ${criterionLabel(c)}** — ${criterionStateText(c)}`);
318
329
  lines.push(`_${c.criterion_md.replace(/\*\*/g, '').slice(0, 160)}_`);
319
330
  lines.push('');
320
331
  if (c.tasks.length) {
@@ -405,7 +416,7 @@ function printAll(data) {
405
416
  }
406
417
 
407
418
  function printOne(data, c) {
408
- console.log(`\n${data.version_id} · ${c.criterion_id} (C${c.cnum}) [${c.satisfied ? 'satisfied ✓' : 'not satisfied'}]`);
419
+ console.log(`\n${data.version_id} · ${c.criterion_id} (C${c.cnum}) [${criterionStateText(c)}]`);
409
420
  console.log(`"${c.criterion_md.replace(/\*\*/g, '').slice(0, 120)}"\n`);
410
421
  if (c.tasks.length === 0) {
411
422
  console.log(' (no tasks linked — this criterion has no structured gating tasks yet)\n');