@promptctl/cc-candybar 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +145 -0
  3. package/bin/cc-candybar +6 -0
  4. package/dist/index.mjs +185 -0
  5. package/package.json +99 -0
  6. package/plugin/.claude-plugin/plugin.json +11 -0
  7. package/plugin/bin/preview.sh +305 -0
  8. package/plugin/commands/candybar.md +403 -0
  9. package/plugin/templates/config-essential.json +36 -0
  10. package/plugin/templates/config-full.json +55 -0
  11. package/plugin/templates/config-standard.json +39 -0
  12. package/plugin/templates/config-tui-compact.json +48 -0
  13. package/plugin/templates/config-tui-full.json +89 -0
  14. package/plugin/templates/config-tui-standard.json +56 -0
  15. package/plugin/templates/config-tui.json +18 -0
  16. package/plugin/templates/nerd-fonts-sample.txt +5 -0
  17. package/schema/cc-candybar.schema.json +1379 -0
  18. package/src/click/wire.ts +113 -0
  19. package/src/config/action.ts +91 -0
  20. package/src/config/cli.ts +170 -0
  21. package/src/config/default-dsl-config.ts +661 -0
  22. package/src/config/dsl-loader.ts +265 -0
  23. package/src/config/dsl-types.ts +425 -0
  24. package/src/config/loader/actions.ts +530 -0
  25. package/src/config/loader/cache.ts +206 -0
  26. package/src/config/loader/cross-ref.ts +326 -0
  27. package/src/config/loader/cycles.ts +148 -0
  28. package/src/config/loader/diagnostics.ts +99 -0
  29. package/src/config/loader/discovery.ts +182 -0
  30. package/src/config/loader/emit-schema.ts +63 -0
  31. package/src/config/loader/globals.ts +42 -0
  32. package/src/config/loader/helpers.ts +48 -0
  33. package/src/config/loader/layout.ts +688 -0
  34. package/src/config/loader/merge.ts +40 -0
  35. package/src/config/loader/refs.ts +96 -0
  36. package/src/config/loader/segments.ts +120 -0
  37. package/src/config/loader/validate-core.ts +674 -0
  38. package/src/config/loader/variables.ts +260 -0
  39. package/src/daemon/acquire.ts +411 -0
  40. package/src/daemon/cache/git.ts +553 -0
  41. package/src/daemon/cache/render.ts +449 -0
  42. package/src/daemon/cache/session-usage-store.ts +446 -0
  43. package/src/daemon/cache/watchers.ts +245 -0
  44. package/src/daemon/client-debug.ts +120 -0
  45. package/src/daemon/client-stats.ts +129 -0
  46. package/src/daemon/client-transport.ts +273 -0
  47. package/src/daemon/client.ts +75 -0
  48. package/src/daemon/debug-types.ts +91 -0
  49. package/src/daemon/debug.ts +264 -0
  50. package/src/daemon/limits.ts +154 -0
  51. package/src/daemon/log.ts +69 -0
  52. package/src/daemon/parent-watchdog.ts +80 -0
  53. package/src/daemon/paths.ts +127 -0
  54. package/src/daemon/protocol.ts +235 -0
  55. package/src/daemon/render-payload.ts +611 -0
  56. package/src/daemon/server.ts +1103 -0
  57. package/src/daemon/session-state-file.ts +108 -0
  58. package/src/daemon/session-state.ts +237 -0
  59. package/src/daemon/stats.ts +229 -0
  60. package/src/daemon/verbs/index.ts +458 -0
  61. package/src/daemon/verbs/state-validators.ts +708 -0
  62. package/src/demo/dsl.ts +117 -0
  63. package/src/demo/mock-data.ts +67 -0
  64. package/src/demo/statusline.json5 +92 -0
  65. package/src/dsl/node-registry.ts +281 -0
  66. package/src/dsl/render.ts +558 -0
  67. package/src/index.ts +206 -0
  68. package/src/install/index.ts +410 -0
  69. package/src/proc/launch.ts +451 -0
  70. package/src/proc/stats-handle.ts +13 -0
  71. package/src/render/action.ts +458 -0
  72. package/src/render/diagnostic-style.ts +23 -0
  73. package/src/render/diagnostic-text.ts +77 -0
  74. package/src/render/error-glyph.ts +53 -0
  75. package/src/render/outcome-plan.ts +45 -0
  76. package/src/render/picker.ts +231 -0
  77. package/src/render/split-lines.ts +51 -0
  78. package/src/render/strip.ts +103 -0
  79. package/src/segments/cache.ts +131 -0
  80. package/src/segments/context.ts +190 -0
  81. package/src/segments/git.ts +561 -0
  82. package/src/segments/metrics.ts +101 -0
  83. package/src/segments/pricing.ts +452 -0
  84. package/src/segments/session.ts +188 -0
  85. package/src/segments/tmux.ts +74 -0
  86. package/src/template-engine/cells.ts +90 -0
  87. package/src/template-engine/colors.ts +102 -0
  88. package/src/template-engine/engine.ts +108 -0
  89. package/src/template-engine/funcs.ts +216 -0
  90. package/src/template-engine/index.ts +11 -0
  91. package/src/template-engine/layout.ts +112 -0
  92. package/src/template-engine/scope.ts +62 -0
  93. package/src/themes/index.ts +19 -0
  94. package/src/themes/palette-resolvers.ts +86 -0
  95. package/src/themes/policy.ts +79 -0
  96. package/src/themes/session-random.ts +88 -0
  97. package/src/utils/cache.ts +206 -0
  98. package/src/utils/claude.ts +616 -0
  99. package/src/utils/color-support.ts +118 -0
  100. package/src/utils/formatters.ts +77 -0
  101. package/src/utils/logger.ts +5 -0
  102. package/src/utils/outcome.ts +33 -0
  103. package/src/utils/schema-validator.ts +126 -0
  104. package/src/utils/single-flight.ts +57 -0
  105. package/src/utils/terminal-width.ts +43 -0
  106. package/src/utils/terminal.ts +11 -0
  107. package/src/utils/transcript-fs.ts +162 -0
  108. package/src/var-system/index.ts +24 -0
  109. package/src/var-system/sources.ts +1038 -0
  110. package/src/var-system/store.ts +223 -0
  111. package/src/var-system/types.ts +57 -0
