@bongos/core 1.19.870 → 1.19.872

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.
Files changed (102) hide show
  1. package/.bongos-core.json +164 -94
  2. package/.claude/skills/merge-mode/SKILL.md +1 -1
  3. package/CONTRIBUTING.md +1 -1
  4. package/README.md +1 -1
  5. package/clients/bongos-client/README.md +1 -1
  6. package/clients/bongos-client/bongos-client.global.js +4 -0
  7. package/clients/bongos-client/index.cjs +4 -0
  8. package/clients/bongos-client/index.d.ts +5 -0
  9. package/clients/bongos-client/index.mjs +4 -0
  10. package/docs/adr/0307-autobongos-a-deterministic-supervisor-and-agentic-workers.md +10 -0
  11. package/docs/api/openapi.json +61 -3
  12. package/docs/api-reference.md +7 -1
  13. package/docs/architecture.md +7 -7
  14. package/docs/branding-contract.md +1 -1
  15. package/docs/copy-inventory.md +190 -189
  16. package/docs/copy-registry.json +264 -255
  17. package/docs/design/hall-direction-v2.md +2 -2
  18. package/docs/module-api-changelog.md +4 -0
  19. package/docs/onboarding/diagrams/README.md +4 -4
  20. package/docs/onboarding/drachmae-and-karma.md +2 -2
  21. package/docs/onboarding/primer.md +1 -1
  22. package/docs/recipes/README.md +1 -1
  23. package/docs/recipes/bongos-cli-release.md +4 -4
  24. package/docs/recipes/core-release-pipeline.md +3 -3
  25. package/docs/recipes/instance-deploy.md +2 -2
  26. package/docs/recipes/ops-gotchas.md +2 -2
  27. package/docs/recipes/search-index-ingestion.md +2 -2
  28. package/docs/recipes/upgrading-the-core.md +1 -1
  29. package/modules/autonomy/gauge.js +218 -0
  30. package/modules/dev-box/app/README.md +1 -1
  31. package/modules/dev-box/app/src/vendor/bongos-client.cjs +4 -0
  32. package/modules/hall-ui/public/blockers.js +138 -34
  33. package/modules/hall-ui/public/builders.js +2 -1
  34. package/modules/hall-ui/public/collab.html +2 -2
  35. package/modules/hall-ui/public/collab.js +6 -3
  36. package/modules/hall-ui/public/dom-utils.js +46 -5
  37. package/modules/hall-ui/public/goals-page.js +7 -3
  38. package/modules/hall-ui/public/goals-render.js +4 -3
  39. package/modules/hall-ui/public/hall-render.js +1 -1
  40. package/modules/hall-ui/public/people.js +3 -3
  41. package/modules/hall-ui/public/profile-charts.js +5 -2
  42. package/modules/hall-ui/public/profile.html +3 -3
  43. package/modules/hall-ui/public/profile.js +6 -6
  44. package/modules/hall-ui/public/settings-specialities.js +2 -2
  45. package/modules/hall-ui/public/settings.html +4 -4
  46. package/modules/hall-ui/public/settings.js +2 -2
  47. package/modules/hall-ui/public/shell.js +70 -0
  48. package/modules/hall-ui/public/style.css +31 -0
  49. package/modules/hall-ui/public/work.js +12 -14
  50. package/modules/provisioning/module.json +3 -3
  51. package/modules/public-landing/public/terms.html +1 -1
  52. package/package-lock.json +2 -2
  53. package/package.json +1 -1
  54. package/scripts/gds/autobongos-loop.js +170 -0
  55. package/scripts/gds/autobongos-run.js +317 -0
  56. package/scripts/gds/autobongos-verify.js +377 -0
  57. package/scripts/gds/autonomy-gauge-statusline.js +124 -0
  58. package/scripts/gds/autonomy-gauge.js +84 -0
  59. package/scripts/gds/run-unit-tests.js +120 -2
  60. package/scripts/gds/sequence.js +3 -0
  61. package/scripts/gds/start.js +16 -10
  62. package/src/bongos/core-update.js +147 -0
  63. package/src/bongos/routes/core-update.js +49 -0
  64. package/src/bongos/routes.js +2 -0
  65. package/src/module-api.js +1 -1
  66. package/tests/account_privacy_flags.mjs +3 -1
  67. package/tests/autobongos_loop.mjs +446 -0
  68. package/tests/autobongos_verify.mjs +325 -0
  69. package/tests/autonomy_gauge.mjs +344 -0
  70. package/tests/blockers_hall.mjs +20 -6
  71. package/tests/blockers_kit_pager.mjs +260 -0
  72. package/tests/blockers_page_ui.mjs +5 -4
  73. package/tests/box_ship_permissions.mjs +30 -0
  74. package/tests/core_update_banner.mjs +308 -0
  75. package/tests/eviction_policy.mjs +22 -1
  76. package/tests/git_guard.mjs +68 -0
  77. package/tests/goal_criterion_confirm_ui.mjs +7 -0
  78. package/tests/goal_invite_consent.mjs +7 -0
  79. package/tests/goal_sort_staleness.mjs +6 -2
  80. package/tests/goal_subtitle.mjs +48 -4
  81. package/tests/hall_board_world.mjs +4 -1
  82. package/tests/hall_builders_page_pass.mjs +7 -0
  83. package/tests/hall_landing_boot.mjs +7 -0
  84. package/tests/hall_profile_world.mjs +20 -2
  85. package/tests/hall_record_world.mjs +7 -0
  86. package/tests/hall_roster_world.mjs +2 -1
  87. package/tests/hall_studio_world.mjs +7 -0
  88. package/tests/hook_shell_tools.mjs +23 -1
  89. package/tests/idea_detail_page.mjs +7 -0
  90. package/tests/idea_spark_hall.mjs +7 -0
  91. package/tests/integration_parked_ratchet.mjs +79 -0
  92. package/tests/module_contributions.mjs +134 -8
  93. package/tests/people_directory_clip.mjs +7 -0
  94. package/tests/profile_activity.mjs +7 -0
  95. package/tests/profile_ui_cross_project.mjs +9 -1
  96. package/tests/provision_disconnect.mjs +7 -0
  97. package/tests/provision_restart.mjs +7 -0
  98. package/tests/provision_settings_apply.mjs +7 -0
  99. package/tests/sessions_kit_pager.mjs +7 -0
  100. package/tests/watch_applications_queue.mjs +7 -0
  101. package/tests/watch_roster_self_row.mjs +7 -0
  102. package/tests/watch_sealed_floor.mjs +7 -0
