@bongos/core 1.19.818 → 1.19.820
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 +85 -35
- package/.claude/skills/strand-fix/SKILL.md +11 -2
- package/.gitattributes +9 -0
- package/clients/bongos-client/index.d.ts +1 -1
- package/docs/api/openapi.json +4 -2
- package/docs/copy-inventory.md +23 -18
- package/docs/copy-registry.json +76 -31
- package/docs/module-api-changelog.md +4 -0
- package/docs/recipes/gds-db-backup.md +46 -0
- package/migrations/core_246_session_type.sql +49 -0
- package/modules/discord/bug-attachments.js +36 -1
- package/modules/hall-ui/public/sessions.css +19 -0
- package/modules/hall-ui/public/sessions.html +16 -1
- package/modules/hall-ui/public/sessions.js +135 -6
- package/modules/sessions/db.js +92 -6
- package/modules/sessions/routes/sessions.js +18 -1
- package/modules/sessions/session-type.js +132 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/backfill-session-types.js +87 -0
- package/scripts/gds/bug-attachment-audit.js +143 -0
- package/scripts/gds/capture.js +19 -0
- package/scripts/gds/db-backup-nightly.sh +37 -1
- package/scripts/gds/fitness.js +12 -1
- package/scripts/gds/home-isolation-guard.js +126 -0
- package/scripts/gds/ship-merge.js +5 -1
- package/scripts/gds/ship-regen.js +29 -0
- package/scripts/gds/skill-preflight.js +28 -4
- package/src/module-api.js +1 -1
- package/tests/bug_attachment_audit.mjs +122 -0
- package/tests/cli_package.mjs +18 -2
- package/tests/cli_surface.mjs +31 -2
- package/tests/hall_roster_world.mjs +10 -5
- package/tests/home_isolation_guard.mjs +95 -0
- package/tests/module_api_lazy.mjs +32 -1
- package/tests/routine_capture_kind.mjs +147 -0
- package/tests/session_type.mjs +215 -0
- package/tests/ship_regen_copy_inventory.mjs +69 -0
- package/tests/skill_preflight.mjs +45 -0
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// tests/bug_attachment_audit.mjs — the described-but-missing probe (task 1003918).
|
|
2
|
+
//
|
|
3
|
+
// WHY THIS FILE EXISTS. Five re-hosted #bugs screenshots — every one this instance
|
|
4
|
+
// ever stored — disappeared in the 2026-07-05 droplet move, and nobody noticed for
|
|
5
|
+
// two months. They cannot be re-fetched: the Discord CDN URL they were copied from
|
|
6
|
+
// is signed and expires in about a day, so our copy IS the image. The probe is what
|
|
7
|
+
// should have said so in July.
|
|
8
|
+
//
|
|
9
|
+
// A probe like this fails by finding nothing, and a clean bill of health looks
|
|
10
|
+
// identical to working — which is exactly what happened while building it: the
|
|
11
|
+
// first cut queried GET /tasks without naming a status, the route omits abandoned
|
|
12
|
+
// rows by default, and every task describing these files is now abandoned or
|
|
13
|
+
// shipped. It printed "0 linked, 0 missing". So the cases below lean on the ways
|
|
14
|
+
// the extraction can go quietly empty.
|
|
15
|
+
//
|
|
16
|
+
// Run: node tests/bug_attachment_audit.mjs
|
|
17
|
+
|
|
18
|
+
import { test } from 'node:test';
|
|
19
|
+
import assert from 'node:assert/strict';
|
|
20
|
+
import { createRequire } from 'node:module';
|
|
21
|
+
|
|
22
|
+
const require = createRequire(import.meta.url);
|
|
23
|
+
const { describedAttachments, auditRows } = require('../scripts/gds/bug-attachment-audit.js');
|
|
24
|
+
const { attachmentsDir } = require('../modules/discord/bug-attachments.js');
|
|
25
|
+
const path = require('node:path');
|
|
26
|
+
|
|
27
|
+
// The real link shape, taken verbatim from task 1003718's description.
|
|
28
|
+
const REAL = '/api/gds/bug-attachments/bug-1523688670492627115-8bb16de1f5b4c20e.png';
|
|
29
|
+
|
|
30
|
+
test('extracts the attachment name from a real embedded link', () => {
|
|
31
|
+
const got = describedAttachments(``);
|
|
32
|
+
assert.deepEqual(got, ['bug-1523688670492627115-8bb16de1f5b4c20e.png']);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('the 19-digit Discord message id is not too long for the name pattern', () => {
|
|
36
|
+
// The id in every real filename here is 19 characters. A pattern sized for
|
|
37
|
+
// something shorter would match nothing and report a clean board.
|
|
38
|
+
assert.equal(describedAttachments(REAL).length, 1);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('both /gds/ and /bongos/ spellings of the route are recognised', () => {
|
|
42
|
+
const a = describedAttachments('/api/gds/bug-attachments/bug-1-0123456789abcdef.png');
|
|
43
|
+
const b = describedAttachments('/api/bongos/bug-attachments/bug-1-0123456789abcdef.png');
|
|
44
|
+
assert.deepEqual(a, b, 'the route was renamed; a description can carry either');
|
|
45
|
+
assert.equal(a.length, 1);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('the same file linked twice in one description is counted once', () => {
|
|
49
|
+
const text = ` and again `;
|
|
50
|
+
assert.equal(describedAttachments(text).length, 1);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('a description with no attachment yields nothing — and that is not an error', () => {
|
|
54
|
+
assert.deepEqual(describedAttachments('a bug about the hall sidebar'), []);
|
|
55
|
+
assert.deepEqual(describedAttachments(null), []);
|
|
56
|
+
assert.deepEqual(describedAttachments(undefined), []);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('a malformed name is NOT extracted — the pattern is the traversal defence too', () => {
|
|
60
|
+
assert.deepEqual(describedAttachments('/api/gds/bug-attachments/../../etc/passwd'), []);
|
|
61
|
+
assert.deepEqual(describedAttachments('/api/gds/bug-attachments/bug-1-short.png'), []);
|
|
62
|
+
assert.deepEqual(describedAttachments('/api/gds/bug-attachments/bug-1-0123456789abcdef.exe'), []);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('auditRows names the TASK for each missing file, not just the file', () => {
|
|
66
|
+
const rows = [
|
|
67
|
+
{ id: '1003718', title: 'a re-filed bug', description: `` },
|
|
68
|
+
{ id: '1003719', title: 'no images here', description: 'plain prose' },
|
|
69
|
+
];
|
|
70
|
+
const r = auditRows(rows, () => false);
|
|
71
|
+
assert.equal(r.described.length, 1);
|
|
72
|
+
assert.equal(r.missing.length, 1);
|
|
73
|
+
assert.equal(r.missing[0].taskId, '1003718', '"a file is gone" is not actionable; "the image in task N is gone" is');
|
|
74
|
+
assert.equal(r.present, 0);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('a present file is not reported missing', () => {
|
|
78
|
+
const rows = [{ id: '1', title: 't', description: `` }];
|
|
79
|
+
const r = auditRows(rows, () => true);
|
|
80
|
+
assert.deepEqual(r.missing, []);
|
|
81
|
+
assert.equal(r.present, 1);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('the same file described by two tasks is reported for BOTH', () => {
|
|
85
|
+
// task 1002076 was re-filed as 1003718 and both descriptions carry the links;
|
|
86
|
+
// each renders a broken image, so each is worth naming.
|
|
87
|
+
const rows = [
|
|
88
|
+
{ id: '1002076', title: 'original', description: `` },
|
|
89
|
+
{ id: '1003718', title: 're-file', description: `` },
|
|
90
|
+
];
|
|
91
|
+
const r = auditRows(rows, () => false);
|
|
92
|
+
assert.equal(r.missing.length, 2);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('the store resolves against the INSTANCE root, not the core package (task 1003918)', () => {
|
|
96
|
+
// The old default was __dirname/../../var, which on a vendored core is
|
|
97
|
+
// node_modules/@bongos/core/var — wiped by every reinstall, and these files
|
|
98
|
+
// cannot be regenerated.
|
|
99
|
+
const prev = process.env.BUG_ATTACHMENTS_DIR;
|
|
100
|
+
delete process.env.BUG_ATTACHMENTS_DIR;
|
|
101
|
+
try {
|
|
102
|
+
const dir = attachmentsDir();
|
|
103
|
+
assert.ok(!dir.includes('node_modules'), `the store must not live inside an installed package: ${dir}`);
|
|
104
|
+
assert.equal(path.basename(dir), 'bug-attachments');
|
|
105
|
+
assert.equal(path.basename(path.dirname(dir)), 'var');
|
|
106
|
+
} finally {
|
|
107
|
+
if (prev === undefined) delete process.env.BUG_ATTACHMENTS_DIR;
|
|
108
|
+
else process.env.BUG_ATTACHMENTS_DIR = prev;
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('BUG_ATTACHMENTS_DIR still wins outright', () => {
|
|
113
|
+
const prev = process.env.BUG_ATTACHMENTS_DIR;
|
|
114
|
+
process.env.BUG_ATTACHMENTS_DIR = '/srv/persistent/bug-attachments';
|
|
115
|
+
try {
|
|
116
|
+
assert.equal(attachmentsDir(), '/srv/persistent/bug-attachments',
|
|
117
|
+
'on a box whose instance root is impermanent, the override is the answer');
|
|
118
|
+
} finally {
|
|
119
|
+
if (prev === undefined) delete process.env.BUG_ATTACHMENTS_DIR;
|
|
120
|
+
else process.env.BUG_ATTACHMENTS_DIR = prev;
|
|
121
|
+
}
|
|
122
|
+
});
|
package/tests/cli_package.mjs
CHANGED
|
@@ -21,6 +21,22 @@ import { spawnSync } from 'node:child_process';
|
|
|
21
21
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
22
22
|
|
|
23
23
|
const REPO_ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
24
|
+
|
|
25
|
+
// HOME ALONE IS NOT ISOLATION ON WINDOWS (task 1003763).
|
|
26
|
+
//
|
|
27
|
+
// os.homedir() reads USERPROFILE on win32 and never consults HOME, so
|
|
28
|
+
// `env: { ...process.env, HOME: home }` isolated nothing there: the installed CLI
|
|
29
|
+
// saw the DEVELOPER'S REAL session file. That is precisely the false green the
|
|
30
|
+
// comment below guards against — an unauthenticated-path assertion passing because
|
|
31
|
+
// the machine happens to be signed in.
|
|
32
|
+
//
|
|
33
|
+
// HOMEDRIVE/HOMEPATH are os.homedir()'s second win32 source, so all four go. Named
|
|
34
|
+
// rather than inlined twice: this is the third site in the repo to need it (after
|
|
35
|
+
// tests/cli_sessions.mjs in task 1003760 and tests/cli_surface.mjs in task 1003941),
|
|
36
|
+
// and every one of them got it half-right first.
|
|
37
|
+
function homelessEnv(home, extra = {}) {
|
|
38
|
+
return { ...process.env, HOME: home, USERPROFILE: home, HOMEDRIVE: '', HOMEPATH: home, ...extra };
|
|
39
|
+
}
|
|
24
40
|
const BUILDER = path.join(REPO_ROOT, 'scripts', 'gds', 'build-cli-package.js');
|
|
25
41
|
// Running npm from a test (task 1003529). On Windows `npm` is a .cmd shim: bare
|
|
26
42
|
// 'npm' is ENOENT and 'npm.cmd' is EINVAL under Node 24's CVE-2024-27980 mitigation,
|
|
@@ -152,7 +168,7 @@ test('the packed tarball installs and every verb runs with no repo present', (t)
|
|
|
152
168
|
// file on this machine cannot make a verb look healthier than it is.
|
|
153
169
|
fs.writeFileSync(path.join(site, 'package.json'), JSON.stringify({ name: 'probe', private: true }));
|
|
154
170
|
const installed = npm(['install', '--no-audit', '--no-fund', '--silent', path.join(site, tarball)], {
|
|
155
|
-
cwd: site, encoding: 'utf8', env:
|
|
171
|
+
cwd: site, encoding: 'utf8', env: homelessEnv(home),
|
|
156
172
|
});
|
|
157
173
|
assert.equal(installed.status, 0, `npm install failed:\n${installed.stderr}`);
|
|
158
174
|
|
|
@@ -166,7 +182,7 @@ test('the packed tarball installs and every verb runs with no repo present', (t)
|
|
|
166
182
|
|
|
167
183
|
const runVerb = (args) => spawnSync(process.execPath, [bongos, ...args], {
|
|
168
184
|
cwd: site, encoding: 'utf8', input: '',
|
|
169
|
-
env:
|
|
185
|
+
env: homelessEnv(home, { CLOUDBONGOS_API_BASE: 'http://127.0.0.1:45999', NO_COLOR: '1' }),
|
|
170
186
|
});
|
|
171
187
|
|
|
172
188
|
// help and version must work with nothing configured at all.
|
package/tests/cli_surface.mjs
CHANGED
|
@@ -169,18 +169,47 @@ function runGuidance(fn, env) {
|
|
|
169
169
|
const code =
|
|
170
170
|
`const l=require(${JSON.stringify(path.join(REPO_ROOT, 'scripts', 'gds', 'cli-lib.js'))});` +
|
|
171
171
|
`Promise.resolve(l[${JSON.stringify(fn)}]()).catch(()=>{});`;
|
|
172
|
+
// HOME ALONE IS NOT ISOLATION ON WINDOWS (task 1003941). cli-lib resolves the
|
|
173
|
+
// session through os.homedir(), which on win32 reads USERPROFILE and ignores
|
|
174
|
+
// HOME entirely — so the child found the DEVELOPER'S REAL session, requireSession
|
|
175
|
+
// succeeded, and the guidance these tests exist to read was never printed. That
|
|
176
|
+
// failed on any Windows box whose builder happened to be signed in, and went
|
|
177
|
+
// green again once their CLI session expired, which made it read as a flake.
|
|
178
|
+
//
|
|
179
|
+
// HOMEDRIVE/HOMEPATH are os.homedir()'s second win32 source, so all four go.
|
|
180
|
+
const HOMELESS = path.join(REPO_ROOT, 'no-such-home-for-tests');
|
|
172
181
|
const r = spawnSync(process.execPath, ['-e', code], {
|
|
173
182
|
encoding: 'utf8',
|
|
174
|
-
env: {
|
|
183
|
+
env: {
|
|
184
|
+
...process.env,
|
|
185
|
+
...env,
|
|
186
|
+
HOME: HOMELESS,
|
|
187
|
+
USERPROFILE: HOMELESS,
|
|
188
|
+
HOMEDRIVE: '',
|
|
189
|
+
HOMEPATH: HOMELESS,
|
|
190
|
+
},
|
|
175
191
|
});
|
|
176
192
|
return `${r.stdout || ''}${r.stderr || ''}`;
|
|
177
193
|
}
|
|
178
194
|
|
|
195
|
+
// The isolation above either worked or it did not, and the difference is visible:
|
|
196
|
+
// a child with no session PRINTS guidance, and a child that found one prints
|
|
197
|
+
// nothing. Every assertion below that looks for something ABSENT is satisfied for
|
|
198
|
+
// free by that silence, so the silence is checked for first (task 1003941).
|
|
199
|
+
function guidanceOutput(fn, env) {
|
|
200
|
+
const out = runGuidance(fn, env);
|
|
201
|
+
assert.ok(out.trim().length > 0,
|
|
202
|
+
`${fn} printed NOTHING. That means the child found a real CLI session instead of `
|
|
203
|
+
+ 'the blanked home, so this test is reading an unisolated environment and every '
|
|
204
|
+
+ '"must not contain" assertion here is vacuous (task 1003941).');
|
|
205
|
+
return out;
|
|
206
|
+
}
|
|
207
|
+
|
|
179
208
|
const GUIDANCE = ['printUnauthGuidanceAndExit', 'printBoxScopeGuidanceAndExit', 'requireSession'];
|
|
180
209
|
|
|
181
210
|
test('no guidance path speaks slash commands to a terminal reader', () => {
|
|
182
211
|
for (const fn of GUIDANCE) {
|
|
183
|
-
const out =
|
|
212
|
+
const out = guidanceOutput(fn, { BONGOS_SURFACE: 'terminal' });
|
|
184
213
|
const leaks = [...out.matchAll(/\/builder-[a-z-]+/g)].map((m) => m[0]);
|
|
185
214
|
assert.deepEqual(leaks, [], `${fn} leaked to a terminal reader: ${leaks.join(', ')}\n${out}`);
|
|
186
215
|
assert.ok(!/node scripts\/gds\//.test(out), `${fn} told a terminal reader to run a repo script:\n${out}`);
|
|
@@ -271,11 +271,16 @@ test('sessions: every id on a row is a door, the earned figure is shown, the tot
|
|
|
271
271
|
assert.match(html, /<div class="stat-row" id="stats-body"><\/div>/);
|
|
272
272
|
const ths = (html.match(/<th[ >]/g) || []).length;
|
|
273
273
|
// task 1002516: a tenth column, "Plain speech" — the transcript accessibility
|
|
274
|
-
// dimension the criterion asks to surface on the dashboard (ADR 0291).
|
|
275
|
-
//
|
|
276
|
-
//
|
|
277
|
-
|
|
278
|
-
|
|
274
|
+
// dimension the criterion asks to surface on the dashboard (ADR 0291).
|
|
275
|
+
// task 1003975: an eleventh, "Kind" — the session's derived shape, so the 54%
|
|
276
|
+
// of rows with no task chip are readable at all. The per-cell contracts (a
|
|
277
|
+
// not-scored session is a dash, never a zero; an unclassified one is a dash,
|
|
278
|
+
// never 'Unclear') live in tests/hall_sessions_ledger.mjs and
|
|
279
|
+
// tests/session_type.mjs; this pin is the column count AND that the empty row
|
|
280
|
+
// still spans them — a header added without widening the colspan leaves the
|
|
281
|
+
// empty state short by a column, which no screenshot pass would catch.
|
|
282
|
+
assert.equal(ths, 11, 'eleven columns: session, builder, kind, tasks, earned, turns, tokens, tool errors, duration, plain speech, recorded');
|
|
283
|
+
assert.match(src, new RegExp(`colspan="${ths}"`), 'the empty row spans them all');
|
|
279
284
|
const fam = html.indexOf('/builders/oversight.css');
|
|
280
285
|
assert.ok(fam > 0 && html.indexOf('/builders/sessions.css') > fam, 'sessions.html links the family sheet before its own');
|
|
281
286
|
});
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// tests/home_isolation_guard.mjs — setting HOME for a child isolates nothing on
|
|
2
|
+
// Windows (task 1003763).
|
|
3
|
+
//
|
|
4
|
+
// os.homedir() reads USERPROFILE on win32 and never consults HOME, so
|
|
5
|
+
// `env: { ...process.env, HOME: tmp }` leaves the child looking at the developer's
|
|
6
|
+
// real home — and their real CLI session. Three tasks fixed this one site at a time:
|
|
7
|
+
// 1003760 (cli_sessions, which DESTROYED a live session), 1003941 (cli_surface) and
|
|
8
|
+
// this one (cli_package). The last two are the dangerous shape — a test that isolates
|
|
9
|
+
// nothing does not announce itself, it quietly stops testing what its comment claims.
|
|
10
|
+
//
|
|
11
|
+
// Run: node tests/home_isolation_guard.mjs
|
|
12
|
+
|
|
13
|
+
import { test } from 'node:test';
|
|
14
|
+
import assert from 'node:assert/strict';
|
|
15
|
+
import { readFileSync } from 'node:fs';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
import { fileURLToPath } from 'node:url';
|
|
18
|
+
import { createRequire } from 'node:module';
|
|
19
|
+
|
|
20
|
+
const require = createRequire(import.meta.url);
|
|
21
|
+
const { checkHomeIsolation, offendingLines, stripComments, ALLOW } =
|
|
22
|
+
require('../scripts/gds/home-isolation-guard.js');
|
|
23
|
+
|
|
24
|
+
const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
25
|
+
|
|
26
|
+
test('the live tree is clean, and the scan is not empty', () => {
|
|
27
|
+
const r = checkHomeIsolation();
|
|
28
|
+
assert.deepEqual(r.violations, [], r.violations.join('\n'));
|
|
29
|
+
// A guard that scans nothing passes for free. The note carries the counts so a
|
|
30
|
+
// silently-broken scan is visible rather than reassuring.
|
|
31
|
+
const scanned = Number(/^(\d+) test file/.exec(r.note)?.[1] ?? 0);
|
|
32
|
+
const sites = Number(/(\d+) of them build a child env/.exec(r.note)?.[1] ?? 0);
|
|
33
|
+
assert.ok(scanned > 100, `expected the whole tests/ tree, scanned ${scanned}`);
|
|
34
|
+
assert.ok(sites > 0, 'no file builds a child env with HOME — the pattern has drifted and this guard is inert');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('the original broken shape IS caught', () => {
|
|
38
|
+
const src = 'const r = spawnSync(node, [x], { env: { ...process.env, HOME: home } });';
|
|
39
|
+
assert.equal(offendingLines(src).length, 1);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('the fixed shape is not', () => {
|
|
43
|
+
const src = 'const r = spawnSync(node, [x], { env: { ...process.env, HOME: home, USERPROFILE: home } });';
|
|
44
|
+
assert.deepEqual(offendingLines(src), []);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('a HELPER CALL SITE is not a child env', () => {
|
|
48
|
+
// tests/setup_smoke.mjs passes { HOME: fakeHome } to its own runSetup(), which adds
|
|
49
|
+
// USERPROFILE itself. Flagging the callers would point at the wrong line and, worse,
|
|
50
|
+
// train someone to "fix" three call sites instead of the one helper.
|
|
51
|
+
assert.deepEqual(offendingLines("runSetup(['--dry-run'], { HOME: fakeHome });"), []);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('a COMMENT quoting the bug is not the bug', () => {
|
|
55
|
+
// Every file that fixed this now explains it by quoting the broken line. A raw text
|
|
56
|
+
// scan therefore reports exactly the files that already fixed it — which is how the
|
|
57
|
+
// first run of this guard produced three false positives.
|
|
58
|
+
assert.deepEqual(offendingLines('// env: { ...process.env, HOME: home } isolated nothing'), []);
|
|
59
|
+
assert.deepEqual(offendingLines('/* env: { ...process.env, HOME: home } */'), []);
|
|
60
|
+
assert.ok(!stripComments('// { ...process.env, HOME: x }').includes('HOME'));
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('a multi-line literal is still read', () => {
|
|
64
|
+
const src = [
|
|
65
|
+
'const r = spawnSync(node, [x], {',
|
|
66
|
+
' env: {',
|
|
67
|
+
' ...process.env,',
|
|
68
|
+
' HOME: home,',
|
|
69
|
+
' },',
|
|
70
|
+
'});',
|
|
71
|
+
].join('\n');
|
|
72
|
+
assert.equal(offendingLines(src).length, 1, 'the literal wraps — a single-line matcher misses the real shape');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('every allowlisted file exists and still means HOME differently', () => {
|
|
76
|
+
// An allowlist entry is a claim. If the file is gone, the claim is stale and the
|
|
77
|
+
// entry is a hole nobody is watching.
|
|
78
|
+
for (const [name, why] of ALLOW) {
|
|
79
|
+
const p = path.join(REPO_ROOT, 'tests', name);
|
|
80
|
+
let src;
|
|
81
|
+
try { src = readFileSync(p, 'utf8'); } catch {
|
|
82
|
+
assert.fail(`allowlisted tests/${name} no longer exists — remove the entry rather than leaving an unwatched hole`);
|
|
83
|
+
}
|
|
84
|
+
assert.ok(why && why.length > 20, `tests/${name} needs a reason, not just an exemption`);
|
|
85
|
+
assert.match(src, /HOME/, `tests/${name} no longer mentions HOME, so its exemption is dead`);
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('the two sites this task fixed are actually fixed', () => {
|
|
90
|
+
const src = readFileSync(path.join(REPO_ROOT, 'tests', 'cli_package.mjs'), 'utf8');
|
|
91
|
+
assert.deepEqual(offendingLines(src), []);
|
|
92
|
+
assert.match(src, /USERPROFILE/, 'cli_package must set USERPROFILE alongside HOME');
|
|
93
|
+
const surface = readFileSync(path.join(REPO_ROOT, 'tests', 'cli_surface.mjs'), 'utf8');
|
|
94
|
+
assert.deepEqual(offendingLines(surface), []);
|
|
95
|
+
});
|
|
@@ -35,8 +35,13 @@ function loadedAfter(spec, read = null) {
|
|
|
35
35
|
const ROOT = ${JSON.stringify(ROOT)};
|
|
36
36
|
const m = require(path.join(ROOT, ${JSON.stringify(spec)}));
|
|
37
37
|
${read ? `void m[${JSON.stringify(read)}];` : ''}
|
|
38
|
+
// POSIX-normalised before filtering (task 1003941). path.relative returns
|
|
39
|
+
// backslashes on win32, so 'src/bongos/' matched NOTHING and every list came
|
|
40
|
+
// back empty — which made the two laziness assertions below pass VACUOUSLY on
|
|
41
|
+
// Windows: they assert that a filtered-empty list is empty. An eager require
|
|
42
|
+
// reintroduced at the doorway would have shipped green.
|
|
38
43
|
const files = Object.keys(require.cache)
|
|
39
|
-
.map((f) => path.relative(ROOT, f))
|
|
44
|
+
.map((f) => path.relative(ROOT, f).split(path.sep).join('/'))
|
|
40
45
|
.filter((f) => f.startsWith('src/bongos/'));
|
|
41
46
|
process.stdout.write(JSON.stringify(files));
|
|
42
47
|
`;
|
|
@@ -50,6 +55,32 @@ function loadedAfter(spec, read = null) {
|
|
|
50
55
|
return JSON.parse(out);
|
|
51
56
|
}
|
|
52
57
|
|
|
58
|
+
// CALIBRATION — can the instrument see anything at all? (task 1003941)
|
|
59
|
+
//
|
|
60
|
+
// Every other assertion in this file has the shape "nothing heavy is in this
|
|
61
|
+
// list", which an EMPTY list satisfies for free. So an empty list is not evidence
|
|
62
|
+
// of laziness; it is evidence of nothing, and the two tests that guard the ADR
|
|
63
|
+
// 0083 doorway invariant cannot tell the difference.
|
|
64
|
+
//
|
|
65
|
+
// That is not hypothetical. loadedAfter filtered on a 'src/bongos/' prefix after
|
|
66
|
+
// path.relative, which returns BACKSLASHES on win32 — so on Windows the filter
|
|
67
|
+
// matched nothing, every list was empty, and both doorway tests passed vacuously.
|
|
68
|
+
// An eager require reintroduced at the doorway would have shipped green.
|
|
69
|
+
//
|
|
70
|
+
// The fix normalises separators, but a normalisation can break again silently, and
|
|
71
|
+
// asserting the doorway's own list is non-empty would be wrong — loading NOTHING
|
|
72
|
+
// from src/bongos/ is precisely what the doorway is supposed to do. So the probe
|
|
73
|
+
// is calibrated against a module that certainly does load one. If this test fails,
|
|
74
|
+
// every emptiness assertion below is meaningless and should be read as untested.
|
|
75
|
+
test('CALIBRATION: the probe can see a src/bongos/ require at all', () => {
|
|
76
|
+
const files = loadedAfter('src/bongos/pool.js');
|
|
77
|
+
assert.ok(files.includes('src/bongos/pool.js'),
|
|
78
|
+
'requiring src/bongos/pool.js must show up in the probe\'s own list. It does not, so '
|
|
79
|
+
+ 'the list is empty for reasons unrelated to laziness — check the path normalisation '
|
|
80
|
+
+ 'in loadedAfter (task 1003941). Until this passes, every "loads nothing" assertion '
|
|
81
|
+
+ `in this file is vacuous. Saw: ${JSON.stringify(files.slice(0, 5))}`);
|
|
82
|
+
});
|
|
83
|
+
|
|
53
84
|
test('importing the doorway loads no server: no pool, no auth, no routes', () => {
|
|
54
85
|
const files = loadedAfter('src/module-api.js');
|
|
55
86
|
const heavy = files.filter((f) => HEAVY.test(f));
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// tests/routine_capture_kind.mjs — a routine that files its findings must name a
|
|
2
|
+
// kind the server accepts (task 1003694).
|
|
3
|
+
//
|
|
4
|
+
// THE FAILURE THIS PINS. .claude/scheduled-tasks/dep-audit-nightly/SKILL.md filed
|
|
5
|
+
// with `--kind security`. That is not one of the kinds — captureIdea throws BAD_KIND
|
|
6
|
+
// and the route 400s — so the capture step of that nightly routine had NEVER filed
|
|
7
|
+
// anything.
|
|
8
|
+
//
|
|
9
|
+
// It is quiet in the worst way. The routine still runs, still does the audit, and
|
|
10
|
+
// only the step that turns a finding into a durable row fails. There is no
|
|
11
|
+
// accumulating queue whose emptiness would look wrong, so nothing ever looked wrong.
|
|
12
|
+
// A scheduled routine cannot notice its own silence, which is exactly why this is a
|
|
13
|
+
// test and not a runtime check.
|
|
14
|
+
//
|
|
15
|
+
// Run: node tests/routine_capture_kind.mjs
|
|
16
|
+
|
|
17
|
+
import { test } from 'node:test';
|
|
18
|
+
import assert from 'node:assert/strict';
|
|
19
|
+
import { readFileSync, readdirSync, existsSync } from 'node:fs';
|
|
20
|
+
import path from 'node:path';
|
|
21
|
+
import { fileURLToPath } from 'node:url';
|
|
22
|
+
import { createRequire } from 'node:module';
|
|
23
|
+
|
|
24
|
+
const require = createRequire(import.meta.url);
|
|
25
|
+
const { VALID_KINDS } = require('../modules/ideas/inbox.js');
|
|
26
|
+
|
|
27
|
+
const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
28
|
+
|
|
29
|
+
// Every SKILL.md that could carry a capture command: the scheduled routines and the
|
|
30
|
+
// skills. A routine is the dangerous case (nobody reads its output), but a skill with
|
|
31
|
+
// a bad kind wastes a builder's time the same way.
|
|
32
|
+
function skillFiles() {
|
|
33
|
+
const out = [];
|
|
34
|
+
const roots = [
|
|
35
|
+
path.join(REPO_ROOT, '.claude', 'scheduled-tasks'),
|
|
36
|
+
path.join(REPO_ROOT, '.claude', 'skills'),
|
|
37
|
+
];
|
|
38
|
+
for (const root of roots) {
|
|
39
|
+
if (!existsSync(root)) continue;
|
|
40
|
+
for (const d of readdirSync(root, { withFileTypes: true })) {
|
|
41
|
+
if (!d.isDirectory()) continue;
|
|
42
|
+
const p = path.join(root, d.name, 'SKILL.md');
|
|
43
|
+
if (existsSync(p)) out.push(p);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// `--kind foo` — a LITERAL kind, on a CAPTURE command only.
|
|
50
|
+
//
|
|
51
|
+
// Both narrowings are load-bearing, and I got each wrong first:
|
|
52
|
+
//
|
|
53
|
+
// - CAPTURE ONLY. `--kind` is not unique to capture.js. recall.js takes its own
|
|
54
|
+
// `--kind doc|db`, and a matcher that reads every `--kind` in the tree reports
|
|
55
|
+
// three recall invocations as broken. A guard whose first run is mostly noise
|
|
56
|
+
// is a guard people learn to suppress.
|
|
57
|
+
// - LITERAL ONLY. A placeholder (`--kind <bug|cleanup>`, `--kind cleanup|refactor`)
|
|
58
|
+
// documents a CHOICE rather than passing a value; overnight-code-review and
|
|
59
|
+
// repo-simplify-sweep both use that form deliberately.
|
|
60
|
+
const CAPTURE_LINE_RE = /capture\.js/;
|
|
61
|
+
const KIND_RE = /--kind\s+([a-z][a-z0-9-]*)(?=[\s`'"]|$)/g;
|
|
62
|
+
|
|
63
|
+
// A capture command can wrap across lines with a trailing backslash, so the unit of
|
|
64
|
+
// inspection is the command, not the line: a run of lines joined while the previous
|
|
65
|
+
// one ends in `\`.
|
|
66
|
+
function captureCommands(text) {
|
|
67
|
+
const lines = String(text || '').split('\n');
|
|
68
|
+
const out = [];
|
|
69
|
+
let buf = null;
|
|
70
|
+
for (const line of lines) {
|
|
71
|
+
const continues = /\\\s*$/.test(line);
|
|
72
|
+
if (buf !== null) {
|
|
73
|
+
buf += ' ' + line;
|
|
74
|
+
if (!continues) { out.push(buf); buf = null; }
|
|
75
|
+
} else if (CAPTURE_LINE_RE.test(line)) {
|
|
76
|
+
if (continues) buf = line;
|
|
77
|
+
else out.push(line);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
if (buf !== null) out.push(buf);
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function literalKinds(text) {
|
|
85
|
+
const out = [];
|
|
86
|
+
for (const cmd of captureCommands(text)) {
|
|
87
|
+
for (const m of cmd.matchAll(KIND_RE)) {
|
|
88
|
+
const after = cmd.slice(m.index + m[0].length, m.index + m[0].length + 1);
|
|
89
|
+
if (after === '|') continue; // `--kind cleanup|refactor` — a menu, not a value
|
|
90
|
+
out.push(m[1]);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
test('every literal --kind in a routine or skill is a kind the server accepts', () => {
|
|
97
|
+
const files = skillFiles();
|
|
98
|
+
// The scan must not pass by finding nothing: these files exist, and at least one of
|
|
99
|
+
// them documents a capture command.
|
|
100
|
+
assert.ok(files.length > 0, 'found no SKILL.md files at all — the scan is broken, not clean');
|
|
101
|
+
|
|
102
|
+
const bad = [];
|
|
103
|
+
let seen = 0;
|
|
104
|
+
for (const f of files) {
|
|
105
|
+
for (const kind of literalKinds(readFileSync(f, 'utf8'))) {
|
|
106
|
+
seen += 1;
|
|
107
|
+
if (!VALID_KINDS.includes(kind)) bad.push(`${path.relative(REPO_ROOT, f)}: --kind ${kind}`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
assert.ok(seen > 0, 'no literal --kind found anywhere — the pattern has drifted, and this test would pass an empty repo');
|
|
111
|
+
assert.deepEqual(bad, [],
|
|
112
|
+
`these file a kind the server rejects with a 400 — for a SCHEDULED routine that means its findings are silently never filed:\n ${bad.join('\n ')}\n`
|
|
113
|
+
+ `Valid kinds: ${VALID_KINDS.join(', ')}`);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test('the matcher reads a literal kind on a capture command', () => {
|
|
117
|
+
assert.deepEqual(literalKinds('node scripts/gds/capture.js "t" --kind infra --body-file x'), ['infra']);
|
|
118
|
+
assert.deepEqual(literalKinds('node scripts/gds/capture.js "t" --kind security'), ['security'],
|
|
119
|
+
'the real bug is still caught');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('a DIFFERENT tool\'s --kind is not this guard\'s business', () => {
|
|
123
|
+
// recall.js takes --kind doc|db. Reading every --kind in the tree reported three
|
|
124
|
+
// recall invocations as broken on the first run of this guard.
|
|
125
|
+
assert.deepEqual(literalKinds('node scripts/gds/recall.js nightly backup --kind doc'), []);
|
|
126
|
+
assert.deepEqual(literalKinds('add `--kind doc` for ADRs/recipes'), []);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('a documented menu is not a value', () => {
|
|
130
|
+
assert.deepEqual(literalKinds('node scripts/gds/capture.js --kind <bug|cleanup|refactor>'), []);
|
|
131
|
+
assert.deepEqual(literalKinds('node scripts/gds/capture.js --kind cleanup|refactor \\'), []);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test('a capture command wrapped over lines is still read', () => {
|
|
135
|
+
const text = ['node scripts/gds/capture.js "t" \\', ' --kind security \\', ' --body-file /tmp/x'].join('\n');
|
|
136
|
+
assert.deepEqual(literalKinds(text), ['security'],
|
|
137
|
+
'dep-audit-nightly writes exactly this shape — a line-at-a-time matcher misses it');
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test('dep-audit-nightly specifically now files with an accepted kind (task 1003694)', () => {
|
|
141
|
+
const p = path.join(REPO_ROOT, '.claude', 'scheduled-tasks', 'dep-audit-nightly', 'SKILL.md');
|
|
142
|
+
const src = readFileSync(p, 'utf8');
|
|
143
|
+
assert.ok(!/--kind\s+security\b/.test(src), 'security is not a kind and never was');
|
|
144
|
+
const kinds = literalKinds(src);
|
|
145
|
+
assert.ok(kinds.length > 0, 'the routine must still file something');
|
|
146
|
+
for (const k of kinds) assert.ok(VALID_KINDS.includes(k), `${k} is not a valid kind`);
|
|
147
|
+
});
|