@promptctl/cc-candybar 1.0.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/LICENSE +21 -0
- package/README.md +145 -0
- package/bin/cc-candybar +6 -0
- package/dist/index.mjs +185 -0
- package/package.json +99 -0
- package/plugin/.claude-plugin/plugin.json +11 -0
- package/plugin/bin/preview.sh +305 -0
- package/plugin/commands/candybar.md +403 -0
- package/plugin/templates/config-essential.json +36 -0
- package/plugin/templates/config-full.json +55 -0
- package/plugin/templates/config-standard.json +39 -0
- package/plugin/templates/config-tui-compact.json +48 -0
- package/plugin/templates/config-tui-full.json +89 -0
- package/plugin/templates/config-tui-standard.json +56 -0
- package/plugin/templates/config-tui.json +18 -0
- package/plugin/templates/nerd-fonts-sample.txt +5 -0
- package/schema/cc-candybar.schema.json +1379 -0
- package/src/click/wire.ts +113 -0
- package/src/config/action.ts +91 -0
- package/src/config/cli.ts +170 -0
- package/src/config/default-dsl-config.ts +661 -0
- package/src/config/dsl-loader.ts +265 -0
- package/src/config/dsl-types.ts +425 -0
- package/src/config/loader/actions.ts +530 -0
- package/src/config/loader/cache.ts +206 -0
- package/src/config/loader/cross-ref.ts +326 -0
- package/src/config/loader/cycles.ts +148 -0
- package/src/config/loader/diagnostics.ts +99 -0
- package/src/config/loader/discovery.ts +182 -0
- package/src/config/loader/emit-schema.ts +63 -0
- package/src/config/loader/globals.ts +42 -0
- package/src/config/loader/helpers.ts +48 -0
- package/src/config/loader/layout.ts +688 -0
- package/src/config/loader/merge.ts +40 -0
- package/src/config/loader/refs.ts +96 -0
- package/src/config/loader/segments.ts +120 -0
- package/src/config/loader/validate-core.ts +674 -0
- package/src/config/loader/variables.ts +260 -0
- package/src/daemon/acquire.ts +411 -0
- package/src/daemon/cache/git.ts +553 -0
- package/src/daemon/cache/render.ts +449 -0
- package/src/daemon/cache/session-usage-store.ts +446 -0
- package/src/daemon/cache/watchers.ts +245 -0
- package/src/daemon/client-debug.ts +120 -0
- package/src/daemon/client-stats.ts +129 -0
- package/src/daemon/client-transport.ts +273 -0
- package/src/daemon/client.ts +75 -0
- package/src/daemon/debug-types.ts +91 -0
- package/src/daemon/debug.ts +264 -0
- package/src/daemon/limits.ts +154 -0
- package/src/daemon/log.ts +69 -0
- package/src/daemon/parent-watchdog.ts +80 -0
- package/src/daemon/paths.ts +127 -0
- package/src/daemon/protocol.ts +235 -0
- package/src/daemon/render-payload.ts +611 -0
- package/src/daemon/server.ts +1103 -0
- package/src/daemon/session-state-file.ts +108 -0
- package/src/daemon/session-state.ts +237 -0
- package/src/daemon/stats.ts +229 -0
- package/src/daemon/verbs/index.ts +458 -0
- package/src/daemon/verbs/state-validators.ts +708 -0
- package/src/demo/dsl.ts +117 -0
- package/src/demo/mock-data.ts +67 -0
- package/src/demo/statusline.json5 +92 -0
- package/src/dsl/node-registry.ts +281 -0
- package/src/dsl/render.ts +558 -0
- package/src/index.ts +206 -0
- package/src/install/index.ts +410 -0
- package/src/proc/launch.ts +451 -0
- package/src/proc/stats-handle.ts +13 -0
- package/src/render/action.ts +458 -0
- package/src/render/diagnostic-style.ts +23 -0
- package/src/render/diagnostic-text.ts +77 -0
- package/src/render/error-glyph.ts +53 -0
- package/src/render/outcome-plan.ts +45 -0
- package/src/render/picker.ts +231 -0
- package/src/render/split-lines.ts +51 -0
- package/src/render/strip.ts +103 -0
- package/src/segments/cache.ts +131 -0
- package/src/segments/context.ts +190 -0
- package/src/segments/git.ts +561 -0
- package/src/segments/metrics.ts +101 -0
- package/src/segments/pricing.ts +452 -0
- package/src/segments/session.ts +188 -0
- package/src/segments/tmux.ts +74 -0
- package/src/template-engine/cells.ts +90 -0
- package/src/template-engine/colors.ts +102 -0
- package/src/template-engine/engine.ts +108 -0
- package/src/template-engine/funcs.ts +216 -0
- package/src/template-engine/index.ts +11 -0
- package/src/template-engine/layout.ts +112 -0
- package/src/template-engine/scope.ts +62 -0
- package/src/themes/index.ts +19 -0
- package/src/themes/palette-resolvers.ts +86 -0
- package/src/themes/policy.ts +79 -0
- package/src/themes/session-random.ts +88 -0
- package/src/utils/cache.ts +206 -0
- package/src/utils/claude.ts +616 -0
- package/src/utils/color-support.ts +118 -0
- package/src/utils/formatters.ts +77 -0
- package/src/utils/logger.ts +5 -0
- package/src/utils/outcome.ts +33 -0
- package/src/utils/schema-validator.ts +126 -0
- package/src/utils/single-flight.ts +57 -0
- package/src/utils/terminal-width.ts +43 -0
- package/src/utils/terminal.ts +11 -0
- package/src/utils/transcript-fs.ts +162 -0
- package/src/var-system/index.ts +24 -0
- package/src/var-system/sources.ts +1038 -0
- package/src/var-system/store.ts +223 -0
- package/src/var-system/types.ts +57 -0
|
@@ -0,0 +1,708 @@
|
|
|
1
|
+
// [LAW:single-enforcer] One registry that names every SessionState key the
|
|
2
|
+
// `set-state` verb is allowed to write, paired with the per-key validator
|
|
3
|
+
// that decides whether a raw incoming string is a legal value for that key.
|
|
4
|
+
// Adding a new state-writable key is one entry in this table — no new verb,
|
|
5
|
+
// no scattered string-matching, no defensive guard in the dispatcher.
|
|
6
|
+
//
|
|
7
|
+
// [LAW:one-source-of-truth] The registered keys ARE the schema for what
|
|
8
|
+
// SessionState mutations the click protocol can perform. Tests assert
|
|
9
|
+
// against this table directly so the live schema and the test enumeration
|
|
10
|
+
// cannot drift. Unknown-key rejection lists these names — operators see
|
|
11
|
+
// exactly the surface they're allowed to write.
|
|
12
|
+
//
|
|
13
|
+
// [LAW:no-silent-fallbacks] An unknown key throws BAD_REQUEST at the
|
|
14
|
+
// dispatcher; never accept-and-store an unvalidated key. A typo on the
|
|
15
|
+
// wire surfaces as a structured rejection, not a silent corruption of
|
|
16
|
+
// SessionState.
|
|
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.
|
|
26
|
+
|
|
27
|
+
import { listResolvablePaletteNames, STYLE_ORDER } from "../../themes/policy";
|
|
28
|
+
import type { ActionDecl, OptionSource } from "../../config/action";
|
|
29
|
+
import type { DslConfig } from "../../config/dsl-types";
|
|
30
|
+
|
|
31
|
+
// [LAW:one-source-of-truth] One contribution shape — a (key, spec) pair — every
|
|
32
|
+
// action's `set` declaration projects to. mergeContributions folds a list of
|
|
33
|
+
// these into the final per-key validator specs, so multiple actions writing one
|
|
34
|
+
// key feed ONE coherence merge regardless of which action authored the write.
|
|
35
|
+
interface KeySpecContribution {
|
|
36
|
+
readonly key: string;
|
|
37
|
+
readonly spec: DerivedValidatorSpec;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// [LAW:types-are-the-program] Discriminated union — every legal return is
|
|
41
|
+
// either an accepted-and-canonicalized string or a structured rejection
|
|
42
|
+
// reason. There is no third state (no `null`, no thrown exception path
|
|
43
|
+
// inside a validator). The verb body matches exhaustively on `ok`.
|
|
44
|
+
export type ValidateResult =
|
|
45
|
+
| { ok: true; value: string }
|
|
46
|
+
| { ok: false; reason: string };
|
|
47
|
+
|
|
48
|
+
// [LAW:one-type-per-behavior] All key validators have the same shape —
|
|
49
|
+
// they don't carry the key name, the registry does. The validator's only
|
|
50
|
+
// concern is: does this raw string belong in this key's value-set?
|
|
51
|
+
export type KeyValidator = (rawValue: string) => ValidateResult;
|
|
52
|
+
|
|
53
|
+
// [LAW:types-are-the-program] A derived key's SEMANTIC identity — the data a
|
|
54
|
+
// widget config declares about a custom SessionState key, from which the
|
|
55
|
+
// validator is residue. A key is one of three key shapes: an integer (a
|
|
56
|
+
// menu's page index), an allow-list (the union of values some button can write),
|
|
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
|
+
};
|
|
82
|
+
|
|
83
|
+
// [LAW:one-source-of-truth] listResolvablePaletteNames is THE set whose
|
|
84
|
+
// members resolve to a concrete Palette. The broader listAvailableThemes
|
|
85
|
+
// includes the "custom" sentinel (read inline colors) which is not a
|
|
86
|
+
// renderable theme name; accepting "custom" here would persist an
|
|
87
|
+
// unrenderable value into SessionState 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 STYLE_ORDER
|
|
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
|
+
const RESOLVABLE_THEMES_LIST: readonly string[] = listResolvablePaletteNames();
|
|
96
|
+
const RESOLVABLE_THEMES: ReadonlySet<string> = new Set(RESOLVABLE_THEMES_LIST);
|
|
97
|
+
const RESOLVABLE_STYLES: ReadonlySet<string> = new Set(STYLE_ORDER);
|
|
98
|
+
|
|
99
|
+
const validateTheme: KeyValidator = (raw) => {
|
|
100
|
+
if (!raw) return { ok: false, reason: "theme name is required" };
|
|
101
|
+
if (!RESOLVABLE_THEMES.has(raw)) {
|
|
102
|
+
return {
|
|
103
|
+
ok: false,
|
|
104
|
+
reason: `unknown theme "${raw}" (have: ${RESOLVABLE_THEMES_LIST.join(", ")})`,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
return { ok: true, value: raw };
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const validateStyle: KeyValidator = (raw) => {
|
|
111
|
+
if (!raw) return { ok: false, reason: "style name is required" };
|
|
112
|
+
if (!RESOLVABLE_STYLES.has(raw)) {
|
|
113
|
+
return {
|
|
114
|
+
ok: false,
|
|
115
|
+
reason: `unknown style "${raw}" (have: ${STYLE_ORDER.join(", ")})`,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
return { ok: true, value: raw };
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
// [LAW:dataflow-not-control-flow] Boolean-ish accepts exactly four
|
|
122
|
+
// canonical inputs and normalizes to two canonical outputs: truthy
|
|
123
|
+
// ("1"/"true") → "1", falsy ("0"/"false") → "" (empty). The empty
|
|
124
|
+
// falsy sentinel matches what `toolbar-toggle` produces via `clear()`
|
|
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.
|
|
138
|
+
const BOOLEAN_TRUTHY = new Set(["1", "true"]);
|
|
139
|
+
const BOOLEAN_FALSY = new Set(["0", "false"]);
|
|
140
|
+
const validateBoolean: KeyValidator = (raw) => {
|
|
141
|
+
if (BOOLEAN_TRUTHY.has(raw)) return { ok: true, value: "1" };
|
|
142
|
+
if (BOOLEAN_FALSY.has(raw)) return { ok: true, value: "" };
|
|
143
|
+
return {
|
|
144
|
+
ok: false,
|
|
145
|
+
reason: `expected boolean-ish (1, 0, true, false), got "${raw}"`,
|
|
146
|
+
};
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
// [LAW:types-are-the-program] Two registry-entry shapes, discriminated by
|
|
150
|
+
// `permanent`. A baseline entry is a fixed built-in validator (theme/style/
|
|
151
|
+
// toolbar-expanded) that can never be removed or re-claimed. A derived entry
|
|
152
|
+
// holds the LIVE registrations for a widget-installed key: each registration
|
|
153
|
+
// contributes a spec, the entry's `validator` is rebuilt as their merge, and
|
|
154
|
+
// `specs.length` IS the ref-count. The same derived key legitimately registers
|
|
155
|
+
// more than once — multiple cache entries share one config (one repo, two
|
|
156
|
+
// cwds), and a hot-reload builds the new state's validators BEFORE disposing the
|
|
157
|
+
// old (so the key is briefly held twice). The merged validator stays valid
|
|
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
|
+
]);
|
|
191
|
+
|
|
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
|
+
export function listStateKeys(): readonly string[] {
|
|
196
|
+
return [..._STATE_VALIDATORS.keys()];
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// [LAW:types-are-the-program] The validator is RESIDUE of the live specs: given
|
|
200
|
+
// the (uniform) kind and every live registration's content, the validator is
|
|
201
|
+
// forced. An int key builds a parse-boundary validator; an allow-list key builds
|
|
202
|
+
// one from the UNION of every live registration's members — so a value any live
|
|
203
|
+
// config can legitimately render is a value the wire accepts, by construction.
|
|
204
|
+
// The label is a pure function of (key, kind) so the built validator is identical
|
|
205
|
+
// across registrations of one key. makeIntValidator/makeAllowListValidator are
|
|
206
|
+
// the single validator constructors (re-validating slash/empty values), so a
|
|
207
|
+
// merged allow-list that somehow held an undeliverable value would throw HERE,
|
|
208
|
+
// at config-load, not at the operator's first click.
|
|
209
|
+
function buildValidatorFromSpecs(
|
|
210
|
+
key: string,
|
|
211
|
+
kind: DerivedValidatorSpec["kind"],
|
|
212
|
+
specs: readonly DerivedValidatorSpec[],
|
|
213
|
+
): KeyValidator {
|
|
214
|
+
if (kind === "int") return makeIntValidator(`menu page "${key}"`);
|
|
215
|
+
if (kind === "range") {
|
|
216
|
+
// [LAW:types-are-the-program] Two configs declaring one stepper key with
|
|
217
|
+
// different bounds widen to the UNION range — parity with allow-list's
|
|
218
|
+
// member union: a value any live config can legitimately render (step into)
|
|
219
|
+
// is a value the wire accepts. The clamp is to the widest live bounds, so
|
|
220
|
+
// the gate never rejects a write a narrower co-resident stepper could make.
|
|
221
|
+
const mins = specs.flatMap((s) => (s.kind === "range" ? [s.min] : []));
|
|
222
|
+
const maxs = specs.flatMap((s) => (s.kind === "range" ? [s.max] : []));
|
|
223
|
+
return makeRangeValidator(
|
|
224
|
+
Math.min(...mins),
|
|
225
|
+
Math.max(...maxs),
|
|
226
|
+
`stepper "${key}"`,
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
const allowed = [
|
|
230
|
+
...new Set(
|
|
231
|
+
specs.flatMap((s) => (s.kind === "allow-list" ? s.allowed : [])),
|
|
232
|
+
),
|
|
233
|
+
];
|
|
234
|
+
return makeAllowListValidator(allowed, `state "${key}"`);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// [LAW:locality-or-seam] The widget config (a config-load consumer) owns the
|
|
238
|
+
// lifecycle of the validators it installs; this returns a disposer rather than
|
|
239
|
+
// coupling the registry to a global "config reload" event. The cache's
|
|
240
|
+
// reloadInto installs the new state's validators, then disposes the old — the
|
|
241
|
+
// dispose-before-swap contract that keeps a broken reload from corrupting
|
|
242
|
+
// last-known-good. See src/daemon/cache/render.ts.
|
|
243
|
+
//
|
|
244
|
+
// [LAW:no-silent-fallbacks] A baseline (permanent) key cannot be re-claimed —
|
|
245
|
+
// re-registering one throws, so a menu naming its page key `theme` surfaces a
|
|
246
|
+
// loud config-load error rather than silently shadowing the theme gate.
|
|
247
|
+
//
|
|
248
|
+
// [LAW:types-are-the-program] The semantic-compatibility gate: a key has ONE
|
|
249
|
+
// key shape. Registering an `int` spec for a key already held as `allow-list`
|
|
250
|
+
// (or vice versa) is a genuine conflict — no merged validator could honor both —
|
|
251
|
+
// so it throws at config-load, not silently keeps whichever loaded first. Two
|
|
252
|
+
// registrations of the SAME kind merge: their specs accumulate and the validator
|
|
253
|
+
// is rebuilt as their union, so two cache entries sharing a config (identical
|
|
254
|
+
// specs → idempotent) and two distinct configs sharing a key (different members
|
|
255
|
+
// → unioned, both deliverable) both resolve to one consistent gate.
|
|
256
|
+
//
|
|
257
|
+
// [LAW:single-enforcer] The disposer removes exactly its own spec once
|
|
258
|
+
// (idempotent via the `active` flag), rebuilds the validator from what remains,
|
|
259
|
+
// and deletes the key only when the last spec is gone.
|
|
260
|
+
export function registerStateValidator(
|
|
261
|
+
key: string,
|
|
262
|
+
spec: DerivedValidatorSpec,
|
|
263
|
+
): () => void {
|
|
264
|
+
if (!key) {
|
|
265
|
+
throw new Error("registerStateValidator: key is required");
|
|
266
|
+
}
|
|
267
|
+
// [LAW:types-are-the-program] The set-state wire splits its tail on `/`, so a
|
|
268
|
+
// slash-bearing key can never be addressed — listing it would be registry-vs-
|
|
269
|
+
// wire drift. Reject at registration so the unreachable-but-listed state is
|
|
270
|
+
// unrepresentable.
|
|
271
|
+
if (key.includes("/")) {
|
|
272
|
+
throw new Error(
|
|
273
|
+
`registerStateValidator: key "${key}" contains "/" — the set-state ` +
|
|
274
|
+
`wire shape splits on "/" so a slash-bearing key cannot be ` +
|
|
275
|
+
`addressed. Use a slash-free key.`,
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
const existing = _STATE_VALIDATORS.get(key);
|
|
279
|
+
if (existing) {
|
|
280
|
+
if (existing.permanent) {
|
|
281
|
+
throw new Error(
|
|
282
|
+
`registerStateValidator: key "${key}" is a built-in state key and ` +
|
|
283
|
+
`cannot be re-claimed (built-in keys: ${[...baselineKeys()].join(", ")})`,
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
if (existing.kind !== spec.kind) {
|
|
287
|
+
throw new Error(
|
|
288
|
+
`registerStateValidator: key "${key}" is already a ${existing.kind} ` +
|
|
289
|
+
`state key; cannot also register it as ${spec.kind}. A state key has ` +
|
|
290
|
+
`one key shape — a menu page index (int) and a button allow-list ` +
|
|
291
|
+
`cannot share a key.`,
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
existing.specs.push(spec);
|
|
295
|
+
existing.validator = buildValidatorFromSpecs(
|
|
296
|
+
key,
|
|
297
|
+
existing.kind,
|
|
298
|
+
existing.specs,
|
|
299
|
+
);
|
|
300
|
+
} else {
|
|
301
|
+
const specs = [spec];
|
|
302
|
+
_STATE_VALIDATORS.set(key, {
|
|
303
|
+
permanent: false,
|
|
304
|
+
kind: spec.kind,
|
|
305
|
+
validator: buildValidatorFromSpecs(key, spec.kind, specs),
|
|
306
|
+
specs,
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
let active = true;
|
|
310
|
+
return () => {
|
|
311
|
+
if (!active) return;
|
|
312
|
+
active = false;
|
|
313
|
+
const entry = _STATE_VALIDATORS.get(key);
|
|
314
|
+
if (!entry || entry.permanent) return;
|
|
315
|
+
const i = entry.specs.indexOf(spec);
|
|
316
|
+
if (i >= 0) entry.specs.splice(i, 1);
|
|
317
|
+
if (entry.specs.length === 0) {
|
|
318
|
+
_STATE_VALIDATORS.delete(key);
|
|
319
|
+
} else {
|
|
320
|
+
entry.validator = buildValidatorFromSpecs(key, entry.kind, entry.specs);
|
|
321
|
+
}
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function baselineKeys(): readonly string[] {
|
|
326
|
+
const out: string[] = [];
|
|
327
|
+
for (const [key, entry] of _STATE_VALIDATORS) {
|
|
328
|
+
if (entry.permanent) out.push(key);
|
|
329
|
+
}
|
|
330
|
+
return out;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// [LAW:one-type-per-behavior] The "values come from list Y" pattern IS
|
|
334
|
+
// the canonical widget-config use case (theme picker draws from
|
|
335
|
+
// themes(), style picker draws from styles(), a custom enum picker
|
|
336
|
+
// draws from a user-declared list). One factory builds the validator
|
|
337
|
+
// from the list — every callsite that registers an allow-list key
|
|
338
|
+
// passes through the same shape, so error messages, empty-input
|
|
339
|
+
// rejection, and lookup semantics are identical by construction.
|
|
340
|
+
//
|
|
341
|
+
// [LAW:no-silent-fallbacks] Empty input is rejected with a label-
|
|
342
|
+
// referencing reason rather than silently mapped to a default — the
|
|
343
|
+
// shape matches validateTheme/validateStyle so the operator experience
|
|
344
|
+
// is consistent across baseline and widget-installed keys.
|
|
345
|
+
export function makeAllowListValidator(
|
|
346
|
+
allowed: readonly string[],
|
|
347
|
+
label: string,
|
|
348
|
+
): KeyValidator {
|
|
349
|
+
// [LAW:types-are-the-program] The factory's contract is "options =
|
|
350
|
+
// allow list" — every value the picker can RENDER must also be a
|
|
351
|
+
// value the wire can DELIVER. Two structural reasons a declared
|
|
352
|
+
// option can't reach the validator as itself:
|
|
353
|
+
// (1) the wire splits the tail on "/", so a slash-bearing value
|
|
354
|
+
// would arrive as two segments — the validator never sees it
|
|
355
|
+
// as one value;
|
|
356
|
+
// (2) the validator's empty-input rejection ("X value is required")
|
|
357
|
+
// fires before the allow-list check, so an "" in the allow
|
|
358
|
+
// list would be listed-but-undeliverable.
|
|
359
|
+
// Both are the same shape as [LAW:registry-vs-wire drift] caught by
|
|
360
|
+
// registerStateValidator's slash-key check. Catching at factory-build
|
|
361
|
+
// time (config-load) per [LAW:verifiable-goals] surfaces a
|
|
362
|
+
// misconfigured option list immediately, not on the operator's first
|
|
363
|
+
// click. Mirrors registry surface = writable surface, by construction.
|
|
364
|
+
const slashOffenders = allowed.filter((v) => v.includes("/"));
|
|
365
|
+
if (slashOffenders.length > 0) {
|
|
366
|
+
throw new Error(
|
|
367
|
+
`makeAllowListValidator(${label}): values contain "/" — the set-state ` +
|
|
368
|
+
`wire shape splits values on "/" so slash-bearing options cannot ` +
|
|
369
|
+
`be addressed. Offending values: ${slashOffenders.join(", ")}`,
|
|
370
|
+
);
|
|
371
|
+
}
|
|
372
|
+
if (allowed.includes("")) {
|
|
373
|
+
throw new Error(
|
|
374
|
+
`makeAllowListValidator(${label}): empty string is not a writable ` +
|
|
375
|
+
`option — the validator rejects empty input before the allow-list ` +
|
|
376
|
+
`check, so an "" in the allowed list could be rendered but never ` +
|
|
377
|
+
`delivered. Remove "" from the allowed list.`,
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
const allowedSet: ReadonlySet<string> = new Set(allowed);
|
|
381
|
+
const allowedList = [...allowed];
|
|
382
|
+
return (raw) => {
|
|
383
|
+
if (!raw) return { ok: false, reason: `${label} value is required` };
|
|
384
|
+
if (!allowedSet.has(raw)) {
|
|
385
|
+
return {
|
|
386
|
+
ok: false,
|
|
387
|
+
reason: `unknown ${label} "${raw}" (have: ${allowedList.join(", ")})`,
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
return { ok: true, value: raw };
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// [LAW:types-are-the-program] An integer-valued state key (a menu's page
|
|
395
|
+
// index). The wire delivers a string; the validator IS the parse boundary —
|
|
396
|
+
// it accepts only `^-?\d+$` and canonicalizes to the minimal decimal form, so
|
|
397
|
+
// "007"/"-0" can't persist a non-canonical page that the next render's `int`
|
|
398
|
+
// read would have to re-normalize. Negative is legal: -1 is the menu's CLOSED
|
|
399
|
+
// sentinel.
|
|
400
|
+
const INT_RE = /^-?\d+$/;
|
|
401
|
+
export function makeIntValidator(label: string): KeyValidator {
|
|
402
|
+
return (raw) => {
|
|
403
|
+
if (!raw) return { ok: false, reason: `${label} value is required` };
|
|
404
|
+
if (!INT_RE.test(raw)) {
|
|
405
|
+
return { ok: false, reason: `${label} must be an integer, got "${raw}"` };
|
|
406
|
+
}
|
|
407
|
+
// [LAW:types-are-the-program] Canonicalize as a pure decimal string —
|
|
408
|
+
// strip leading zeros, fold "-0" → "0" — NOT via parseInt/String, which for
|
|
409
|
+
// a >= 1e21 magnitude would emit scientific notation ("1e+21") that a later
|
|
410
|
+
// parseInt(_, 10) reads back as 1. A page index is small in practice, but
|
|
411
|
+
// the canonical form must hold for every accepted input, not just small ones.
|
|
412
|
+
const neg = raw[0] === "-";
|
|
413
|
+
const digits = (neg ? raw.slice(1) : raw).replace(/^0+/, "");
|
|
414
|
+
if (digits === "") return { ok: true, value: "0" };
|
|
415
|
+
return { ok: true, value: neg ? `-${digits}` : digits };
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// [LAW:types-are-the-program] A bounded-integer state key (a stepper's value).
|
|
420
|
+
// The validator is the parse-AND-clamp boundary: it accepts only `^-?\d+$` then
|
|
421
|
+
// clamps into [min,max]. [LAW:single-enforcer] This is the ONE place bounds are
|
|
422
|
+
// enforced — it owns the [min,max] floor/ceiling for EVERY write to the key,
|
|
423
|
+
// including a hand-typed wire URL. The stepper render owns NAVIGATION (wrap past
|
|
424
|
+
// a bound to the other end) the way the menu render owns page navigation; the
|
|
425
|
+
// stepper only ever emits values already inside bounds, so for stepper clicks
|
|
426
|
+
// this clamp is identity. The clamped result is small (≤ |max| or |min| digits),
|
|
427
|
+
// so String() cannot emit the scientific notation the raw int canonicalizer
|
|
428
|
+
// guards against.
|
|
429
|
+
export function makeRangeValidator(
|
|
430
|
+
min: number,
|
|
431
|
+
max: number,
|
|
432
|
+
label: string,
|
|
433
|
+
): KeyValidator {
|
|
434
|
+
return (raw) => {
|
|
435
|
+
if (!raw) return { ok: false, reason: `${label} value is required` };
|
|
436
|
+
if (!INT_RE.test(raw)) {
|
|
437
|
+
return { ok: false, reason: `${label} must be an integer, got "${raw}"` };
|
|
438
|
+
}
|
|
439
|
+
const clamped = Math.max(min, Math.min(max, parseInt(raw, 10)));
|
|
440
|
+
return { ok: true, value: String(clamped) };
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// [LAW:one-source-of-truth] The option members a picker draws from ARE the same
|
|
445
|
+
// canonical lists the `themes()`/`styles()` bindings and the baseline theme/
|
|
446
|
+
// style validators consult — the rendered options and the derived gate cannot
|
|
447
|
+
// diverge because there is no second enumeration.
|
|
448
|
+
function optionValuesFor(src: OptionSource): readonly string[] {
|
|
449
|
+
return src === "themes" ? RESOLVABLE_THEMES_LIST : STYLE_ORDER;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// [LAW:types-are-the-program] Collapse one key's spec contributions into the
|
|
453
|
+
// single spec that gates it. A key is an INTEGER spec (a paged cursor `int` or a
|
|
454
|
+
// bounded `range`) or an allow-list — never both. An integer spec ABSORBS
|
|
455
|
+
// integer allow-list members (a trigger writing "0" to a page cursor is a legal
|
|
456
|
+
// int write — the open-trigger pattern), and a NON-integer member aimed at it is
|
|
457
|
+
// the genuine contradiction that throws. Two ranges widen-union; two allow-lists
|
|
458
|
+
// union; an int and a range on one key (a page cursor vs a bounded value) conflict.
|
|
459
|
+
function mergeKeySpecs(
|
|
460
|
+
key: string,
|
|
461
|
+
specs: readonly DerivedValidatorSpec[],
|
|
462
|
+
): DerivedValidatorSpec {
|
|
463
|
+
type Range = Extract<DerivedValidatorSpec, { kind: "range" }>;
|
|
464
|
+
const ranges = specs.filter((s): s is Range => s.kind === "range");
|
|
465
|
+
const hasInt = specs.some((s) => s.kind === "int");
|
|
466
|
+
const allowed = specs.flatMap((s) =>
|
|
467
|
+
s.kind === "allow-list" ? s.allowed : [],
|
|
468
|
+
);
|
|
469
|
+
if (ranges.length === 0 && !hasInt) {
|
|
470
|
+
return { kind: "allow-list", allowed: [...new Set(allowed)] };
|
|
471
|
+
}
|
|
472
|
+
// [LAW:no-silent-fallbacks] An integer spec accepts only integer writes; a
|
|
473
|
+
// non-integer member is a one-key-shape contradiction surfaced at load.
|
|
474
|
+
const nonInt = allowed.filter((v) => !INT_RE.test(v));
|
|
475
|
+
if (nonInt.length > 0) {
|
|
476
|
+
throw new Error(
|
|
477
|
+
`deriveActionValidators: key "${key}" is an integer spec (a paged ` +
|
|
478
|
+
`cursor or a bounded value) but a click writes non-integer ` +
|
|
479
|
+
`value(s) to it (${nonInt.join(", ")}). A state key has one key ` +
|
|
480
|
+
`shape — point that click at a distinct key, or write an integer.`,
|
|
481
|
+
);
|
|
482
|
+
}
|
|
483
|
+
if (hasInt && ranges.length > 0) {
|
|
484
|
+
throw new Error(
|
|
485
|
+
`deriveActionValidators: key "${key}" is declared as both a paged ` +
|
|
486
|
+
`cursor (int) and a bounded value (range) — a state key has one key ` +
|
|
487
|
+
`shape. Use distinct keys.`,
|
|
488
|
+
);
|
|
489
|
+
}
|
|
490
|
+
if (ranges.length > 0) {
|
|
491
|
+
const min = Math.min(...ranges.map((r) => r.min));
|
|
492
|
+
const max = Math.max(...ranges.map((r) => r.max));
|
|
493
|
+
// [LAW:no-silent-fallbacks] A page cursor (int) is UNBOUNDED, so any integer
|
|
494
|
+
// write is a legal member to absorb. A bounded range is BOUNDED — an integer
|
|
495
|
+
// a click declares OUTSIDE [min,max] would be clamped by the range gate at
|
|
496
|
+
// click time, silently storing a different value than the click rendered.
|
|
497
|
+
// That is a config error, surfaced at load rather than papered over at click.
|
|
498
|
+
const outOfRange = allowed.filter((v) => {
|
|
499
|
+
const n = parseInt(v, 10);
|
|
500
|
+
return n < min || n > max;
|
|
501
|
+
});
|
|
502
|
+
if (outOfRange.length > 0) {
|
|
503
|
+
throw new Error(
|
|
504
|
+
`deriveActionValidators: key "${key}" is a bounded range [${min},${max}] ` +
|
|
505
|
+
`but a click writes out-of-range value(s) to it ` +
|
|
506
|
+
`(${outOfRange.join(", ")}). The range gate would clamp them, storing a ` +
|
|
507
|
+
`different value than the click renders — write an in-range integer, ` +
|
|
508
|
+
`or point that click at a distinct key.`,
|
|
509
|
+
);
|
|
510
|
+
}
|
|
511
|
+
// [LAW:one-source-of-truth] Every range contribution to a key carries the
|
|
512
|
+
// same seed (the one backing state variable's default), so any is canonical;
|
|
513
|
+
// re-clamp it into the widened [min,max] to stay an in-range start value.
|
|
514
|
+
const seed = clampSeed(ranges[0]!.seed, min, max);
|
|
515
|
+
return { kind: "range", min, max, seed };
|
|
516
|
+
}
|
|
517
|
+
return { kind: "int" };
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// [LAW:single-enforcer] A STRUCTURAL spec (menu int / stepper range) is always
|
|
521
|
+
// kept — even on a baseline key — so a collision throws loudly at registration
|
|
522
|
+
// rather than silently shadowing the permanent gate. Only an ALLOW-LIST
|
|
523
|
+
// contribution to a baseline key is dropped (the click reuses the baseline gate
|
|
524
|
+
// as intended). The spec kind IS that discriminator: structural is int/range, an
|
|
525
|
+
// item/onClick spec is allow-list. Shared by both contribution collectors.
|
|
526
|
+
function dropBaselineAllowLists(
|
|
527
|
+
contributions: readonly KeySpecContribution[],
|
|
528
|
+
): KeySpecContribution[] {
|
|
529
|
+
const baseline = new Set(baselineKeys());
|
|
530
|
+
return contributions.filter(
|
|
531
|
+
(c) => c.spec.kind !== "allow-list" || !baseline.has(c.key),
|
|
532
|
+
);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// [LAW:single-enforcer] THE coherence merge: group every contribution by key and
|
|
536
|
+
// collapse each key's specs into the one spec that gates it (mergeKeySpecs).
|
|
537
|
+
// Multiple actions writing the same key (a picker's int page and a trigger's
|
|
538
|
+
// literal "0" are different KINDS to registerStateValidator) resolve here because
|
|
539
|
+
// mergeKeySpecs absorbs an integer allow-list member into the int spec. One
|
|
540
|
+
// merge, one gate per key.
|
|
541
|
+
function mergeContributions(
|
|
542
|
+
contributions: readonly KeySpecContribution[],
|
|
543
|
+
): KeySpecContribution[] {
|
|
544
|
+
const byKey = new Map<string, DerivedValidatorSpec[]>();
|
|
545
|
+
for (const { key, spec } of contributions) {
|
|
546
|
+
const specs = byKey.get(key);
|
|
547
|
+
if (specs) specs.push(spec);
|
|
548
|
+
else byKey.set(key, [spec]);
|
|
549
|
+
}
|
|
550
|
+
return [...byKey].map(([key, specs]) => ({
|
|
551
|
+
key,
|
|
552
|
+
spec: mergeKeySpecs(key, specs),
|
|
553
|
+
}));
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// [LAW:single-enforcer] The ONE place mapping a decoupled ACTION to the validator
|
|
557
|
+
// key SPEC it declares. The discriminator is the action's value SOURCE (which key
|
|
558
|
+
// is present), as DATA:
|
|
559
|
+
// • a literal `set` + `to` declares an allow-list of {to};
|
|
560
|
+
// • an option `set` + `from` declares an allow-list of the resolved domain —
|
|
561
|
+
// the SAME canonical list the picker iterates, so the rendered options and
|
|
562
|
+
// the gate cannot diverge;
|
|
563
|
+
// • a bounded `set` + `min/max/by` declares a range [min,max] (the stepper's
|
|
564
|
+
// navigation owns the wrap; the gate owns the bounds — `by` is render-only,
|
|
565
|
+
// never in the spec) plus a `seed` (the unset initial value, read from the
|
|
566
|
+
// backing state variable's `default` so the first relative click steps from
|
|
567
|
+
// the displayed number);
|
|
568
|
+
// • an `int` `set` declares an unbounded int (a paged picker's page cursor —
|
|
569
|
+
// the renderer owns clamping; the gate requires integer shape);
|
|
570
|
+
// • a `cycle` `set` declares an allow-list of its members (the renderer only
|
|
571
|
+
// ever writes the successor member);
|
|
572
|
+
// • copy/open write nothing, so they declare no spec.
|
|
573
|
+
// A new action arm is one new branch here, returning data the existing merge
|
|
574
|
+
// folds — no consumer re-walks an action's shape.
|
|
575
|
+
function actionKeySpecs(
|
|
576
|
+
a: ActionDecl,
|
|
577
|
+
seeds: ReadonlyMap<string, number>,
|
|
578
|
+
): KeySpecContribution[] {
|
|
579
|
+
if (!("set" in a)) return [];
|
|
580
|
+
if ("to" in a) {
|
|
581
|
+
return [{ key: a.set, spec: { kind: "allow-list", allowed: [a.to] } }];
|
|
582
|
+
}
|
|
583
|
+
if ("from" in a) {
|
|
584
|
+
return [
|
|
585
|
+
{
|
|
586
|
+
key: a.set,
|
|
587
|
+
spec: { kind: "allow-list", allowed: optionValuesFor(a.from) },
|
|
588
|
+
},
|
|
589
|
+
];
|
|
590
|
+
}
|
|
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
|
+
if ("int" in a) {
|
|
595
|
+
return [{ key: a.set, spec: { kind: "int" } }];
|
|
596
|
+
}
|
|
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
|
+
if ("cycle" in a) {
|
|
603
|
+
return [{ key: a.set, spec: { kind: "allow-list", allowed: a.cycle } }];
|
|
604
|
+
}
|
|
605
|
+
return [
|
|
606
|
+
{
|
|
607
|
+
key: a.set,
|
|
608
|
+
spec: {
|
|
609
|
+
kind: "range",
|
|
610
|
+
min: a.min,
|
|
611
|
+
max: a.max,
|
|
612
|
+
seed: clampSeed(seeds.get(a.set), a.min, a.max),
|
|
613
|
+
},
|
|
614
|
+
},
|
|
615
|
+
];
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// [LAW:one-source-of-truth] The unset seed for a stepped key is the backing
|
|
619
|
+
// state variable's `default` — the SAME number the bar displays before the first
|
|
620
|
+
// click — so the first relative step doesn't silently start from `min`. Absent or
|
|
621
|
+
// non-integer default falls back to `min` (the historical render-side behavior).
|
|
622
|
+
function clampSeed(seed: number | undefined, min: number, max: number): number {
|
|
623
|
+
if (seed === undefined) return min;
|
|
624
|
+
return Math.max(min, Math.min(max, seed));
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// [LAW:one-source-of-truth] Each `state` variable's integer `default` is the
|
|
628
|
+
// 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 derived
|
|
630
|
+
// range spec carries it. A non-integer or absent default contributes nothing
|
|
631
|
+
// (the key seeds from `min`).
|
|
632
|
+
function stateKeySeeds(config: DslConfig): ReadonlyMap<string, number> {
|
|
633
|
+
const seeds = new Map<string, number>();
|
|
634
|
+
for (const decl of Object.values(config.variables)) {
|
|
635
|
+
if (decl.kind !== "state") continue;
|
|
636
|
+
const raw = decl.default;
|
|
637
|
+
if (raw !== undefined && INT_RE.test(raw)) {
|
|
638
|
+
seeds.set(decl.key, parseInt(raw, 10));
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
return seeds;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// [LAW:one-source-of-truth] The writable-key surface a config's ACTIONS need,
|
|
645
|
+
// DERIVED from the action table — the same declarations the `{{ action }}` fn
|
|
646
|
+
// realizes a click from are the gate the wire enforces.
|
|
647
|
+
function actionContributions(config: DslConfig): KeySpecContribution[] {
|
|
648
|
+
const seeds = stateKeySeeds(config);
|
|
649
|
+
return dropBaselineAllowLists(
|
|
650
|
+
Object.values(config.actions).flatMap((a) => actionKeySpecs(a, seeds)),
|
|
651
|
+
);
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
// [LAW:single-enforcer] The SOLE install-site derivation: a config's writable-key
|
|
655
|
+
// surface is the merge of every ACTION it declares, through ONE coherence pass.
|
|
656
|
+
// The action table is the single interaction authority — the same declarations
|
|
657
|
+
// the `{{ action }}`/`{{ picker }}` funcs realize clicks from are the gate the
|
|
658
|
+
// wire enforces. mergeContributions resolves any intra-table key collision (a
|
|
659
|
+
// picker's int page and a trigger's literal "0" on one key) into one gate.
|
|
660
|
+
export function deriveActionValidators(
|
|
661
|
+
config: DslConfig,
|
|
662
|
+
): readonly KeySpecContribution[] {
|
|
663
|
+
return mergeContributions(actionContributions(config));
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// [LAW:dataflow-not-control-flow] Single entry point for validation: the
|
|
667
|
+
// caller hands over (key, value), this returns a uniform ValidateResult
|
|
668
|
+
// regardless of whether the key was known. The verb body never branches
|
|
669
|
+
// on "did I get a validator" — the absence of a validator IS the rejection.
|
|
670
|
+
export function validateStateWrite(
|
|
671
|
+
key: string,
|
|
672
|
+
rawValue: string,
|
|
673
|
+
): ValidateResult {
|
|
674
|
+
const entry = _STATE_VALIDATORS.get(key);
|
|
675
|
+
if (!entry) {
|
|
676
|
+
return {
|
|
677
|
+
ok: false,
|
|
678
|
+
reason: `unknown state key "${key}" (have: ${listStateKeys().join(", ")})`,
|
|
679
|
+
};
|
|
680
|
+
}
|
|
681
|
+
return entry.validator(rawValue);
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// [LAW:types-are-the-program] The bounded-step parameters of a key: the (widened)
|
|
685
|
+
// [min,max] the step wraps within plus the `seed` an unset key starts from. The
|
|
686
|
+
// step-state handler reads these to compute `wrap(current ± by)` against LIVE
|
|
687
|
+
// state — the link carries only the signed `by`, so every numeric the wrap needs
|
|
688
|
+
// lives here in the single registry, never snapshotted into the link.
|
|
689
|
+
export interface RangeParams {
|
|
690
|
+
readonly min: number;
|
|
691
|
+
readonly max: number;
|
|
692
|
+
readonly seed: number;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
// [LAW:one-source-of-truth] The registry IS the source of a key's bounds; the
|
|
696
|
+
// step handler reads them through this one boundary rather than re-deriving from
|
|
697
|
+
// the config. A key with no range registration (unknown, baseline, allow-list,
|
|
698
|
+
// or int) returns null — the handler rejects it as "not a stepper" loudly,
|
|
699
|
+
// never silently treating it as a step target.
|
|
700
|
+
export function rangeParamsFor(key: string): RangeParams | null {
|
|
701
|
+
const entry = _STATE_VALIDATORS.get(key);
|
|
702
|
+
if (!entry || entry.permanent || entry.kind !== "range") return null;
|
|
703
|
+
const ranges = entry.specs.flatMap((s) => (s.kind === "range" ? [s] : []));
|
|
704
|
+
if (ranges.length === 0) return null;
|
|
705
|
+
const min = Math.min(...ranges.map((r) => r.min));
|
|
706
|
+
const max = Math.max(...ranges.map((r) => r.max));
|
|
707
|
+
return { min, max, seed: clampSeed(ranges[0]!.seed, min, max) };
|
|
708
|
+
}
|