@bongos/core 1.19.772 → 1.19.774

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 (97) hide show
  1. package/.bongos-core.json +204 -79
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +2 -0
  4. package/clients/bongos-client/index.cjs +2 -0
  5. package/clients/bongos-client/index.d.ts +5 -1
  6. package/clients/bongos-client/index.mjs +2 -0
  7. package/docs/adr/0083-modular-architecture-strangler-build-plan.md +1 -0
  8. package/docs/adr/0177-goal-membership-kind.md +26 -2
  9. package/docs/adr/0181-teardown-state-gate-and-address-retention.md +2 -0
  10. package/docs/adr/0285-a-shared-box-holds-about-twelve-projects-per-gb-and-memory-is-the-wall.md +1 -1
  11. package/docs/adr/0288-the-goal-wall-has-a-shared-floor-for-paths-no-module-owns.md +33 -0
  12. package/docs/adr/0289-an-unconsumed-contribution-is-a-ci-failure-not-a-boot-refusal.md +33 -0
  13. package/docs/adr/0290-disconnect-is-the-owner-initiated-exception-to-address-retention.md +40 -0
  14. package/docs/adr/README.md +3 -0
  15. package/docs/api/openapi.json +102 -11
  16. package/docs/api-reference.md +5 -4
  17. package/docs/copy-inventory.md +205 -190
  18. package/docs/copy-registry.json +435 -269
  19. package/docs/file-map.md +1 -0
  20. package/docs/module-api-changelog.md +4 -0
  21. package/docs/modules-contract.md +1 -1
  22. package/modules/builder-settings/render-prefs.js +281 -33
  23. package/modules/builder-settings/routes/settings.js +20 -12
  24. package/modules/dev-box/app/src/vendor/bongos-client.cjs +2 -0
  25. package/modules/dev-box/module.json +4 -0
  26. package/modules/hall-ui/public/settings-render.js +138 -0
  27. package/modules/hall-ui/public/settings.html +16 -9
  28. package/modules/hall-ui/public/settings.js +3 -70
  29. package/modules/lifecycle/db-tasks.js +7 -1
  30. package/modules/lifecycle/dead-deps.js +10 -2
  31. package/modules/lifecycle/ship-card.js +49 -13
  32. package/modules/lifecycle/task-list-limits.js +44 -0
  33. package/modules/platform-identity/migrations/platform_identity_021_drop_legacy_role.sql +94 -0
  34. package/modules/platform-identity/platform-identity.js +8 -19
  35. package/modules/platform-identity/project-catalog.js +24 -0
  36. package/modules/platform-identity/routes/sso.js +11 -14
  37. package/modules/platform-identity/tests/platform-identity.mjs +7 -9
  38. package/modules/provisioning/capacity-gate.js +54 -0
  39. package/modules/provisioning/catalog-bridge.js +2 -0
  40. package/modules/provisioning/disconnect.js +35 -0
  41. package/modules/provisioning/migrations/provisioning_016_disconnect.sql +21 -0
  42. package/modules/provisioning/module.json +1 -1
  43. package/modules/provisioning/provisioning.js +3 -3
  44. package/modules/provisioning/routes/disconnect.js +63 -0
  45. package/modules/provisioning/routes/provisioning.js +6 -6
  46. package/modules/public-landing/public/projects.html +116 -11
  47. package/modules/public-landing/public/projects.probes.json +3 -0
  48. package/modules/ui-design/kit/serve.js +3 -2
  49. package/modules/ui-design/module.json +1 -0
  50. package/package-lock.json +2 -2
  51. package/package.json +1 -1
  52. package/scripts/gds/address-park-sweep.sh +78 -0
  53. package/scripts/gds/audit-deps.js +38 -4
  54. package/scripts/gds/cli-lib.js +37 -0
  55. package/scripts/gds/fitness-migration-contraction.js +50 -0
  56. package/scripts/gds/fitness.js +18 -0
  57. package/scripts/gds/land-watch.js +8 -2
  58. package/scripts/gds/provision-config.js +4 -4
  59. package/scripts/gds/provision-disconnect.js +90 -0
  60. package/scripts/gds/provision-teardown.js +108 -0
  61. package/scripts/gds/provision.js +6 -65
  62. package/scripts/gds/seam-wiring-guard.js +273 -0
  63. package/scripts/gds/ship-finish.js +17 -10
  64. package/scripts/gds/ship-flow.js +28 -6
  65. package/scripts/gds/ship-resume.js +32 -4
  66. package/scripts/gds/start.js +13 -7
  67. package/src/bongos/auth-admission.js +7 -8
  68. package/src/bongos/auth-config.js +3 -2
  69. package/src/bongos/goal-scope-check.js +23 -8
  70. package/src/bongos/module-scope-map.js +40 -0
  71. package/src/module-api.js +1 -1
  72. package/src/module-loader/manifest-schema.js +79 -1
  73. package/tests/catalog_only_client.mjs +8 -5
  74. package/tests/fitness.mjs +22 -0
  75. package/tests/goal_scope_check.mjs +63 -1
  76. package/tests/hall_settings_world.mjs +57 -1
  77. package/tests/hub_disconnect_ui.mjs +105 -0
  78. package/tests/land_watch.mjs +44 -0
  79. package/tests/membership_kind_lockstep.mjs +86 -27
  80. package/tests/module-scope-map.mjs +39 -0
  81. package/tests/module_exports_unique_keys.mjs +70 -0
  82. package/tests/module_manifest.mjs +85 -0
  83. package/tests/project_catalog_forget.mjs +83 -0
  84. package/tests/projects_hub.mjs +10 -4
  85. package/tests/projects_hub_pre_uat.mjs +5 -3
  86. package/tests/provision_disconnect.mjs +231 -0
  87. package/tests/provision_teardown_address.mjs +230 -0
  88. package/tests/provisioning_capacity_gate.mjs +192 -0
  89. package/tests/provisioning_disconnect.mjs +231 -0
  90. package/tests/provisioning_restart.mjs +5 -5
  91. package/tests/public_landing_projects.mjs +4 -3
  92. package/tests/render_prefs.mjs +152 -0
  93. package/tests/seam_wiring_guard.mjs +234 -0
  94. package/tests/ship_card.mjs +67 -1
  95. package/tests/ship_resume.mjs +86 -12
  96. package/tests/sso_checkin_legacy_field_gone.mjs +246 -0
  97. package/tests/task_list_paging.mjs +178 -0