@@ -72,15 +72,29 @@ test('blockers.js renders rows through the SAME shared lib the attention slice u
72
72
  assert.match(BLOCKERS_JS_SRC, /lib\.blockerFacts\(/);
73
73
  });
74
74
 
75
- test('blockers.js pages through OTBKit — makePager/paginate/wirePager/pagerBarHtml — not by hand', () => {
75
+ test('blockers.js pages through OTBKit\'s SERVER-OFFSET pager, not a client-held fetch-then-slice', () => {
76
+ // task 1003983: `?limit=200` fetched once and sliced client-side with
77
+ // kit.paginate() silently dropped everything past the 200th open blocker
78
+ // (200 is _helpers.js parsePagination's MAX_LIMIT) with no notice, and the
79
+ // headline pill counted the fetched (possibly-truncated) array's length
80
+ // rather than the route's own `page.total`. The fix asks for one page at a
81
+ // time — the profile activity-log / session-ledger serverPager precedent
82
+ // (hall-ui/CLAUDE.md) — so the browser never holds more than one page's
83
+ // worth of rows.
76
84
  assert.match(BLOCKERS_JS_SRC, /kit\.makePager\(/);
77
- assert.match(BLOCKERS_JS_SRC, /kit\.paginate\(/);
78
85
  assert.match(BLOCKERS_JS_SRC, /kit\.wirePager\(/);
79
86
  assert.match(BLOCKERS_JS_SRC, /kit\.pagerBarHtml\(/);
80
- // The negative half: no hand-rolled slice math (start/end arithmetic) sitting
81
- // beside the kit call, which is what "paged through OTBKit rather than
82
- // hand-rolled" would otherwise silently tolerate.
83
- assert.doesNotMatch(BLOCKERS_JS_SRC, /\.slice\(\s*\(?\s*pager\.page/);
87
+ assert.match(BLOCKERS_JS_SRC, /kit\.pageOffset\(/);
88
+ assert.match(BLOCKERS_JS_SRC, /kit\.renderServerPage\(/);
89
+ // The negative half: no client-side pager.paginate() slicing, and no fixed
90
+ // `?limit=200` fetch — both are the truncation shape this task removes.
91
+ assert.doesNotMatch(BLOCKERS_JS_SRC, /kit\.paginate\(/);
92
+ assert.doesNotMatch(BLOCKERS_JS_SRC, /\?limit=200/);
93
+ });
94
+
95
+ test('blockers.js reads the server\'s own page.total for the headline count, never a held array\'s length', () => {
96
+ assert.match(BLOCKERS_JS_SRC, /data\.page\.total/);
97
+ assert.doesNotMatch(BLOCKERS_JS_SRC, /all\.length/);
84
98
  });
85
99
 
86
100
  test('blockers.html loads blockers-lib.js BEFORE blockers.js', () => {
@@ -0,0 +1,260 @@
1
+ // tests/blockers_kit_pager.mjs — task 1003983: blockers.js's server-offset
2
+ // paging, EXECUTED against the real kit and the real page, not just pinned by
3
+ // call-shape regex (tests/blockers_hall.mjs does that half).
4
+ //
5
+ // WHY THIS FILE EXISTS. blockers.js used to fetch the whole open-blocker
6
+ // queue at `?limit=200` once and slice it client-side with `kit.paginate` —
7
+ // past 200 rows it silently dropped everything beyond the 200th and
8
+ // undercounted its own headline pill from that truncated array. The fix
9
+ // moved it onto the kit's server-offset pager primitives, hand-wired the way
10
+ // sessions.js wires them (tests/sessions_kit_pager.mjs is this file's direct
11
+ // model). The first version of that migration shipped WITHOUT sessions.js's
12
+ // rollback behaviour — a failed page-turn wiped the whole list and pager and
13
+ // never restored `pager.page`/`perPage`, so one failed click left the page
14
+ // dead until reload. This file runs the REAL hall-kit.js + the REAL
15
+ // blockers.js in one vm against a stub DOM and a stub `fetch`, so what is
16
+ // asserted is the request the page actually issues and the bar a reader
17
+ // actually sees.
18
+ //
19
+ // Run: node tests/blockers_kit_pager.mjs
20
+ import { strict as assert } from 'node:assert';
21
+ import fs from 'node:fs';
22
+ import path from 'node:path';
23
+ import vm from 'node:vm';
24
+ import { fileURLToPath } from 'node:url';
25
+ import { makeRunner } from './helpers.mjs';
26
+
27
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
28
+ const read = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8');
29
+ const KIT_JS = read('modules/hall-ui/public/hall-kit.js');
30
+ const BLOCKERS_JS = read('modules/hall-ui/public/blockers.js');
31
+
32
+ const { test, summary } = makeRunner();
33
+ const tick = () => new Promise((r) => setTimeout(r, 0));
34
+
35
+ function makeEl(id) {
36
+ const listeners = new Map();
37
+ return {
38
+ id,
39
+ innerHTML: '', textContent: '', className: '', value: '',
40
+ hidden: false, disabled: false,
41
+ options: [], style: {}, dataset: {},
42
+ classList: { add() {}, remove() {}, toggle() {} },
43
+ addEventListener(ev, fn) {
44
+ if (!listeners.has(ev)) listeners.set(ev, []);
45
+ listeners.get(ev).push(fn);
46
+ },
47
+ fire(ev, arg) { for (const fn of listeners.get(ev) || []) fn(arg); },
48
+ };
49
+ }
50
+
51
+ // `b(id)` is a minimal blocker row — enough for blockerFacts() (loaded as
52
+ // window.OTBBlockersLib) to render without throwing.
53
+ const b = (id) => ({ id, title: `Blocker ${id}`, body_md: '', created_at: '2026-08-01T00:00:00Z', blocking_task_ids: [] });
54
+
55
+ function harness({ total = 57, failAfter = null, perPagePref = null } = {}) {
56
+ const els = new Map();
57
+ const getEl = (id) => {
58
+ if (!els.has(id)) els.set(id, makeEl(id));
59
+ return els.get(id);
60
+ };
61
+ const documentObj = {
62
+ getElementById: getEl,
63
+ querySelector(sel) { return getEl(String(sel).replace(/^#/, '')); },
64
+ addEventListener() {},
65
+ };
66
+
67
+ const requests = [];
68
+ let calls = 0;
69
+ const rowsFor = (limit, offset) => Array.from(
70
+ { length: Math.max(0, Math.min(limit, total - offset)) },
71
+ (_, i) => b(offset + i + 1)
72
+ );
73
+
74
+ const fetchImpl = async (url) => {
75
+ requests.push(url);
76
+ calls += 1;
77
+ if (failAfter != null && calls > failAfter) {
78
+ return { ok: false, status: 500, json: async () => ({}) };
79
+ }
80
+ const q = new URLSearchParams(url.split('?')[1] || '');
81
+ const limit = Number(q.get('limit'));
82
+ const offset = Number(q.get('offset'));
83
+ return {
84
+ ok: true,
85
+ status: 200,
86
+ json: async () => ({ blockers: rowsFor(limit, offset), page: { limit, offset, total } }),
87
+ };
88
+ };
89
+
90
+ const store = new Map();
91
+ if (perPagePref != null) store.set('hall-blockers-per-page', String(perPagePref));
92
+
93
+ const toasts = [];
94
+ const windowObj = {
95
+ fetch: fetchImpl,
96
+ localStorage: {
97
+ getItem: (k) => (store.has(k) ? store.get(k) : null),
98
+ setItem: (k, v) => store.set(k, String(v)),
99
+ },
100
+ OTB: { toast: (msg) => toasts.push(msg) },
101
+ addEventListener() {},
102
+ // loadViewer() calls this for GET /me; signed-out (401) keeps isReviewer()
103
+ // false, which is fine for these paging tests — they don't inspect the
104
+ // Resolve/Link action column.
105
+ BongosClient: { createClient: () => ({ request: async () => ({ status: 401, ok: false, data: null }) }) },
106
+ OTBBlockersLib: {
107
+ esc: (s) => String(s ?? ''),
108
+ blockerFacts: (row) => ({
109
+ titleHtml: `<a>${row.title}</a>`, noteHtml: '', factsHtml: '<span></span>',
110
+ }),
111
+ },
112
+ };
113
+
114
+ const sandbox = {
115
+ window: windowObj, document: documentObj, fetch: fetchImpl,
116
+ console: { error() {}, warn() {}, log() {} },
117
+ setTimeout, clearTimeout, URLSearchParams,
118
+ };
119
+ sandbox.globalThis = sandbox;
120
+ vm.createContext(sandbox);
121
+ vm.runInContext(KIT_JS, sandbox, { filename: 'hall-kit.js' });
122
+ vm.runInContext(BLOCKERS_JS, sandbox, { filename: 'blockers.js' });
123
+
124
+ const q = (n) => {
125
+ const url = requests[n < 0 ? requests.length + n : n];
126
+ return Object.fromEntries(new URLSearchParams(url.split('?')[1]));
127
+ };
128
+ return { getEl, requests, q, store, toasts };
129
+ }
130
+
131
+ await test('the first load asks the SERVER for one page — the corpus is never held', async () => {
132
+ const h = harness({ total: 57 });
133
+ await tick(); await tick();
134
+ assert.equal(h.requests.length, 1);
135
+ const p = h.q(0);
136
+ assert.equal(p.offset, '0');
137
+ assert.equal(p.limit, '25', 'the page keeps its default per-page');
138
+ });
139
+
140
+ await test('the headline pill reads page.total, not the fetched rows\' own count', async () => {
141
+ const h = harness({ total: 57 });
142
+ await tick(); await tick();
143
+ assert.equal(h.getEl('blockers-count').textContent, '57');
144
+ assert.equal(h.getEl('blockers-count').hidden, false);
145
+ });
146
+
147
+ await test('Next moves the offset by the reader\'s per-page, and the bar says where they are', async () => {
148
+ const h = harness({ total: 57 });
149
+ await tick(); await tick();
150
+ h.getEl('blockers-next').fire('click');
151
+ await tick(); await tick();
152
+ assert.equal(h.requests.length, 2);
153
+ assert.equal(h.q(-1).offset, '25');
154
+ assert.equal(h.getEl('blockers-page').textContent, 'Page 2 of 3');
155
+ assert.equal(h.getEl('blockers-range').textContent, 'Showing 26–50 of 57');
156
+ });
157
+
158
+ await test('per-page is a real control: it re-asks, resets to page 1, and persists', async () => {
159
+ const h = harness({ total: 57 });
160
+ await tick(); await tick();
161
+ h.getEl('blockers-next').fire('click');
162
+ await tick(); await tick();
163
+ assert.equal(h.q(-1).offset, '25', 'sanity: on page 2');
164
+
165
+ h.getEl('blockers-per-page').fire('change', { target: { value: '50' } });
166
+ await tick(); await tick();
167
+ const p = h.q(-1);
168
+ assert.equal(p.limit, '50');
169
+ assert.equal(p.offset, '0', 'a per-page change returns to page 1');
170
+ assert.equal(h.store.get('hall-blockers-per-page'), '50');
171
+ });
172
+
173
+ await test('a remembered per-page is used on the very first request', async () => {
174
+ const h = harness({ total: 57, perPagePref: 10 });
175
+ await tick(); await tick();
176
+ assert.equal(h.requests.length, 1);
177
+ assert.equal(h.q(0).limit, '10');
178
+ });
179
+
180
+ // ---------------------------------------------------------------------------
181
+ // the bug this task fixes: a failed PAGE-TURN used to wipe the whole list and
182
+ // pager and never restore pager.page/perPage — one failed click left the page
183
+ // permanently dead. sessions.js's contract (hall-ui/CLAUDE.md) is the fix.
184
+ // ---------------------------------------------------------------------------
185
+
186
+ await test('a failed page-turn ROLLS BACK — the bar never claims a page whose rows never came', async () => {
187
+ const h = harness({ total: 57, failAfter: 1 });
188
+ await tick(); await tick();
189
+ assert.equal(h.getEl('blockers-page').textContent, 'Page 1 of 3');
190
+
191
+ h.getEl('blockers-next').fire('click');
192
+ await tick(); await tick();
193
+ assert.equal(h.requests.length, 2, 'it did try');
194
+ assert.equal(h.getEl('blockers-page').textContent, 'Page 1 of 3',
195
+ 'the failed page 2 must not be left showing in the bar');
196
+ assert.equal(h.getEl('blockers-range').textContent, 'Showing 1–25 of 57',
197
+ 'and the range must still describe the rows actually on screen');
198
+ });
199
+
200
+ await test('a failed page-turn does NOT wipe the list — the reader can retry from the pager', async () => {
201
+ const h = harness({ total: 57, failAfter: 1 });
202
+ await tick(); await tick();
203
+ const before = h.getEl('blockers-list').innerHTML;
204
+ assert.ok(before.length > 0, 'sanity: rows painted');
205
+
206
+ h.getEl('blockers-next').fire('click');
207
+ await tick(); await tick();
208
+ assert.equal(h.getEl('blockers-list').innerHTML, before,
209
+ 'the last successfully painted rows must still be on screen');
210
+ assert.equal(h.getEl('blockers-body').className, '',
211
+ 'notice()\'s full-body wipe must not fire once something has painted');
212
+ assert.equal(h.toasts.length, 1, 'the reader is told, even though the list stays put');
213
+ });
214
+
215
+ await test('a failed FIRST load still shows the honest notice — nothing has painted to keep', async () => {
216
+ const h = harness({ total: 57, failAfter: 0 });
217
+ await tick(); await tick();
218
+ assert.equal(h.getEl('blockers-body').className, 'note');
219
+ assert.match(h.getEl('blockers-body').textContent, /couldn.t load/i);
220
+ });
221
+
222
+ await test('a failed per-page change rolls the SIZE back too', async () => {
223
+ const h = harness({ total: 57, failAfter: 1 });
224
+ await tick(); await tick();
225
+ h.getEl('blockers-per-page').fire('change', { target: { value: '10' } });
226
+ await tick(); await tick();
227
+ h.getEl('blockers-next').fire('click');
228
+ await tick(); await tick();
229
+ // Back on the 25-row page 1, the only page whose rows ever arrived, so Next
230
+ // asks for offset 25, not offset 10.
231
+ assert.equal(h.q(-1).limit, '25');
232
+ });
233
+
234
+ await test('nothing is pressable while a fetch is in flight', async () => {
235
+ const h = harness({ total: 57 });
236
+ await tick(); await tick();
237
+ assert.equal(h.getEl('blockers-per-page').disabled, false, 'idle: the control is live');
238
+ h.getEl('blockers-next').fire('click');
239
+ // deliberately not awaited — this is mid-flight
240
+ assert.equal(h.getEl('blockers-next').disabled, true, 'in flight: Next is dead');
241
+ assert.equal(h.getEl('blockers-prev').disabled, true, 'in flight: Prev is dead');
242
+ assert.equal(h.getEl('blockers-per-page').disabled, true, 'in flight: per-page is dead');
243
+ await tick(); await tick();
244
+ assert.equal(h.getEl('blockers-per-page').disabled, false, 'landed: live again');
245
+ });
246
+
247
+ await test('Prev stays dead on page 1 — the kit decides that, not the page', async () => {
248
+ const h = harness({ total: 57 });
249
+ await tick(); await tick();
250
+ assert.equal(h.getEl('blockers-prev').disabled, true);
251
+ assert.equal(h.getEl('blockers-next').disabled, false);
252
+ });
253
+
254
+ await test('a queue that fits one page shows no bar at all', async () => {
255
+ const h = harness({ total: 4 });
256
+ await tick(); await tick();
257
+ assert.equal(h.getEl('blockers-pager').hidden, true);
258
+ });
259
+
260
+ summary();
@@ -285,8 +285,9 @@ await test('a reviewer resolving a row posts to the real route, toasts, and relo
285
285
  assert.equal(posts.length, 1);
286
286
  assert.equal(posts[0].body.resolution_note, 'raised the budget');
287
287
  assert.ok(h.toasts.some((t) => t === 'Blocker #12 resolved.'));
288
- // reload() re-fetches — a second GET /blockers beyond the initial boot fetch.
289
- assert.ok(h.requestsTo('GET', /\/blockers\?limit=200$/).length >= 2, 'the queue reloads after a mutation');
288
+ // reload() re-fetches the current server page (task 1003983's server-offset
289
+ // paging) — a second GET beyond the initial boot fetch, same page/offset.
290
+ assert.ok(h.requestsTo('GET', /\/blockers\?limit=25&offset=0$/).length >= 2, 'the queue reloads after a mutation');
290
291
  });
291
292
 
292
293
  await test('a reviewer linking a row posts task_id as a NUMBER', async () => {
@@ -312,13 +313,13 @@ await test('a 403 on resolve renders HONEST copy and leaves the form open — ne
312
313
 
313
314
  await test('resolving through the shared #/blocker/N overlay (otb:blocker-resolved) reloads this page\'s queue too', async () => {
314
315
  const h = await boot({ rank: 'metic' });
315
- const before = h.requestsTo('GET', /\/blockers\?limit=200$/).length;
316
+ const before = h.requestsTo('GET', /\/blockers\?limit=25&offset=0$/).length;
316
317
  // Simulate task-detail.js's blockerPanel firing the event on THIS window —
317
318
  // the SAME event the overlay dispatches when a builder resolves a blocker
318
319
  // from the deep-link card opened on top of this page.
319
320
  h.dispatchWindowEvent('otb:blocker-resolved');
320
321
  await h.settle();
321
- const after = h.requestsTo('GET', /\/blockers\?limit=200$/).length;
322
+ const after = h.requestsTo('GET', /\/blockers\?limit=25&offset=0$/).length;
322
323
  assert.equal(after, before + 1, 'the queue re-fetched exactly once in response to the event');
323
324
  });
324
325
 
@@ -92,6 +92,7 @@ function allowCovers(allow, command) {
92
92
  return parts.every((part) => allow.some((rule) => ruleCovers(rule, part)));
93
93
  }
94
94
  const isBlocked = (command) => guard.decide({ tool_name: 'Bash', tool_input: { command } }, {}).block;
95
+ const isBlockedPwsh = (command) => guard.decide({ tool_name: 'PowerShell', tool_input: { command } }, {}).block;
95
96
 
96
97
  // At least one sample per git-guard rule, keyed by the rule's own name.
97
98
  const BLOCKED_SAMPLES = {
@@ -101,13 +102,42 @@ const BLOCKED_SAMPLES = {
101
102
  'git branch -D': ['git branch -D task-1', 'git branch --list -D task-1', 'git branch -a -D task-1'],
102
103
  };
103
104
 
105
+ // The samples above are all Bash-shaped, and replaying them against PowerShell would
106
+ // prove nothing: they are the one spelling that already matched (task 1004028). A
107
+ // Windows session writes the executable in whatever case it likes, appends `.exe`,
108
+ // and may carry a backtick that vanishes — so each rule owns a sample in a spelling
109
+ // only PowerShell resolves, and a new rule cannot be added without one.
110
+ const BLOCKED_SAMPLES_PWSH = {
111
+ 'git stash': ['Git stash', 'GIT.EXE stash pop'],
112
+ 'git worktree remove': ['git.exe worktree remove ../wt', 'Git worktree remove --force ../wt'],
113
+ 'git push origin main': ['g`it push origin main', 'GIT push -u origin HEAD:main'],
114
+ 'git branch -D': ['GIT branch -D task-1', 'Git.exe branch -a -D task-1'],
115
+ };
116
+
104
117
  test('every git-guard rule has blocked samples here (a new rule forces a new sample)', () => {
105
118
  for (const [name] of guard.RULES) {
106
119
  assert.ok((BLOCKED_SAMPLES[name] || []).length, `no blocked sample for git-guard rule "${name}"`);
120
+ assert.ok((BLOCKED_SAMPLES_PWSH[name] || []).length, `no PowerShell-spelled sample for git-guard rule "${name}"`);
107
121
  }
108
122
  for (const samples of Object.values(BLOCKED_SAMPLES)) {
109
123
  for (const cmd of samples) assert.ok(isBlocked(cmd), `sample is not actually blocked by git-guard: ${cmd}`);
110
124
  }
125
+ for (const samples of Object.values(BLOCKED_SAMPLES_PWSH)) {
126
+ for (const cmd of samples) {
127
+ assert.ok(isBlockedPwsh(cmd), `PowerShell sample is not actually blocked by git-guard: ${cmd}`);
128
+ }
129
+ }
130
+ });
131
+
132
+ // Each PowerShell sample must be spelled in a way the Bash-shaped list does not
133
+ // already cover — otherwise the new list is a copy and proves nothing. Every one
134
+ // differs from a plain `git …` in the executable token alone.
135
+ test('the PowerShell samples are genuinely different spellings, not a copy', () => {
136
+ const bashSamples = new Set(Object.values(BLOCKED_SAMPLES).flat());
137
+ for (const cmd of Object.values(BLOCKED_SAMPLES_PWSH).flat()) {
138
+ assert.ok(!bashSamples.has(cmd), `PowerShell sample repeats a Bash sample verbatim: ${cmd}`);
139
+ assert.ok(!/^git /.test(cmd), `PowerShell sample uses the one spelling that always matched: ${cmd}`);
140
+ }
111
141
  });
112
142
 
113
143
  test('no allow-rule covers a command git-guard blocks', () => {