@bongos/core 1.21.178 → 1.21.180
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 +39 -29
- package/docs/adr/0017-code-simplification-and-repo-cleanliness.md +1 -0
- package/docs/copy-inventory.md +25 -25
- package/docs/copy-registry.json +36 -36
- package/docs/file-map.md +1 -0
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-readings.json +2 -2
- package/modules/builder-settings/display-name.js +0 -2
- package/modules/hall-ui/public/work.js +67 -14
- package/modules/ideas/born-of.js +1 -3
- package/modules/lifecycle/task-classifier.js +0 -2
- package/modules/specialities/db.js +1 -11
- package/modules/specialities/skills.js +1 -7
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +12 -0
- package/scripts/bongos/fitness-ratchets.js +84 -11
- package/scripts/bongos/pulse.js +5 -3
- package/scripts/bongos/repo-metrics.js +5 -2
- package/src/bongos/module-scope-map.js +3 -0
- package/src/module-api.js +1 -1
- package/tests/applicant_profile_boundary.mjs +8 -2
- package/tests/goal_sort_staleness.mjs +1 -1
- package/tests/knip_modules_scope.mjs +145 -0
- package/tests/work_board_live_refresh.mjs +236 -0
|
@@ -240,6 +240,85 @@ function localKnipPath() {
|
|
|
240
240
|
let knipSkipReason = null;
|
|
241
241
|
function lastKnipSkipReason() { return knipSkipReason; }
|
|
242
242
|
|
|
243
|
+
// ---- scoring knip's JSON (task 1004875) ----
|
|
244
|
+
//
|
|
245
|
+
// TWO THINGS THE OLD INLINE COUNT GOT WRONG, both found by putting modules/ in scope:
|
|
246
|
+
//
|
|
247
|
+
// 1. UNUSED FILES WERE NEVER COUNTED. It read a top-level `files` array; knip 6's
|
|
248
|
+
// JSON reporter has none — an unused file is an issue whose own `files` array is
|
|
249
|
+
// non-empty. So a brand-new dead file anywhere scored 0 and the ratchet waved it
|
|
250
|
+
// through. `files` is now one of the per-issue keys (the legacy top-level array is
|
|
251
|
+
// still read, for an older knip).
|
|
252
|
+
//
|
|
253
|
+
// 2. knip DOES NOT CREDIT A CommonJS NAMESPACE USE. Measured on a two-file repro:
|
|
254
|
+
// `const a = require('./a'); a.used()` leaves `used` reported as an unused export;
|
|
255
|
+
// only a destructured `const { used } = require('./a')` counts. src/ is mostly
|
|
256
|
+
// destructured (module-api.js says so), but modules/ is written the namespace way,
|
|
257
|
+
// so knip reports ~1,600 exports there that ARE used. Counting those would make
|
|
258
|
+
// every ordinary new export under modules/ red CI. So for a finding under
|
|
259
|
+
// modules/, an unused export counts only when its NAME appears in no other code
|
|
260
|
+
// file at all (a word match over .js/.mjs/.cjs/.html/.sh). That is the case knip
|
|
261
|
+
// is right about: exported, and nothing else even spells it. It under-counts (a
|
|
262
|
+
// name that happens to be spelled elsewhere hides) and never over-counts, which
|
|
263
|
+
// is the direction a gate that fails CI must err in. src/, scripts/ and tests/
|
|
264
|
+
// keep knip's own verdict, so their part of the baseline is unchanged.
|
|
265
|
+
const KNIP_ISSUE_KEYS = ['files', 'dependencies', 'devDependencies', 'optionalPeerDependencies', 'unlisted', 'binaries', 'unresolved', 'exports', 'types', 'nsExports', 'nsTypes', 'duplicates'];
|
|
266
|
+
const NAME_FILTERED_PREFIX = 'modules/';
|
|
267
|
+
const WORD_SCAN_EXT_RE = /\.(m?js|cjs|html|sh)$/;
|
|
268
|
+
const WORD_SCAN_SKIP = new Set(['node_modules', '.git', 'docs', 'public-docs', 'worktrees']);
|
|
269
|
+
const WORD_RE = /[A-Za-z_$][\w$]*/g;
|
|
270
|
+
|
|
271
|
+
// word -> the one repo-relative file that uses it, or true when 2+ files do.
|
|
272
|
+
function codeWordIndex(root = ROOT) {
|
|
273
|
+
const index = new Map();
|
|
274
|
+
const walk = (dir) => {
|
|
275
|
+
let ents = [];
|
|
276
|
+
try { ents = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
277
|
+
for (const e of ents) {
|
|
278
|
+
if (WORD_SCAN_SKIP.has(e.name)) continue;
|
|
279
|
+
const abs = path.join(dir, e.name);
|
|
280
|
+
if (e.isDirectory()) { walk(abs); continue; }
|
|
281
|
+
if (!WORD_SCAN_EXT_RE.test(e.name)) continue;
|
|
282
|
+
let text;
|
|
283
|
+
try { text = fs.readFileSync(abs, 'utf8'); } catch { continue; }
|
|
284
|
+
const rel = path.relative(root, abs).split(path.sep).join('/');
|
|
285
|
+
for (const w of new Set(text.match(WORD_RE) || [])) {
|
|
286
|
+
const seen = index.get(w);
|
|
287
|
+
if (seen === undefined) index.set(w, rel);
|
|
288
|
+
else if (seen !== true && seen !== rel) index.set(w, true);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
};
|
|
292
|
+
walk(root);
|
|
293
|
+
return index;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function nameUsedOutside(index, name, file) {
|
|
297
|
+
const seen = index.get(name);
|
|
298
|
+
return seen === true || (seen !== undefined && seen !== file);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// knip JSON -> the integer the ratchet scores. `wordIndex` is built on demand, and
|
|
302
|
+
// only when a modules/ export finding needs it.
|
|
303
|
+
function countKnipFindings(json, { root = ROOT, wordIndex = null } = {}) {
|
|
304
|
+
let total = Array.isArray(json && json.files) ? json.files.length : 0;
|
|
305
|
+
let index = wordIndex;
|
|
306
|
+
for (const issue of (json && json.issues) || []) {
|
|
307
|
+
const filtered = typeof issue.file === 'string' && issue.file.startsWith(NAME_FILTERED_PREFIX);
|
|
308
|
+
for (const key of KNIP_ISSUE_KEYS) {
|
|
309
|
+
const v = issue[key];
|
|
310
|
+
if (!Array.isArray(v)) continue;
|
|
311
|
+
if (filtered && (key === 'exports' || key === 'types')) {
|
|
312
|
+
if (!index) index = codeWordIndex(root);
|
|
313
|
+
total += v.filter((e) => !(e && nameUsedOutside(index, e.name, issue.file))).length;
|
|
314
|
+
} else {
|
|
315
|
+
total += v.length;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
return total;
|
|
320
|
+
}
|
|
321
|
+
|
|
243
322
|
function knipIssueCount() {
|
|
244
323
|
knipSkipReason = null;
|
|
245
324
|
if (!localKnipPath()) {
|
|
@@ -259,22 +338,14 @@ function knipIssueCount() {
|
|
|
259
338
|
if (e && e.stdout) { raw = e.stdout.toString(); }
|
|
260
339
|
else { knipSkipReason = `npx knip failed to run in ${ROOT}`; return null; }
|
|
261
340
|
}
|
|
341
|
+
let j;
|
|
262
342
|
try {
|
|
263
|
-
|
|
264
|
-
// knip's json reporter: { files: [...], issues: [{ file, owners, exports:[], types:[], ... }] }
|
|
265
|
-
const files = Array.isArray(j.files) ? j.files.length : 0;
|
|
266
|
-
let items = 0;
|
|
267
|
-
for (const issue of j.issues || []) {
|
|
268
|
-
for (const key of ['dependencies', 'devDependencies', 'optionalPeerDependencies', 'unlisted', 'binaries', 'unresolved', 'exports', 'types', 'nsExports', 'nsTypes', 'duplicates']) {
|
|
269
|
-
const v = issue[key];
|
|
270
|
-
items += Array.isArray(v) ? v.length : 0;
|
|
271
|
-
}
|
|
272
|
-
}
|
|
273
|
-
return files + items;
|
|
343
|
+
j = JSON.parse(raw);
|
|
274
344
|
} catch {
|
|
275
345
|
knipSkipReason = 'knip produced output this script could not parse as JSON';
|
|
276
346
|
return null;
|
|
277
347
|
}
|
|
348
|
+
return countKnipFindings(j);
|
|
278
349
|
}
|
|
279
350
|
|
|
280
351
|
// ---------- baseline + ratchet ----------
|
|
@@ -586,6 +657,8 @@ module.exports = {
|
|
|
586
657
|
consoleCallCount,
|
|
587
658
|
isServerPath,
|
|
588
659
|
knipIssueCount,
|
|
660
|
+
countKnipFindings,
|
|
661
|
+
codeWordIndex,
|
|
589
662
|
localKnipPath,
|
|
590
663
|
lastKnipSkipReason,
|
|
591
664
|
OVERSIZE_LINES,
|
package/scripts/bongos/pulse.js
CHANGED
|
@@ -71,12 +71,14 @@ const REPO_ROOT = path.resolve(__dirname, '..', '..');
|
|
|
71
71
|
// droplet machine without Postgres reachable).
|
|
72
72
|
function loadDeps() {
|
|
73
73
|
// eslint-disable-next-line global-require
|
|
74
|
-
const { pool } = require(
|
|
74
|
+
const { pool } = require('../../src/bongos/pool');
|
|
75
75
|
// eslint-disable-next-line global-require
|
|
76
76
|
// pulse.js (the lib) carved → modules/sessions/ (BV1.R82 / ADR 0093 §1); the CLI
|
|
77
77
|
// shell stays here and requires the module lib directly (scripts are outside the
|
|
78
|
-
// kernel boundary, so a direct module require is fine).
|
|
79
|
-
|
|
78
|
+
// kernel boundary, so a direct module require is fine). A LITERAL path, not
|
|
79
|
+
// path.join: the dead-code scanner (knip) cannot follow a computed require, and
|
|
80
|
+
// reported the lib as an unused file while this was its only caller (task 1004875).
|
|
81
|
+
const { sweepStaleClaims } = require('../../modules/sessions/pulse');
|
|
80
82
|
return { pool, sweepStaleClaims };
|
|
81
83
|
}
|
|
82
84
|
|
|
@@ -121,7 +121,9 @@ function scanDeadCode() {
|
|
|
121
121
|
configured =
|
|
122
122
|
!!pkg.knip ||
|
|
123
123
|
fs.existsSync(path.join(REPO_ROOT, 'knip.json')) ||
|
|
124
|
-
fs.existsSync(path.join(REPO_ROOT, 'knip.jsonc'))
|
|
124
|
+
fs.existsSync(path.join(REPO_ROOT, 'knip.jsonc')) ||
|
|
125
|
+
// The config moved to a JS file so it can generate modules/ entries (task 1004875).
|
|
126
|
+
fs.existsSync(path.join(REPO_ROOT, 'knip.config.js'));
|
|
125
127
|
} catch (_) {
|
|
126
128
|
configured = false;
|
|
127
129
|
}
|
|
@@ -163,7 +165,8 @@ function scanDeadCode() {
|
|
|
163
165
|
return acc + ex + ty;
|
|
164
166
|
}, 0);
|
|
165
167
|
deadFiles = report.issues.reduce(
|
|
166
|
-
|
|
168
|
+
// knip 6 gives an ARRAY here ([{ name }]), not a bool (task 1004875).
|
|
169
|
+
(acc, issue) => acc + (issue.files === true || (Array.isArray(issue.files) && issue.files.length) ? 1 : 0),
|
|
167
170
|
0
|
|
168
171
|
);
|
|
169
172
|
}
|
|
@@ -427,6 +427,9 @@ const MODULE_GLOBS = {
|
|
|
427
427
|
// scanner config above (task 1004146).
|
|
428
428
|
'.gitignore', '.env.local.example',
|
|
429
429
|
'package.json', 'package-lock.json',
|
|
430
|
+
// The dead-code scanner's config, moved out of package.json's "knip" key so it
|
|
431
|
+
// can generate modules/ entries from each module.json (task 1004875).
|
|
432
|
+
'knip.config.js',
|
|
430
433
|
'scripts/bongos/fitness.js', 'scripts/bongos/docs-entropy.js',
|
|
431
434
|
'scripts/bongos/redteam-patrol.js', 'scripts/bongos/audit-gate.js',
|
|
432
435
|
'scripts/bongos/fitness-ratchets.js', 'scripts/bongos/control-manifest.js',
|
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.21.
|
|
78
|
+
const CORE_VERSION = '1.21.180'; // 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');
|
|
@@ -737,8 +737,14 @@ test('this proof runs in a lane that actually executes', () => {
|
|
|
737
737
|
const listed = runners.some((f) => {
|
|
738
738
|
try { return fs.readFileSync(path.join(process.cwd(), 'tests', f), 'utf8').includes(self); } catch { return false; }
|
|
739
739
|
});
|
|
740
|
-
|
|
741
|
-
|
|
740
|
+
// `npm test` is scripts/bongos/run-unit-tests.js, which readdir()s EVERY
|
|
741
|
+
// tests/*.mjs into the unit or the integration lane — no allowlist. (This used to
|
|
742
|
+
// regex package.json for a tests/** glob, which only ever matched the knip
|
|
743
|
+
// config's entry list; task 1004875 moved that out and the check went red.)
|
|
744
|
+
let testScript = '';
|
|
745
|
+
try { testScript = (JSON.parse(fs.readFileSync(path.join(process.cwd(), 'package.json'), 'utf8')).scripts || {}).test || ''; } catch { /* no package.json */ }
|
|
746
|
+
const globbed = /scripts\/bongos\/run-unit-tests\.js/.test(testScript)
|
|
747
|
+
&& /readdirSync\(TESTS_DIR\)/.test(fs.readFileSync(path.join(process.cwd(), 'scripts/bongos/run-unit-tests.js'), 'utf8'));
|
|
742
748
|
assert.ok(listed || globbed,
|
|
743
749
|
`${self} is in no runner list and no glob — it would assert nothing forever (learning 1000218)`);
|
|
744
750
|
});
|
|
@@ -195,7 +195,7 @@ await test('the rollup activity clock is the SAME max(updated_at, shipped_at)',
|
|
|
195
195
|
|
|
196
196
|
await test('Creator and Age are sent to the server, on BOTH board feeds', () => {
|
|
197
197
|
assert.match(WORK_SRC, /function serverFilterQuery\(\)/);
|
|
198
|
-
assert.match(WORK_SRC, /tasks\/claimable\$\{serverFilterQuery\(\)
|
|
198
|
+
assert.match(WORK_SRC, /tasks\/claimable\?limit=\$\{CLAIMABLE_LIMIT\}\$\{serverFilterQuery\(\)/,
|
|
199
199
|
'the claimable feed narrows at the source, not in the browser');
|
|
200
200
|
assert.match(WORK_SRC, /tasks\?status=backlog&limit=\$\{BACKLOG_LIMIT\}\$\{q\}/,
|
|
201
201
|
'and so does the backlog read');
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// tests/knip_modules_scope.mjs — the dead-code scanner covers modules/ (task 1004875).
|
|
2
|
+
//
|
|
3
|
+
// Three layers:
|
|
4
|
+
// 1. the scorer (countKnipFindings) on synthetic knip JSON — hermetic;
|
|
5
|
+
// 2. the generated entry list (knip.config.js) against the real modules/ tree —
|
|
6
|
+
// every route and poller a manifest declares is an entry, by the loader's rule;
|
|
7
|
+
// 3. REAL knip on a throwaway fixture repo that uses the real config builder: a
|
|
8
|
+
// new unused file, and a new unused export, under modules/ each raise the score
|
|
9
|
+
// and the ratchet then fails. That layer needs knip in THIS checkout's
|
|
10
|
+
// node_modules (CI's `npm ci` provides it); without it, it skips and says why.
|
|
11
|
+
import assert from 'node:assert/strict';
|
|
12
|
+
import { test } from 'node:test';
|
|
13
|
+
import { createRequire } from 'node:module';
|
|
14
|
+
import { execFileSync } from 'node:child_process';
|
|
15
|
+
import fs from 'node:fs';
|
|
16
|
+
import os from 'node:os';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
import { fileURLToPath } from 'node:url';
|
|
19
|
+
|
|
20
|
+
const require = createRequire(import.meta.url);
|
|
21
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
22
|
+
const fr = require(path.join(ROOT, 'scripts/bongos/fitness-ratchets.js'));
|
|
23
|
+
const knipConfig = require(path.join(ROOT, 'knip.config.js'));
|
|
24
|
+
|
|
25
|
+
const issue = (file, extra) => ({ file, files: [], exports: [], types: [], dependencies: [], unlisted: [], ...extra });
|
|
26
|
+
|
|
27
|
+
test('scorer: an unused FILE counts (knip 6 reports it per issue, not top-level)', () => {
|
|
28
|
+
const json = { issues: [issue('modules/x/orphan.js', { files: [{ name: 'modules/x/orphan.js' }] })] };
|
|
29
|
+
assert.equal(fr.countKnipFindings(json, { wordIndex: new Map() }), 1);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('scorer: a modules/ export named by no other code file counts; one named elsewhere does not', () => {
|
|
33
|
+
const wordIndex = new Map([
|
|
34
|
+
['usedViaNamespace', true], // two+ files spell it
|
|
35
|
+
['onlyHere', 'modules/x/lib.js'], // only its own file
|
|
36
|
+
['spelledInConsumer', 'modules/y/consumer.js'], // one OTHER file
|
|
37
|
+
]);
|
|
38
|
+
const json = {
|
|
39
|
+
issues: [issue('modules/x/lib.js', {
|
|
40
|
+
exports: [{ name: 'usedViaNamespace' }, { name: 'onlyHere' }, { name: 'spelledInConsumer' }, { name: 'neverSpelled' }],
|
|
41
|
+
})],
|
|
42
|
+
};
|
|
43
|
+
assert.equal(fr.countKnipFindings(json, { wordIndex }), 2);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('scorer: src/ exports keep knip\'s own verdict (no name filter outside modules/)', () => {
|
|
47
|
+
const wordIndex = new Map([['usedViaNamespace', true]]);
|
|
48
|
+
const json = { issues: [issue('src/x.js', { exports: [{ name: 'usedViaNamespace' }] })] };
|
|
49
|
+
assert.equal(fr.countKnipFindings(json, { wordIndex }), 1);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('config: every manifest-declared route and poller is an entry, and the file exists', () => {
|
|
53
|
+
const entries = new Set(knipConfig.generatedModuleEntries(ROOT));
|
|
54
|
+
const modulesDir = path.join(ROOT, 'modules');
|
|
55
|
+
let checked = 0;
|
|
56
|
+
for (const key of fs.readdirSync(modulesDir)) {
|
|
57
|
+
const mf = path.join(modulesDir, key, 'module.json');
|
|
58
|
+
if (!fs.existsSync(mf)) continue;
|
|
59
|
+
const c = JSON.parse(fs.readFileSync(mf, 'utf8')).contributes || {};
|
|
60
|
+
for (const [dir, list] of [['routes', c.routes], ['pollers', c.pollers]]) {
|
|
61
|
+
for (const k of list || []) {
|
|
62
|
+
const rel = `modules/${key}/${dir}/${k}.js`;
|
|
63
|
+
assert.ok(entries.has(rel), `${rel} is declared in ${key}/module.json but is not a knip entry`);
|
|
64
|
+
assert.ok(fs.existsSync(path.join(ROOT, rel)), `${rel} is declared but missing`);
|
|
65
|
+
checked++;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
assert.ok(checked > 50, `expected the real tree's routes+pollers, saw ${checked}`);
|
|
70
|
+
assert.ok(knipConfig.project.includes('modules/**/*.{js,mjs}'), 'modules/ must be in knip "project"');
|
|
71
|
+
assert.ok(entries.has('modules/ui-design/kit/render.js'), 'a #! CLI under modules/ is an entry');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// ---------- real knip on a fixture ----------
|
|
75
|
+
|
|
76
|
+
const KNIP_BIN = path.join(ROOT, 'node_modules', 'knip', 'bin', 'knip.js');
|
|
77
|
+
const HAVE_KNIP = fs.existsSync(KNIP_BIN);
|
|
78
|
+
|
|
79
|
+
function write(root, rel, text) {
|
|
80
|
+
const abs = path.join(root, rel);
|
|
81
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
82
|
+
fs.writeFileSync(abs, text);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function makeFixture() {
|
|
86
|
+
// realpath: on macOS os.tmpdir() is a /var -> /private/var symlink, and knip then
|
|
87
|
+
// resolves a require to a path outside its own project set and calls the target an
|
|
88
|
+
// unused file.
|
|
89
|
+
const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'knip-modules-')));
|
|
90
|
+
write(dir, 'package.json', JSON.stringify({ name: 'knip-fixture', version: '1.0.0', private: true }));
|
|
91
|
+
// The fixture uses the REAL config builder, pointed at itself.
|
|
92
|
+
write(dir, 'knip.config.js',
|
|
93
|
+
`module.exports = require(${JSON.stringify(path.join(ROOT, 'knip.config.js'))}).buildConfig(__dirname);\n`);
|
|
94
|
+
write(dir, 'modules/demo/module.json', JSON.stringify({ key: 'demo', contributes: { routes: ['demo'] } }));
|
|
95
|
+
// The route requires the lib the namespace way — the shape knip is blind to.
|
|
96
|
+
write(dir, 'modules/demo/routes/demo.js',
|
|
97
|
+
"'use strict';\nconst lib = require('../lib');\nmodule.exports = function route() { return lib.hello(); };\n");
|
|
98
|
+
write(dir, 'modules/demo/lib.js',
|
|
99
|
+
"'use strict';\nfunction hello() { return 'hi'; }\nmodule.exports = { hello };\n");
|
|
100
|
+
return dir;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function score(dir) {
|
|
104
|
+
const raw = execFileSync(process.execPath, [KNIP_BIN, '--directory', dir, '--reporter', 'json', '--no-exit-code'], {
|
|
105
|
+
cwd: dir, encoding: 'utf8', maxBuffer: 16 * 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'],
|
|
106
|
+
});
|
|
107
|
+
return fr.countKnipFindings(JSON.parse(raw), { root: dir });
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const ratchetFails = (value, baseline) =>
|
|
111
|
+
fr.compareToBaselines({ knip_issue_count: value }, { knip_issue_count: baseline }).violations.length > 0;
|
|
112
|
+
|
|
113
|
+
test('real knip: a new unused file or export under modules/ fails the ratchet; a namespace-used export does not',
|
|
114
|
+
{ skip: HAVE_KNIP ? false : `no knip at ${KNIP_BIN} — run \`npm ci\` (CI always has it)` }, () => {
|
|
115
|
+
const dir = makeFixture();
|
|
116
|
+
try {
|
|
117
|
+
const baseline = score(dir);
|
|
118
|
+
assert.equal(baseline, 0, 'the clean fixture scores 0 — the route is a generated entry, lib.hello is namespace-used');
|
|
119
|
+
assert.equal(ratchetFails(baseline, baseline), false);
|
|
120
|
+
|
|
121
|
+
// A new export that the route uses through the namespace: knip alone flags it;
|
|
122
|
+
// the scorer must not, or every ordinary export under modules/ would red CI.
|
|
123
|
+
write(dir, 'modules/demo/lib.js',
|
|
124
|
+
"'use strict';\nfunction hello() { return 'hi'; }\nfunction greet() { return 'yo'; }\nmodule.exports = { hello, greet };\n");
|
|
125
|
+
write(dir, 'modules/demo/routes/demo.js',
|
|
126
|
+
"'use strict';\nconst lib = require('../lib');\nmodule.exports = function route() { return lib.hello() + lib.greet(); };\n");
|
|
127
|
+
assert.equal(score(dir), baseline, 'a namespace-used export is not a finding');
|
|
128
|
+
|
|
129
|
+
// A new unused EXPORT.
|
|
130
|
+
write(dir, 'modules/demo/lib.js',
|
|
131
|
+
"'use strict';\nfunction hello() { return 'hi'; }\nfunction greet() { return 'yo'; }\n"
|
|
132
|
+
+ "function zzDeadExport() { return 0; }\nmodule.exports = { hello, greet, zzDeadExport };\n");
|
|
133
|
+
const withExport = score(dir);
|
|
134
|
+
assert.equal(withExport, baseline + 1);
|
|
135
|
+
assert.equal(ratchetFails(withExport, baseline), true, 'an unused export under modules/ must fail the ratchet');
|
|
136
|
+
|
|
137
|
+
// A new unused FILE.
|
|
138
|
+
write(dir, 'modules/demo/orphan.js', "'use strict';\nmodule.exports = { nobodyCalls() { return 1; } };\n");
|
|
139
|
+
const withFile = score(dir);
|
|
140
|
+
assert.equal(withFile, withExport + 1);
|
|
141
|
+
assert.equal(ratchetFails(withFile, withExport), true, 'an unused file under modules/ must fail the ratchet');
|
|
142
|
+
} finally {
|
|
143
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
144
|
+
}
|
|
145
|
+
});
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
// tests/work_board_live_refresh.mjs — the Work board refreshes on the hall's live
|
|
2
|
+
// signal, not a 20-second timer, and asks for a bounded claimable list (task 1004877).
|
|
3
|
+
//
|
|
4
|
+
// Before: an idle, visible Work tab re-fetched the WHOLE claimable feed every 20 s
|
|
5
|
+
// (30 requests per 10 minutes) whether or not anything had changed. Now it listens
|
|
6
|
+
// on the same live channel the hall home uses (GET /api/bongos/live, an
|
|
7
|
+
// EventSource that says "something changed" and nothing else), re-fetches a
|
|
8
|
+
// second after a change (at most one read per 20 s while changes keep coming), and
|
|
9
|
+
// keeps a slow 2-minute timer as a backstop in case the live connection is down. Every claimable read carries ?limit= so the list it
|
|
10
|
+
// returns has a ceiling.
|
|
11
|
+
//
|
|
12
|
+
// work.js is BOOTED in a vm here (the harness shape tests/hall_mine_lens.mjs uses)
|
|
13
|
+
// against a fake clock and a fake EventSource, so the counts below are the real
|
|
14
|
+
// page's timers, not a reading of its source.
|
|
15
|
+
|
|
16
|
+
import { test } from 'node:test';
|
|
17
|
+
import assert from 'node:assert/strict';
|
|
18
|
+
import fs from 'node:fs';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
import vm from 'node:vm';
|
|
21
|
+
import { fileURLToPath } from 'node:url';
|
|
22
|
+
import { settle } from './helpers.mjs';
|
|
23
|
+
|
|
24
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
25
|
+
const HALL = path.join(ROOT, 'modules', 'hall-ui', 'public');
|
|
26
|
+
const read = (f) => fs.readFileSync(path.join(HALL, f), 'utf8');
|
|
27
|
+
|
|
28
|
+
const MIN = 60 * 1000;
|
|
29
|
+
|
|
30
|
+
// A virtual clock: setTimeout/setInterval queue here and only fire on advance(),
|
|
31
|
+
// so ten minutes of page life run in milliseconds.
|
|
32
|
+
function makeClock() {
|
|
33
|
+
let now = 1_000_000;
|
|
34
|
+
let seq = 0;
|
|
35
|
+
const timers = new Map();
|
|
36
|
+
const add = (fn, ms, every) => { seq += 1; timers.set(seq, { at: now + Math.max(0, Number(ms) || 0), fn, every }); return seq; };
|
|
37
|
+
return {
|
|
38
|
+
now: () => now,
|
|
39
|
+
setTimeout: (fn, ms) => add(fn, ms, 0),
|
|
40
|
+
setInterval: (fn, ms) => add(fn, ms, Math.max(1, Number(ms) || 1)),
|
|
41
|
+
clear: (id) => { timers.delete(id); },
|
|
42
|
+
async advance(ms) {
|
|
43
|
+
const end = now + ms;
|
|
44
|
+
for (;;) {
|
|
45
|
+
let next = null;
|
|
46
|
+
for (const [id, t] of timers) if (t.at <= end && (!next || t.at < next[1].at)) next = [id, t];
|
|
47
|
+
if (!next) break;
|
|
48
|
+
const [id, t] = next;
|
|
49
|
+
now = t.at;
|
|
50
|
+
if (t.every) t.at += t.every; else timers.delete(id);
|
|
51
|
+
t.fn();
|
|
52
|
+
await settle(); await settle();
|
|
53
|
+
}
|
|
54
|
+
now = end;
|
|
55
|
+
await settle();
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function bootWorkBoard() {
|
|
61
|
+
const clock = makeClock();
|
|
62
|
+
const els = new Map();
|
|
63
|
+
const makeEl = (id = '') => ({
|
|
64
|
+
id, dataset: {}, style: {}, textContent: '', hidden: false, disabled: false,
|
|
65
|
+
checked: false, value: '', title: '', _html: '',
|
|
66
|
+
classList: { add() {}, remove() {}, toggle() { return false; }, contains() { return false; } },
|
|
67
|
+
setAttribute() {}, getAttribute() { return null; },
|
|
68
|
+
listeners: {},
|
|
69
|
+
addEventListener(type, fn) { (this.listeners[type] ||= []).push(fn); },
|
|
70
|
+
querySelector() { return null; }, querySelectorAll() { return []; },
|
|
71
|
+
appendChild() {}, remove() {}, closest() { return null; },
|
|
72
|
+
set innerHTML(v) { this._html = String(v); }, get innerHTML() { return this._html; },
|
|
73
|
+
});
|
|
74
|
+
const el = (key) => { if (!els.has(key)) els.set(key, makeEl(key)); return els.get(key); };
|
|
75
|
+
const documentObj = {
|
|
76
|
+
getElementById: (id) => el(id),
|
|
77
|
+
querySelector: (sel) => el(String(sel).replace(/^#/, '')),
|
|
78
|
+
querySelectorAll: () => [],
|
|
79
|
+
createElement: () => makeEl(),
|
|
80
|
+
addEventListener(type, fn) { (this.listeners[type] ||= []).push(fn); },
|
|
81
|
+
listeners: {},
|
|
82
|
+
body: { dataset: {} }, documentElement: { dataset: {} },
|
|
83
|
+
hidden: false,
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const requests = [];
|
|
87
|
+
const client = {
|
|
88
|
+
request: async (method, url) => {
|
|
89
|
+
requests.push(url);
|
|
90
|
+
const ok = (data) => ({ status: 200, ok: true, data });
|
|
91
|
+
if (url.endsWith('/me')) return ok({ builder: { id: 7, github_login: 'percy', rank: 'metic' } });
|
|
92
|
+
if (url.endsWith('/goals')) return ok({ goals: [] });
|
|
93
|
+
if (url.includes('/tasks/claimable')) {
|
|
94
|
+
return ok({ claimable: [{ id: 501, title: 'a ready task', status: 'ready', version_id: 'V1' }], claimable_total: 1234, active_claims: [] });
|
|
95
|
+
}
|
|
96
|
+
return ok({});
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
// The fake live stream: one instance per `new EventSource(url)`.
|
|
101
|
+
const streams = [];
|
|
102
|
+
class FakeEventSource {
|
|
103
|
+
constructor(url) { this.url = url; this.readyState = 1; this.listeners = {}; streams.push(this); }
|
|
104
|
+
addEventListener(type, fn) { (this.listeners[type] ||= []).push(fn); }
|
|
105
|
+
emit(type) { (this.listeners[type] || []).forEach((fn) => fn({ data: '{}' })); }
|
|
106
|
+
close() { this.readyState = 2; }
|
|
107
|
+
}
|
|
108
|
+
FakeEventSource.CLOSED = 2;
|
|
109
|
+
|
|
110
|
+
const tabCounts = {};
|
|
111
|
+
const kit = {
|
|
112
|
+
makePager: (prefix) => ({ prefix, page: 1, perPage: 100 }),
|
|
113
|
+
paginate: (rows) => rows,
|
|
114
|
+
wirePager() {},
|
|
115
|
+
pagerBarHtml: () => '',
|
|
116
|
+
makeTabs: ({ tabs }) => ({ active: () => tabs[0].id, setCount(id, n) { tabCounts[id] = n; }, activate() {} }),
|
|
117
|
+
makeFilterBar: ({ search, primary = [], overflow = [] }) => {
|
|
118
|
+
[search, ...primary, ...overflow].filter(Boolean).forEach((c) => { el(c.id).value = c.value ?? c.default ?? ''; });
|
|
119
|
+
return { values: () => ({}), get: () => '', set() {} };
|
|
120
|
+
},
|
|
121
|
+
ageChipHtml: () => '',
|
|
122
|
+
};
|
|
123
|
+
const windowObj = {
|
|
124
|
+
__BRANDING__: { currency: { label: 'credits' } },
|
|
125
|
+
BongosClient: { createClient: () => client },
|
|
126
|
+
OTBKit: kit,
|
|
127
|
+
OTBTaskDetail: { href: (id) => `/task/${id}`, open() {} },
|
|
128
|
+
OTBTaskLib: {
|
|
129
|
+
priorityRank: () => 0, touchesConflict: () => false, conflictPeers: () => [], conflictChipHtml: () => '', rankTagHtml: () => '',
|
|
130
|
+
creditsPerMinute: () => 0, buildPromptForTask: () => '', friendlyBatchReason: () => '',
|
|
131
|
+
taskActivityMs: () => 0, STALE_CHOICES: [{ value: '', label: '— any —' }], isStale: () => false,
|
|
132
|
+
focusVersionDefault: () => null,
|
|
133
|
+
},
|
|
134
|
+
OTB: {
|
|
135
|
+
escapeHtml: (s) => String(s ?? ''), $: (sel) => documentObj.querySelector(sel),
|
|
136
|
+
fmtNum: (n) => String(n ?? 0), fmtDate: (d) => String(d ?? ''),
|
|
137
|
+
errCode: () => null, errDetails: () => ({}), toast() {}, categoryChip: () => '', fillKindFilter() {},
|
|
138
|
+
kindIsUnset: () => true, KIND_UNSET: 'unclassified', roleAxisLabel: () => 'Role',
|
|
139
|
+
roleOptions: () => [{ value: '', label: '— any —' }], roleIsUnset: () => true, roleLabel: () => '', ROLE_UNSET: 'unclassified',
|
|
140
|
+
},
|
|
141
|
+
location: { hostname: 'builders.example.com', pathname: '/work', search: '', hash: '', href: 'https://builders.example.com/work' },
|
|
142
|
+
localStorage: { getItem: () => null, setItem() {}, removeItem() {} },
|
|
143
|
+
confirm: () => true,
|
|
144
|
+
addEventListener() {},
|
|
145
|
+
};
|
|
146
|
+
class FakeDate extends Date {}
|
|
147
|
+
FakeDate.now = () => clock.now();
|
|
148
|
+
const sandbox = {
|
|
149
|
+
window: windowObj, document: documentObj, location: windowObj.location,
|
|
150
|
+
localStorage: windowObj.localStorage, console,
|
|
151
|
+
setTimeout: clock.setTimeout, clearTimeout: clock.clear, setInterval: clock.setInterval, clearInterval: clock.clear,
|
|
152
|
+
Date: FakeDate, URL, URLSearchParams, navigator: {}, history: { replaceState() {} },
|
|
153
|
+
EventSource: FakeEventSource,
|
|
154
|
+
};
|
|
155
|
+
sandbox.globalThis = sandbox;
|
|
156
|
+
vm.createContext(sandbox);
|
|
157
|
+
for (const f of ['mine-lens.js', 'work-summary.js', 'claim-action.js', 'bulk-toolbar.js']) vm.runInContext(read(f), sandbox, { filename: f });
|
|
158
|
+
windowObj.OTBMineLens = sandbox.OTBMineLens;
|
|
159
|
+
windowObj.OTBWorkSummary = sandbox.OTBWorkSummary;
|
|
160
|
+
vm.runInContext(read('work.js'), sandbox, { filename: 'work.js' });
|
|
161
|
+
(documentObj.listeners.DOMContentLoaded || []).forEach((fn) => fn());
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
clock, requests, streams, tabCounts,
|
|
165
|
+
claimableReads: () => requests.filter((u) => u.includes('/tasks/claimable')),
|
|
166
|
+
setHidden(h) { documentObj.hidden = h; (documentObj.listeners.visibilitychange || []).forEach((fn) => fn()); },
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const boot = async () => {
|
|
171
|
+
const b = bootWorkBoard();
|
|
172
|
+
for (let i = 0; i < 8; i += 1) await settle();
|
|
173
|
+
await b.clock.advance(0);
|
|
174
|
+
return b;
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
test('every claimable read asks for a bounded list', async () => {
|
|
178
|
+
const b = await boot();
|
|
179
|
+
await b.clock.advance(10 * MIN);
|
|
180
|
+
const reads = b.claimableReads();
|
|
181
|
+
assert.ok(reads.length >= 1);
|
|
182
|
+
for (const u of reads) assert.match(u, /[?&]limit=\d+/, `unbounded claimable read: ${u}`);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test('the Ready tab count is the server total, not the length of the bounded list', async () => {
|
|
186
|
+
const b = await boot();
|
|
187
|
+
assert.equal(b.tabCounts.ready, 1234);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test('an idle visible tab makes at most 5 claimable reads in 10 minutes (was 30)', async () => {
|
|
191
|
+
const b = await boot();
|
|
192
|
+
const atLoad = b.claimableReads().length;
|
|
193
|
+
await b.clock.advance(10 * MIN);
|
|
194
|
+
const idle = b.claimableReads().length - atLoad;
|
|
195
|
+
assert.ok(idle <= 5, `expected ≤ 5 background reads per 10 min, got ${idle}`);
|
|
196
|
+
assert.ok(idle >= 4, `the slow fallback timer must still run (got ${idle})`);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test('the page subscribes to the live channel', async () => {
|
|
200
|
+
const b = await boot();
|
|
201
|
+
assert.equal(b.streams.length, 1);
|
|
202
|
+
assert.equal(b.streams[0].url, '/api/bongos/live');
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test('a live nudge refreshes the claimable list within seconds', async () => {
|
|
206
|
+
const b = await boot();
|
|
207
|
+
const before = b.claimableReads().length;
|
|
208
|
+
b.streams[0].emit('nudge');
|
|
209
|
+
await b.clock.advance(3000);
|
|
210
|
+
assert.equal(b.claimableReads().length, before + 1);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test('a burst of nudges is coalesced: a busy minute costs at most 3 reads', async () => {
|
|
214
|
+
const b = await boot();
|
|
215
|
+
const before = b.claimableReads().length;
|
|
216
|
+
for (let s = 0; s < 60; s += 1) { b.streams[0].emit('nudge'); await b.clock.advance(1000); }
|
|
217
|
+
const n = b.claimableReads().length - before;
|
|
218
|
+
assert.ok(n >= 1 && n <= 3, `expected 1–3 reads for 60 nudges in a minute, got ${n}`);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test('a project changing every second never costs more than the old 20 s timer (30 per 10 min)', async () => {
|
|
222
|
+
const b = await boot();
|
|
223
|
+
const before = b.claimableReads().length;
|
|
224
|
+
for (let s = 0; s < 600; s += 1) { b.streams[0].emit('nudge'); await b.clock.advance(1000); }
|
|
225
|
+
const n = b.claimableReads().length - before;
|
|
226
|
+
assert.ok(n <= 30, `expected ≤ 30 reads in a busy 10 minutes, got ${n}`);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test('a hidden tab fetches nothing, on the timer or on a nudge', async () => {
|
|
230
|
+
const b = await boot();
|
|
231
|
+
b.setHidden(true);
|
|
232
|
+
const before = b.claimableReads().length;
|
|
233
|
+
b.streams[0].emit('nudge');
|
|
234
|
+
await b.clock.advance(10 * MIN);
|
|
235
|
+
assert.equal(b.claimableReads().length, before);
|
|
236
|
+
});
|