@@ -138,7 +138,8 @@ test('the delete section is served live, and never claims to erase (task 1003042
138
138
  assert.match(body, /id="mDelWhat"/, 'the teardown-vs-delete fold is served');
139
139
  assert.match(body, /does <b>not<\/b> erase the project/i,
140
140
  'the distinction survives to the wire, not just the source');
141
- assert.match(body, /no permanent-delete button/i, 'the absent capability is admitted');
141
+ assert.match(body, /use <b>Disconnect from Bongos entirely<\/b> below/, 'the true delete is its own card (task 1003249), and the fold says where');
142
+ assert.match(body, /id="mDisconnect"/, 'and that card is served');
142
143
  // the confirmation asks for the project's own name — the heaviest gate on the page
143
144
  assert.match(body, /Type the project’s name to confirm/, 'the typed confirmation is served');
144
145
  assert.match(body, /data-del-yes disabled/, 'and the act starts unreachable');
@@ -146,8 +147,8 @@ test('the delete section is served live, and never claims to erase (task 1003042
146
147
 
147
148
  test('the served teardown copy no longer claims the address goes quiet (task 1003042)', async () => {
148
149
  const { body } = await request('/projects', 'game.test.local');
149
- // teardown has no DNS or Caddy leg: the hostname keeps resolving and 502s, so
150
- // this sentence was false on the wire. Task 1002897 owns actually releasing it.
150
+ // teardown parks the hostname and removes its Caddy block (task 1002897) but
151
+ // keeps the address for a revive, so "reserved" stays and "goes quiet" never did.
151
152
  assert.doesNotMatch(body, /the address stops answering/,
152
153
  'no served copy may promise a silence the runner does not deliver');
153
154
  assert.match(body, /stays reserved for this project/,
@@ -118,5 +118,157 @@ t('describeForApi reflects an explicit OFF override', () => {
118
118
  assert.equal(d.override, false);
119
119
  });
120
120
 
121
+ // ---- the card-richness ladder (task 1002518) --------------------------------
122
+ // The boolean became four rungs, and the rung a builder gets with no override is
123
+ // DERIVED from their interaction profile. These pin both halves, plus the
124
+ // compatibility surface every pinned CLI still reads.
125
+
126
+ console.log('\nthe ladder:');
127
+
128
+ t('LEVELS is ordered least → most rich, and rank follows the order', () => {
129
+ assert.deepEqual(r.LEVELS, ['minimal', 'text', 'standard', 'rich']);
130
+ assert.ok(r.levelRank('minimal') < r.levelRank('text'));
131
+ assert.ok(r.levelRank('text') < r.levelRank('standard'));
132
+ assert.ok(r.levelRank('standard') < r.levelRank('rich'));
133
+ });
134
+
135
+ t('the predicates say what each rung draws', () => {
136
+ // minimal: nothing but the confirmation line.
137
+ assert.equal(r.showsCard('minimal'), false);
138
+ assert.equal(r.showsWidget('minimal'), false);
139
+ // text: the monospace card, no HTML.
140
+ assert.equal(r.showsCard('text'), true);
141
+ assert.equal(r.showsWidget('text'), false);
142
+ // standard: the widget, summary folded.
143
+ assert.equal(r.showsWidget('standard'), true);
144
+ assert.equal(r.widgetExpanded('standard'), false);
145
+ // rich: the widget, summary open.
146
+ assert.equal(r.showsWidget('rich'), true);
147
+ assert.equal(r.widgetExpanded('rich'), true);
148
+ });
149
+
150
+ t('cardFlags bundles the three answers a card emitter needs', () => {
151
+ assert.deepEqual(r.cardFlags('rich'), { level: 'rich', showCard: true, showWidget: true, expanded: true });
152
+ assert.deepEqual(r.cardFlags('minimal'), { level: 'minimal', showCard: false, showWidget: false, expanded: false });
153
+ });
154
+
155
+ t('an unknown level rank-resolves to standard rather than throwing', () => {
156
+ // Defensive: a level string from an instance ahead of this code must degrade,
157
+ // not crash a ship. standard is the historical default rendering.
158
+ assert.equal(r.levelRank('nonsense'), r.levelRank('standard'));
159
+ });
160
+
161
+ console.log('\nderivation from the interaction profile:');
162
+
163
+ const withProfile = (interaction) => ({ interaction });
164
+
165
+ t('the DEFAULT builder (non-technical, concise) derives the RICHEST card', () => {
166
+ // The point of the task: the reader who most needs explanation, and who by
167
+ // definition never opened Settings, must not get the terser card.
168
+ assert.equal(r.derivedLevel({}), 'rich');
169
+ assert.equal(r.derivedLevel(null), 'rich');
170
+ assert.equal(r.resolveLevel({}), 'rich');
171
+ });
172
+
173
+ t('technical_level is the primary axis', () => {
174
+ assert.equal(r.derivedLevel(withProfile({ technical_level: 'non_technical' })), 'rich');
175
+ assert.equal(r.derivedLevel(withProfile({ technical_level: 'mixed' })), 'standard');
176
+ assert.equal(r.derivedLevel(withProfile({ technical_level: 'engineer' })), 'text');
177
+ });
178
+
179
+ t('machinery:quiet drops a rung — an expert who asked for less gets terse or none', () => {
180
+ assert.equal(r.derivedLevel(withProfile({ technical_level: 'engineer', machinery: 'quiet' })), 'minimal');
181
+ assert.equal(r.derivedLevel(withProfile({ technical_level: 'non_technical', machinery: 'quiet' })), 'standard');
182
+ });
183
+
184
+ t('verbosity:detailed raises a rung', () => {
185
+ assert.equal(r.derivedLevel(withProfile({ technical_level: 'engineer', verbosity: 'detailed' })), 'standard');
186
+ });
187
+
188
+ t('verbosity:concise does NOT drop a rung (it is the default — see the derivation note)', () => {
189
+ // Taxing the default would push the accessible-by-default builder from rich
190
+ // down to standard, inverting the whole point of deriving from the profile.
191
+ assert.equal(
192
+ r.derivedLevel(withProfile({ technical_level: 'non_technical', verbosity: 'concise' })),
193
+ r.derivedLevel(withProfile({ technical_level: 'non_technical' })),
194
+ );
195
+ });
196
+
197
+ t('the ladder clamps at both ends', () => {
198
+ // non_technical (rich) + detailed would be rank 4; engineer (text) + quiet
199
+ // twice over would go below 0. Neither may fall off the ladder.
200
+ assert.equal(r.derivedLevel(withProfile({ technical_level: 'non_technical', verbosity: 'detailed' })), 'rich');
201
+ assert.equal(r.derivedLevel(withProfile({ technical_level: 'engineer', machinery: 'quiet', verbosity: 'concise' })), 'minimal');
202
+ });
203
+
204
+ t('derivedReason names the fields that actually moved the level', () => {
205
+ const plain = r.derivedReason({});
206
+ assert.match(plain, /technical level: non-technical/);
207
+ assert.ok(!/machinery/.test(plain), 'a default field is not listed as a reason');
208
+ const quiet = r.derivedReason(withProfile({ technical_level: 'engineer', machinery: 'quiet' }));
209
+ assert.match(quiet, /machinery: quiet/);
210
+ });
211
+
212
+ console.log('\noverrides beat derivation:');
213
+
214
+ t('an explicit level override wins over the profile', () => {
215
+ const eng = { interaction: { technical_level: 'engineer' }, widgets: 'rich' };
216
+ assert.equal(r.derivedLevel(eng), 'text', 'the profile would have said text');
217
+ assert.equal(r.resolveLevel(eng), 'rich', 'the override wins');
218
+ assert.equal(r.overrideLevel(eng), 'rich');
219
+ });
220
+
221
+ t('clearing the override falls back to the derived level', () => {
222
+ const cleared = r.applyPatch({ widgets: 'minimal', interaction: { technical_level: 'mixed' } }, { widgets: null });
223
+ assert.ok(!('widgets' in cleared));
224
+ assert.equal(r.resolveLevel(cleared), 'standard', 'back to what the profile says');
225
+ assert.equal(r.overrideLevel(cleared), null);
226
+ });
227
+
228
+ console.log('\nlegacy boolean compatibility (a pinned CLI / an older hall build):');
229
+
230
+ t('a stored legacy boolean keeps the exact card it always meant', () => {
231
+ assert.equal(r.overrideLevel({ widgets: true }), 'standard');
232
+ assert.equal(r.overrideLevel({ widgets: false }), 'text');
233
+ // And it still beats the profile, because it IS an explicit choice.
234
+ assert.equal(r.resolveLevel({ widgets: false, interaction: { technical_level: 'non_technical' } }), 'text');
235
+ });
236
+
237
+ t('validatePatch still accepts booleans, normalising them to a level on the way in', () => {
238
+ assert.equal(r.validatePatch({ widgets: true }).patch.widgets, 'standard');
239
+ assert.equal(r.validatePatch({ widgets: false }).patch.widgets, 'text');
240
+ });
241
+
242
+ t('validatePatch accepts every level and rejects a non-level string', () => {
243
+ for (const lvl of r.LEVELS) {
244
+ assert.equal(r.validatePatch({ widgets: lvl }).ok, true, `accept ${lvl}`);
245
+ }
246
+ const bad = r.validatePatch({ widgets: 'verbose' });
247
+ assert.equal(bad.ok, false);
248
+ assert.equal(bad.errors[0].error, 'invalid_value');
249
+ });
250
+
251
+ t('widgetsEnabled — the boolean every pinned CLI reads — tracks the ladder', () => {
252
+ assert.equal(r.widgetsEnabled({ widgets: 'minimal' }), false);
253
+ assert.equal(r.widgetsEnabled({ widgets: 'text' }), false);
254
+ assert.equal(r.widgetsEnabled({ widgets: 'standard' }), true);
255
+ assert.equal(r.widgetsEnabled({ widgets: 'rich' }), true);
256
+ });
257
+
258
+ t('describeForApi keeps the legacy keys AND adds the ladder', () => {
259
+ const d = r.describeForApi({ interaction: { technical_level: 'engineer' } });
260
+ // legacy half — an older CLI reads only these and is correct
261
+ assert.equal(d.widgets_enabled, false, 'engineer derives text, so no widget');
262
+ assert.equal(d.default_enabled, true);
263
+ assert.equal(d.override, null, 'derived, not chosen');
264
+ // ladder half
265
+ assert.equal(d.level, 'text');
266
+ assert.equal(d.derived_level, 'text');
267
+ assert.equal(d.override_level, null);
268
+ assert.match(d.derived_reason, /technical level: engineer/);
269
+ assert.deepEqual(d.levels.map((l) => l.value), r.LEVELS);
270
+ assert.ok(d.levels.every((l) => l.label && l.blurb), 'every rung is labelled for the UI');
271
+ });
272
+
121
273
  console.log(`\n${passed} passed, ${failed} failed`);
122
274
  process.exit(failed === 0 ? 0 : 1);
@@ -0,0 +1,234 @@
1
+ // tests/seam_wiring_guard.mjs — the declared-seam-is-consumed guard (task 1003819,
2
+ // ADR 0289; fitness Check 33, successor to abandoned task 1002464).
3
+ //
4
+ // The scanner is the tested surface, driven over SYNTHETIC manifests with an injected
5
+ // `exists` — the scanScopeMapSource / scanDeterministicBody precedent. That keeps every
6
+ // case hermetic (no tmp dirs, no fixture modules) AND lets a case prove the guard
7
+ // actually catches a violation, which a suite that only runs it over the real tree
8
+ // cannot: a clean tree passes whether the check works or is a stub.
9
+ //
10
+ // The three properties that make this guard worth having, each pinned below:
11
+ // 1. a declared entry whose consumer's artifact is absent is REPORTED, naming the
12
+ // module key and the seam;
13
+ // 2. a legitimately declarative-only entry is DISTINGUISHED from that, and a STALE
14
+ // exemption (the artifact exists now) is itself reported;
15
+ // 3. the wired set is reported on the PASS path too — a guard that is silent when
16
+ // it passes leaves no evidence it ran.
17
+
18
+ import assert from 'node:assert/strict';
19
+ import { test } from 'node:test';
20
+ import { createRequire } from 'node:module';
21
+ import path from 'node:path';
22
+ import { fileURLToPath } from 'node:url';
23
+
24
+ const require = createRequire(import.meta.url);
25
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
26
+ const guard = require(path.join(ROOT, 'scripts', 'gds', 'seam-wiring-guard.js'));
27
+ const { scanSeamWiring, SEAM_CONSUMERS, declaredEntries, entryLabel } = guard;
28
+
29
+ // A synthetic module. `dir` is a bare key — the scanner only ever hands it to
30
+ // SEAM_CONSUMERS.artifact() and to `exists`, both of which we control here.
31
+ const mod = (key, contributes, declarativeSeams) => ({
32
+ key,
33
+ dir: `modules/${key}`,
34
+ manifest: { key, contributes, ...(declarativeSeams ? { declarativeSeams } : {}) },
35
+ });
36
+
37
+ // An `exists` that answers true for exactly the paths listed (forward-slashed).
38
+ const only = (...paths) => {
39
+ const set = new Set(paths);
40
+ return (p) => set.has(String(p).split(path.sep).join('/'));
41
+ };
42
+ const nothingExists = () => false;
43
+
44
+ // ---------------------------------------------------------------------------
45
+ // 1. a declared seam with no consumer artifact is reported
46
+ // ---------------------------------------------------------------------------
47
+
48
+ test('reports a declared poller whose file was never shipped, naming module + seam', () => {
49
+ const r = scanSeamWiring([mod('dev-box', { pollers: ['ghost'] })], { exists: nothingExists });
50
+ assert.equal(r.ok, false);
51
+ assert.equal(r.findings.length, 1);
52
+ assert.match(r.findings[0], /module "dev-box"/);
53
+ assert.match(r.findings[0], /contributes\.pollers: "ghost"/);
54
+ assert.match(r.findings[0], /modules\/dev-box\/pollers\/ghost\.js does not exist/);
55
+ // and it says what to do about it — the three legitimate resolutions
56
+ assert.match(r.findings[0], /Ship the artifact, drop the declaration, or record it in declarativeSeams\.pollers/);
57
+ });
58
+
59
+ test('every seam kind is covered: file, per-module file, dir, and the webSurfaces object', () => {
60
+ const r = scanSeamWiring([
61
+ mod('a', { routes: ['missing'] }),
62
+ mod('b', { uiSections: ['sec'] }),
63
+ mod('c', { skills: ['sk'] }),
64
+ mod('d', { webSurfaces: [{ host: 'x.', dir: 'public' }] }),
65
+ mod('e', { migrations: true }),
66
+ ], { exists: nothingExists });
67
+ assert.equal(r.findings.length, 5, r.findings.join('\n'));
68
+ for (const [key, frag] of [
69
+ ['a', 'modules/a/routes/missing.js'],
70
+ ['b', 'modules/b/public/hall-widget.js'],
71
+ ['c', 'modules/c/skills/sk'],
72
+ ['d', 'modules/d/public'],
73
+ ['e', 'modules/e/migrations'],
74
+ ]) {
75
+ assert.ok(r.findings.some((f) => f.includes(`module "${key}"`) && f.includes(frag)),
76
+ `no finding for ${key} naming ${frag}:\n${r.findings.join('\n')}`);
77
+ }
78
+ });
79
+
80
+ test('a seam NOTHING consumes is reported on sight — the strongest form of the defect', () => {
81
+ // `rooms` is allowed by the manifest schema but read by no code in the tree.
82
+ assert.equal(SEAM_CONSUMERS.rooms.consumer, null);
83
+ // …and shipping the file does not help, because nothing would load it.
84
+ const r = scanSeamWiring([mod('game', { rooms: ['WorldRoom'] })], { exists: only('modules/game/rooms/WorldRoom.js') });
85
+ assert.equal(r.ok, false);
86
+ assert.match(r.findings[0], /NOTHING in the tree consumes the rooms seam at all/);
87
+ });
88
+
89
+ test('a disciplines entry absent from the registry is reported', () => {
90
+ const r = scanSeamWiring([mod('ui-design', { disciplines: ['nope'] })], { registryKeys: new Set(['ui']) });
91
+ assert.equal(r.ok, false);
92
+ assert.match(r.findings[0], /has no such entry/);
93
+ // a null registry leaves the seam UNCHECKED rather than failing it
94
+ assert.equal(scanSeamWiring([mod('ui-design', { disciplines: ['nope'] })], { registryKeys: null }).ok, true);
95
+ });
96
+
97
+ // ---------------------------------------------------------------------------
98
+ // 2. declarative-only is distinguished, and a stale exemption is caught
99
+ // ---------------------------------------------------------------------------
100
+
101
+ test('a declarative-only entry is recorded, not reported — the load-bearing nuance', () => {
102
+ const r = scanSeamWiring(
103
+ [mod('dev-box', { pollers: ['box-lifecycle'] }, { pollers: ['box-lifecycle'] })],
104
+ { exists: nothingExists },
105
+ );
106
+ assert.equal(r.ok, true, r.findings.join('; '));
107
+ assert.deepEqual(r.declarative, [{ key: 'dev-box', seam: 'pollers', entry: 'box-lifecycle' }]);
108
+ assert.deepEqual(r.wired, []);
109
+ });
110
+
111
+ test('the exemption is per-ENTRY, not a blanket skip of the seam', () => {
112
+ const r = scanSeamWiring(
113
+ [mod('dev-box', { pollers: ['box-lifecycle', 'ghost'] }, { pollers: ['box-lifecycle'] })],
114
+ { exists: nothingExists },
115
+ );
116
+ assert.equal(r.ok, false);
117
+ assert.equal(r.findings.length, 1);
118
+ assert.match(r.findings[0], /"ghost"/);
119
+ assert.deepEqual(r.declarative.map((d) => d.entry), ['box-lifecycle']);
120
+ });
121
+
122
+ test('a STALE exemption — the artifact exists now — is itself reported', () => {
123
+ const r = scanSeamWiring(
124
+ [mod('dev-box', { pollers: ['box-lifecycle'] }, { pollers: ['box-lifecycle'] })],
125
+ { exists: only('modules/dev-box/pollers/box-lifecycle.js') },
126
+ );
127
+ assert.equal(r.ok, false);
128
+ assert.match(r.findings[0], /exempts pollers "box-lifecycle" as declarative-only, but modules\/dev-box\/pollers\/box-lifecycle\.js exists/);
129
+ assert.match(r.findings[0], /drop the stale declarativeSeams\.pollers entry/);
130
+ assert.deepEqual(r.declarative, [], 'a stale exemption must not also count as declarative-only');
131
+ });
132
+
133
+ test('an exemption on one seam does not leak to another seam of the same module', () => {
134
+ const r = scanSeamWiring(
135
+ [mod('dev-box', { pollers: ['x'], uiSections: ['x'] }, { pollers: ['x'] })],
136
+ { exists: nothingExists },
137
+ );
138
+ assert.equal(r.findings.length, 1);
139
+ assert.match(r.findings[0], /contributes\.uiSections/);
140
+ });
141
+
142
+ test('a skill counts as wired from the module tree OR its vendor/ subdir', () => {
143
+ const own = scanSeamWiring([mod('ui-design', { skills: ['style'] })], { exists: only('modules/ui-design/skills/style/SKILL.md') });
144
+ assert.equal(own.ok, true, own.findings.join('; '));
145
+ const vendored = scanSeamWiring([mod('ui-design', { skills: ['style'] })], { exists: only('modules/ui-design/skills/vendor/style/SKILL.md') });
146
+ assert.equal(vendored.ok, true, vendored.findings.join('; '));
147
+ });
148
+
149
+ test('a skills/<name>/ dir with NO SKILL.md is unwired — the consumer requires the file', () => {
150
+ // moduleSkillSources tests `fs.existsSync(path.join(p, 'SKILL.md'))`, so a bare dir
151
+ // is not a skill to it. Probing the directory would call such an entry wired while
152
+ // the real consumer skipped it — this guard's own version of the defect it catches.
153
+ const r = scanSeamWiring([mod('ui-design', { skills: ['style'] })], { exists: only('modules/ui-design/skills/style') });
154
+ assert.equal(r.ok, false, 'a dir without SKILL.md must not count as wired');
155
+ assert.match(r.findings[0], /modules\/ui-design\/skills\/style\/SKILL\.md does not exist/);
156
+ });
157
+
158
+ test('an entry that reaches outside its own module dir is reported, never probed', () => {
159
+ // Only routes/pollers/rooms/uiSections/skills are constrained to non-empty strings
160
+ // by the schema, so an entry could carry '..'. The probe is an existence test over
161
+ // repo-committed content, but a declaration reaching out of its module is a finding.
162
+ let probed = 0;
163
+ const r = scanSeamWiring([mod('a', { routes: ['../../etc/passwd'] })], { exists: () => { probed += 1; return true; } });
164
+ assert.equal(r.ok, false);
165
+ assert.match(r.findings[0], /outside the module's own directory/);
166
+ assert.equal(probed, 0, 'the escaping path must not be probed at all');
167
+ });
168
+
169
+ // ---------------------------------------------------------------------------
170
+ // 3. the wired set is reported on the PASS path too
171
+ // ---------------------------------------------------------------------------
172
+
173
+ test('a fully wired module reports its wired entries, so a pass is evidence the guard ran', () => {
174
+ const r = scanSeamWiring(
175
+ [mod('economy', { routes: ['cost', 'credits'], migrations: true })],
176
+ { exists: only('modules/economy/routes/cost.js', 'modules/economy/routes/credits.js', 'modules/economy/migrations') },
177
+ );
178
+ assert.equal(r.ok, true, r.findings.join('; '));
179
+ assert.equal(r.wired.length, 3);
180
+ assert.deepEqual(r.wired.map((w) => w.entry).sort(), ['cost', 'credits', 'migrations/']);
181
+ for (const w of r.wired) assert.ok(w.via, 'each wired entry names the consumer that wires it');
182
+ });
183
+
184
+ test('an empty array and an absent contributes declare nothing at all', () => {
185
+ const r = scanSeamWiring([mod('discord', { uiSections: [] }), mod('quiet', {})], { exists: nothingExists });
186
+ assert.equal(r.ok, true);
187
+ assert.deepEqual(r.wired, []);
188
+ assert.deepEqual(r.declarative, []);
189
+ });
190
+
191
+ test('an unknown contributes key is validateManifest\'s error, not this guard\'s', () => {
192
+ const r = scanSeamWiring([mod('x', { pollerz: ['a'] })], { exists: nothingExists });
193
+ assert.equal(r.ok, true, 'the guard must not double-report a shape error');
194
+ });
195
+
196
+ test('the guard and the schema share ONE enumerator + labeller, not two copies', () => {
197
+ // The exemption cross-check (validateManifest) and the wiring check (this guard)
198
+ // must agree on what a contributes block declares and what each entry is called.
199
+ // Two copies would drift, and the drift would land on whether an exemption is honoured.
200
+ const schema = require(path.join(ROOT, 'src', 'module-loader', 'manifest-schema.js'));
201
+ assert.equal(declaredEntries, schema.declaredEntries);
202
+ assert.equal(entryLabel, schema.entryLabel);
203
+ });
204
+
205
+ test('declaredEntries + entryLabel: migrations true/array, and the two webSurface shapes', () => {
206
+ assert.deepEqual(declaredEntries('migrations', true), [true]);
207
+ assert.deepEqual(declaredEntries('migrations', ['001_x.sql']), ['001_x.sql']);
208
+ assert.deepEqual(declaredEntries('migrations', false), []);
209
+ assert.deepEqual(declaredEntries('routes', undefined), []);
210
+ assert.equal(entryLabel('migrations', true), 'migrations/');
211
+ assert.equal(entryLabel('webSurfaces', { host: 'status.', dir: 'public' }), 'status.(public)');
212
+ assert.equal(entryLabel('webSurfaces', { apexRoot: true, dir: 'public' }), 'apexRoot:public');
213
+ });
214
+
215
+ // ---------------------------------------------------------------------------
216
+ // the live tree — the regression the guard exists to hold
217
+ // ---------------------------------------------------------------------------
218
+
219
+ test('the real module set is clean: every declared seam is wired or recorded declarative-only', () => {
220
+ const r = guard.checkSeamWiring();
221
+ assert.equal(r.ok, true, r.violations.join('\n'));
222
+ assert.equal(r.hardFail, false);
223
+ // the pass note carries the evidence, not just "ok"
224
+ assert.match(r.note, /WIRED across \d+ seam\(s\)/);
225
+ assert.match(r.note, /recorded declarative-only/);
226
+ });
227
+
228
+ test('every seam the manifest schema allows has an entry in SEAM_CONSUMERS', () => {
229
+ // Otherwise a new contributes key would be silently unchecked — the same gap in a
230
+ // new place. The schema's list is the source; this guard must span all of it.
231
+ const { ALLOWED_CONTRIBUTES } = require(path.join(ROOT, 'src', 'module-loader', 'manifest-schema.js'));
232
+ const missing = ALLOWED_CONTRIBUTES.filter((k) => !(k in SEAM_CONSUMERS));
233
+ assert.deepEqual(missing, [], `SEAM_CONSUMERS has no entry for: ${missing.join(', ')}`);
234
+ });
@@ -291,7 +291,73 @@ test('emitCard with widgetsEnabled:false: monospace only, no HTML, off-marker',
291
291
  assert.equal(file, null, 'no HTML file written when widgets are off');
292
292
  assert.match(out, /Example World · task #1266/); // the monospace card still printed
293
293
  assert.ok(!out.includes('[otb-card-html]'), 'no [otb-card-html] marker emitted');
294
- assert.match(out, /widgets are off/); // off-marker tells the skill not to call show_widget
294
+ assert.match(out, /rich cards are off/); // off-marker tells the skill not to call show_widget
295
+ });
296
+
297
+ // ---- the card-richness ladder (task 1002518) --------------------------------
298
+ // emitCard receives the resolved rung as FLAGS, never as a level string: the
299
+ // ladder lives in modules/builder-settings/render-prefs.js and a module may not
300
+ // require a sibling module (ADR 0083). These pin what each rung draws.
301
+
302
+ test('flags.expanded opens the widget summary; without it the summary stays folded', () => {
303
+ const model = card.buildCardModel(shippedInput);
304
+ const folded = card.renderCardHtml(model);
305
+ const open = card.renderCardHtml(model, { expanded: true });
306
+ // The summary body is the LAST div; folded hides it, expanded shows it.
307
+ assert.match(folded, /<div style="display:none;margin-top:0\.6rem/, 'default card folds its summary');
308
+ assert.match(open, /<div style="display:block;margin-top:0\.6rem/, 'expanded card opens its summary');
309
+ // The toggle still works either way — expanded only picks the starting state.
310
+ assert.match(open, /Toggle task summary/);
311
+ assert.match(open, /rotate\(90deg\)/, 'chevron points down when it starts open');
312
+ });
313
+
314
+ test('minimal rung: no monospace card at all, but the confirmation line survives', () => {
315
+ const lines = [];
316
+ const file = card.emitCard(card.buildCardModel(shippedInput), 99997, {
317
+ log: (s) => lines.push(String(s)),
318
+ flags: { showCard: false, showWidget: false, expanded: false },
319
+ });
320
+ const out = lines.join('\n');
321
+ assert.equal(file, null, 'no HTML written at the minimal rung');
322
+ assert.ok(!out.includes('Example World · task #1266'), 'the monospace card is NOT printed');
323
+ assert.ok(!out.includes('[otb-card-html]'), 'no widget marker emitted');
324
+ // task 1001730's floor holds at every rung — this is the whole reason the
325
+ // confirm line is emitted before the card branches.
326
+ assert.match(out, /\[otb-confirm\] shipped: task 1266/, 'the guaranteed line still prints');
327
+ assert.match(out, /minimal cards/, 'the directive tells the skill to relay only that line');
328
+ });
329
+
330
+ test('rich rung: the widget is written AND its summary starts open', () => {
331
+ const lines = [];
332
+ const file = card.emitCard(card.buildCardModel(shippedInput), 99996, {
333
+ log: (s) => lines.push(String(s)),
334
+ flags: { showCard: true, showWidget: true, expanded: true },
335
+ });
336
+ assert.ok(file, 'HTML file written at the rich rung');
337
+ const html = fs.readFileSync(file, 'utf8');
338
+ assert.match(html, /<div style="display:block;margin-top:0\.6rem/, 'summary open on arrival');
339
+ const out = lines.join('\n');
340
+ assert.match(out, /Example World · task #1266/, 'monospace card still printed');
341
+ assert.match(out, /\[otb-card-html\]/, 'widget marker emitted');
342
+ fs.unlinkSync(file);
343
+ });
344
+
345
+ test('flags absent falls back to the historical boolean meaning', () => {
346
+ // A caller that has not learned the ladder yet (an older script, a test) keeps
347
+ // exactly the behaviour it had: monospace always, widget iff true, never open.
348
+ for (const widgetsEnabled of [true, false]) {
349
+ const lines = [];
350
+ const file = card.emitCard(card.buildCardModel(shippedInput), 99995, {
351
+ log: (s) => lines.push(String(s)), widgetsEnabled,
352
+ });
353
+ const out = lines.join('\n');
354
+ assert.match(out, /Example World · task #1266/, `boolean ${widgetsEnabled} still prints the monospace card`);
355
+ assert.equal(!!file, widgetsEnabled, `boolean ${widgetsEnabled} decides the widget`);
356
+ if (file) {
357
+ assert.match(fs.readFileSync(file, 'utf8'), /<div style="display:none;margin-top:0\.6rem/, 'never expanded via the boolean');
358
+ fs.unlinkSync(file);
359
+ }
360
+ }
295
361
  });
296
362
 
297
363
  // ---- lifecycle trail: full journey on every card (tasks 1487 + 1489) ----
@@ -133,25 +133,88 @@ test('completed + owner with low rank still resumes (ownership, not rank)', () =
133
133
  // 'completed' task into the re-grade before reading a flag. The manual-confirm
134
134
  // path a no-artifact task needs is unreachable from 'completed' any other way.
135
135
 
136
- test('empty diff + --allow-empty → confirm instead of bailing (the 1003146 shape)', () => {
137
- assert.equal(shouldConfirmWithoutGrade({ committedFileCount: 0, args: ['1003146', '--allow-empty'] }), true);
138
- });
139
-
140
- test('empty diff + --skip-grade → confirm (the other documented no-artifact flag)', () => {
141
- assert.equal(shouldConfirmWithoutGrade({ committedFileCount: 0, args: ['1003146', '--skip-grade'] }), true);
136
+ test('empty diff + --allow-empty + a CLEAN tree → confirm instead of bailing (the 1003146 shape)', () => {
137
+ const v = shouldConfirmWithoutGrade({ committedFileCount: 0, porcelain: '', args: ['1003146', '--allow-empty'] });
138
+ assert.equal(v.confirm, true);
139
+ assert.equal(v.reason, 'allow-empty');
142
140
  });
143
141
 
144
142
  test('a REAL diff + --allow-empty still grades — the flag is not a grader bypass', () => {
145
143
  // The hole this guard exists to avoid: if a declaration alone were enough, a task
146
144
  // whose grade FAILED could dodge the verdict by re-running with --allow-empty.
147
- assert.equal(shouldConfirmWithoutGrade({ committedFileCount: 3, args: ['1003146', '--allow-empty'] }), false);
148
- assert.equal(shouldConfirmWithoutGrade({ committedFileCount: 1, args: ['1003146', '--skip-grade'] }), false);
145
+ assert.equal(shouldConfirmWithoutGrade({ committedFileCount: 3, porcelain: '', args: ['1003146', '--allow-empty'] }).confirm, false);
146
+ assert.equal(shouldConfirmWithoutGrade({ committedFileCount: 1, porcelain: '', args: ['1003146', '--allow-empty'] }).reason, 'has-diff');
149
147
  });
150
148
 
151
149
  test('empty diff with NO flag keeps dead-ending — uncommitted work must not "ship"', () => {
152
150
  // The overwhelmingly common cause of an empty diff is forgetting to commit.
153
151
  // Confirming that silently would report success having shipped nothing.
154
- assert.equal(shouldConfirmWithoutGrade({ committedFileCount: 0, args: ['1003146'] }), false);
152
+ assert.equal(shouldConfirmWithoutGrade({ committedFileCount: 0, porcelain: '', args: ['1003146'] }).confirm, false);
153
+ });
154
+
155
+ // ---- task 1003944: the same guard had re-opened the task-1363 phantom ship ----
156
+ // The first cut asked only "is the diff empty?" and "was a flag passed?", which is
157
+ // strictly WEAKER than the decision the FIRST-ship path makes at the same moment.
158
+ // Two consequences, both reachable in one command, both fixed here.
159
+
160
+ test('an UNTRACKED deliverable is refused even under --allow-empty (task 1363)', () => {
161
+ // The whole point of task 1363: a new file left untracked is invisible to
162
+ // preflight (trackedDirty ignores untracked), so this decision was the ONLY thing
163
+ // standing between "you forgot to commit" and a row marked shipped with HEAD
164
+ // never moved. --allow-empty asserts there is no artifact; the tree proves there
165
+ // is one, and the tree wins.
166
+ const v = shouldConfirmWithoutGrade({
167
+ committedFileCount: 0, porcelain: '?? docs/the-deliverable.md', args: ['1003146', '--allow-empty'],
168
+ });
169
+ assert.equal(v.confirm, false, 'an untracked deliverable must never manual-confirm');
170
+ assert.equal(v.reason, 'uncommitted-work');
171
+ assert.deepEqual(v.uncommitted, ['docs/the-deliverable.md'], 'and the builder is told WHICH file');
172
+ });
173
+
174
+ test('a tracked-but-uncommitted edit is refused under --allow-empty too', () => {
175
+ const v = shouldConfirmWithoutGrade({
176
+ committedFileCount: 0, porcelain: ' M scripts/gds/ship.js', args: ['1003146', '--allow-empty'],
177
+ });
178
+ assert.equal(v.confirm, false);
179
+ assert.equal(v.reason, 'uncommitted-work');
180
+ });
181
+
182
+ test('--skip-grade is NOT an artifact declaration — it declares nothing about artifacts', () => {
183
+ // ship.js documents it as a grader-outage override, "NOT a routine flag", and
184
+ // task 1365 removed it from the first-ship empty-diff decision for exactly this
185
+ // reason. Accepting it here re-opened the half of task 1363 that fix had closed.
186
+ assert.equal(shouldConfirmWithoutGrade({ committedFileCount: 0, porcelain: '', args: ['1003146', '--skip-grade'] }).confirm,
187
+ false, '--skip-grade must not confirm an empty ship on its own');
188
+ assert.equal(shouldConfirmWithoutGrade({ committedFileCount: 0, porcelain: '', args: ['1003146', '--skip-grade', '--allow-empty'] }).confirm,
189
+ true, 'but it must not BLOCK a properly declared one either');
190
+ });
191
+
192
+ test('the refusal names the PATH, not the porcelain status codes', () => {
193
+ // A rename line is "R old -> new" and a quoted path is wrapped in "…". The
194
+ // builder has to be told which file to commit, so the reported name must be the
195
+ // path. (.gitignored files never reach here at all: `git status --porcelain`
196
+ // omits them unless --ignored is passed, which the call site does not pass — the
197
+ // call-site test below pins the exact command.)
198
+ const v = shouldConfirmWithoutGrade({
199
+ committedFileCount: 0, porcelain: 'R docs/old.md -> docs/new.md\n?? "docs/a b.md"', args: ['1003146', '--allow-empty'],
200
+ });
201
+ assert.equal(v.confirm, false);
202
+ assert.deepEqual(v.uncommitted, ['docs/new.md', 'docs/a b.md']);
203
+ });
204
+
205
+ test('the re-grade decision and the FIRST-ship decision agree on the same tree', () => {
206
+ // The defect was a second, weaker copy of one rule. This asserts they are the
207
+ // same rule: for every tree, the re-grade verdict must equal what classifyEmptyShip
208
+ // — the function the first ship calls — says about it. A re-divergence reds here.
209
+ const { classifyEmptyShip } = require('../modules/lifecycle/ship-preflight');
210
+ for (const porcelain of ['', '?? docs/new.md', ' M scripts/gds/ship.js', '!! node_modules/x', 'A a.js\n?? b.js']) {
211
+ for (const args of [['--allow-empty'], []]) {
212
+ const first = classifyEmptyShip(porcelain, { allowEmpty: args.includes('--allow-empty') });
213
+ const resume = shouldConfirmWithoutGrade({ committedFileCount: 0, porcelain, args });
214
+ assert.equal(resume.confirm, !first.refuse, `divergence on ${JSON.stringify(porcelain)} ${JSON.stringify(args)}`);
215
+ assert.equal(resume.reason, first.reason, `reason divergence on ${JSON.stringify(porcelain)}`);
216
+ }
217
+ }
155
218
  });
156
219
 
157
220
  test('the empty-diff guard is actually WIRED to the decision, not just exporting it', () => {
@@ -171,10 +234,21 @@ test('the empty-diff guard is actually WIRED to the decision, not just exporting
171
234
  'and reaching it must land on the manual-confirm route a no-artifact task needs');
172
235
  });
173
236
 
237
+ test('the call site MEASURES the working tree — the truth table alone cannot prove this', () => {
238
+ // task 1003944, and the reason this assertion is separate from the wiring test
239
+ // above: `porcelain` DEFAULTS to '' — a clean tree. Drop the argument at the call
240
+ // site and every truth-table case above stays green while the real ship goes back
241
+ // to confirming untracked work. The evidence has to be shown to be COLLECTED.
242
+ const src = shipCliSource();
243
+ assert.match(src, /shouldConfirmWithoutGrade\(\{[^}]*porcelain:\s*gitOk\('git status --porcelain'\)/,
244
+ 'the re-grade guard must pass the REAL `git status --porcelain`, not fall back to the clean-tree default');
245
+ });
246
+
174
247
  test('shouldConfirmWithoutGrade: defaults and odd inputs never throw', () => {
175
- assert.equal(shouldConfirmWithoutGrade({ committedFileCount: 0 }), false, 'no args → no declaration');
176
- assert.equal(shouldConfirmWithoutGrade({ committedFileCount: '0', args: ['--allow-empty'] }), true, 'a numeric string still counts as empty');
177
- assert.equal(shouldConfirmWithoutGrade({ committedFileCount: undefined, args: ['--allow-empty'] }), false, 'an unknown count is not an empty one');
248
+ assert.equal(shouldConfirmWithoutGrade({ committedFileCount: 0 }).confirm, false, 'no args → no declaration');
249
+ assert.equal(shouldConfirmWithoutGrade({ committedFileCount: '0', args: ['--allow-empty'] }).confirm, true, 'a numeric string still counts as empty');
250
+ assert.equal(shouldConfirmWithoutGrade({ committedFileCount: undefined, args: ['--allow-empty'] }).confirm, false, 'an unknown count is not an empty one');
251
+ assert.equal(shouldConfirmWithoutGrade({ committedFileCount: 0, porcelain: null, args: ['--allow-empty'] }).confirm, true, 'a null porcelain reads as a clean tree');
178
252
  });
179
253
 
180
254
  console.log('');