@bongos/core 1.19.910 → 1.19.912
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 +62 -37
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +2 -0
- package/clients/bongos-client/index.cjs +2 -0
- package/clients/bongos-client/index.d.ts +3 -0
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/adr/0314-module-protection-is-per-path-not-per-module.md +7 -2
- package/docs/adr/0315-a-plane-with-no-proxy-is-refused-not-given-a-no-op-ingress.md +96 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +62 -2
- package/docs/api-reference.md +3 -2
- package/docs/copy-inventory.md +25 -25
- package/docs/copy-registry.json +29 -29
- package/docs/module-api-changelog.md +5 -1
- package/docs/recipes/self-host.md +1 -1
- package/modules/hall-ui/public/ideas.css +25 -0
- package/modules/hall-ui/public/ideas.html +12 -0
- package/modules/hall-ui/public/ideas.js +115 -2
- package/modules/ideas/landing-preview.js +180 -0
- package/modules/ideas/routes/inbox.js +47 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/api-alias-caller-scan.js +213 -0
- package/scripts/gds/fitness-ratchets.js +31 -0
- package/scripts/gds/provision-net.js +41 -0
- package/scripts/gds/provision.js +11 -3
- package/scripts/hall-preview/server.js +14 -1
- package/src/bongos/module-scope-map.js +43 -5
- package/src/module-api.js +1 -1
- package/tests/api_alias_caller_ratchet.mjs +130 -0
- package/tests/idea_landing_preview.mjs +337 -0
- package/tests/idea_spark_hall.mjs +183 -0
- package/tests/module-scope-map.mjs +87 -3
- package/tests/provision_caddy_wiring.mjs +76 -2
- package/tests/quarantine_canonical.mjs +32 -4
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
// tests/idea_landing_preview.mjs
|
|
2
|
+
//
|
|
3
|
+
// task 1004083 (BV2.R02, goal 1000095) — GRAVITY: the landing verdict read
|
|
4
|
+
// before the write. This drives the REAL `previewLanding` over a fake pool and a
|
|
5
|
+
// fake lifecycle port, so `decideLanding`, `authorizeRoutedLanding` and every
|
|
6
|
+
// branch inside the preview actually run.
|
|
7
|
+
//
|
|
8
|
+
// WHY THIS FILE EXISTS, stated plainly because it was a grader finding: the
|
|
9
|
+
// first cut of this task tested only the hall composer, against a mock of
|
|
10
|
+
// `postInboxPreviewLanding`. That proves the LINE renders what the server says
|
|
11
|
+
// and proves nothing at all about what the server says — the verdict every one
|
|
12
|
+
// of those cases asserted on was written by the test itself. The two halves need
|
|
13
|
+
// two harnesses, and this is the server one.
|
|
14
|
+
//
|
|
15
|
+
// DB-FREE, the tests/capture_goal_routing.mjs way: the collaborators the preview
|
|
16
|
+
// reaches through (`pool`, the `lifecycle` port, `moduleScopeMap`) are all
|
|
17
|
+
// injectable or patchable, and none of them is the thing under test — the matrix
|
|
18
|
+
// is, and the matrix is pure.
|
|
19
|
+
//
|
|
20
|
+
// THE PROPERTY THAT MATTERS MOST IS THE NEGATIVE ONE: previewing must never
|
|
21
|
+
// WRITE. A preview runs on every keystroke of a debounced composer, and the
|
|
22
|
+
// homeless path in the real capture resolves through `ensureMaintenanceGoal`,
|
|
23
|
+
// which is find-or-CREATE. The last case in this file fails if the preview ever
|
|
24
|
+
// reaches for it.
|
|
25
|
+
//
|
|
26
|
+
// Run: node tests/idea_landing_preview.mjs
|
|
27
|
+
|
|
28
|
+
import { strict as assert } from 'node:assert';
|
|
29
|
+
import { createRequire } from 'node:module';
|
|
30
|
+
import { readFileSync } from 'node:fs';
|
|
31
|
+
import { makeRunner } from './helpers.mjs';
|
|
32
|
+
|
|
33
|
+
const require = createRequire(import.meta.url);
|
|
34
|
+
const { test, summary } = makeRunner();
|
|
35
|
+
|
|
36
|
+
process.env.NODE_ENV = 'test';
|
|
37
|
+
|
|
38
|
+
const api = require('../src/module-api');
|
|
39
|
+
const { previewLanding, OUTCOMES } = require('../modules/ideas/landing-preview');
|
|
40
|
+
|
|
41
|
+
// The goal row the preview reads, in the shape its SELECT returns. Defaults are
|
|
42
|
+
// the happy one: a real, open goal on a building version, unprotected scope.
|
|
43
|
+
function goalRow(over = {}) {
|
|
44
|
+
return {
|
|
45
|
+
id: '77', title: 'Idea routing', status: 'open',
|
|
46
|
+
version_id: 'BV2', scope_modules: ['ideas'], version_status: 'building',
|
|
47
|
+
...over,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// A pool that answers one query and COUNTS them, so a case can assert that a
|
|
52
|
+
// branch never reached the database at all.
|
|
53
|
+
function fakePool(rows = []) {
|
|
54
|
+
const calls = [];
|
|
55
|
+
return { calls, query: async (sql, params) => { calls.push({ sql, params }); return { rows }; } };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Patch the two singletons the preview reaches through the doorway, and restore
|
|
59
|
+
// afterwards so cases cannot leak into each other.
|
|
60
|
+
// `version` defaults to a real building version, because "there IS one" is the
|
|
61
|
+
// ordinary world and a case that means the opposite must say so. The first cut
|
|
62
|
+
// of this helper defaulted the lifecycle port to NULL, which made two cases pass
|
|
63
|
+
// for the wrong reason — including the one whose whole subject is the missing
|
|
64
|
+
// version, so it asserted the right answer via the wrong cause.
|
|
65
|
+
async function withApi({ version = 'BV2', protectedScope = false } = {}, fn) {
|
|
66
|
+
const realResolve = api.resolveOptional;
|
|
67
|
+
const realMap = Object.getOwnPropertyDescriptor(api, 'moduleScopeMap');
|
|
68
|
+
const reached = { ensureMaintenanceGoal: 0, currentBuildingVersionId: 0 };
|
|
69
|
+
const lifecycle = { currentBuildingVersionId: async () => { reached.currentBuildingVersionId++; return version; } };
|
|
70
|
+
api.resolveOptional = (name) => (name === 'lifecycle' ? lifecycle : realResolve(name));
|
|
71
|
+
Object.defineProperty(api, 'moduleScopeMap', {
|
|
72
|
+
configurable: true,
|
|
73
|
+
value: { scopeIncludesProtected: () => protectedScope },
|
|
74
|
+
});
|
|
75
|
+
try { return await fn(reached); } finally {
|
|
76
|
+
api.resolveOptional = realResolve;
|
|
77
|
+
if (realMap) Object.defineProperty(api, 'moduleScopeMap', realMap);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// A lifecycle port that records which methods were touched. `ensureMaintenanceGoal`
|
|
82
|
+
// THROWS rather than answering: the preview must never call it, and a method that
|
|
83
|
+
// quietly succeeds would let that regression pass as a green test.
|
|
84
|
+
function fakeLifecycle(versionId, reached) {
|
|
85
|
+
return {
|
|
86
|
+
currentBuildingVersionId: async () => { reached.currentBuildingVersionId++; return versionId; },
|
|
87
|
+
ensureMaintenanceGoal: async () => {
|
|
88
|
+
reached.ensureMaintenanceGoal++;
|
|
89
|
+
throw new Error('previewLanding must never create a goal');
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
// the two lies the composer's picker tells — the reason this task exists
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
await test('a homeless BUG routes, and the preview names whose maintenance goal catches it', async () => {
|
|
99
|
+
await withApi({}, async (reached) => {
|
|
100
|
+
const v = await previewLanding(
|
|
101
|
+
{ goalId: null, kind: 'bug', grade: null, filerRank: 'archon', vector: 'api' },
|
|
102
|
+
{ pool: fakePool() }
|
|
103
|
+
);
|
|
104
|
+
assert.equal(v.outcome, OUTCOMES.ROUTED_HOME, 'the picker says "homeless"; ADR 0262 says otherwise');
|
|
105
|
+
assert.equal(v.status, 'ready', 'a trusted filer\'s bug is claimable at once');
|
|
106
|
+
assert.equal(v.version_id, 'BV2');
|
|
107
|
+
assert.equal(v.goal, null, 'the goal row does not exist yet, so it is not named');
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
await test('the same bug from an UNTRUSTED filer still lands, but at backlog', async () => {
|
|
112
|
+
await withApi({}, async () => {
|
|
113
|
+
const v = await previewLanding(
|
|
114
|
+
{ goalId: null, kind: 'bug', filerRank: 'thetes', vector: 'api' },
|
|
115
|
+
{ pool: fakePool() }
|
|
116
|
+
);
|
|
117
|
+
assert.equal(v.outcome, OUTCOMES.ROUTED_HOME);
|
|
118
|
+
assert.equal(v.status, 'backlog', 'the rank split is the matrix\'s, not re-derived here');
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
await test('a homeless NON-bug floats — the homeless door is bug-only', async () => {
|
|
123
|
+
await withApi({}, async () => {
|
|
124
|
+
const v = await previewLanding({ goalId: null, kind: 'feature', filerRank: 'archon', vector: 'api' }, { pool: fakePool() });
|
|
125
|
+
assert.equal(v.outcome, OUTCOMES.FLOATS);
|
|
126
|
+
assert.equal(v.reason, 'no_goal');
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
await test('a homeless bug from an UNRECOGNISED vector floats — the allowlist holds', async () => {
|
|
131
|
+
await withApi({}, async () => {
|
|
132
|
+
const v = await previewLanding({ goalId: null, kind: 'bug', filerRank: 'archon', vector: 'discord-ideas' }, { pool: fakePool() });
|
|
133
|
+
assert.equal(v.outcome, OUTCOMES.FLOATS, '#ideas files with hints only (ADR 0234)');
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
await test('a NAMED goal on a judgment kind floats, and the goal is not claimed to catch it', async () => {
|
|
138
|
+
await withApi({}, async () => {
|
|
139
|
+
const v = await previewLanding(
|
|
140
|
+
{ goalId: 77, kind: 'decision', filerRank: 'archon', vector: 'api' },
|
|
141
|
+
{ pool: fakePool([goalRow()]) }
|
|
142
|
+
);
|
|
143
|
+
assert.equal(v.outcome, OUTCOMES.FLOATS);
|
|
144
|
+
assert.equal(v.reason, 'judgment_kind', 'the second lie the picker tells');
|
|
145
|
+
assert.equal(v.goal, null);
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
await test('a Full Idea never routes, however confidently a goal is picked', async () => {
|
|
150
|
+
await withApi({}, async () => {
|
|
151
|
+
const v = await previewLanding(
|
|
152
|
+
{ goalId: 77, kind: 'feature', grade: 'full', filerRank: 'archon', vector: 'api' },
|
|
153
|
+
{ pool: fakePool([goalRow()]) }
|
|
154
|
+
);
|
|
155
|
+
assert.equal(v.outcome, OUTCOMES.FLOATS);
|
|
156
|
+
assert.equal(v.reason, 'full_idea_never_routes');
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
// ---------------------------------------------------------------------------
|
|
161
|
+
// a named goal: the happy paths and the wall
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
|
|
164
|
+
await test('a named goal is named back, with the status the matrix chose', async () => {
|
|
165
|
+
await withApi({}, async () => {
|
|
166
|
+
const pool = fakePool([goalRow()]);
|
|
167
|
+
const v = await previewLanding({ goalId: 77, kind: 'bug', filerRank: 'archon', vector: 'api' }, { pool });
|
|
168
|
+
assert.equal(v.outcome, OUTCOMES.ROUTED);
|
|
169
|
+
assert.equal(v.status, 'ready');
|
|
170
|
+
assert.deepEqual(v.goal, { id: '77', title: 'Idea routing' });
|
|
171
|
+
assert.equal(pool.calls.length, 1, 'one read, and it is a SELECT');
|
|
172
|
+
assert.match(pool.calls[0].sql, /^\s*SELECT/i);
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
await test('a non-bug with a home soft-lands at backlog', async () => {
|
|
177
|
+
await withApi({}, async () => {
|
|
178
|
+
const v = await previewLanding({ goalId: 77, kind: 'refactor', filerRank: 'archon', vector: 'api' }, { pool: fakePool([goalRow()]) });
|
|
179
|
+
assert.equal(v.outcome, OUTCOMES.ROUTED);
|
|
180
|
+
assert.equal(v.status, 'backlog');
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
await test('PROTECTED scope + a sub-Metic filer is refused, in words, before the filing is spent', async () => {
|
|
185
|
+
await withApi({ protectedScope: true }, async () => {
|
|
186
|
+
const v = await previewLanding({ goalId: 77, kind: 'bug', filerRank: 'thetes', vector: 'api' }, { pool: fakePool([goalRow()]) });
|
|
187
|
+
assert.equal(v.outcome, OUTCOMES.REFUSED);
|
|
188
|
+
assert.equal(v.refusal.code, 'protected_scope');
|
|
189
|
+
assert.match(v.refusal.reason, /protected territory/);
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
await test('PROTECTED scope + a Metic+ filer is NOT refused — the two are layers, not alternatives', async () => {
|
|
194
|
+
await withApi({ protectedScope: true }, async () => {
|
|
195
|
+
const v = await previewLanding({ goalId: 77, kind: 'bug', filerRank: 'archon', vector: 'api' }, { pool: fakePool([goalRow()]) });
|
|
196
|
+
assert.equal(v.outcome, OUTCOMES.ROUTED);
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
// ---------------------------------------------------------------------------
|
|
201
|
+
// the degraded states — silence, never a guess
|
|
202
|
+
// ---------------------------------------------------------------------------
|
|
203
|
+
|
|
204
|
+
for (const [label, row] of [
|
|
205
|
+
['a goal that is gone', undefined],
|
|
206
|
+
['a goal that is archived', goalRow({ status: 'archived' })],
|
|
207
|
+
['a goal on a version that is not building', goalRow({ version_status: 'shipped' })],
|
|
208
|
+
]) {
|
|
209
|
+
await test(`${label} states NOTHING rather than guessing a sentence`, async () => {
|
|
210
|
+
await withApi({}, async () => {
|
|
211
|
+
const v = await previewLanding({ goalId: 77, kind: 'bug', filerRank: 'archon', vector: 'api' }, { pool: fakePool(row ? [row] : []) });
|
|
212
|
+
assert.equal(v.outcome, null, 'the real capture will name the refusal; the preview does not invent one');
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
await test('no building version means no promise — the homeless path says nothing', async () => {
|
|
218
|
+
await withApi({ version: null }, async (reached) => {
|
|
219
|
+
const v = await previewLanding({ goalId: null, kind: 'bug', filerRank: 'archon', vector: 'api' }, { pool: fakePool() });
|
|
220
|
+
assert.equal(v.outcome, null, 'the real capture fails closed here (NO_BUILDING_VERSION)');
|
|
221
|
+
assert.equal(reached.currentBuildingVersionId, 1, 'and it got there by asking, not by the port being absent');
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
await test('a pool that throws degrades to silence — a composer must never red on a keystroke', async () => {
|
|
226
|
+
await withApi({}, async () => {
|
|
227
|
+
const angry = { query: async () => { throw new Error('connection reset'); } };
|
|
228
|
+
const v = await previewLanding({ goalId: 77, kind: 'bug', filerRank: 'archon', vector: 'api' }, { pool: angry });
|
|
229
|
+
assert.equal(v.outcome, null);
|
|
230
|
+
assert.equal(v.refusal, null);
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
await test('a null input does not throw — the destructuring-defaults trap decideLanding records', async () => {
|
|
235
|
+
await withApi({}, async () => {
|
|
236
|
+
const v = await previewLanding(null, { pool: fakePool() });
|
|
237
|
+
assert.equal(v.outcome, OUTCOMES.FLOATS, 'nothing named, so nothing catches it');
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
// ---------------------------------------------------------------------------
|
|
242
|
+
// THE NEGATIVE PROPERTY — the one that makes this safe to run per keystroke
|
|
243
|
+
// ---------------------------------------------------------------------------
|
|
244
|
+
|
|
245
|
+
await test('previewing a homeless bug NEVER creates the maintenance goal', async () => {
|
|
246
|
+
const reached = { ensureMaintenanceGoal: 0, currentBuildingVersionId: 0 };
|
|
247
|
+
const realResolve = api.resolveOptional;
|
|
248
|
+
api.resolveOptional = (n) => (n === 'lifecycle' ? fakeLifecycle('BV2', reached) : realResolve(n));
|
|
249
|
+
try {
|
|
250
|
+
const v = await previewLanding({ goalId: null, kind: 'bug', filerRank: 'archon', vector: 'api' }, { pool: fakePool() });
|
|
251
|
+
assert.equal(v.outcome, OUTCOMES.ROUTED_HOME, 'it still answers');
|
|
252
|
+
assert.equal(reached.currentBuildingVersionId, 1, 'by READING the version');
|
|
253
|
+
assert.equal(reached.ensureMaintenanceGoal, 0,
|
|
254
|
+
'ensureMaintenanceGoal is find-or-CREATE; a preview that called it would mint a goal per keystroke');
|
|
255
|
+
} finally { api.resolveOptional = realResolve; }
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
await test('no branch writes — every pool call in this file was a SELECT', async () => {
|
|
259
|
+
await withApi({}, async () => {
|
|
260
|
+
const pool = fakePool([goalRow()]);
|
|
261
|
+
for (const kind of ['bug', 'feature', 'decision', 'criterion-proposal', 'refactor']) {
|
|
262
|
+
await previewLanding({ goalId: 77, kind, filerRank: 'archon', vector: 'api' }, { pool });
|
|
263
|
+
}
|
|
264
|
+
assert.ok(pool.calls.length > 0, 'the goal read did happen');
|
|
265
|
+
for (const c of pool.calls) {
|
|
266
|
+
assert.match(c.sql, /^\s*SELECT/i, `a preview issued a non-SELECT: ${c.sql.slice(0, 40)}`);
|
|
267
|
+
assert.doesNotMatch(c.sql, /FOR SHARE|FOR UPDATE/i, 'a preview holds no lock — it races nothing');
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
// ---------------------------------------------------------------------------
|
|
273
|
+
// THE ROUTE — asserted in SOURCE, the tests/filing_abuse_matrix.mjs idiom
|
|
274
|
+
//
|
|
275
|
+
// Those properties are guarantees about the CALL SITE, not about a return value:
|
|
276
|
+
// "the rank is the authenticated one" cannot be observed from the outside,
|
|
277
|
+
// because a route that read it from the body would answer identically to one
|
|
278
|
+
// that did not — for a caller who sent the true rank. So they are asserted where
|
|
279
|
+
// they live, exactly as F3 and F18 of the filing abuse matrix already do for
|
|
280
|
+
// POST /inbox. This file is the ideas module's second reader of that source.
|
|
281
|
+
// ---------------------------------------------------------------------------
|
|
282
|
+
|
|
283
|
+
const routeSrc = readFileSync(new URL('../modules/ideas/routes/inbox.js', import.meta.url), 'utf8');
|
|
284
|
+
const previewBlock = routeSrc.slice(routeSrc.indexOf("router.post('/inbox/preview-landing'"));
|
|
285
|
+
|
|
286
|
+
await test('[route] the preview exists, is builder-gated, and declares its validator', () => {
|
|
287
|
+
assert.ok(previewBlock.length > 0, 'POST /inbox/preview-landing is declared');
|
|
288
|
+
assert.match(previewBlock.slice(0, 400), /api\.requireBuilder/, 'no anonymous caller');
|
|
289
|
+
assert.match(previewBlock.slice(0, 900), /validateOrRespond\(req, res, \{/, 'the input SHAPE is validated');
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
await test('[route] rank and vector come from AUTH and a literal — never from the body', () => {
|
|
293
|
+
const head = previewBlock.slice(0, 1600);
|
|
294
|
+
assert.match(head, /filerRank:\s*req\.builder\?\.rank/,
|
|
295
|
+
'a filer must not be able to preview a landing their rank does not grant');
|
|
296
|
+
assert.match(head, /vector:\s*'api'/, 'the vector is the door, hardcoded');
|
|
297
|
+
assert.ok(!/filerRank:\s*body\.|vector:\s*body\.|vector:\s*req\.body/.test(head),
|
|
298
|
+
'neither may be read from the request body');
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
await test('[route] the verdict is passed through, not re-shaped into a second vocabulary', () => {
|
|
302
|
+
assert.match(previewBlock.slice(0, 1600), /const verdict = await previewLanding\(/);
|
|
303
|
+
assert.match(previewBlock.slice(0, 1600), /res\.json\(verdict\)/,
|
|
304
|
+
'a route that rebuilt the object by hand would be a second place the shape can drift');
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
await test('[route] it is declared BEFORE the :id routes that would otherwise shadow it', () => {
|
|
308
|
+
// The file header calls this ordering load-bearing. A literal path declared
|
|
309
|
+
// after `/inbox/:id` binds `:id = 'preview-landing'`, parseId 400s, and the
|
|
310
|
+
// whole feature disappears with no error naming it.
|
|
311
|
+
const mine = routeSrc.indexOf("'/inbox/preview-landing'");
|
|
312
|
+
const byId = routeSrc.indexOf("'/inbox/:id(\\\\d+)'");
|
|
313
|
+
assert.ok(mine > 0, 'the route is there');
|
|
314
|
+
assert.ok(byId === -1 || mine < byId, 'and it is declared above the id-bearing routes');
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
await test('[route] the preview is not wired into the CAPTURE path — it is a read beside it', () => {
|
|
318
|
+
// The whole claim of "no new routing" is that the matrix has ONE caller that
|
|
319
|
+
// can write. previewLanding must never appear inside POST /inbox.
|
|
320
|
+
const capture = routeSrc.slice(routeSrc.indexOf("router.post('/inbox',"), routeSrc.indexOf("router.get('/inbox',"));
|
|
321
|
+
assert.ok(!/previewLanding/.test(capture), 'the capture does not consult the preview');
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
// Probes run, and these are the MEASURED counts:
|
|
325
|
+
// make the homeless branch call ensureMaintenanceGoal -> 4 red (incl. the negative property)
|
|
326
|
+
// let authorizeRoutedLanding's refusal fall through -> 1 red (protected-scope)
|
|
327
|
+
// drop the grade check from decideLanding's early exit -> 1 red (full-idea)
|
|
328
|
+
// fabricate the goal instead of reading it -> 1 red (goal-is-gone)
|
|
329
|
+
//
|
|
330
|
+
// Writing this file is what found two defects the composer suite could not:
|
|
331
|
+
// `previewLanding(null)` THREW (a default in a parameter list fires on undefined
|
|
332
|
+
// and not on null — the trap routing.js's own comment records, reproduced one
|
|
333
|
+
// file away from it), and this harness's first `withApi` defaulted the lifecycle
|
|
334
|
+
// port to null, which made two cases pass for the wrong reason — including the
|
|
335
|
+
// one whose entire subject is the missing version.
|
|
336
|
+
|
|
337
|
+
summary('idea_landing_preview');
|
|
@@ -60,6 +60,11 @@ const GOALS = [
|
|
|
60
60
|
function boot({
|
|
61
61
|
search = '', sparks = QUEUE, count = null, idea = SPARK, ideaOk = true, store = {},
|
|
62
62
|
versions = VERSIONS, goals = GOALS, suggestions = [],
|
|
63
|
+
// GRAVITY (task 1004083 / spec R02). The landing verdict the composer draws
|
|
64
|
+
// its one line from. `null` means the read FAILED — distinct from a verdict of
|
|
65
|
+
// `{ outcome: null }`, which is the server saying "nothing to state yet"; the
|
|
66
|
+
// line must go quiet for both, and there is a case below for each.
|
|
67
|
+
landing = undefined,
|
|
63
68
|
} = {}) {
|
|
64
69
|
const els = new Map();
|
|
65
70
|
const makeEl = () => ({
|
|
@@ -108,6 +113,21 @@ function boot({
|
|
|
108
113
|
return { status: 201, ok: true, data: { idea: { id: '900', grade: 'full', completeness_score: 80 } } };
|
|
109
114
|
},
|
|
110
115
|
postInboxIdDevelop: async (a) => { calls.push(['develop', a]); return { status: 200, ok: true, data: { idea: { ...idea, grade: 'full', completeness_score: 80, ideator_credit: 5 } } }; },
|
|
116
|
+
// task 1004083 (spec R02) — the landing preview. The DEFAULT mirrors the
|
|
117
|
+
// real matrix for the fixtures this file already files with: a goal named
|
|
118
|
+
// ⇒ it lands there, no goal ⇒ it floats. A test that cares about a
|
|
119
|
+
// particular verdict passes `landing` and overrides the lot.
|
|
120
|
+
postInboxPreviewLanding: async (a) => {
|
|
121
|
+
calls.push(['preview', a]);
|
|
122
|
+
if (landing === null) return { status: 500, ok: false, data: null };
|
|
123
|
+
if (landing !== undefined) return { status: 200, ok: true, data: landing };
|
|
124
|
+
const gid = a.body && a.body.goal_id;
|
|
125
|
+
if (gid != null) {
|
|
126
|
+
const g = goals.find((x) => String(x.id) === String(gid));
|
|
127
|
+
return { status: 200, ok: true, data: { outcome: 'routed', reason: 'bug_trusted_filer', status: 'ready', goal: { id: String(gid), title: g ? g.title : `goal ${gid}` }, version_id: 'BV1', refusal: null } };
|
|
128
|
+
}
|
|
129
|
+
return { status: 200, ok: true, data: { outcome: 'floats', reason: 'no_goal', status: null, goal: null, version_id: null, refusal: null } };
|
|
130
|
+
},
|
|
111
131
|
},
|
|
112
132
|
// task 1003069 (R08) — the quick-filing goal picker's reads: the building
|
|
113
133
|
// versions (to scope the suggester) and the open goal list (the picker's
|
|
@@ -515,5 +535,168 @@ await test('storage that refuses never takes the composer down, and says it is n
|
|
|
515
535
|
// the element. Without the markup pin this file would have certified a page
|
|
516
536
|
// that renders nothing.
|
|
517
537
|
|
|
538
|
+
// ---------------------------------------------------------------------------
|
|
539
|
+
// GRAVITY — which goal will catch this thought, said before it is released
|
|
540
|
+
// (task 1004083 / spec R02)
|
|
541
|
+
//
|
|
542
|
+
// THE DEFECT THESE PIN is not a missing control: #fq-goal is a visible select.
|
|
543
|
+
// It is that the select's own labels are UNTRUE in two directions — "No goal
|
|
544
|
+
// (homeless)" for a bug (ADR 0262 routes it to the maintenance goal) and a named
|
|
545
|
+
// goal for a judgment kind (routing.js NEVER_ROUTE_KINDS). So the cases that
|
|
546
|
+
// matter most here are the two where the LINE AND THE PICKER DISAGREE.
|
|
547
|
+
//
|
|
548
|
+
// The same stub caveat the receipts block above records applies in full: the DOM
|
|
549
|
+
// fake manufactures an element for any id, so the markup pin is load-bearing and
|
|
550
|
+
// every other case below would stay green without it.
|
|
551
|
+
// ---------------------------------------------------------------------------
|
|
552
|
+
|
|
553
|
+
await test('ideas.html actually declares the gravity line (the stub would fake it otherwise)', async () => {
|
|
554
|
+
const html = fs.readFileSync(path.join(HALL, 'ideas.html'), 'utf8');
|
|
555
|
+
assert.match(html, /id="fq-gravity"/, 'the gravity line is in the real markup');
|
|
556
|
+
assert.match(html, /id="fq-gravity"[^>]*hidden/, 'and it starts hidden — nothing true to say yet');
|
|
557
|
+
assert.match(html, /id="fq-gravity"[^>]*aria-live="polite"/, 'and it is announced, not just painted');
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
await test('picking a goal says which goal will catch it, and that it is claimable', async () => {
|
|
561
|
+
const r = boot();
|
|
562
|
+
await settle(); await settle();
|
|
563
|
+
r.el('fq-title').value = 'the widget is broken';
|
|
564
|
+
r.el('fq-goal').value = '77';
|
|
565
|
+
r.el('fq-goal')._handlers.change();
|
|
566
|
+
await settle(); await settle();
|
|
567
|
+
assert.equal(r.el('fq-gravity').hidden, false, 'the line is shown');
|
|
568
|
+
assert.match(r.el('fq-gravity').textContent, /Goal Seventy Seven/, 'and it names the goal');
|
|
569
|
+
assert.match(r.el('fq-gravity').textContent, /anyone can claim/, 'and says the task is claimable');
|
|
570
|
+
assert.equal(r.el('fq-gravity').dataset.tone, 'lands');
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
await test('a goal that only lands at backlog says a nod is still needed', async () => {
|
|
574
|
+
const r = boot({ landing: { outcome: 'routed', reason: 'bug_awaiting_nod', status: 'backlog', goal: { id: '77', title: 'Goal Seventy Seven' }, version_id: 'BV1', refusal: null } });
|
|
575
|
+
await settle(); await settle();
|
|
576
|
+
r.el('fq-title').value = 'the widget is broken';
|
|
577
|
+
r.el('fq-title')._handlers.input();
|
|
578
|
+
await new Promise((res) => setTimeout(res, 320));
|
|
579
|
+
await settle();
|
|
580
|
+
assert.match(r.el('fq-gravity').textContent, /waiting for a nod/, 'the two routed outcomes are not conflated');
|
|
581
|
+
assert.equal(r.el('fq-gravity').dataset.tone, 'lands', 'it still lands — the tone is about having a home, not about being claimable');
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
await test('a homeless BUG is told it lands anyway — the picker says "homeless" and that is false', async () => {
|
|
585
|
+
// ADR 0262: decideLanding routes a goal-less kind='bug' from the api vector
|
|
586
|
+
// into the version's maintenance goal. The select still reads "No goal
|
|
587
|
+
// (homeless)". This line is the correction, and it is the whole reason the
|
|
588
|
+
// verdict is read from the server instead of inferred from the select's value.
|
|
589
|
+
const r = boot({ landing: { outcome: 'routed_home', reason: 'bug_homeless_trusted_filer', status: 'ready', goal: null, version_id: 'BV1', refusal: null } });
|
|
590
|
+
await settle(); await settle();
|
|
591
|
+
r.el('fq-title').value = 'the widget is broken';
|
|
592
|
+
r.el('fq-title')._handlers.input();
|
|
593
|
+
await new Promise((res) => setTimeout(res, 320));
|
|
594
|
+
await settle();
|
|
595
|
+
const said = r.el('fq-gravity').textContent;
|
|
596
|
+
assert.match(said, /A bug always lands/, 'it contradicts the picker rather than echoing it');
|
|
597
|
+
assert.match(said, /BV1/, 'and names whose maintenance goal catches it');
|
|
598
|
+
assert.equal(r.el('fq-gravity').dataset.tone, 'lands');
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
await test('a named goal that the matrix DECLINES says so instead of implying the goal caught it', async () => {
|
|
602
|
+
// The second lie: a criterion-proposal or a decision never routes, however
|
|
603
|
+
// confidently the select shows a goal beside it.
|
|
604
|
+
const r = boot({ landing: { outcome: 'floats', reason: 'judgment_kind', status: null, goal: { id: '77', title: 'Goal Seventy Seven' }, version_id: 'BV1', refusal: null } });
|
|
605
|
+
await settle(); await settle();
|
|
606
|
+
r.el('fq-title').value = 'should we split the hall';
|
|
607
|
+
r.el('fq-title')._handlers.input();
|
|
608
|
+
await new Promise((res) => setTimeout(res, 320));
|
|
609
|
+
await settle();
|
|
610
|
+
const said = r.el('fq-gravity').textContent;
|
|
611
|
+
assert.match(said, /will not catch this/, 'the goal is named as NOT catching it');
|
|
612
|
+
assert.match(said, /inbox/, 'and the real destination is stated');
|
|
613
|
+
assert.equal(r.el('fq-gravity').dataset.tone, 'floats');
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
await test('letting it float is stated as an outcome, not left as an empty default', async () => {
|
|
617
|
+
const r = boot();
|
|
618
|
+
await settle(); await settle();
|
|
619
|
+
r.el('fq-title').value = 'a loose thought';
|
|
620
|
+
r.el('fq-title')._handlers.input();
|
|
621
|
+
await new Promise((res) => setTimeout(res, 320));
|
|
622
|
+
await settle();
|
|
623
|
+
assert.equal(r.el('fq-gravity').hidden, false);
|
|
624
|
+
assert.match(r.el('fq-gravity').textContent, /floats in the inbox/);
|
|
625
|
+
assert.equal(r.el('fq-gravity').dataset.tone, 'floats', 'floating is neutral — it is a legal choice, not a warning');
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
await test('a refusal is shown in the filer\'s own words before they spend the filing', async () => {
|
|
629
|
+
const r = boot({ landing: { outcome: 'refused', reason: 'soft_landing', status: null, goal: { id: '77', title: 'Goal Seventy Seven' }, version_id: 'BV1', refusal: { code: 'protected_scope', reason: 'This goal reaches protected territory, so a filer below Metic cannot route work into it.' } } });
|
|
630
|
+
await settle(); await settle();
|
|
631
|
+
r.el('fq-title').value = 'touch the deploy core';
|
|
632
|
+
r.el('fq-title')._handlers.input();
|
|
633
|
+
await new Promise((res) => setTimeout(res, 320));
|
|
634
|
+
await settle();
|
|
635
|
+
assert.match(r.el('fq-gravity').textContent, /protected territory/, 'the server\'s reason is passed through');
|
|
636
|
+
assert.equal(r.el('fq-gravity').dataset.tone, 'refused');
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
await test('an empty title states nothing, and a failed read states nothing — never a guess', async () => {
|
|
640
|
+
const quiet = boot();
|
|
641
|
+
await settle(); await settle();
|
|
642
|
+
quiet.el('fq-title').value = ' ';
|
|
643
|
+
quiet.el('fq-title')._handlers.input();
|
|
644
|
+
await new Promise((res) => setTimeout(res, 320));
|
|
645
|
+
await settle();
|
|
646
|
+
assert.equal(quiet.el('fq-gravity').hidden, true, 'nothing to file, nothing to say');
|
|
647
|
+
|
|
648
|
+
const down = boot({ landing: null });
|
|
649
|
+
await settle(); await settle();
|
|
650
|
+
down.el('fq-title').value = 'the widget is broken';
|
|
651
|
+
down.el('fq-title')._handlers.input();
|
|
652
|
+
await new Promise((res) => setTimeout(res, 320));
|
|
653
|
+
await settle();
|
|
654
|
+
assert.equal(down.el('fq-gravity').hidden, true, 'a preview outage degrades to silence, not to a wrong sentence');
|
|
655
|
+
assert.equal(down.el('fq-err').textContent, '', 'and never to an error under a half-typed thought');
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
await test('filing clears the line — gravity describes a draft, and the draft is gone', async () => {
|
|
659
|
+
const r = boot();
|
|
660
|
+
await settle(); await settle();
|
|
661
|
+
r.el('fq-title').value = 'the widget is broken';
|
|
662
|
+
r.el('fq-goal').value = '77';
|
|
663
|
+
r.el('fq-goal')._handlers.change();
|
|
664
|
+
await settle(); await settle();
|
|
665
|
+
// The TEXT is what proves it was shown, not `hidden`. The DOM stub starts an
|
|
666
|
+
// element at hidden:false, so asserting only on that passes when the line was
|
|
667
|
+
// never rendered at all — the same class of vacuous pass the markup pin above
|
|
668
|
+
// exists for, caught by reverting the goal-change redraw and watching this
|
|
669
|
+
// case stay green.
|
|
670
|
+
assert.match(r.el('fq-gravity').textContent, /Goal Seventy Seven/, 'shown while composing');
|
|
671
|
+
assert.equal(r.el('fq-gravity').hidden, false);
|
|
672
|
+
await r.submitQuick();
|
|
673
|
+
await settle();
|
|
674
|
+
assert.equal(r.el('fq-gravity').hidden, true, 'and gone once it is filed');
|
|
675
|
+
assert.equal(r.el('fq-gravity').textContent, '', 'and the sentence with it');
|
|
676
|
+
assert.equal(r.el('fq-receipts').hidden, false, 'the RECEIPT is what survives instead (task 1004082)');
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
// Every guard above was reverted and each redded its named case (RUN, and these
|
|
680
|
+
// are the measured counts, not the predicted ones):
|
|
681
|
+
// drop renderGravity from the goal-change handler -> 2 red (named-goal + filing-clears)
|
|
682
|
+
// infer from the select instead of the verdict -> 4 red (both lies, + backlog + refusal)
|
|
683
|
+
// drop the seq guard and paint every answer -> 0 red <-- see below
|
|
684
|
+
// rename the id in ideas.html -> 1 red (the markup pin ONLY)
|
|
685
|
+
//
|
|
686
|
+
// THE FIRST LINE COST A TEST FIX, and it is the reason to run these rather than
|
|
687
|
+
// reason about them. That probe first redded only ONE case: "filing clears the
|
|
688
|
+
// line" opened by asserting `hidden === false`, and the DOM stub starts every
|
|
689
|
+
// element at `hidden: false` — so the assertion passed while the line was never
|
|
690
|
+
// rendered at all. It asserts on the TEXT now. A stub's defaults can agree with
|
|
691
|
+
// a broken page, which is the same lesson the markup pin below teaches twice.
|
|
692
|
+
//
|
|
693
|
+
// THE THIRD LINE IS AN HONEST GAP, recorded rather than papered over. The
|
|
694
|
+
// out-of-order guard in refreshGravity is not covered: the fake resolves
|
|
695
|
+
// synchronously, so two previews cannot interleave here and a test that removed
|
|
696
|
+
// the guard would still pass. Pinning it needs a fake that delays per call,
|
|
697
|
+
// which is a different harness from this one. Filed as task 1004108 rather than
|
|
698
|
+
// claimed as tested — a green test over an unreachable branch would be a false
|
|
699
|
+
// claim of coverage, which is the failure this file's own note above warns about.
|
|
700
|
+
|
|
518
701
|
console.log(`\nidea_spark_hall: ${passed} passed, ${failed} failed`);
|
|
519
702
|
if (failed > 0) process.exit(1);
|
|
@@ -12,6 +12,9 @@ const { PROTECTED_GLOBS } = require(path.join(ROOT, 'src', 'bongos', 'permission
|
|
|
12
12
|
// the REAL consumer of the roster — asserted against directly so a wall assertion
|
|
13
13
|
// here can never diverge from what the live goal-task-create gate computes.
|
|
14
14
|
const gsc = require(path.join(ROOT, 'src', 'bongos', 'goal-scope-check.js'));
|
|
15
|
+
// the OLD protection rule, recomputed in-test so the shrink-only property below
|
|
16
|
+
// is proved against real globs rather than a copied literal (ADR 0314).
|
|
17
|
+
const { touchesOverlap } = require(path.join(ROOT, 'src', 'bongos', 'path-match.js'));
|
|
15
18
|
|
|
16
19
|
test('roster covers the decided ~16 modules + kernel, each with non-empty globs', () => {
|
|
17
20
|
const keys = scope.moduleKeys();
|
|
@@ -66,11 +69,21 @@ test('`protected` is DERIVED from the imported PROTECTED_GLOBS, never hand-set',
|
|
|
66
69
|
//
|
|
67
70
|
// So the set is spelled out. A module ARRIVING here is a deliberate line in a
|
|
68
71
|
// diff; a module LEAVING is the one that must never happen quietly.
|
|
72
|
+
// NARROWED TWELVE -> NINE BY ADR 0314 (task 1004109). `onboarding`, `autonomy`
|
|
73
|
+
// and `copy-desk` left the set. Each derived protected on the strength of exactly
|
|
74
|
+
// one CLI it owns that sits on a protected path — setup.js, conductor-dispatch.js,
|
|
75
|
+
// copy-inventory.js — and owning the command is not owning the authority. Nothing
|
|
76
|
+
// was unprotected to achieve it: the globs still belong to those modules, the
|
|
77
|
+
// paths are still in PROTECTED_GLOBS, and a sub-Metic builder touching any of the
|
|
78
|
+
// three is still refused by the path wall (asserted below). What changed is who
|
|
79
|
+
// may CREATE a goal whose territory happens to include them.
|
|
80
|
+
//
|
|
81
|
+
// `builder-settings` STAYS, deliberately: its overlap is src/bongos/routes/me.js
|
|
82
|
+
// caught by the blanket src/bongos/routes/ prefix, not a command, so narrowing it
|
|
83
|
+
// needs the me.js strangler move or a blanket-glob change — out of scope here.
|
|
69
84
|
const PROTECTED_MODULES = [
|
|
70
85
|
'agents',
|
|
71
|
-
'autonomy',
|
|
72
86
|
'builder-settings',
|
|
73
|
-
'copy-desk',
|
|
74
87
|
'dev-box',
|
|
75
88
|
'devsecops',
|
|
76
89
|
'government',
|
|
@@ -78,7 +91,6 @@ const PROTECTED_MODULES = [
|
|
|
78
91
|
'kernel',
|
|
79
92
|
'lifecycle',
|
|
80
93
|
'methodology',
|
|
81
|
-
'onboarding',
|
|
82
94
|
];
|
|
83
95
|
|
|
84
96
|
test('the protected module set is exactly this, by name', () => {
|
|
@@ -133,6 +145,78 @@ test('isProtected actually consults PROTECTED_GLOBS (overlap, prefix-aware both
|
|
|
133
145
|
assert.equal(scope.isProtected([]), false, 'empty scope is not protected');
|
|
134
146
|
});
|
|
135
147
|
|
|
148
|
+
// ADR 0314 — A PROTECTED CLI ENTRYPOINT NO LONGER PROTECTS ITS OWNING MODULE.
|
|
149
|
+
//
|
|
150
|
+
// These three are the whole reason the ADR exists: each derived protected on the
|
|
151
|
+
// strength of exactly one command it owns that happens to sit on a protected
|
|
152
|
+
// path. Owning the command is not owning the authority. They are asserted by
|
|
153
|
+
// name rather than by count because a count passes on the wrong three.
|
|
154
|
+
const COMMAND_ONLY_MODULES = [
|
|
155
|
+
['onboarding', 'scripts/gds/setup.js'],
|
|
156
|
+
['autonomy', 'scripts/gds/conductor-dispatch.js'],
|
|
157
|
+
['copy-desk', 'scripts/gds/copy-inventory.js'],
|
|
158
|
+
];
|
|
159
|
+
|
|
160
|
+
test('a module protected ONLY by a CLI entrypoint it owns now derives non-protected', () => {
|
|
161
|
+
for (const [mod, cli] of COMMAND_ONLY_MODULES) {
|
|
162
|
+
const e = scope.scopeForModule(mod);
|
|
163
|
+
assert.ok(e, `fixture assumption: ${mod} is a roster key`);
|
|
164
|
+
assert.ok(e.globs.includes(cli), `fixture assumption: ${mod} still OWNS ${cli}`);
|
|
165
|
+
assert.ok(PROTECTED_GLOBS.includes(cli), `fixture assumption: ${cli} is still a protected surface`);
|
|
166
|
+
// The glob was not deleted and the path was not unprotected — only the
|
|
167
|
+
// question changed. That distinction is the ADR, so assert all three.
|
|
168
|
+
assert.equal(e.protected, false, `${mod} must no longer derive protected (ADR 0314)`);
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test('...and creating a goal scoped to one is therefore no longer Archon-only', () => {
|
|
173
|
+
// The consequence the ADR was filed for: onboarding was Archon-mediated-join
|
|
174
|
+
// for a Metic because of setup.js. This is the assertion that would catch a
|
|
175
|
+
// revert of the behaviour while the name-pin above still passed.
|
|
176
|
+
for (const [mod] of COMMAND_ONLY_MODULES) {
|
|
177
|
+
assert.equal(scope.scopeIncludesProtected([mod]), false, `a goal scoped to ${mod} must not require Archon`);
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test('protected TERRITORY still protects — a directory, and a non-command file', () => {
|
|
182
|
+
// The other side of the rule. If this ever goes false the ADR has been
|
|
183
|
+
// over-applied and the trust core is scopeable by anyone.
|
|
184
|
+
assert.equal(scope.isProtected(['migrations/']), true, 'a protected directory is territory');
|
|
185
|
+
assert.equal(scope.isProtected(['modules/government/']), true, 'a module namespace is territory');
|
|
186
|
+
assert.equal(scope.isProtected(['server.js']), true, 'a protected non-command file is territory');
|
|
187
|
+
assert.equal(scope.isProtected(['CLAUDE.md']), true, 'a protected doc is territory');
|
|
188
|
+
// A DIRECTORY under scripts/ is territory, not a command — the boundary the
|
|
189
|
+
// path test has to get right, since both start with 'scripts/'.
|
|
190
|
+
assert.ok(PROTECTED_GLOBS.includes('scripts/deploy/'), 'fixture assumption: scripts/deploy/ is protected');
|
|
191
|
+
assert.equal(scope.isProtected(['scripts/deploy/']), true, 'a protected directory under scripts/ is territory');
|
|
192
|
+
assert.ok(PROTECTED_GLOBS.includes('scripts/gds/hooks/'), 'fixture assumption: scripts/gds/hooks/ is protected');
|
|
193
|
+
assert.equal(scope.isProtected(['scripts/gds/hooks/']), true, 'scripts/gds/hooks/ is a directory, not a command');
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test('a lone protected CLI computes non-protected, but a CLI PLUS territory does not', () => {
|
|
197
|
+
assert.equal(scope.isProtected(['scripts/gds/setup.js']), false, 'a lone protected CLI does not protect');
|
|
198
|
+
assert.equal(
|
|
199
|
+
scope.isProtected(['scripts/gds/setup.js', 'migrations/']), true,
|
|
200
|
+
'one command must not mask owned territory — the mixed case is still protected',
|
|
201
|
+
);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test('ADR 0314 can only SHRINK the protected set, never grow it', () => {
|
|
205
|
+
// The safety property that makes this a narrowing and not a re-derivation: the
|
|
206
|
+
// new rule filters a SUBSET of the globs the old rule accepted, so no module
|
|
207
|
+
// can arrive in the protected set that was not already in it. Computed here
|
|
208
|
+
// from the OLD rule rather than from a literal, so it keeps holding as the
|
|
209
|
+
// roster changes.
|
|
210
|
+
const oldRule = (globs) => touchesOverlap(globs || [], PROTECTED_GLOBS);
|
|
211
|
+
for (const k of scope.moduleKeys()) {
|
|
212
|
+
const globs = scope.scopeForModule(k).globs;
|
|
213
|
+
if (scope.isProtected(globs)) {
|
|
214
|
+
assert.equal(oldRule(globs), true, `${k} is newly protected — the rule grew the set, which it must never do`);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
|
|
136
220
|
test('globsForModules flattens a scope set; unknown keys contribute nothing', () => {
|
|
137
221
|
const g = scope.globsForModules(['ideas']);
|
|
138
222
|
assert.ok(g.includes('modules/ideas/'), 'ideas owns its module directory (carved BV1.R78)');
|