@bongos/core 1.19.802 → 1.19.804

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/gds/package-core.js",
5
- "core_version": "1.19.802",
6
- "core_contract": "1.19.802",
7
- "source_commit": "45efc6ff51adfc6e4910c9a985eea7d6d03a55b5",
5
+ "core_version": "1.19.804",
6
+ "core_contract": "1.19.804",
7
+ "source_commit": "992839692e5a396ca7655a00df02413a3300b72a",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-17T02:33:54.490Z",
9
+ "built_at": "2026-09-17T03:35:15.794Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 487,
13
13
  "agent_docs_stubbed": 25,
14
- "functional_verbatim": 2188,
14
+ "functional_verbatim": 2191,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 2700,
20
- "tree_sha256": "4ee6d59590012d183002008d4e4cc2ebacb310dc098269f77c621e2d07a90df5",
19
+ "file_count": 2703,
20
+ "tree_sha256": "773756af73632e2824a78bf24ff19e17d3c1e2c077d511030de84bda409ed684",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/ask-for-help/SKILL.md",
@@ -2522,7 +2522,7 @@
2522
2522
  {
2523
2523
  "path": "docs/file-map.md",
2524
2524
  "mode": "0000644",
2525
- "sha256": "44af03bcfe197664d948053f26ed4af87c7b6fbe1a2a341d3ba3b02439357445"
2525
+ "sha256": "5560da3dad24d04ba192d4cbcab9f7e669ebd6ead35bde8d92a222a763289a36"
2526
2526
  },
