@bongos/core 1.19.773 → 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 +189 -74
- 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 +2 -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/ship-card.js +49 -13
- 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/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 +5 -5
- 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/sso_checkin_legacy_field_gone.mjs +246 -0
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
const fs = require('node:fs');
|
|
12
12
|
const os = require('node:os');
|
|
13
|
-
const { cliClient, printOnboardingNudge, rewardPolicy } = require('./cli-lib');
|
|
13
|
+
const { cliClient, printOnboardingNudge, rewardPolicy, cardFlagsFromMe } = require('./cli-lib');
|
|
14
14
|
const { closeoutPlan } = require('../../modules/lifecycle/ship-preflight');
|
|
15
15
|
const { syncMemoryOnShip } = require('./ship-memory-sync.js');
|
|
16
16
|
const { uploadSessionOnShip } = require('./ship-session-upload.js');
|
|
@@ -264,17 +264,18 @@ async function printCompletionCard({ taskId, postGradeStatus, card, usage, land
|
|
|
264
264
|
commit = (v && v.commit) ? String(v.commit).slice(0, 7) : (gitOk('git rev-parse --short HEAD') || null);
|
|
265
265
|
}
|
|
266
266
|
|
|
267
|
-
// Running drachmae total + the builder's
|
|
268
|
-
// both from one /me fetch (best-effort).
|
|
267
|
+
// Running drachmae total + the builder's card-richness level (task 1279's
|
|
268
|
+
// knob, a ladder since task 1002518), both from one /me fetch (best-effort).
|
|
269
269
|
let total = null;
|
|
270
|
-
let
|
|
270
|
+
let cardFlags = cardFlagsFromMe(null);
|
|
271
271
|
try {
|
|
272
272
|
const me = noteServerReward(await (await cliClient()).me.getMe());
|
|
273
273
|
if (me.ok && me.data) {
|
|
274
274
|
if (me.data.builder) total = me.data.builder.total_credits;
|
|
275
|
-
|
|
275
|
+
cardFlags = cardFlagsFromMe(me);
|
|
276
276
|
}
|
|
277
|
-
} catch (_) { /* total + knob are optional; default
|
|
277
|
+
} catch (_) { /* total + knob are optional; default to the standard rung */ }
|
|
278
|
+
const widgetsEnabled = cardFlags.showWidget;
|
|
278
279
|
|
|
279
280
|
// Token count + estimated cost from the session usage. Split the tokens into
|
|
280
281
|
// 'new' (fresh input + output + first-time cache writes) vs 'cached' re-reads
|
|
@@ -328,9 +329,12 @@ async function printCompletionCard({ taskId, postGradeStatus, card, usage, land
|
|
|
328
329
|
// Print the monospace card + write the branded HTML for the in-feed renderer
|
|
329
330
|
// (shared emitCard, task 1275). The skill reads the printed path and passes
|
|
330
331
|
// the contents to the visualization tool — the agent authors nothing, so the
|
|
331
|
-
// card is identical every ship. task
|
|
332
|
-
// emitCard
|
|
333
|
-
|
|
332
|
+
// card is identical every ship. task 1002518: the resolved richness level
|
|
333
|
+
// reaches emitCard as flags — at the plain rung no HTML is written or
|
|
334
|
+
// relayed, at the minimal rung not even the monospace card is printed (the
|
|
335
|
+
// guaranteed [otb-confirm] line always is), and at the rich rung the
|
|
336
|
+
// widget's summary arrives already open.
|
|
337
|
+
shipCard.emitCard(model, taskId, { flags: cardFlags });
|
|
334
338
|
|
|
335
339
|
// task 1687: in CI deploy mode this card is 'confirmed — landing' and the real
|
|
336
340
|
// confirmed->shipped land happens minutes later, server-side — so the SHIPPED
|
|
@@ -338,7 +342,10 @@ async function printCompletionCard({ taskId, postGradeStatus, card, usage, land
|
|
|
338
342
|
// land-watch hook renders the shipped card on a later prompt once it lands.
|
|
339
343
|
// Best-effort: a breadcrumb must never fail a ship (mirrors the card itself).
|
|
340
344
|
if (postGradeStatus === 'confirmed') {
|
|
341
|
-
|
|
345
|
+
// `cardFlags` carries the resolved rung so the later-rendered shipped card
|
|
346
|
+
// matches the one this session printed; `widgetsEnabled` stays beside it
|
|
347
|
+
// so a breadcrumb written by an older CLI still reads correctly.
|
|
348
|
+
try { require('./land-watch').writeBreadcrumb(taskId, { widgetsEnabled, cardFlags, cardInputs }); }
|
|
342
349
|
catch (_) { /* breadcrumb is best-effort */ }
|
|
343
350
|
}
|
|
344
351
|
} catch (_) {
|
package/scripts/gds/ship-flow.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
'use strict';
|
|
10
10
|
|
|
11
|
-
const { cliClient, requireSession, arg, detectWorktreeName } = require('./cli-lib');
|
|
11
|
+
const { cliClient, requireSession, arg, detectWorktreeName, cardFlagsFromMe } = require('./cli-lib');
|
|
12
12
|
const { resolveProse, markShipProgress, smokeGateMessages, proseFlagPresent, regradeNotesRefusal } = require('./ship-honesty.js');
|
|
13
13
|
const { classifyEmptyShip } = require('../../modules/lifecycle/ship-preflight');
|
|
14
14
|
const { scopeForModule, isModuleKey } = require('../../src/bongos/module-scope-map');
|
|
@@ -342,10 +342,10 @@ async function shipMain() {
|
|
|
342
342
|
removed,
|
|
343
343
|
sandboxUrl: sandboxGate.url,
|
|
344
344
|
});
|
|
345
|
-
// task 1279: honour the builder's
|
|
346
|
-
// fetched at the top of shipMain;
|
|
347
|
-
|
|
348
|
-
shipCard.emitCard(model, taskId, {
|
|
345
|
+
// task 1279 / 1002518: honour the builder's card-richness level (from the
|
|
346
|
+
// /me fetched at the top of shipMain; the standard rung if absent, and
|
|
347
|
+
// the boolean's old meaning against an older server).
|
|
348
|
+
shipCard.emitCard(model, taskId, { flags: cardFlagsFromMe(me) });
|
|
349
349
|
} catch (_) { /* the review pause stands with or without the card */ }
|
|
350
350
|
// #1061 polish: a sandbox review pause is a DELIBERATE, expected
|
|
351
351
|
// checkpoint — not a failed ship. printReviewBlock already showed the URL
|
package/scripts/gds/start.js
CHANGED
|
@@ -67,7 +67,7 @@ const { checkTree: _checkTree } = require('./tree-preflight.js');
|
|
|
67
67
|
if (_fault) { console.error(_fault); process.exit(1); }
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
-
const { cliClient, requireSession, fmtUsd, printOnboardingNudge, printCostWarning, printNeedsNudge, hallBase, API_BASE, rewardPolicy, costPlusOnlyNote, isDispatcherIncompatError } = require('./cli-lib');
|
|
70
|
+
const { cliClient, requireSession, fmtUsd, printOnboardingNudge, printCostWarning, printNeedsNudge, hallBase, API_BASE, rewardPolicy, costPlusOnlyNote, isDispatcherIncompatError, cardFlagsFromMe } = require('./cli-lib');
|
|
71
71
|
const { contextPack, formatContextPackCompact } = require('./context-pack');
|
|
72
72
|
const { depsFinding } = require('./preflight');
|
|
73
73
|
|
|
@@ -782,12 +782,18 @@ async function main() {
|
|
|
782
782
|
}
|
|
783
783
|
|
|
784
784
|
// ---------- widget (HTML for the app renderer) ----------
|
|
785
|
-
// task 1279: the per-builder
|
|
786
|
-
//
|
|
787
|
-
//
|
|
788
|
-
//
|
|
789
|
-
//
|
|
790
|
-
|
|
785
|
+
// task 1279 / 1002518: the per-builder card-richness level. Below the widget
|
|
786
|
+
// rung, even an explicit --widget request degrades to the markdown card below
|
|
787
|
+
// — so no HTML is emitted, read, or re-relayed (a real token saving, not just
|
|
788
|
+
// a visual change). /me carries the resolved level; an older instance carries
|
|
789
|
+
// only the boolean and cardFlagsFromMe reads either. Headless/cron callers
|
|
790
|
+
// don't pass --widget at all (no renderer), so they're unaffected.
|
|
791
|
+
//
|
|
792
|
+
// The `minimal` rung lands on the markdown card here rather than on nothing:
|
|
793
|
+
// the lifecycle card has a guaranteed one-line confirmation to fall back to,
|
|
794
|
+
// and this card — the list of what you can claim — has no such floor. Dropping
|
|
795
|
+
// it would leave a session opening with no answer to the question it asked.
|
|
796
|
+
const widgetsPref = me.ok ? cardFlagsFromMe(me).showWidget : true;
|
|
791
797
|
if (widget && widgetsPref) {
|
|
792
798
|
const myClaims = me.ok ? (me.data.active_claims || (me.data.active_claim ? [me.data.active_claim] : [])) : [];
|
|
793
799
|
// Private warnings (budget / onboarding next-stage) → stderr, so stdout
|
|
@@ -46,12 +46,13 @@ function membershipKind({ githubLogin } = {}) {
|
|
|
46
46
|
// Best-effort server-to-server membership check-in to the hub (ADR 0141 §3): "this
|
|
47
47
|
// github_id builds on project <client_id>, coarse membership <membership_kind>".
|
|
48
48
|
//
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
//
|
|
49
|
+
// ONE SPELLING ON THE WIRE: `membership_kind` (task 1003090 renamed it; task 1003092
|
|
50
|
+
// closed the cross-version window). During the window this sender wrote BOTH
|
|
51
|
+
// spellings, because the receiver is the HUB — a different instance on its own
|
|
52
|
+
// upgrade schedule — and new-field-only against an un-upgraded hub would have
|
|
53
|
+
// silently dropped the owner flag. The hub has been past that release since core
|
|
54
|
+
// 1.19.241 and no longer accepts the legacy field (its schema is strict), so
|
|
55
|
+
// sending it again would turn every check-in into a 400. Runs on every
|
|
55
56
|
// FEDERATED sign-in (the only sign-ins a federated instance has) so a CLI-only
|
|
56
57
|
// project still appears in the builder's My Projects with no hub browser session.
|
|
57
58
|
// Fire-and-forget + fully swallowed: a hub outage must NEVER fail a sign-in (mirrors
|
|
@@ -72,8 +73,6 @@ async function reportMembershipCheckIn({ githubId, githubLogin, displayName, ava
|
|
|
72
73
|
display_name: displayName ?? null,
|
|
73
74
|
avatar_url: avatarUrl ?? null,
|
|
74
75
|
membership_kind: kind,
|
|
75
|
-
// LEGACY (cross-version window): 'member' → 'builder' for an un-upgraded hub.
|
|
76
|
-
role: kind === 'member' ? 'builder' : kind,
|
|
77
76
|
}),
|
|
78
77
|
});
|
|
79
78
|
} catch (err) {
|
|
@@ -135,8 +135,9 @@ function idpConfigured() {
|
|
|
135
135
|
//
|
|
136
136
|
// 'member', not 'builder' (task 1003090, ADR 0177 §"the fifth use"): ADR 0174 reserves
|
|
137
137
|
// `role` — and the craft name 'Builder' — for Artist / Ideator / Builder, so the coarse
|
|
138
|
-
// project membership says what it means.
|
|
139
|
-
//
|
|
138
|
+
// project membership says what it means. It is the only spelling on the wire — the
|
|
139
|
+
// cross-version window that carried the legacy one closed under task 1003092; see
|
|
140
|
+
// reportMembershipCheckIn.
|
|
140
141
|
|
|
141
142
|
module.exports = {
|
|
142
143
|
SCOPE,
|
|
@@ -11,8 +11,11 @@
|
|
|
11
11
|
//
|
|
12
12
|
// 1. SCOPE-SUBSET — every `touches[]` element must be CONTAINED WITHIN the
|
|
13
13
|
// goal's territory (the union of its modules' globs, resolved via
|
|
14
|
-
// module-scope-map G01)
|
|
15
|
-
//
|
|
14
|
+
// module-scope-map G01) or in SHARED_GLOBS, the small set of roots no module
|
|
15
|
+
// can honestly own that every task legitimately writes (`tests/` — see the
|
|
16
|
+
// SHARED territory block in module-scope-map.js, task 1003655 / ADR 0288).
|
|
17
|
+
// This bounds *everyone*, regardless of rank: a goal member can only author
|
|
18
|
+
// work inside the goal's modules, plus that shared floor.
|
|
16
19
|
//
|
|
17
20
|
// 2. PROTECTED-PATH — a sub-Metic member (xenos/thetes) may not author a task
|
|
18
21
|
// touching the permission/ship-grade/deploy/migration core, even when that
|
|
@@ -37,7 +40,7 @@
|
|
|
37
40
|
'use strict';
|
|
38
41
|
|
|
39
42
|
const { matchOne } = require('./path-match');
|
|
40
|
-
const { globsForModules } = require('./module-scope-map');
|
|
43
|
+
const { globsForModules, SHARED_GLOBS } = require('./module-scope-map');
|
|
41
44
|
const { checkPermissionPaths } = require('./permission-path-check');
|
|
42
45
|
|
|
43
46
|
function clean(arr) {
|
|
@@ -54,13 +57,25 @@ function isCovered(touch, goalGlobs) {
|
|
|
54
57
|
return matchOne(touch, goalGlobs || []) !== null;
|
|
55
58
|
}
|
|
56
59
|
|
|
57
|
-
// Wall 1 — scope subset. Pure: { ok, out_of_scope[], goal_globs[] }.
|
|
58
|
-
// `ok` is true iff every cleaned touch is covered by the goal's resolved globs
|
|
59
|
-
// An empty/unknown module set resolves to zero globs
|
|
60
|
+
// Wall 1 — scope subset. Pure: { ok, out_of_scope[], goal_globs[], shared_globs[] }.
|
|
61
|
+
// `ok` is true iff every cleaned touch is covered by the goal's resolved globs OR
|
|
62
|
+
// by the shared floor. An empty/unknown module set resolves to zero goal globs.
|
|
63
|
+
//
|
|
64
|
+
// `goal_globs` stays the MODULE-resolved set only — the shared floor is reported
|
|
65
|
+
// separately and never folded in. check()'s unknown_scope guard tests
|
|
66
|
+
// `goal_globs.length === 0`, so mixing the always-present shared globs into it
|
|
67
|
+
// would make a goal scoped to nothing but garbage resolve to a non-empty
|
|
68
|
+
// territory and slip past that fail-closed edge.
|
|
60
69
|
function scopeSubset(goalScopeModules, touches) {
|
|
61
70
|
const goalGlobs = globsForModules(clean(goalScopeModules));
|
|
62
|
-
const outOfScope = clean(touches)
|
|
63
|
-
|
|
71
|
+
const outOfScope = clean(touches)
|
|
72
|
+
.filter((p) => !isCovered(p, goalGlobs) && !isCovered(p, SHARED_GLOBS));
|
|
73
|
+
return {
|
|
74
|
+
ok: outOfScope.length === 0,
|
|
75
|
+
out_of_scope: outOfScope,
|
|
76
|
+
goal_globs: goalGlobs,
|
|
77
|
+
shared_globs: [...SHARED_GLOBS],
|
|
78
|
+
};
|
|
64
79
|
}
|
|
65
80
|
|
|
66
81
|
// Wall 2 — the protected-path delegate. Thin wrapper over checkPermissionPaths
|
|
@@ -388,6 +388,44 @@ const MODULE_GLOBS = {
|
|
|
388
388
|
],
|
|
389
389
|
};
|
|
390
390
|
|
|
391
|
+
// ---------------------------------------------------------------------------
|
|
392
|
+
// SHARED territory — the globs EVERY goal's wall implicitly includes (task 1003655).
|
|
393
|
+
//
|
|
394
|
+
// MODULE_GLOBS answers "who OWNS this path". Some paths have no honest owner: a
|
|
395
|
+
// shared root that every module writes into and none ships. `tests/` is the one
|
|
396
|
+
// that mattered — 751 flat suites covering all ~24 modules, owned by no key — so
|
|
397
|
+
// scopeSubset() denied EVERY touch under it. Since every real task adds a test,
|
|
398
|
+
// the create gate (POST /goals/:id/tasks) refused any task that honestly declared
|
|
399
|
+
// its own test file, whichever goal it was filed under. The wall only looked
|
|
400
|
+
// passable because callers under-declared their touches[] (idea 1000816).
|
|
401
|
+
//
|
|
402
|
+
// The two fixes we did NOT take, and why:
|
|
403
|
+
// • Give `tests/` to a key (devsecops, methodology). That is a FALSE ownership
|
|
404
|
+
// claim — an ideas-module test is the ideas module's work, not devsecops' —
|
|
405
|
+
// and it would hand one goal's members the whole suite while denying it to
|
|
406
|
+
// everyone else, which is worse than the gap.
|
|
407
|
+
// • Enumerate per-module test paths. The suites are flat and named by feature,
|
|
408
|
+
// not by module, and the glob format has no wildcards to carve with; the list
|
|
409
|
+
// would rot on the next test file.
|
|
410
|
+
//
|
|
411
|
+
// So `tests/` is SHARED, not owned: inside every goal's wall, outside every
|
|
412
|
+
// module's territory. That is sound because a test file is not a shipping
|
|
413
|
+
// surface — nothing imports it, so its blast radius on another module is zero —
|
|
414
|
+
// and the other two walls are untouched: claim collisions are still caught by
|
|
415
|
+
// touchesOverlap, and the protected-path wall (below) runs independently.
|
|
416
|
+
//
|
|
417
|
+
// FAIL-CLOSED, like `protected`: the exported list is DERIVED, not trusted. A
|
|
418
|
+
// shared glob is dropped if it overlaps PROTECTED_GLOBS, because a protected path
|
|
419
|
+
// in here would widen every goal's wall into the trust core in one line. Adding
|
|
420
|
+
// an entry is a deliberate design decision (ADR 0288) — the bar is "no module can
|
|
421
|
+
// honestly own it AND every task legitimately writes it", which today is `tests/`
|
|
422
|
+
// alone. Module-owned suites (modules/<key>/tests/) are already covered by their
|
|
423
|
+
// module's glob and do NOT belong here.
|
|
424
|
+
const SHARED_GLOBS_RAW = ['tests/'];
|
|
425
|
+
const SHARED_GLOBS = Object.freeze(
|
|
426
|
+
SHARED_GLOBS_RAW.filter((g) => !touchesOverlap([g], PROTECTED_GLOBS)),
|
|
427
|
+
);
|
|
428
|
+
|
|
391
429
|
// A glob set is protected iff it overlaps the imported PROTECTED_GLOBS — the one
|
|
392
430
|
// source of truth (permission-path-check.js). Prefix-aware both directions
|
|
393
431
|
// (path-match.touchesOverlap), so e.g. a module owning 'src/bongos/routes/inbox.js'
|
|
@@ -451,6 +489,8 @@ function scopeIncludesProtected(keys) {
|
|
|
451
489
|
module.exports = {
|
|
452
490
|
MODULE_GLOBS,
|
|
453
491
|
MODULE_SCOPE_MAP,
|
|
492
|
+
SHARED_GLOBS,
|
|
493
|
+
SHARED_GLOBS_RAW,
|
|
454
494
|
isProtected,
|
|
455
495
|
moduleKeys,
|
|
456
496
|
isModuleKey,
|
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
|