@promptctl/cc-candybar 1.26.0 → 1.28.0
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/dist/index.mjs +86 -85
- package/package.json +6 -6
- package/schema/cc-candybar.schema.json +193 -4
- package/src/check.ts +49 -27
- package/src/click/wire.ts +16 -0
- package/src/config/action.ts +57 -22
- package/src/config/default-dsl-config.ts +424 -55
- package/src/config/dsl-loader.ts +14 -2
- package/src/config/dsl-types.ts +59 -0
- package/src/config/loader/actions.ts +283 -109
- package/src/config/loader/cross-ref.ts +148 -28
- package/src/config/loader/emit-schema.ts +2 -0
- package/src/config/loader/globals.ts +118 -31
- package/src/config/loader/merge.ts +58 -1
- package/src/config/loader/persist-target.ts +32 -0
- package/src/config/loader/presets.ts +107 -0
- package/src/config/option-domain.ts +164 -0
- package/src/config/presets.ts +188 -0
- package/src/daemon/cache/git.ts +1 -1
- package/src/daemon/cache/render.ts +72 -7
- package/src/daemon/config-overrides-store.ts +322 -0
- package/src/daemon/paths.ts +10 -0
- package/src/daemon/render-payload.ts +84 -19
- package/src/daemon/server.ts +68 -55
- package/src/daemon/verbs/config-validators.ts +127 -0
- package/src/daemon/verbs/index.ts +129 -2
- package/src/daemon/verbs/state-validators.ts +98 -586
- package/src/daemon/verbs/validator-registry.ts +457 -0
- package/src/demo/dsl.ts +17 -10
- package/src/dsl/node-registry.ts +54 -39
- package/src/dsl/render.ts +158 -46
- package/src/help-text.ts +3 -3
- package/src/install/index.ts +2 -2
- package/src/render/action.ts +155 -33
- package/src/render/active-segment.ts +78 -0
- package/src/render/menu.ts +16 -11
- package/src/render/picker.ts +51 -13
- package/src/render/segment-color.ts +74 -0
- package/src/segments/git.ts +389 -48
- package/src/template-engine/colors.ts +67 -45
- package/src/template-engine/engine.ts +11 -12
- package/src/themes/index.ts +1 -4
- package/src/themes/palette-resolvers.ts +22 -30
- package/src/themes/policy.ts +37 -16
|
@@ -1,97 +1,60 @@
|
|
|
1
|
-
// [LAW:single-enforcer]
|
|
2
|
-
//
|
|
1
|
+
// [LAW:single-enforcer] The SessionState instance of the shared keyed-
|
|
2
|
+
// validator registry (validator-registry.ts): the click protocol's `set-state`
|
|
3
|
+
// verb writes only what's registered here, paired with the per-key validator
|
|
3
4
|
// that decides whether a raw incoming string is a legal value for that key.
|
|
4
5
|
// Adding a new state-writable key is one entry in this table — no new verb,
|
|
5
6
|
// no scattered string-matching, no defensive guard in the dispatcher.
|
|
6
7
|
//
|
|
7
8
|
// [LAW:one-source-of-truth] The registered keys ARE the schema for what
|
|
8
|
-
// SessionState mutations the click protocol can perform.
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
// exactly the surface they're allowed to write.
|
|
9
|
+
// SessionState mutations the click protocol can perform. Unknown-key
|
|
10
|
+
// rejection lists these names — operators see exactly the surface they're
|
|
11
|
+
// allowed to write.
|
|
12
12
|
//
|
|
13
|
-
// [LAW:
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
// [LAW:types-are-the-program] The validator's return type is the program:
|
|
19
|
-
// the verb body cannot proceed without an `ok: true` branch and cannot
|
|
20
|
-
// fabricate a value — on failure it surfaces the reason verbatim. The
|
|
21
|
-
// SessionState value is currently string-typed, so the validator's
|
|
22
|
-
// `value` is the canonical string to write (post-normalization for boolean-
|
|
23
|
-
// ish keys). When a future widget needs a non-string typed value (e.g. a
|
|
24
|
-
// numeric stepper with int-range bounds), this same shape extends — the
|
|
25
|
-
// validator becomes the parsing boundary, the verb body the dataflow.
|
|
13
|
+
// [LAW:one-type-per-behavior] The spec algebra (DerivedValidatorSpec,
|
|
14
|
+
// mergeKeySpecs, the registry's register/validate/dispose lifecycle) lives in
|
|
15
|
+
// validator-registry.ts, shared verbatim with config-validators.ts (the
|
|
16
|
+
// `persist` action's keyspace) — two keyspaces, one mechanism.
|
|
26
17
|
|
|
27
18
|
import { listResolvablePaletteNames, STRIP_STYLES } from "../../themes/policy";
|
|
28
|
-
import type { ActionDecl
|
|
19
|
+
import type { ActionDecl } from "../../config/action";
|
|
20
|
+
import {
|
|
21
|
+
perConfigDomainsFor,
|
|
22
|
+
resolveOptionDomain,
|
|
23
|
+
} from "../../config/option-domain";
|
|
29
24
|
import type { DslConfig } from "../../config/dsl-types";
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
// [LAW:
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
// or a bounded integer range (a stepper's value). The registry compares specs to
|
|
58
|
-
// decide whether two registrations can share a key (same `kind`) and merges them
|
|
59
|
-
// by unioning content (allow-list members; range bounds); the opaque
|
|
60
|
-
// `KeyValidator` it builds from the spec cannot be compared or merged, which is
|
|
61
|
-
// why registration takes the spec and owns validator construction.
|
|
62
|
-
//
|
|
63
|
-
// [LAW:one-source-of-truth] The spec carries only content (kind + allow-list
|
|
64
|
-
// members + range bounds), never the human label — the label is a pure function
|
|
65
|
-
// of the key, computed where the validator is built, so two registrations of one
|
|
66
|
-
// key yield byte-identical validators regardless of which config registered first.
|
|
67
|
-
export type DerivedValidatorSpec =
|
|
68
|
-
| { readonly kind: "int" }
|
|
69
|
-
| { readonly kind: "allow-list"; readonly allowed: readonly string[] }
|
|
70
|
-
| {
|
|
71
|
-
// [LAW:one-source-of-truth] A bounded-integer state key (a stepper's
|
|
72
|
-
// value). `min`/`max` gate the value; `seed` is the value an UNSET key
|
|
73
|
-
// reads as — sourced from the backing state variable's `default` so the
|
|
74
|
-
// first relative click steps from the same number the bar displays (not
|
|
75
|
-
// silently from `min`). The validator ignores `seed` (it only clamps); the
|
|
76
|
-
// step-state handler reads it via rangeParamsFor when the key is unset.
|
|
77
|
-
readonly kind: "range";
|
|
78
|
-
readonly min: number;
|
|
79
|
-
readonly max: number;
|
|
80
|
-
readonly seed: number;
|
|
81
|
-
};
|
|
25
|
+
import {
|
|
26
|
+
clampSeed,
|
|
27
|
+
createValidatorRegistry,
|
|
28
|
+
mergeContributions,
|
|
29
|
+
type DerivedValidatorSpec,
|
|
30
|
+
type KeySpecContribution,
|
|
31
|
+
type KeyValidator,
|
|
32
|
+
type RangeParams,
|
|
33
|
+
type ValidateResult,
|
|
34
|
+
} from "./validator-registry";
|
|
35
|
+
|
|
36
|
+
export type {
|
|
37
|
+
DerivedValidatorSpec,
|
|
38
|
+
KeySpecContribution,
|
|
39
|
+
KeyValidator,
|
|
40
|
+
RangeParams,
|
|
41
|
+
ValidateResult,
|
|
42
|
+
} from "./validator-registry";
|
|
43
|
+
// [LAW:locality-or-seam] Re-exported for existing test/consumer imports — the
|
|
44
|
+
// builder is generic (validator-registry.ts owns it), but state-validators.ts
|
|
45
|
+
// stays a stable barrel so nothing outside this module needs to know the
|
|
46
|
+
// factory moved.
|
|
47
|
+
export {
|
|
48
|
+
makeAllowListValidator,
|
|
49
|
+
makeIntValidator,
|
|
50
|
+
makeRangeValidator,
|
|
51
|
+
} from "./validator-registry";
|
|
82
52
|
|
|
83
53
|
// [LAW:one-source-of-truth] listResolvablePaletteNames is THE set whose
|
|
84
54
|
// members resolve to a concrete Palette. It deliberately excludes the "custom"
|
|
85
55
|
// sentinel (which needs inline colors and is not a renderable theme name):
|
|
86
56
|
// accepting "custom" here would persist an unrenderable value into SessionState
|
|
87
57
|
// and break the next render.
|
|
88
|
-
//
|
|
89
|
-
// [LAW:single-enforcer] Each validator's accepted-set is one constant
|
|
90
|
-
// lookup structure — a Set for O(1) `has` (matching the BOOLEAN_*
|
|
91
|
-
// validators below). The theme registry (rich-js THEMES) and STRIP_STYLES
|
|
92
|
-
// are module-init-static, so caching at module load is correct by
|
|
93
|
-
// construction; the (list, set) pair is built from the same source so
|
|
94
|
-
// the error-message ordering and the lookup membership cannot drift.
|
|
95
58
|
const RESOLVABLE_THEMES_LIST: readonly string[] = listResolvablePaletteNames();
|
|
96
59
|
const RESOLVABLE_THEMES: ReadonlySet<string> = new Set(RESOLVABLE_THEMES_LIST);
|
|
97
60
|
const RESOLVABLE_STYLES: ReadonlySet<string> = new Set(STRIP_STYLES);
|
|
@@ -118,23 +81,10 @@ const validateStyle: KeyValidator = (raw) => {
|
|
|
118
81
|
return { ok: true, value: raw };
|
|
119
82
|
};
|
|
120
83
|
|
|
121
|
-
// [LAW:dataflow-not-control-flow] Boolean-ish accepts exactly four
|
|
122
|
-
//
|
|
123
|
-
//
|
|
124
|
-
//
|
|
125
|
-
// for the next render — readers treat both as "off" because the DSL
|
|
126
|
-
// state binding's default fires on null/empty. Centralizing this
|
|
127
|
-
// canonical pair here means any future widget that writes a boolean
|
|
128
|
-
// state key gets the same on/off contract by registry row, not by
|
|
129
|
-
// re-deriving it inline.
|
|
130
|
-
//
|
|
131
|
-
// [LAW:no-silent-fallbacks] The empty string as INPUT is rejected, not
|
|
132
|
-
// silently mapped to falsy. An empty value on the wire is structurally
|
|
133
|
-
// ambiguous (did the operator mean "0", or did they forget to provide
|
|
134
|
-
// a value?); accepting it would be a silent semantic guess. Each of
|
|
135
|
-
// the comment, the accepted-input set, and the rejection message names
|
|
136
|
-
// the same four inputs — [LAW:one-source-of-truth] kept by construction
|
|
137
|
-
// instead of by maintenance.
|
|
84
|
+
// [LAW:dataflow-not-control-flow] Boolean-ish accepts exactly four canonical
|
|
85
|
+
// inputs and normalizes to two canonical outputs: truthy ("1"/"true") → "1",
|
|
86
|
+
// falsy ("0"/"false") → "" (empty). The empty falsy sentinel matches what
|
|
87
|
+
// `toolbar-toggle` produces via `clear()` for the next render.
|
|
138
88
|
const BOOLEAN_TRUTHY = new Set(["1", "true"]);
|
|
139
89
|
const BOOLEAN_FALSY = new Set(["0", "false"]);
|
|
140
90
|
const validateBoolean: KeyValidator = (raw) => {
|
|
@@ -146,432 +96,46 @@ const validateBoolean: KeyValidator = (raw) => {
|
|
|
146
96
|
};
|
|
147
97
|
};
|
|
148
98
|
|
|
149
|
-
// [LAW:
|
|
150
|
-
//
|
|
151
|
-
//
|
|
152
|
-
//
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
// across the whole overlap; the key is removed only when the last spec disposes.
|
|
159
|
-
interface BaselineEntry {
|
|
160
|
-
readonly permanent: true;
|
|
161
|
-
readonly validator: KeyValidator;
|
|
162
|
-
}
|
|
163
|
-
interface DerivedEntry {
|
|
164
|
-
readonly permanent: false;
|
|
165
|
-
// [LAW:types-are-the-program] All live specs for a key share one kind — the
|
|
166
|
-
// registration check rejects a kind change, so `kind` is the entry's stable
|
|
167
|
-
// key shape and the discriminator the rebuild matches on.
|
|
168
|
-
readonly kind: DerivedValidatorSpec["kind"];
|
|
169
|
-
validator: KeyValidator;
|
|
170
|
-
// [LAW:one-source-of-truth] The live registrations. The validator is derived
|
|
171
|
-
// from these (union of allow-list members); they are the single source, the
|
|
172
|
-
// validator the cache. `length` is the ref-count — no separate counter to drift.
|
|
173
|
-
readonly specs: DerivedValidatorSpec[];
|
|
174
|
-
}
|
|
175
|
-
type ValidatorEntry = BaselineEntry | DerivedEntry;
|
|
176
|
-
|
|
177
|
-
// [LAW:one-source-of-truth] THE registry of state keys the click protocol can
|
|
178
|
-
// write. Baseline keys are permanent; widget-derived keys carry their live
|
|
179
|
-
// registrations.
|
|
180
|
-
//
|
|
181
|
-
// [LAW:types-are-the-program] A `Map` lookup is `(key) → entry | undefined` with
|
|
182
|
-
// NO prototype chain — `__proto__`/`constructor` from an untrusted wire URL are
|
|
183
|
-
// ordinary non-members, not truthy hits on Object.prototype. A plain object
|
|
184
|
-
// would admit those as truthy lookups that crash on invocation (RENDER_FAILED
|
|
185
|
-
// instead of the intended BAD_REQUEST). Map makes that unrepresentable.
|
|
186
|
-
const _STATE_VALIDATORS = new Map<string, ValidatorEntry>([
|
|
187
|
-
["style", { validator: validateStyle, permanent: true }],
|
|
188
|
-
["theme", { validator: validateTheme, permanent: true }],
|
|
189
|
-
["toolbar-expanded", { validator: validateBoolean, permanent: true }],
|
|
190
|
-
]);
|
|
99
|
+
// [LAW:one-source-of-truth] THE SessionState instance of the shared registry.
|
|
100
|
+
// Baseline keys (style/theme/toolbar-expanded) are legacy widget-era targets
|
|
101
|
+
// that predate the action-table-driven world and stay permanent; every other
|
|
102
|
+
// SessionState key is fully derived from a config's action table.
|
|
103
|
+
const registry = createValidatorRegistry({
|
|
104
|
+
style: validateStyle,
|
|
105
|
+
theme: validateTheme,
|
|
106
|
+
"toolbar-expanded": validateBoolean,
|
|
107
|
+
});
|
|
191
108
|
|
|
192
|
-
// [LAW:dataflow-not-control-flow] listStateKeys returns a fresh snapshot on each
|
|
193
|
-
// call — the snapshot semantics IS the contract. A frozen constant would
|
|
194
|
-
// silently misreport the writable surface after a widget config registered a key.
|
|
195
109
|
export function listStateKeys(): readonly string[] {
|
|
196
|
-
return
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
// [LAW:types-are-the-program] The validator is RESIDUE of a SETTLED spec: given
|
|
200
|
-
// one merged spec, its validator is forced. This is a pure projection — kind ⇒
|
|
201
|
-
// constructor — with NO union or widen of its own. The label is a pure function
|
|
202
|
-
// of (key, kind) so the built validator is identical across registrations of one
|
|
203
|
-
// key. makeIntValidator/makeRangeValidator/makeAllowListValidator are the single
|
|
204
|
-
// validator constructors (re-validating slash/empty values), so a merged spec
|
|
205
|
-
// that somehow held an undeliverable value would throw HERE, at config-load, not
|
|
206
|
-
// at the operator's first click.
|
|
207
|
-
function validatorForSpec(
|
|
208
|
-
key: string,
|
|
209
|
-
spec: DerivedValidatorSpec,
|
|
210
|
-
): KeyValidator {
|
|
211
|
-
if (spec.kind === "int") return makeIntValidator(`menu page "${key}"`);
|
|
212
|
-
if (spec.kind === "range")
|
|
213
|
-
return makeRangeValidator(spec.min, spec.max, `stepper "${key}"`);
|
|
214
|
-
return makeAllowListValidator(spec.allowed, `state "${key}"`);
|
|
110
|
+
return registry.listKeys();
|
|
215
111
|
}
|
|
216
112
|
|
|
217
|
-
// [LAW:one-source-of-truth] The validator for a key's live registrations, built
|
|
218
|
-
// through the ONE collapse: mergeKeySpecs unions allow-list members, widens range
|
|
219
|
-
// bounds, clamps the seed, and absorbs integer members — so the union/widen logic
|
|
220
|
-
// lives in exactly one place and this builder is pure plumbing (collapse → project).
|
|
221
|
-
// A value any live config can legitimately render is a value the wire accepts, by
|
|
222
|
-
// construction, because the rendered options and the derived gate read one merge.
|
|
223
|
-
function buildValidatorFromSpecs(
|
|
224
|
-
key: string,
|
|
225
|
-
specs: readonly DerivedValidatorSpec[],
|
|
226
|
-
): KeyValidator {
|
|
227
|
-
return validatorForSpec(key, mergeKeySpecs(key, specs));
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
// [LAW:locality-or-seam] The widget config (a config-load consumer) owns the
|
|
231
|
-
// lifecycle of the validators it installs; this returns a disposer rather than
|
|
232
|
-
// coupling the registry to a global "config reload" event. The cache's
|
|
233
|
-
// reloadInto installs the new state's validators, then disposes the old — the
|
|
234
|
-
// dispose-before-swap contract that keeps a broken reload from corrupting
|
|
235
|
-
// last-known-good. See src/daemon/cache/render.ts.
|
|
236
|
-
//
|
|
237
|
-
// [LAW:no-silent-fallbacks] A baseline (permanent) key cannot be re-claimed —
|
|
238
|
-
// re-registering one throws, so a menu naming its page key `theme` surfaces a
|
|
239
|
-
// loud config-load error rather than silently shadowing the theme gate.
|
|
240
|
-
//
|
|
241
|
-
// [LAW:types-are-the-program] The semantic-compatibility gate: a key has ONE
|
|
242
|
-
// key shape. Registering an `int` spec for a key already held as `allow-list`
|
|
243
|
-
// (or vice versa) is a genuine conflict — no merged validator could honor both —
|
|
244
|
-
// so it throws at config-load, not silently keeps whichever loaded first. Two
|
|
245
|
-
// registrations of the SAME kind merge: their specs accumulate and the validator
|
|
246
|
-
// is rebuilt as their union, so two cache entries sharing a config (identical
|
|
247
|
-
// specs → idempotent) and two distinct configs sharing a key (different members
|
|
248
|
-
// → unioned, both deliverable) both resolve to one consistent gate.
|
|
249
|
-
//
|
|
250
|
-
// [LAW:single-enforcer] The disposer removes exactly its own spec once
|
|
251
|
-
// (idempotent via the `active` flag), rebuilds the validator from what remains,
|
|
252
|
-
// and deletes the key only when the last spec is gone.
|
|
253
113
|
export function registerStateValidator(
|
|
254
114
|
key: string,
|
|
255
115
|
spec: DerivedValidatorSpec,
|
|
256
116
|
): () => void {
|
|
257
|
-
|
|
258
|
-
throw new Error("registerStateValidator: key is required");
|
|
259
|
-
}
|
|
260
|
-
// [LAW:types-are-the-program] The set-state wire splits its tail on `/`, so a
|
|
261
|
-
// slash-bearing key can never be addressed — listing it would be registry-vs-
|
|
262
|
-
// wire drift. Reject at registration so the unreachable-but-listed state is
|
|
263
|
-
// unrepresentable.
|
|
264
|
-
if (key.includes("/")) {
|
|
265
|
-
throw new Error(
|
|
266
|
-
`registerStateValidator: key "${key}" contains "/" — the set-state ` +
|
|
267
|
-
`wire shape splits on "/" so a slash-bearing key cannot be ` +
|
|
268
|
-
`addressed. Use a slash-free key.`,
|
|
269
|
-
);
|
|
270
|
-
}
|
|
271
|
-
const existing = _STATE_VALIDATORS.get(key);
|
|
272
|
-
if (existing) {
|
|
273
|
-
if (existing.permanent) {
|
|
274
|
-
throw new Error(
|
|
275
|
-
`registerStateValidator: key "${key}" is a built-in state key and ` +
|
|
276
|
-
`cannot be re-claimed (built-in keys: ${[...baselineKeys()].join(", ")})`,
|
|
277
|
-
);
|
|
278
|
-
}
|
|
279
|
-
if (existing.kind !== spec.kind) {
|
|
280
|
-
throw new Error(
|
|
281
|
-
`registerStateValidator: key "${key}" is already a ${existing.kind} ` +
|
|
282
|
-
`state key; cannot also register it as ${spec.kind}. A state key has ` +
|
|
283
|
-
`one key shape — a menu page index (int) and a button allow-list ` +
|
|
284
|
-
`cannot share a key.`,
|
|
285
|
-
);
|
|
286
|
-
}
|
|
287
|
-
existing.specs.push(spec);
|
|
288
|
-
existing.validator = buildValidatorFromSpecs(key, existing.specs);
|
|
289
|
-
} else {
|
|
290
|
-
const specs = [spec];
|
|
291
|
-
_STATE_VALIDATORS.set(key, {
|
|
292
|
-
permanent: false,
|
|
293
|
-
kind: spec.kind,
|
|
294
|
-
validator: buildValidatorFromSpecs(key, specs),
|
|
295
|
-
specs,
|
|
296
|
-
});
|
|
297
|
-
}
|
|
298
|
-
let active = true;
|
|
299
|
-
return () => {
|
|
300
|
-
if (!active) return;
|
|
301
|
-
active = false;
|
|
302
|
-
const entry = _STATE_VALIDATORS.get(key);
|
|
303
|
-
if (!entry || entry.permanent) return;
|
|
304
|
-
const i = entry.specs.indexOf(spec);
|
|
305
|
-
if (i >= 0) entry.specs.splice(i, 1);
|
|
306
|
-
if (entry.specs.length === 0) {
|
|
307
|
-
_STATE_VALIDATORS.delete(key);
|
|
308
|
-
} else {
|
|
309
|
-
entry.validator = buildValidatorFromSpecs(key, entry.specs);
|
|
310
|
-
}
|
|
311
|
-
};
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
function baselineKeys(): readonly string[] {
|
|
315
|
-
const out: string[] = [];
|
|
316
|
-
for (const [key, entry] of _STATE_VALIDATORS) {
|
|
317
|
-
if (entry.permanent) out.push(key);
|
|
318
|
-
}
|
|
319
|
-
return out;
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
// [LAW:one-type-per-behavior] The "values come from list Y" pattern IS
|
|
323
|
-
// the canonical widget-config use case (theme picker draws from
|
|
324
|
-
// themes(), style picker draws from styles(), a custom enum picker
|
|
325
|
-
// draws from a user-declared list). One factory builds the validator
|
|
326
|
-
// from the list — every callsite that registers an allow-list key
|
|
327
|
-
// passes through the same shape, so error messages, empty-input
|
|
328
|
-
// rejection, and lookup semantics are identical by construction.
|
|
329
|
-
//
|
|
330
|
-
// [LAW:no-silent-fallbacks] Empty input is rejected with a label-
|
|
331
|
-
// referencing reason rather than silently mapped to a default — the
|
|
332
|
-
// shape matches validateTheme/validateStyle so the operator experience
|
|
333
|
-
// is consistent across baseline and widget-installed keys.
|
|
334
|
-
export function makeAllowListValidator(
|
|
335
|
-
allowed: readonly string[],
|
|
336
|
-
label: string,
|
|
337
|
-
): KeyValidator {
|
|
338
|
-
// [LAW:types-are-the-program] The factory's contract is "options =
|
|
339
|
-
// allow list" — every value the picker can RENDER must also be a
|
|
340
|
-
// value the wire can DELIVER. Two structural reasons a declared
|
|
341
|
-
// option can't reach the validator as itself:
|
|
342
|
-
// (1) the wire splits the tail on "/", so a slash-bearing value
|
|
343
|
-
// would arrive as two segments — the validator never sees it
|
|
344
|
-
// as one value;
|
|
345
|
-
// (2) the validator's empty-input rejection ("X value is required")
|
|
346
|
-
// fires before the allow-list check, so an "" in the allow
|
|
347
|
-
// list would be listed-but-undeliverable.
|
|
348
|
-
// Both are the same shape as [LAW:registry-vs-wire drift] caught by
|
|
349
|
-
// registerStateValidator's slash-key check. Catching at factory-build
|
|
350
|
-
// time (config-load) per [LAW:verifiable-goals] surfaces a
|
|
351
|
-
// misconfigured option list immediately, not on the operator's first
|
|
352
|
-
// click. Mirrors registry surface = writable surface, by construction.
|
|
353
|
-
const slashOffenders = allowed.filter((v) => v.includes("/"));
|
|
354
|
-
if (slashOffenders.length > 0) {
|
|
355
|
-
throw new Error(
|
|
356
|
-
`makeAllowListValidator(${label}): values contain "/" — the set-state ` +
|
|
357
|
-
`wire shape splits values on "/" so slash-bearing options cannot ` +
|
|
358
|
-
`be addressed. Offending values: ${slashOffenders.join(", ")}`,
|
|
359
|
-
);
|
|
360
|
-
}
|
|
361
|
-
if (allowed.includes("")) {
|
|
362
|
-
throw new Error(
|
|
363
|
-
`makeAllowListValidator(${label}): empty string is not a writable ` +
|
|
364
|
-
`option — the validator rejects empty input before the allow-list ` +
|
|
365
|
-
`check, so an "" in the allowed list could be rendered but never ` +
|
|
366
|
-
`delivered. Remove "" from the allowed list.`,
|
|
367
|
-
);
|
|
368
|
-
}
|
|
369
|
-
const allowedSet: ReadonlySet<string> = new Set(allowed);
|
|
370
|
-
const allowedList = [...allowed];
|
|
371
|
-
return (raw) => {
|
|
372
|
-
if (!raw) return { ok: false, reason: `${label} value is required` };
|
|
373
|
-
if (!allowedSet.has(raw)) {
|
|
374
|
-
return {
|
|
375
|
-
ok: false,
|
|
376
|
-
reason: `unknown ${label} "${raw}" (have: ${allowedList.join(", ")})`,
|
|
377
|
-
};
|
|
378
|
-
}
|
|
379
|
-
return { ok: true, value: raw };
|
|
380
|
-
};
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
// [LAW:types-are-the-program] An integer-valued state key (a menu's page
|
|
384
|
-
// index). The wire delivers a string; the validator IS the parse boundary —
|
|
385
|
-
// it accepts only `^-?\d+$` and canonicalizes to the minimal decimal form, so
|
|
386
|
-
// "007"/"-0" can't persist a non-canonical page that the next render's `int`
|
|
387
|
-
// read would have to re-normalize. Negative is legal: -1 is the menu's CLOSED
|
|
388
|
-
// sentinel.
|
|
389
|
-
const INT_RE = /^-?\d+$/;
|
|
390
|
-
export function makeIntValidator(label: string): KeyValidator {
|
|
391
|
-
return (raw) => {
|
|
392
|
-
if (!raw) return { ok: false, reason: `${label} value is required` };
|
|
393
|
-
if (!INT_RE.test(raw)) {
|
|
394
|
-
return { ok: false, reason: `${label} must be an integer, got "${raw}"` };
|
|
395
|
-
}
|
|
396
|
-
// [LAW:types-are-the-program] Canonicalize as a pure decimal string —
|
|
397
|
-
// strip leading zeros, fold "-0" → "0" — NOT via parseInt/String, which for
|
|
398
|
-
// a >= 1e21 magnitude would emit scientific notation ("1e+21") that a later
|
|
399
|
-
// parseInt(_, 10) reads back as 1. A page index is small in practice, but
|
|
400
|
-
// the canonical form must hold for every accepted input, not just small ones.
|
|
401
|
-
const neg = raw[0] === "-";
|
|
402
|
-
const digits = (neg ? raw.slice(1) : raw).replace(/^0+/, "");
|
|
403
|
-
if (digits === "") return { ok: true, value: "0" };
|
|
404
|
-
return { ok: true, value: neg ? `-${digits}` : digits };
|
|
405
|
-
};
|
|
117
|
+
return registry.register(key, spec);
|
|
406
118
|
}
|
|
407
119
|
|
|
408
|
-
|
|
409
|
-
// The validator is the parse-AND-clamp boundary: it accepts only `^-?\d+$` then
|
|
410
|
-
// clamps into [min,max]. [LAW:single-enforcer] This is the ONE place bounds are
|
|
411
|
-
// enforced — it owns the [min,max] floor/ceiling for EVERY write to the key,
|
|
412
|
-
// including a hand-typed wire URL. The stepper render owns NAVIGATION (wrap past
|
|
413
|
-
// a bound to the other end) the way the menu render owns page navigation; the
|
|
414
|
-
// stepper only ever emits values already inside bounds, so for stepper clicks
|
|
415
|
-
// this clamp is identity. The clamped result is small (≤ |max| or |min| digits),
|
|
416
|
-
// so String() cannot emit the scientific notation the raw int canonicalizer
|
|
417
|
-
// guards against.
|
|
418
|
-
export function makeRangeValidator(
|
|
419
|
-
min: number,
|
|
420
|
-
max: number,
|
|
421
|
-
label: string,
|
|
422
|
-
): KeyValidator {
|
|
423
|
-
return (raw) => {
|
|
424
|
-
if (!raw) return { ok: false, reason: `${label} value is required` };
|
|
425
|
-
if (!INT_RE.test(raw)) {
|
|
426
|
-
return { ok: false, reason: `${label} must be an integer, got "${raw}"` };
|
|
427
|
-
}
|
|
428
|
-
const clamped = Math.max(min, Math.min(max, parseInt(raw, 10)));
|
|
429
|
-
return { ok: true, value: String(clamped) };
|
|
430
|
-
};
|
|
431
|
-
}
|
|
432
|
-
|
|
433
|
-
// [LAW:one-source-of-truth] The option members a picker draws from ARE the same
|
|
434
|
-
// canonical lists the `themes()`/`styles()`/`looks()` bindings and the baseline
|
|
435
|
-
// theme/style validators consult — the rendered options and the derived gate
|
|
436
|
-
// cannot diverge because there is no second enumeration. "looks" is the one
|
|
437
|
-
// per-config domain: its names come from the config's merged looks block,
|
|
438
|
-
// threaded in as data (the render-side optionDomain takes the same list).
|
|
439
|
-
function optionValuesFor(
|
|
440
|
-
src: OptionSource,
|
|
441
|
-
lookNames: readonly string[],
|
|
442
|
-
): readonly string[] {
|
|
443
|
-
if (src === "themes") return RESOLVABLE_THEMES_LIST;
|
|
444
|
-
if (src === "styles") return STRIP_STYLES;
|
|
445
|
-
return lookNames;
|
|
446
|
-
}
|
|
447
|
-
|
|
448
|
-
// [LAW:types-are-the-program] Collapse one key's spec contributions into the
|
|
449
|
-
// single spec that gates it. A key is an INTEGER spec (a paged cursor `int` or a
|
|
450
|
-
// bounded `range`) or an allow-list — never both. An integer spec ABSORBS
|
|
451
|
-
// integer allow-list members (a trigger writing "0" to a page cursor is a legal
|
|
452
|
-
// int write — the open-trigger pattern), and a NON-integer member aimed at it is
|
|
453
|
-
// the genuine contradiction that throws. Two ranges widen-union; two allow-lists
|
|
454
|
-
// union; an int and a range on one key (a page cursor vs a bounded value) conflict.
|
|
455
|
-
function mergeKeySpecs(
|
|
120
|
+
export function validateStateWrite(
|
|
456
121
|
key: string,
|
|
457
|
-
|
|
458
|
-
):
|
|
459
|
-
|
|
460
|
-
const ranges = specs.filter((s): s is Range => s.kind === "range");
|
|
461
|
-
const hasInt = specs.some((s) => s.kind === "int");
|
|
462
|
-
const allowed = specs.flatMap((s) =>
|
|
463
|
-
s.kind === "allow-list" ? s.allowed : [],
|
|
464
|
-
);
|
|
465
|
-
if (ranges.length === 0 && !hasInt) {
|
|
466
|
-
return { kind: "allow-list", allowed: [...new Set(allowed)] };
|
|
467
|
-
}
|
|
468
|
-
// [LAW:no-silent-fallbacks] An integer spec accepts only integer writes; a
|
|
469
|
-
// non-integer member is a one-key-shape contradiction surfaced at load.
|
|
470
|
-
const nonInt = allowed.filter((v) => !INT_RE.test(v));
|
|
471
|
-
if (nonInt.length > 0) {
|
|
472
|
-
throw new Error(
|
|
473
|
-
`deriveActionValidators: key "${key}" is an integer spec (a paged ` +
|
|
474
|
-
`cursor or a bounded value) but a click writes non-integer ` +
|
|
475
|
-
`value(s) to it (${nonInt.join(", ")}). A state key has one key ` +
|
|
476
|
-
`shape — point that click at a distinct key, or write an integer.`,
|
|
477
|
-
);
|
|
478
|
-
}
|
|
479
|
-
if (hasInt && ranges.length > 0) {
|
|
480
|
-
throw new Error(
|
|
481
|
-
`deriveActionValidators: key "${key}" is declared as both a paged ` +
|
|
482
|
-
`cursor (int) and a bounded value (range) — a state key has one key ` +
|
|
483
|
-
`shape. Use distinct keys.`,
|
|
484
|
-
);
|
|
485
|
-
}
|
|
486
|
-
if (ranges.length > 0) {
|
|
487
|
-
const min = Math.min(...ranges.map((r) => r.min));
|
|
488
|
-
const max = Math.max(...ranges.map((r) => r.max));
|
|
489
|
-
// [LAW:no-silent-fallbacks] A page cursor (int) is UNBOUNDED, so any integer
|
|
490
|
-
// write is a legal member to absorb. A bounded range is BOUNDED — an integer
|
|
491
|
-
// a click declares OUTSIDE [min,max] would be clamped by the range gate at
|
|
492
|
-
// click time, silently storing a different value than the click rendered.
|
|
493
|
-
// That is a config error, surfaced at load rather than papered over at click.
|
|
494
|
-
const outOfRange = allowed.filter((v) => {
|
|
495
|
-
const n = parseInt(v, 10);
|
|
496
|
-
return n < min || n > max;
|
|
497
|
-
});
|
|
498
|
-
if (outOfRange.length > 0) {
|
|
499
|
-
throw new Error(
|
|
500
|
-
`deriveActionValidators: key "${key}" is a bounded range [${min},${max}] ` +
|
|
501
|
-
`but a click writes out-of-range value(s) to it ` +
|
|
502
|
-
`(${outOfRange.join(", ")}). The range gate would clamp them, storing a ` +
|
|
503
|
-
`different value than the click renders — write an in-range integer, ` +
|
|
504
|
-
`or point that click at a distinct key.`,
|
|
505
|
-
);
|
|
506
|
-
}
|
|
507
|
-
// [LAW:one-source-of-truth] Every range contribution to a key carries the
|
|
508
|
-
// same seed (the one backing state variable's default), so any is canonical;
|
|
509
|
-
// re-clamp it into the widened [min,max] to stay an in-range start value.
|
|
510
|
-
const seed = clampSeed(ranges[0]!.seed, min, max);
|
|
511
|
-
return { kind: "range", min, max, seed };
|
|
512
|
-
}
|
|
513
|
-
return { kind: "int" };
|
|
514
|
-
}
|
|
515
|
-
|
|
516
|
-
// [LAW:single-enforcer] A STRUCTURAL spec (menu int / stepper range) is always
|
|
517
|
-
// kept — even on a baseline key — so a collision throws loudly at registration
|
|
518
|
-
// rather than silently shadowing the permanent gate. Only an ALLOW-LIST
|
|
519
|
-
// contribution to a baseline key is dropped (the click reuses the baseline gate
|
|
520
|
-
// as intended). The spec kind IS that discriminator: structural is int/range, an
|
|
521
|
-
// item/onClick spec is allow-list. Shared by both contribution collectors.
|
|
522
|
-
function dropBaselineAllowLists(
|
|
523
|
-
contributions: readonly KeySpecContribution[],
|
|
524
|
-
): KeySpecContribution[] {
|
|
525
|
-
const baseline = new Set(baselineKeys());
|
|
526
|
-
return contributions.filter(
|
|
527
|
-
(c) => c.spec.kind !== "allow-list" || !baseline.has(c.key),
|
|
528
|
-
);
|
|
122
|
+
rawValue: string,
|
|
123
|
+
): ValidateResult {
|
|
124
|
+
return registry.validate(key, rawValue);
|
|
529
125
|
}
|
|
530
126
|
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
// Multiple actions writing the same key (a picker's int page and a trigger's
|
|
534
|
-
// literal "0" are different KINDS to registerStateValidator) resolve here because
|
|
535
|
-
// mergeKeySpecs absorbs an integer allow-list member into the int spec. One
|
|
536
|
-
// merge, one gate per key.
|
|
537
|
-
function mergeContributions(
|
|
538
|
-
contributions: readonly KeySpecContribution[],
|
|
539
|
-
): KeySpecContribution[] {
|
|
540
|
-
const byKey = new Map<string, DerivedValidatorSpec[]>();
|
|
541
|
-
for (const { key, spec } of contributions) {
|
|
542
|
-
const specs = byKey.get(key);
|
|
543
|
-
if (specs) specs.push(spec);
|
|
544
|
-
else byKey.set(key, [spec]);
|
|
545
|
-
}
|
|
546
|
-
return [...byKey].map(([key, specs]) => ({
|
|
547
|
-
key,
|
|
548
|
-
spec: mergeKeySpecs(key, specs),
|
|
549
|
-
}));
|
|
127
|
+
export function rangeParamsFor(key: string): RangeParams | null {
|
|
128
|
+
return registry.rangeParamsFor(key);
|
|
550
129
|
}
|
|
551
130
|
|
|
552
|
-
// [LAW:
|
|
553
|
-
// key SPEC it declares
|
|
554
|
-
//
|
|
555
|
-
//
|
|
556
|
-
// • an option `set` + `from` declares an allow-list of the resolved domain —
|
|
557
|
-
// the SAME canonical list the picker iterates, so the rendered options and
|
|
558
|
-
// the gate cannot diverge;
|
|
559
|
-
// • a bounded `set` + `min/max/by` declares a range [min,max] (the stepper's
|
|
560
|
-
// navigation owns the wrap; the gate owns the bounds — `by` is render-only,
|
|
561
|
-
// never in the spec) plus a `seed` (the unset initial value, read from the
|
|
562
|
-
// backing state variable's `default` so the first relative click steps from
|
|
563
|
-
// the displayed number);
|
|
564
|
-
// • an `int` `set` declares an unbounded int (a paged picker's page cursor —
|
|
565
|
-
// the renderer owns clamping; the gate requires integer shape);
|
|
566
|
-
// • a `cycle` `set` declares an allow-list of its members (the renderer only
|
|
567
|
-
// ever writes the successor member);
|
|
568
|
-
// • copy/open write nothing, so they declare no spec.
|
|
569
|
-
// A new action arm is one new branch here, returning data the existing merge
|
|
570
|
-
// folds — no consumer re-walks an action's shape.
|
|
131
|
+
// [LAW:one-source-of-truth] The ONE place mapping a decoupled ACTION to the
|
|
132
|
+
// validator key SPEC it declares, for `set` (SessionState) actions. See
|
|
133
|
+
// config-validators.ts's actionKeySpecs for the `persist` (config-overrides)
|
|
134
|
+
// twin — same shape, different action key and target keyspace.
|
|
571
135
|
function actionKeySpecs(
|
|
572
136
|
a: ActionDecl,
|
|
573
137
|
seeds: ReadonlyMap<string, number>,
|
|
574
|
-
|
|
138
|
+
perConfigDomains: ReadonlyMap<string, readonly string[]>,
|
|
575
139
|
): KeySpecContribution[] {
|
|
576
140
|
if (!("set" in a)) return [];
|
|
577
141
|
if ("to" in a) {
|
|
@@ -583,22 +147,14 @@ function actionKeySpecs(
|
|
|
583
147
|
key: a.set,
|
|
584
148
|
spec: {
|
|
585
149
|
kind: "allow-list",
|
|
586
|
-
allowed:
|
|
150
|
+
allowed: resolveOptionDomain(a.from, perConfigDomains),
|
|
587
151
|
},
|
|
588
152
|
},
|
|
589
153
|
];
|
|
590
154
|
}
|
|
591
|
-
// [LAW:single-enforcer] An int cursor (a paged picker's page key) gates as an
|
|
592
|
-
// unbounded int — the SAME `int` spec a menu page used. The renderer owns
|
|
593
|
-
// clamping to valid pages; the gate only requires integer shape.
|
|
594
155
|
if ("int" in a) {
|
|
595
156
|
return [{ key: a.set, spec: { kind: "int" } }];
|
|
596
157
|
}
|
|
597
|
-
// [LAW:one-source-of-truth] A cycle's members ARE its gate: the renderer only
|
|
598
|
-
// ever writes a member (the successor of the current value), and the
|
|
599
|
-
// allow-list admits exactly the members. Sharing groups' cycles on one key
|
|
600
|
-
// union here like any other allow-list contributions — that union IS the
|
|
601
|
-
// accordion's writable path set.
|
|
602
158
|
if ("cycle" in a) {
|
|
603
159
|
return [{ key: a.set, spec: { kind: "allow-list", allowed: a.cycle } }];
|
|
604
160
|
}
|
|
@@ -615,22 +171,33 @@ function actionKeySpecs(
|
|
|
615
171
|
];
|
|
616
172
|
}
|
|
617
173
|
|
|
618
|
-
// [LAW:
|
|
619
|
-
//
|
|
620
|
-
//
|
|
621
|
-
//
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
174
|
+
// [LAW:single-enforcer] A STRUCTURAL spec (menu int / stepper range) is
|
|
175
|
+
// always kept — even on a baseline key — so a collision throws loudly at
|
|
176
|
+
// registration rather than silently shadowing the permanent gate. Only an
|
|
177
|
+
// ALLOW-LIST contribution to a baseline key is dropped (the click reuses the
|
|
178
|
+
// baseline gate as intended).
|
|
179
|
+
//
|
|
180
|
+
// [LAW:one-source-of-truth] The baseline set is read from the registry that
|
|
181
|
+
// owns it (registry.listBaselineKeys()), not re-declared here — the baseline
|
|
182
|
+
// keys were passed to createValidatorRegistry above; a second hardcoded list
|
|
183
|
+
// could silently drift from them if a future baseline key were added there
|
|
184
|
+
// and forgotten here.
|
|
185
|
+
function dropBaselineAllowLists(
|
|
186
|
+
contributions: readonly KeySpecContribution[],
|
|
187
|
+
): KeySpecContribution[] {
|
|
188
|
+
const baseline = new Set(registry.listBaselineKeys());
|
|
189
|
+
return contributions.filter(
|
|
190
|
+
(c) => c.spec.kind !== "allow-list" || !baseline.has(c.key),
|
|
191
|
+
);
|
|
625
192
|
}
|
|
626
193
|
|
|
627
194
|
// [LAW:one-source-of-truth] Each `state` variable's integer `default` is the
|
|
628
195
|
// initial value of its key — the value the bar renders before any click. The
|
|
629
|
-
// step-state handler must seed an unset key from the SAME number, so the
|
|
630
|
-
// range spec carries it.
|
|
631
|
-
// (the key seeds from `min`).
|
|
196
|
+
// step-state handler must seed an unset key from the SAME number, so the
|
|
197
|
+
// derived range spec carries it.
|
|
632
198
|
function stateKeySeeds(config: DslConfig): ReadonlyMap<string, number> {
|
|
633
199
|
const seeds = new Map<string, number>();
|
|
200
|
+
const INT_RE = /^-?\d+$/;
|
|
634
201
|
for (const decl of Object.values(config.variables)) {
|
|
635
202
|
if (decl.kind !== "state") continue;
|
|
636
203
|
const raw = decl.default;
|
|
@@ -641,79 +208,24 @@ function stateKeySeeds(config: DslConfig): ReadonlyMap<string, number> {
|
|
|
641
208
|
return seeds;
|
|
642
209
|
}
|
|
643
210
|
|
|
644
|
-
// [LAW:one-source-of-truth] The writable-key surface a config's
|
|
645
|
-
// DERIVED from the action table — the same declarations the
|
|
646
|
-
// realizes a click from are the gate the wire enforces.
|
|
211
|
+
// [LAW:one-source-of-truth] The writable-key surface a config's `set` actions
|
|
212
|
+
// need, DERIVED from the action table — the same declarations the
|
|
213
|
+
// `{{ action }}` fn realizes a click from are the gate the wire enforces.
|
|
647
214
|
function actionContributions(config: DslConfig): KeySpecContribution[] {
|
|
648
215
|
const seeds = stateKeySeeds(config);
|
|
649
|
-
const
|
|
216
|
+
const perConfigDomains = perConfigDomainsFor(config);
|
|
650
217
|
return dropBaselineAllowLists(
|
|
651
218
|
Object.values(config.actions).flatMap((a) =>
|
|
652
|
-
actionKeySpecs(a, seeds,
|
|
219
|
+
actionKeySpecs(a, seeds, perConfigDomains),
|
|
653
220
|
),
|
|
654
221
|
);
|
|
655
222
|
}
|
|
656
223
|
|
|
657
|
-
// [LAW:single-enforcer] The SOLE install-site derivation: a config's
|
|
658
|
-
// surface is the merge of every ACTION it
|
|
659
|
-
//
|
|
660
|
-
// the `{{ action }}`/`{{ picker }}` funcs realize clicks from are the gate the
|
|
661
|
-
// wire enforces. mergeContributions resolves any intra-table key collision (a
|
|
662
|
-
// picker's int page and a trigger's literal "0" on one key) into one gate.
|
|
224
|
+
// [LAW:single-enforcer] The SOLE install-site derivation: a config's
|
|
225
|
+
// SessionState-writable-key surface is the merge of every `set` ACTION it
|
|
226
|
+
// declares, through ONE coherence pass.
|
|
663
227
|
export function deriveActionValidators(
|
|
664
228
|
config: DslConfig,
|
|
665
229
|
): readonly KeySpecContribution[] {
|
|
666
230
|
return mergeContributions(actionContributions(config));
|
|
667
231
|
}
|
|
668
|
-
|
|
669
|
-
// [LAW:dataflow-not-control-flow] Single entry point for validation: the
|
|
670
|
-
// caller hands over (key, value), this returns a uniform ValidateResult
|
|
671
|
-
// regardless of whether the key was known. The verb body never branches
|
|
672
|
-
// on "did I get a validator" — the absence of a validator IS the rejection.
|
|
673
|
-
export function validateStateWrite(
|
|
674
|
-
key: string,
|
|
675
|
-
rawValue: string,
|
|
676
|
-
): ValidateResult {
|
|
677
|
-
const entry = _STATE_VALIDATORS.get(key);
|
|
678
|
-
if (!entry) {
|
|
679
|
-
return {
|
|
680
|
-
ok: false,
|
|
681
|
-
reason: `unknown state key "${key}" (have: ${listStateKeys().join(", ")})`,
|
|
682
|
-
};
|
|
683
|
-
}
|
|
684
|
-
return entry.validator(rawValue);
|
|
685
|
-
}
|
|
686
|
-
|
|
687
|
-
// [LAW:types-are-the-program] The bounded-step parameters of a key: the (widened)
|
|
688
|
-
// [min,max] the step wraps within plus the `seed` an unset key starts from. The
|
|
689
|
-
// step-state handler reads these to compute `wrap(current ± by)` against LIVE
|
|
690
|
-
// state — the link carries only the signed `by`, so every numeric the wrap needs
|
|
691
|
-
// lives here in the single registry, never snapshotted into the link.
|
|
692
|
-
export interface RangeParams {
|
|
693
|
-
readonly min: number;
|
|
694
|
-
readonly max: number;
|
|
695
|
-
readonly seed: number;
|
|
696
|
-
}
|
|
697
|
-
|
|
698
|
-
// [LAW:one-source-of-truth] The registry IS the source of a key's bounds; the
|
|
699
|
-
// step handler reads them through this one boundary rather than re-deriving from
|
|
700
|
-
// the config. A key with no range registration (unknown, baseline, allow-list,
|
|
701
|
-
// or int) returns null — the handler rejects it as "not a stepper" loudly,
|
|
702
|
-
// never silently treating it as a step target.
|
|
703
|
-
export function rangeParamsFor(key: string): RangeParams | null {
|
|
704
|
-
const entry = _STATE_VALIDATORS.get(key);
|
|
705
|
-
if (!entry || entry.permanent || entry.kind !== "range") return null;
|
|
706
|
-
// [LAW:one-source-of-truth] The bounds/seed come from THE same collapse the
|
|
707
|
-
// validator is built from (mergeKeySpecs) — no second widen/clamp lives here.
|
|
708
|
-
const spec = mergeKeySpecs(key, entry.specs);
|
|
709
|
-
// [LAW:no-silent-failure] entry.kind === "range" means every live spec is a
|
|
710
|
-
// range (registration rejects a kind change), so the collapse is a range too;
|
|
711
|
-
// a non-range here is a broken invariant, surfaced loudly, not a silent null.
|
|
712
|
-
if (spec.kind !== "range") {
|
|
713
|
-
throw new Error(
|
|
714
|
-
`rangeParamsFor: key "${key}" holds range specs but the merge produced ` +
|
|
715
|
-
`a ${spec.kind} spec — the entry-kind invariant is broken.`,
|
|
716
|
-
);
|
|
717
|
-
}
|
|
718
|
-
return { min: spec.min, max: spec.max, seed: spec.seed };
|
|
719
|
-
}
|