@@ -0,0 +1,674 @@
1
+ // [LAW:single-enforcer] The validation engine's primitives: the shared
2
+ // ValidateCtx every per-type validator threads, the field-combinators
3
+ // (requireString / optionalEnum / …) they compose from, and the type-describe
4
+ // helpers used in messages. Each per-type schema module (variables, segments,
5
+ // …) is a DECLARATION built from these; changing a primitive changes every
6
+ // validator uniformly. This file changes when the combinator vocabulary changes.
7
+
8
+ import {
9
+ SOURCE_KINDS,
10
+ type GroupSugarDecl,
11
+ type SourceKind,
12
+ } from "../dsl-types.js";
13
+ import { findKeyLine, type ConfigIssue } from "./diagnostics.js";
14
+
15
+ export interface ValidateCtx {
16
+ readonly source: string;
17
+ readonly issues: ConfigIssue[];
18
+ readonly allowedPalettes: ReadonlySet<string>;
19
+ // [LAW:one-source-of-truth] The `group` sugar nodes collected during the root
20
+ // walk — the single record the loader's synthesis pass (group state var +
21
+ // cycle action + toggle segment) derives from. Parse-time collection, post-
22
+ // walk synthesis: the walk owns positions, the synthesis owns cross-section
23
+ // emission.
24
+ readonly groups: GroupSugarDecl[];
25
+ }
26
+
27
+ export type Mutable<T> = { -readonly [K in keyof T]: T[K] };
28
+
29
+ export function requireString(
30
+ ctx: ValidateCtx,
31
+ path: string,
32
+ raw: Record<string, unknown>,
33
+ field: string,
34
+ ): string | null {
35
+ const v = raw[field];
36
+ if (typeof v !== "string") {
37
+ ctx.issues.push({
38
+ path: `${path}.${field}`,
39
+ message: `${path}.${field} must be a string, got ${describeType(v)}`,
40
+ line: findKeyLine(ctx.source, [...path.split("."), field]),
41
+ });
42
+ return null;
43
+ }
44
+ return v;
45
+ }
46
+
47
+ export function optionalString(
48
+ ctx: ValidateCtx,
49
+ path: string,
50
+ raw: Record<string, unknown>,
51
+ field: string,
52
+ ): { default?: string } {
53
+ const v = optionalStringField(ctx, path, raw, field);
54
+ return v === undefined ? {} : { [field]: v };
55
+ }
56
+
57
+ // [LAW:types-are-the-program] Input-var defaults must match the declared
58
+ // `type` exactly — a string default on a number-typed input would silently
59
+ // coerce or throw on first render. Reject the mismatch at load time so the
60
+ // renderer can read `.default` as the declared type without re-checking.
61
+ export function optionalTypedDefault(
62
+ ctx: ValidateCtx,
63
+ path: string,
64
+ raw: Record<string, unknown>,
65
+ type: "string" | "number" | "boolean",
66
+ ): string | number | boolean | undefined {
67
+ const v = raw.default;
68
+ if (v === undefined) return undefined;
69
+ const ok =
70
+ (type === "string" && typeof v === "string") ||
71
+ (type === "number" && typeof v === "number") ||
72
+ (type === "boolean" && typeof v === "boolean");
73
+ if (!ok) {
74
+ ctx.issues.push({
75
+ path: `${path}.default`,
76
+ message: `default must be a ${type}, got ${describeType(v)}`,
77
+ line: findKeyLine(ctx.source, [...path.split("."), "default"]),
78
+ });
79
+ return undefined;
80
+ }
81
+ return v as string | number | boolean;
82
+ }
83
+
84
+ export function optionalStringField(
85
+ ctx: ValidateCtx,
86
+ path: string,
87
+ raw: Record<string, unknown>,
88
+ field: string,
89
+ ): string | undefined {
90
+ const v = raw[field];
91
+ if (v === undefined) return undefined;
92
+ if (typeof v !== "string") {
93
+ ctx.issues.push({
94
+ path: `${path}.${field}`,
95
+ message: `${path}.${field} must be a string, got ${describeType(v)}`,
96
+ line: findKeyLine(ctx.source, [...path.split("."), field]),
97
+ });
98
+ return undefined;
99
+ }
100
+ return v;
101
+ }
102
+
103
+ // [LAW:single-enforcer] One place validates a palette NAME, shared by globals
104
+ // and per-segment. An unknown name is a hard error, never a silent fallback —
105
+ // the renderer must never receive a name that won't resolve to a Palette.
106
+ export function validatePaletteName(
107
+ ctx: ValidateCtx,
108
+ path: string,
109
+ raw: Record<string, unknown>,
110
+ ): string | undefined {
111
+ const v = optionalStringField(ctx, path, raw, "palette");
112
+ if (v === undefined) return undefined;
113
+ if (!ctx.allowedPalettes.has(v)) {
114
+ ctx.issues.push({
115
+ path: `${path}.palette`,
116
+ message: `Unknown palette "${v}". Expected one of: ${[...ctx.allowedPalettes].sort().join(", ")}`,
117
+ line: findKeyLine(ctx.source, [...path.split("."), "palette"]),
118
+ });
119
+ return undefined;
120
+ }
121
+ return v;
122
+ }
123
+
124
+ export function optionalEnum<T extends string>(
125
+ ctx: ValidateCtx,
126
+ path: string,
127
+ raw: Record<string, unknown>,
128
+ field: string,
129
+ allowed: readonly T[],
130
+ ): T | undefined {
131
+ const v = raw[field];
132
+ if (v === undefined) return undefined;
133
+ if (typeof v !== "string" || !(allowed as readonly string[]).includes(v)) {
134
+ ctx.issues.push({
135
+ path: `${path}.${field}`,
136
+ message: `${path}.${field} must be one of: ${allowed.join(", ")}; got ${describeValue(v)}`,
137
+ line: findKeyLine(ctx.source, [...path.split("."), field]),
138
+ });
139
+ return undefined;
140
+ }
141
+ return v as T;
142
+ }
143
+
144
+ export function isSourceKind(s: string): s is SourceKind {
145
+ return (SOURCE_KINDS as readonly string[]).includes(s);
146
+ }
147
+
148
+ export function isPlainObject(v: unknown): v is Record<string, unknown> {
149
+ return typeof v === "object" && v !== null && !Array.isArray(v);
150
+ }
151
+
152
+ export function describeType(v: unknown): string {
153
+ if (v === null) return "null";
154
+ if (Array.isArray(v)) return "array";
155
+ return typeof v;
156
+ }
157
+
158
+ export function describeValue(v: unknown): string {
159
+ if (typeof v === "string") return JSON.stringify(v);
160
+ if (v === undefined) return "undefined";
161
+ return String(v);
162
+ }
163
+
164
+ // ─── Schema engine kernel ────────────────────────────────────────────────────
165
+
166
+ // [LAW:types-are-the-program] A FieldSpec is the parser for ONE field of a record:
167
+ // it reads raw[field] at `${path}.${field}`, reports any issue into ctx, and
168
+ // yields the parsed value or undefined (absent or invalid → omitted from output).
169
+ // `required` lets `record` fail the whole record when a load-bearing field is
170
+ // absent or invalid, so a per-type schema declares its shape as DATA instead of
171
+ // hand-threading combinator results through `if (x === null) return null`.
172
+ export interface FieldSpec<T> {
173
+ readonly required: boolean;
174
+ // [LAW:one-source-of-truth] The emit facet, authored beside `parse`: the
175
+ // JSON-Schema fragment for THIS field's value. `parse` is the validate
176
+ // interpreter, `json` the schema interpreter — two projections of one
177
+ // declaration, so the editor-facing schema can never describe a different
178
+ // grammar than the runtime validator. Both read the same source constants
179
+ // (e.g. an enum spec's `allowed` feeds both `parse`'s membership check and
180
+ // `json`'s `enum`), so they cannot drift. A JSON Schema fragment IS data
181
+ // (JSON Schema is its own serialization), so the facet is a plain object.
182
+ readonly json: JsonNode;
183
+ parse(
184
+ ctx: ValidateCtx,
185
+ path: string,
186
+ field: string,
187
+ raw: Record<string, unknown>,
188
+ ): T | undefined;
189
+ }
190
+
191
+ // [LAW:types-are-the-program] A JSON-Schema fragment: the schema-shape facet of
192
+ // a declaration. JSON Schema is itself JSON, so the emit AST is just the target
193
+ // format — no parallel descriptor type to keep in sync with the serializer.
194
+ export type JsonNode = Readonly<Record<string, unknown>>;
195
+
196
+ // [LAW:types-are-the-program] The field map must cover EXACTLY the keys of the
197
+ // target type — `-?` forces a spec for every field (forgetting one is a compile
198
+ // error) and NonNullable lets an optional field declare a spec for its present
199
+ // value type. The schema is checked against T, so the record body needs no cast
200
+ // beyond the final dynamic assembly.
201
+ export type FieldSpecMap<T> = {
202
+ [K in keyof T]-?: FieldSpec<NonNullable<T[K]>>;
203
+ };
204
+
205
+ export interface RecordSchema<T> {
206
+ // The noun in this record's unknown-key message ("globals key", "layout-node
207
+ // key", …) — the one phrasing that varies per record; everything else is shared.
208
+ readonly noun: string;
209
+ readonly fields: FieldSpecMap<T>;
210
+ }
211
+
212
+ // [LAW:dataflow-not-control-flow] The record interpreter: the same unconditional
213
+ // sequence for every record — guard object, reject unknown keys, run each field
214
+ // spec, collect the present values. The variability (which fields, required-ness,
215
+ // each field's message) lives in the schema DATA, never in branches here. Returns
216
+ // the assembled record, or null when raw is not an object or a required field
217
+ // failed — the two recovery shapes a caller wraps (`?? {}` for an optional block,
218
+ // a drop for a union arm). This absorbs the per-type isPlainObject guard, the
219
+ // reject-unknown-key loop, the result-threading, and the optional-omission spreads.
220
+ export function record<T>(
221
+ ctx: ValidateCtx,
222
+ schema: RecordSchema<T>,
223
+ path: string,
224
+ raw: unknown,
225
+ ): T | null {
226
+ if (!isPlainObject(raw)) {
227
+ ctx.issues.push({
228
+ path,
229
+ message: `${path} must be an object, got ${describeType(raw)}`,
230
+ line: findKeyLine(ctx.source, path.split(".")),
231
+ });
232
+ return null;
233
+ }
234
+
235
+ rejectUnknownKeys(
236
+ ctx,
237
+ path,
238
+ raw,
239
+ schema.noun,
240
+ new Set(Object.keys(schema.fields)),
241
+ );
242
+ return fields(ctx, schema.fields, path, raw);
243
+ }
244
+
245
+ // [LAW:decomposition] The field-assembly core: run each field spec against an
246
+ // already-guarded object, collect the present values, fail the whole when a
247
+ // required field is absent or invalid. `record` adds the object guard and
248
+ // unknown-key rejection on top; a tagged-union arm reuses THIS directly, because
249
+ // an arm must NOT reject unknown keys — the discriminator (`kind`) is a sibling
250
+ // key the arm doesn't list. Returns the assembled record, or null when a required
251
+ // field failed. This is the join `record` and `taggedUnion`'s arms share.
252
+ export function fields<T>(
253
+ ctx: ValidateCtx,
254
+ fieldMap: FieldSpecMap<T>,
255
+ path: string,
256
+ raw: Record<string, unknown>,
257
+ ): T | null {
258
+ const specs = fieldMap as Readonly<Record<string, FieldSpec<unknown>>>;
259
+ const out: Record<string, unknown> = {};
260
+ let ok = true;
261
+ for (const [field, spec] of Object.entries(specs)) {
262
+ const value = spec.parse(ctx, path, field, raw);
263
+ if (value !== undefined) out[field] = value;
264
+ else if (spec.required) ok = false;
265
+ }
266
+ return ok ? (out as T) : null;
267
+ }
268
+
269
+ // [LAW:single-enforcer] One reject-unknown-key loop for every record, replacing
270
+ // the per-module hand-rolled copies. The `noun` carries the only per-record
271
+ // variation in the message; the allowed set is the schema's declared field names.
272
+ function rejectUnknownKeys(
273
+ ctx: ValidateCtx,
274
+ path: string,
275
+ raw: Record<string, unknown>,
276
+ noun: string,
277
+ allowed: ReadonlySet<string>,
278
+ ): void {
279
+ for (const key of Object.keys(raw)) {
280
+ if (!allowed.has(key)) {
281
+ ctx.issues.push({
282
+ path: `${path}.${key}`,
283
+ message: `Unknown ${noun} "${key}". Expected one of: ${[...allowed].join(", ")}`,
284
+ line: findKeyLine(ctx.source, [...path.split("."), key]),
285
+ });
286
+ }
287
+ }
288
+ }
289
+
290
+ // [LAW:types-are-the-program] A tag-by-which-key-present union: every member
291
+ // carries exactly one own key (CacheDecl's ttl/watch_file/…, an action's
292
+ // set/copy/open). PresentArm parses the VALUE held at that key into its member
293
+ // shape; the arm map must cover every present-key (the `-?` + Extract force an
294
+ // arm per member, typed to return exactly that member — forgetting one is a
295
+ // compile error). The bespoke per-arm message lives in its parse closure as DATA.
296
+ export interface PresentArm<M> {
297
+ // [LAW:one-source-of-truth] The emit facet: the JSON-Schema for the VALUE held
298
+ // at this arm's present key (oneOfPresentJson wraps it in the single-required
299
+ // object the present-key contract describes). Authored beside `parse`.
300
+ readonly json: JsonNode;
301
+ parse(ctx: ValidateCtx, path: string, value: unknown): M | null;
302
+ }
303
+
304
+ type PresentKeyOf<T> = T extends infer M ? keyof M : never;
305
+
306
+ export type PresentArmMap<T> = {
307
+ [K in PresentKeyOf<T> & string]-?: PresentArm<
308
+ Extract<T, { readonly [P in K]: unknown }>
309
+ >;
310
+ };
311
+
312
+ export interface OneOfPresentSchema<T> {
313
+ // The noun in this union's structural messages ("cache must be an object",
314
+ // "Unknown cache key", "cache must declare exactly one of") — the one phrasing
315
+ // that varies per union; the candidate key list is the arm-map's key order.
316
+ readonly noun: string;
317
+ readonly arms: PresentArmMap<T>;
318
+ }
319
+
320
+ // [LAW:dataflow-not-control-flow] The tag-by-present-key interpreter: the same
321
+ // unconditional sequence for every such union — guard object, reject unknown
322
+ // keys, enforce exactly-one present, dispatch to that arm. Distinct from `record`
323
+ // because the contract differs ("Expected exactly one of", zero/multiple-present
324
+ // counting); the variability (noun, arms, each arm's message) is DATA. Returns
325
+ // the parsed member, or null when raw is not an object, no/multiple keys are
326
+ // present, or the single arm fails — the drop shape a union caller recovers.
327
+ export function oneOfPresent<T>(
328
+ ctx: ValidateCtx,
329
+ schema: OneOfPresentSchema<T>,
330
+ path: string,
331
+ raw: unknown,
332
+ ): T | null {
333
+ if (!isPlainObject(raw)) {
334
+ ctx.issues.push({
335
+ path,
336
+ message: `${schema.noun} must be an object, got ${describeType(raw)}`,
337
+ line: findKeyLine(ctx.source, path.split(".")),
338
+ });
339
+ return null;
340
+ }
341
+
342
+ const arms = schema.arms as Readonly<Record<string, PresentArm<T>>>;
343
+ const keys = Object.keys(arms);
344
+ const present = Object.keys(raw).filter((k) => k in arms);
345
+ const unknown = Object.keys(raw).filter((k) => !(k in arms));
346
+ for (const k of unknown) {
347
+ ctx.issues.push({
348
+ path: `${path}.${k}`,
349
+ message: `Unknown ${schema.noun} key "${k}". Expected exactly one of: ${keys.join(", ")}`,
350
+ line: findKeyLine(ctx.source, [...path.split("."), k]),
351
+ });
352
+ }
353
+
354
+ if (present.length === 0) {
355
+ ctx.issues.push({
356
+ path,
357
+ message: `${schema.noun} must declare exactly one of: ${keys.join(", ")}`,
358
+ line: findKeyLine(ctx.source, path.split(".")),
359
+ });
360
+ return null;
361
+ }
362
+ if (present.length > 1) {
363
+ ctx.issues.push({
364
+ path,
365
+ message: `${schema.noun} must declare exactly one of: ${keys.join(", ")} (found: ${present.join(", ")})`,
366
+ line: findKeyLine(ctx.source, path.split(".")),
367
+ });
368
+ return null;
369
+ }
370
+
371
+ const key = present[0]!;
372
+ return arms[key]!.parse(ctx, `${path}.${key}`, raw[key]);
373
+ }
374
+
375
+ // [LAW:types-are-the-program] A tag-by-field-value union: every member carries a
376
+ // shared discriminator field (VariableDecl's `kind`) whose value selects the arm.
377
+ // TaggedArm parses the WHOLE raw object into its member shape (an arm reads many
378
+ // sibling fields, so it receives `raw`, not one extracted value), at the union's
379
+ // own path (the discriminator is a sibling, so the path doesn't descend). The arm
380
+ // map must cover every tag value (`-?` + Extract force an arm per member, typed
381
+ // to return exactly that member). The bespoke per-arm field schema lives in its
382
+ // parse closure as DATA.
383
+ export interface TaggedArm<M> {
384
+ // [LAW:one-source-of-truth] The emit facet: the FULL object schema for this
385
+ // member, discriminator included (the arm knows its own tag value, so it bakes
386
+ // `{ [tag]: { const } }` into `json`). taggedUnionJson simply collects each
387
+ // arm's `json` into the `anyOf`. Authored beside `parse`.
388
+ readonly json: JsonNode;
389
+ parse(ctx: ValidateCtx, path: string, raw: Record<string, unknown>): M | null;
390
+ }
391
+
392
+ type TagValueOf<T, K extends string> =
393
+ T extends Record<K, infer V> ? V & string : never;
394
+
395
+ export type TaggedArmMap<T, K extends string> = {
396
+ [V in TagValueOf<T, K>]-?: TaggedArm<Extract<T, Record<K, V>>>;
397
+ };
398
+
399
+ export interface TaggedUnionSchema<T, K extends string> {
400
+ // The discriminator field name ("kind") and the noun in its unknown-value
401
+ // message ("source kind") — the two phrasings that vary per union; the valid
402
+ // tag-value list is the arm-map's key order.
403
+ readonly tag: K;
404
+ readonly noun: string;
405
+ readonly arms: TaggedArmMap<T, K>;
406
+ }
407
+
408
+ // [LAW:dataflow-not-control-flow] The tag-by-field-value interpreter: the same
409
+ // unconditional sequence for every such union — guard object, read the
410
+ // discriminator, reject a non-string or unknown tag, dispatch to that arm.
411
+ // Distinct from `oneOfPresent` because the tag is a named field's VALUE, not
412
+ // which key is present; the variability (tag name, noun, arms) is DATA. Returns
413
+ // the parsed member, or null when raw is not an object, the tag is missing/
414
+ // non-string/unknown, or the arm fails — the drop shape the per-name caller
415
+ // recovers. A non-string tag points at the variable (the key may be absent); an
416
+ // unknown tag value points at the discriminator key itself.
417
+ export function taggedUnion<T, K extends string>(
418
+ ctx: ValidateCtx,
419
+ schema: TaggedUnionSchema<T, K>,
420
+ path: string,
421
+ raw: unknown,
422
+ ): T | null {
423
+ if (!isPlainObject(raw)) {
424
+ ctx.issues.push({
425
+ path,
426
+ message: `${path} must be an object, got ${describeType(raw)}`,
427
+ line: findKeyLine(ctx.source, path.split(".")),
428
+ });
429
+ return null;
430
+ }
431
+
432
+ const tagValue = raw[schema.tag];
433
+ if (typeof tagValue !== "string") {
434
+ ctx.issues.push({
435
+ path: `${path}.${schema.tag}`,
436
+ message: `${path}.${schema.tag} must be a string, got ${describeType(tagValue)}`,
437
+ line: findKeyLine(ctx.source, path.split(".")),
438
+ });
439
+ return null;
440
+ }
441
+
442
+ const arms = schema.arms as Readonly<Record<string, TaggedArm<T>>>;
443
+ if (!(tagValue in arms)) {
444
+ ctx.issues.push({
445
+ path: `${path}.${schema.tag}`,
446
+ message: `Unknown ${schema.noun} "${tagValue}". Expected one of: ${Object.keys(arms).join(", ")}`,
447
+ line: findKeyLine(ctx.source, [...path.split("."), schema.tag]),
448
+ });
449
+ return null;
450
+ }
451
+
452
+ return arms[tagValue]!.parse(ctx, path, raw);
453
+ }
454
+
455
+ // [LAW:types-are-the-program] An arm parser narrows an already-guarded record to
456
+ // a member shape or null — the signature `fields`, `refine`, and a union arm all
457
+ // speak. Exposing it as a name lets a per-type schema compose arms (refine a
458
+ // fields-record, hand it to a present-key dispatch) without restating the shape.
459
+ export type ArmParse<T> = (
460
+ ctx: ValidateCtx,
461
+ path: string,
462
+ raw: Record<string, unknown>,
463
+ ) => T | null;
464
+
465
+ // [LAW:types-are-the-program] The recursion seam: a parser referenced before it
466
+ // exists. A recursive config shape (the layout node tree — a container's children
467
+ // are themselves nodes) declares its child-list field as DATA that points back at
468
+ // the very parser it is part of; reading that parser at schema-construction time
469
+ // is a temporal-dead-zone crash, so `lazy` defers the read to call time. Generic
470
+ // over any parser signature (a node parser takes raw:unknown and recovers, an arm
471
+ // takes a guarded record and may drop) — it owns no validation, only the deferral,
472
+ // so the same primitive serves every self-referential schema [LAW:decomposition].
473
+ export function lazy<A extends readonly unknown[], R>(
474
+ thunk: () => (...args: A) => R,
475
+ ): (...args: A) => R {
476
+ return (...args) => thunk()(...args);
477
+ }
478
+
479
+ // [LAW:types-are-the-program] A cross-field refinement: a predicate over the
480
+ // ASSEMBLED member that the field specs cannot express alone (min < max, by != 0
481
+ // — invariants relating two fields), paired with the bespoke issue it yields when
482
+ // violated. `ok` and `issue` both read the value, so an interpolated message
483
+ // ("min (0) must be less than max (-1)") is DATA derived from the value, not a
484
+ // branch. `issue.field` names the sub-path the message points at ("" = the record
485
+ // itself); the engine prepends the path and resolves the source line.
486
+ export interface Refinement<T> {
487
+ ok(value: T): boolean;
488
+ issue(value: T): { readonly field: string; readonly message: string };
489
+ }
490
+
491
+ // [LAW:dataflow-not-control-flow] The refinement interpreter: run the inner arm,
492
+ // then fold the assembled value through each refinement in order, surfacing the
493
+ // first violated invariant and dropping the member. The lawful generalization of
494
+ // the hand-rolled `if (min >= max) { push; return null }` tail every record grew
495
+ // for its cross-field checks — the invariant is a DECLARATION, the report is
496
+ // mechanical. Null threads through untouched (a failed inner parse never reaches
497
+ // a refinement), and the order of `checks` is the order of reporting — the same
498
+ // short-circuit the inline tail expressed with sequential `if`s.
499
+ export function refine<T>(
500
+ inner: ArmParse<T>,
501
+ ...checks: ReadonlyArray<Refinement<T>>
502
+ ): ArmParse<T> {
503
+ return (ctx, path, raw) => {
504
+ const value = inner(ctx, path, raw);
505
+ if (value === null) return null;
506
+ for (const check of checks) {
507
+ if (check.ok(value)) continue;
508
+ const { field, message } = check.issue(value);
509
+ const at = field === "" ? path : `${path}.${field}`;
510
+ ctx.issues.push({
511
+ path: at,
512
+ message,
513
+ line: findKeyLine(ctx.source, at.split(".")),
514
+ });
515
+ return null;
516
+ }
517
+ return value;
518
+ };
519
+ }
520
+
521
+ // [LAW:dataflow-not-control-flow] Field specs lift the existing field combinators
522
+ // into the record vocabulary. An optional string is included when present-and-
523
+ // valid, omitted (with an issue) when present-and-wrong, omitted silently when
524
+ // absent — the same three-way the hand-rolled loops expressed as `continue`.
525
+ export function optionalStringSpec(): FieldSpec<string> {
526
+ return {
527
+ required: false,
528
+ json: { type: "string" },
529
+ parse: (ctx, path, field, raw) =>
530
+ optionalStringField(ctx, path, raw, field),
531
+ };
532
+ }
533
+
534
+ // [LAW:dataflow-not-control-flow] An optional boolean field: present-and-valid
535
+ // is included, present-and-wrong reports an issue and omits, absent omits
536
+ // silently — the boolean twin of `optionalStringSpec`.
537
+ export function optionalBooleanSpec(): FieldSpec<boolean> {
538
+ return {
539
+ required: false,
540
+ json: { type: "boolean" },
541
+ parse: (ctx, path, field, raw) => {
542
+ const v = raw[field];
543
+ if (v === undefined) return undefined;
544
+ if (typeof v !== "boolean") {
545
+ ctx.issues.push({
546
+ path: `${path}.${field}`,
547
+ message: `${field} must be a boolean, got ${describeType(v)}`,
548
+ line: findKeyLine(ctx.source, [field]),
549
+ });
550
+ return undefined;
551
+ }
552
+ return v;
553
+ },
554
+ };
555
+ }
556
+
557
+ // [LAW:single-enforcer] The palette field defers to the one palette-name
558
+ // authority; the field key is conventionally "palette", which validatePaletteName
559
+ // reads directly.
560
+ export function paletteSpec(): FieldSpec<string> {
561
+ return {
562
+ required: false,
563
+ // Palette NAME membership is semantic (the allowed set is resolved at load
564
+ // from installed palettes, not a closed compile-time enum), so the schema
565
+ // checks only `type: string` — the same shape/meaning split the loader keeps.
566
+ json: { type: "string" },
567
+ parse: (ctx, path, _field, raw) => validatePaletteName(ctx, path, raw),
568
+ };
569
+ }
570
+
571
+ // [LAW:dataflow-not-control-flow] A required string field: present-and-valid is
572
+ // included, present-and-wrong reports an issue and fails the record, absent fails
573
+ // the record — the map key names the field, so one spec serves path/command/
574
+ // layout/name/key. `requireString` returns null on failure; the record engine
575
+ // reads undefined as "absent or invalid", so null collapses to undefined.
576
+ export function requireStringSpec(): FieldSpec<string> {
577
+ return {
578
+ required: true,
579
+ json: { type: "string" },
580
+ parse: (ctx, path, field, raw) =>
581
+ requireString(ctx, path, raw, field) ?? undefined,
582
+ };
583
+ }
584
+
585
+ // [LAW:dataflow-not-control-flow] An optional enum field over a closed set; the
586
+ // allowed values are DATA, the field key comes from the map. Present-and-invalid
587
+ // reports the one-of message and omits; absent omits silently.
588
+ export function optionalEnumSpec<T extends string>(
589
+ allowed: readonly T[],
590
+ ): FieldSpec<T> {
591
+ return {
592
+ required: false,
593
+ // [LAW:one-source-of-truth] `allowed` is the single source: `parse` checks
594
+ // membership against it, `json` lists it as the schema `enum`.
595
+ json: { enum: [...allowed] },
596
+ parse: (ctx, path, field, raw) =>
597
+ optionalEnum(ctx, path, raw, field, allowed),
598
+ };
599
+ }
600
+
601
+ // ─── Schema emit: the second interpreter over the same declarations ──────────
602
+
603
+ // [LAW:dataflow-not-control-flow] The record/field emit-twin of `fields`: walk
604
+ // the SAME field-map the validator walks, projecting each spec's `json` into a
605
+ // JSON-Schema `properties` map and collecting the required field names. The
606
+ // caller chooses whether unknown keys are forbidden — a `record` forbids them
607
+ // (additionalProperties:false), a tagged-union arm allows the sibling
608
+ // discriminator. The structure is the declaration; emit is mechanical.
609
+ export function objectJson<T>(
610
+ fieldMap: FieldSpecMap<T>,
611
+ opts: { readonly closed: boolean } = { closed: true },
612
+ ): JsonNode {
613
+ const specs = fieldMap as Readonly<Record<string, FieldSpec<unknown>>>;
614
+ const properties: Record<string, JsonNode> = {};
615
+ const required: string[] = [];
616
+ for (const [field, spec] of Object.entries(specs)) {
617
+ properties[field] = spec.json;
618
+ if (spec.required) required.push(field);
619
+ }
620
+ return {
621
+ type: "object",
622
+ properties,
623
+ ...(required.length > 0 && { required }),
624
+ ...(opts.closed && { additionalProperties: false }),
625
+ };
626
+ }
627
+
628
+ // [LAW:one-source-of-truth] The emit-twin of `record`: a closed object schema
629
+ // over the schema's fields — the same shape `record` enforces at runtime.
630
+ export function recordJson<T>(schema: RecordSchema<T>): JsonNode {
631
+ return objectJson(schema.fields);
632
+ }
633
+
634
+ // [LAW:dataflow-not-control-flow] Merge a discriminator constant into an object
635
+ // schema: add `{ [tag]: { const } }` to its properties and `tag` to required.
636
+ // An arm bakes this in so taggedUnionJson can collect arms verbatim.
637
+ export function withConst(
638
+ base: JsonNode,
639
+ key: string,
640
+ value: string,
641
+ ): JsonNode {
642
+ const b = base as Record<string, unknown>;
643
+ const properties = {
644
+ [key]: { const: value },
645
+ ...(b.properties as Record<string, JsonNode> | undefined),
646
+ };
647
+ const required = [key, ...((b.required as string[] | undefined) ?? [])];
648
+ return { ...b, properties, required };
649
+ }
650
+
651
+ // [LAW:one-source-of-truth] The emit-twin of `taggedUnion`: each arm already
652
+ // carries its full member schema (discriminator baked in), so the union is just
653
+ // the `anyOf` of arm schemas — the same disjoint set the dispatcher selects from.
654
+ export function taggedUnionJson<T, K extends string>(
655
+ schema: TaggedUnionSchema<T, K>,
656
+ ): JsonNode {
657
+ const arms = schema.arms as Readonly<Record<string, TaggedArm<T>>>;
658
+ return { anyOf: Object.values(arms).map((arm) => arm.json) };
659
+ }
660
+
661
+ // [LAW:one-source-of-truth] The emit-twin of `oneOfPresent`: each member is the
662
+ // closed single-key object the present-key contract describes (exactly that key
663
+ // required, no others) — the `anyOf` of those is the tag-by-present-key shape.
664
+ export function oneOfPresentJson<T>(schema: OneOfPresentSchema<T>): JsonNode {
665
+ const arms = schema.arms as Readonly<Record<string, PresentArm<T>>>;
666
+ return {
667
+ anyOf: Object.entries(arms).map(([key, arm]) => ({
668
+ type: "object",
669
+ properties: { [key]: arm.json },
670
+ required: [key],
671
+ additionalProperties: false,
672
+ })),
673
+ };
674
+ }