@promptctl/cc-candybar 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +145 -0
- package/bin/cc-candybar +6 -0
- package/dist/index.mjs +185 -0
- package/package.json +99 -0
- package/plugin/.claude-plugin/plugin.json +11 -0
- package/plugin/bin/preview.sh +305 -0
- package/plugin/commands/candybar.md +403 -0
- package/plugin/templates/config-essential.json +36 -0
- package/plugin/templates/config-full.json +55 -0
- package/plugin/templates/config-standard.json +39 -0
- package/plugin/templates/config-tui-compact.json +48 -0
- package/plugin/templates/config-tui-full.json +89 -0
- package/plugin/templates/config-tui-standard.json +56 -0
- package/plugin/templates/config-tui.json +18 -0
- package/plugin/templates/nerd-fonts-sample.txt +5 -0
- package/schema/cc-candybar.schema.json +1379 -0
- package/src/click/wire.ts +113 -0
- package/src/config/action.ts +91 -0
- package/src/config/cli.ts +170 -0
- package/src/config/default-dsl-config.ts +661 -0
- package/src/config/dsl-loader.ts +265 -0
- package/src/config/dsl-types.ts +425 -0
- package/src/config/loader/actions.ts +530 -0
- package/src/config/loader/cache.ts +206 -0
- package/src/config/loader/cross-ref.ts +326 -0
- package/src/config/loader/cycles.ts +148 -0
- package/src/config/loader/diagnostics.ts +99 -0
- package/src/config/loader/discovery.ts +182 -0
- package/src/config/loader/emit-schema.ts +63 -0
- package/src/config/loader/globals.ts +42 -0
- package/src/config/loader/helpers.ts +48 -0
- package/src/config/loader/layout.ts +688 -0
- package/src/config/loader/merge.ts +40 -0
- package/src/config/loader/refs.ts +96 -0
- package/src/config/loader/segments.ts +120 -0
- package/src/config/loader/validate-core.ts +674 -0
- package/src/config/loader/variables.ts +260 -0
- package/src/daemon/acquire.ts +411 -0
- package/src/daemon/cache/git.ts +553 -0
- package/src/daemon/cache/render.ts +449 -0
- package/src/daemon/cache/session-usage-store.ts +446 -0
- package/src/daemon/cache/watchers.ts +245 -0
- package/src/daemon/client-debug.ts +120 -0
- package/src/daemon/client-stats.ts +129 -0
- package/src/daemon/client-transport.ts +273 -0
- package/src/daemon/client.ts +75 -0
- package/src/daemon/debug-types.ts +91 -0
- package/src/daemon/debug.ts +264 -0
- package/src/daemon/limits.ts +154 -0
- package/src/daemon/log.ts +69 -0
- package/src/daemon/parent-watchdog.ts +80 -0
- package/src/daemon/paths.ts +127 -0
- package/src/daemon/protocol.ts +235 -0
- package/src/daemon/render-payload.ts +611 -0
- package/src/daemon/server.ts +1103 -0
- package/src/daemon/session-state-file.ts +108 -0
- package/src/daemon/session-state.ts +237 -0
- package/src/daemon/stats.ts +229 -0
- package/src/daemon/verbs/index.ts +458 -0
- package/src/daemon/verbs/state-validators.ts +708 -0
- package/src/demo/dsl.ts +117 -0
- package/src/demo/mock-data.ts +67 -0
- package/src/demo/statusline.json5 +92 -0
- package/src/dsl/node-registry.ts +281 -0
- package/src/dsl/render.ts +558 -0
- package/src/index.ts +206 -0
- package/src/install/index.ts +410 -0
- package/src/proc/launch.ts +451 -0
- package/src/proc/stats-handle.ts +13 -0
- package/src/render/action.ts +458 -0
- package/src/render/diagnostic-style.ts +23 -0
- package/src/render/diagnostic-text.ts +77 -0
- package/src/render/error-glyph.ts +53 -0
- package/src/render/outcome-plan.ts +45 -0
- package/src/render/picker.ts +231 -0
- package/src/render/split-lines.ts +51 -0
- package/src/render/strip.ts +103 -0
- package/src/segments/cache.ts +131 -0
- package/src/segments/context.ts +190 -0
- package/src/segments/git.ts +561 -0
- package/src/segments/metrics.ts +101 -0
- package/src/segments/pricing.ts +452 -0
- package/src/segments/session.ts +188 -0
- package/src/segments/tmux.ts +74 -0
- package/src/template-engine/cells.ts +90 -0
- package/src/template-engine/colors.ts +102 -0
- package/src/template-engine/engine.ts +108 -0
- package/src/template-engine/funcs.ts +216 -0
- package/src/template-engine/index.ts +11 -0
- package/src/template-engine/layout.ts +112 -0
- package/src/template-engine/scope.ts +62 -0
- package/src/themes/index.ts +19 -0
- package/src/themes/palette-resolvers.ts +86 -0
- package/src/themes/policy.ts +79 -0
- package/src/themes/session-random.ts +88 -0
- package/src/utils/cache.ts +206 -0
- package/src/utils/claude.ts +616 -0
- package/src/utils/color-support.ts +118 -0
- package/src/utils/formatters.ts +77 -0
- package/src/utils/logger.ts +5 -0
- package/src/utils/outcome.ts +33 -0
- package/src/utils/schema-validator.ts +126 -0
- package/src/utils/single-flight.ts +57 -0
- package/src/utils/terminal-width.ts +43 -0
- package/src/utils/terminal.ts +11 -0
- package/src/utils/transcript-fs.ts +162 -0
- package/src/var-system/index.ts +24 -0
- package/src/var-system/sources.ts +1038 -0
- package/src/var-system/store.ts +223 -0
- package/src/var-system/types.ts +57 -0
|
@@ -0,0 +1,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
|
+
}
|