@promptctl/cc-candybar 1.26.0 → 1.28.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.mjs +86 -85
- package/package.json +6 -6
- package/schema/cc-candybar.schema.json +193 -4
- package/src/check.ts +49 -27
- package/src/click/wire.ts +16 -0
- package/src/config/action.ts +57 -22
- package/src/config/default-dsl-config.ts +424 -55
- package/src/config/dsl-loader.ts +14 -2
- package/src/config/dsl-types.ts +59 -0
- package/src/config/loader/actions.ts +283 -109
- package/src/config/loader/cross-ref.ts +148 -28
- package/src/config/loader/emit-schema.ts +2 -0
- package/src/config/loader/globals.ts +118 -31
- package/src/config/loader/merge.ts +58 -1
- package/src/config/loader/persist-target.ts +32 -0
- package/src/config/loader/presets.ts +107 -0
- package/src/config/option-domain.ts +164 -0
- package/src/config/presets.ts +188 -0
- package/src/daemon/cache/git.ts +1 -1
- package/src/daemon/cache/render.ts +72 -7
- package/src/daemon/config-overrides-store.ts +322 -0
- package/src/daemon/paths.ts +10 -0
- package/src/daemon/render-payload.ts +84 -19
- package/src/daemon/server.ts +68 -55
- package/src/daemon/verbs/config-validators.ts +127 -0
- package/src/daemon/verbs/index.ts +129 -2
- package/src/daemon/verbs/state-validators.ts +98 -586
- package/src/daemon/verbs/validator-registry.ts +457 -0
- package/src/demo/dsl.ts +17 -10
- package/src/dsl/node-registry.ts +54 -39
- package/src/dsl/render.ts +158 -46
- package/src/help-text.ts +3 -3
- package/src/install/index.ts +2 -2
- package/src/render/action.ts +155 -33
- package/src/render/active-segment.ts +78 -0
- package/src/render/menu.ts +16 -11
- package/src/render/picker.ts +51 -13
- package/src/render/segment-color.ts +74 -0
- package/src/segments/git.ts +389 -48
- package/src/template-engine/colors.ts +67 -45
- package/src/template-engine/engine.ts +11 -12
- package/src/themes/index.ts +1 -4
- package/src/themes/palette-resolvers.ts +22 -30
- package/src/themes/policy.ts +37 -16
|
@@ -10,9 +10,20 @@ import {
|
|
|
10
10
|
hasCacheField,
|
|
11
11
|
walkNodes,
|
|
12
12
|
type DslConfig,
|
|
13
|
+
type LayoutNode,
|
|
13
14
|
type VariableDecl,
|
|
14
15
|
} from "../dsl-types.js";
|
|
15
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
actionBindsPersist,
|
|
18
|
+
actionBindsReset,
|
|
19
|
+
actionBindsSet,
|
|
20
|
+
} from "../action.js";
|
|
21
|
+
import {
|
|
22
|
+
knownOptionDomainNames,
|
|
23
|
+
perConfigDomainsFor,
|
|
24
|
+
} from "../option-domain.js";
|
|
25
|
+
import { listGlobalsFieldNames } from "./globals.js";
|
|
26
|
+
import { parsePersistTarget } from "./persist-target.js";
|
|
16
27
|
import { findKeyLine } from "./diagnostics.js";
|
|
17
28
|
import { isPlainObject, type ValidateCtx } from "./validate-core.js";
|
|
18
29
|
import {
|
|
@@ -50,6 +61,89 @@ export function validateCrossReferences(
|
|
|
50
61
|
line: findKeyLine(ctx.source, ["globals", "look"]),
|
|
51
62
|
});
|
|
52
63
|
}
|
|
64
|
+
// [LAW:one-type-per-behavior] globals.preset is globals.look one dimension
|
|
65
|
+
// over — the same post-merge membership check against the same kind of
|
|
66
|
+
// per-config block, for the same reason (a user's default may name a
|
|
67
|
+
// bundled preset). A typo'd DEFAULT is a load error even though a stale
|
|
68
|
+
// SESSION pick collapses silently to the floor: the config file is authored
|
|
69
|
+
// and re-readable, so naming a preset that does not exist is a mistake we can
|
|
70
|
+
// point at; a session pick is a click made against a config that has since
|
|
71
|
+
// changed, which is not [LAW:no-silent-failure].
|
|
72
|
+
if (
|
|
73
|
+
cfg.globals.preset !== undefined &&
|
|
74
|
+
!Object.prototype.hasOwnProperty.call(cfg.presets, cfg.globals.preset)
|
|
75
|
+
) {
|
|
76
|
+
ctx.issues.push({
|
|
77
|
+
path: "globals.preset",
|
|
78
|
+
message: `globals.preset "${cfg.globals.preset}" does not match any declared preset (have: ${Object.keys(cfg.presets).join(", ")})`,
|
|
79
|
+
line: findKeyLine(ctx.source, ["globals", "preset"]),
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
// [LAW:one-source-of-truth] A `set … from` NAME must resolve — checked
|
|
83
|
+
// against this config's per-config domains ("looks", the merged looks:
|
|
84
|
+
// block) plus the global registry (themes/styles, and any future
|
|
85
|
+
// registration), the SAME set resolveOptionDomain consults at render and
|
|
86
|
+
// gate-derivation time. An inline array `from` is its own domain — nothing
|
|
87
|
+
// to resolve. Runs post-merge for the same reason globals.look does above:
|
|
88
|
+
// "looks" isn't fully known until the user's looks: block has merged onto
|
|
89
|
+
// the bundled stdlib.
|
|
90
|
+
const optionDomains = perConfigDomainsFor(cfg);
|
|
91
|
+
for (const [name, a] of Object.entries(cfg.actions)) {
|
|
92
|
+
if (!("set" in a) || !("from" in a) || typeof a.from !== "string") continue;
|
|
93
|
+
if (!knownOptionDomainNames(optionDomains).includes(a.from)) {
|
|
94
|
+
ctx.issues.push({
|
|
95
|
+
path: `actions.${name}.from`,
|
|
96
|
+
message: `actions.${name} from: references unknown option domain "${a.from}" (have: ${knownOptionDomainNames(optionDomains).join(", ")})`,
|
|
97
|
+
line: findKeyLine(ctx.source, ["actions", name, "from"]),
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
// [LAW:no-silent-failure] A `persist`/`reset` target must name a REAL
|
|
102
|
+
// Globals field OR a declared segment's `palette` (candybar-config-engine-
|
|
103
|
+
// 71o.6 — `segments.<name>.palette`, parsed by the one shared authority in
|
|
104
|
+
// persist-target.ts) — the loader's structural pass (loader/actions.ts)
|
|
105
|
+
// only proves the key is non-empty/slash-free, the same shape a `set` key
|
|
106
|
+
// needs for the wire, but a persist/reset key additionally has to land
|
|
107
|
+
// somewhere real. Catching a typo (`persist: "pallete"`) or a dangling
|
|
108
|
+
// segment name here turns a confusing click-time "registration invariant
|
|
109
|
+
// broken" error into a clear load-time one naming the actual allowed
|
|
110
|
+
// targets — the same species of fix as the `from` domain check just above.
|
|
111
|
+
for (const [name, a] of Object.entries(cfg.actions)) {
|
|
112
|
+
const key = "persist" in a ? a.persist : "reset" in a ? a.reset : null;
|
|
113
|
+
if (key === null) continue;
|
|
114
|
+
const discriminator = "persist" in a ? "persist" : "reset";
|
|
115
|
+
const target = parsePersistTarget(key);
|
|
116
|
+
if (target === null) {
|
|
117
|
+
ctx.issues.push({
|
|
118
|
+
path: `actions.${name}.${discriminator}`,
|
|
119
|
+
message: `actions.${name}: "${key}" is not a config globals field (have: ${listGlobalsFieldNames().join(", ")}) or a "segments.<name>.palette" target`,
|
|
120
|
+
line: findKeyLine(ctx.source, ["actions", name, discriminator]),
|
|
121
|
+
});
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
if (target.scope !== "segment-palette") continue;
|
|
125
|
+
if (!Object.prototype.hasOwnProperty.call(cfg.segments, target.segment)) {
|
|
126
|
+
ctx.issues.push({
|
|
127
|
+
path: `actions.${name}.${discriminator}`,
|
|
128
|
+
message: `actions.${name}: "${key}" names segment "${target.segment}" which is not declared (have segments: ${Object.keys(cfg.segments).join(", ")})`,
|
|
129
|
+
line: findKeyLine(ctx.source, ["actions", name, discriminator]),
|
|
130
|
+
});
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
// [LAW:types-are-the-program] A palette is a NAME, not a number — a
|
|
134
|
+
// bounded stepper (`min`/`max`/`by`) has no meaning over it, unlike a
|
|
135
|
+
// Globals field where nothing today enforces value/field-kind agreement
|
|
136
|
+
// either way. Rejecting it here (rather than tolerating a numeric-string
|
|
137
|
+
// palette name that only fails later at `paletteForThemeName`) keeps
|
|
138
|
+
// the failure at load time, next to the typo it actually is.
|
|
139
|
+
if ("min" in a) {
|
|
140
|
+
ctx.issues.push({
|
|
141
|
+
path: `actions.${name}.${discriminator}`,
|
|
142
|
+
message: `actions.${name}: "${key}" is a segment palette target and cannot use a bounded stepper (min/max/by) — use "to", "from", or "cycle" instead`,
|
|
143
|
+
line: findKeyLine(ctx.source, ["actions", name, discriminator]),
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
}
|
|
53
147
|
// [LAW:one-source-of-truth] THE set of resolvable variable names — a
|
|
54
148
|
// faithful mirror of the runtime store's key set (declareOne in
|
|
55
149
|
// src/dsl/render.ts registers globals under their bare names and segment
|
|
@@ -80,30 +174,51 @@ export function validateCrossReferences(
|
|
|
80
174
|
// field) — would fool a raw `findKeyLine` search and misclassify the config.
|
|
81
175
|
// Validation is cold-path, so reading the source's top-level keys is exact.
|
|
82
176
|
// The reported path/message then point at the surface the user wrote.
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
177
|
+
//
|
|
178
|
+
// [LAW:one-type-per-behavior] A PRESET's `root` is a root: it gets this exact
|
|
179
|
+
// walk, not a reduced copy. The only thing that varies between the config's
|
|
180
|
+
// own tree and a preset's is the diagnostic key + line — data threaded in,
|
|
181
|
+
// never a second traversal that could learn a different idea of what a valid
|
|
182
|
+
// layout is. This is what makes `cc-candybar check` catch a preset staging a
|
|
183
|
+
// segment nobody declared.
|
|
184
|
+
const checkLayoutTree = (
|
|
185
|
+
root: LayoutNode,
|
|
186
|
+
layoutKey: string,
|
|
187
|
+
layoutLine: number | undefined,
|
|
188
|
+
): void => {
|
|
189
|
+
for (const node of walkNodes(root)) {
|
|
190
|
+
// [LAW:locality-or-seam] A node's `when` reads the global scope (bare
|
|
191
|
+
// globals + namespaced segment vars) — the same existence-check shape as a
|
|
192
|
+
// segment template, surfaced at load time.
|
|
193
|
+
if (node.when !== undefined) {
|
|
194
|
+
checkTemplateRefs(ctx, `${layoutKey}.when`, node.when, templateScope, {
|
|
195
|
+
line: layoutLine,
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
if (node.kind !== "segment") continue;
|
|
199
|
+
if (!Object.prototype.hasOwnProperty.call(cfg.segments, node.name)) {
|
|
200
|
+
const renamed = RENAMED_SEGMENTS[node.name];
|
|
201
|
+
const hint =
|
|
202
|
+
renamed !== undefined
|
|
203
|
+
? ` (the built-in segment "${node.name}" was renamed to "${renamed}" — update this reference)`
|
|
204
|
+
: "";
|
|
205
|
+
ctx.issues.push({
|
|
206
|
+
path: layoutKey,
|
|
207
|
+
message: `${layoutKey} entry "${node.name}" does not match any declared segment${hint}`,
|
|
208
|
+
line: layoutLine,
|
|
209
|
+
});
|
|
210
|
+
}
|
|
106
211
|
}
|
|
212
|
+
};
|
|
213
|
+
const layoutKey = authoredLayoutKey(ctx.source);
|
|
214
|
+
checkLayoutTree(cfg.root, layoutKey, findKeyLine(ctx.source, [layoutKey]));
|
|
215
|
+
for (const [name, preset] of Object.entries(cfg.presets)) {
|
|
216
|
+
if (preset.root === undefined) continue;
|
|
217
|
+
checkLayoutTree(
|
|
218
|
+
preset.root,
|
|
219
|
+
`presets.${name}.root`,
|
|
220
|
+
findKeyLine(ctx.source, ["presets", name, "root"]),
|
|
221
|
+
);
|
|
107
222
|
}
|
|
108
223
|
|
|
109
224
|
// For each variable's template/cache.key, every dotted ref must exist
|
|
@@ -243,11 +358,16 @@ function hasStateKind(cfg: DslConfig): boolean {
|
|
|
243
358
|
return false;
|
|
244
359
|
}
|
|
245
360
|
|
|
246
|
-
// [LAW:dataflow-not-control-flow] A config emits a set-state
|
|
247
|
-
// session.id — when any declared action is
|
|
248
|
-
//
|
|
361
|
+
// [LAW:dataflow-not-control-flow] A config emits a set-state, set-config, OR
|
|
362
|
+
// reset-config click — and so needs session.id — when any declared action is
|
|
363
|
+
// a `set` (literal/option/bounded/cycle), a `persist` (its config-overrides
|
|
364
|
+
// twin), or a `reset` (persist's gated undo) — all three carry session.id on
|
|
365
|
+
// the wire for click-error surfacing. copy/open actions write nothing, so
|
|
366
|
+
// they embed no session.id.
|
|
249
367
|
function hasActionSetAction(cfg: DslConfig): boolean {
|
|
250
|
-
return Object.values(cfg.actions).some(
|
|
368
|
+
return Object.values(cfg.actions).some(
|
|
369
|
+
(a) => actionBindsSet(a) || actionBindsPersist(a) || actionBindsReset(a),
|
|
370
|
+
);
|
|
251
371
|
}
|
|
252
372
|
|
|
253
373
|
function checkVarRefs(
|
|
@@ -17,6 +17,7 @@ import { variablesMapJson } from "./variables.js";
|
|
|
17
17
|
import { segmentsJson } from "./segments.js";
|
|
18
18
|
import { actionsJson } from "./actions.js";
|
|
19
19
|
import { looksJson } from "./looks.js";
|
|
20
|
+
import { presetsJson } from "./presets.js";
|
|
20
21
|
import {
|
|
21
22
|
layoutNodeJson,
|
|
22
23
|
LAYOUT_NODE_DEF_NAME,
|
|
@@ -48,6 +49,7 @@ export function emitConfigSchema(): JsonNode {
|
|
|
48
49
|
root: { $ref: LAYOUT_NODE_REF },
|
|
49
50
|
actions: actionsJson(),
|
|
50
51
|
looks: looksJson(),
|
|
52
|
+
presets: presetsJson(),
|
|
51
53
|
helpers: { type: "object", additionalProperties: { type: "string" } },
|
|
52
54
|
},
|
|
53
55
|
definitions: {
|
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
record,
|
|
21
21
|
recordJson,
|
|
22
22
|
type FieldSpec,
|
|
23
|
+
type FieldSpecMap,
|
|
23
24
|
type JsonNode,
|
|
24
25
|
type RecordSchema,
|
|
25
26
|
type ValidateCtx,
|
|
@@ -53,46 +54,110 @@ const colorCompatibilitySpec: FieldSpec<ColorCompatibility> = {
|
|
|
53
54
|
},
|
|
54
55
|
};
|
|
55
56
|
|
|
57
|
+
// [LAW:one-source-of-truth] THE globals field table, declared once. Both the
|
|
58
|
+
// top-level `globals:` schema and the preset-scoped one below are built from
|
|
59
|
+
// this map, so a field added here is automatically settable from a preset —
|
|
60
|
+
// there is no second list to remember to grow.
|
|
61
|
+
const GLOBALS_FIELDS: FieldSpecMap<Globals> = {
|
|
62
|
+
default_bg: optionalStringSpec(),
|
|
63
|
+
default_fg: optionalStringSpec(),
|
|
64
|
+
default_empty_value: optionalStringSpec(),
|
|
65
|
+
default_separator: optionalStringSpec(),
|
|
66
|
+
default_truncate_marker: optionalStringSpec(),
|
|
67
|
+
palette: paletteSpec(),
|
|
68
|
+
// [LAW:types-are-the-program] The config-default LOOK name. Unlike the
|
|
69
|
+
// registry-static palette set, the look domain is per-config (the merged
|
|
70
|
+
// `looks` block), so membership is a cross-ref check on the MERGED config —
|
|
71
|
+
// a user's globals.look may name a default-provided look. Shape-only here,
|
|
72
|
+
// exactly the shape/meaning split paletteSpec's schema facet keeps.
|
|
73
|
+
look: optionalStringSpec(),
|
|
74
|
+
// [LAW:types-are-the-program] The config-default PRESET name — same
|
|
75
|
+
// per-config-domain shape as `look` (membership is a post-merge cross-ref
|
|
76
|
+
// check, since a user's globals.preset may name a default-provided preset).
|
|
77
|
+
preset: optionalStringSpec(),
|
|
78
|
+
// [LAW:types-are-the-program] The strip style is a CLOSED enum (the powerline
|
|
79
|
+
// shapes the joiner can render), unlike the open-ended palette NAME — so it
|
|
80
|
+
// validates by membership and emits a JSON-Schema `enum`.
|
|
81
|
+
style: optionalEnumSpec(STRIP_STYLES),
|
|
82
|
+
autoWrap: optionalBooleanSpec(),
|
|
83
|
+
// Intra-cell spaces per side. Bounded above so a config value can never
|
|
84
|
+
// drive an unbounded `" ".repeat` allocation in the daemon
|
|
85
|
+
// [LAW:no-silent-failure] — an absurd value is a loud load error, not a
|
|
86
|
+
// silently-huge render.
|
|
87
|
+
padding: optionalIntSpec({ min: 0, max: 16 }),
|
|
88
|
+
// [LAW:types-are-the-program] Closed enum like `style`: the joiner glyph
|
|
89
|
+
// vocabularies pickJoiner can render — validates by membership, emits a
|
|
90
|
+
// JSON-Schema `enum` from the same CHARSETS literal.
|
|
91
|
+
charset: optionalEnumSpec(CHARSETS),
|
|
92
|
+
// Closed enum with a bespoke "auto" rejection — see colorCompatibilitySpec.
|
|
93
|
+
colorCompatibility: colorCompatibilitySpec,
|
|
94
|
+
};
|
|
95
|
+
|
|
56
96
|
const GLOBALS_SCHEMA: RecordSchema<Globals> = {
|
|
57
97
|
noun: "globals key",
|
|
58
|
-
fields:
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
autoWrap: optionalBooleanSpec(),
|
|
76
|
-
// Intra-cell spaces per side. Bounded above so a config value can never
|
|
77
|
-
// drive an unbounded `" ".repeat` allocation in the daemon
|
|
78
|
-
// [LAW:no-silent-failure] — an absurd value is a loud load error, not a
|
|
79
|
-
// silently-huge render.
|
|
80
|
-
padding: optionalIntSpec({ min: 0, max: 16 }),
|
|
81
|
-
// [LAW:types-are-the-program] Closed enum like `style`: the joiner glyph
|
|
82
|
-
// vocabularies pickJoiner can render — validates by membership, emits a
|
|
83
|
-
// JSON-Schema `enum` from the same CHARSETS literal.
|
|
84
|
-
charset: optionalEnumSpec(CHARSETS),
|
|
85
|
-
// Closed enum with a bespoke "auto" rejection — see colorCompatibilitySpec.
|
|
86
|
-
colorCompatibility: colorCompatibilitySpec,
|
|
98
|
+
fields: GLOBALS_FIELDS,
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
// [LAW:one-source-of-truth] A preset's `globals` may not carry `preset`: which
|
|
102
|
+
// preset is active has exactly one authority (session pick over globals.preset
|
|
103
|
+
// over the floor), and a preset re-selecting a preset would be a second one —
|
|
104
|
+
// a cyclic second one. Same species of bespoke, migration-pointing rejection as
|
|
105
|
+
// colorCompatibility's "auto" above, and for the same reason: an author who
|
|
106
|
+
// writes it deserves to be told WHY, not handed a bare unknown-key message.
|
|
107
|
+
const nestedPresetSpec: FieldSpec<string> = {
|
|
108
|
+
required: false,
|
|
109
|
+
// Always-fail: JSON Schema's `not: {}` matches nothing, so an editor flags
|
|
110
|
+
// the key at the same moment the validator does.
|
|
111
|
+
json: {
|
|
112
|
+
not: {},
|
|
113
|
+
description:
|
|
114
|
+
"not allowed inside a preset — a preset cannot select a preset",
|
|
87
115
|
},
|
|
116
|
+
parse: (ctx, path, field, raw) => {
|
|
117
|
+
if (raw[field] !== undefined) {
|
|
118
|
+
ctx.issues.push({
|
|
119
|
+
path: `${path}.${field}`,
|
|
120
|
+
message:
|
|
121
|
+
`${path}.${field}: a preset cannot select a preset. Which preset is active is ` +
|
|
122
|
+
`resolved once, as session pick over globals.preset over "default"; a preset ` +
|
|
123
|
+
`naming another would be a second authority over that, and a cyclic one. ` +
|
|
124
|
+
`Set the default arrangement in the top-level globals.preset instead.`,
|
|
125
|
+
line: findKeyLine(ctx.source, [...path.split("."), field]),
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
return undefined;
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
// [LAW:one-source-of-truth] The preset-scoped globals schema is the SAME field
|
|
133
|
+
// table with exactly one field swapped for its rejection — not a hand-listed
|
|
134
|
+
// subset that a future globals field could be forgotten from.
|
|
135
|
+
const PRESET_GLOBALS_SCHEMA: RecordSchema<Globals> = {
|
|
136
|
+
noun: "preset globals key",
|
|
137
|
+
fields: { ...GLOBALS_FIELDS, preset: nestedPresetSpec },
|
|
88
138
|
};
|
|
89
139
|
|
|
90
140
|
// An absent globals block is the empty default (no issue); a non-object is a
|
|
91
141
|
// reported error that recovers to the empty default, since parseDslConfig throws
|
|
92
142
|
// once any issue exists so the recovery value never renders [LAW:no-silent-failure].
|
|
93
|
-
|
|
143
|
+
// `path` is explicit because the same schema validates the top-level `globals:`
|
|
144
|
+
// block and a preset's nested one, and a diagnostic must name where it actually is.
|
|
145
|
+
export function validateGlobals(
|
|
146
|
+
ctx: ValidateCtx,
|
|
147
|
+
path: string,
|
|
148
|
+
raw: unknown,
|
|
149
|
+
): Globals {
|
|
94
150
|
if (raw === undefined) return {};
|
|
95
|
-
return record(ctx, GLOBALS_SCHEMA,
|
|
151
|
+
return record(ctx, GLOBALS_SCHEMA, path, raw) ?? {};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// The preset-scoped twin: same interpreter, the schema that rejects `preset`.
|
|
155
|
+
export function validatePresetGlobals(
|
|
156
|
+
ctx: ValidateCtx,
|
|
157
|
+
path: string,
|
|
158
|
+
raw: unknown,
|
|
159
|
+
): Globals {
|
|
160
|
+
return record(ctx, PRESET_GLOBALS_SCHEMA, path, raw) ?? {};
|
|
96
161
|
}
|
|
97
162
|
|
|
98
163
|
// [LAW:one-source-of-truth] The schema emitter derives from the SAME declaration
|
|
@@ -101,3 +166,25 @@ export function validateGlobals(ctx: ValidateCtx, raw: unknown): Globals {
|
|
|
101
166
|
export function globalsJson(): JsonNode {
|
|
102
167
|
return recordJson(GLOBALS_SCHEMA);
|
|
103
168
|
}
|
|
169
|
+
|
|
170
|
+
export function presetGlobalsJson(): JsonNode {
|
|
171
|
+
return recordJson(PRESET_GLOBALS_SCHEMA);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// [LAW:one-source-of-truth] THE membership check for "is this a real Globals
|
|
175
|
+
// field" — derived from GLOBALS_SCHEMA.fields, the same declaration
|
|
176
|
+
// validateGlobals/globalsJson interpret, so a `persist`/`reset` action's
|
|
177
|
+
// target key is checked against exactly the field set a hand-authored
|
|
178
|
+
// `globals: {...}` block would be. Used by cross-ref.ts (candybar-config-
|
|
179
|
+
// engine-71o.2) to catch a typo'd persist target at config-load time instead
|
|
180
|
+
// of a confusing click-time "invariant broken" error.
|
|
181
|
+
const GLOBALS_FIELD_NAMES: ReadonlySet<string> = new Set(
|
|
182
|
+
Object.keys(GLOBALS_SCHEMA.fields),
|
|
183
|
+
);
|
|
184
|
+
export function isGlobalsField(key: string): key is keyof Globals {
|
|
185
|
+
return GLOBALS_FIELD_NAMES.has(key);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function listGlobalsFieldNames(): readonly string[] {
|
|
189
|
+
return [...GLOBALS_FIELD_NAMES];
|
|
190
|
+
}
|
|
@@ -13,7 +13,11 @@
|
|
|
13
13
|
// specific consumer, a cycle. Callers who want "the bundled default" import
|
|
14
14
|
// DEFAULT_DSL_CONFIG from default-dsl-config.ts and pass it explicitly.
|
|
15
15
|
|
|
16
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
type DslConfig,
|
|
18
|
+
type RawDslConfig,
|
|
19
|
+
type SegmentDecl,
|
|
20
|
+
} from "../dsl-types.js";
|
|
17
21
|
|
|
18
22
|
/**
|
|
19
23
|
* Merge a RawDslConfig on top of a default DslConfig. Pure function.
|
|
@@ -44,9 +48,62 @@ export function mergeWithDefault(
|
|
|
44
48
|
// overrides one adaptation by re-declaring its name; the bundled stdlib
|
|
45
49
|
// (incl. the "none" identity floor) survives every merge by construction.
|
|
46
50
|
looks: { ...dflt.looks, ...(raw.looks ?? {}) },
|
|
51
|
+
// [LAW:one-source-of-truth] presets merge by name, same cascade — a user
|
|
52
|
+
// overrides one arrangement by re-declaring its name; the bundled stdlib
|
|
53
|
+
// (incl. the "default" empty-fragment floor effectivePresetName collapses
|
|
54
|
+
// to) survives every merge by construction, exactly as looks' "none" does.
|
|
55
|
+
presets: { ...dflt.presets, ...(raw.presets ?? {}) },
|
|
47
56
|
// [LAW:one-source-of-truth] helpers merge by name, same cascade — a user
|
|
48
57
|
// overrides one formatter helper by re-declaring its name; the rest inherit
|
|
49
58
|
// from the bundled default.
|
|
50
59
|
helpers: { ...dflt.helpers, ...(raw.helpers ?? {}) },
|
|
51
60
|
};
|
|
52
61
|
}
|
|
62
|
+
|
|
63
|
+
// [LAW:one-source-of-truth] The segment-scoped half of the config-overrides
|
|
64
|
+
// layer's merge (candybar-config-engine-71o.6) — the SAME "changes the
|
|
65
|
+
// DEFAULT, never the hand-authored file" precedence mergeWithDefault's
|
|
66
|
+
// `globals` cascade already applies, but patches ONE field (`palette`)
|
|
67
|
+
// inside an already-merged segment rather than replacing the segment
|
|
68
|
+
// wholesale. mergeWithDefault's `segments` cascade is deliberately per-name
|
|
69
|
+
// WHOLESALE replacement (a user overriding a segment restates it in full,
|
|
70
|
+
// same as any other by-name merge in this file) — routing a one-field
|
|
71
|
+
// override through that cascade would silently drop every other field the
|
|
72
|
+
// segment declares (template, bg, fg, when, vars...). This runs AFTER
|
|
73
|
+
// mergeWithDefault, directly against the already-merged config, so it never
|
|
74
|
+
// fights that cascade; it is its own, later, narrower merge step.
|
|
75
|
+
//
|
|
76
|
+
// [LAW:no-silent-failure] exception: a stale override naming a segment the
|
|
77
|
+
// config no longer declares is not a load-time error — the CONFIG, not the
|
|
78
|
+
// override, is the source of truth for which segments exist. Skipping it is
|
|
79
|
+
// a no-op, not a swallowed failure: a fresh `persist` write can only ever
|
|
80
|
+
// name a segment the config declares (cross-ref checks that at load time),
|
|
81
|
+
// so a dangling entry here only happens after a later config edit removed
|
|
82
|
+
// the segment, and there is nothing left for the override to apply to.
|
|
83
|
+
//
|
|
84
|
+
// [LAW:no-defensive-null-guards] exception: `Object.assign(Object.create(null),
|
|
85
|
+
// ...)` instead of `{ ...config.segments }` — the SAME null-prototype hygiene
|
|
86
|
+
// as the config-overrides-store.ts accumulators above (segment names come
|
|
87
|
+
// from user config and this loop WRITES via bracket assignment, `segments[name]
|
|
88
|
+
// = ...`, not a pure spread). Pure object spread never risks this (it defines
|
|
89
|
+
// every key directly, never invoking an inherited setter), but a stale
|
|
90
|
+
// override naming a since-removed segment `__proto__` hits exactly the
|
|
91
|
+
// "no own property yet, so the read returns the inherited accessor and the
|
|
92
|
+
// write invokes its setter" case a plain accumulator does not guard against.
|
|
93
|
+
export function applySegmentPaletteOverrides(
|
|
94
|
+
config: DslConfig,
|
|
95
|
+
overrides: Readonly<Record<string, string>>,
|
|
96
|
+
): DslConfig {
|
|
97
|
+
const entries = Object.entries(overrides);
|
|
98
|
+
if (entries.length === 0) return config;
|
|
99
|
+
const segments: Record<string, SegmentDecl> = Object.assign(
|
|
100
|
+
Object.create(null) as Record<string, SegmentDecl>,
|
|
101
|
+
config.segments,
|
|
102
|
+
);
|
|
103
|
+
for (const [name, palette] of entries) {
|
|
104
|
+
const seg = segments[name];
|
|
105
|
+
if (seg === undefined) continue;
|
|
106
|
+
segments[name] = { ...seg, palette };
|
|
107
|
+
}
|
|
108
|
+
return { ...config, segments };
|
|
109
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// [LAW:one-source-of-truth] THE parser for what a `persist`/`reset` action's
|
|
2
|
+
// key STRING may legally name — the single place a key is classified as a
|
|
3
|
+
// Globals field (candybar-config-engine-71o.3's scope) or a per-segment
|
|
4
|
+
// palette override (candybar-config-engine-71o.6's scope), so cross-ref
|
|
5
|
+
// validation (load time), the config-overrides store (write/read), and the
|
|
6
|
+
// daemon's write-gate all classify a key through ONE authority instead of
|
|
7
|
+
// three independently-drifting `isGlobalsField` checks.
|
|
8
|
+
//
|
|
9
|
+
// [LAW:types-are-the-program] A discriminated union, not a bag of optional
|
|
10
|
+
// fields: every legal key shape is representable, every illegal one
|
|
11
|
+
// collapses to `null` at exactly this one boundary — no downstream consumer
|
|
12
|
+
// re-parses the string itself.
|
|
13
|
+
|
|
14
|
+
import type { Globals } from "../dsl-types.js";
|
|
15
|
+
import { isGlobalsField } from "./globals.js";
|
|
16
|
+
|
|
17
|
+
export type PersistTarget =
|
|
18
|
+
| { readonly scope: "globals"; readonly field: keyof Globals }
|
|
19
|
+
| { readonly scope: "segment-palette"; readonly segment: string };
|
|
20
|
+
|
|
21
|
+
// [LAW:locality-or-seam] `segments.<name>.palette` reuses the SAME dotted
|
|
22
|
+
// namespacing SegmentDecl.vars already uses for segment-local variables
|
|
23
|
+
// (`<segment>.<var>`, declared in src/dsl/render.ts) — one idiom for "a name
|
|
24
|
+
// scoped under a segment", not a bespoke second syntax invented for persist
|
|
25
|
+
// targets alone.
|
|
26
|
+
const SEGMENT_PALETTE_KEY = /^segments\.([^.]+)\.palette$/;
|
|
27
|
+
|
|
28
|
+
export function parsePersistTarget(key: string): PersistTarget | null {
|
|
29
|
+
if (isGlobalsField(key)) return { scope: "globals", field: key };
|
|
30
|
+
const match = SEGMENT_PALETTE_KEY.exec(key);
|
|
31
|
+
return match ? { scope: "segment-palette", segment: match[1]! } : null;
|
|
32
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// [LAW:types-are-the-program] The `presets` schema: each preset is a named
|
|
2
|
+
// config FRAGMENT — an alternative arrangement of the bar the user switches to
|
|
3
|
+
// — capped at exactly the two sections a render RESOLVES (`root`, `globals`)
|
|
4
|
+
// and closed to the sections the daemon REGISTERS once per process
|
|
5
|
+
// (`variables`, `segments`, `actions`, `helpers`). The reasoning for that cap
|
|
6
|
+
// lives on PresetDecl in dsl-types.ts; enforcing it is this file's job, and the
|
|
7
|
+
// record engine enforces it for free — `rejectUnknownKeys` reports any other
|
|
8
|
+
// section as a "preset key" error naming the two that are legal.
|
|
9
|
+
//
|
|
10
|
+
// This file is the structural pass only. Whether a preset's root names a
|
|
11
|
+
// segment that exists, and whether globals.preset names a declared preset, are
|
|
12
|
+
// CROSS-REFERENCE concerns (loader/cross-ref.ts), because both run on the
|
|
13
|
+
// MERGED config — a preset may stage segments the bundled default provides.
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
describeType,
|
|
17
|
+
isPlainObject,
|
|
18
|
+
record,
|
|
19
|
+
recordJson,
|
|
20
|
+
type FieldSpec,
|
|
21
|
+
type JsonNode,
|
|
22
|
+
type RecordSchema,
|
|
23
|
+
type ValidateCtx,
|
|
24
|
+
} from "./validate-core.js";
|
|
25
|
+
import { findKeyLine } from "./diagnostics.js";
|
|
26
|
+
import { presetGlobalsJson, validatePresetGlobals } from "./globals.js";
|
|
27
|
+
import { LAYOUT_NODE_REF, validateRoot } from "./layout.js";
|
|
28
|
+
import type { PresetDecl } from "../dsl-types.js";
|
|
29
|
+
|
|
30
|
+
// [LAW:one-source-of-truth] A preset's `root` runs through THE layout validator
|
|
31
|
+
// — the same `validateRoot` the top-level `root:` uses, not a reduced copy — so
|
|
32
|
+
// the A-grammar, the group sugar, and every migration error read identically
|
|
33
|
+
// wherever a layout is authored. Group sugar declared inside a preset therefore
|
|
34
|
+
// also lands in `ctx.groups` and synthesizes its state var / cycle action /
|
|
35
|
+
// toggle segment into the shared sections, exactly as a top-level group does:
|
|
36
|
+
// the synthesized artifacts are process-lifetime (see PresetDecl's cap), the
|
|
37
|
+
// preset only chooses whether to stage them.
|
|
38
|
+
const presetRootSpec: FieldSpec<NonNullable<PresetDecl["root"]>> = {
|
|
39
|
+
required: false,
|
|
40
|
+
json: { $ref: LAYOUT_NODE_REF },
|
|
41
|
+
parse: (ctx, path, field, raw) =>
|
|
42
|
+
raw[field] === undefined
|
|
43
|
+
? undefined
|
|
44
|
+
: validateRoot(ctx, `${path}.${field}`, raw[field]),
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
// [LAW:one-source-of-truth] A preset's `globals` runs through the globals field
|
|
48
|
+
// table, minus `preset` itself (a preset cannot select a preset — see
|
|
49
|
+
// validatePresetGlobals). A field added to Globals is preset-settable the same
|
|
50
|
+
// day, with no edit here.
|
|
51
|
+
const presetGlobalsSpec: FieldSpec<NonNullable<PresetDecl["globals"]>> = {
|
|
52
|
+
required: false,
|
|
53
|
+
json: presetGlobalsJson(),
|
|
54
|
+
parse: (ctx, path, field, raw) =>
|
|
55
|
+
raw[field] === undefined
|
|
56
|
+
? undefined
|
|
57
|
+
: validatePresetGlobals(ctx, `${path}.${field}`, raw[field]),
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const PRESET_SCHEMA: RecordSchema<PresetDecl> = {
|
|
61
|
+
noun: "preset key",
|
|
62
|
+
fields: { root: presetRootSpec, globals: presetGlobalsSpec },
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
// An absent presets block is handled by the caller (absence survives the parse);
|
|
66
|
+
// a non-object is a reported error recovering to empty — parseDslConfig throws
|
|
67
|
+
// once any issue exists, so the recovery value never renders.
|
|
68
|
+
export function validatePresets(
|
|
69
|
+
ctx: ValidateCtx,
|
|
70
|
+
raw: unknown,
|
|
71
|
+
): Readonly<Record<string, PresetDecl>> {
|
|
72
|
+
if (!isPlainObject(raw)) {
|
|
73
|
+
ctx.issues.push({
|
|
74
|
+
path: "presets",
|
|
75
|
+
message: `presets must be an object mapping preset names to config fragments, got ${describeType(raw)}`,
|
|
76
|
+
line: findKeyLine(ctx.source, ["presets"]),
|
|
77
|
+
});
|
|
78
|
+
return {};
|
|
79
|
+
}
|
|
80
|
+
const out: Record<string, PresetDecl> = {};
|
|
81
|
+
for (const [name, value] of Object.entries(raw)) {
|
|
82
|
+
// [LAW:no-silent-failure] A preset name is a deliverable set-state value —
|
|
83
|
+
// a preset picker writes it on the wire, which rejects empty values and
|
|
84
|
+
// splits on "/". Rejecting the shape HERE surfaces the error on every
|
|
85
|
+
// config load, not only once an action ranges the "presets" domain (the
|
|
86
|
+
// identical guard looks.ts applies to look names, for the identical
|
|
87
|
+
// reason).
|
|
88
|
+
if (name === "" || name.includes("/")) {
|
|
89
|
+
ctx.issues.push({
|
|
90
|
+
path: `presets.${name}`,
|
|
91
|
+
message: `preset name ${JSON.stringify(name)} must be non-empty and slash-free — a preset picker writes the name on the set-state wire, which rejects empty values and splits on "/"`,
|
|
92
|
+
line: findKeyLine(ctx.source, ["presets", name]),
|
|
93
|
+
});
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
const parsed = record(ctx, PRESET_SCHEMA, `presets.${name}`, value);
|
|
97
|
+
if (parsed !== null) out[name] = parsed;
|
|
98
|
+
}
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// [LAW:one-source-of-truth] The schema emitter derives from the SAME declaration
|
|
103
|
+
// the validator interprets — a map of preset names to the closed two-section
|
|
104
|
+
// fragment object.
|
|
105
|
+
export function presetsJson(): JsonNode {
|
|
106
|
+
return { type: "object", additionalProperties: recordJson(PRESET_SCHEMA) };
|
|
107
|
+
}
|