@bongos/core 1.21.179 → 1.21.181

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 CHANGED
@@ -2,22 +2,22 @@
2
2
  "artifact": "bongos-core",
3
3
  "manifest_schema": 1,
4
4
  "generator": "scripts/bongos/package-core.js",
5
- "core_version": "1.21.179",
6
- "core_contract": "1.21.179",
7
- "source_commit": "178a6fa539e3718e93bab31e4ced14262d1457a6",
5
+ "core_version": "1.21.181",
6
+ "core_contract": "1.21.181",
7
+ "source_commit": "677ee2b818cf829dddd5434301b158be3408fbd5",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-10-26T00:44:53.003Z",
9
+ "built_at": "2026-10-26T01:16:25.738Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 616,
13
13
  "agent_docs_stubbed": 29,
14
- "functional_verbatim": 3537,
14
+ "functional_verbatim": 3539,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 4183,
20
- "tree_sha256": "026997dfda1f061de2986ae1e869fbe88be80237fa31d02b7f1d2480dd9d51e4",
19
+ "file_count": 4185,
20
+ "tree_sha256": "e69b48d53a056c99114f3c5904e69828363e91f5f0df70b7b599e61e8e299d1d",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/ask-for-help/SKILL.md",
@@ -472,7 +472,7 @@
472
472
  {
473
473
  "path": "docs/adr/0017-code-simplification-and-repo-cleanliness.md",
474
474
  "mode": "0000644",
475
- "sha256": "6e57ab51a44f245f1691fb9f0de43d78aa05faa0465f12659c2eb89c088b5fb8"
475
+ "sha256": "d841dd656d9678981310f69cdc277fc259fde3dd37252a8ff4612f95e777de42"
476
476
  },
