@bongos/core 1.20.6 → 1.20.8
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 +46 -26
- package/docs/adr/0274-one-kernel-three-role-packs.md +1 -1
- package/docs/file-map.md +1 -1
- package/docs/module-api-changelog.md +4 -0
- package/docs/onboarding/slash-commands.md +1 -1
- package/docs/packs/artist.md +1 -1
- package/docs/packs/engineer.md +1 -1
- package/docs/packs/ideator.md +1 -1
- package/modules/lifecycle/ship-card.js +1 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +12 -0
- package/scripts/gds/claim.js +22 -33
- package/scripts/gds/discipline-modes.json +1 -1
- package/scripts/gds/find-untested.js +371 -0
- package/scripts/gds/newcomer-floor.templates.json +1 -1
- package/scripts/gds/role-pack-guard.js +2 -2
- package/src/bongos/craft-mode.js +87 -0
- package/src/bongos/routes/me.js +7 -1
- package/src/module-api.js +1 -1
- package/tests/conductor_main_e2e.mjs +38 -4
- package/tests/craft_mode.mjs +141 -0
- package/tests/find_untested.mjs +137 -0
- package/tests/visual_review.mjs +4 -2
|
@@ -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
|
+
};
|
|
@@ -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.",
|
|
@@ -72,8 +72,8 @@ function checkRolePacks(fsImpl = fs) {
|
|
|
72
72
|
violations.push(`role registry entry '${discipline}' is not an object — every entry must carry a directive plus a pack or a skill.`);
|
|
73
73
|
continue;
|
|
74
74
|
}
|
|
75
|
-
// An entry with neither pointer routes a claim nowhere:
|
|
76
|
-
// label and
|
|
75
|
+
// An entry with neither pointer routes a claim nowhere: the session is told the
|
|
76
|
+
// label only and is left to guess its own playbook.
|
|
77
77
|
if (!mode.pack && !mode.skill) {
|
|
78
78
|
violations.push(`role registry entry '${discipline}' names neither a 'pack' (docs/packs/<craft>.md) nor a 'skill' — a claim of this discipline would be routed to no playbook at all.`);
|
|
79
79
|
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// src/bongos/craft-mode.js — a claimed task's craft, as the pointer a session
|
|
2
|
+
// follows to its playbook (task 1002992).
|
|
3
|
+
//
|
|
4
|
+
// WHY. The role registry (scripts/gds/discipline-modes.json) says which role
|
|
5
|
+
// pack or skill each craft works from. claim.js used to PRINT that as a directive
|
|
6
|
+
// block, which reaches the session only if someone reads the claim scroll by —
|
|
7
|
+
// and a role pack skipped that way is invisible. This turns an entry into the same
|
|
8
|
+
// { inject, contract } shape the interaction profile and the speciality contract
|
|
9
|
+
// use, so GET /me can carry it on each active claim and the Conductor can relay it
|
|
10
|
+
// on the next prompt, exactly as it relays those.
|
|
11
|
+
//
|
|
12
|
+
// ONE REGISTRY. This reads the file claim.js and fitness.js (role-pack-guard.js)
|
|
13
|
+
// read — never a server-side copy of the map — so CI's hard-fail on a pack that is
|
|
14
|
+
// not in the tree covers what the session is told, too.
|
|
15
|
+
//
|
|
16
|
+
// Fail-open throughout: an unreadable or invalid registry, or a craft with no
|
|
17
|
+
// entry, yields nothing, never an error, so /me and a claim behave as before.
|
|
18
|
+
'use strict';
|
|
19
|
+
|
|
20
|
+
const fs = require('fs');
|
|
21
|
+
const path = require('path');
|
|
22
|
+
const { resolveCoreRoot } = require('../instance-config');
|
|
23
|
+
|
|
24
|
+
function registryPath() {
|
|
25
|
+
return path.join(resolveCoreRoot(), 'scripts', 'gds', 'discipline-modes.json');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// The `modes` map, or {} for a missing / unparsable file.
|
|
29
|
+
//
|
|
30
|
+
// The DEFAULT registry is read once per process and memoised: GET /me calls this
|
|
31
|
+
// on every request, and the file ships with the core, so it changes only on a
|
|
32
|
+
// deploy, which restarts the process. A failed read is NOT memoised, so a
|
|
33
|
+
// transient error does not pin {} for the life of the server. An explicit `file`
|
|
34
|
+
// (claim.js, tests) is always read fresh.
|
|
35
|
+
let memo = null;
|
|
36
|
+
function readModes(file) {
|
|
37
|
+
if (file === undefined) {
|
|
38
|
+
if (!memo) {
|
|
39
|
+
const modes = readModesFile(registryPath());
|
|
40
|
+
if (Object.keys(modes).length) memo = modes;
|
|
41
|
+
return modes;
|
|
42
|
+
}
|
|
43
|
+
return memo;
|
|
44
|
+
}
|
|
45
|
+
return readModesFile(file);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function readModesFile(file) {
|
|
49
|
+
try {
|
|
50
|
+
const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
51
|
+
const modes = parsed && parsed.modes;
|
|
52
|
+
return modes && typeof modes === 'object' ? modes : {};
|
|
53
|
+
} catch (_) {
|
|
54
|
+
return {};
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// The opener a session acts on. A pack is a file to READ (no slash command sits
|
|
59
|
+
// behind it); a skill is one to INVOKE. The wrong verb sends a session hunting for
|
|
60
|
+
// a command that does not exist, so the two shapes stay distinct.
|
|
61
|
+
function opener(mode) {
|
|
62
|
+
if (!mode) return null;
|
|
63
|
+
if (mode.pack) return `read ${mode.pack}`;
|
|
64
|
+
if (mode.skill) return `/${mode.skill}`;
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// The one-line role indicator claim.js prints, or null.
|
|
69
|
+
function roleLine(mode) {
|
|
70
|
+
if (!mode) return null;
|
|
71
|
+
const o = opener(mode);
|
|
72
|
+
return `▶ ${mode.label || 'Working mode'}${o ? ` — ${o}` : ''}`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// The injected fragment for one craft, or null when the registry has no entry
|
|
76
|
+
// for it. Static registry text only — no task or builder text is interpolated —
|
|
77
|
+
// so this adds no prompt-injection surface to the session.
|
|
78
|
+
function describeCraftMode(discipline, modes) {
|
|
79
|
+
if (!discipline || !modes) return null;
|
|
80
|
+
const mode = modes[discipline];
|
|
81
|
+
if (!mode || typeof mode !== 'object' || !mode.directive) return null;
|
|
82
|
+
const parts = [`${roleLine(mode)} (the craft of the task claimed in this worktree).`, String(mode.directive).trim()];
|
|
83
|
+
if (mode.pack) parts.push(`Your pack: ${mode.pack} (the kernel is CLAUDE.md; this is the half that is only true for your craft).`);
|
|
84
|
+
return { discipline, inject: true, contract: parts.join('\n') };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
module.exports = { readModes, roleLine, describeCraftMode };
|
package/src/bongos/routes/me.js
CHANGED
|
@@ -33,6 +33,7 @@ const db = require('../db');
|
|
|
33
33
|
// provider is default-on (present in prod); resolveOptional degrades on a
|
|
34
34
|
// prefs-module-less instance so the slices simply render their unset/default shape.
|
|
35
35
|
const { validateOrRespond } = require('./_helpers');
|
|
36
|
+
const { readModes: readCraftModes, describeCraftMode } = require('../craft-mode'); // task 1002992
|
|
36
37
|
const { branding } = require('../../branding'); // task 2011: instance model-allocation default
|
|
37
38
|
// The onboarding state machine carved into modules/onboarding/ (BV1.R81 / ADR
|
|
38
39
|
// 0093 §1): the GET /me aggregator + PATCH /me/disciplines resolve the
|
|
@@ -333,10 +334,15 @@ module.exports = function buildMeRouter() {
|
|
|
333
334
|
};
|
|
334
335
|
// For each active claim, compute the streak preview (cheap; pool query).
|
|
335
336
|
// Lets the CLI render "shipping this would extend streak to N days" hints.
|
|
337
|
+
// task 1002992: each claim also carries its craft's pack pointer
|
|
338
|
+
// (craft_mode: { discipline, inject, contract } or null), read from the one
|
|
339
|
+
// role registry, so the Conductor can tell the session which playbook to open
|
|
340
|
+
// for the claim in ITS worktree. Read once per request; fail-open to null.
|
|
341
|
+
const craftModes = readCraftModes();
|
|
336
342
|
const activeClaimsWithPreview = await Promise.all(
|
|
337
343
|
activeClaims.map(async (c) => {
|
|
338
344
|
const preview = await reward.wouldExtendStreak(req.builder.id, c.task_id);
|
|
339
|
-
return { ...c, streak_preview: preview };
|
|
345
|
+
return { ...c, streak_preview: preview, craft_mode: describeCraftMode(c.discipline, craftModes) };
|
|
340
346
|
})
|
|
341
347
|
);
|
|
342
348
|
const payload = {
|
package/src/module-api.js
CHANGED
|
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
75
75
|
// MAJOR (see allowBoxScope below): passes the request through untouched.
|
|
76
76
|
function deprecatedNoopMiddleware(_req, _res, next) { next(); }
|
|
77
77
|
|
|
78
|
-
const CORE_VERSION = '1.20.
|
|
78
|
+
const CORE_VERSION = '1.20.8'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
79
79
|
|
|
80
80
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
81
81
|
// Usage: const log = api.logger('discord'); log.info('mounted');
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
|
|
15
15
|
import { strict as assert } from 'node:assert';
|
|
16
16
|
import { createRequire } from 'node:module';
|
|
17
|
-
import { spawn } from 'node:child_process';
|
|
17
|
+
import { spawn, execFileSync } from 'node:child_process';
|
|
18
18
|
import http from 'node:http';
|
|
19
19
|
import fs from 'node:fs';
|
|
20
20
|
import os from 'node:os';
|
|
@@ -47,7 +47,9 @@ function mePayload({ discipline = 'engineer' } = {}) {
|
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
// Run the hook once in a throwaway HOME whose session points at `base`.
|
|
50
|
-
|
|
50
|
+
// `worktree` names a git repo created under the fake home for the hook to stand
|
|
51
|
+
// in — its basename is what binds a claim to "this" tree (task 1002992).
|
|
52
|
+
async function runHook(me, { worktree = null } = {}) {
|
|
51
53
|
const server = http.createServer((req, res) => {
|
|
52
54
|
if (req.url.startsWith('/api/bongos/me')) { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(me)); return; }
|
|
53
55
|
res.writeHead(404); res.end('{}');
|
|
@@ -62,12 +64,18 @@ async function runHook(me) {
|
|
|
62
64
|
fs.writeFileSync(sessionPath, JSON.stringify({ token: 'test-token', api_base: base, builder: { id: 4242 } }));
|
|
63
65
|
const env = { ...process.env, HOME: home, USERPROFILE: home };
|
|
64
66
|
for (const k of Object.keys(env)) if (/_API_BASE$/.test(k) || k === 'OTB_CONDUCTOR_OFF' || k === 'OTB_SUBAGENT') delete env[k];
|
|
67
|
+
let cwd = home;
|
|
68
|
+
if (worktree) {
|
|
69
|
+
cwd = path.join(home, worktree);
|
|
70
|
+
fs.mkdirSync(cwd, { recursive: true });
|
|
71
|
+
execFileSync('git', ['init', '-q'], { cwd, stdio: 'ignore' });
|
|
72
|
+
}
|
|
65
73
|
try {
|
|
66
|
-
const child = spawn(process.execPath, [HOOK], { env, cwd
|
|
74
|
+
const child = spawn(process.execPath, [HOOK], { env, cwd, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
67
75
|
let out = ''; let err = '';
|
|
68
76
|
child.stdout.on('data', (d) => { out += d; });
|
|
69
77
|
child.stderr.on('data', (d) => { err += d; });
|
|
70
|
-
child.stdin.end(JSON.stringify({ session_id: 'e2e-' + Date.now(), prompt: 'hello', cwd
|
|
78
|
+
child.stdin.end(JSON.stringify({ session_id: 'e2e-' + Date.now(), prompt: 'hello', cwd }));
|
|
71
79
|
const code = await new Promise((r) => child.on('close', r));
|
|
72
80
|
return { out, err, code };
|
|
73
81
|
} finally {
|
|
@@ -94,4 +102,30 @@ await test('with an artist claim, the artist contract is the one injected', asyn
|
|
|
94
102
|
assert.ok(!r.out.includes(SPECIALITY));
|
|
95
103
|
});
|
|
96
104
|
|
|
105
|
+
// task 1002992: the craft's pack pointer, built by the SERVER's own helper from
|
|
106
|
+
// the real registry (what GET /me puts on each claim), run through the real hook.
|
|
107
|
+
const craftMode = require('../src/bongos/craft-mode.js');
|
|
108
|
+
const MODES = craftMode.readModes();
|
|
109
|
+
function twoTreePayload() {
|
|
110
|
+
const claim = (id, discipline) => ({ task_id: id, title: 't', touches: [], worktree_name: `task-${id}`, discipline, craft_mode: craftMode.describeCraftMode(discipline, MODES) });
|
|
111
|
+
return { ...mePayload(), specialities: [], active_claims: [claim(77, 'engineer'), claim(88, 'ideator')] };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
await test('a session in a claimed worktree is told its craft\'s pack on the first prompt, and not the other tree\'s', async () => {
|
|
115
|
+
const r = await runHook(twoTreePayload(), { worktree: 'task-77' });
|
|
116
|
+
assert.equal(r.code, 0);
|
|
117
|
+
assert.ok(r.out.includes('docs/packs/engineer.md'), `the engineer pack pointer reaches the session; stdout was: ${JSON.stringify(r.out.slice(0, 400))}`);
|
|
118
|
+
assert.ok(!r.out.includes('docs/packs/ideator.md'), 'worktree task-88\'s ideator craft does not');
|
|
119
|
+
const other = await runHook(twoTreePayload(), { worktree: 'task-88' });
|
|
120
|
+
assert.ok(other.out.includes('docs/packs/ideator.md'));
|
|
121
|
+
assert.ok(!other.out.includes('docs/packs/engineer.md'));
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
await test('outside any claimed worktree no craft is guessed', async () => {
|
|
125
|
+
const r = await runHook(twoTreePayload());
|
|
126
|
+
assert.equal(r.code, 0);
|
|
127
|
+
assert.ok(!r.out.includes('docs/packs/'), 'no tree, no craft — silent rather than pooled');
|
|
128
|
+
assert.ok(r.out.includes(INTERACTION), 'and the rest of the hook still runs');
|
|
129
|
+
});
|
|
130
|
+
|
|
97
131
|
summary();
|