@promptctl/cc-candybar 1.26.0 → 1.27.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 +156 -0
- package/src/daemon/cache/git.ts +1 -1
- package/src/daemon/cache/render.ts +61 -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,26 +1,28 @@
|
|
|
1
1
|
// [LAW:types-are-the-program] The action-table schema. An ActionDecl is
|
|
2
|
-
// discriminated by exactly-one-of set/copy/open; a `set`
|
|
3
|
-
// SOURCE (to/from/min-max-by/int
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
2
|
+
// discriminated by exactly-one-of set/persist/copy/open/reset; a `set` or
|
|
3
|
+
// `persist` adds exactly-one value SOURCE (to/from/min-max-by/int — `persist`
|
|
4
|
+
// excludes `int`). The proof here is what lets every downstream consumer
|
|
5
|
+
// (renderAction, deriveActionValidators, deriveConfigActionValidators) match
|
|
6
|
+
// on the present key with no fallthrough. Whether a `{{ action "name" }}`
|
|
7
|
+
// reference resolves is a cross-ref concern. This file changes when the
|
|
8
|
+
// action vocabulary changes.
|
|
7
9
|
//
|
|
8
10
|
// [LAW:no-mode-explosion] Unlike cache (single-key value-arms → oneOfPresent) and
|
|
9
11
|
// variables (tag-by-field-value → taggedUnion), an action's arms are multi-key
|
|
10
|
-
// RECORDS: a `set` carries
|
|
11
|
-
// `min`/`max`/`by` | `int`). A single key never selects an arm, so
|
|
12
|
-
// present-key engine doesn't fit — bending it to would mean per-arm
|
|
13
|
-
// allow-lists and bespoke unknown-key messages bolted on as modes. Instead
|
|
14
|
-
// leaf machinery is shared (`fields` + `refine` + field specs carry every arm's
|
|
15
|
-
// shape and cross-field invariant as DATA
|
|
16
|
-
//
|
|
12
|
+
// RECORDS: a `set`/`persist` carries the discriminator plus a value-source group
|
|
13
|
+
// (`to` | `from` | `min`/`max`/`by` | `int`). A single key never selects an arm, so
|
|
14
|
+
// the shared present-key engine doesn't fit — bending it to would mean per-arm
|
|
15
|
+
// sibling allow-lists and bespoke unknown-key messages bolted on as modes. Instead
|
|
16
|
+
// the leaf machinery is shared (`fields` + `refine` + field specs carry every arm's
|
|
17
|
+
// shape and cross-field invariant as DATA, parameterized by discriminator name so
|
|
18
|
+
// `set` and `persist` share one field-map definition) and this file owns only the
|
|
19
|
+
// thin total present-key dispatch — the irreducible union eliminator.
|
|
17
20
|
|
|
18
21
|
import {
|
|
19
22
|
ACTION_KEYS,
|
|
20
|
-
OPTION_SOURCES,
|
|
21
23
|
type ActionDecl,
|
|
22
24
|
type ActionKey,
|
|
23
|
-
type
|
|
25
|
+
type OptionDomain,
|
|
24
26
|
} from "../action.js";
|
|
25
27
|
import { findKeyLine } from "./diagnostics.js";
|
|
26
28
|
import {
|
|
@@ -116,18 +118,38 @@ function validateActionDecl(
|
|
|
116
118
|
}
|
|
117
119
|
|
|
118
120
|
// [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
|
|
120
|
-
//
|
|
121
|
-
//
|
|
121
|
+
// one template-arm shape (their key names the only difference); set/persist
|
|
122
|
+
// delegate to their value-source sub-union (the SAME field shapes, a different
|
|
123
|
+
// discriminator key — see valueSourceAction); reset is copy/open's plain-string
|
|
124
|
+
// sibling. The present key indexes this map — the eliminator never branches on
|
|
125
|
+
// the key name.
|
|
122
126
|
const ACTION_ARMS: Record<ActionKey, ArmParse<ActionDecl>> = {
|
|
123
|
-
set:
|
|
127
|
+
set: (ctx, path, raw) =>
|
|
128
|
+
valueSourceAction(
|
|
129
|
+
ctx,
|
|
130
|
+
path,
|
|
131
|
+
raw,
|
|
132
|
+
"set",
|
|
133
|
+
SET_ARMS,
|
|
134
|
+
"the SessionState key to write",
|
|
135
|
+
),
|
|
136
|
+
persist: (ctx, path, raw) =>
|
|
137
|
+
valueSourceAction(
|
|
138
|
+
ctx,
|
|
139
|
+
path,
|
|
140
|
+
raw,
|
|
141
|
+
"persist",
|
|
142
|
+
PERSIST_ARMS,
|
|
143
|
+
"the config globals field to write",
|
|
144
|
+
),
|
|
124
145
|
copy: templateArm("copy"),
|
|
125
146
|
open: templateArm("open"),
|
|
147
|
+
reset: resetArm,
|
|
126
148
|
};
|
|
127
149
|
|
|
128
150
|
// [LAW:one-source-of-truth] A copy/open action emits the closed single-key
|
|
129
151
|
// object its arm validates — symmetric to `templateArm(key)`'s parse.
|
|
130
|
-
function templateArmJson(key: "copy" | "open"): JsonNode {
|
|
152
|
+
function templateArmJson(key: "copy" | "open" | "reset"): JsonNode {
|
|
131
153
|
return {
|
|
132
154
|
type: "object",
|
|
133
155
|
properties: { [key]: { type: "string" } },
|
|
@@ -136,16 +158,18 @@ function templateArmJson(key: "copy" | "open"): JsonNode {
|
|
|
136
158
|
};
|
|
137
159
|
}
|
|
138
160
|
|
|
139
|
-
// [LAW:one-source-of-truth] One ActionDecl's schema: the set sub-
|
|
140
|
-
// arm's `json`, derived from SET_ARMS) joined with
|
|
141
|
-
// `validateActionDecl` dispatches over. The
|
|
142
|
-
// map, symmetric to `validateActions`.
|
|
161
|
+
// [LAW:one-source-of-truth] One ActionDecl's schema: the set/persist sub-unions
|
|
162
|
+
// (each arm's `json`, derived from SET_ARMS/PERSIST_ARMS) joined with
|
|
163
|
+
// copy/open/reset — the SAME members `validateActionDecl` dispatches over. The
|
|
164
|
+
// `actions` block is a name → ActionDecl map, symmetric to `validateActions`.
|
|
143
165
|
function actionDeclJson(): JsonNode {
|
|
144
166
|
return {
|
|
145
167
|
anyOf: [
|
|
146
168
|
...SET_ARMS.map((arm) => arm.json),
|
|
169
|
+
...PERSIST_ARMS.map((arm) => arm.json),
|
|
147
170
|
templateArmJson("copy"),
|
|
148
171
|
templateArmJson("open"),
|
|
172
|
+
templateArmJson("reset"),
|
|
149
173
|
],
|
|
150
174
|
};
|
|
151
175
|
}
|
|
@@ -173,6 +197,37 @@ function templateArm(key: "copy" | "open"): ArmParse<ActionDecl> {
|
|
|
173
197
|
};
|
|
174
198
|
}
|
|
175
199
|
|
|
200
|
+
// [LAW:one-type-per-behavior] `reset` is copy/open's shape (a single required
|
|
201
|
+
// string, no other keys) but the string is a KEY (a config globals field name),
|
|
202
|
+
// not a template — no Go-template parsing happens for it, so it reuses the
|
|
203
|
+
// slash-free/non-empty shape `set`/`persist` keys share rather than
|
|
204
|
+
// requireString's bare-presence check. A `function` declaration (not a const
|
|
205
|
+
// arrow) so it is hoisted — ACTION_ARMS above references it directly, not
|
|
206
|
+
// through a deferred closure.
|
|
207
|
+
function resetArm(
|
|
208
|
+
ctx: ValidateCtx,
|
|
209
|
+
path: string,
|
|
210
|
+
raw: Record<string, unknown>,
|
|
211
|
+
): ActionDecl | null {
|
|
212
|
+
for (const k of Object.keys(raw)) {
|
|
213
|
+
if (k !== "reset")
|
|
214
|
+
issue(
|
|
215
|
+
ctx,
|
|
216
|
+
`${path}.${k}`,
|
|
217
|
+
`Unknown key "${k}" on a reset action. Expected only: reset`,
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
const key = slashFreeString(
|
|
221
|
+
ctx,
|
|
222
|
+
path,
|
|
223
|
+
"reset",
|
|
224
|
+
raw,
|
|
225
|
+
`reset key must be non-empty (the config globals field to clear)`,
|
|
226
|
+
(v) => `reset key "${v}" contains "/" — keys must be slash-free`,
|
|
227
|
+
);
|
|
228
|
+
return key === null ? null : { reset: key };
|
|
229
|
+
}
|
|
230
|
+
|
|
176
231
|
// ─── The `set` value-source sub-union ────────────────────────────────────────
|
|
177
232
|
|
|
178
233
|
// [LAW:single-enforcer] A set-state URL path segment must be a non-empty,
|
|
@@ -205,41 +260,69 @@ function slashFreeString(
|
|
|
205
260
|
return v;
|
|
206
261
|
}
|
|
207
262
|
|
|
208
|
-
// [LAW:dataflow-not-control-flow] The
|
|
209
|
-
// source (it is shared across all
|
|
210
|
-
//
|
|
211
|
-
//
|
|
212
|
-
// arm parses only the value-source payload, and
|
|
213
|
-
|
|
263
|
+
// [LAW:dataflow-not-control-flow] The discriminator key ("set" or "persist")
|
|
264
|
+
// is validated once for every value source (it is shared across all arms of
|
|
265
|
+
// that discriminator), before the source is detected — so a bad key and an
|
|
266
|
+
// ambiguous source both surface in one pass. It is therefore NOT a field of
|
|
267
|
+
// any arm's `fields` map; the arm parses only the value-source payload, and
|
|
268
|
+
// the dispatcher re-attaches the discriminator.
|
|
269
|
+
function validateValueSourceKey(
|
|
214
270
|
ctx: ValidateCtx,
|
|
215
271
|
path: string,
|
|
216
272
|
raw: Record<string, unknown>,
|
|
273
|
+
discriminator: "set" | "persist",
|
|
274
|
+
keyNoun: string,
|
|
217
275
|
): string | null {
|
|
218
276
|
return slashFreeString(
|
|
219
277
|
ctx,
|
|
220
278
|
path,
|
|
221
|
-
|
|
279
|
+
discriminator,
|
|
222
280
|
raw,
|
|
223
|
-
|
|
224
|
-
(v) =>
|
|
281
|
+
`${discriminator} key must be non-empty (${keyNoun})`,
|
|
282
|
+
(v) => `${discriminator} key "${v}" contains "/" — keys must be slash-free`,
|
|
225
283
|
);
|
|
226
284
|
}
|
|
227
285
|
|
|
286
|
+
// [LAW:one-source-of-truth] The wire verb name a discriminator's writes
|
|
287
|
+
// travel over — `set-state` for `set` (SessionState), `set-config` for
|
|
288
|
+
// `persist` (the config-overrides layer). Threaded into the shared field
|
|
289
|
+
// specs below so their "cannot be delivered on the X wire" messages name
|
|
290
|
+
// the wire the value actually crosses, and the field/value noun ("set
|
|
291
|
+
// value" / "persist value") names the actual action kind, not always `set`.
|
|
292
|
+
function wireName(discriminator: "set" | "persist"): string {
|
|
293
|
+
return discriminator === "set" ? "set-state" : "set-config";
|
|
294
|
+
}
|
|
295
|
+
|
|
228
296
|
// [LAW:types-are-the-program] Each value source's payload as a field map — the
|
|
229
|
-
// non
|
|
230
|
-
// issues) and fails the arm when a required field is absent or
|
|
231
|
-
// adds the cross-field invariants `fields` cannot express.
|
|
232
|
-
// payload IS the member minus
|
|
233
|
-
|
|
234
|
-
|
|
297
|
+
// non-discriminator keys that source carries. `fields` runs every spec
|
|
298
|
+
// (reporting all issues) and fails the arm when a required field is absent or
|
|
299
|
+
// invalid; `refine` adds the cross-field invariants `fields` cannot express.
|
|
300
|
+
// The reconstructed payload IS the member minus the discriminator, which the
|
|
301
|
+
// dispatcher re-attaches. Built once per discriminator (`set`/`persist` share
|
|
302
|
+
// field SHAPE but not error WORDING — see setLiteralSpec/fromSpec/cycleSpec).
|
|
303
|
+
const TO_FIELDS_SET: FieldSpecMap<{ to: string }> = {
|
|
304
|
+
to: setLiteralSpec("set"),
|
|
305
|
+
};
|
|
306
|
+
const TO_FIELDS_PERSIST: FieldSpecMap<{ to: string }> = {
|
|
307
|
+
to: setLiteralSpec("persist"),
|
|
308
|
+
};
|
|
309
|
+
const FROM_FIELDS_SET: FieldSpecMap<{ from: OptionDomain }> = {
|
|
310
|
+
from: fromSpec("set"),
|
|
311
|
+
};
|
|
312
|
+
const FROM_FIELDS_PERSIST: FieldSpecMap<{ from: OptionDomain }> = {
|
|
313
|
+
from: fromSpec("persist"),
|
|
314
|
+
};
|
|
235
315
|
const BOUNDED_FIELDS: FieldSpecMap<{ min: number; max: number; by: number }> = {
|
|
236
316
|
min: requireIntSpec(),
|
|
237
317
|
max: requireIntSpec(),
|
|
238
318
|
by: requireIntSpec(),
|
|
239
319
|
};
|
|
240
320
|
const INT_FIELDS: FieldSpecMap<{ int: true }> = { int: intMarkerSpec() };
|
|
241
|
-
const
|
|
242
|
-
cycle: cycleSpec(),
|
|
321
|
+
const CYCLE_FIELDS_SET: FieldSpecMap<{ cycle: readonly string[] }> = {
|
|
322
|
+
cycle: cycleSpec("set"),
|
|
323
|
+
};
|
|
324
|
+
const CYCLE_FIELDS_PERSIST: FieldSpecMap<{ cycle: readonly string[] }> = {
|
|
325
|
+
cycle: cycleSpec("persist"),
|
|
243
326
|
};
|
|
244
327
|
|
|
245
328
|
// [LAW:types-are-the-program] A bounded step is fully described by an integer
|
|
@@ -270,29 +353,34 @@ const byNonZero: Refinement<BoundedPayload> = {
|
|
|
270
353
|
}),
|
|
271
354
|
};
|
|
272
355
|
|
|
273
|
-
// [LAW:types-are-the-program] A
|
|
274
|
-
// refinements; `detect` (the non
|
|
275
|
-
// (those keys plus
|
|
276
|
-
// name in the exactly-one message — the
|
|
277
|
-
//
|
|
278
|
-
// permits, and is named by.
|
|
279
|
-
|
|
356
|
+
// [LAW:types-are-the-program] A value-source arm is its payload field map plus
|
|
357
|
+
// its refinements; `detect` (the non-discriminator keys whose presence
|
|
358
|
+
// selects it), `allowed` (those keys plus the discriminator, the unknown-key
|
|
359
|
+
// allow-list), and `label` (the source name in the exactly-one message — the
|
|
360
|
+
// detect keys joined by "/") all DERIVE from the field map, so the field set
|
|
361
|
+
// is the single source for what the arm parses, permits, and is named by.
|
|
362
|
+
// Parameterized by `discriminator` ("set" | "persist") so `set` and `persist`
|
|
363
|
+
// share the identical to/from/min-max-by/cycle shapes without duplicating
|
|
364
|
+
// their field maps — only the discriminator's NAME differs in the emitted
|
|
365
|
+
// object shape and the reconstructed member.
|
|
366
|
+
interface ValueSourceArm {
|
|
280
367
|
readonly detect: readonly string[];
|
|
281
368
|
readonly allowed: readonly string[];
|
|
282
369
|
readonly label: string;
|
|
283
|
-
// [LAW:one-source-of-truth] The arm's emit facet: the closed object schema
|
|
284
|
-
//
|
|
285
|
-
// derived from the SAME field map `fields` validates.
|
|
286
|
-
// (min<max, by≠0) are unexpressible in JSON Schema,
|
|
287
|
-
//
|
|
370
|
+
// [LAW:one-source-of-truth] The arm's emit facet: the closed object schema
|
|
371
|
+
// for this discriminator's value source — the discriminator key plus the
|
|
372
|
+
// source's own fields, derived from the SAME field map `fields` validates.
|
|
373
|
+
// Cross-field refinements (min<max, by≠0) are unexpressible in JSON Schema,
|
|
374
|
+
// so only the structural shape is emitted.
|
|
288
375
|
readonly json: JsonNode;
|
|
289
376
|
readonly parse: ArmParse<Partial<ActionDecl>>;
|
|
290
377
|
}
|
|
291
378
|
|
|
292
|
-
function
|
|
379
|
+
function valueSourceArm<P extends object>(
|
|
380
|
+
discriminator: "set" | "persist",
|
|
293
381
|
fieldMap: FieldSpecMap<P>,
|
|
294
382
|
...checks: ReadonlyArray<Refinement<P>>
|
|
295
|
-
):
|
|
383
|
+
): ValueSourceArm {
|
|
296
384
|
const detect = Object.keys(fieldMap);
|
|
297
385
|
const inner: ArmParse<P> = (ctx, path, raw) =>
|
|
298
386
|
fields(ctx, fieldMap, path, raw);
|
|
@@ -302,12 +390,12 @@ function setArm<P extends object>(
|
|
|
302
390
|
};
|
|
303
391
|
return {
|
|
304
392
|
detect,
|
|
305
|
-
allowed: [
|
|
393
|
+
allowed: [discriminator, ...detect],
|
|
306
394
|
label: detect.join("/"),
|
|
307
395
|
json: {
|
|
308
396
|
type: "object",
|
|
309
|
-
properties: {
|
|
310
|
-
required: [
|
|
397
|
+
properties: { [discriminator]: { type: "string" }, ...source.properties },
|
|
398
|
+
required: [discriminator, ...(source.required ?? [])],
|
|
311
399
|
additionalProperties: false,
|
|
312
400
|
},
|
|
313
401
|
parse: (checks.length
|
|
@@ -316,40 +404,60 @@ function setArm<P extends object>(
|
|
|
316
404
|
};
|
|
317
405
|
}
|
|
318
406
|
|
|
319
|
-
// [LAW:dataflow-not-control-flow] The value-source arms in the order their
|
|
320
|
-
// appear in the exactly-one message. A `set` declares exactly one of
|
|
321
|
-
// dispatcher counts presence over `detect` and reconstructs
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
407
|
+
// [LAW:dataflow-not-control-flow] The value-source arms in the order their
|
|
408
|
+
// labels appear in the exactly-one message. A `set` declares exactly one of
|
|
409
|
+
// these; the dispatcher counts presence over `detect` and reconstructs
|
|
410
|
+
// `{ set, ...payload }`.
|
|
411
|
+
const SET_ARMS: readonly ValueSourceArm[] = [
|
|
412
|
+
valueSourceArm("set", TO_FIELDS_SET),
|
|
413
|
+
valueSourceArm("set", FROM_FIELDS_SET),
|
|
414
|
+
valueSourceArm("set", BOUNDED_FIELDS, minLessThanMax, byNonZero),
|
|
415
|
+
valueSourceArm("set", INT_FIELDS),
|
|
416
|
+
valueSourceArm("set", CYCLE_FIELDS_SET),
|
|
417
|
+
];
|
|
418
|
+
|
|
419
|
+
// [LAW:one-type-per-behavior] `persist` mirrors `set` minus the `int` arm — a
|
|
420
|
+
// page cursor is a UI-only paging concept with no meaning as a persisted
|
|
421
|
+
// config default (see action.ts's ActionDecl comment).
|
|
422
|
+
const PERSIST_ARMS: readonly ValueSourceArm[] = [
|
|
423
|
+
valueSourceArm("persist", TO_FIELDS_PERSIST),
|
|
424
|
+
valueSourceArm("persist", FROM_FIELDS_PERSIST),
|
|
425
|
+
valueSourceArm("persist", BOUNDED_FIELDS, minLessThanMax, byNonZero),
|
|
426
|
+
valueSourceArm("persist", CYCLE_FIELDS_PERSIST),
|
|
328
427
|
];
|
|
329
428
|
|
|
330
|
-
const VALUE_SOURCE_MESSAGE =
|
|
331
|
-
"/",
|
|
332
|
-
)}), "min"/"max"/"by" (a bounded step), "int" (an unbounded integer cursor), or "cycle" (an enumerated domain stepped in order)`;
|
|
429
|
+
const VALUE_SOURCE_MESSAGE = (discriminator: "set" | "persist") =>
|
|
430
|
+
`a ${discriminator} action declares exactly one value source: "to" (a literal value), "from" (an option domain — a registered domain name like "themes"/"styles"/"looks", or an inline array of literal values), "min"/"max"/"by" (a bounded step)${discriminator === "set" ? `, "int" (an unbounded integer cursor)` : ""}, or "cycle" (an enumerated domain stepped in order)`;
|
|
333
431
|
|
|
334
|
-
// [LAW:dataflow-not-control-flow] The set sub-union eliminator:
|
|
335
|
-
// shared
|
|
336
|
-
// reject keys outside that arm's allow-list,
|
|
337
|
-
// member. The variability (which arms,
|
|
338
|
-
// is the
|
|
339
|
-
// null-threading both the key and
|
|
340
|
-
|
|
432
|
+
// [LAW:dataflow-not-control-flow] The set/persist sub-union eliminator:
|
|
433
|
+
// validate the shared discriminator key, count which value sources are
|
|
434
|
+
// present, require exactly one, reject keys outside that arm's allow-list,
|
|
435
|
+
// parse the payload, reconstruct the member. The variability (which arms,
|
|
436
|
+
// each arm's fields/refinements/allow-list) is the `arms` data; the only
|
|
437
|
+
// branches are the presence-count and the null-threading both the key and
|
|
438
|
+
// the payload share.
|
|
439
|
+
function valueSourceAction(
|
|
341
440
|
ctx: ValidateCtx,
|
|
342
441
|
path: string,
|
|
343
442
|
raw: Record<string, unknown>,
|
|
443
|
+
discriminator: "set" | "persist",
|
|
444
|
+
arms: readonly ValueSourceArm[],
|
|
445
|
+
keyNoun: string,
|
|
344
446
|
): ActionDecl | null {
|
|
345
|
-
const stateKey =
|
|
447
|
+
const stateKey = validateValueSourceKey(
|
|
448
|
+
ctx,
|
|
449
|
+
path,
|
|
450
|
+
raw,
|
|
451
|
+
discriminator,
|
|
452
|
+
keyNoun,
|
|
453
|
+
);
|
|
346
454
|
|
|
347
|
-
const present =
|
|
455
|
+
const present = arms.filter((arm) => arm.detect.some((k) => k in raw));
|
|
348
456
|
if (present.length !== 1) {
|
|
349
457
|
issue(
|
|
350
458
|
ctx,
|
|
351
459
|
path,
|
|
352
|
-
`${VALUE_SOURCE_MESSAGE}${
|
|
460
|
+
`${VALUE_SOURCE_MESSAGE(discriminator)}${
|
|
353
461
|
present.length > 1
|
|
354
462
|
? ` — found: ${present.map((a) => a.label).join(", ")}`
|
|
355
463
|
: ""
|
|
@@ -364,21 +472,24 @@ function validateSetAction(
|
|
|
364
472
|
issue(
|
|
365
473
|
ctx,
|
|
366
474
|
`${path}.${k}`,
|
|
367
|
-
`Unknown key "${k}" on this
|
|
475
|
+
`Unknown key "${k}" on this ${discriminator} action. Expected one of: ${arm.allowed.join(", ")}`,
|
|
368
476
|
);
|
|
369
477
|
}
|
|
370
478
|
|
|
371
479
|
const payload = arm.parse(ctx, path, raw);
|
|
372
480
|
return stateKey === null || payload === null
|
|
373
481
|
? null
|
|
374
|
-
: ({
|
|
482
|
+
: ({ [discriminator]: stateKey, ...payload } as unknown as ActionDecl);
|
|
375
483
|
}
|
|
376
484
|
|
|
377
|
-
// [LAW:no-silent-fallbacks] A literal `to` and the
|
|
378
|
-
// non-empty/slash-free shape — the
|
|
379
|
-
// on "/", so either is undeliverable. The empty/slash messages are this
|
|
380
|
-
// the shape is the shared enforcer's.
|
|
381
|
-
|
|
485
|
+
// [LAW:no-silent-fallbacks] A literal `to` and the discriminator key share
|
|
486
|
+
// the non-empty/slash-free shape — the wire rejects empty values and splits
|
|
487
|
+
// on "/", so either is undeliverable. The empty/slash messages are this
|
|
488
|
+
// arm's, the shape is the shared enforcer's. Built once per discriminator
|
|
489
|
+
// (see TO_FIELDS_SET/TO_FIELDS_PERSIST) so a `persist` action's message names
|
|
490
|
+
// "persist value" and the set-config wire, never `set`'s wording.
|
|
491
|
+
function setLiteralSpec(discriminator: "set" | "persist"): FieldSpec<string> {
|
|
492
|
+
const wire = wireName(discriminator);
|
|
382
493
|
return {
|
|
383
494
|
required: true,
|
|
384
495
|
json: { type: "string" },
|
|
@@ -388,45 +499,108 @@ function setLiteralSpec(): FieldSpec<string> {
|
|
|
388
499
|
path,
|
|
389
500
|
field,
|
|
390
501
|
raw,
|
|
391
|
-
|
|
392
|
-
(v) =>
|
|
502
|
+
`${discriminator} value must be non-empty — an empty value cannot be delivered on the ${wire} wire`,
|
|
503
|
+
(v) =>
|
|
504
|
+
`${discriminator} value "${v}" contains "/" — ${discriminator} values must be slash-free`,
|
|
393
505
|
) ?? undefined,
|
|
394
506
|
};
|
|
395
507
|
}
|
|
396
508
|
|
|
397
|
-
// [LAW:types-are-the-program] `from` is a
|
|
398
|
-
//
|
|
399
|
-
//
|
|
400
|
-
|
|
509
|
+
// [LAW:types-are-the-program] `from` is either a NAME (a non-empty string,
|
|
510
|
+
// resolved against the option-domain registry) or an INLINE literal domain (a
|
|
511
|
+
// non-empty array of deliverable wire values — the same non-empty/
|
|
512
|
+
// slash-free wire shape `to` and `cycle` members enforce, plus the same
|
|
513
|
+
// uniqueness `cycleSpec` requires: a duplicate has no successor-ambiguity
|
|
514
|
+
// concern here, but it would render the same picker cell twice for no
|
|
515
|
+
// benefit). This arm proves only the SHAPE; whether a named domain actually
|
|
516
|
+
// resolves needs the merged config's per-config domains (e.g. "looks"), so
|
|
517
|
+
// that check is a cross-reference concern (validateCrossReferences) —
|
|
518
|
+
// symmetric to how a layout node's segment ref or a `{{ action }}` ref
|
|
519
|
+
// resolves post-merge. Built once per discriminator, same reason as
|
|
520
|
+
// setLiteralSpec.
|
|
521
|
+
function fromSpec(discriminator: "set" | "persist"): FieldSpec<OptionDomain> {
|
|
522
|
+
const wire = wireName(discriminator);
|
|
401
523
|
return {
|
|
402
524
|
required: true,
|
|
403
|
-
json: {
|
|
525
|
+
json: {
|
|
526
|
+
anyOf: [
|
|
527
|
+
{ type: "string", minLength: 1 },
|
|
528
|
+
{
|
|
529
|
+
type: "array",
|
|
530
|
+
items: { type: "string", minLength: 1 },
|
|
531
|
+
minItems: 1,
|
|
532
|
+
uniqueItems: true,
|
|
533
|
+
},
|
|
534
|
+
],
|
|
535
|
+
},
|
|
404
536
|
parse: (ctx, path, field, raw) => {
|
|
405
537
|
const from = raw[field];
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
538
|
+
const at = `${path}.${field}`;
|
|
539
|
+
if (typeof from === "string") {
|
|
540
|
+
if (from === "") {
|
|
541
|
+
issue(ctx, at, `from must be a non-empty domain name`);
|
|
542
|
+
return undefined;
|
|
543
|
+
}
|
|
544
|
+
return from;
|
|
545
|
+
}
|
|
546
|
+
if (Array.isArray(from) && from.every((m) => typeof m === "string")) {
|
|
547
|
+
const members = from as string[];
|
|
548
|
+
if (members.length === 0) {
|
|
549
|
+
issue(
|
|
550
|
+
ctx,
|
|
551
|
+
at,
|
|
552
|
+
`from must name a domain (a non-empty string) or declare an inline domain (a non-empty array of values)`,
|
|
553
|
+
);
|
|
554
|
+
return undefined;
|
|
555
|
+
}
|
|
556
|
+
if (members.some((m) => m === "")) {
|
|
557
|
+
issue(
|
|
558
|
+
ctx,
|
|
559
|
+
at,
|
|
560
|
+
`from array members must be non-empty — an empty value cannot be delivered on the ${wire} wire`,
|
|
561
|
+
);
|
|
562
|
+
return undefined;
|
|
563
|
+
}
|
|
564
|
+
const slashed = members.filter((m) => m.includes("/"));
|
|
565
|
+
if (slashed.length > 0) {
|
|
566
|
+
issue(
|
|
567
|
+
ctx,
|
|
568
|
+
at,
|
|
569
|
+
`from array member(s) ${slashed.map((m) => `"${m}"`).join(", ")} contain "/" — ${discriminator} values must be slash-free`,
|
|
570
|
+
);
|
|
571
|
+
return undefined;
|
|
572
|
+
}
|
|
573
|
+
if (new Set(members).size !== members.length) {
|
|
574
|
+
issue(
|
|
575
|
+
ctx,
|
|
576
|
+
at,
|
|
577
|
+
`from array members must be unique — a duplicated value would render the same picker option twice`,
|
|
578
|
+
);
|
|
579
|
+
return undefined;
|
|
580
|
+
}
|
|
581
|
+
return members;
|
|
416
582
|
}
|
|
417
|
-
|
|
583
|
+
issue(
|
|
584
|
+
ctx,
|
|
585
|
+
at,
|
|
586
|
+
`from must be a domain name (a string) or an inline domain (an array of strings), got ${describeValue(from)}`,
|
|
587
|
+
);
|
|
588
|
+
return undefined;
|
|
418
589
|
},
|
|
419
590
|
};
|
|
420
591
|
}
|
|
421
592
|
|
|
422
593
|
// [LAW:types-are-the-program] `cycle` is the enumerated domain a click steps
|
|
423
594
|
// through: at least two members (one member has no successor to step to — that
|
|
424
|
-
// is a literal `to`), each a deliverable
|
|
595
|
+
// is a literal `to`), each a deliverable wire value (non-empty, slash-free
|
|
425
596
|
// — the same wire shape `to` enforces), no duplicates (the successor of a
|
|
426
597
|
// duplicated member is ambiguous). Members double as the derived allow-list
|
|
427
598
|
// gate, so a member this spec admits is a value the wire delivers, by
|
|
428
|
-
// construction.
|
|
429
|
-
function cycleSpec(
|
|
599
|
+
// construction. Built once per discriminator, same reason as setLiteralSpec.
|
|
600
|
+
function cycleSpec(
|
|
601
|
+
discriminator: "set" | "persist",
|
|
602
|
+
): FieldSpec<readonly string[]> {
|
|
603
|
+
const wire = wireName(discriminator);
|
|
430
604
|
return {
|
|
431
605
|
required: true,
|
|
432
606
|
json: {
|
|
@@ -461,7 +635,7 @@ function cycleSpec(): FieldSpec<readonly string[]> {
|
|
|
461
635
|
issue(
|
|
462
636
|
ctx,
|
|
463
637
|
at,
|
|
464
|
-
`cycle members must be non-empty — an empty value cannot be delivered on the
|
|
638
|
+
`cycle members must be non-empty — an empty value cannot be delivered on the ${wire} wire`,
|
|
465
639
|
);
|
|
466
640
|
return undefined;
|
|
467
641
|
}
|
|
@@ -469,7 +643,7 @@ function cycleSpec(): FieldSpec<readonly string[]> {
|
|
|
469
643
|
issue(
|
|
470
644
|
ctx,
|
|
471
645
|
at,
|
|
472
|
-
`cycle member(s) ${slashed.map((m) => `"${m}"`).join(", ")} contain "/" —
|
|
646
|
+
`cycle member(s) ${slashed.map((m) => `"${m}"`).join(", ")} contain "/" — ${discriminator} values must be slash-free`,
|
|
473
647
|
);
|
|
474
648
|
return undefined;
|
|
475
649
|
}
|