477
477
  {
478
478
  "path": "docs/adr/0018-three-rank-model-goes-live.md",
@@ -2927,7 +2927,7 @@
2927
2927
  {
2928
2928
  "path": "docs/file-map.md",
2929
2929
  "mode": "0000644",
2930
- "sha256": "ad8dd9b3c01736a49e3559a3dc0f5e37120ac1b4de4318a7bba07be3609aaf6b"
2930
+ "sha256": "4e0a9cf41330f62ec1d2bacdc07a7071b4081517e60f6446fe66a33e44175ce9"
2931
2931
  },
2932
2932
  {
2933
2933
  "path": "docs/handoff-template.md",
@@ -2937,7 +2937,7 @@
2937
2937
  {
2938
2938
  "path": "docs/module-api-changelog.md",
2939
2939
  "mode": "0000644",
2940
- "sha256": "5d9dd0fe4686c1dde4914b7b17f35d182ef0e931f4e4e3a682008df73364cc94"
2940
+ "sha256": "4835f21d9d6cf7eaa82821e1bb5eb4c79493267300ef852ad1ca1c05b4a41e65"
2941
2941
  },
2942
2942
  {
2943
2943
  "path": "docs/modules-contract.md",
@@ -4397,7 +4397,7 @@
4397
4397
  {
4398
4398
  "path": "modules/builder-settings/display-name.js",
4399
4399
  "mode": "0000644",
4400
- "sha256": "f959d06834da24a929e22f7159f8451a4e9578e4ffe32b20bfa9301bfd47fbed"
4400
+ "sha256": "9414b08857cf2d1eba6bf43127436f86f04ed5df707a7a04027c556a9ce91a39"
4401
4401
  },
4402
4402
  {
4403
4403
  "path": "modules/builder-settings/event-sound-prefs.js",
@@ -7052,7 +7052,7 @@
7052
7052
  {
7053
7053
  "path": "modules/ideas/born-of.js",
7054
7054
  "mode": "0000644",
7055
- "sha256": "c7458b5d65de75d0eb6bafdbb0b61b156d0f7d7e25c9c82229794494338a6808"
7055
+ "sha256": "bc7e3b1121d63fc0736e0a44894997710f872548cfd0ce66ae9903bb69910bc6"
7056
7056
  },
7057
7057
  {
7058
7058
  "path": "modules/ideas/completeness.js",
@@ -7932,7 +7932,7 @@
7932
7932
  {
7933
7933
  "path": "modules/lifecycle/task-classifier.js",
7934
7934
  "mode": "0000644",
7935
- "sha256": "95907661350fece131d9063b795a10139a2f8777a0a49f7f39c50e61a9568a1d"
7935
+ "sha256": "ec331ba544e199ec80c3cbc08bc4eb6bb9f6fba1e4aa70f9bd02c1402647f7dc"
7936
7936
  },
7937
7937
  {
7938
7938
  "path": "modules/lifecycle/task-list-limits.js",
@@ -10342,7 +10342,7 @@
10342
10342
  {
10343
10343
  "path": "modules/specialities/db.js",
10344
10344
  "mode": "0000644",
10345
- "sha256": "c3ef28d95b6d93b0d302c2705522550b701931deb932016ee2134b03eadff358"
10345
+ "sha256": "4badd0141e04154a73cf1e9bde4a1a794a756e0ea09f5b7d19c1c8dbcfa3fb0b"
10346
10346
  },
10347
10347
  {
10348
10348
  "path": "modules/specialities/migrations/specialities_001_specialities.sql",
@@ -10372,7 +10372,7 @@
10372
10372
  {
10373
10373
  "path": "modules/specialities/skills.js",
10374
10374
  "mode": "0000644",
10375
- "sha256": "be3948128b557774f65a02f5b12497979d4c38334552947b8c4ef9c47e8562a2"
10375
+ "sha256": "eb1663db43e9e7a1b83313c3c1619ea4dabe011edbf86910d55e95bd3e1247c4"
10376
10376
  },
10377
10377
  {
10378
10378
  "path": "modules/specialities/specialities.js",
@@ -10827,12 +10827,12 @@
10827
10827
  {
10828
10828
  "path": "package-lock.json",
10829
10829
  "mode": "0000644",
10830
- "sha256": "a310406cb88d9fa7053aa0002367fc0d02e00f0fa3a566353e7ec4d93700ee0f"
10830
+ "sha256": "4d3ecd3d1a0b7e6602da53e83e88c204ab82a45ca83ae3d24979746970f15ac9"
10831
10831
  },
10832
10832
  {
10833
10833
  "path": "package.json",
10834
10834
  "mode": "0000644",
10835
- "sha256": "78692b77c9c7abc874c9237014f3953b714f1b4d90b7102b323a48cff93c41ed"
10835
+ "sha256": "2a01fd05d918fc38ad18d1cc6305681129b6115a4eab33f7d1fa4d34b8da2e44"
10836
10836
  },
10837
10837
  {
10838
10838
  "path": "public-docs/index.html",
@@ -10852,7 +10852,7 @@
10852
10852
  {
10853
10853
  "path": "release-notes.json",
10854
10854
  "mode": "0000644",
10855
- "sha256": "9c2abac99e107bc56a08f65863cf2367ceeceb9c7a4823e41db7e9805c829e4c"
10855
+ "sha256": "2a6e6fa89b09fd51aa8fae09c92eec30b6d0823dcdf3eaeda63826e7269a46ea"
10856
10856
  },
10857
10857
  {
10858
10858
  "path": "scripts/bongos/CLAUDE.md",
@@ -11397,7 +11397,7 @@
11397
11397
  {
11398
11398
  "path": "scripts/bongos/fitness-ratchets.js",
11399
11399
  "mode": "0000644",
11400
- "sha256": "15c1d62a515cf0ee1b651f33233e22f66144c88a20680bcb6b90ca3b73ddb81f"
11400
+ "sha256": "29c8c99a1380a0df741338feef8cc0465e22b12c45ba7655584a6a24e48987ac"
11401
11401
  },
11402
11402
  {
11403
11403
  "path": "scripts/bongos/fitness.js",
@@ -11967,7 +11967,7 @@
11967
11967
  {
11968
11968
  "path": "scripts/bongos/pulse.js",
11969
11969
  "mode": "0000644",
11970
- "sha256": "c0ae5acf5576014c44c488e7f4c195cf69fbfab420311f4726424a8f9dec024e"
11970
+ "sha256": "908648ffc796e4eecda652333a67626a2ec206dd394038dab4bf981c5a5272d5"
11971
11971
  },
11972
11972
  {
11973
11973
  "path": "scripts/bongos/push-main.js",
@@ -12052,7 +12052,7 @@
12052
12052
  {
12053
12053
  "path": "scripts/bongos/repo-metrics.js",
12054
12054
  "mode": "0000644",
12055
- "sha256": "b68dc127a8ef9eacd4abd7f90eef05ff4bfe788b6770ae2b517759b399c50dd3"
12055
+ "sha256": "15dee800bf6fc5aca8a9d332854825b1d0081f2269ccdf69c2b5d3e1010009cb"
12056
12056
  },
12057
12057
  {
12058
12058
  "path": "scripts/bongos/retired-core-skills.json",
@@ -14422,7 +14422,7 @@
14422
14422
  {
14423
14423
  "path": "src/bongos/module-scope-map.js",
14424
14424
  "mode": "0000644",
14425
- "sha256": "93d9e5e91b4caf90bc2f1955be557fa7ba17105e5a8e3f1d94f9651ff4e1925c"
14425
+ "sha256": "b9dd189b2035885f8ccb205de20d45409380f16f1110de50447acd9ff866f52a"
14426
14426
  },
14427
14427
  {
14428
14428
  "path": "src/bongos/module-store.js",
@@ -14677,7 +14677,7 @@
14677
14677
  {
14678
14678
  "path": "src/module-api.js",
14679
14679
  "mode": "0000644",
14680
- "sha256": "622387442066f1d43a8f2514f706770b6c3fb6972e7bb60642da1a7b31117c4d"
14680
+ "sha256": "c48862d3a5d62afff6c50b8de356b68ec2ef4f22ced0c0caa36a99d5f135747a"
14681
14681
  },
14682
14682
  {
14683
14683
  "path": "src/module-loader/catalog.js",
@@ -14972,7 +14972,7 @@
14972
14972
  {
14973
14973
  "path": "tests/applicant_profile_boundary.mjs",
14974
14974
  "mode": "0000644",
14975
- "sha256": "f7c94079d3c85cc6513dba591111f81fa45115e25d32469d67e949a8e7abee3d"
14975
+ "sha256": "3bde3c999f6ef1b1cdcc3185450c7e8e03d819d621ee5684ebee94536299b0fd"
14976
14976
  },
14977
14977
  {
14978
14978
  "path": "tests/applicant_profile_read_bounds.mjs",
@@ -18009,6 +18009,11 @@
18009
18009
  "mode": "0000644",
18010
18010
  "sha256": "9448a42c0a10144d9e9cd96aa7ae894db6a2e30319be1ab5013c3b52d2ad7496"
18011
18011
  },
18012
+ {
18013
+ "path": "tests/knip_modules_scope.mjs",
18014
+ "mode": "0000644",
18015
+ "sha256": "6dadd33910d25806110296d916e5a0301c9e7bb4baa69103b71fdfea94ad3ac7"
18016
+ },
18012
18017
  {
18013
18018
  "path": "tests/knip_ratchet_ambient.mjs",
18014
18019
  "mode": "0000644",
@@ -19599,6 +19604,11 @@
19599
19604
  "mode": "0000644",
19600
19605
  "sha256": "5c964c162b87f2e73f6dc3343b1c4be886b7d3e0d29c01d51280dc790fc218e7"
19601
19606
  },
19607
+ {
19608
+ "path": "tests/repo_metrics_dead_exports.mjs",
19609
+ "mode": "0000644",
19610
+ "sha256": "f98a3d9635557d050f428c5ad3258ff1ef7652bf0f4fba87830cc09481b76e9b"
19611
+ },
19602
19612
  {
19603
19613
  "path": "tests/reward_gate.mjs",
19604
19614
  "mode": "0000644",
@@ -37,6 +37,7 @@ We will adopt **knip** (dev-only) as the one tool the substrate and janitor lean
37
37
  - **It targets the exact failure mode.** Unused exports and unreferenced files are the residue of AI over-writing — code written, then orphaned. Reliable dead-file/dead-export detection is genuinely hard to hand-roll (it requires building and walking the import graph); knip does it.
38
38
  - **Zero production cost.** knip is in `devDependencies`. The droplet deploy runs `npm ci --omit=dev`, so knip and its dependency tree never reach production. No runtime, no monthly cost — consistent with the $5K budget posture. (Its transitive dev-tree does carry npm-audit advisories; these are dev-only and are the concern of the dependency-scan task V3.R38, not prod.)
39
39
  - **Config lives in `package.json`, not a new file.** knip reads a `"knip"` key in `package.json`. Embedding it there — rather than adding a root `knip.json` — keeps the footprint minimal, which is the very ethos this ADR encodes. (The task description floated `knip.json`; the package.json key is the simpler in-scope choice and is what shipped.)
40
+ - **Amended 2026-10-25 (task 1004875):** the config moved to a root `knip.config.js`. Bringing `modules/` into scope needs entry points GENERATED from each `module.json` (routes, pollers, web surfaces) — the module loader finds those files by name, so a static JSON key would have to be a hand list that rots. The package.json key was removed so there is one config, not two.
40
41
 
41
42
  The committed config declares entry points (CLI scripts, tests, the browser `public/game/main.js`, the status/builders pages) and the project globs, and lists `phaser` + `colyseus.js` under `ignoreDependencies` because those are loaded as vendored browser globals via `<script>` tags (`public/index.html`), not statically imported — so knip would otherwise report them as unused. The config was trimmed to remove every pattern knip flagged as redundant; it now emits no configuration hints.
42
43
 
package/docs/file-map.md CHANGED
@@ -14,6 +14,7 @@
14
14
  ├── .impeccable/ ← impeccable skill artifacts: config.json (buildPath: code), surfaces/<route>.md (per-route briefs), mocks/world-assets/ (Higgsfield renders + prompt sidecars; the SOURCE of modules/public-landing/public/assets/world/), mocks/decision/ (A1 comps), design.json (DESIGN.md sidecar), review/ (finish captures)
15
15
  ├── .gitignore
16
16
  ├── package.json / package-lock.json
17
+ ├── knip.config.js ← dead-code scanner config (task 1004875): modules/ entries GENERATED from each module.json + #! CLIs; scored by scripts/bongos/fitness-ratchets.js --knip
17
18
  ├── CODEOWNERS ← owner review on the merge-gate surfaces (real since task 1002611, ADR 0159); inert until branch protection is on (scripts/bongos/gating.js) — keep aligned with scripts/bongos/hard-floor.js HARD_FLOOR_GLOBS
18
19
  ├── server.js ← game boot: Express + Colyseus + the shared internal surface (mountInternalSurfaces), binds 127.0.0.1:3000
19
20
  │
@@ -3195,5 +3195,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
3195
3195
  landed since 1.21.177 with no explicit bump. run 40249608959. (task 1002620)
3196
3196
  1.21.179 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
3197
3197
  landed since 1.21.178 with no explicit bump. run 40250732318. (task 1002620)
3198
+ 1.21.180 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
3199
+ landed since 1.21.179 with no explicit bump. run 40251859318. (task 1002620)
3200
+ 1.21.181 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
3201
+ landed since 1.21.180 with no explicit bump. run 40253320267. (task 1002620)
3198
3202
  ---------------------------------------------------------------------------
3199
3203
  ```
@@ -24,7 +24,6 @@
24
24
  // The longest display name we accept, in Unicode code points (so a multi-byte
25
25
  // character counts as one, matching how a human reads the limit).
26
26
  const MAX_LENGTH = 60;
27
- const MIN_LENGTH = 1; // after trimming — empty / whitespace-only is rejected
28
27
 
29
28
  // Count code points, not UTF-16 units, so an emoji or astral character is "1".
30
29
  function codePointLength(str) {
@@ -77,7 +76,6 @@ function validate(name) {
77
76
 
78
77
  module.exports = {
79
78
  MAX_LENGTH,
80
- MIN_LENGTH,
81
79
  codePointLength,
82
80
  hasControlChar,
83
81
  validate,
@@ -26,8 +26,6 @@
26
26
  const api = require('../../src/module-api');
27
27
  const { pool } = api;
28
28
 
29
- function emptyBorn() { return { goals: {}, criteria: {} }; }
30
-
31
29
  // readBornOf — the two reads, folded first-wins into plain objects keyed by the
32
30
  // born thing's id. deps.pool honours the test seam.
33
31
  async function readBornOf(deps = {}) {
@@ -52,4 +50,4 @@ async function readBornOf(deps = {}) {
52
50
  return { goals: firstWins(goals.rows, 'goal_id'), criteria: firstWins(criteria.rows, 'criterion_id') };
53
51
  }
54
52
 
55
- module.exports = { readBornOf, emptyBorn };
53
+ module.exports = { readBornOf };
@@ -71,7 +71,6 @@ function blockerVendorRule() {
71
71
  }
72
72
  return _blockerVendorRe;
73
73
  }
74
- const _resetVendorCacheForTests = () => { _blockerVendorRe = undefined; };
75
74
 
76
75
  function extractKindTag(description) {
77
76
  if (!description) return null;
@@ -549,7 +548,6 @@ module.exports = {
549
548
  classifyTaskKind,
550
549
  classifyTaskDiscipline,
551
550
  buildBlockerVendorRe,
552
- _resetVendorCacheForTests,
553
551
  VALID_KINDS,
554
552
  VALID_DISCIPLINES,
555
553
  WRITABLE_DISCIPLINES,
@@ -113,16 +113,6 @@ async function listDocuments(specialityId, executor) {
113
113
  return r.rows;
114
114
  }
115
115
 
116
- async function getDocument(specialityId, docId, executor) {
117
- const q = exec(executor);
118
- const r = await q(
119
- `SELECT id, speciality_id, title, body_md, source, content_sha, created_at
120
- FROM specialities_documents WHERE speciality_id = $1 AND id = $2`,
121
- [specialityId, docId],
122
- );
123
- return r.rows[0] || null;
124
- }
125
-
126
116
  async function deleteDocument(specialityId, docId, executor) {
127
117
  const q = exec(executor);
128
118
  const r = await q(
@@ -286,7 +276,7 @@ async function listOffered({ builderId, projectIds = [], discipline = null }, ex
286
276
  module.exports = {
287
277
  UPDATABLE, taskDiscipline, setOffered, listOffered,
288
278
  createSpeciality, getSpeciality, listVisibleTo, updateSpeciality,
289
- addDocument, listDocuments, getDocument, deleteDocument,
279
+ addDocument, listDocuments, deleteDocument,
290
280
  linkLearning, countTraining,
291
281
  adopt, abandon, deactivateCraft, activeForCraft, listAdoptions, setEnabledSkills,
292
282
  };
@@ -70,12 +70,6 @@ function readFrontmatter(skillMd) {
70
70
  try { return parseFrontmatter(fs.readFileSync(skillMd, 'utf8')); } catch { return {}; }
71
71
  }
72
72
 
73
- // The description a SKILL.md gives, for the adoption walkthrough (task 1004073).
74
- function readDescription(skillMd) {
75
- const d = readFrontmatter(skillMd).description;
76
- return typeof d === 'string' ? d : '';
77
- }
78
-
79
73
  // The optional PEOPLE-FACING fields (task 1004073). `description` is written to
80
74
  // be MATCHED by a model, so a skill's author may also say, for the person
81
75
  // deciding whether to turn it on: what it does (`plain`), when to reach for it
@@ -193,4 +187,4 @@ function installedSkillsCached(opts, now = Date.now()) {
193
187
  }
194
188
  function _resetCache() { cached = null; }
195
189
 
196
- module.exports = { SKILL_NAME_RE, installedSkills, installedSkillsCached, readDescription, parseFrontmatter, explainSkill, _resetCache };
190
+ module.exports = { SKILL_NAME_RE, installedSkills, installedSkillsCached, parseFrontmatter, explainSkill, _resetCache };
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.21.179",
3
+ "version": "1.21.181",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.21.179",
9
+ "version": "1.21.181",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.21.179",
3
+ "version": "1.21.181",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
@@ -10350,5 +10350,17 @@
10350
10350
  "id": "1004877",
10351
10351
  "text": "The Work page no longer re-downloads the whole task list every 20 seconds: it updates within about a second when something actually changes, and an idle tab now asks 5 times in 10 minutes instead of 30."
10352
10352
  }
10353
+ ],
10354
+ "1.21.180": [
10355
+ {
10356
+ "id": "1004875",
10357
+ "text": "The unused-code check now covers modules/, the largest part of the codebase, and fails the build when someone adds a dead file or an unused export there. Five dead exports were removed along the way."
10358
+ }
10359
+ ],
10360
+ "1.21.181": [
10361
+ {
10362
+ "id": "1004892",
10363
+ "text": "The repo health dashboard's dead-code number now counts the same way the CI check does, so it no longer jumps by about 1,800 false alarms from modules/."
10364
+ }
10353
10365
  ]
10354
10366
  }
@@ -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
- const j = JSON.parse(raw);
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,
@@ -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(path.join(REPO_ROOT, 'src/bongos/pool'));
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
- const { sweepStaleClaims } = require(path.join(REPO_ROOT, 'modules/sessions/pulse'));
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
 
@@ -37,6 +37,7 @@ const { execSync } = require('node:child_process');
37
37
  const fs = require('node:fs');
38
38
  const path = require('node:path');
39
39
  const { hasFlag } = require('./cli-lib');
40
+ const { countKnipFindings } = require('./fitness-ratchets');
40
41
 
41
42
  const REPO_ROOT = path.resolve(__dirname, '..', '..');
42
43
  const TOP_N_LARGEST = 15;
@@ -121,7 +122,9 @@ function scanDeadCode() {
121
122
  configured =
122
123
  !!pkg.knip ||
123
124
  fs.existsSync(path.join(REPO_ROOT, 'knip.json')) ||
124
- fs.existsSync(path.join(REPO_ROOT, 'knip.jsonc'));
125
+ fs.existsSync(path.join(REPO_ROOT, 'knip.jsonc')) ||
126
+ // The config moved to a JS file so it can generate modules/ entries (task 1004875).
127
+ fs.existsSync(path.join(REPO_ROOT, 'knip.config.js'));
125
128
  } catch (_) {
126
129
  configured = false;
127
130
  }
@@ -149,35 +152,44 @@ function scanDeadCode() {
149
152
  return { dead_exports: null, dead_files: null, knip_status: 'unparseable' };
150
153
  }
151
154
 
152
- // knip v6 JSON shape: { issues: [{ file, files (bool: whole file unused),
153
- // exports:[...], types:[...], ... }] }. A top-level `files` array exists in
154
- // some reporter modes; handle both. dead_exports sums unused exports+types
155
- // across issues; dead_files counts issues whose whole file is unused.
156
- let deadFiles = null;
155
+ const scored = scoreKnipReport(report);
156
+ if (!scored) return { dead_exports: null, dead_files: null, knip_status: 'unexpected_shape' };
157
+ return { ...scored, knip_status: 'ok' };
158
+ }
159
+
160
+ // knip JSON -> { dead_exports, dead_files }, or null on a shape we cannot read.
161
+ //
162
+ // dead_exports goes through the CI ratchet's own scorer, countKnipFindings, fed only
163
+ // the exports/types findings (task 1004892). Counting knip's raw list instead put
164
+ // ~1,800 exports under modules/ on the dashboard that ARE used: knip does not credit
165
+ // a CommonJS namespace use (`lib.x`), and modules/ is written that way. The scorer's
166
+ // name filter drops those, so this number and the ratchet's agree. `opts` passes
167
+ // through to the scorer (a test injects `wordIndex`).
168
+ //
169
+ // COST: the scorer builds its word index once per call (codeWordIndex walks the
170
+ // repo's .js/.mjs/.cjs/.html/.sh text) — measured ~2.3 s on this repo, beside a knip
171
+ // run that already takes far longer. It is deliberately NOT fed from
172
+ // gatherFileSizes()'s reads: that list is git-tracked .js/.mjs only, while the
173
+ // ratchet's index also reads .cjs/.html/.sh and skips docs/ — a different file set
174
+ // gives a different count, and matching CI's number is the whole point.
175
+ function scoreKnipReport(report, opts = {}) {
176
+ if (!report || typeof report !== 'object') return null;
177
+ const hasIssues = Array.isArray(report.issues);
178
+ if (!hasIssues && !Array.isArray(report.files)) return null;
157
179
  let deadExports = null;
158
- try {
159
- if (Array.isArray(report.issues)) {
160
- deadExports = report.issues.reduce((acc, issue) => {
161
- const ex = Array.isArray(issue.exports) ? issue.exports.length : 0;
162
- const ty = Array.isArray(issue.types) ? issue.types.length : 0;
163
- return acc + ex + ty;
164
- }, 0);
165
- deadFiles = report.issues.reduce(
166
- (acc, issue) => acc + (issue.files === true ? 1 : 0),
167
- 0
168
- );
169
- }
170
- // Some reporter modes additionally surface a flat unused-file list.
171
- if (Array.isArray(report.files)) {
172
- deadFiles = (deadFiles ?? 0) + report.files.length;
173
- }
174
- if (deadExports == null && deadFiles == null) {
175
- return { dead_exports: null, dead_files: null, knip_status: 'unexpected_shape' };
176
- }
177
- } catch (_) {
178
- return { dead_exports: null, dead_files: null, knip_status: 'unexpected_shape' };
180
+ let deadFiles = null;
181
+ if (hasIssues) {
182
+ const exportsOnly = report.issues.map((issue) => ({ file: issue.file, exports: issue.exports, types: issue.types }));
183
+ deadExports = countKnipFindings({ issues: exportsOnly }, opts);
184
+ deadFiles = report.issues.reduce(
185
+ // knip 6 gives an ARRAY here ([{ name }]), not a bool (task 1004875).
186
+ (acc, issue) => acc + (issue.files === true || (Array.isArray(issue.files) && issue.files.length) ? 1 : 0),
187
+ 0
188
+ );
179
189
  }
180
- return { dead_exports: deadExports, dead_files: deadFiles, knip_status: 'ok' };
190
+ // Some reporter modes additionally surface a flat unused-file list.
191
+ if (Array.isArray(report.files)) deadFiles = (deadFiles ?? 0) + report.files.length;
192
+ return { dead_exports: deadExports, dead_files: deadFiles };
181
193
  }
182
194
 
183
195
  // ---------- branch hygiene ----------
@@ -284,7 +296,7 @@ async function upsertSnapshot(pool, snap) {
284
296
 
285
297
  // ---------- main ----------
286
298
 
287
- (async () => {
299
+ async function main() {
288
300
  const args = process.argv.slice(2);
289
301
  const asJson = hasFlag('--json', args);
290
302
  const noWrite = hasFlag('--no-write', args);
@@ -357,7 +369,15 @@ async function upsertSnapshot(pool, snap) {
357
369
  }
358
370
 
359
371
  process.exit(0);
360
- })().catch((err) => {
361
- console.error('fatal:', err);
362
- process.exit(1);
363
- });
372
+ }
373
+
374
+ module.exports = { scoreKnipReport };
375
+
376
+ // CLI only when run directly, so a test can require this file without it
377
+ // scanning the repo or writing to the DB (task 1004892).
378
+ if (require.main === module) {
379
+ main().catch((err) => {
380
+ console.error('fatal:', err);
381
+ process.exit(1);
382
+ });
383
+ }
@@ -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.179'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.21.181'; // 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
- const globbed = fs.existsSync(path.join(process.cwd(), 'package.json'))
741
- && /tests\/\*|tests\/\*\*|\*\.mjs/.test(fs.readFileSync(path.join(process.cwd(), 'package.json'), 'utf8'));
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
  });
@@ -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,57 @@
1
+ // tests/repo_metrics_dead_exports.mjs — repo-metrics scores dead exports the way the
2
+ // CI knip ratchet does (task 1004892).
3
+ //
4
+ // Once knip scanned modules/ (task 1004875), repo-metrics' raw export count jumped by
5
+ // ~1,800: knip does not credit a CommonJS namespace use (`lib.x`), and modules/ is
6
+ // written that way. The ratchet's scorer (countKnipFindings) filters those by name;
7
+ // repo-metrics must use the same scorer, so the dashboard number matches CI's.
8
+ // Hermetic: synthetic knip JSON and an injected word index, no knip run.
9
+ import assert from 'node:assert/strict';
10
+ import { test } from 'node:test';
11
+ import { createRequire } from 'node:module';
12
+ import path from 'node:path';
13
+ import { fileURLToPath } from 'node:url';
14
+
15
+ const require = createRequire(import.meta.url);
16
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
17
+ const rm = require(path.join(ROOT, 'scripts/bongos/repo-metrics.js'));
18
+ const fr = require(path.join(ROOT, 'scripts/bongos/fitness-ratchets.js'));
19
+
20
+ const issue = (file, extra) => ({ file, files: [], exports: [], types: [], dependencies: [], unlisted: [], ...extra });
21
+
22
+ test('a modules/ export used through a namespace elsewhere is not a dead export', () => {
23
+ const wordIndex = new Map([
24
+ ['usedViaNamespace', true], // spelled in 2+ files
25
+ ['onlyHere', 'modules/x/lib.js'], // spelled only in its own file
26
+ ]);
27
+ const report = {
28
+ issues: [
29
+ issue('modules/x/lib.js', {
30
+ exports: [{ name: 'usedViaNamespace' }, { name: 'onlyHere' }],
31
+ types: [{ name: 'neverSpelled' }],
32
+ }),
33
+ issue('src/y.js', { exports: [{ name: 'usedViaNamespace' }] }), // outside modules/: knip's verdict stands
34
+ ],
35
+ };
36
+ const scored = rm.scoreKnipReport(report, { wordIndex });
37
+ assert.equal(scored.dead_exports, 3); // onlyHere + neverSpelled + the src/ one
38
+ // ...and that is exactly the ratchet scorer's count for the same export/type findings.
39
+ assert.equal(scored.dead_exports, fr.countKnipFindings(report, { wordIndex }));
40
+ });
41
+
42
+ test('non-export findings (deps, unlisted) do not leak into dead_exports; dead files still count', () => {
43
+ const report = {
44
+ issues: [
45
+ issue('src/a.js', { files: [{ name: 'src/a.js' }], dependencies: [{ name: 'left-pad' }], exports: [{ name: 'x' }] }),
46
+ issue('src/b.js', { unlisted: [{ name: 'chalk' }] }),
47
+ ],
48
+ };
49
+ const scored = rm.scoreKnipReport(report, { wordIndex: new Map() });
50
+ assert.equal(scored.dead_exports, 1);
51
+ assert.equal(scored.dead_files, 1);
52
+ });
53
+
54
+ test('a report with no issues list is an unexpected shape (null), not zero', () => {
55
+ assert.equal(rm.scoreKnipReport({}, { wordIndex: new Map() }), null);
56
+ assert.equal(rm.scoreKnipReport(null, { wordIndex: new Map() }), null);
57
+ });