@promptctl/cc-candybar 1.42.1 → 1.43.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 +72 -71
- package/package.json +5 -6
- package/src/check.ts +0 -478
- package/src/cli-flags.ts +0 -8
- package/src/click/wire.ts +0 -158
- package/src/config/action.ts +0 -329
- package/src/config/cli.ts +0 -71
- package/src/config/default-dsl-config.ts +0 -1645
- package/src/config/disclosure.ts +0 -170
- package/src/config/dsl-loader.ts +0 -339
- package/src/config/dsl-types.ts +0 -581
- package/src/config/edit-chrome.ts +0 -559
- package/src/config/help.ts +0 -151
- package/src/config/ident.ts +0 -22
- package/src/config/layout-ops.ts +0 -177
- package/src/config/loader/actions.ts +0 -972
- package/src/config/loader/cache.ts +0 -206
- package/src/config/loader/cross-ref.ts +0 -714
- package/src/config/loader/cycles.ts +0 -148
- package/src/config/loader/diagnostics.ts +0 -99
- package/src/config/loader/discovery.ts +0 -182
- package/src/config/loader/edit-mode.ts +0 -137
- package/src/config/loader/emit-schema.ts +0 -68
- package/src/config/loader/globals.ts +0 -269
- package/src/config/loader/helpers.ts +0 -48
- package/src/config/loader/layout.ts +0 -693
- package/src/config/loader/looks.ts +0 -96
- package/src/config/loader/menu-synth.ts +0 -435
- package/src/config/loader/merge.ts +0 -115
- package/src/config/loader/persist-target.ts +0 -67
- package/src/config/loader/presets.ts +0 -119
- package/src/config/loader/refs.ts +0 -100
- package/src/config/loader/reserved-namespace.ts +0 -38
- package/src/config/loader/segments.ts +0 -120
- package/src/config/loader/validate-core.ts +0 -737
- package/src/config/loader/variables.ts +0 -260
- package/src/config/menu-keys.ts +0 -139
- package/src/config/option-domain.ts +0 -164
- package/src/config/presets.ts +0 -326
- package/src/config/settings-menu.ts +0 -775
- package/src/daemon/acquire.ts +0 -684
- package/src/daemon/cache/git.ts +0 -649
- package/src/daemon/cache/render.ts +0 -623
- package/src/daemon/cache/session-usage-store.ts +0 -720
- package/src/daemon/cache/watchers.ts +0 -249
- package/src/daemon/client-debug.ts +0 -120
- package/src/daemon/client-stats.ts +0 -130
- package/src/daemon/client-transport.ts +0 -273
- package/src/daemon/client.ts +0 -78
- package/src/daemon/config-overrides-store.ts +0 -663
- package/src/daemon/debug-types.ts +0 -91
- package/src/daemon/debug.ts +0 -264
- package/src/daemon/fork-bomb-breaker.ts +0 -351
- package/src/daemon/limits.ts +0 -211
- package/src/daemon/log.ts +0 -81
- package/src/daemon/parent-watchdog.ts +0 -87
- package/src/daemon/paths.ts +0 -211
- package/src/daemon/process-fingerprint.ts +0 -146
- package/src/daemon/protocol.ts +0 -292
- package/src/daemon/render-payload.ts +0 -1256
- package/src/daemon/server.ts +0 -1330
- package/src/daemon/session-state-file.ts +0 -108
- package/src/daemon/session-state.ts +0 -237
- package/src/daemon/socket-lease.ts +0 -209
- package/src/daemon/socket-ownership.ts +0 -209
- package/src/daemon/stats.ts +0 -235
- package/src/daemon/verbs/config-validators.ts +0 -250
- package/src/daemon/verbs/index.ts +0 -706
- package/src/daemon/verbs/state-validators.ts +0 -249
- package/src/daemon/verbs/validator-registry.ts +0 -457
- package/src/demo/dsl.ts +0 -143
- package/src/demo/mock-data.ts +0 -67
- package/src/demo/statusline.json5 +0 -94
- package/src/dsl/node-registry.ts +0 -374
- package/src/dsl/render.ts +0 -803
- package/src/help-text.ts +0 -90
- package/src/index.ts +0 -210
- package/src/install/currency.ts +0 -197
- package/src/install/index.ts +0 -557
- package/src/proc/launch.ts +0 -459
- package/src/proc/stats-handle.ts +0 -13
- package/src/render/action.ts +0 -883
- package/src/render/active-segment.ts +0 -78
- package/src/render/diagnostic-style.ts +0 -23
- package/src/render/diagnostic-text.ts +0 -77
- package/src/render/error-glyph.ts +0 -53
- package/src/render/menu.ts +0 -257
- package/src/render/outcome-plan.ts +0 -45
- package/src/render/picker.ts +0 -372
- package/src/render/segment-color.ts +0 -74
- package/src/render/split-lines.ts +0 -51
- package/src/render/strip.ts +0 -228
- package/src/segments/cache.ts +0 -131
- package/src/segments/context.ts +0 -190
- package/src/segments/git.ts +0 -1084
- package/src/segments/metrics.ts +0 -187
- package/src/segments/pricing.ts +0 -452
- package/src/segments/session.ts +0 -23
- package/src/segments/tmux.ts +0 -74
- package/src/template-engine/cells.ts +0 -90
- package/src/template-engine/colors.ts +0 -124
- package/src/template-engine/engine.ts +0 -108
- package/src/template-engine/funcs.ts +0 -232
- package/src/template-engine/index.ts +0 -11
- package/src/template-engine/layout.ts +0 -133
- package/src/template-engine/scope.ts +0 -62
- package/src/template-engine/sparkline.ts +0 -79
- package/src/themes/index.ts +0 -20
- package/src/themes/palette-resolvers.ts +0 -84
- package/src/themes/policy.ts +0 -393
- package/src/utils/cache.ts +0 -206
- package/src/utils/claude.ts +0 -683
- package/src/utils/color-support.ts +0 -118
- package/src/utils/formatters.ts +0 -99
- package/src/utils/logger.ts +0 -5
- package/src/utils/outcome.ts +0 -33
- package/src/utils/schema-validator.ts +0 -126
- package/src/utils/single-flight.ts +0 -57
- package/src/utils/terminal-width.ts +0 -51
- package/src/utils/terminal.ts +0 -11
- package/src/utils/transcript-fs.ts +0 -279
- package/src/var-system/index.ts +0 -24
- package/src/var-system/sources.ts +0 -1047
- package/src/var-system/store.ts +0 -223
- package/src/var-system/types.ts +0 -57
- package/src/version.ts +0 -17
|
@@ -1,972 +0,0 @@
|
|
|
1
|
-
// [LAW:types-are-the-program] The action-table schema. An ActionDecl is
|
|
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.
|
|
9
|
-
//
|
|
10
|
-
// [LAW:no-mode-explosion] Unlike cache (single-key value-arms → oneOfPresent) and
|
|
11
|
-
// variables (tag-by-field-value → taggedUnion), an action's arms are multi-key
|
|
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.
|
|
20
|
-
|
|
21
|
-
import {
|
|
22
|
-
ACTION_KEYS,
|
|
23
|
-
PERSIST_WHEN,
|
|
24
|
-
type ActionDecl,
|
|
25
|
-
type ActionKey,
|
|
26
|
-
type OptionDomain,
|
|
27
|
-
} from "../action.js";
|
|
28
|
-
import { findKeyLine } from "./diagnostics.js";
|
|
29
|
-
import {
|
|
30
|
-
describeType,
|
|
31
|
-
describeValue,
|
|
32
|
-
fields,
|
|
33
|
-
isPlainObject,
|
|
34
|
-
objectJson,
|
|
35
|
-
refine,
|
|
36
|
-
requireString,
|
|
37
|
-
type ArmParse,
|
|
38
|
-
type FieldSpec,
|
|
39
|
-
type FieldSpecMap,
|
|
40
|
-
type JsonNode,
|
|
41
|
-
type Refinement,
|
|
42
|
-
type ValidateCtx,
|
|
43
|
-
} from "./validate-core.js";
|
|
44
|
-
|
|
45
|
-
// [LAW:locality-or-seam] Structural validation of the `actions` block: each
|
|
46
|
-
// action is discriminated by which of set/copy/open is present, a `set` further
|
|
47
|
-
// by its value SOURCE (to | from | min/max/by | int). Whether a `{{ action
|
|
48
|
-
// "name" }}` reference resolves is a cross-ref concern (validateCrossReferences),
|
|
49
|
-
// which runs on the MERGED config so a segment can reference a default-provided
|
|
50
|
-
// action.
|
|
51
|
-
export function validateActions(
|
|
52
|
-
ctx: ValidateCtx,
|
|
53
|
-
raw: unknown,
|
|
54
|
-
): Record<string, ActionDecl> {
|
|
55
|
-
if (raw === undefined) return {};
|
|
56
|
-
if (!isPlainObject(raw)) {
|
|
57
|
-
issue(
|
|
58
|
-
ctx,
|
|
59
|
-
"actions",
|
|
60
|
-
`actions must be an object, got ${describeType(raw)}`,
|
|
61
|
-
);
|
|
62
|
-
return {};
|
|
63
|
-
}
|
|
64
|
-
// [LAW:types-are-the-program] Null-prototype record for user-keyed data, so an
|
|
65
|
-
// action named "__proto__"/"constructor" is an ordinary own property, never a
|
|
66
|
-
// prototype-chain mutation — matching the widgets block and the compiled maps.
|
|
67
|
-
const out: Record<string, ActionDecl> = Object.create(null) as Record<
|
|
68
|
-
string,
|
|
69
|
-
ActionDecl
|
|
70
|
-
>;
|
|
71
|
-
for (const [name, decl] of Object.entries(raw)) {
|
|
72
|
-
const parsed = validateActionDecl(ctx, `actions.${name}`, decl);
|
|
73
|
-
if (parsed !== null) out[name] = parsed;
|
|
74
|
-
}
|
|
75
|
-
return out;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
// [LAW:single-enforcer] One place pushes an issue with the resolved source line —
|
|
79
|
-
// the line derivation is mechanical from the path, so no callsite restates it.
|
|
80
|
-
function issue(ctx: ValidateCtx, path: string, message: string): void {
|
|
81
|
-
ctx.issues.push({
|
|
82
|
-
path,
|
|
83
|
-
message,
|
|
84
|
-
line: findKeyLine(ctx.source, path.split(".")),
|
|
85
|
-
});
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
// [LAW:dataflow-not-control-flow] The top-level union eliminator: exactly one of
|
|
89
|
-
// set/copy/open is present, then dispatch the whole record to that arm via the
|
|
90
|
-
// arm table. The dispatch is a total projection over the present key (no
|
|
91
|
-
// fallthrough) — the only branch is the presence-count, which every union must
|
|
92
|
-
// discriminate somewhere. The set arm owns its own siblings (the value source),
|
|
93
|
-
// so this level rejects no keys generically.
|
|
94
|
-
function validateActionDecl(
|
|
95
|
-
ctx: ValidateCtx,
|
|
96
|
-
path: string,
|
|
97
|
-
raw: unknown,
|
|
98
|
-
): ActionDecl | null {
|
|
99
|
-
if (!isPlainObject(raw)) {
|
|
100
|
-
issue(
|
|
101
|
-
ctx,
|
|
102
|
-
path,
|
|
103
|
-
`${path} must be an action object, got ${describeType(raw)}`,
|
|
104
|
-
);
|
|
105
|
-
return null;
|
|
106
|
-
}
|
|
107
|
-
// [LAW:dataflow-not-control-flow] A dual-destination action
|
|
108
|
-
// (candybar-settings-ui-aok.3) carries BOTH `set` and `persist`, so it
|
|
109
|
-
// cannot be reached through the exactly-one-of eliminator below —
|
|
110
|
-
// `persistWhen` is its own discriminator, and its presence selects the arm
|
|
111
|
-
// exactly as the presence of `set` selects that one. The dual arm owns its
|
|
112
|
-
// own siblings (the two destination keys plus its value source), like every
|
|
113
|
-
// other arm here.
|
|
114
|
-
if (PERSIST_WHEN in raw) {
|
|
115
|
-
return valueSourceAction(ctx, path, raw, "dual", DUAL_ARMS);
|
|
116
|
-
}
|
|
117
|
-
const present = (ACTION_KEYS as readonly string[]).filter((k) => k in raw);
|
|
118
|
-
if (present.length !== 1) {
|
|
119
|
-
issue(
|
|
120
|
-
ctx,
|
|
121
|
-
path,
|
|
122
|
-
`action must declare exactly one of: ${ACTION_KEYS.join(", ")}${
|
|
123
|
-
present.length > 1 ? ` (found: ${present.join(", ")})` : ""
|
|
124
|
-
}`,
|
|
125
|
-
);
|
|
126
|
-
return null;
|
|
127
|
-
}
|
|
128
|
-
return ACTION_ARMS[present[0] as ActionKey](ctx, path, raw);
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
// [LAW:dataflow-not-control-flow] The top-level arm table as DATA: copy/open share
|
|
132
|
-
// one template-arm shape (their key names the only difference); set/persist
|
|
133
|
-
// delegate to their value-source sub-union (the SAME field shapes, a different
|
|
134
|
-
// discriminator key — see valueSourceAction); reset is copy/open's plain-string
|
|
135
|
-
// sibling. The present key indexes this map — the eliminator never branches on
|
|
136
|
-
// the key name.
|
|
137
|
-
const ACTION_ARMS: Record<ActionKey, ArmParse<ActionDecl>> = {
|
|
138
|
-
set: (ctx, path, raw) => valueSourceAction(ctx, path, raw, "set", SET_ARMS),
|
|
139
|
-
persist: (ctx, path, raw) =>
|
|
140
|
-
valueSourceAction(ctx, path, raw, "persist", PERSIST_ARMS),
|
|
141
|
-
copy: templateArm("copy"),
|
|
142
|
-
open: templateArm("open"),
|
|
143
|
-
reset: resetArm,
|
|
144
|
-
undo: markerArm("undo"),
|
|
145
|
-
redo: markerArm("redo"),
|
|
146
|
-
};
|
|
147
|
-
|
|
148
|
-
// [LAW:one-source-of-truth] A copy/open action emits the closed single-key
|
|
149
|
-
// object its arm validates — symmetric to `templateArm(key)`'s parse.
|
|
150
|
-
function templateArmJson(key: "copy" | "open" | "reset"): JsonNode {
|
|
151
|
-
return {
|
|
152
|
-
type: "object",
|
|
153
|
-
properties: { [key]: { type: "string" } },
|
|
154
|
-
required: [key],
|
|
155
|
-
additionalProperties: false,
|
|
156
|
-
};
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
// [LAW:one-source-of-truth] One ActionDecl's schema: the set/persist sub-unions
|
|
160
|
-
// (each arm's `json`, derived from SET_ARMS/PERSIST_ARMS) joined with
|
|
161
|
-
// copy/open/reset — the SAME members `validateActionDecl` dispatches over. The
|
|
162
|
-
// `actions` block is a name → ActionDecl map, symmetric to `validateActions`.
|
|
163
|
-
function actionDeclJson(): JsonNode {
|
|
164
|
-
return {
|
|
165
|
-
anyOf: [
|
|
166
|
-
...SET_ARMS.map((arm) => arm.json),
|
|
167
|
-
...PERSIST_ARMS.map((arm) => arm.json),
|
|
168
|
-
...DUAL_ARMS.map((arm) => arm.json),
|
|
169
|
-
templateArmJson("copy"),
|
|
170
|
-
templateArmJson("open"),
|
|
171
|
-
templateArmJson("reset"),
|
|
172
|
-
markerArmJson("undo"),
|
|
173
|
-
markerArmJson("redo"),
|
|
174
|
-
],
|
|
175
|
-
};
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
export function actionsJson(): JsonNode {
|
|
179
|
-
return { type: "object", additionalProperties: actionDeclJson() };
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
// [LAW:one-type-per-behavior] copy and open are one behavior — a single required
|
|
183
|
-
// template string, no other keys — parameterized by the key. Both reject every
|
|
184
|
-
// sibling key (the arm's only legal key is its own) with the bespoke per-key
|
|
185
|
-
// message, then read the template.
|
|
186
|
-
function templateArm(key: "copy" | "open"): ArmParse<ActionDecl> {
|
|
187
|
-
return (ctx, path, raw) => {
|
|
188
|
-
for (const k of Object.keys(raw)) {
|
|
189
|
-
if (k !== key)
|
|
190
|
-
issue(
|
|
191
|
-
ctx,
|
|
192
|
-
`${path}.${k}`,
|
|
193
|
-
`Unknown key "${k}" on a ${key} action. Expected only: ${key}`,
|
|
194
|
-
);
|
|
195
|
-
}
|
|
196
|
-
const tmpl = requireString(ctx, path, raw, key);
|
|
197
|
-
return tmpl === null ? null : ({ [key]: tmpl } as unknown as ActionDecl);
|
|
198
|
-
};
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
// [LAW:one-type-per-behavior] `reset` is copy/open's shape (a single required
|
|
202
|
-
// string, no other keys) but the string is a KEY (a config globals field name),
|
|
203
|
-
// not a template — no Go-template parsing happens for it, so it reuses the
|
|
204
|
-
// slash-free/non-empty shape `set`/`persist` keys share rather than
|
|
205
|
-
// requireString's bare-presence check. A `function` declaration (not a const
|
|
206
|
-
// arrow) so it is hoisted — ACTION_ARMS above references it directly, not
|
|
207
|
-
// through a deferred closure.
|
|
208
|
-
function resetArm(
|
|
209
|
-
ctx: ValidateCtx,
|
|
210
|
-
path: string,
|
|
211
|
-
raw: Record<string, unknown>,
|
|
212
|
-
): ActionDecl | null {
|
|
213
|
-
for (const k of Object.keys(raw)) {
|
|
214
|
-
if (k !== "reset")
|
|
215
|
-
issue(
|
|
216
|
-
ctx,
|
|
217
|
-
`${path}.${k}`,
|
|
218
|
-
`Unknown key "${k}" on a reset action. Expected only: reset`,
|
|
219
|
-
);
|
|
220
|
-
}
|
|
221
|
-
const key = slashFreeString(
|
|
222
|
-
ctx,
|
|
223
|
-
path,
|
|
224
|
-
"reset",
|
|
225
|
-
raw,
|
|
226
|
-
`reset key must be non-empty (the config globals field to clear)`,
|
|
227
|
-
(v) => `reset key "${v}" contains "/" — keys must be slash-free`,
|
|
228
|
-
);
|
|
229
|
-
return key === null ? null : { reset: key };
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
// [LAW:one-type-per-behavior] `undo`/`redo` are copy/open/reset's shape one
|
|
233
|
-
// step further reduced: a single required key whose only legal VALUE is the
|
|
234
|
-
// literal `true` (mirrors intMarkerSpec — a marker, not data), because there
|
|
235
|
-
// is no key to name: the history they step is one global stack over the
|
|
236
|
-
// whole overrides layer, not a per-target write. `function`, not a const
|
|
237
|
-
// arrow, so ACTION_ARMS above (built before this declaration in source
|
|
238
|
-
// order) can reference it directly via hoisting.
|
|
239
|
-
function markerArm(key: "undo" | "redo"): ArmParse<ActionDecl> {
|
|
240
|
-
return (ctx, path, raw) => {
|
|
241
|
-
for (const k of Object.keys(raw)) {
|
|
242
|
-
if (k !== key)
|
|
243
|
-
issue(
|
|
244
|
-
ctx,
|
|
245
|
-
`${path}.${k}`,
|
|
246
|
-
`Unknown key "${k}" on a ${key} action. Expected only: ${key}`,
|
|
247
|
-
);
|
|
248
|
-
}
|
|
249
|
-
if (raw[key] !== true) {
|
|
250
|
-
issue(
|
|
251
|
-
ctx,
|
|
252
|
-
`${path}.${key}`,
|
|
253
|
-
`${key} must be the literal true (it takes no key — it steps the ONE global history over the whole overrides layer), got ${describeValue(raw[key])}`,
|
|
254
|
-
);
|
|
255
|
-
return null;
|
|
256
|
-
}
|
|
257
|
-
return { [key]: true } as unknown as ActionDecl;
|
|
258
|
-
};
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
// [LAW:one-source-of-truth] Mirrors templateArmJson's shape one level
|
|
262
|
-
// narrower: the value schema is `const: true`, not `type: string` — a
|
|
263
|
-
// marker action carries no data, on the wire or in the schema.
|
|
264
|
-
function markerArmJson(key: "undo" | "redo"): JsonNode {
|
|
265
|
-
return {
|
|
266
|
-
type: "object",
|
|
267
|
-
properties: { [key]: { const: true } },
|
|
268
|
-
required: [key],
|
|
269
|
-
additionalProperties: false,
|
|
270
|
-
};
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
// ─── The `set` value-source sub-union ────────────────────────────────────────
|
|
274
|
-
|
|
275
|
-
// [LAW:single-enforcer] A set-state URL path segment must be a non-empty,
|
|
276
|
-
// slash-free string — the set-state value is a slash-delimited
|
|
277
|
-
// <session>/<key>/<value> run, so an empty or slash-bearing segment is
|
|
278
|
-
// undeliverable. One validator, two callers (the `set` key and a literal `to`
|
|
279
|
-
// value), each supplying its bespoke message — the shape is enforced once, the
|
|
280
|
-
// wording stays per-use DATA. The codec itself is slash-safe; this is a
|
|
281
|
-
// deliberate upstream restriction so a slash-bearing key/value never reaches the
|
|
282
|
-
// wire, surfaced at load rather than thrown when validators register.
|
|
283
|
-
function slashFreeString(
|
|
284
|
-
ctx: ValidateCtx,
|
|
285
|
-
path: string,
|
|
286
|
-
field: string,
|
|
287
|
-
raw: Record<string, unknown>,
|
|
288
|
-
emptyMessage: string,
|
|
289
|
-
slashMessage: (value: string) => string,
|
|
290
|
-
): string | null {
|
|
291
|
-
const v = requireString(ctx, path, raw, field);
|
|
292
|
-
if (v === null) return null;
|
|
293
|
-
const at = `${path}.${field}`;
|
|
294
|
-
if (v === "") {
|
|
295
|
-
issue(ctx, at, emptyMessage);
|
|
296
|
-
return null;
|
|
297
|
-
}
|
|
298
|
-
if (v.includes("/")) {
|
|
299
|
-
issue(ctx, at, slashMessage(v));
|
|
300
|
-
return null;
|
|
301
|
-
}
|
|
302
|
-
return v;
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
// [LAW:types-are-the-program] Which KEYS a value-source action carries beside
|
|
306
|
-
// its value source, as data — one for a single-destination `set`/`persist`,
|
|
307
|
-
// three for a `dual` (both destination keys plus the selector naming which is
|
|
308
|
-
// written). Every consumer below (the key validation, the unknown-key
|
|
309
|
-
// allow-list, the emitted JSON schema, the reconstructed member) reads this
|
|
310
|
-
// one table, so adding the dual arm never meant a second dispatcher: the
|
|
311
|
-
// discriminator stopped being ONE key and became a LIST of them, and the
|
|
312
|
-
// existing machinery folds over the list [LAW:dataflow-not-control-flow].
|
|
313
|
-
type Discriminator = "set" | "persist" | "dual";
|
|
314
|
-
|
|
315
|
-
const DISCRIMINATOR_KEYS: Readonly<
|
|
316
|
-
Record<Discriminator, ReadonlyArray<readonly [string, string]>>
|
|
317
|
-
> = {
|
|
318
|
-
set: [["set", "the SessionState key to write"]],
|
|
319
|
-
persist: [["persist", "the config globals field to write"]],
|
|
320
|
-
dual: [
|
|
321
|
-
["set", "the SessionState key written while persistWhen is off"],
|
|
322
|
-
["persist", "the config globals field written while persistWhen is on"],
|
|
323
|
-
[
|
|
324
|
-
PERSIST_WHEN,
|
|
325
|
-
"the SessionState key whose boolean value chooses the destination",
|
|
326
|
-
],
|
|
327
|
-
],
|
|
328
|
-
};
|
|
329
|
-
|
|
330
|
-
// [LAW:dataflow-not-control-flow] The discriminator keys are validated once
|
|
331
|
-
// for every value source (they are shared across all arms of that
|
|
332
|
-
// discriminator), before the source is detected — so a bad key and an
|
|
333
|
-
// ambiguous source both surface in one pass. They are therefore NOT fields of
|
|
334
|
-
// any arm's `fields` map; the arm parses only the value-source payload, and
|
|
335
|
-
// the dispatcher re-attaches them.
|
|
336
|
-
//
|
|
337
|
-
// [LAW:no-silent-failure] Returns null when ANY key fails, after reporting
|
|
338
|
-
// every one of them — the caller threads that null exactly as it threads a
|
|
339
|
-
// failed payload, so a partly-valid dual never reconstructs into a member
|
|
340
|
-
// missing a destination.
|
|
341
|
-
function validateDiscriminatorKeys(
|
|
342
|
-
ctx: ValidateCtx,
|
|
343
|
-
path: string,
|
|
344
|
-
raw: Record<string, unknown>,
|
|
345
|
-
discriminator: Discriminator,
|
|
346
|
-
): Record<string, string> | null {
|
|
347
|
-
const out: Record<string, string> = {};
|
|
348
|
-
const keys = DISCRIMINATOR_KEYS[discriminator];
|
|
349
|
-
let ok = true;
|
|
350
|
-
for (const [key, noun] of keys) {
|
|
351
|
-
// [LAW:no-silent-failure] An ABSENT key gets the shape, not a type
|
|
352
|
-
// mismatch. A single-destination arm cannot reach this (its key is the
|
|
353
|
-
// discriminator that selected the arm), so this only ever fires on a dual
|
|
354
|
-
// that named one destination and not the other — where "persist must be a
|
|
355
|
-
// string, got undefined" describes the symptom and teaches nothing, and
|
|
356
|
-
// the author needs to be told the three keys travel together.
|
|
357
|
-
if (!(key in raw)) {
|
|
358
|
-
issue(
|
|
359
|
-
ctx,
|
|
360
|
-
path,
|
|
361
|
-
`${key} is required here (${noun}) — a dual-destination action declares ${keys
|
|
362
|
-
.map(([k]) => k)
|
|
363
|
-
.join(", ")} together, plus one value source`,
|
|
364
|
-
);
|
|
365
|
-
ok = false;
|
|
366
|
-
continue;
|
|
367
|
-
}
|
|
368
|
-
const value = slashFreeString(
|
|
369
|
-
ctx,
|
|
370
|
-
path,
|
|
371
|
-
key,
|
|
372
|
-
raw,
|
|
373
|
-
`${key} key must be non-empty (${noun})`,
|
|
374
|
-
(v) => `${key} key "${v}" contains "/" — keys must be slash-free`,
|
|
375
|
-
);
|
|
376
|
-
if (value === null) {
|
|
377
|
-
ok = false;
|
|
378
|
-
continue;
|
|
379
|
-
}
|
|
380
|
-
out[key] = value;
|
|
381
|
-
}
|
|
382
|
-
// [LAW:no-silent-failure] Every failing key is reported before returning, so
|
|
383
|
-
// an author who omits two of a dual's three keys sees both in one pass —
|
|
384
|
-
// matching every other multi-issue check in this file, and matching what the
|
|
385
|
-
// comment above promises.
|
|
386
|
-
return ok ? out : null;
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
// [LAW:one-source-of-truth] The wire verb name a discriminator's writes
|
|
390
|
-
// travel over — `set-state` for `set` (SessionState), `set-config` for
|
|
391
|
-
// `persist` (the config-overrides layer), and BOTH for a dual, whose one
|
|
392
|
-
// value crosses whichever wire the selector names. Threaded into the shared
|
|
393
|
-
// field specs below so their "cannot be delivered on the X wire" messages
|
|
394
|
-
// name the wire the value actually crosses, and the field/value noun ("set
|
|
395
|
-
// value" / "persist value") names the actual action kind, not always `set`.
|
|
396
|
-
function wireName(discriminator: Discriminator): string {
|
|
397
|
-
if (discriminator === "set") return "set-state";
|
|
398
|
-
return discriminator === "persist" ? "set-config" : "set-state/set-config";
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
// [LAW:types-are-the-program] Each value source's payload as a field map — the
|
|
402
|
-
// non-discriminator keys that source carries. `fields` runs every spec
|
|
403
|
-
// (reporting all issues) and fails the arm when a required field is absent or
|
|
404
|
-
// invalid; `refine` adds the cross-field invariants `fields` cannot express.
|
|
405
|
-
// The reconstructed payload IS the member minus the discriminator, which the
|
|
406
|
-
// dispatcher re-attaches. Built once per discriminator (`set`/`persist` share
|
|
407
|
-
// field SHAPE but not error WORDING — see setLiteralSpec/fromSpec/cycleSpec).
|
|
408
|
-
const TO_FIELDS_SET: FieldSpecMap<{ to: string }> = {
|
|
409
|
-
to: setLiteralSpec("set"),
|
|
410
|
-
};
|
|
411
|
-
const TO_FIELDS_PERSIST: FieldSpecMap<{ to: string }> = {
|
|
412
|
-
to: setLiteralSpec("persist"),
|
|
413
|
-
};
|
|
414
|
-
const FROM_FIELDS_SET: FieldSpecMap<{ from: OptionDomain }> = {
|
|
415
|
-
from: fromSpec("set"),
|
|
416
|
-
};
|
|
417
|
-
const FROM_FIELDS_PERSIST: FieldSpecMap<{ from: OptionDomain }> = {
|
|
418
|
-
from: fromSpec("persist"),
|
|
419
|
-
};
|
|
420
|
-
const BOUNDED_FIELDS: FieldSpecMap<{ min: number; max: number; by: number }> = {
|
|
421
|
-
min: requireIntSpec(),
|
|
422
|
-
max: requireIntSpec(),
|
|
423
|
-
by: requireIntSpec(),
|
|
424
|
-
};
|
|
425
|
-
const INT_FIELDS: FieldSpecMap<{ int: true }> = { int: intMarkerSpec() };
|
|
426
|
-
const CYCLE_FIELDS_SET: FieldSpecMap<{ cycle: readonly string[] }> = {
|
|
427
|
-
cycle: cycleSpec("set"),
|
|
428
|
-
};
|
|
429
|
-
const TO_FIELDS_DUAL: FieldSpecMap<{ to: string }> = {
|
|
430
|
-
to: setLiteralSpec("dual"),
|
|
431
|
-
};
|
|
432
|
-
const FROM_FIELDS_DUAL: FieldSpecMap<{ from: OptionDomain }> = {
|
|
433
|
-
from: fromSpec("dual"),
|
|
434
|
-
};
|
|
435
|
-
const CYCLE_FIELDS_DUAL: FieldSpecMap<{ cycle: readonly string[] }> = {
|
|
436
|
-
cycle: cycleSpec("dual"),
|
|
437
|
-
};
|
|
438
|
-
const CYCLE_FIELDS_PERSIST: FieldSpecMap<{ cycle: readonly string[] }> = {
|
|
439
|
-
cycle: cycleSpec("persist"),
|
|
440
|
-
};
|
|
441
|
-
// [LAW:one-type-per-behavior] brandon-layout-edit-2gc.1's two structural-edit
|
|
442
|
-
// arms — PERSIST-only (see action.ts's ActionDecl doc comment for why there
|
|
443
|
-
// is no `set` twin). Each field reuses layoutNameSpec: a segment/anchor name
|
|
444
|
-
// must be non-empty and free of both `/` (the click wire's own segment
|
|
445
|
-
// delimiter) and `:` (layout-ops.ts's op-token delimiter) — the SAME
|
|
446
|
-
// wire-safety diligence slashFreeString already applies to `to`/`cycle`
|
|
447
|
-
// members, one forbidden character wider.
|
|
448
|
-
const REMOVE_SEGMENT_FIELDS: FieldSpecMap<{ removeSegment: string }> = {
|
|
449
|
-
removeSegment: layoutNameSpec("removeSegment"),
|
|
450
|
-
};
|
|
451
|
-
const INSERT_SEGMENT_FIELDS: FieldSpecMap<{
|
|
452
|
-
insertSegment: string;
|
|
453
|
-
anchor: string;
|
|
454
|
-
relation: "before" | "after";
|
|
455
|
-
}> = {
|
|
456
|
-
insertSegment: layoutNameSpec("insertSegment"),
|
|
457
|
-
anchor: layoutNameSpec("anchor"),
|
|
458
|
-
relation: relationSpec(),
|
|
459
|
-
};
|
|
460
|
-
// [LAW:one-type-per-behavior] `insertSegmentFrom`'s payload mirrors
|
|
461
|
-
// `insertSegment`'s verbatim except the segment name is a `from`-shaped
|
|
462
|
-
// OptionDomain (fromSpec, the SAME field `set`/`persist … from` already
|
|
463
|
-
// validate) instead of a literal layout name — the "to" vs "from" split every
|
|
464
|
-
// other value source already draws, one arm over.
|
|
465
|
-
const INSERT_SEGMENT_FROM_FIELDS: FieldSpecMap<{
|
|
466
|
-
insertSegmentFrom: OptionDomain;
|
|
467
|
-
anchor: string;
|
|
468
|
-
relation: "before" | "after";
|
|
469
|
-
}> = {
|
|
470
|
-
insertSegmentFrom: fromSpec("persist"),
|
|
471
|
-
anchor: layoutNameSpec("anchor"),
|
|
472
|
-
relation: relationSpec(),
|
|
473
|
-
};
|
|
474
|
-
|
|
475
|
-
// [LAW:types-are-the-program] A bounded step is fully described by an integer
|
|
476
|
-
// domain (min < max) and a non-zero integer increment (`by`; negative for a
|
|
477
|
-
// down-step). The validator derives the range [min,max] (the wire gate); the
|
|
478
|
-
// renderer wraps current ± by inside it. These two cross-field invariants are the
|
|
479
|
-
// refinements `fields` cannot express — relating two fields, not one — carried as
|
|
480
|
-
// DATA whose messages interpolate the assembled value. Unlike a stepper widget's
|
|
481
|
-
// positive `step`, `by` may be negative (the down affordance), so the check is
|
|
482
|
-
// non-zero, not positive.
|
|
483
|
-
interface BoundedPayload {
|
|
484
|
-
min: number;
|
|
485
|
-
max: number;
|
|
486
|
-
by: number;
|
|
487
|
-
}
|
|
488
|
-
const minLessThanMax: Refinement<BoundedPayload> = {
|
|
489
|
-
ok: (v) => v.min < v.max,
|
|
490
|
-
issue: (v) => ({
|
|
491
|
-
field: "min",
|
|
492
|
-
message: `min (${v.min}) must be less than max (${v.max})`,
|
|
493
|
-
}),
|
|
494
|
-
};
|
|
495
|
-
const byNonZero: Refinement<BoundedPayload> = {
|
|
496
|
-
ok: (v) => v.by !== 0,
|
|
497
|
-
issue: () => ({
|
|
498
|
-
field: "by",
|
|
499
|
-
message: `by must be a non-zero integer (the per-click increment; negative steps down)`,
|
|
500
|
-
}),
|
|
501
|
-
};
|
|
502
|
-
|
|
503
|
-
// [LAW:types-are-the-program] A value-source arm is its payload field map plus
|
|
504
|
-
// its refinements; `detect` (the non-discriminator keys whose presence
|
|
505
|
-
// selects it), `allowed` (those keys plus the discriminator, the unknown-key
|
|
506
|
-
// allow-list), and `label` (the source name in the exactly-one message — the
|
|
507
|
-
// detect keys joined by "/") all DERIVE from the field map, so the field set
|
|
508
|
-
// is the single source for what the arm parses, permits, and is named by.
|
|
509
|
-
// Parameterized by `discriminator` ("set" | "persist") so `set` and `persist`
|
|
510
|
-
// share the identical to/from/min-max-by/cycle shapes without duplicating
|
|
511
|
-
// their field maps — only the discriminator's NAME differs in the emitted
|
|
512
|
-
// object shape and the reconstructed member.
|
|
513
|
-
interface ValueSourceArm {
|
|
514
|
-
readonly detect: readonly string[];
|
|
515
|
-
readonly allowed: readonly string[];
|
|
516
|
-
readonly label: string;
|
|
517
|
-
// [LAW:one-source-of-truth] The arm's emit facet: the closed object schema
|
|
518
|
-
// for this discriminator's value source — the discriminator key plus the
|
|
519
|
-
// source's own fields, derived from the SAME field map `fields` validates.
|
|
520
|
-
// Cross-field refinements (min<max, by≠0) are unexpressible in JSON Schema,
|
|
521
|
-
// so only the structural shape is emitted.
|
|
522
|
-
readonly json: JsonNode;
|
|
523
|
-
readonly parse: ArmParse<Partial<ActionDecl>>;
|
|
524
|
-
}
|
|
525
|
-
|
|
526
|
-
// [LAW:no-mode-explosion] `detectKeys` narrows WHICH of an arm's fields the
|
|
527
|
-
// present-count dispatch keys off, independent of `allowed`/`label` (still
|
|
528
|
-
// the full field set — what the arm PERMITS and is NAMED by never changes).
|
|
529
|
-
// Every arm before insertSegmentFrom had a field set disjoint from every
|
|
530
|
-
// other arm's, so `Object.keys(fieldMap)` was a safe default for both jobs
|
|
531
|
-
// at once. insertSegmentFrom breaks that: it shares `anchor`/`relation` with
|
|
532
|
-
// insertSegment (same POSITION shape, different segment-name SOURCE), so
|
|
533
|
-
// dispatching on the full set would make an ordinary `insertSegment` action
|
|
534
|
-
// spuriously match both arms via those shared keys. Pass the true
|
|
535
|
-
// discriminator (the field no sibling arm carries) here; omit it when the
|
|
536
|
-
// field set already is disjoint from every sibling, as it is everywhere else.
|
|
537
|
-
function valueSourceArm<P extends object>(
|
|
538
|
-
discriminator: Discriminator,
|
|
539
|
-
fieldMap: FieldSpecMap<P>,
|
|
540
|
-
checks: ReadonlyArray<Refinement<P>> = [],
|
|
541
|
-
detectKeys?: readonly string[],
|
|
542
|
-
): ValueSourceArm {
|
|
543
|
-
const fullKeys = Object.keys(fieldMap);
|
|
544
|
-
const detect = detectKeys ?? fullKeys;
|
|
545
|
-
const keys = DISCRIMINATOR_KEYS[discriminator].map(([k]) => k);
|
|
546
|
-
const inner: ArmParse<P> = (ctx, path, raw) =>
|
|
547
|
-
fields(ctx, fieldMap, path, raw);
|
|
548
|
-
const source = objectJson(fieldMap) as {
|
|
549
|
-
properties: Record<string, JsonNode>;
|
|
550
|
-
required?: readonly string[];
|
|
551
|
-
};
|
|
552
|
-
return {
|
|
553
|
-
detect,
|
|
554
|
-
allowed: [...keys, ...fullKeys],
|
|
555
|
-
label: fullKeys.join("/"),
|
|
556
|
-
json: {
|
|
557
|
-
type: "object",
|
|
558
|
-
properties: {
|
|
559
|
-
...Object.fromEntries(keys.map((k) => [k, { type: "string" }])),
|
|
560
|
-
...source.properties,
|
|
561
|
-
},
|
|
562
|
-
required: [...keys, ...(source.required ?? [])],
|
|
563
|
-
additionalProperties: false,
|
|
564
|
-
},
|
|
565
|
-
parse: (checks.length
|
|
566
|
-
? refine(inner, ...checks)
|
|
567
|
-
: inner) as unknown as ArmParse<Partial<ActionDecl>>,
|
|
568
|
-
};
|
|
569
|
-
}
|
|
570
|
-
|
|
571
|
-
// [LAW:dataflow-not-control-flow] The value-source arms in the order their
|
|
572
|
-
// labels appear in the exactly-one message. A `set` declares exactly one of
|
|
573
|
-
// these; the dispatcher counts presence over `detect` and reconstructs
|
|
574
|
-
// `{ set, ...payload }`.
|
|
575
|
-
const SET_ARMS: readonly ValueSourceArm[] = [
|
|
576
|
-
valueSourceArm("set", TO_FIELDS_SET),
|
|
577
|
-
valueSourceArm("set", FROM_FIELDS_SET),
|
|
578
|
-
valueSourceArm("set", BOUNDED_FIELDS, [minLessThanMax, byNonZero]),
|
|
579
|
-
valueSourceArm("set", INT_FIELDS),
|
|
580
|
-
valueSourceArm("set", CYCLE_FIELDS_SET),
|
|
581
|
-
];
|
|
582
|
-
|
|
583
|
-
// [LAW:one-type-per-behavior] `persist` mirrors `set` minus the `int` arm — a
|
|
584
|
-
// page cursor is a UI-only paging concept with no meaning as a persisted
|
|
585
|
-
// config default (see action.ts's ActionDecl comment). `removeSegment`/
|
|
586
|
-
// `insertSegment` are ADDITIONAL persist-only arms with no `set` counterpart.
|
|
587
|
-
const PERSIST_ARMS: readonly ValueSourceArm[] = [
|
|
588
|
-
valueSourceArm("persist", TO_FIELDS_PERSIST),
|
|
589
|
-
valueSourceArm("persist", FROM_FIELDS_PERSIST),
|
|
590
|
-
valueSourceArm("persist", BOUNDED_FIELDS, [minLessThanMax, byNonZero]),
|
|
591
|
-
valueSourceArm("persist", CYCLE_FIELDS_PERSIST),
|
|
592
|
-
valueSourceArm("persist", REMOVE_SEGMENT_FIELDS),
|
|
593
|
-
// [LAW:no-mode-explosion] Both insertSegment arms narrow detectKeys to
|
|
594
|
-
// their own discriminating field — see valueSourceArm's own comment. They
|
|
595
|
-
// share "anchor"/"relation" (same position shape, different segment-name
|
|
596
|
-
// source), so dispatching on the full field set would make EITHER arm
|
|
597
|
-
// spuriously match an action declaring the other.
|
|
598
|
-
valueSourceArm("persist", INSERT_SEGMENT_FIELDS, [], ["insertSegment"]),
|
|
599
|
-
valueSourceArm(
|
|
600
|
-
"persist",
|
|
601
|
-
INSERT_SEGMENT_FROM_FIELDS,
|
|
602
|
-
[],
|
|
603
|
-
["insertSegmentFrom"],
|
|
604
|
-
),
|
|
605
|
-
];
|
|
606
|
-
|
|
607
|
-
// [LAW:one-type-per-behavior] A dual declares any value source BOTH
|
|
608
|
-
// destinations share — `set` minus `int` (a page cursor has no durable
|
|
609
|
-
// meaning), which is also `persist` minus its structural-edit arms (those are
|
|
610
|
-
// persist-only by design, so they have no destination to choose between).
|
|
611
|
-
// The field maps are the SET ones with dual wording, so a dual's value obeys
|
|
612
|
-
// exactly the shape a `set` and a `persist` of that source each obey.
|
|
613
|
-
const DUAL_ARMS: readonly ValueSourceArm[] = [
|
|
614
|
-
valueSourceArm("dual", TO_FIELDS_DUAL),
|
|
615
|
-
valueSourceArm("dual", FROM_FIELDS_DUAL),
|
|
616
|
-
valueSourceArm("dual", BOUNDED_FIELDS, [minLessThanMax, byNonZero]),
|
|
617
|
-
valueSourceArm("dual", CYCLE_FIELDS_DUAL),
|
|
618
|
-
];
|
|
619
|
-
|
|
620
|
-
// [LAW:one-source-of-truth] The clause list, not the joined string, is the
|
|
621
|
-
// data that varies per discriminator — the "or" belongs on the LAST clause
|
|
622
|
-
// only, and which clause is last differs between `set` (ends at cycle) and
|
|
623
|
-
// `persist` (ends at insertSegment), so building a list and joining it is
|
|
624
|
-
// what keeps that placement correct without a second copy of the sentence.
|
|
625
|
-
function valueSourceClauses(discriminator: Discriminator): string[] {
|
|
626
|
-
const clauses = [
|
|
627
|
-
`"to" (a literal value)`,
|
|
628
|
-
`"from" (an option domain — a registered domain name like "themes"/"styles"/"looks", or an inline array of literal values)`,
|
|
629
|
-
`"min"/"max"/"by" (a bounded step)`,
|
|
630
|
-
];
|
|
631
|
-
if (discriminator === "set")
|
|
632
|
-
clauses.push(`"int" (an unbounded integer cursor)`);
|
|
633
|
-
clauses.push(`"cycle" (an enumerated domain stepped in order)`);
|
|
634
|
-
if (discriminator === "persist") {
|
|
635
|
-
clauses.push(
|
|
636
|
-
`"removeSegment" (remove a named segment from the layout)`,
|
|
637
|
-
`"insertSegment"/"anchor"/"relation" (insert a named segment before/after an existing one)`,
|
|
638
|
-
`"insertSegmentFrom"/"anchor"/"relation" (insert a segment PICKED from an option domain before/after an existing one)`,
|
|
639
|
-
);
|
|
640
|
-
}
|
|
641
|
-
return clauses;
|
|
642
|
-
}
|
|
643
|
-
|
|
644
|
-
function VALUE_SOURCE_MESSAGE(discriminator: Discriminator): string {
|
|
645
|
-
const clauses = valueSourceClauses(discriminator);
|
|
646
|
-
const last = clauses[clauses.length - 1]!;
|
|
647
|
-
const list =
|
|
648
|
-
clauses.length === 1
|
|
649
|
-
? last
|
|
650
|
-
: `${clauses.slice(0, -1).join(", ")}, or ${last}`;
|
|
651
|
-
return `a ${discriminator} action declares exactly one value source: ${list}`;
|
|
652
|
-
}
|
|
653
|
-
|
|
654
|
-
// [LAW:dataflow-not-control-flow] The set/persist sub-union eliminator:
|
|
655
|
-
// validate the shared discriminator key, count which value sources are
|
|
656
|
-
// present, require exactly one, reject keys outside that arm's allow-list,
|
|
657
|
-
// parse the payload, reconstruct the member. The variability (which arms,
|
|
658
|
-
// each arm's fields/refinements/allow-list) is the `arms` data; the only
|
|
659
|
-
// branches are the presence-count and the null-threading both the key and
|
|
660
|
-
// the payload share.
|
|
661
|
-
function valueSourceAction(
|
|
662
|
-
ctx: ValidateCtx,
|
|
663
|
-
path: string,
|
|
664
|
-
raw: Record<string, unknown>,
|
|
665
|
-
discriminator: Discriminator,
|
|
666
|
-
arms: readonly ValueSourceArm[],
|
|
667
|
-
): ActionDecl | null {
|
|
668
|
-
const keys = validateDiscriminatorKeys(ctx, path, raw, discriminator);
|
|
669
|
-
|
|
670
|
-
const present = arms.filter((arm) => arm.detect.some((k) => k in raw));
|
|
671
|
-
if (present.length !== 1) {
|
|
672
|
-
issue(
|
|
673
|
-
ctx,
|
|
674
|
-
path,
|
|
675
|
-
`${VALUE_SOURCE_MESSAGE(discriminator)}${
|
|
676
|
-
present.length > 1
|
|
677
|
-
? ` — found: ${present.map((a) => a.label).join(", ")}`
|
|
678
|
-
: ""
|
|
679
|
-
}`,
|
|
680
|
-
);
|
|
681
|
-
return null;
|
|
682
|
-
}
|
|
683
|
-
const arm = present[0]!;
|
|
684
|
-
|
|
685
|
-
for (const k of Object.keys(raw)) {
|
|
686
|
-
if (!arm.allowed.includes(k))
|
|
687
|
-
issue(
|
|
688
|
-
ctx,
|
|
689
|
-
`${path}.${k}`,
|
|
690
|
-
`Unknown key "${k}" on this ${discriminator} action. Expected one of: ${arm.allowed.join(", ")}`,
|
|
691
|
-
);
|
|
692
|
-
}
|
|
693
|
-
|
|
694
|
-
const payload = arm.parse(ctx, path, raw);
|
|
695
|
-
return keys === null || payload === null
|
|
696
|
-
? null
|
|
697
|
-
: ({ ...keys, ...payload } as unknown as ActionDecl);
|
|
698
|
-
}
|
|
699
|
-
|
|
700
|
-
// [LAW:no-silent-fallbacks] A literal `to` and the discriminator key share
|
|
701
|
-
// the non-empty/slash-free shape — the wire rejects empty values and splits
|
|
702
|
-
// on "/", so either is undeliverable. The empty/slash messages are this
|
|
703
|
-
// arm's, the shape is the shared enforcer's. Built once per discriminator
|
|
704
|
-
// (see TO_FIELDS_SET/TO_FIELDS_PERSIST) so a `persist` action's message names
|
|
705
|
-
// "persist value" and the set-config wire, never `set`'s wording.
|
|
706
|
-
function setLiteralSpec(discriminator: Discriminator): FieldSpec<string> {
|
|
707
|
-
const wire = wireName(discriminator);
|
|
708
|
-
return {
|
|
709
|
-
required: true,
|
|
710
|
-
json: { type: "string" },
|
|
711
|
-
parse: (ctx, path, field, raw) =>
|
|
712
|
-
slashFreeString(
|
|
713
|
-
ctx,
|
|
714
|
-
path,
|
|
715
|
-
field,
|
|
716
|
-
raw,
|
|
717
|
-
`${discriminator} value must be non-empty — an empty value cannot be delivered on the ${wire} wire`,
|
|
718
|
-
(v) =>
|
|
719
|
-
`${discriminator} value "${v}" contains "/" — ${discriminator} values must be slash-free`,
|
|
720
|
-
) ?? undefined,
|
|
721
|
-
};
|
|
722
|
-
}
|
|
723
|
-
|
|
724
|
-
// [LAW:types-are-the-program] `from` is either a NAME (a non-empty string,
|
|
725
|
-
// resolved against the option-domain registry) or an INLINE literal domain (a
|
|
726
|
-
// non-empty array of deliverable wire values — the same non-empty/
|
|
727
|
-
// slash-free wire shape `to` and `cycle` members enforce, plus the same
|
|
728
|
-
// uniqueness `cycleSpec` requires: a duplicate has no successor-ambiguity
|
|
729
|
-
// concern here, but it would render the same picker cell twice for no
|
|
730
|
-
// benefit). This arm proves only the SHAPE; whether a named domain actually
|
|
731
|
-
// resolves needs the merged config's per-config domains (e.g. "looks"), so
|
|
732
|
-
// that check is a cross-reference concern (validateCrossReferences) —
|
|
733
|
-
// symmetric to how a layout node's segment ref or a `{{ action }}` ref
|
|
734
|
-
// resolves post-merge. Built once per discriminator, same reason as
|
|
735
|
-
// setLiteralSpec.
|
|
736
|
-
function fromSpec(discriminator: Discriminator): FieldSpec<OptionDomain> {
|
|
737
|
-
const wire = wireName(discriminator);
|
|
738
|
-
return {
|
|
739
|
-
required: true,
|
|
740
|
-
json: {
|
|
741
|
-
anyOf: [
|
|
742
|
-
{ type: "string", minLength: 1 },
|
|
743
|
-
{
|
|
744
|
-
type: "array",
|
|
745
|
-
items: { type: "string", minLength: 1 },
|
|
746
|
-
minItems: 1,
|
|
747
|
-
uniqueItems: true,
|
|
748
|
-
},
|
|
749
|
-
],
|
|
750
|
-
},
|
|
751
|
-
parse: (ctx, path, field, raw) => {
|
|
752
|
-
const from = raw[field];
|
|
753
|
-
const at = `${path}.${field}`;
|
|
754
|
-
if (typeof from === "string") {
|
|
755
|
-
if (from === "") {
|
|
756
|
-
issue(ctx, at, `${field} must be a non-empty domain name`);
|
|
757
|
-
return undefined;
|
|
758
|
-
}
|
|
759
|
-
return from;
|
|
760
|
-
}
|
|
761
|
-
if (Array.isArray(from) && from.every((m) => typeof m === "string")) {
|
|
762
|
-
const members = from as string[];
|
|
763
|
-
if (members.length === 0) {
|
|
764
|
-
issue(
|
|
765
|
-
ctx,
|
|
766
|
-
at,
|
|
767
|
-
`${field} must name a domain (a non-empty string) or declare an inline domain (a non-empty array of values)`,
|
|
768
|
-
);
|
|
769
|
-
return undefined;
|
|
770
|
-
}
|
|
771
|
-
if (members.some((m) => m === "")) {
|
|
772
|
-
issue(
|
|
773
|
-
ctx,
|
|
774
|
-
at,
|
|
775
|
-
`${field} array members must be non-empty — an empty value cannot be delivered on the ${wire} wire`,
|
|
776
|
-
);
|
|
777
|
-
return undefined;
|
|
778
|
-
}
|
|
779
|
-
const slashed = members.filter((m) => m.includes("/"));
|
|
780
|
-
if (slashed.length > 0) {
|
|
781
|
-
issue(
|
|
782
|
-
ctx,
|
|
783
|
-
at,
|
|
784
|
-
`${field} array member(s) ${slashed.map((m) => `"${m}"`).join(", ")} contain "/" — ${discriminator} values must be slash-free`,
|
|
785
|
-
);
|
|
786
|
-
return undefined;
|
|
787
|
-
}
|
|
788
|
-
if (new Set(members).size !== members.length) {
|
|
789
|
-
issue(
|
|
790
|
-
ctx,
|
|
791
|
-
at,
|
|
792
|
-
`${field} array members must be unique — a duplicated value would render the same picker option twice`,
|
|
793
|
-
);
|
|
794
|
-
return undefined;
|
|
795
|
-
}
|
|
796
|
-
return members;
|
|
797
|
-
}
|
|
798
|
-
issue(
|
|
799
|
-
ctx,
|
|
800
|
-
at,
|
|
801
|
-
`${field} must be a domain name (a string) or an inline domain (an array of strings), got ${describeValue(from)}`,
|
|
802
|
-
);
|
|
803
|
-
return undefined;
|
|
804
|
-
},
|
|
805
|
-
};
|
|
806
|
-
}
|
|
807
|
-
|
|
808
|
-
// [LAW:types-are-the-program] `cycle` is the enumerated domain a click steps
|
|
809
|
-
// through: at least two members (one member has no successor to step to — that
|
|
810
|
-
// is a literal `to`), each a deliverable wire value (non-empty, slash-free
|
|
811
|
-
// — the same wire shape `to` enforces), no duplicates (the successor of a
|
|
812
|
-
// duplicated member is ambiguous). Members double as the derived allow-list
|
|
813
|
-
// gate, so a member this spec admits is a value the wire delivers, by
|
|
814
|
-
// construction. Built once per discriminator, same reason as setLiteralSpec.
|
|
815
|
-
function cycleSpec(discriminator: Discriminator): FieldSpec<readonly string[]> {
|
|
816
|
-
const wire = wireName(discriminator);
|
|
817
|
-
return {
|
|
818
|
-
required: true,
|
|
819
|
-
json: {
|
|
820
|
-
type: "array",
|
|
821
|
-
items: { type: "string", minLength: 1 },
|
|
822
|
-
minItems: 2,
|
|
823
|
-
uniqueItems: true,
|
|
824
|
-
},
|
|
825
|
-
parse: (ctx, path, field, raw) => {
|
|
826
|
-
const v = raw[field];
|
|
827
|
-
const at = `${path}.${field}`;
|
|
828
|
-
if (!Array.isArray(v) || v.some((m) => typeof m !== "string")) {
|
|
829
|
-
issue(
|
|
830
|
-
ctx,
|
|
831
|
-
at,
|
|
832
|
-
`cycle must be an array of strings (the enumerated values a click steps through), got ${describeType(v)}`,
|
|
833
|
-
);
|
|
834
|
-
return undefined;
|
|
835
|
-
}
|
|
836
|
-
const members = v as string[];
|
|
837
|
-
if (members.length < 2) {
|
|
838
|
-
issue(
|
|
839
|
-
ctx,
|
|
840
|
-
at,
|
|
841
|
-
`cycle needs at least two members (one member has no successor — use a literal "to")`,
|
|
842
|
-
);
|
|
843
|
-
return undefined;
|
|
844
|
-
}
|
|
845
|
-
const empty = members.some((m) => m === "");
|
|
846
|
-
const slashed = members.filter((m) => m.includes("/"));
|
|
847
|
-
if (empty) {
|
|
848
|
-
issue(
|
|
849
|
-
ctx,
|
|
850
|
-
at,
|
|
851
|
-
`cycle members must be non-empty — an empty value cannot be delivered on the ${wire} wire`,
|
|
852
|
-
);
|
|
853
|
-
return undefined;
|
|
854
|
-
}
|
|
855
|
-
if (slashed.length > 0) {
|
|
856
|
-
issue(
|
|
857
|
-
ctx,
|
|
858
|
-
at,
|
|
859
|
-
`cycle member(s) ${slashed.map((m) => `"${m}"`).join(", ")} contain "/" — ${discriminator} values must be slash-free`,
|
|
860
|
-
);
|
|
861
|
-
return undefined;
|
|
862
|
-
}
|
|
863
|
-
if (new Set(members).size !== members.length) {
|
|
864
|
-
issue(
|
|
865
|
-
ctx,
|
|
866
|
-
at,
|
|
867
|
-
`cycle members must be unique — the successor of a duplicated member is ambiguous`,
|
|
868
|
-
);
|
|
869
|
-
return undefined;
|
|
870
|
-
}
|
|
871
|
-
return members;
|
|
872
|
-
},
|
|
873
|
-
};
|
|
874
|
-
}
|
|
875
|
-
|
|
876
|
-
// [LAW:no-silent-fallbacks] `int` is a marker, not a value — it declares the key
|
|
877
|
-
// an unbounded-integer cursor. Only the literal `true` is meaningful; anything
|
|
878
|
-
// else is a typo to surface, not silently coerce.
|
|
879
|
-
function intMarkerSpec(): FieldSpec<true> {
|
|
880
|
-
return {
|
|
881
|
-
required: true,
|
|
882
|
-
json: { const: true },
|
|
883
|
-
parse: (ctx, path, field, raw) => {
|
|
884
|
-
if (raw[field] !== true) {
|
|
885
|
-
issue(
|
|
886
|
-
ctx,
|
|
887
|
-
`${path}.${field}`,
|
|
888
|
-
`int must be the literal true (declares the key an unbounded integer cursor — a paged picker's page key), got ${describeValue(raw[field])}`,
|
|
889
|
-
);
|
|
890
|
-
return undefined;
|
|
891
|
-
}
|
|
892
|
-
return true;
|
|
893
|
-
},
|
|
894
|
-
};
|
|
895
|
-
}
|
|
896
|
-
|
|
897
|
-
// [LAW:one-source-of-truth] A layout op's segment-name field (removeSegment /
|
|
898
|
-
// insertSegment / anchor) is non-empty and free of BOTH wire-structural
|
|
899
|
-
// characters: `/` (the click wire's own multi-arg segment delimiter, the
|
|
900
|
-
// same restriction slashFreeString already enforces for `to`/`cycle`) and
|
|
901
|
-
// `:` (layout-ops.ts's op-token delimiter — a name containing it would make
|
|
902
|
-
// encodeLayoutOp's output ambiguous to decode). One spec, three callsites,
|
|
903
|
-
// so the two-character restriction can't drift between them.
|
|
904
|
-
function layoutNameSpec(field: string): FieldSpec<string> {
|
|
905
|
-
return {
|
|
906
|
-
required: true,
|
|
907
|
-
json: { type: "string" },
|
|
908
|
-
parse: (ctx, path, f, raw) => {
|
|
909
|
-
const v = requireString(ctx, path, raw, f);
|
|
910
|
-
if (v === null) return undefined;
|
|
911
|
-
const at = `${path}.${f}`;
|
|
912
|
-
if (v === "") {
|
|
913
|
-
issue(ctx, at, `${field} must be non-empty (a segment name)`);
|
|
914
|
-
return undefined;
|
|
915
|
-
}
|
|
916
|
-
if (v.includes("/") || v.includes(":")) {
|
|
917
|
-
issue(
|
|
918
|
-
ctx,
|
|
919
|
-
at,
|
|
920
|
-
`${field} "${v}" contains "/" or ":" — segment names in a layout op must be free of both (the click wire's own delimiter and layout-ops.ts's op-token delimiter)`,
|
|
921
|
-
);
|
|
922
|
-
return undefined;
|
|
923
|
-
}
|
|
924
|
-
return v;
|
|
925
|
-
},
|
|
926
|
-
};
|
|
927
|
-
}
|
|
928
|
-
|
|
929
|
-
// [LAW:types-are-the-program] `relation` is a closed two-value enum, not a
|
|
930
|
-
// free string — a typo (`"befor"`) is a load error, never a click-time
|
|
931
|
-
// surprise. Mirrors intMarkerSpec's "one legal literal" shape, widened to
|
|
932
|
-
// two.
|
|
933
|
-
function relationSpec(): FieldSpec<"before" | "after"> {
|
|
934
|
-
return {
|
|
935
|
-
required: true,
|
|
936
|
-
json: { enum: ["before", "after"] },
|
|
937
|
-
parse: (ctx, path, field, raw) => {
|
|
938
|
-
const v = raw[field];
|
|
939
|
-
if (v !== "before" && v !== "after") {
|
|
940
|
-
issue(
|
|
941
|
-
ctx,
|
|
942
|
-
`${path}.${field}`,
|
|
943
|
-
`relation must be "before" or "after", got ${describeValue(v)}`,
|
|
944
|
-
);
|
|
945
|
-
return undefined;
|
|
946
|
-
}
|
|
947
|
-
return v;
|
|
948
|
-
},
|
|
949
|
-
};
|
|
950
|
-
}
|
|
951
|
-
|
|
952
|
-
// [LAW:types-are-the-program] A required integer field — the field key (min / max
|
|
953
|
-
// / by) comes from the map, the message names it. A non-integer or absent value
|
|
954
|
-
// reports and fails the arm.
|
|
955
|
-
function requireIntSpec(): FieldSpec<number> {
|
|
956
|
-
return {
|
|
957
|
-
required: true,
|
|
958
|
-
json: { type: "integer" },
|
|
959
|
-
parse: (ctx, path, field, raw) => {
|
|
960
|
-
const v = raw[field];
|
|
961
|
-
if (typeof v !== "number" || !Number.isInteger(v)) {
|
|
962
|
-
issue(
|
|
963
|
-
ctx,
|
|
964
|
-
`${path}.${field}`,
|
|
965
|
-
`${field} must be an integer, got ${describeValue(v)}`,
|
|
966
|
-
);
|
|
967
|
-
return undefined;
|
|
968
|
-
}
|
|
969
|
-
return v;
|
|
970
|
-
},
|
|
971
|
-
};
|
|
972
|
-
}
|