2527
2527
  {
2528
2528
  "path": "docs/handoff-template.md",
@@ -2532,7 +2532,7 @@
2532
2532
  {
2533
2533
  "path": "docs/module-api-changelog.md",
2534
2534
  "mode": "0000644",
2535
- "sha256": "6d176113a1a846ac31afbcab3dfcf8125ce4b90ce2f8308dd1d00b16da96490c"
2535
+ "sha256": "b1b8571ef8f537feeffbca2a68ee1bb71b5d127bbfcfe1a45b3a3ac0d807e717"
2536
2536
  },
2537
2537
  {
2538
2538
  "path": "docs/modules-contract.md",
@@ -5052,12 +5052,12 @@
5052
5052
  {
5053
5053
  "path": "modules/hall-ui/public/people.html",
5054
5054
  "mode": "0000644",
5055
- "sha256": "5e5c99eab08f89feb05ab4db5015d6db3a78324777ffad245aa03c91a633032e"
5055
+ "sha256": "ee7118a1d61518750d4ec105d9965a92784ad1564f7e6b75496d412b9d54be85"
5056
5056
  },
5057
5057
  {
5058
5058
  "path": "modules/hall-ui/public/people.js",
5059
5059
  "mode": "0000644",
5060
- "sha256": "145f767f473b11c222bde97e5de519c9eb0a9be0ddb2f75969d35dff086b3e01"
5060
+ "sha256": "8834c3f6ec31d669dafc268ffbc12c9375e9ca4a8b00c45b164aa2d103af8db9"
5061
5061
  },
5062
5062
  {
5063
5063
  "path": "modules/hall-ui/public/people.states.json",
@@ -7712,12 +7712,12 @@
7712
7712
  {
7713
7713
  "path": "package-lock.json",
7714
7714
  "mode": "0000644",
7715
- "sha256": "34cfc2b2ac517fd72eedb774df0e1f91e182f265bd29109af0a51a172fef6ca9"
7715
+ "sha256": "95dd4df126fd7a7617f5a1bc84c6f0ad6403d59edb377102978beab0180f0dca"
7716
7716
  },
7717
7717
  {
7718
7718
  "path": "package.json",
7719
7719
  "mode": "0000644",
7720
- "sha256": "ecd677de73f7be790d92bea1d0fc1d999d9ebfc43d6098810ce255eb9cb1d395"
7720
+ "sha256": "8cfddd30203037f279a7c1d4e5205bfcda5b464b57022c5af3db7da4ac930e70"
7721
7721
  },
7722
7722
  {
7723
7723
  "path": "public-docs/index.html",
@@ -7834,6 +7834,11 @@
7834
7834
  "mode": "0000644",
7835
7835
  "sha256": "36dd5165b146e52b4dfc79eccda080f57e4eb72e67f2e691d347edd09c6d617d"
7836
7836
  },
7837
+ {
7838
+ "path": "scripts/gds/artifact-staleness.js",
7839
+ "mode": "0000644",
7840
+ "sha256": "af1ee2128e0009edb52cce9a416a8805ed954f78b763f1825d56cc476e179daa"
7841
+ },
7837
7842
  {
7838
7843
  "path": "scripts/gds/audit-authorship.sh",
7839
7844
  "mode": "0000644",
@@ -9602,7 +9607,7 @@
9602
9607
  {
9603
9608
  "path": "src/module-api.js",
9604
9609
  "mode": "0000644",
9605
- "sha256": "fdcbeb75b3f63bb495547ba048fdce05ff4956c1e1fce9a79376dcfc78109734"
9610
+ "sha256": "8778f98197e12eaa8fc397413c13584bc079d72bedb8737ba4a7db15109bb497"
9606
9611
  },
9607
9612
  {
9608
9613
  "path": "src/module-loader/catalog.js",
@@ -9839,6 +9844,11 @@
9839
9844
  "mode": "0000644",
9840
9845
  "sha256": "d9e4b5363224b1aaf019c2edaacb47028ac1521896f901184e805a3923fc070a"
9841
9846
  },
9847
+ {
9848
+ "path": "tests/artifact_staleness.mjs",
9849
+ "mode": "0000644",
9850
+ "sha256": "8eaeb56d805615a65f30a62e32dd4d180f1b61d7ac084725bc9d9bde8a1d67d5"
9851
+ },
9842
9852
  {
9843
9853
  "path": "tests/artist_gate.mjs",
9844
9854
  "mode": "0000644",
@@ -12119,6 +12129,11 @@
12119
12129
  "mode": "0000644",
12120
12130
  "sha256": "6c623a5abe9aadf4821ad8ca291fb5d7805a7a5bc874bfc3cd7e51e83de289d2"
12121
12131
  },
12132
+ {
12133
+ "path": "tests/people_directory_clip.mjs",
12134
+ "mode": "0000644",
12135
+ "sha256": "a7d4e29162194a67e2f1b948d0522f1ecdff11218d49fbd2b9d7eb0be1cc624c"
12136
+ },
12122
12137
  {
12123
12138
  "path": "tests/permission_path.mjs",
12124
12139
  "mode": "0000644",
@@ -12207,7 +12222,7 @@
12207
12222
  {
12208
12223
  "path": "tests/profile_route.mjs",
12209
12224
  "mode": "0000644",
12210
- "sha256": "d70924dd2ddfc4e05c4b52d9763d76b0a68cf0ed16db6ffd1b07a1834bef0890"
12225
+ "sha256": "ac7f07f4226ed410012ee2650e1870c109d0f3eb8b9142208d8a339ebff27e6c"
12211
12226
  },
12212
12227
  {
12213
12228
  "path": "tests/profile_ui_cross_project.mjs",
package/docs/file-map.md CHANGED
@@ -443,6 +443,7 @@ tests/
443
443
  ```
444
444
  .claude/scheduled-tasks/
445
445
  ├── artifact-audit/SKILL.md ← weekly artifact audit (task 1003200): every task at shipped/confirmed/completed that DECLARES a code artifact is checked against main's commit messages AND the content of its tracked files — both, because a fix can land under another branch and be cited only in a comment (task 1003212). Anything unproven is filed as a blocker, deduped on the finding set. Scoped to ships on/after the ship-assurance gate (2026-08-19, ADR 0184), before which a DB-only ship recorded no declaration and silence proves nothing. FIRED BY .github/workflows/artifact-audit.yml, Mondays 06:23 UTC — `cadence: weekly` here and that cron must agree or fitness Check 25 (scripts/gds/routine-schedule.js) fails the gate (task 1003209, ADR 0188)
446
+ ├── artifact-staleness/SKILL.md ← freshness backstop for the gitignored deploy-generated navigation docs (task 1001930, ADR 0110 pt.5): docs/repo-map.md and docs/session-log-index.md are untracked, so no PR gate can see them and a broken deploy regen step leaves a stale or missing map with only a journald line as the alarm. Asks each artifact's own generator (--check-whole) whether it still matches the served commit, reports absent and stale separately, and posts findings to Discord #ship-news itself. INSTANCE-SIDE ONLY — a fresh CI clone legitimately lacks these files, so it declares no cadence and no workflow fires it; config/scheduled-routines.json records the intended daily cadence.
446
447
  ├── bfg/SKILL.md ← nightly BFG pass (MAS Phase 6, ADR 0026) — doc/memory hygiene + DB counter-vs-ledger drift (Part A), per-builder interaction scoring into transferable-practice suggestions (Part B), and the audited cross-builder memory write (Part C, OFF unless BFG_WRITE_ENABLED) (restored in task 1003365; unscheduled in core — only redteam-weekly has a firing workflow; config/scheduled-routines.json records the intended cadence)
447
448
  ├── bfg-session-eval/SKILL.md ← nightly session-inefficiency evaluator (MAS Phase 6D, ADR 0027) — mines the session corpus for wasteful sessions and files the fixes as idea_inbox rows plus a docs/audits report (restored in task 1003365; unscheduled in core — only redteam-weekly has a firing workflow; config/scheduled-routines.json records the intended cadence)
448
449
  ├── box-dormant-reclaim/SKILL.md ← daily control-plane sweep that fully de-provisions dev boxes parked longer than BOX_DORMANT_DAYS, destroying the leftover snapshot and pinging the builder (ADR 0031 §5(a)); a no-op on an instance with no managed boxes (restored in task 1003365; unscheduled in core — only redteam-weekly has a firing workflow; config/scheduled-routines.json records the intended cadence)
@@ -2078,5 +2078,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
2078
2078
  landed since 1.19.800 with no explicit bump. run 35173442594. (task 1002620)
2079
2079
  1.19.802 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2080
2080
  landed since 1.19.801 with no explicit bump. run 35174865190. (task 1002620)
2081
+ 1.19.803 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2082
+ landed since 1.19.802 with no explicit bump. run 35175617185. (task 1002620)
2083
+ 1.19.804 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2084
+ landed since 1.19.803 with no explicit bump. run 35178689663. (task 1002620)
2081
2085
  ---------------------------------------------------------------------------
2082
2086
  ```
@@ -39,6 +39,10 @@
39
39
  <div id="people-body">
40
40
  <div class="profile__placeholder">Loading the directory…</div>
41
41
  </div>
42
+ <!-- task 1003110: the clipped-directory footnote. Hidden unless the
43
+ response came back FULL, which is the only truncation signal the
44
+ rollup offers; people.js writes it. -->
45
+ <p class="ledger__note" id="people-foot" hidden></p>
42
46
  </section>
43
47
  </main>
44
48
 
@@ -25,6 +25,7 @@
25
25
  var body = document.getElementById('people-body');
26
26
  var sub = document.getElementById('people-sub');
27
27
  var countPill = document.getElementById('people-count');
28
+ var foot = document.getElementById('people-foot');
28
29
 
29
30
  function fmtRecency(iso) {
30
31
  if (!iso) return '—';
@@ -73,6 +74,23 @@
73
74
  }).join('') + '</span>';
74
75
  }
75
76
 
77
+ // THE DIRECTORY ASKS FOR THE ENDPOINT MAXIMUM, so a full response is the only
78
+ // truncation signal that exists. `GET /scouting` clamps at parsePagination
79
+ // maxLimit: 500, takes no offset, and `listScoutingRollup` returns rows with no
80
+ // count of the whole set — so `length === DIRECTORY_LIMIT` means "at least this
81
+ // many", never "exactly this many", and there is nothing else to read it from.
82
+ // ONE constant feeds the request AND every sentence written about it, so the
83
+ // number a reader is shown can never drift from the number that was asked for.
84
+ //
85
+ // This is part 1 of task 1003110 — the honest half, which removes the
86
+ // silent-lie property whatever the real count turns out to be. Part 2 is
87
+ // server-offset paging, and it is deliberately NOT here: the kit has carried
88
+ // the mode since task 1003281 (`makeLedger({ serverPager, onPage })`), but the
89
+ // ROUTE cannot serve it — no offset parameter, no total, a hard 500 ceiling —
90
+ // so paging this page is a server change first. Its trigger is a directory
91
+ // approaching ~400; a read on 2026-09-16 returned far fewer.
92
+ var DIRECTORY_LIMIT = 500;
93
+
76
94
  var all = [];
77
95
  var query = '';
78
96
  var craft = '';
@@ -143,10 +161,31 @@
143
161
  + 'and none are listed without it. Their work still shows on each project’s own ledger.');
144
162
  return;
145
163
  }
146
- if (countPill) { countPill.textContent = String(all.length); countPill.hidden = false; }
164
+ // A FULL PAGE IS A CLIPPED PAGE. Every sentence below has to hedge when this
165
+ // is true, because each of them otherwise states a total the page cannot know.
166
+ var clipped = all.length >= DIRECTORY_LIMIT;
167
+ if (countPill) {
168
+ countPill.textContent = clipped ? fmtInt(DIRECTORY_LIMIT) + '+' : String(all.length);
169
+ countPill.hidden = false;
170
+ }
147
171
  if (sub) {
148
- sub.textContent = fmtInt(all.length) + ' builder' + (all.length === 1 ? '' : 's') + ' listed across hosted projects, ranked by track record. '
149
- + 'Descriptive only — cross-project numbers never carry authority here.';
172
+ sub.textContent = clipped
173
+ ? 'Showing the first ' + fmtInt(DIRECTORY_LIMIT) + ' builders across hosted projects, ranked by track record — there are more than this page can load. '
174
+ + 'Descriptive only — cross-project numbers never carry authority here.'
175
+ : fmtInt(all.length) + ' builder' + (all.length === 1 ? '' : 's') + ' listed across hosted projects, ranked by track record. '
176
+ + 'Descriptive only — cross-project numbers never carry authority here.';
177
+ }
178
+ // THE LENSES LIE HARDER THAN THE COUNT DOES, and that is worth its own
179
+ // sentence. Search, sort and the craft filter all re-order the set this page
180
+ // already loaded (see SORTS above) — so on a clipped directory "most karma"
181
+ // is the most karma AMONG THE TOP 500 BY TRACK RECORD, and a builder outside
182
+ // that 500 cannot be found by any of them. A reader who is told only that the
183
+ // list is long would still trust the search.
184
+ if (foot) {
185
+ foot.textContent = clipped
186
+ ? 'The directory is clipped at the first ' + fmtInt(DIRECTORY_LIMIT) + ' by track record. Search and the sort lenses only re-order what this page loaded, so a builder outside that ' + fmtInt(DIRECTORY_LIMIT) + ' will not appear in them.'
187
+ : '';
188
+ foot.hidden = !clipped;
150
189
  }
151
190
  var crafts = [];
152
191
  all.forEach(function (b) {
@@ -194,7 +233,7 @@
194
233
  body.textContent = msg;
195
234
  }
196
235
 
197
- fetch('/api/gds/scouting?limit=500', { headers: { Accept: 'application/json' } })
236
+ fetch('/api/gds/scouting?limit=' + DIRECTORY_LIMIT, { headers: { Accept: 'application/json' } })
198
237
  .then(function (r) {
199
238
  if (r.status === 403 || r.status === 401) { throw { gated: true }; }
200
239
  if (!r.ok) throw new Error('http ' + r.status);
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.802",
3
+ "version": "1.19.804",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.802",
9
+ "version": "1.19.804",
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.19.802",
3
+ "version": "1.19.804",
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",
@@ -0,0 +1,267 @@
1
+ #!/usr/bin/env node
2
+ // scripts/gds/artifact-staleness.js — are the gitignored generated artifacts on
3
+ // THIS machine actually current with the commit it has checked out? (task 1001930,
4
+ // ADR 0110 pt.5)
5
+ //
6
+ // WHY THIS EXISTS. ADR 0110 moved `docs/repo-map.md` and `docs/session-log-index.md`
7
+ // off the git graph: they are 100%-generated, two parallel branches collided on them
8
+ // constantly, so they became untracked files regenerated at deploy. That trade was
9
+ // deliberate, and the ADR names the debt it took on in its own Consequences:
10
+ //
11
+ // "A generator that breaks at deploy leaves a stale/absent file with no blocking
12
+ // backstop; the LOUD ::ERROR:: line is the alarm (mandatory, not optional)."
13
+ //
14
+ // That alarm is a line in the droplet's journald. Nobody reads journald. The per-PR
15
+ // freshness gate cannot help either — the files are gitignored, so they never appear
16
+ // in a diff for CI to check (`--check-whole`'s own comment says it plainly: "used by
17
+ // nothing hard; deploy owns it"). So if the deploy regen step silently stops running,
18
+ // every agent keeps reading a symbol map of a codebase that has moved on, and nothing
19
+ // anywhere says so. This check is the backstop that posture depends on.
20
+ //
21
+ // WHAT IT COMPARES. For each registered artifact, its own generator's `--check-whole`
22
+ // answers "would regenerating this file change it?" — run against the checkout that is
23
+ // actually serving. A difference means the deploy generator did not run, or ran and
24
+ // broke. Asking the generator rather than reimplementing the comparison is the point:
25
+ // there is exactly one definition of "fresh" per artifact and it lives with the thing
26
+ // that writes it, so this check cannot drift away from what deploy produces.
27
+ //
28
+ // ABSENT AND STALE ARE REPORTED SEPARATELY, and the split is operational, not cosmetic:
29
+ // absent means the regen step never ran here at all (a deploy line was dropped, or a
30
+ // fresh clone was never generated into), stale means it ran once and has not since.
31
+ // Those send someone to two different places, so the report must not blur them.
32
+ //
33
+ // ── WHERE THIS MAY RUN — read before scheduling it anywhere new ───────────────
34
+ //
35
+ // ONLY where the artifacts are SUPPOSED to already exist: a deployed instance, or a
36
+ // dev box after its source-fetch regen. NEVER in CI. A fresh `git clone` legitimately
37
+ // has no `docs/repo-map.md` — it is gitignored, so it is not in the repository at all
38
+ // — and this check would report it absent on every single run. A monitor that is red
39
+ // every run teaches its reader to ignore it, which is the exact failure ADR 0110's
40
+ // warn-only posture already has. That is why this ships as a scheduled routine on the
41
+ // instance and NOT as a `.github/workflows/` job, unlike its sibling publish-watch.js
42
+ // (which reads git and the registry, and so is true anywhere).
43
+ //
44
+ // THE MAIN GUARD. "Fresh" here means "matches what THIS commit generates". On a
45
+ // deployed instance that commit is main, because deploy does `git reset --hard
46
+ // origin/main` — so the question the caller actually wants ("does the served artifact
47
+ // lag main's HEAD?") and the question `--check-whole` answers are the same question.
48
+ // In a feature worktree they are NOT: the artifact would be compared against that
49
+ // branch, and a pass or a fail would both be meaningless. So a checkout that is not at
50
+ // origin/main is INCONCLUSIVE (exit 2), never a finding — a monitor that cannot tell
51
+ // which question it just answered must say so rather than guess.
52
+ 'use strict';
53
+
54
+ const { spawnSync } = require('child_process');
55
+ const fs = require('fs');
56
+ const path = require('path');
57
+
58
+ const REPO_ROOT = path.resolve(__dirname, '..', '..');
59
+ const GIT_TIMEOUT_MS = 15000;
60
+ const CHECK_TIMEOUT_MS = 120000;
61
+
62
+ // The gitignored, whole-file generated artifacts this check covers.
63
+ //
64
+ // MEMBERSHIP RULE — an artifact belongs here when all three hold: it is gitignored (so
65
+ // no PR gate can see it), it is written by a deploy-time generator (so it can silently
66
+ // stop being written), and its generator exposes `--check-whole` (so freshness has one
67
+ // definition, owned by the writer). tests/artifact_staleness.mjs pins this list against
68
+ // .gitignore and against the generators' real flags, so an artifact that gains a
69
+ // `--check-whole` and is left out of here fails that test rather than going unwatched.
70
+ //
71
+ // DELIBERATELY NOT HERE, and each for a reason that is not "we forgot":
72
+ // • modules/hall-ui/public/atlas.json (gen-atlas.js, ADR 0094) — gitignored and
73
+ // deploy-generated, but its payload embeds `generatedAt: new Date()`, so a
74
+ // regenerate-and-compare differs on every run by construction. Checking it needs a
75
+ // comparison over `views` only, or a freshness-by-age test against the last deploy
76
+ // — a different mechanism, not this one. gen-atlas has no --check mode at all today.
77
+ // • docs/onboarding/diagrams/*.svg|png — rendering them needs a headless Chromium the
78
+ // droplet does not have (the reason ADR 0110 deferred the diagram half in the first
79
+ // place), so freshness cannot be evaluated where this runs. Their FACTS are already
80
+ // backstopped by the diagram-drift routine, which reads assertions.json instead.
81
+ const ARTIFACTS = Object.freeze([
82
+ Object.freeze({
83
+ rel: 'docs/repo-map.md',
84
+ generator: 'gen-repo-map.js',
85
+ what: 'the symbol skeleton agents navigate the codebase by (ADR 0063)',
86
+ }),
87
+ Object.freeze({
88
+ rel: 'docs/session-log-index.md',
89
+ generator: 'gen-session-index.js',
90
+ what: 'the full session-log index the CLAUDE.md §13 snippet links out to (ADR 0062 §8)',
91
+ }),
92
+ ]);
93
+
94
+ function git(args, run = spawnSync) {
95
+ const raw = gitRaw(args, run);
96
+ return raw === null ? null : raw.trim();
97
+ }
98
+
99
+ // Untrimmed, because `git status --porcelain` is column-significant: a line is
100
+ // `XY<space>PATH` and an unstaged modification's X is a SPACE (" M path"). Trimming the
101
+ // buffer eats that leading space on the FIRST line only, so a slice(3) then swallows the
102
+ // path's first character — which is exactly how this printed "cripts/gds/…" until
103
+ // tests/artifact_staleness.mjs caught it.
104
+ function gitRaw(args, run = spawnSync) {
105
+ const r = run('git', args, { cwd: REPO_ROOT, encoding: 'utf8', timeout: GIT_TIMEOUT_MS });
106
+ if (!r || r.error || r.status !== 0) return null;
107
+ return String(r.stdout || '');
108
+ }
109
+
110
+ // The paths in a porcelain listing, status columns removed.
111
+ function porcelainPaths(raw) {
112
+ return raw.split('\n').filter((l) => l.length > 3).map((l) => l.slice(3).trim()).filter(Boolean);
113
+ }
114
+
115
+ // Is this checkout a fair thing to compare the artifacts against — at origin/main AND
116
+ // clean? Returns { ok } or { ok:false, reason }; git being unable to answer is equally
117
+ // inconclusive.
118
+ //
119
+ // The CLEAN half is not belt-and-braces, and it was found by running this check against
120
+ // its own uncommitted source file: a generator regenerates from the WORKING TREE, so one
121
+ // uncommitted file that moves a symbol makes repo-map.md "stale" against a main it in
122
+ // fact matches. On the droplet the tree is clean by construction (deploy does
123
+ // `git reset --hard`), so requiring it costs a correct run nothing — while without it
124
+ // every hand-run from a working checkout invents a finding, which is precisely how a
125
+ // monitor teaches its reader to ignore it. Gitignored files (the artifacts themselves)
126
+ // never appear in `--porcelain`, so they cannot trip this.
127
+ function atMain(run = spawnSync) {
128
+ const head = git(['rev-parse', 'HEAD'], run);
129
+ const main = git(['rev-parse', 'origin/main'], run);
130
+ if (!head) return { ok: false, reason: 'could not read HEAD (not a git checkout?)' };
131
+ if (!main) return { ok: false, reason: 'could not read origin/main (no remote ref here?)' };
132
+ if (head !== main) {
133
+ const branch = git(['rev-parse', '--abbrev-ref', 'HEAD'], run) || '(detached)';
134
+ return { ok: false, reason: `checkout is at ${branch} ${head.slice(0, 8)}, not origin/main ${main.slice(0, 8)}` };
135
+ }
136
+ const dirty = gitRaw(['status', '--porcelain'], run);
137
+ if (dirty === null) return { ok: false, reason: 'could not read the working tree state' };
138
+ const paths = porcelainPaths(dirty);
139
+ if (paths.length) {
140
+ const shown = paths.slice(0, 3).join(', ');
141
+ const more = paths.length > 3 ? `, +${paths.length - 3} more` : '';
142
+ return { ok: false, reason: `working tree is not clean (${shown}${more}) — the generators would compare against those edits, not main` };
143
+ }
144
+ return { ok: true, sha: head };
145
+ }
146
+
147
+ // Ask ONE artifact's own generator whether it is current.
148
+ // fresh exit 0 — regenerating would change nothing
149
+ // stale exit 2 — it would (absent counts as stale; `present` says which)
150
+ // unknown anything else — the generator itself could not answer, which is not a
151
+ // finding about the artifact and must never be reported as one.
152
+ function checkArtifact(a, { root = REPO_ROOT, run = spawnSync, exists = fs.existsSync } = {}) {
153
+ const present = exists(path.join(root, a.rel));
154
+ const r = run(process.execPath, [path.join(root, 'scripts', 'gds', a.generator), '--check-whole'],
155
+ { cwd: root, encoding: 'utf8', timeout: CHECK_TIMEOUT_MS });
156
+ if (!r || r.error) {
157
+ return { ...a, state: 'unknown', present, detail: r && r.error ? r.error.message : 'generator did not run' };
158
+ }
159
+ if (r.status === 0) return { ...a, state: 'fresh', present };
160
+ if (r.status === 2) return { ...a, state: present ? 'stale' : 'absent', present };
161
+ const said = String(r.stderr || r.stdout || '').trim().split('\n').pop() || `exit ${r.status}`;
162
+ return { ...a, state: 'unknown', present, detail: said };
163
+ }
164
+
165
+ function check({ root = REPO_ROOT, run = spawnSync, exists = fs.existsSync, artifacts = ARTIFACTS, requireMain = true } = {}) {
166
+ if (requireMain) {
167
+ const m = atMain(run);
168
+ if (!m.ok) { const e = new Error(m.reason); e.inconclusive = true; throw e; }
169
+ }
170
+ const results = artifacts.map((a) => checkArtifact(a, { root, run, exists }));
171
+ return {
172
+ results,
173
+ fresh: results.filter((r) => r.state === 'fresh'),
174
+ stale: results.filter((r) => r.state === 'stale'),
175
+ absent: results.filter((r) => r.state === 'absent'),
176
+ unknown: results.filter((r) => r.state === 'unknown'),
177
+ };
178
+ }
179
+
180
+ // Plain language: this lands in Discord #ship-news, where the audience is the owner,
181
+ // not whoever wrote the generator.
182
+ function report(r) {
183
+ const bad = [...r.absent, ...r.stale];
184
+ if (!bad.length && !r.unknown.length) {
185
+ return `Generated navigation docs are current — ${r.fresh.length} artifact(s) match this commit.`;
186
+ }
187
+ const lines = [];
188
+ if (bad.length) {
189
+ lines.push(`⚠ ${bad.length} generated file(s) on this instance no longer match the code being served.`);
190
+ lines.push('');
191
+ for (const a of r.absent) lines.push(` • ${a.rel} — MISSING entirely. Nothing has generated it here. It is ${a.what}.`);
192
+ for (const a of r.stale) lines.push(` • ${a.rel} — out of date: it no longer matches what this commit would produce. It is ${a.what}.`);
193
+ lines.push('');
194
+ lines.push('These are rebuilt by the deploy step, so this means that step stopped running or broke.');
195
+ lines.push('Nothing blocks on them, which is why it went unreported until now — anyone reading these');
196
+ lines.push('files is being given an out-of-date picture. Check the deploy log for its regen lines');
197
+ lines.push('(they print ::ERROR:: when a generator fails).');
198
+ }
199
+ if (r.unknown.length) {
200
+ if (bad.length) lines.push('');
201
+ lines.push(`Could not check ${r.unknown.length} artifact(s):`);
202
+ for (const a of r.unknown) lines.push(` • ${a.rel} — ${a.detail}`);
203
+ }
204
+ return lines.join('\n');
205
+ }
206
+
207
+ // Deliver the finding here rather than through run-routine.js's `sink: discord`.
208
+ // run-routine treats a non-zero step as a FAILED step and aborts BEFORE it routes
209
+ // stdout, so exiting 1 on a finding would throw that finding away. publish-watch.js
210
+ // and artifact-audit.js carry the same shape for the same reason; their routine steps
211
+ // all stay `sink: none` and the script performs its own side effect.
212
+ async function deliver(text) {
213
+ const { postWebhook } = require(path.join(__dirname, '..', 'discord', 'lib.js'));
214
+ await postWebhook('ship_news', text);
215
+ }
216
+
217
+ async function main() {
218
+ const argv = process.argv.slice(2);
219
+ const json = argv.includes('--json');
220
+
221
+ let r;
222
+ try {
223
+ r = check({ requireMain: !argv.includes('--any-ref') });
224
+ } catch (err) {
225
+ // Exit 2 = COULD NOT RUN, kept distinct from 1 (ran, found something) so a
226
+ // scheduler can go red on the former and merely report the latter.
227
+ console.error(`artifact-staleness could not complete: ${err && err.message ? err.message : err}`);
228
+ if (err && err.inconclusive) {
229
+ console.error(' Freshness is only meaningful against the commit being served. Re-run on the');
230
+ console.error(' deployed instance, or pass --any-ref to compare against whatever is checked out.');
231
+ }
232
+ process.exit(2);
233
+ }
234
+
235
+ const text = report(r);
236
+ const found = r.stale.length + r.absent.length;
237
+
238
+ if (json) { console.log(JSON.stringify(r, null, 2)); process.exit(found ? 1 : 0); }
239
+
240
+ // A healthy run is deliberately quiet — it tells a person who ran it by hand
241
+ // (stderr) and posts nothing. A daily "all fine" is how a channel becomes wallpaper.
242
+ if (!found) {
243
+ console.error(text);
244
+ // An artifact we could not check is not a finding, but it must not be silent
245
+ // either: a permanently-unknown artifact is unwatched without anyone deciding so.
246
+ process.exit(r.unknown.length ? 2 : 0);
247
+ }
248
+
249
+ console.log(text);
250
+ if (argv.includes('--no-post')) process.exit(1);
251
+
252
+ try {
253
+ await deliver(text);
254
+ console.error('posted the finding to Discord #ship-news.');
255
+ process.exit(1);
256
+ } catch (err) {
257
+ // Found something and could not say it — the original defect one layer down, so
258
+ // it is the LOUDEST outcome: exit 3 is >= 2, which turns the scheduled run red.
259
+ console.error(`\nFOUND STALE ARTIFACTS AND COULD NOT DELIVER THE FINDING: ${err && err.message ? err.message : err}`);
260
+ console.error('The finding is above. Set DISCORD_SHIP_NEWS_WEBHOOK so this reaches #ship-news.');
261
+ process.exit(3);
262
+ }
263
+ }
264
+
265
+ if (require.main === module) main();
266
+
267
+ module.exports = { ARTIFACTS, atMain, checkArtifact, check, report };
package/src/module-api.js CHANGED
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
71
71
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
72
72
  // the entry to that file. Look for a version's history there, not here.
73
73
  // ---------------------------------------------------------------------------
74
- const CORE_VERSION = '1.19.802'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.804'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
75
75
 
76
76
  // A namespaced logger so a module's log lines are attributable + consistent.
77
77
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -0,0 +1,202 @@
1
+ // tests/artifact_staleness.mjs
2
+ //
3
+ // Covers scripts/gds/artifact-staleness.js (task 1001930, ADR 0110 pt.5) — the backstop
4
+ // for the gitignored, deploy-generated navigation docs.
5
+ //
6
+ // ADR 0110 untracked docs/repo-map.md + docs/session-log-index.md to kill a merge-collision
7
+ // class, and accepted a debt in writing: a deploy generator that breaks leaves a stale or
8
+ // absent file with no blocking gate, alarming only via a ::ERROR:: line in journald. This
9
+ // check is that backstop, so the properties worth pinning are the ones that decide whether
10
+ // anyone will still be reading it in six months:
11
+ //
12
+ // 1. it distinguishes CANNOT-CHECK from FOUND-SOMETHING (a monitor that cries wolf when
13
+ // it is merely in the wrong place is one nobody reads — the exact ADR 0110 failure);
14
+ // 2. absent and stale stay separate (they send a reader to two different places);
15
+ // 3. the registry cannot silently fall behind the set of gitignored artifacts.
16
+ //
17
+ // `run`/`exists` are injected throughout, so nothing here touches a real generator, git
18
+ // repo, or network. The end-to-end behaviour against the REAL generators was verified by
19
+ // hand at ship time (fresh / absent / stale / both), which fakes cannot prove.
20
+ //
21
+ // Run: node --test tests/artifact_staleness.mjs
22
+
23
+ import { test } from 'node:test';
24
+ import assert from 'node:assert/strict';
25
+ import { createRequire } from 'node:module';
26
+ import { readFileSync, readdirSync } from 'node:fs';
27
+
28
+ const require = createRequire(import.meta.url);
29
+ const { ARTIFACTS, atMain, checkArtifact, check, report } = require('../scripts/gds/artifact-staleness.js');
30
+
31
+ const SHA = 'a'.repeat(40);
32
+ const OTHER = 'b'.repeat(40);
33
+
34
+ // A fake `run` for git: a table keyed by the git subcommand+args it is asked.
35
+ function fakeGit({ head = SHA, main = SHA, porcelain = '', branch = 'main', fail = null } = {}) {
36
+ return (cmd, args) => {
37
+ const key = args.join(' ');
38
+ if (fail && key.includes(fail)) return { status: 1, stdout: '', stderr: 'boom' };
39
+ if (key === 'rev-parse HEAD') return { status: 0, stdout: `${head}\n` };
40
+ if (key === 'rev-parse origin/main') return { status: 0, stdout: `${main}\n` };
41
+ if (key === 'rev-parse --abbrev-ref HEAD') return { status: 0, stdout: `${branch}\n` };
42
+ if (key === 'status --porcelain') return { status: 0, stdout: porcelain };
43
+ return { status: 0, stdout: '' };
44
+ };
45
+ }
46
+
47
+ // ── the guard: when is a comparison even meaningful? ────────────────────────────
48
+
49
+ test('at origin/main with a clean tree is comparable', () => {
50
+ assert.equal(atMain(fakeGit()).ok, true);
51
+ });
52
+
53
+ test('a feature branch is INCONCLUSIVE — the artifact would be judged against the wrong code', () => {
54
+ const r = atMain(fakeGit({ head: OTHER, branch: 'claude/some-task' }));
55
+ assert.equal(r.ok, false);
56
+ assert.match(r.reason, /claude\/some-task/);
57
+ assert.match(r.reason, /not origin\/main/);
58
+ });
59
+
60
+ test('a DIRTY tree is inconclusive — a generator regenerates from the working tree, not from main', () => {
61
+ // The real bug this caught: running the check against its own uncommitted source file
62
+ // reported repo-map.md stale when it in fact matched main.
63
+ const r = atMain(fakeGit({ porcelain: ' M scripts/gds/artifact-staleness.js\n' }));
64
+ assert.equal(r.ok, false);
65
+ assert.match(r.reason, /not clean/);
66
+ assert.match(r.reason, /scripts\/gds\/artifact-staleness\.js/);
67
+ });
68
+
69
+ test('every porcelain status column is parsed whole — an unstaged " M" must not lose its first letter', () => {
70
+ // The SECOND bug this file caught: the reader trimmed the buffer, and an unstaged
71
+ // modification's leading space is significant, so the first line printed "cripts/…".
72
+ // One case per column shape, first-line position included, since only the first line
73
+ // was ever affected.
74
+ for (const line of [' M scripts/gds/artifact-staleness.js', 'M scripts/gds/artifact-staleness.js', '?? scripts/gds/artifact-staleness.js', 'A scripts/gds/artifact-staleness.js']) {
75
+ const r = atMain(fakeGit({ porcelain: `${line}\n` }));
76
+ assert.match(r.reason, /\(scripts\/gds\/artifact-staleness\.js\)/, `mangled the path for "${line}"`);
77
+ }
78
+ });
79
+
80
+ test('a long dirty list is truncated with a count rather than dumped', () => {
81
+ const porcelain = [' M a.js', ' M b.js', ' M c.js', ' M d.js', ' M e.js'].join('\n') + '\n';
82
+ const r = atMain(fakeGit({ porcelain }));
83
+ assert.match(r.reason, /a\.js, b\.js, c\.js, \+2 more/);
84
+ });
85
+
86
+ test('git being unable to answer is inconclusive, never a pass', () => {
87
+ assert.equal(atMain(fakeGit({ fail: 'rev-parse origin/main' })).ok, false);
88
+ assert.equal(atMain(fakeGit({ fail: 'status --porcelain' })).ok, false);
89
+ assert.equal(atMain(fakeGit({ fail: 'rev-parse HEAD' })).ok, false);
90
+ });
91
+
92
+ test('check() REFUSES rather than reporting a finding when the checkout is not comparable', () => {
93
+ assert.throws(
94
+ () => check({ run: fakeGit({ head: OTHER }), exists: () => true }),
95
+ (e) => e.inconclusive === true,
96
+ 'an inconclusive run must be distinguishable from a finding',
97
+ );
98
+ });
99
+
100
+ // ── one artifact's verdict ──────────────────────────────────────────────────────
101
+
102
+ const A = { rel: 'docs/repo-map.md', generator: 'gen-repo-map.js', what: 'the symbol skeleton' };
103
+ const gen = (status, extra = {}) => () => ({ status, stdout: '', stderr: '', ...extra });
104
+
105
+ test('generator exit 0 → fresh', () => {
106
+ assert.equal(checkArtifact(A, { run: gen(0), exists: () => true }).state, 'fresh');
107
+ });
108
+
109
+ test('generator exit 2 + file present → stale; + file absent → absent', () => {
110
+ assert.equal(checkArtifact(A, { run: gen(2), exists: () => true }).state, 'stale');
111
+ assert.equal(checkArtifact(A, { run: gen(2), exists: () => false }).state, 'absent');
112
+ });
113
+
114
+ test('a generator that cannot answer is UNKNOWN, never stale — it is not a finding about the artifact', () => {
115
+ const crashed = checkArtifact(A, { run: gen(1, { stderr: 'SyntaxError: bad\n' }), exists: () => true });
116
+ assert.equal(crashed.state, 'unknown');
117
+ assert.match(crashed.detail, /SyntaxError/);
118
+ const spawnFailed = checkArtifact(A, { run: () => ({ error: new Error('ENOENT') }), exists: () => true });
119
+ assert.equal(spawnFailed.state, 'unknown');
120
+ });
121
+
122
+ // ── the report a human actually reads ───────────────────────────────────────────
123
+
124
+ const runFor = (byGenerator) => (cmd, args) => {
125
+ if (cmd === 'git') return fakeGit()(cmd, args);
126
+ const which = String(args[0] || '').split('/').pop();
127
+ return { status: byGenerator[which], stdout: '', stderr: '' };
128
+ };
129
+
130
+ test('all fresh → one quiet line, and no alarm language', () => {
131
+ const r = check({ run: runFor({ 'gen-repo-map.js': 0, 'gen-session-index.js': 0 }), exists: () => true });
132
+ assert.equal(r.stale.length + r.absent.length, 0);
133
+ const text = report(r);
134
+ assert.match(text, /current/);
135
+ assert.ok(!text.includes('⚠'), 'a healthy run must not look like a finding');
136
+ });
137
+
138
+ test('absent and stale are reported as different things, not one blurred count', () => {
139
+ const r = check({
140
+ run: runFor({ 'gen-repo-map.js': 2, 'gen-session-index.js': 2 }),
141
+ exists: (p) => !String(p).endsWith('repo-map.md'), // repo-map gone, session-index present
142
+ });
143
+ assert.deepEqual(r.absent.map((a) => a.rel), ['docs/repo-map.md']);
144
+ assert.deepEqual(r.stale.map((a) => a.rel), ['docs/session-log-index.md']);
145
+ const text = report(r);
146
+ assert.match(text, /docs\/repo-map\.md — MISSING entirely/);
147
+ assert.match(text, /docs\/session-log-index\.md — out of date/);
148
+ assert.match(text, /deploy step/, 'the report must say where to go look');
149
+ });
150
+
151
+ test('an unchecked artifact is surfaced even when nothing is stale — unwatched must not read as healthy', () => {
152
+ const r = check({ run: runFor({ 'gen-repo-map.js': 0, 'gen-session-index.js': 1 }), exists: () => true });
153
+ assert.equal(r.stale.length + r.absent.length, 0);
154
+ assert.equal(r.unknown.length, 1);
155
+ assert.match(report(r), /Could not check 1 artifact/);
156
+ });
157
+
158
+ // ── the registry cannot fall behind reality ─────────────────────────────────────
159
+
160
+ test('every registered artifact is really gitignored — a tracked file belongs to the PR gate instead', () => {
161
+ const ignore = readFileSync(new URL('../.gitignore', import.meta.url), 'utf8')
162
+ .split('\n').map((l) => l.trim());
163
+ for (const a of ARTIFACTS) {
164
+ assert.ok(ignore.includes(a.rel), `${a.rel} must be gitignored to belong in this check`);
165
+ }
166
+ });
167
+
168
+ test('every registered generator really exposes --check-whole — the contract this check is built on', () => {
169
+ for (const a of ARTIFACTS) {
170
+ const src = readFileSync(new URL(`../scripts/gds/${a.generator}`, import.meta.url), 'utf8');
171
+ assert.match(src, /--check-whole/, `${a.generator} must support --check-whole`);
172
+ }
173
+ });
174
+
175
+ test('the two ADR 0110 artifacts are both covered, and the known exclusions stay deliberate', () => {
176
+ assert.deepEqual(ARTIFACTS.map((a) => a.rel).sort(), ['docs/repo-map.md', 'docs/session-log-index.md']);
177
+ // atlas.json and the diagram renders are gitignored + deploy-generated too, but neither
178
+ // can be answered by this mechanism (a timestamped payload; a render needing Chromium).
179
+ // The reasons are written out at ARTIFACTS — this asserts they were a decision, so that
180
+ // adding either later is a deliberate act rather than a silent widening.
181
+ const src = readFileSync(new URL('../scripts/gds/artifact-staleness.js', import.meta.url), 'utf8');
182
+ assert.match(src, /atlas\.json/, 'the atlas exclusion must stay documented');
183
+ assert.match(src, /diagrams/, 'the diagram-render exclusion must stay documented');
184
+ });
185
+
186
+ test('this check must NOT be wired into CI — a fresh clone legitimately has none of these files', () => {
187
+ // The artifacts are gitignored, so a CI checkout never has them and this would be red on
188
+ // every single run — the "monitor nobody reads" failure ADR 0110 already has once. The
189
+ // mistake is an easy one to make by analogy with publish-watch.js, which IS a workflow
190
+ // (it reads git + the registry, so it is true anywhere). Assert no workflow invokes it.
191
+ const src = readFileSync(new URL('../scripts/gds/artifact-staleness.js', import.meta.url), 'utf8');
192
+ assert.match(src, /NEVER in CI/, 'the file must state where it may run');
193
+
194
+ let names = [];
195
+ try { names = readdirSync(new URL('../.github/workflows', import.meta.url)); } catch { names = []; }
196
+ const callers = names.filter((n) => {
197
+ try {
198
+ return readFileSync(new URL(`../.github/workflows/${n}`, import.meta.url), 'utf8').includes('artifact-staleness');
199
+ } catch { return false; }
200
+ });
201
+ assert.deepEqual(callers, [], `no workflow may run this check; found: ${callers.join(', ')}`);
202
+ });
@@ -0,0 +1,219 @@
1
+ // tests/people_directory_clip.mjs — the People directory says so when it is
2
+ // clipped (task 1003110, goal 1000095).
3
+ //
4
+ // WHY THIS FILE EXISTS. `people.js` asks `GET /scouting` for 500 rows, which is
5
+ // that route's HARD CEILING (parsePagination maxLimit: 500). The rollup returns
6
+ // no count of the whole set and the route takes no offset, so a response that
7
+ // comes back FULL is the only truncation signal the page will ever get — and
8
+ // until this task the page simply printed "500 builders listed" and said nothing
9
+ // else. A directory that lies by omission is worse than one that admits it is
10
+ // incomplete.
11
+ //
12
+ // It runs the REAL people.js in a vm against a stub DOM and a fake fetch, so
13
+ // what is asserted is the sentence a reader actually sees, not a regex agreeing
14
+ // with a regex. A source-text assertion would have passed just as happily with
15
+ // the comparison written the wrong way round.
16
+ //
17
+ // The four behaviours:
18
+ // 1. the page asks for exactly the number its copy then names (no drift)
19
+ // 2. a SHORT response is not hedged — the exact count is the honest one
20
+ // 3. a FULL response hedges every sentence that states a total: the pill, the
21
+ // subtitle, and a footnote about the lenses
22
+ // 4. one row short of the ceiling is NOT clipped (the boundary, in the
23
+ // direction that would otherwise cry wolf on every load)
24
+ //
25
+ // Run: node tests/people_directory_clip.mjs
26
+ import { strict as assert } from 'node:assert';
27
+ import fs from 'node:fs';
28
+ import path from 'node:path';
29
+ import vm from 'node:vm';
30
+ import { fileURLToPath } from 'node:url';
31
+ import { makeRunner } from './helpers.mjs';
32
+
33
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
34
+ const read = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8');
35
+ const KIT_JS = read('modules/hall-ui/public/hall-kit.js');
36
+ const PEOPLE_JS = read('modules/hall-ui/public/people.js');
37
+ const PEOPLE_HTML = read('modules/hall-ui/public/people.html');
38
+
39
+ // The route's ceiling, spelled here as the CONTRACT rather than read out of the
40
+ // page — a test that imports the page's own constant cannot notice it changing.
41
+ const CEILING = 500;
42
+
43
+ const { test, summary } = makeRunner();
44
+ const tick = () => new Promise((r) => setTimeout(r, 0));
45
+
46
+ function makeEl(id) {
47
+ return {
48
+ id,
49
+ innerHTML: '', textContent: '', value: '',
50
+ hidden: false, disabled: false, className: '',
51
+ options: [], style: {}, dataset: {},
52
+ classList: { add() {}, remove() {}, toggle() {}, contains: () => false },
53
+ appendChild() {}, setAttribute() {}, removeAttribute() {},
54
+ addEventListener() {},
55
+ querySelector() { return makeEl(); },
56
+ querySelectorAll() { return []; },
57
+ };
58
+ }
59
+
60
+ // `count` builders, each distinct enough that a filter lens has something to bite.
61
+ function buildersFor(count) {
62
+ return Array.from({ length: count }, (_, i) => ({
63
+ github_id: 1000 + i,
64
+ github_login: `builder${i}`,
65
+ display_name: `Builder ${i}`,
66
+ avatar_url: null,
67
+ project_count: 1,
68
+ total_credits: count - i,
69
+ total_tasks_shipped: 1,
70
+ total_karma: 1,
71
+ last_active: '2026-09-01T00:00:00Z',
72
+ disciplines: ['engineer'],
73
+ }));
74
+ }
75
+
76
+ function harness({ count }) {
77
+ const els = new Map();
78
+ const getEl = (id) => {
79
+ if (!els.has(id)) els.set(id, makeEl(id));
80
+ return els.get(id);
81
+ };
82
+ const documentObj = {
83
+ documentElement: { dataset: {}, style: {}, classList: { add() {}, remove() {} } },
84
+ getElementById: getEl,
85
+ querySelector(sel) { return getEl(String(sel).replace(/^#/, '')); },
86
+ querySelectorAll() { return []; },
87
+ createElement() { return makeEl(); },
88
+ addEventListener() {},
89
+ };
90
+
91
+ const requests = [];
92
+ const store = new Map();
93
+ const windowObj = {
94
+ location: { assign() {}, href: '/people' },
95
+ localStorage: {
96
+ getItem: (k) => (store.has(k) ? store.get(k) : null),
97
+ setItem: (k, v) => store.set(k, String(v)),
98
+ removeItem: (k) => store.delete(k),
99
+ },
100
+ scrollTo() {},
101
+ addEventListener() {},
102
+ matchMedia: () => ({ matches: false, addEventListener() {}, addListener() {} }),
103
+ OTBBranding: { currencyLabel: () => 'Credits' },
104
+ OTB: {
105
+ escapeHtml: (s) => String(s == null ? '' : s),
106
+ $: (sel) => documentObj.querySelector(sel),
107
+ num: (n) => Number(n) || 0,
108
+ fmtInt: (n) => String(Number(n) || 0),
109
+ fmtCompact: (n) => String(Number(n) || 0),
110
+ fmtDate: (iso) => String(iso ?? '').slice(0, 10),
111
+ rankLabel: (r) => String(r ?? ''),
112
+ toast() {},
113
+ },
114
+ };
115
+
116
+ const sandbox = {
117
+ window: windowObj, document: documentObj, location: windowObj.location,
118
+ console: { error() {}, warn() {}, log() {} },
119
+ setTimeout, clearTimeout, URLSearchParams, encodeURIComponent, Date, Math, JSON,
120
+ fetch: (url) => {
121
+ requests.push(String(url));
122
+ return Promise.resolve({
123
+ ok: true,
124
+ status: 200,
125
+ json: () => Promise.resolve({ builders: buildersFor(count) }),
126
+ });
127
+ },
128
+ };
129
+ sandbox.globalThis = sandbox;
130
+ vm.createContext(sandbox);
131
+ // The real kit, for the same reason sessions_kit_pager.mjs runs it: the page is
132
+ // held to the shipped contract, not to a stub that agrees with it.
133
+ vm.runInContext(KIT_JS, sandbox, { filename: 'hall-kit.js' });
134
+ vm.runInContext(PEOPLE_JS, sandbox, { filename: 'people.js' });
135
+
136
+ return { getEl, requests };
137
+ }
138
+
139
+ // ---------------------------------------------------------------------------
140
+
141
+ await test('the page markup carries the footnote slot, hidden until the page writes it', () => {
142
+ assert.match(PEOPLE_HTML, /id="people-foot"/, 'the clipped-directory footnote has a mount');
143
+ const tag = PEOPLE_HTML.match(/<p[^>]*id="people-foot"[^>]*>/);
144
+ assert.ok(tag, 'the footnote is a <p>');
145
+ assert.match(tag[0], /\bhidden\b/, 'it starts hidden — an empty note is still a visible gap');
146
+ assert.match(tag[0], /class="ledger__note"/, "the shell's ledger footnote class, not a bespoke one");
147
+ });
148
+
149
+ await test('the page asks for exactly the ceiling, and asks for it ONCE', async () => {
150
+ const h = harness({ count: 3 });
151
+ await tick(); await tick();
152
+ assert.equal(h.requests.length, 1, 'one directory read');
153
+ assert.match(h.requests[0], /\/api\/gds\/scouting\?limit=/);
154
+ const limit = Number(new URLSearchParams(h.requests[0].split('?')[1]).get('limit'));
155
+ assert.equal(limit, CEILING, `the page must ask for the route ceiling (${CEILING})`);
156
+ });
157
+
158
+ await test('a SHORT directory is not hedged — the exact count is the honest one', async () => {
159
+ const h = harness({ count: 3 });
160
+ await tick(); await tick();
161
+ assert.equal(h.getEl('people-count').textContent, '3', 'the pill states the real count');
162
+ assert.equal(h.getEl('people-count').hidden, false);
163
+ assert.match(h.getEl('people-sub').textContent, /^3 builders listed/, 'the subtitle states the real count');
164
+ assert.doesNotMatch(h.getEl('people-sub').textContent, /first|more than/i,
165
+ 'nothing is clipped, so nothing may suggest it is');
166
+ assert.equal(h.getEl('people-foot').hidden, true, 'no footnote on a complete directory');
167
+ assert.equal(h.getEl('people-foot').textContent, '', 'and no stale text left behind it');
168
+ });
169
+
170
+ await test('a FULL response hedges the pill, the subtitle AND the lenses', async () => {
171
+ const h = harness({ count: CEILING });
172
+ await tick(); await tick();
173
+
174
+ const pill = h.getEl('people-count').textContent;
175
+ assert.equal(pill, `${CEILING}+`, 'the pill may not state a total the page cannot know');
176
+
177
+ const sub = h.getEl('people-sub').textContent;
178
+ assert.match(sub, new RegExp(`first ${CEILING}\\b`), 'the subtitle says which slice this is');
179
+ assert.match(sub, /more than this page can load/, 'and says the directory is larger');
180
+ assert.doesNotMatch(sub, new RegExp(`^${CEILING} builders listed`),
181
+ 'the old sentence claimed the slice was the whole set');
182
+
183
+ const foot = h.getEl('people-foot');
184
+ assert.equal(foot.hidden, false, 'the footnote is shown');
185
+ // THE LENSES ARE THE SHARPER LIE. Search/sort/craft re-order only the loaded
186
+ // set, so on a clipped directory a builder outside the top 500 cannot be found
187
+ // by any of them — a reader told only "the list is long" would still trust the
188
+ // search box.
189
+ assert.match(foot.textContent, /search/i, 'the footnote names the search');
190
+ assert.match(foot.textContent, /sort/i, 'and the sort lenses');
191
+ assert.match(foot.textContent, new RegExp(`will not appear`),
192
+ 'and says plainly that a builder outside the slice is unreachable through them');
193
+ });
194
+
195
+ await test('one row short of the ceiling is NOT clipped', async () => {
196
+ const h = harness({ count: CEILING - 1 });
197
+ await tick(); await tick();
198
+ assert.equal(h.getEl('people-count').textContent, String(CEILING - 1),
199
+ 'a nearly-full page is still a complete one');
200
+ assert.equal(h.getEl('people-foot').hidden, true, 'crying wolf here would train the notice away');
201
+ });
202
+
203
+ await test('every number in the clipped copy is the number that was requested', async () => {
204
+ const h = harness({ count: CEILING });
205
+ await tick(); await tick();
206
+ const limit = new URLSearchParams(h.requests[0].split('?')[1]).get('limit');
207
+ // The drift this guards: someone raises the request to 1000 and leaves "500"
208
+ // written in three sentences. Every integer the clipped copy prints must be
209
+ // the one the request carried.
210
+ const copy = [h.getEl('people-count').textContent, h.getEl('people-sub').textContent,
211
+ h.getEl('people-foot').textContent].join(' ');
212
+ const printed = copy.match(/\d+/g) || [];
213
+ assert.ok(printed.length >= 3, 'the clipped copy states the number more than once');
214
+ for (const n of printed) {
215
+ assert.equal(n, limit, `copy prints ${n} but the page requested ${limit}`);
216
+ }
217
+ });
218
+
219
+ summary();
@@ -651,8 +651,19 @@ await t('R06.3 GET /scouting drops the hidden, the provisional AND the private b
651
651
  await t('R06.3 the /people page reads /scouting and nothing else', async () => {
652
652
  // /people is the hall's render of the same endpoint, so whatever /scouting
653
653
  // drops, /people drops. Pin that it has no second data source.
654
- const endpoints = [...sourceOf('modules/hall-ui/public/people.js').matchAll(/fetch\(\s*'([^']+)'/g)].map((m) => m[1]);
655
- assert.deepEqual(endpoints, ['/api/gds/scouting?limit=500'], '/people has exactly one data source');
654
+ //
655
+ // MATCHED AS AN EXPRESSION, NOT A STRING LITERAL. This used to capture the
656
+ // whole URL out of `fetch('…')` and compare it to one hardcoded string, which
657
+ // pinned the row limit as a side effect of pinning the ENDPOINT — so task
658
+ // 1003110 naming that limit (`DIRECTORY_LIMIT`, the one constant that feeds
659
+ // both the request and the "showing the first N" copy) failed a test about
660
+ // data sources. What is load-bearing here is the count and the destination:
661
+ // ONE read, and it goes to the scouting rollup. How the query string is
662
+ // assembled is the page's business.
663
+ const args = [...sourceOf('modules/hall-ui/public/people.js').matchAll(/\bfetch\(\s*([^,)]+)/g)].map((m) => m[1].trim());
664
+ assert.equal(args.length, 1, '/people has exactly one data source');
665
+ assert.match(args[0], /^'\/api\/gds\/scouting\?limit='/,
666
+ 'and it is the scouting rollup — nothing may re-enter through a second endpoint');
656
667
  });
657
668
 
658
669
  scout.server.close();