@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,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
|
+
}
|