@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.
- package/.bongos-core.json +204 -79
- 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 +5 -1
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/adr/0083-modular-architecture-strangler-build-plan.md +1 -0
- package/docs/adr/0177-goal-membership-kind.md +26 -2
- package/docs/adr/0181-teardown-state-gate-and-address-retention.md +2 -0
- package/docs/adr/0285-a-shared-box-holds-about-twelve-projects-per-gb-and-memory-is-the-wall.md +1 -1
- package/docs/adr/0288-the-goal-wall-has-a-shared-floor-for-paths-no-module-owns.md +33 -0
- package/docs/adr/0289-an-unconsumed-contribution-is-a-ci-failure-not-a-boot-refusal.md +33 -0
- package/docs/adr/0290-disconnect-is-the-owner-initiated-exception-to-address-retention.md +40 -0
- package/docs/adr/README.md +3 -0
- package/docs/api/openapi.json +102 -11
- package/docs/api-reference.md +5 -4
- package/docs/copy-inventory.md +205 -190
- package/docs/copy-registry.json +435 -269
- package/docs/file-map.md +1 -0
- package/docs/module-api-changelog.md +4 -0
- package/docs/modules-contract.md +1 -1
- package/modules/builder-settings/render-prefs.js +281 -33
- package/modules/builder-settings/routes/settings.js +20 -12
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +2 -0
- package/modules/dev-box/module.json +4 -0
- package/modules/hall-ui/public/settings-render.js +138 -0
- package/modules/hall-ui/public/settings.html +16 -9
- package/modules/hall-ui/public/settings.js +3 -70
- package/modules/lifecycle/db-tasks.js +7 -1
- package/modules/lifecycle/dead-deps.js +10 -2
- package/modules/lifecycle/ship-card.js +49 -13
- package/modules/lifecycle/task-list-limits.js +44 -0
- package/modules/platform-identity/migrations/platform_identity_021_drop_legacy_role.sql +94 -0
- package/modules/platform-identity/platform-identity.js +8 -19
- package/modules/platform-identity/project-catalog.js +24 -0
- package/modules/platform-identity/routes/sso.js +11 -14
- package/modules/platform-identity/tests/platform-identity.mjs +7 -9
- package/modules/provisioning/capacity-gate.js +54 -0
- package/modules/provisioning/catalog-bridge.js +2 -0
- package/modules/provisioning/disconnect.js +35 -0
- package/modules/provisioning/migrations/provisioning_016_disconnect.sql +21 -0
- package/modules/provisioning/module.json +1 -1
- package/modules/provisioning/provisioning.js +3 -3
- package/modules/provisioning/routes/disconnect.js +63 -0
- package/modules/provisioning/routes/provisioning.js +6 -6
- package/modules/public-landing/public/projects.html +116 -11
- package/modules/public-landing/public/projects.probes.json +3 -0
- package/modules/ui-design/kit/serve.js +3 -2
- package/modules/ui-design/module.json +1 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/address-park-sweep.sh +78 -0
- package/scripts/gds/audit-deps.js +38 -4
- package/scripts/gds/cli-lib.js +37 -0
- package/scripts/gds/fitness-migration-contraction.js +50 -0
- package/scripts/gds/fitness.js +18 -0
- package/scripts/gds/land-watch.js +8 -2
- package/scripts/gds/provision-config.js +4 -4
- package/scripts/gds/provision-disconnect.js +90 -0
- package/scripts/gds/provision-teardown.js +108 -0
- package/scripts/gds/provision.js +6 -65
- package/scripts/gds/seam-wiring-guard.js +273 -0
- package/scripts/gds/ship-finish.js +17 -10
- package/scripts/gds/ship-flow.js +28 -6
- package/scripts/gds/ship-resume.js +32 -4
- package/scripts/gds/start.js +13 -7
- package/src/bongos/auth-admission.js +7 -8
- package/src/bongos/auth-config.js +3 -2
- package/src/bongos/goal-scope-check.js +23 -8
- package/src/bongos/module-scope-map.js +40 -0
- package/src/module-api.js +1 -1
- package/src/module-loader/manifest-schema.js +79 -1
- package/tests/catalog_only_client.mjs +8 -5
- package/tests/fitness.mjs +22 -0
- package/tests/goal_scope_check.mjs +63 -1
- package/tests/hall_settings_world.mjs +57 -1
- package/tests/hub_disconnect_ui.mjs +105 -0
- package/tests/land_watch.mjs +44 -0
- package/tests/membership_kind_lockstep.mjs +86 -27
- package/tests/module-scope-map.mjs +39 -0
- package/tests/module_exports_unique_keys.mjs +70 -0
- package/tests/module_manifest.mjs +85 -0
- package/tests/project_catalog_forget.mjs +83 -0
- package/tests/projects_hub.mjs +10 -4
- package/tests/projects_hub_pre_uat.mjs +5 -3
- package/tests/provision_disconnect.mjs +231 -0
- package/tests/provision_teardown_address.mjs +230 -0
- package/tests/provisioning_capacity_gate.mjs +192 -0
- package/tests/provisioning_disconnect.mjs +231 -0
- package/tests/provisioning_restart.mjs +5 -5
- package/tests/public_landing_projects.mjs +4 -3
- package/tests/render_prefs.mjs +152 -0
- package/tests/seam_wiring_guard.mjs +234 -0
- package/tests/ship_card.mjs +67 -1
- package/tests/ship_resume.mjs +86 -12
- package/tests/sso_checkin_legacy_field_gone.mjs +246 -0
- package/tests/task_list_paging.mjs +178 -0
package/src/module-api.js
CHANGED
|
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
71
71
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
72
72
|
// the entry to that file. Look for a version's history there, not here.
|
|
73
73
|
// ---------------------------------------------------------------------------
|
|
74
|
-
const CORE_VERSION = '1.19.
|
|
74
|
+
const CORE_VERSION = '1.19.774'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
75
75
|
|
|
76
76
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
77
77
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -45,6 +45,12 @@
|
|
|
45
45
|
// // it may be enabled where anyone can sign up. src/modules.js
|
|
46
46
|
// // refuses the enablement when the join door is `open` and no
|
|
47
47
|
// // payer is configured.
|
|
48
|
+
// "declarativeSeams": { // OPTIONAL. Per-entry exemptions for the seam-wiring
|
|
49
|
+
// "pollers": ["box-lifecycle"] // guard (task 1003819): "this contributes entry ships no
|
|
50
|
+
// }, // artifact ON PURPOSE". Every key must be a key the module
|
|
51
|
+
// // actually declares in `contributes`, and every entry must be
|
|
52
|
+
// // one it actually lists there — a typo'd exemption is an error,
|
|
53
|
+
// // never a silent pass.
|
|
48
54
|
// "provides": ["reward"], // OPTIONAL. seam PORTS this module registers a provider for.
|
|
49
55
|
// "consumes": ["grade"], // OPTIONAL. seam ports this module resolves (required capabilities).
|
|
50
56
|
// "prerequisites": { "modules": ["economy"] }, // OPTIONAL. other modules that must be enabled.
|
|
@@ -117,6 +123,30 @@ const isWebSurface = (v) =>
|
|
|
117
123
|
isObj(v) && isWebSurfaceDir(v) && (v.apexRoot === true ? !('host' in v) : isStr(v.host));
|
|
118
124
|
const isWebSurfaceArray = (v) => Array.isArray(v) && v.every(isWebSurface);
|
|
119
125
|
|
|
126
|
+
// The entries a module declares for one `contributes` seam, normalised to a list.
|
|
127
|
+
// `migrations: true` is the one implicit entry (the modules/<key>/migrations/ dir);
|
|
128
|
+
// every array seam yields its own entries; an empty array declares nothing.
|
|
129
|
+
//
|
|
130
|
+
// SINGLE SOURCE, deliberately. This pair lives here rather than in the seam-wiring
|
|
131
|
+
// guard that also needs it (scripts/gds/seam-wiring-guard.js, task 1003819), because
|
|
132
|
+
// "what entries does this contributes block declare, and what is each one called" is
|
|
133
|
+
// schema knowledge — and two copies would be exactly the second list that drifts,
|
|
134
|
+
// with the drift landing on the guard's exemption cross-check.
|
|
135
|
+
function declaredEntries(seam, value) {
|
|
136
|
+
if (seam === 'migrations') return value === true ? [true] : (Array.isArray(value) ? value : []);
|
|
137
|
+
return Array.isArray(value) ? value : [];
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// A declared entry's stable display name — what a declarativeSeams exemption names.
|
|
141
|
+
// webSurfaces entries are objects and `migrations: true` is implicit, so neither is
|
|
142
|
+
// its own label; everything else is already a string.
|
|
143
|
+
function entryLabel(seam, entry) {
|
|
144
|
+
if (seam === 'migrations' && entry === true) return 'migrations/';
|
|
145
|
+
if (seam !== 'webSurfaces') return String(entry);
|
|
146
|
+
if (!isObj(entry)) return String(entry);
|
|
147
|
+
return entry.apexRoot === true ? `apexRoot:${entry.dir}` : `${entry.host}(${entry.dir})`;
|
|
148
|
+
}
|
|
149
|
+
|
|
120
150
|
function validateManifest(obj) {
|
|
121
151
|
const errors = [];
|
|
122
152
|
const E = (msg) => errors.push(msg);
|
|
@@ -206,6 +236,54 @@ function validateManifest(obj) {
|
|
|
206
236
|
}
|
|
207
237
|
}
|
|
208
238
|
|
|
239
|
+
// --- optional declarativeSeams (task 1003819) ---
|
|
240
|
+
// The exemption list for the seam-wiring guard: "this contributes entry ships no
|
|
241
|
+
// artifact on purpose". Some declarations are legitimately declarative-only —
|
|
242
|
+
// dev-box's `box-lifecycle` poller runs on the control plane, not in this process,
|
|
243
|
+
// and its `boxes` uiSection ships no hall widget — so the guard needs a way to say
|
|
244
|
+
// so without a blanket skip that would hide the real cases it exists to catch.
|
|
245
|
+
//
|
|
246
|
+
// Validated HERE, against `contributes`, because this is the one place that holds
|
|
247
|
+
// both: an exemption naming a seam the module does not declare, or an entry it does
|
|
248
|
+
// not list, is an ERROR rather than a no-op. A typo'd exemption that quietly
|
|
249
|
+
// validated would exempt nothing while reading as though it exempted something —
|
|
250
|
+
// the failure mode the closed key sets on `contributes`/`maintenance`/`spend` exist
|
|
251
|
+
// to prevent, in the direction that hides a defect. The guard itself (the on-disk
|
|
252
|
+
// artifact check, and whether an exemption has gone STALE because the artifact
|
|
253
|
+
// now exists) lives in scripts/gds/seam-wiring-guard.js, which needs the filesystem.
|
|
254
|
+
if ('declarativeSeams' in obj) {
|
|
255
|
+
const ds = obj.declarativeSeams;
|
|
256
|
+
if (!isObj(ds)) {
|
|
257
|
+
E('declarativeSeams: must be an object of { "<contributes seam>": ["<entry>", …] }');
|
|
258
|
+
} else {
|
|
259
|
+
const contributes = isObj(obj.contributes) ? obj.contributes : {};
|
|
260
|
+
for (const [seam, entries] of Object.entries(ds)) {
|
|
261
|
+
if (!ALLOWED_CONTRIBUTES.includes(seam)) {
|
|
262
|
+
E(`declarativeSeams.${seam}: unknown seam (allowed: ${ALLOWED_CONTRIBUTES.join(', ')})`);
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
if (!isStrArray(entries)) {
|
|
266
|
+
E(`declarativeSeams.${seam}: must be an array of non-empty strings`);
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
if (!(seam in contributes)) {
|
|
270
|
+
E(`declarativeSeams.${seam}: the module does not declare contributes.${seam} — an exemption for a seam it never contributes exempts nothing`);
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
// Compared against the LABELS, not the raw values, so the check spans every
|
|
274
|
+
// seam — webSurfaces declares objects and `migrations: true` is implicit, and
|
|
275
|
+
// an earlier cut that matched raw strings silently skipped both, letting
|
|
276
|
+
// `declarativeSeams: { webSurfaces: ['bogus'] }` validate clean.
|
|
277
|
+
const labels = declaredEntries(seam, contributes[seam]).map((e) => entryLabel(seam, e));
|
|
278
|
+
for (const entry of entries) {
|
|
279
|
+
if (!labels.includes(entry)) {
|
|
280
|
+
E(`declarativeSeams.${seam}: "${entry}" is not in contributes.${seam} (${labels.join(', ') || 'empty'}) — an exemption must name an entry the module actually declares`);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
209
287
|
// --- optional spend declaration (task 1003884) ---
|
|
210
288
|
if ('spend' in obj) {
|
|
211
289
|
const sp = obj.spend;
|
|
@@ -255,6 +333,6 @@ function assertValidManifest(obj, label = 'module.json') {
|
|
|
255
333
|
}
|
|
256
334
|
|
|
257
335
|
module.exports = {
|
|
258
|
-
validateManifest, assertValidManifest, KEY_RE, ALLOWED_CONTRIBUTES, ARRAY_OF_STRING_KEYS, ATTRIBUTION_KEYS,
|
|
336
|
+
validateManifest, assertValidManifest, declaredEntries, entryLabel, KEY_RE, ALLOWED_CONTRIBUTES, ARRAY_OF_STRING_KEYS, ATTRIBUTION_KEYS,
|
|
259
337
|
MAINTENANCE_STATUSES, MAINTENANCE_KEYS, SUNSET_STATUSES,
|
|
260
338
|
};
|
|
@@ -227,14 +227,17 @@ test('mounting the projects routes registers the `project.catalog` port', async
|
|
|
227
227
|
assert.ok(Object.isFrozen(catalog.projectCatalogPort), 'a consumer must not be able to re-point the port');
|
|
228
228
|
});
|
|
229
229
|
|
|
230
|
-
test('the port declares the catalog write, one presence read, and nothing else', () => {
|
|
231
|
-
//
|
|
230
|
+
test('the port declares the catalog write, one presence read, the disconnect delete, and nothing else', () => {
|
|
231
|
+
// Three verbs, and the reason for the ceiling is unchanged: a consumer maps its own row onto
|
|
232
232
|
// the entry shape and asks about ORIGINS, so the catalog never learns another module's
|
|
233
233
|
// schema and no module gets a general-purpose handle on these tables. `catalogued` arrived
|
|
234
234
|
// with the backfill (task 1003581) because healing history without a presence read means
|
|
235
|
-
// re-writing every catalog row at every boot.
|
|
236
|
-
//
|
|
237
|
-
|
|
235
|
+
// re-writing every catalog row at every boot. `forget` arrived with "Disconnect from Bongos
|
|
236
|
+
// entirely" (task 1003249, ADR 0290): a released name and address cannot leave a hidden row
|
|
237
|
+
// behind, and it takes the same {clientId, origin, ownerGithubId} a publish carries, under
|
|
238
|
+
// the same two claim rules. Anything wider is a new decision, and this list is where it has
|
|
239
|
+
// to be made.
|
|
240
|
+
assert.deepEqual(Object.keys(catalog.projectCatalogPort).sort(), ['catalogued', 'forget', 'publish']);
|
|
238
241
|
});
|
|
239
242
|
|
|
240
243
|
test('the module manifest DECLARES the port it registers', async () => {
|
package/tests/fitness.mjs
CHANGED
|
@@ -421,6 +421,28 @@ test('module-migrations-additive actually CATCHES destructive + non-namespaced D
|
|
|
421
421
|
// but a BARE drop constraint (no matching re-add) is STILL destructive
|
|
422
422
|
assert.ok(fitness.moduleMigrationViolations('dev-box', 'ALTER TABLE dev_box_runs DROP CONSTRAINT dev_box_runs_shape_check;').some((v) => /destructive/i.test(v)), 'a bare DROP CONSTRAINT (no re-add) must still be flagged');
|
|
423
423
|
|
|
424
|
+
// an ACKNOWLEDGED contract-phase column drop (task 1003092, ADR 0177's expand-then-
|
|
425
|
+
// contract) is the ONE destructive shape allowed: the file carries the same
|
|
426
|
+
// `-- forward-unsafe:` pragma lint-migrations.js requires, the statement is exactly
|
|
427
|
+
// one DROP COLUMN, and the table is the module's own.
|
|
428
|
+
const PRAGMA = '-- forward-unsafe: drop-column on dev_box_runs — the contract phase of a two-step rename\n';
|
|
429
|
+
const contract = fitness.moduleMigrationViolations('dev-box',
|
|
430
|
+
PRAGMA + 'ALTER TABLE dev_box_runs DROP COLUMN IF EXISTS note;\nALTER TABLE dev_box_runs ALTER COLUMN note2 SET NOT NULL;');
|
|
431
|
+
assert.deepEqual(contract, [], 'an acknowledged own-table DROP COLUMN must pass: ' + contract.join('; '));
|
|
432
|
+
// the pragma is per-STATEMENT permission for that one shape, not a per-file switch
|
|
433
|
+
assert.ok(fitness.moduleMigrationViolations('dev-box', PRAGMA + 'DROP TABLE dev_box_runs;').some((v) => /destructive/i.test(v)), 'DROP TABLE stays flagged even under the pragma');
|
|
434
|
+
assert.ok(fitness.moduleMigrationViolations('dev-box', PRAGMA + 'TRUNCATE dev_box_runs;').some((v) => /destructive/i.test(v)), 'TRUNCATE stays flagged even under the pragma');
|
|
435
|
+
assert.ok(fitness.moduleMigrationViolations('dev-box', PRAGMA + 'ALTER TABLE dev_box_runs DROP CONSTRAINT dev_box_runs_shape_check;').some((v) => /destructive/i.test(v)), 'a bare DROP CONSTRAINT stays flagged even under the pragma');
|
|
436
|
+
assert.ok(fitness.moduleMigrationViolations('dev-box', PRAGMA + 'ALTER TABLE dev_box_runs DROP COLUMN note, DROP COLUMN note2;').some((v) => /destructive/i.test(v)), 'a multi-clause drop is not the one allowed shape');
|
|
437
|
+
// and a column drop on a table OUTSIDE the namespace is refused twice over
|
|
438
|
+
const foreign = fitness.moduleMigrationViolations('dev-box', PRAGMA + 'ALTER TABLE builders DROP COLUMN note;');
|
|
439
|
+
assert.ok(foreign.some((v) => /destructive/i.test(v)), 'a core-table DROP COLUMN is still destructive under the pragma');
|
|
440
|
+
assert.ok(foreign.some((v) => /namespace/i.test(v)), 'and still a namespace violation');
|
|
441
|
+
// the real contract migration is the shape's first user — it must pass the REAL check
|
|
442
|
+
assert.equal(fitness.moduleMigrationViolations('platform-identity',
|
|
443
|
+
fs.readFileSync(path.join(ROOT, 'modules', 'platform-identity', 'migrations', 'platform_identity_021_drop_legacy_role.sql'), 'utf8')).length, 0,
|
|
444
|
+
'platform_identity_021 is the acknowledged contract phase and must be clean');
|
|
445
|
+
|
|
424
446
|
// a created object outside the <key>_ namespace is rejected
|
|
425
447
|
assert.ok(fitness.moduleMigrationViolations('dev-box', 'CREATE TABLE runs (id int);').some((v) => /namespace/i.test(v)), 'a table outside dev_box_ must be flagged');
|
|
426
448
|
// a module migration altering a CORE table is rejected (reach core via the seam)
|
|
@@ -18,7 +18,8 @@ import { createRequire } from 'node:module';
|
|
|
18
18
|
const require = createRequire(import.meta.url);
|
|
19
19
|
const gsc = require('../src/bongos/goal-scope-check.js');
|
|
20
20
|
const { touchesOverlap } = require('../src/bongos/path-match.js');
|
|
21
|
-
const {
|
|
21
|
+
const { PROTECTED_GLOBS: protectedGlobs } = require('../src/bongos/permission-path-check.js');
|
|
22
|
+
const { globsForModules, isModuleKey, protectedModules, SHARED_GLOBS, SHARED_GLOBS_RAW } = require('../src/bongos/module-scope-map.js');
|
|
22
23
|
|
|
23
24
|
let passed = 0;
|
|
24
25
|
let failed = 0;
|
|
@@ -102,6 +103,67 @@ t('the subset does NOT use symmetric touchesOverlap (which would leak the broad
|
|
|
102
103
|
'goal-scope-check must REJECT the broad touch despite the symmetric overlap');
|
|
103
104
|
});
|
|
104
105
|
|
|
106
|
+
// ---------- the shared floor: tests/ is inside EVERY wall (task 1003655) ----------
|
|
107
|
+
//
|
|
108
|
+
// `tests/` belongs to no module, and every real task adds a test — so before the
|
|
109
|
+
// shared floor existed the create gate refused any task that honestly declared its
|
|
110
|
+
// own test file, whichever goal it was filed under (idea 1000816). These cases pin
|
|
111
|
+
// both halves: the floor is reachable from any scope, and it did not become a hole.
|
|
112
|
+
|
|
113
|
+
t('shared floor: a test file passes under a goal scoped to an unrelated module', () => {
|
|
114
|
+
const r = gsc.check(['art-pipeline'], ['tests/idea_routing.mjs'], 'metic');
|
|
115
|
+
assert.equal(r.ok, true, r.reason);
|
|
116
|
+
// and it is NOT because art-pipeline owns it — no module does.
|
|
117
|
+
assert.equal(gsc.isCovered('tests/idea_routing.mjs', globsForModules(['art-pipeline'])), false);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
t('shared floor: sub-Metic too — the floor is scope, not rank (tests/ is unprotected)', () => {
|
|
121
|
+
assert.equal(gsc.check(['game'], ['tests/seats.mjs'], 'thetes').ok, true);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
t('shared floor: the realistic mixed task — module code PLUS its own test', () => {
|
|
125
|
+
const r = gsc.check(['ideas'], ['modules/ideas/inbox.js', 'tests/idea_routing.mjs'], 'metic');
|
|
126
|
+
assert.equal(r.ok, true, r.reason);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
t('shared floor is reported separately and NEVER folded into goal_globs', () => {
|
|
130
|
+
// check()'s unknown_scope guard tests goal_globs.length === 0; folding the
|
|
131
|
+
// always-present shared globs in would give a garbage scope a non-empty
|
|
132
|
+
// territory and slip it past that fail-closed edge.
|
|
133
|
+
const s = gsc.scopeSubset(['art-pipeline'], ['tests/x.mjs']);
|
|
134
|
+
assert.equal(s.ok, true);
|
|
135
|
+
assert.deepEqual(s.shared_globs, ['tests/']);
|
|
136
|
+
assert.ok(!s.goal_globs.includes('tests/'), 'goal_globs must stay module-resolved only');
|
|
137
|
+
const garbage = gsc.scopeSubset(['not-a-real-module'], ['tests/x.mjs']);
|
|
138
|
+
assert.equal(garbage.goal_globs.length, 0, 'an unknown scope still resolves to zero territory');
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
t('shared floor does not defeat the other fail-closed edges', () => {
|
|
142
|
+
assert.equal(gsc.check(['not-a-real-module'], ['tests/x.mjs'], 'metic').code, 'unknown_scope');
|
|
143
|
+
assert.equal(gsc.check([], ['tests/x.mjs'], 'metic').code, 'empty_scope');
|
|
144
|
+
assert.equal(gsc.check(['art-pipeline'], [], 'metic').code, 'empty_touches');
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
t('shared floor is NARROW: it opens tests/ and nothing else', () => {
|
|
148
|
+
assert.deepEqual([...SHARED_GLOBS], ['tests/']);
|
|
149
|
+
// the sibling roots that DO have owners must still be walled to their owner
|
|
150
|
+
assert.equal(gsc.check(['art-pipeline'], ['.claude/skills/status/SKILL.md'], 'metic').code, 'out_of_scope');
|
|
151
|
+
assert.equal(gsc.check(['art-pipeline'], ['docs/adr/0288-x.md'], 'metic').code, 'out_of_scope');
|
|
152
|
+
assert.equal(gsc.check(['art-pipeline'], ['src/bongos/auth.js'], 'metic').code, 'out_of_scope');
|
|
153
|
+
// …and a lookalike sibling dir is not swept in by the name prefix
|
|
154
|
+
assert.equal(gsc.check(['art-pipeline'], ['tests-e2e/x.mjs'], 'metic').code, 'out_of_scope');
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
t('shared floor is DERIVED fail-closed: a protected glob could never take effect', () => {
|
|
158
|
+
// The exported list filters its own literal against PROTECTED_GLOBS, so a future
|
|
159
|
+
// edit adding e.g. 'migrations/' would drop out rather than widen every goal's
|
|
160
|
+
// wall into the trust core. Today the raw literal is already clean.
|
|
161
|
+
assert.deepEqual([...SHARED_GLOBS], SHARED_GLOBS_RAW.filter((g) => !touchesOverlap([g], protectedGlobs)));
|
|
162
|
+
for (const g of SHARED_GLOBS) {
|
|
163
|
+
assert.equal(touchesOverlap([g], protectedGlobs), false, `shared glob ${g} overlaps the protected core`);
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
|
|
105
167
|
// ---------- fail-closed edges ----------
|
|
106
168
|
|
|
107
169
|
t('empty-touches reject: a task with no touches[] cannot be scope-checked', () => {
|
|
@@ -127,7 +127,11 @@ test('a toggle is a role=switch with aria-checked, and the page script keeps dat
|
|
|
127
127
|
assert.match(fn[0], /data-on="\$\{on \? '1' : '0'\}"/, 'and data-on stays, because every delegated click handler reads it');
|
|
128
128
|
assert.match(fn[0], /sw__track[\s\S]*sw__knob/, 'drawn as a track and a knob');
|
|
129
129
|
assert.doesNotMatch(settingsJs, /aria-pressed/, 'no aria-pressed toggles are left on the page');
|
|
130
|
-
|
|
130
|
+
// renderRender is deliberately absent: the Rendering panel stopped being a
|
|
131
|
+
// toggle in task 1002518 (four rungs, not on/off), so it became a radio group
|
|
132
|
+
// in settings-render.js — covered by the radio assertions and the wiring test
|
|
133
|
+
// below, not by the switch contract.
|
|
134
|
+
for (const fnName of ['renderSounds', 'renderCrossProject', 'privacyRow']) {
|
|
131
135
|
const body = settingsJs.match(new RegExp(`function ${fnName}\\([\\s\\S]*?\\n \\}\\n`));
|
|
132
136
|
assert.ok(body && /switchHtml\(/.test(body[0]), `${fnName} renders the switch`);
|
|
133
137
|
}
|
|
@@ -199,6 +203,58 @@ test('the interaction panel stays out of settings.js, which is against the size
|
|
|
199
203
|
assert.ok(lines < 1500, `settings.js is ${lines} lines — at or over the 1500 ratchet; carve the next panel out too`);
|
|
200
204
|
});
|
|
201
205
|
|
|
206
|
+
// ---- the Rendering panel, carved out the same way (task 1002518) -----------
|
|
207
|
+
//
|
|
208
|
+
// Same two-file shape as the interaction panel above, and the same reason: the
|
|
209
|
+
// knob became a four-rung ladder with a DERIVED default, which is more UI than
|
|
210
|
+
// settings.js has room for. The extra fact this panel has to keep true is that
|
|
211
|
+
// its "default" row explains itself — the default is computed from another
|
|
212
|
+
// panel's answers, so a bare "default" tag would name a choice without naming
|
|
213
|
+
// who made it.
|
|
214
|
+
|
|
215
|
+
test('the rendering panel is wired end to end across its two files', () => {
|
|
216
|
+
const html = read('settings.html');
|
|
217
|
+
const panelJs = read('settings-render.js');
|
|
218
|
+
|
|
219
|
+
const section = html.match(/<section class="scroll" id="render-scroll"[^>]*>/);
|
|
220
|
+
assert.ok(section, 'the panel has a section');
|
|
221
|
+
assert.match(section[0], /data-group="preferences"/, 'it joins a group the rail already renders');
|
|
222
|
+
assert.match(section[0], /\bhidden\b/, 'hidden until its own read succeeds');
|
|
223
|
+
assert.match(html, /<script src="\/builders\/settings-render\.js/, 'the page loads the panel script');
|
|
224
|
+
|
|
225
|
+
assert.match(panelJs, /\$\('#render-scroll'\)\.hidden = false/, 'the script reveals its own section');
|
|
226
|
+
assert.match(panelJs, /setup\(\);/, 'it self-boots — settings.js does not call it');
|
|
227
|
+
|
|
228
|
+
// settings.js keeps ONLY the anchor entry.
|
|
229
|
+
assert.match(settingsJs, /'rendering': 'render-scroll'/, 'the deep-link anchor stays in settings.js');
|
|
230
|
+
assert.doesNotMatch(settingsJs, /function renderRender\(/, 'the renderer moved out and must not come back inline');
|
|
231
|
+
assert.doesNotMatch(settingsJs, /loadRender\(\)/, 'and settings.js no longer calls it');
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test('the rendering panel is a radio group, wired on change, and can clear the override', () => {
|
|
235
|
+
const panelJs = read('settings-render.js');
|
|
236
|
+
const render = panelJs.match(/function render\([\s\S]*?\n \}\n/);
|
|
237
|
+
assert.ok(render, 'render is a whole function');
|
|
238
|
+
assert.match(render[0], /<input class="pradio" type="radio" name="render-level"/, 'a real radio group');
|
|
239
|
+
assert.match(render[0], /<label class="prow prow--pick"/, 'the whole row is the radio\'s label');
|
|
240
|
+
const setup = panelJs.match(/async function setup\([\s\S]*?\n \}\n/);
|
|
241
|
+
assert.match(setup[0], /addEventListener\('change'/, 'a radio fires change, not click');
|
|
242
|
+
assert.match(setup[0], /closest\('\.pradio'\)/);
|
|
243
|
+
// The sentinel row must send null, not a level: pinning the rung that happens
|
|
244
|
+
// to be derived today would silently stop tracking the interaction profile.
|
|
245
|
+
assert.match(panelJs, /widgets: value === USE_DEFAULT \? null : value/,
|
|
246
|
+
'choosing "match my profile" CLEARS the override rather than pinning a level');
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test('the derived default explains itself, because it is computed elsewhere', () => {
|
|
250
|
+
// Unlike every other picker on the page, "default" here is not a constant —
|
|
251
|
+
// render-prefs.js derives it from the interaction profile. A row that said
|
|
252
|
+
// only "default" would name a choice the reader never made and cannot see.
|
|
253
|
+
const panelJs = read('settings-render.js');
|
|
254
|
+
assert.match(panelJs, /derived_reason/, 'the row states WHY the default landed where it did');
|
|
255
|
+
assert.match(panelJs, /derived_level/, 'and WHICH rung that is');
|
|
256
|
+
});
|
|
257
|
+
|
|
202
258
|
test('a default value is named once, by the API, not twice by the row', () => {
|
|
203
259
|
// The first render read "Non-technical (default) · DEFAULT": interaction-prefs.js
|
|
204
260
|
// already ends every default value's LABEL with "(default)", and the row also
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// tests/hub_disconnect_ui.mjs
|
|
2
|
+
//
|
|
3
|
+
// The manage page's "Disconnect from Bongos entirely" card (task 1003249, ADR 0290).
|
|
4
|
+
//
|
|
5
|
+
// "Take it offline" is reversible and says so; this is the owner's TRUE delete, so it lives in
|
|
6
|
+
// its own card after the ender — never folded into it, where the reversible control could read
|
|
7
|
+
// as the destructive one. What these pins hold:
|
|
8
|
+
// • the card is real markup, after "Take it offline", with its fold a SIBLING of the volatile
|
|
9
|
+
// slot (the #mDelWhat shape), itemising what is erased and what is kept, and saying it
|
|
10
|
+
// cannot be undone — with no wall-clock promise
|
|
11
|
+
// • the ask is the name typed back; the act re-checks it at click time and SENDS it, because
|
|
12
|
+
// the route checks it again (confirm_slug)
|
|
13
|
+
// • the minute tick never repaints over a half-typed name (a project-scoped guard), and an open
|
|
14
|
+
// run is named rather than offering a button the server would refuse
|
|
15
|
+
// • the copy that said nothing on this page erases a project was true until this card: it now
|
|
16
|
+
// scopes that promise to "Take it offline", and the fold points at the card
|
|
17
|
+
// • the design kit's stub answers the ask, so the probe drives it end to end
|
|
18
|
+
//
|
|
19
|
+
// Run: node tests/hub_disconnect_ui.mjs
|
|
20
|
+
|
|
21
|
+
import { test } from 'node:test';
|
|
22
|
+
import assert from 'node:assert/strict';
|
|
23
|
+
import fs from 'node:fs';
|
|
24
|
+
import path from 'node:path';
|
|
25
|
+
import { fileURLToPath } from 'node:url';
|
|
26
|
+
|
|
27
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
28
|
+
const LANDING = path.join(ROOT, 'modules', 'public-landing', 'public');
|
|
29
|
+
const HUB = fs.readFileSync(path.join(LANDING, 'projects.html'), 'utf8').replace(/\r\n/g, '\n');
|
|
30
|
+
const SCRIPT = HUB.slice(HUB.lastIndexOf('<script>'), HUB.lastIndexOf('</script>'));
|
|
31
|
+
const fn = (name) => (SCRIPT.match(new RegExp(`function ${name}\\([^)]*\\)\\s*\\{([\\s\\S]*?)\\n \\}`)) || [, ''])[1];
|
|
32
|
+
const PROMISE = /shortly|briefly|minute|second|soon\b/i;
|
|
33
|
+
|
|
34
|
+
test('the card is real markup, after "Take it offline", with the fold outside the volatile slot', () => {
|
|
35
|
+
const del = HUB.indexOf('id="mDelete"');
|
|
36
|
+
const card = (HUB.match(/<div class="card mSec" id="mDisconnect">[\s\S]*?<\/details>\s*<\/div>/) || [''])[0];
|
|
37
|
+
assert.ok(card, '#mDisconnect must be extractable');
|
|
38
|
+
assert.ok(del > 0 && HUB.indexOf('id="mDisconnect"') > del, 'the true delete comes after the reversible ender');
|
|
39
|
+
assert.match(card, /<h2>Disconnect from Bongos entirely<\/h2>/);
|
|
40
|
+
const slot = card.indexOf('id="mDisc"'), fold = card.indexOf('id="mDiscWhat"');
|
|
41
|
+
assert.ok(slot > 0 && fold > slot, 'the fold is a sibling after the slot, so no re-render can drop it');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('the fold itemises what is erased and what is kept, says it cannot be undone, and promises no time', () => {
|
|
45
|
+
const fold = (HUB.match(/<details class="finePrint" id="mDiscWhat">[\s\S]*?<\/details>/) || [''])[0];
|
|
46
|
+
assert.ok(fold);
|
|
47
|
+
assert.match(fold, /<summary>What this erases — and what it keeps<\/summary>/);
|
|
48
|
+
for (const erased of [/database/, /nightly backup/, /place on the map/, /members/, /name and web address are released/]) {
|
|
49
|
+
assert.match(fold, erased, `names what is erased: ${erased}`);
|
|
50
|
+
}
|
|
51
|
+
assert.match(fold, /<b>Kept:<\/b> your repository on GitHub/);
|
|
52
|
+
assert.match(fold, /GitHub sign-in app/, 'the one thing GitHub will not let the platform remove is named, with where to remove it');
|
|
53
|
+
assert.match(fold, /cannot be undone/);
|
|
54
|
+
assert.match(fold, /<b>Take it offline<\/b> above/, 'the reversible alternative is pointed at');
|
|
55
|
+
assert.doesNotMatch(fold, PROMISE);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('the ask is the name typed back, and the act re-checks it and SENDS it for the server to check again', () => {
|
|
59
|
+
const ask = fn('askDisconnect');
|
|
60
|
+
assert.ok(ask, 'askDisconnect() must be extractable');
|
|
61
|
+
assert.match(ask, /mDiscAsking = inst\.slug;/);
|
|
62
|
+
assert.match(ask, /Type the project’s name to confirm/);
|
|
63
|
+
assert.match(ask, /id="mDiscName"/);
|
|
64
|
+
assert.match(ask, /data-disc-yes disabled/);
|
|
65
|
+
assert.match(ask, /data-disc-no/);
|
|
66
|
+
assert.match(ask, /cannot be undone/);
|
|
67
|
+
const act = fn('doDisconnect');
|
|
68
|
+
assert.match(act, /if \(!inst \|\| !discTypedMatches\(\)\) return;/, 'an enabled-LOOKING button cannot act');
|
|
69
|
+
assert.match(act, /'\/disconnect'/);
|
|
70
|
+
assert.match(act, /JSON\.stringify\(\{ confirm_slug: /, 'the typed name travels as confirm_slug');
|
|
71
|
+
assert.match(act, /loadManage\(true\)/, 'the page re-reads the project so the queued run shows');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('the render guards a half-typed name per project, names an open run, and offers the button otherwise', () => {
|
|
75
|
+
const render = fn('renderDisconnect');
|
|
76
|
+
assert.ok(render, 'renderDisconnect() must be extractable');
|
|
77
|
+
assert.match(render, /if \(mDiscAsking && mDiscAsking === inst\.slug\) return;/);
|
|
78
|
+
assert.match(render, /open\.action === 'disconnect'/);
|
|
79
|
+
assert.match(render, /This project is being disconnected\./);
|
|
80
|
+
assert.match(render, /Disconnecting waits for the current /, 'another open run is named, no button the server would refuse');
|
|
81
|
+
assert.match(render, /data-disc-ask/);
|
|
82
|
+
assert.doesNotMatch(render, PROMISE);
|
|
83
|
+
assert.match(SCRIPT, /renderDelete\(inst\);\n[\s\S]{0,200}renderDisconnect\(inst\);/, 'rendered on the same fetch-free tick as the ender');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('the ender no longer claims nothing on the page erases — it scopes that to itself and points at the card', () => {
|
|
87
|
+
const truth = fn('teardownTruth');
|
|
88
|
+
assert.equal((truth.match(/until you bring it back\. Taking it offline erases nothing\.'/g) || []).length, 2, 'both shapes');
|
|
89
|
+
assert.doesNotMatch(HUB, /Nothing here erases a project/);
|
|
90
|
+
const delFold = (HUB.match(/<details class="finePrint" id="mDelWhat">[\s\S]*?<\/details>/) || [''])[0];
|
|
91
|
+
assert.doesNotMatch(delFold, /no permanent-delete button/i, 'the capability exists now');
|
|
92
|
+
assert.match(delFold, /<b>Disconnect from Bongos entirely<\/b> below/);
|
|
93
|
+
assert.match(fn('renderDelete'), /open_intent\.action === 'disconnect'/, 'a queued disconnect is not offered a teardown the server would refuse');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('the design kit stub answers the ask, and a probe drives it end to end', () => {
|
|
97
|
+
const stub = fs.readFileSync(path.join(ROOT, 'modules', 'ui-design', 'kit', 'serve.js'), 'utf8');
|
|
98
|
+
assert.match(stub, /\(teardown\|restart\|domain\|disconnect\)/);
|
|
99
|
+
assert.match(stub, /disconnect: \{ queued: true, message: /);
|
|
100
|
+
const probes = JSON.parse(fs.readFileSync(path.join(LANDING, 'projects.probes.json'), 'utf8')).probes;
|
|
101
|
+
const p = probes.find((x) => /disconnect/.test(x.name));
|
|
102
|
+
assert.ok(p, 'a disconnect probe exists');
|
|
103
|
+
assert.ok(p.steps.some((s) => s[0] === 'fill' && s[1] === '#mDiscName'));
|
|
104
|
+
assert.ok(p.steps.some((s) => s[0] === 'click' && s[1] === '[data-disc-yes]'));
|
|
105
|
+
});
|
package/tests/land_watch.mjs
CHANGED
|
@@ -89,6 +89,50 @@ t('falls back to the task row for title/value when the crumb lacks them', () =>
|
|
|
89
89
|
assert.ok(/From task row/.test(env.html), 'title should come from the fresh task row');
|
|
90
90
|
});
|
|
91
91
|
|
|
92
|
+
// ── the card-richness rung survives the async land (task 1002518) ────────────────────
|
|
93
|
+
//
|
|
94
|
+
// This is the path the ladder is easiest to lose on. The card a CI land renders is
|
|
95
|
+
// built minutes later, in another process, from the breadcrumb alone — so if the
|
|
96
|
+
// crumb does not carry the rung the shipping session resolved, the shipped card
|
|
97
|
+
// silently reverts to the default rendering and the builder's choice is honoured
|
|
98
|
+
// everywhere EXCEPT the one card that says the work actually landed.
|
|
99
|
+
|
|
100
|
+
t('a crumb carrying cardFlags renders at that rung — rich opens the summary', () => {
|
|
101
|
+
const flags = { level: 'rich', showCard: true, showWidget: true, expanded: true };
|
|
102
|
+
const env = buildShippedEnvelope({ ...CRUMB, cardFlags: flags }, { status: 'shipped' });
|
|
103
|
+
assert.equal(env.widgets_enabled, true);
|
|
104
|
+
assert.match(env.html, /<div style="display:block;margin-top:0\.6rem/, 'the rich rung opens the summary on arrival');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
t('cardFlags at a non-widget rung writes no widget, whatever the legacy boolean says', () => {
|
|
108
|
+
// widgetsEnabled:true is left on the crumb deliberately — cardFlags must WIN.
|
|
109
|
+
// A crumb written by this CLI carries both, so the precedence is what decides
|
|
110
|
+
// the render, not which field happens to be present.
|
|
111
|
+
const flags = { level: 'text', showCard: true, showWidget: false, expanded: false };
|
|
112
|
+
const env = buildShippedEnvelope({ ...CRUMB, widgetsEnabled: true, cardFlags: flags }, { status: 'shipped' });
|
|
113
|
+
assert.equal(env.widgets_enabled, false, 'the rung decides, not the legacy boolean');
|
|
114
|
+
assert.ok(env.markdown && /Shipped/.test(env.markdown), 'the monospace card still carries the land');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
t('the standard rung leaves the summary folded — expanded is not assumed from showWidget', () => {
|
|
118
|
+
const flags = { level: 'standard', showCard: true, showWidget: true, expanded: false };
|
|
119
|
+
const env = buildShippedEnvelope({ ...CRUMB, cardFlags: flags }, { status: 'shipped' });
|
|
120
|
+
assert.equal(env.widgets_enabled, true);
|
|
121
|
+
assert.match(env.html, /<div style="display:none;margin-top:0\.6rem/, 'folded, as the standard rung means');
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
t('a crumb WITHOUT cardFlags still reads the legacy boolean — older crumbs on disk', () => {
|
|
125
|
+
// A breadcrumb written before this shipped is sitting on someone's machine right
|
|
126
|
+
// now, and land-watch will read it. It must render exactly as it always did.
|
|
127
|
+
for (const widgetsEnabled of [true, false]) {
|
|
128
|
+
const env = buildShippedEnvelope({ ...CRUMB, widgetsEnabled, cardFlags: undefined }, { status: 'shipped' });
|
|
129
|
+
assert.equal(env.widgets_enabled, widgetsEnabled, `legacy crumb (${widgetsEnabled}) keeps its meaning`);
|
|
130
|
+
if (widgetsEnabled) {
|
|
131
|
+
assert.match(env.html, /<div style="display:none;margin-top:0\.6rem/, 'a legacy crumb never renders expanded');
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
|
|
92
136
|
// ── watch() orchestration (injected deps — no disk, no network) ──────────────────────
|
|
93
137
|
await ta('emits the NEWEST shipped card, keeps confirmed, drops the rest — one card per run', async () => {
|
|
94
138
|
const removed = [];
|
|
@@ -91,45 +91,104 @@ for (const m of EXPAND_MIGRATIONS) {
|
|
|
91
91
|
});
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
-
test('the value
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
|
|
98
|
-
assert.match(pi, /function membershipKindFromLegacy\(/);
|
|
99
|
-
assert.match(pi, /function legacyRoleFromKind\(/);
|
|
100
|
-
const sso = read('modules/platform-identity/routes/sso.js');
|
|
101
|
-
assert.match(sso, /pi\.membershipKindFromLegacy\(role\)/,
|
|
102
|
-
'the check-in translates the legacy field through the shared mapping');
|
|
94
|
+
test('the expand-phase trigger mapped the value pair in BOTH directions (history, still readable)', () => {
|
|
95
|
+
// 010 is history now — 021 dropped the trigger — but the expand migration stays
|
|
96
|
+
// in the tree (a from-scratch replay must still match), so its shape stays pinned:
|
|
97
|
+
// this is the trigger the no-DEFAULT rule above exists for.
|
|
103
98
|
const sql = read('modules/platform-identity/migrations/platform_identity_010_membership_kind.sql');
|
|
104
|
-
assert.match(sql, /CASE WHEN NEW\.role = 'builder' THEN 'member'/, 'the trigger maps the
|
|
105
|
-
assert.match(sql, /CASE WHEN NEW\.membership_kind = 'member' THEN 'builder'
|
|
99
|
+
assert.match(sql, /CASE WHEN NEW\.role = 'builder' THEN 'member'/, 'the trigger maps the legacy value in');
|
|
100
|
+
assert.match(sql, /CASE WHEN NEW\.membership_kind = 'member' THEN 'builder'/, 'and the new value out');
|
|
106
101
|
});
|
|
107
102
|
|
|
108
|
-
test('the sender writes
|
|
103
|
+
test('the sender writes ONE spelling, computing the kind once (the window is closed)', () => {
|
|
109
104
|
// task 1003206: the auth flows are auth.js + auth-*.js now — read the family.
|
|
105
|
+
// During the window (task 1003090) this body carried BOTH spellings; task 1003092
|
|
106
|
+
// closed it. The legacy field must not come back: the hub schema is strict, so
|
|
107
|
+
// re-adding it would turn every federated check-in into a 400.
|
|
110
108
|
const auth = authSource();
|
|
111
109
|
const body = auth.slice(auth.indexOf('async function reportMembershipCheckIn'), auth.indexOf('async function reportMembershipCheckIn') + 2400);
|
|
112
|
-
assert.match(body, /membership_kind: kind/, 'the
|
|
113
|
-
assert.
|
|
114
|
-
'
|
|
110
|
+
assert.match(body, /membership_kind: kind/, 'the field goes on the wire');
|
|
111
|
+
assert.ok(!/\brole:/.test(body),
|
|
112
|
+
'the legacy field is gone from the wire — the hub no longer accepts it (task 1003092)');
|
|
115
113
|
assert.equal((body.match(/membershipKind\(\{ githubLogin \}\)/g) || []).length, 1,
|
|
116
|
-
'the kind is computed ONCE per check-in
|
|
114
|
+
'the kind is computed ONCE per check-in');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('the translator functions are gone from the domain module (nothing left to translate)', () => {
|
|
118
|
+
const pi = read('modules/platform-identity/platform-identity.js');
|
|
119
|
+
assert.ok(!/membershipKindFromLegacy/.test(pi), 'membershipKindFromLegacy was the window; the window is closed');
|
|
120
|
+
assert.ok(!/legacyRoleFromKind/.test(pi), 'legacyRoleFromKind likewise');
|
|
121
|
+
const sso = read('modules/platform-identity/routes/sso.js');
|
|
122
|
+
assert.ok(!/membershipKindFromLegacy/.test(sso), 'the route no longer translates anything');
|
|
117
123
|
});
|
|
118
124
|
|
|
119
|
-
// ── THE CONTRACT
|
|
125
|
+
// ── THE CONTRACT PHASES — the tests of the shims' own removal ──────────────────
|
|
120
126
|
//
|
|
121
127
|
// ADR 0174's Lineage lesson: a time-boxed shim outlives its release unless its
|
|
122
|
-
// removal ships with the test that says it is gone.
|
|
123
|
-
//
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
//
|
|
127
|
-
//
|
|
128
|
-
//
|
|
129
|
-
//
|
|
130
|
-
//
|
|
131
|
-
// schedules.
|
|
128
|
+
// removal ships with the test that says it is gone. The two windows closed on
|
|
129
|
+
// DIFFERENT schedules, which is the whole point of the pair:
|
|
130
|
+
// • core_240 (task 1003089) closed the GOAL membership window one release later;
|
|
131
|
+
// • platform_identity_021 (task 1003092) closed the PROJECT membership window
|
|
132
|
+
// only once every federated instance that can check in was past core 1.19.241,
|
|
133
|
+
// because its writer is another instance (ADR 0177 §"the window closed").
|
|
134
|
+
// In both, the trigger, the function and the legacy column are dropped, and
|
|
135
|
+
// `membership_kind` carries NOT NULL + DEFAULT itself rather than resting on a
|
|
136
|
+
// trigger surviving.
|
|
132
137
|
const CONTRACT = 'migrations/core_240_goal_members_drop_legacy_role.sql';
|
|
138
|
+
const PI_CONTRACT = 'modules/platform-identity/migrations/platform_identity_021_drop_legacy_role.sql';
|
|
139
|
+
|
|
140
|
+
test('platform_identity_021: the contract phase drops the trigger, the function AND the column', () => {
|
|
141
|
+
const sql = read(PI_CONTRACT);
|
|
142
|
+
assert.match(sql, /DROP TRIGGER IF EXISTS platform_identity_membership_kind_lockstep\s+ON platform_identity_project_memberships/);
|
|
143
|
+
assert.match(sql, /DROP FUNCTION IF EXISTS platform_identity_membership_kind_lockstep\(\)/,
|
|
144
|
+
'the FUNCTION outlives the trigger unless it is dropped by name');
|
|
145
|
+
assert.match(sql, /ALTER TABLE platform_identity_project_memberships DROP COLUMN IF EXISTS role/);
|
|
146
|
+
assert.match(sql, /ALTER TABLE platform_identity_project_memberships ALTER COLUMN membership_kind SET NOT NULL/,
|
|
147
|
+
'the guarantee 010 deferred to step 2 arrives here, or the column never gets one');
|
|
148
|
+
assert.match(sql, /ALTER TABLE platform_identity_project_memberships ALTER COLUMN membership_kind SET DEFAULT 'member'/,
|
|
149
|
+
'safe only NOW: a default while the legacy column was still written is the task-1003090 bug');
|
|
150
|
+
// The last backfill must carry the VALUE rename too — a stray legacy 'builder'
|
|
151
|
+
// must never land in membership_kind, whose CHECK does not admit it.
|
|
152
|
+
assert.match(sql, /CASE WHEN role = ''builder'' THEN ''member'' ELSE role END/,
|
|
153
|
+
'the guarded backfill maps the craft name, not just copies the column');
|
|
154
|
+
const trigDrop = sql.indexOf('DROP TRIGGER IF EXISTS platform_identity_membership_kind_lockstep');
|
|
155
|
+
const colDrop = sql.indexOf('DROP COLUMN IF EXISTS role');
|
|
156
|
+
assert.ok(trigDrop > -1 && trigDrop < colDrop,
|
|
157
|
+
'the trigger goes BEFORE the column: plpgsql bodies are not dependency-tracked, so a survivor fails at the next write');
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test('platform_identity_021: the forward-unsafe pragma is present, and its FIRST line carries the gating fact', () => {
|
|
161
|
+
const scan = lint.scanMigrationSql(read(PI_CONTRACT));
|
|
162
|
+
assert.deepEqual([...scan.unsafe].sort(), ['drop-column', 'set-not-null'],
|
|
163
|
+
'the linter must still SEE both operations — an acknowledged migration is not an exempt one');
|
|
164
|
+
assert.ok(scan.acknowledged, 'a drop-column with no pragma is unacknowledged forward-unsafety');
|
|
165
|
+
// The one fact that makes THIS drop safe is not a hub release alone — it is that
|
|
166
|
+
// every federated sender is past the release carrying the expand phase.
|
|
167
|
+
assert.match(scan.ackReason, /1\.19\.241/,
|
|
168
|
+
'the pragma must name the release the senders had to be past, not merely assert safety');
|
|
169
|
+
assert.match(scan.ackReason, /federated instance/i,
|
|
170
|
+
'and say the condition was about the SENDERS — the cross-version window is what made this one different');
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test('no platform-identity migration outside the three that own its lifecycle still names project_memberships.role', () => {
|
|
174
|
+
// Comments are stripped first: the history may DISCUSS the old column; this rule
|
|
175
|
+
// is about statements. The exemptions are the migrations that created the column,
|
|
176
|
+
// expanded away from it, and dropped it.
|
|
177
|
+
const OWNS_ITS_LIFECYCLE = new Set([
|
|
178
|
+
'platform_identity_003_project_memberships.sql', // created it
|
|
179
|
+
'platform_identity_010_membership_kind.sql', // expand: backfilled from it; the trigger kept it in step
|
|
180
|
+
'platform_identity_021_drop_legacy_role.sql', // contract: dropped it
|
|
181
|
+
]);
|
|
182
|
+
const dir = path.join(ROOT, 'modules', 'platform-identity', 'migrations');
|
|
183
|
+
const offenders = [];
|
|
184
|
+
for (const f of fs.readdirSync(dir)) {
|
|
185
|
+
if (!f.endsWith('.sql') || OWNS_ITS_LIFECYCLE.has(f)) continue;
|
|
186
|
+
const code = lint.stripSqlComments(fs.readFileSync(path.join(dir, f), 'utf8'));
|
|
187
|
+
if (/project_memberships/.test(code) && /\brole\b/.test(code)) offenders.push(f);
|
|
188
|
+
}
|
|
189
|
+
assert.deepEqual(offenders, [],
|
|
190
|
+
`these migrations touch project_memberships and still name the dropped column: ${offenders.join(', ')}`);
|
|
191
|
+
});
|
|
133
192
|
|
|
134
193
|
test('core_240: the contract phase drops the trigger, the function AND the column', () => {
|
|
135
194
|
const sql = read(CONTRACT);
|