@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.
- package/.bongos-core.json +113 -53
- package/.claude/skills/goal-review/SKILL.md +16 -24
- package/.claude/skills/goal-uat/SKILL.md +84 -0
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +12 -0
- package/clients/bongos-client/index.cjs +12 -0
- package/clients/bongos-client/index.d.ts +18 -1
- package/clients/bongos-client/index.mjs +12 -0
- package/docs/adr/0183-criteria-close-themselves.md +1 -1
- package/docs/adr/0310-a-speciality-offers-skills-and-the-adopter-chooses-them.md +1 -1
- package/docs/adr/0351-a-criterion-closes-on-a-uat.md +73 -0
- package/docs/adr/README.md +28 -0
- package/docs/api/openapi.json +385 -5
- package/docs/api-reference.md +14 -4
- package/docs/architecture.md +7 -0
- package/docs/copy-inventory.md +22 -22
- package/docs/copy-registry.json +23 -23
- package/docs/file-map.md +2 -1
- package/docs/module-api-changelog.md +5 -1
- package/docs/page-readings.json +3 -3
- package/modules/hall-ui/public/goals-page.js +8 -3
- package/modules/hall-ui/public/tweak-editor.css +4 -7
- package/modules/hall-ui/public/tweak-editor.html +3 -3
- package/modules/hall-ui/public/tweak-editor.js +3 -0
- package/modules/lifecycle/criterion-uat-db.js +267 -0
- package/modules/lifecycle/criterion-uat.js +303 -0
- package/modules/lifecycle/db-goals.js +25 -12
- package/modules/lifecycle/done-when.js +84 -17
- package/modules/lifecycle/migrations/lifecycle_015_criterion_uat.sql +73 -0
- package/modules/lifecycle/module.json +1 -0
- package/modules/lifecycle/routes/criterion-uat.js +169 -0
- package/modules/lifecycle/routes/done-when.js +25 -1
- package/modules/npm-release/module.json +3 -1
- package/modules/npm-release/routes/task-where.js +18 -1
- package/modules/npm-release/work.js +38 -7
- package/modules/specialities/routes/specialities.js +8 -1
- package/modules/specialities/specialities.js +26 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +24 -0
- package/scripts/gds/cli-lib.js +4 -1
- package/scripts/gds/find-untested.js +371 -0
- package/scripts/gds/fitness-checks-write-validation.js +4 -0
- package/scripts/gds/newcomer-floor.templates.json +1 -1
- package/scripts/gds/status.js +14 -3
- package/scripts/gds/uat.js +120 -0
- package/src/bongos/route-rank-check.js +9 -0
- package/src/module-api.js +1 -1
- package/tests/auto_satisfy_criteria.mjs +4 -2
- package/tests/criterion_uat.mjs +467 -0
- package/tests/criterion_uat_routes.mjs +232 -0
- package/tests/find_untested.mjs +137 -0
- package/tests/fitness.mjs +3 -1
- package/tests/goal_achievement.mjs +4 -2
- package/tests/goal_routes.mjs +4 -2
- package/tests/ideator_full_idea_shapes_space_proof.mjs +3 -2
- package/tests/npm_release_where.mjs +18 -2
- 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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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",
|
package/release-notes.json
CHANGED
|
@@ -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
|
}
|
package/scripts/gds/cli-lib.js
CHANGED
|
@@ -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.
|
|
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.",
|
package/scripts/gds/status.js
CHANGED
|
@@ -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 ?
|
|
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
|
|
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
|
|
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');
|