@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.
Files changed (111) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +145 -0
  3. package/bin/cc-candybar +6 -0
  4. package/dist/index.mjs +185 -0
  5. package/package.json +99 -0
  6. package/plugin/.claude-plugin/plugin.json +11 -0
  7. package/plugin/bin/preview.sh +305 -0
  8. package/plugin/commands/candybar.md +403 -0
  9. package/plugin/templates/config-essential.json +36 -0
  10. package/plugin/templates/config-full.json +55 -0
  11. package/plugin/templates/config-standard.json +39 -0
  12. package/plugin/templates/config-tui-compact.json +48 -0
  13. package/plugin/templates/config-tui-full.json +89 -0
  14. package/plugin/templates/config-tui-standard.json +56 -0
  15. package/plugin/templates/config-tui.json +18 -0
  16. package/plugin/templates/nerd-fonts-sample.txt +5 -0
  17. package/schema/cc-candybar.schema.json +1379 -0
  18. package/src/click/wire.ts +113 -0
  19. package/src/config/action.ts +91 -0
  20. package/src/config/cli.ts +170 -0
  21. package/src/config/default-dsl-config.ts +661 -0
  22. package/src/config/dsl-loader.ts +265 -0
  23. package/src/config/dsl-types.ts +425 -0
  24. package/src/config/loader/actions.ts +530 -0
  25. package/src/config/loader/cache.ts +206 -0
  26. package/src/config/loader/cross-ref.ts +326 -0
  27. package/src/config/loader/cycles.ts +148 -0
  28. package/src/config/loader/diagnostics.ts +99 -0
  29. package/src/config/loader/discovery.ts +182 -0
  30. package/src/config/loader/emit-schema.ts +63 -0
  31. package/src/config/loader/globals.ts +42 -0
  32. package/src/config/loader/helpers.ts +48 -0
  33. package/src/config/loader/layout.ts +688 -0
  34. package/src/config/loader/merge.ts +40 -0
  35. package/src/config/loader/refs.ts +96 -0
  36. package/src/config/loader/segments.ts +120 -0
  37. package/src/config/loader/validate-core.ts +674 -0
  38. package/src/config/loader/variables.ts +260 -0
  39. package/src/daemon/acquire.ts +411 -0
  40. package/src/daemon/cache/git.ts +553 -0
  41. package/src/daemon/cache/render.ts +449 -0
  42. package/src/daemon/cache/session-usage-store.ts +446 -0
  43. package/src/daemon/cache/watchers.ts +245 -0
  44. package/src/daemon/client-debug.ts +120 -0
  45. package/src/daemon/client-stats.ts +129 -0
  46. package/src/daemon/client-transport.ts +273 -0
  47. package/src/daemon/client.ts +75 -0
  48. package/src/daemon/debug-types.ts +91 -0
  49. package/src/daemon/debug.ts +264 -0
  50. package/src/daemon/limits.ts +154 -0
  51. package/src/daemon/log.ts +69 -0
  52. package/src/daemon/parent-watchdog.ts +80 -0
  53. package/src/daemon/paths.ts +127 -0
  54. package/src/daemon/protocol.ts +235 -0
  55. package/src/daemon/render-payload.ts +611 -0
  56. package/src/daemon/server.ts +1103 -0
  57. package/src/daemon/session-state-file.ts +108 -0
  58. package/src/daemon/session-state.ts +237 -0
  59. package/src/daemon/stats.ts +229 -0
  60. package/src/daemon/verbs/index.ts +458 -0
  61. package/src/daemon/verbs/state-validators.ts +708 -0
  62. package/src/demo/dsl.ts +117 -0
  63. package/src/demo/mock-data.ts +67 -0
  64. package/src/demo/statusline.json5 +92 -0
  65. package/src/dsl/node-registry.ts +281 -0
  66. package/src/dsl/render.ts +558 -0
  67. package/src/index.ts +206 -0
  68. package/src/install/index.ts +410 -0
  69. package/src/proc/launch.ts +451 -0
  70. package/src/proc/stats-handle.ts +13 -0
  71. package/src/render/action.ts +458 -0
  72. package/src/render/diagnostic-style.ts +23 -0
  73. package/src/render/diagnostic-text.ts +77 -0
  74. package/src/render/error-glyph.ts +53 -0
  75. package/src/render/outcome-plan.ts +45 -0
  76. package/src/render/picker.ts +231 -0
  77. package/src/render/split-lines.ts +51 -0
  78. package/src/render/strip.ts +103 -0
  79. package/src/segments/cache.ts +131 -0
  80. package/src/segments/context.ts +190 -0
  81. package/src/segments/git.ts +561 -0
  82. package/src/segments/metrics.ts +101 -0
  83. package/src/segments/pricing.ts +452 -0
  84. package/src/segments/session.ts +188 -0
  85. package/src/segments/tmux.ts +74 -0
  86. package/src/template-engine/cells.ts +90 -0
  87. package/src/template-engine/colors.ts +102 -0
  88. package/src/template-engine/engine.ts +108 -0
  89. package/src/template-engine/funcs.ts +216 -0
  90. package/src/template-engine/index.ts +11 -0
  91. package/src/template-engine/layout.ts +112 -0
  92. package/src/template-engine/scope.ts +62 -0
  93. package/src/themes/index.ts +19 -0
  94. package/src/themes/palette-resolvers.ts +86 -0
  95. package/src/themes/policy.ts +79 -0
  96. package/src/themes/session-random.ts +88 -0
  97. package/src/utils/cache.ts +206 -0
  98. package/src/utils/claude.ts +616 -0
  99. package/src/utils/color-support.ts +118 -0
  100. package/src/utils/formatters.ts +77 -0
  101. package/src/utils/logger.ts +5 -0
  102. package/src/utils/outcome.ts +33 -0
  103. package/src/utils/schema-validator.ts +126 -0
  104. package/src/utils/single-flight.ts +57 -0
  105. package/src/utils/terminal-width.ts +43 -0
  106. package/src/utils/terminal.ts +11 -0
  107. package/src/utils/transcript-fs.ts +162 -0
  108. package/src/var-system/index.ts +24 -0
  109. package/src/var-system/sources.ts +1038 -0
  110. package/src/var-system/store.ts +223 -0
  111. package/src/var-system/types.ts +57 -0
@@ -0,0 +1,530 @@
1
+ // [LAW:types-are-the-program] The action-table schema. An ActionDecl is
2
+ // discriminated by exactly-one-of set/copy/open; a `set` adds exactly-one value
3
+ // SOURCE (to/from/min-max-by/int). The proof here is what lets every downstream
4
+ // consumer (renderAction, deriveActionValidators) match on the present key with
5
+ // no fallthrough. Whether a `{{ action "name" }}` reference resolves is a
6
+ // cross-ref concern. This file changes when the action vocabulary changes.
7
+ //
8
+ // [LAW:no-mode-explosion] Unlike cache (single-key value-arms → oneOfPresent) and
9
+ // variables (tag-by-field-value → taggedUnion), an action's arms are multi-key
10
+ // RECORDS: a `set` carries `set` plus a value-source group (`to` | `from` |
11
+ // `min`/`max`/`by` | `int`). A single key never selects an arm, so the shared
12
+ // present-key engine doesn't fit — bending it to would mean per-arm sibling
13
+ // allow-lists and bespoke unknown-key messages bolted on as modes. Instead the
14
+ // leaf machinery is shared (`fields` + `refine` + field specs carry every arm's
15
+ // shape and cross-field invariant as DATA) and this file owns only the thin total
16
+ // present-key dispatch — the irreducible union eliminator.
17
+
18
+ import {
19
+ ACTION_KEYS,
20
+ OPTION_SOURCES,
21
+ type ActionDecl,
22
+ type ActionKey,
23
+ type OptionSource,
24
+ } from "../action.js";
25
+ import { findKeyLine } from "./diagnostics.js";
26
+ import {
27
+ describeType,
28
+ describeValue,
29
+ fields,
30
+ isPlainObject,
31
+ objectJson,
32
+ refine,
33
+ requireString,
34
+ type ArmParse,
35
+ type FieldSpec,
36
+ type FieldSpecMap,
37
+ type JsonNode,
38
+ type Refinement,
39
+ type ValidateCtx,
40
+ } from "./validate-core.js";
41
+
42
+ // [LAW:locality-or-seam] Structural validation of the `actions` block: each
43
+ // action is discriminated by which of set/copy/open is present, a `set` further
44
+ // by its value SOURCE (to | from | min/max/by | int). Whether a `{{ action
45
+ // "name" }}` reference resolves is a cross-ref concern (validateCrossReferences),
46
+ // which runs on the MERGED config so a segment can reference a default-provided
47
+ // action.
48
+ export function validateActions(
49
+ ctx: ValidateCtx,
50
+ raw: unknown,
51
+ ): Record<string, ActionDecl> {
52
+ if (raw === undefined) return {};
53
+ if (!isPlainObject(raw)) {
54
+ issue(
55
+ ctx,
56
+ "actions",
57
+ `actions must be an object, got ${describeType(raw)}`,
58
+ );
59
+ return {};
60
+ }
61
+ // [LAW:types-are-the-program] Null-prototype record for user-keyed data, so an
62
+ // action named "__proto__"/"constructor" is an ordinary own property, never a
63
+ // prototype-chain mutation — matching the widgets block and the compiled maps.
64
+ const out: Record<string, ActionDecl> = Object.create(null) as Record<
65
+ string,
66
+ ActionDecl
67
+ >;
68
+ for (const [name, decl] of Object.entries(raw)) {
69
+ const parsed = validateActionDecl(ctx, `actions.${name}`, decl);
70
+ if (parsed !== null) out[name] = parsed;
71
+ }
72
+ return out;
73
+ }
74
+
75
+ // [LAW:single-enforcer] One place pushes an issue with the resolved source line —
76
+ // the line derivation is mechanical from the path, so no callsite restates it.
77
+ function issue(ctx: ValidateCtx, path: string, message: string): void {
78
+ ctx.issues.push({
79
+ path,
80
+ message,
81
+ line: findKeyLine(ctx.source, path.split(".")),
82
+ });
83
+ }
84
+
85
+ // [LAW:dataflow-not-control-flow] The top-level union eliminator: exactly one of
86
+ // set/copy/open is present, then dispatch the whole record to that arm via the
87
+ // arm table. The dispatch is a total projection over the present key (no
88
+ // fallthrough) — the only branch is the presence-count, which every union must
89
+ // discriminate somewhere. The set arm owns its own siblings (the value source),
90
+ // so this level rejects no keys generically.
91
+ function validateActionDecl(
92
+ ctx: ValidateCtx,
93
+ path: string,
94
+ raw: unknown,
95
+ ): ActionDecl | null {
96
+ if (!isPlainObject(raw)) {
97
+ issue(
98
+ ctx,
99
+ path,
100
+ `${path} must be an action object, got ${describeType(raw)}`,
101
+ );
102
+ return null;
103
+ }
104
+ const present = (ACTION_KEYS as readonly string[]).filter((k) => k in raw);
105
+ if (present.length !== 1) {
106
+ issue(
107
+ ctx,
108
+ path,
109
+ `action must declare exactly one of: ${ACTION_KEYS.join(", ")}${
110
+ present.length > 1 ? ` (found: ${present.join(", ")})` : ""
111
+ }`,
112
+ );
113
+ return null;
114
+ }
115
+ return ACTION_ARMS[present[0] as ActionKey](ctx, path, raw);
116
+ }
117
+
118
+ // [LAW:dataflow-not-control-flow] The top-level arm table as DATA: copy/open share
119
+ // one template-arm shape (their key names the only difference); set delegates to
120
+ // its value-source sub-union. The present key indexes this map — the eliminator
121
+ // never branches on the key name.
122
+ const ACTION_ARMS: Record<ActionKey, ArmParse<ActionDecl>> = {
123
+ set: validateSetAction,
124
+ copy: templateArm("copy"),
125
+ open: templateArm("open"),
126
+ };
127
+
128
+ // [LAW:one-source-of-truth] A copy/open action emits the closed single-key
129
+ // object its arm validates — symmetric to `templateArm(key)`'s parse.
130
+ function templateArmJson(key: "copy" | "open"): JsonNode {
131
+ return {
132
+ type: "object",
133
+ properties: { [key]: { type: "string" } },
134
+ required: [key],
135
+ additionalProperties: false,
136
+ };
137
+ }
138
+
139
+ // [LAW:one-source-of-truth] One ActionDecl's schema: the set sub-union (each
140
+ // arm's `json`, derived from SET_ARMS) joined with copy/open — the SAME members
141
+ // `validateActionDecl` dispatches over. The `actions` block is a name → ActionDecl
142
+ // map, symmetric to `validateActions`.
143
+ function actionDeclJson(): JsonNode {
144
+ return {
145
+ anyOf: [
146
+ ...SET_ARMS.map((arm) => arm.json),
147
+ templateArmJson("copy"),
148
+ templateArmJson("open"),
149
+ ],
150
+ };
151
+ }
152
+
153
+ export function actionsJson(): JsonNode {
154
+ return { type: "object", additionalProperties: actionDeclJson() };
155
+ }
156
+
157
+ // [LAW:one-type-per-behavior] copy and open are one behavior — a single required
158
+ // template string, no other keys — parameterized by the key. Both reject every
159
+ // sibling key (the arm's only legal key is its own) with the bespoke per-key
160
+ // message, then read the template.
161
+ function templateArm(key: "copy" | "open"): ArmParse<ActionDecl> {
162
+ return (ctx, path, raw) => {
163
+ for (const k of Object.keys(raw)) {
164
+ if (k !== key)
165
+ issue(
166
+ ctx,
167
+ `${path}.${k}`,
168
+ `Unknown key "${k}" on a ${key} action. Expected only: ${key}`,
169
+ );
170
+ }
171
+ const tmpl = requireString(ctx, path, raw, key);
172
+ return tmpl === null ? null : ({ [key]: tmpl } as unknown as ActionDecl);
173
+ };
174
+ }
175
+
176
+ // ─── The `set` value-source sub-union ────────────────────────────────────────
177
+
178
+ // [LAW:single-enforcer] A set-state URL path segment must be a non-empty,
179
+ // slash-free string — the set-state value is a slash-delimited
180
+ // <session>/<key>/<value> run, so an empty or slash-bearing segment is
181
+ // undeliverable. One validator, two callers (the `set` key and a literal `to`
182
+ // value), each supplying its bespoke message — the shape is enforced once, the
183
+ // wording stays per-use DATA. The codec itself is slash-safe; this is a
184
+ // deliberate upstream restriction so a slash-bearing key/value never reaches the
185
+ // wire, surfaced at load rather than thrown when validators register.
186
+ function slashFreeString(
187
+ ctx: ValidateCtx,
188
+ path: string,
189
+ field: string,
190
+ raw: Record<string, unknown>,
191
+ emptyMessage: string,
192
+ slashMessage: (value: string) => string,
193
+ ): string | null {
194
+ const v = requireString(ctx, path, raw, field);
195
+ if (v === null) return null;
196
+ const at = `${path}.${field}`;
197
+ if (v === "") {
198
+ issue(ctx, at, emptyMessage);
199
+ return null;
200
+ }
201
+ if (v.includes("/")) {
202
+ issue(ctx, at, slashMessage(v));
203
+ return null;
204
+ }
205
+ return v;
206
+ }
207
+
208
+ // [LAW:dataflow-not-control-flow] The `set` key is validated once for every value
209
+ // source (it is shared across all set arms), before the source is detected — so a
210
+ // bad key and an ambiguous source both surface in one pass, matching the
211
+ // hand-rolled order. It is therefore NOT a field of any arm's `fields` map; the
212
+ // arm parses only the value-source payload, and the dispatcher re-attaches `set`.
213
+ function validateSetKey(
214
+ ctx: ValidateCtx,
215
+ path: string,
216
+ raw: Record<string, unknown>,
217
+ ): string | null {
218
+ return slashFreeString(
219
+ ctx,
220
+ path,
221
+ "set",
222
+ raw,
223
+ `set key must be non-empty (the SessionState key to write)`,
224
+ (v) => `set key "${v}" contains "/" — state keys must be slash-free`,
225
+ );
226
+ }
227
+
228
+ // [LAW:types-are-the-program] Each value source's payload as a field map — the
229
+ // non-`set` keys that source carries. `fields` runs every spec (reporting all
230
+ // issues) and fails the arm when a required field is absent or invalid; `refine`
231
+ // adds the cross-field invariants `fields` cannot express. The reconstructed
232
+ // payload IS the member minus `set`, which the dispatcher re-attaches.
233
+ const TO_FIELDS: FieldSpecMap<{ to: string }> = { to: setLiteralSpec() };
234
+ const FROM_FIELDS: FieldSpecMap<{ from: OptionSource }> = { from: fromSpec() };
235
+ const BOUNDED_FIELDS: FieldSpecMap<{ min: number; max: number; by: number }> = {
236
+ min: requireIntSpec(),
237
+ max: requireIntSpec(),
238
+ by: requireIntSpec(),
239
+ };
240
+ const INT_FIELDS: FieldSpecMap<{ int: true }> = { int: intMarkerSpec() };
241
+ const CYCLE_FIELDS: FieldSpecMap<{ cycle: readonly string[] }> = {
242
+ cycle: cycleSpec(),
243
+ };
244
+
245
+ // [LAW:types-are-the-program] A bounded step is fully described by an integer
246
+ // domain (min < max) and a non-zero integer increment (`by`; negative for a
247
+ // down-step). The validator derives the range [min,max] (the wire gate); the
248
+ // renderer wraps current ± by inside it. These two cross-field invariants are the
249
+ // refinements `fields` cannot express — relating two fields, not one — carried as
250
+ // DATA whose messages interpolate the assembled value. Unlike a stepper widget's
251
+ // positive `step`, `by` may be negative (the down affordance), so the check is
252
+ // non-zero, not positive.
253
+ interface BoundedPayload {
254
+ min: number;
255
+ max: number;
256
+ by: number;
257
+ }
258
+ const minLessThanMax: Refinement<BoundedPayload> = {
259
+ ok: (v) => v.min < v.max,
260
+ issue: (v) => ({
261
+ field: "min",
262
+ message: `min (${v.min}) must be less than max (${v.max})`,
263
+ }),
264
+ };
265
+ const byNonZero: Refinement<BoundedPayload> = {
266
+ ok: (v) => v.by !== 0,
267
+ issue: () => ({
268
+ field: "by",
269
+ message: `by must be a non-zero integer (the per-click increment; negative steps down)`,
270
+ }),
271
+ };
272
+
273
+ // [LAW:types-are-the-program] A set arm is its payload field map plus its
274
+ // refinements; `detect` (the non-`set` keys whose presence selects it), `allowed`
275
+ // (those keys plus `set`, the unknown-key allow-list), and `label` (the source
276
+ // name in the exactly-one message — the detect keys joined by "/") all DERIVE from
277
+ // the field map, so the field set is the single source for what the arm parses,
278
+ // permits, and is named by.
279
+ interface SetArm {
280
+ readonly detect: readonly string[];
281
+ readonly allowed: readonly string[];
282
+ readonly label: string;
283
+ // [LAW:one-source-of-truth] The arm's emit facet: the closed object schema for
284
+ // a `set` of this source — the shared `set` key plus the source's own fields,
285
+ // derived from the SAME field map `fields` validates. Cross-field refinements
286
+ // (min<max, by≠0) are unexpressible in JSON Schema, so only the structural
287
+ // shape is emitted — the shape/meaning split every refinement keeps.
288
+ readonly json: JsonNode;
289
+ readonly parse: ArmParse<Partial<ActionDecl>>;
290
+ }
291
+
292
+ function setArm<P extends object>(
293
+ fieldMap: FieldSpecMap<P>,
294
+ ...checks: ReadonlyArray<Refinement<P>>
295
+ ): SetArm {
296
+ const detect = Object.keys(fieldMap);
297
+ const inner: ArmParse<P> = (ctx, path, raw) =>
298
+ fields(ctx, fieldMap, path, raw);
299
+ const source = objectJson(fieldMap) as {
300
+ properties: Record<string, JsonNode>;
301
+ required?: readonly string[];
302
+ };
303
+ return {
304
+ detect,
305
+ allowed: ["set", ...detect],
306
+ label: detect.join("/"),
307
+ json: {
308
+ type: "object",
309
+ properties: { set: { type: "string" }, ...source.properties },
310
+ required: ["set", ...(source.required ?? [])],
311
+ additionalProperties: false,
312
+ },
313
+ parse: (checks.length
314
+ ? refine(inner, ...checks)
315
+ : inner) as unknown as ArmParse<Partial<ActionDecl>>,
316
+ };
317
+ }
318
+
319
+ // [LAW:dataflow-not-control-flow] The value-source arms in the order their labels
320
+ // appear in the exactly-one message. A `set` declares exactly one of these; the
321
+ // dispatcher counts presence over `detect` and reconstructs `{ set, ...payload }`.
322
+ const SET_ARMS: readonly SetArm[] = [
323
+ setArm(TO_FIELDS),
324
+ setArm(FROM_FIELDS),
325
+ setArm(BOUNDED_FIELDS, minLessThanMax, byNonZero),
326
+ setArm(INT_FIELDS),
327
+ setArm(CYCLE_FIELDS),
328
+ ];
329
+
330
+ const VALUE_SOURCE_MESSAGE = `a set action declares exactly one value source: "to" (a literal value), "from" (an option domain: ${OPTION_SOURCES.join(
331
+ "/",
332
+ )}), "min"/"max"/"by" (a bounded step), "int" (an unbounded integer cursor), or "cycle" (an enumerated domain stepped in order)`;
333
+
334
+ // [LAW:dataflow-not-control-flow] The set sub-union eliminator: validate the
335
+ // shared `set` key, count which value sources are present, require exactly one,
336
+ // reject keys outside that arm's allow-list, parse the payload, reconstruct the
337
+ // member. The variability (which arms, each arm's fields/refinements/allow-list)
338
+ // is the SET_ARMS data; the only branches are the presence-count and the
339
+ // null-threading both the key and the payload share.
340
+ function validateSetAction(
341
+ ctx: ValidateCtx,
342
+ path: string,
343
+ raw: Record<string, unknown>,
344
+ ): ActionDecl | null {
345
+ const stateKey = validateSetKey(ctx, path, raw);
346
+
347
+ const present = SET_ARMS.filter((arm) => arm.detect.some((k) => k in raw));
348
+ if (present.length !== 1) {
349
+ issue(
350
+ ctx,
351
+ path,
352
+ `${VALUE_SOURCE_MESSAGE}${
353
+ present.length > 1
354
+ ? ` — found: ${present.map((a) => a.label).join(", ")}`
355
+ : ""
356
+ }`,
357
+ );
358
+ return null;
359
+ }
360
+ const arm = present[0]!;
361
+
362
+ for (const k of Object.keys(raw)) {
363
+ if (!arm.allowed.includes(k))
364
+ issue(
365
+ ctx,
366
+ `${path}.${k}`,
367
+ `Unknown key "${k}" on this set action. Expected one of: ${arm.allowed.join(", ")}`,
368
+ );
369
+ }
370
+
371
+ const payload = arm.parse(ctx, path, raw);
372
+ return stateKey === null || payload === null
373
+ ? null
374
+ : ({ set: stateKey, ...payload } as unknown as ActionDecl);
375
+ }
376
+
377
+ // [LAW:no-silent-fallbacks] A literal `to` and the `set` key share the
378
+ // non-empty/slash-free shape — the set-state wire rejects empty values and splits
379
+ // on "/", so either is undeliverable. The empty/slash messages are this arm's,
380
+ // the shape is the shared enforcer's.
381
+ function setLiteralSpec(): FieldSpec<string> {
382
+ return {
383
+ required: true,
384
+ json: { type: "string" },
385
+ parse: (ctx, path, field, raw) =>
386
+ slashFreeString(
387
+ ctx,
388
+ path,
389
+ field,
390
+ raw,
391
+ `set value must be non-empty — an empty value cannot be delivered on the set-state wire`,
392
+ (v) => `set value "${v}" contains "/" — set values must be slash-free`,
393
+ ) ?? undefined,
394
+ };
395
+ }
396
+
397
+ // [LAW:types-are-the-program] `from` is a required member of the closed
398
+ // OPTION_SOURCES domain — the option set a picker ranges. A non-member is a hard
399
+ // error with the bespoke one-of message, never a silent fallback.
400
+ function fromSpec(): FieldSpec<OptionSource> {
401
+ return {
402
+ required: true,
403
+ json: { enum: [...OPTION_SOURCES] },
404
+ parse: (ctx, path, field, raw) => {
405
+ const from = raw[field];
406
+ if (
407
+ typeof from !== "string" ||
408
+ !(OPTION_SOURCES as readonly string[]).includes(from)
409
+ ) {
410
+ issue(
411
+ ctx,
412
+ `${path}.${field}`,
413
+ `from must be one of: ${OPTION_SOURCES.join(", ")}, got ${describeValue(from)}`,
414
+ );
415
+ return undefined;
416
+ }
417
+ return from as OptionSource;
418
+ },
419
+ };
420
+ }
421
+
422
+ // [LAW:types-are-the-program] `cycle` is the enumerated domain a click steps
423
+ // through: at least two members (one member has no successor to step to — that
424
+ // is a literal `to`), each a deliverable set-state value (non-empty, slash-free
425
+ // — the same wire shape `to` enforces), no duplicates (the successor of a
426
+ // duplicated member is ambiguous). Members double as the derived allow-list
427
+ // gate, so a member this spec admits is a value the wire delivers, by
428
+ // construction.
429
+ function cycleSpec(): FieldSpec<readonly string[]> {
430
+ return {
431
+ required: true,
432
+ json: {
433
+ type: "array",
434
+ items: { type: "string", minLength: 1 },
435
+ minItems: 2,
436
+ uniqueItems: true,
437
+ },
438
+ parse: (ctx, path, field, raw) => {
439
+ const v = raw[field];
440
+ const at = `${path}.${field}`;
441
+ if (!Array.isArray(v) || v.some((m) => typeof m !== "string")) {
442
+ issue(
443
+ ctx,
444
+ at,
445
+ `cycle must be an array of strings (the enumerated values a click steps through), got ${describeType(v)}`,
446
+ );
447
+ return undefined;
448
+ }
449
+ const members = v as string[];
450
+ if (members.length < 2) {
451
+ issue(
452
+ ctx,
453
+ at,
454
+ `cycle needs at least two members (one member has no successor — use a literal "to")`,
455
+ );
456
+ return undefined;
457
+ }
458
+ const empty = members.some((m) => m === "");
459
+ const slashed = members.filter((m) => m.includes("/"));
460
+ if (empty) {
461
+ issue(
462
+ ctx,
463
+ at,
464
+ `cycle members must be non-empty — an empty value cannot be delivered on the set-state wire`,
465
+ );
466
+ return undefined;
467
+ }
468
+ if (slashed.length > 0) {
469
+ issue(
470
+ ctx,
471
+ at,
472
+ `cycle member(s) ${slashed.map((m) => `"${m}"`).join(", ")} contain "/" — set values must be slash-free`,
473
+ );
474
+ return undefined;
475
+ }
476
+ if (new Set(members).size !== members.length) {
477
+ issue(
478
+ ctx,
479
+ at,
480
+ `cycle members must be unique — the successor of a duplicated member is ambiguous`,
481
+ );
482
+ return undefined;
483
+ }
484
+ return members;
485
+ },
486
+ };
487
+ }
488
+
489
+ // [LAW:no-silent-fallbacks] `int` is a marker, not a value — it declares the key
490
+ // an unbounded-integer cursor. Only the literal `true` is meaningful; anything
491
+ // else is a typo to surface, not silently coerce.
492
+ function intMarkerSpec(): FieldSpec<true> {
493
+ return {
494
+ required: true,
495
+ json: { const: true },
496
+ parse: (ctx, path, field, raw) => {
497
+ if (raw[field] !== true) {
498
+ issue(
499
+ ctx,
500
+ `${path}.${field}`,
501
+ `int must be the literal true (declares the key an unbounded integer cursor — a paged picker's page key), got ${describeValue(raw[field])}`,
502
+ );
503
+ return undefined;
504
+ }
505
+ return true;
506
+ },
507
+ };
508
+ }
509
+
510
+ // [LAW:types-are-the-program] A required integer field — the field key (min / max
511
+ // / by) comes from the map, the message names it. A non-integer or absent value
512
+ // reports and fails the arm.
513
+ function requireIntSpec(): FieldSpec<number> {
514
+ return {
515
+ required: true,
516
+ json: { type: "integer" },
517
+ parse: (ctx, path, field, raw) => {
518
+ const v = raw[field];
519
+ if (typeof v !== "number" || !Number.isInteger(v)) {
520
+ issue(
521
+ ctx,
522
+ `${path}.${field}`,
523
+ `${field} must be an integer, got ${describeValue(v)}`,
524
+ );
525
+ return undefined;
526
+ }
527
+ return v;
528
+ },
529
+ };
530
+ }