@promptctl/cc-candybar 1.42.1 → 1.43.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/dist/index.mjs +72 -71
  2. package/package.json +5 -6
  3. package/src/check.ts +0 -478
  4. package/src/cli-flags.ts +0 -8
  5. package/src/click/wire.ts +0 -158
  6. package/src/config/action.ts +0 -329
  7. package/src/config/cli.ts +0 -71
  8. package/src/config/default-dsl-config.ts +0 -1645
  9. package/src/config/disclosure.ts +0 -170
  10. package/src/config/dsl-loader.ts +0 -339
  11. package/src/config/dsl-types.ts +0 -581
  12. package/src/config/edit-chrome.ts +0 -559
  13. package/src/config/help.ts +0 -151
  14. package/src/config/ident.ts +0 -22
  15. package/src/config/layout-ops.ts +0 -177
  16. package/src/config/loader/actions.ts +0 -972
  17. package/src/config/loader/cache.ts +0 -206
  18. package/src/config/loader/cross-ref.ts +0 -714
  19. package/src/config/loader/cycles.ts +0 -148
  20. package/src/config/loader/diagnostics.ts +0 -99
  21. package/src/config/loader/discovery.ts +0 -182
  22. package/src/config/loader/edit-mode.ts +0 -137
  23. package/src/config/loader/emit-schema.ts +0 -68
  24. package/src/config/loader/globals.ts +0 -269
  25. package/src/config/loader/helpers.ts +0 -48
  26. package/src/config/loader/layout.ts +0 -693
  27. package/src/config/loader/looks.ts +0 -96
  28. package/src/config/loader/menu-synth.ts +0 -435
  29. package/src/config/loader/merge.ts +0 -115
  30. package/src/config/loader/persist-target.ts +0 -67
  31. package/src/config/loader/presets.ts +0 -119
  32. package/src/config/loader/refs.ts +0 -100
  33. package/src/config/loader/reserved-namespace.ts +0 -38
  34. package/src/config/loader/segments.ts +0 -120
  35. package/src/config/loader/validate-core.ts +0 -737
  36. package/src/config/loader/variables.ts +0 -260
  37. package/src/config/menu-keys.ts +0 -139
  38. package/src/config/option-domain.ts +0 -164
  39. package/src/config/presets.ts +0 -326
  40. package/src/config/settings-menu.ts +0 -775
  41. package/src/daemon/acquire.ts +0 -684
  42. package/src/daemon/cache/git.ts +0 -649
  43. package/src/daemon/cache/render.ts +0 -623
  44. package/src/daemon/cache/session-usage-store.ts +0 -720
  45. package/src/daemon/cache/watchers.ts +0 -249
  46. package/src/daemon/client-debug.ts +0 -120
  47. package/src/daemon/client-stats.ts +0 -130
  48. package/src/daemon/client-transport.ts +0 -273
  49. package/src/daemon/client.ts +0 -78
  50. package/src/daemon/config-overrides-store.ts +0 -663
  51. package/src/daemon/debug-types.ts +0 -91
  52. package/src/daemon/debug.ts +0 -264
  53. package/src/daemon/fork-bomb-breaker.ts +0 -351
  54. package/src/daemon/limits.ts +0 -211
  55. package/src/daemon/log.ts +0 -81
  56. package/src/daemon/parent-watchdog.ts +0 -87
  57. package/src/daemon/paths.ts +0 -211
  58. package/src/daemon/process-fingerprint.ts +0 -146
  59. package/src/daemon/protocol.ts +0 -292
  60. package/src/daemon/render-payload.ts +0 -1256
  61. package/src/daemon/server.ts +0 -1330
  62. package/src/daemon/session-state-file.ts +0 -108
  63. package/src/daemon/session-state.ts +0 -237
  64. package/src/daemon/socket-lease.ts +0 -209
  65. package/src/daemon/socket-ownership.ts +0 -209
  66. package/src/daemon/stats.ts +0 -235
  67. package/src/daemon/verbs/config-validators.ts +0 -250
  68. package/src/daemon/verbs/index.ts +0 -706
  69. package/src/daemon/verbs/state-validators.ts +0 -249
  70. package/src/daemon/verbs/validator-registry.ts +0 -457
  71. package/src/demo/dsl.ts +0 -143
  72. package/src/demo/mock-data.ts +0 -67
  73. package/src/demo/statusline.json5 +0 -94
  74. package/src/dsl/node-registry.ts +0 -374
  75. package/src/dsl/render.ts +0 -803
  76. package/src/help-text.ts +0 -90
  77. package/src/index.ts +0 -210
  78. package/src/install/currency.ts +0 -197
  79. package/src/install/index.ts +0 -557
  80. package/src/proc/launch.ts +0 -459
  81. package/src/proc/stats-handle.ts +0 -13
  82. package/src/render/action.ts +0 -883
  83. package/src/render/active-segment.ts +0 -78
  84. package/src/render/diagnostic-style.ts +0 -23
  85. package/src/render/diagnostic-text.ts +0 -77
  86. package/src/render/error-glyph.ts +0 -53
  87. package/src/render/menu.ts +0 -257
  88. package/src/render/outcome-plan.ts +0 -45
  89. package/src/render/picker.ts +0 -372
  90. package/src/render/segment-color.ts +0 -74
  91. package/src/render/split-lines.ts +0 -51
  92. package/src/render/strip.ts +0 -228
  93. package/src/segments/cache.ts +0 -131
  94. package/src/segments/context.ts +0 -190
  95. package/src/segments/git.ts +0 -1084
  96. package/src/segments/metrics.ts +0 -187
  97. package/src/segments/pricing.ts +0 -452
  98. package/src/segments/session.ts +0 -23
  99. package/src/segments/tmux.ts +0 -74
  100. package/src/template-engine/cells.ts +0 -90
  101. package/src/template-engine/colors.ts +0 -124
  102. package/src/template-engine/engine.ts +0 -108
  103. package/src/template-engine/funcs.ts +0 -232
  104. package/src/template-engine/index.ts +0 -11
  105. package/src/template-engine/layout.ts +0 -133
  106. package/src/template-engine/scope.ts +0 -62
  107. package/src/template-engine/sparkline.ts +0 -79
  108. package/src/themes/index.ts +0 -20
  109. package/src/themes/palette-resolvers.ts +0 -84
  110. package/src/themes/policy.ts +0 -393
  111. package/src/utils/cache.ts +0 -206
  112. package/src/utils/claude.ts +0 -683
  113. package/src/utils/color-support.ts +0 -118
  114. package/src/utils/formatters.ts +0 -99
  115. package/src/utils/logger.ts +0 -5
  116. package/src/utils/outcome.ts +0 -33
  117. package/src/utils/schema-validator.ts +0 -126
  118. package/src/utils/single-flight.ts +0 -57
  119. package/src/utils/terminal-width.ts +0 -51
  120. package/src/utils/terminal.ts +0 -11
  121. package/src/utils/transcript-fs.ts +0 -279
  122. package/src/var-system/index.ts +0 -24
  123. package/src/var-system/sources.ts +0 -1047
  124. package/src/var-system/store.ts +0 -223
  125. package/src/var-system/types.ts +0 -57
  126. package/src/version.ts +0 -17
@@ -1,714 +0,0 @@
1
- // [LAW:single-enforcer] All cross-reference resolution on the MERGED config:
2
- // layout nodes name declared segments, every template-bearing field references
3
- // only existing variables/actions, depends_on points at declared variables, and
4
- // state/set-action configs declare the session.id anchor. Runs after merge so a
5
- // user surface can reference default-provided segments/actions. This file changes
6
- // when the visibility/scoping rules between config parts change.
7
-
8
- import JSON5 from "json5";
9
- import {
10
- hasCacheField,
11
- walkNodes,
12
- type DslConfig,
13
- type LayoutNode,
14
- type PresetDecl,
15
- type VariableDecl,
16
- } from "../dsl-types.js";
17
- import {
18
- actionBindsPersist,
19
- actionBindsRedo,
20
- actionBindsReset,
21
- actionBindsSet,
22
- actionIsDual,
23
- PERSIST_WHEN,
24
- actionBindsUndo,
25
- type ActionDecl,
26
- } from "../action.js";
27
- import {
28
- knownOptionDomainNames,
29
- perConfigDomainsFor,
30
- } from "../option-domain.js";
31
- import { listGlobalsFieldNames } from "./globals.js";
32
- import { parsePersistTarget } from "./persist-target.js";
33
- import { presetNames } from "../presets.js";
34
- import {
35
- canHostSessionState,
36
- countAnchors,
37
- isSettingsAnchor,
38
- SESSION_ID_VAR,
39
- SETTINGS_ANCHOR,
40
- } from "../settings-menu.js";
41
- import { ident } from "../ident.js";
42
- import { findKeyLine } from "./diagnostics.js";
43
- import { isPlainObject, type ValidateCtx } from "./validate-core.js";
44
- import {
45
- extractActionRefs,
46
- extractPickerMenuRefs,
47
- extractTemplateRefs,
48
- refResolves,
49
- } from "./refs.js";
50
-
51
- // [LAW:one-source-of-truth] The renamed built-in segments: old name → current
52
- // name. A user config (which merges on top of the bundled default) that names a
53
- // renamed segment in `root` finds no matching declaration and would otherwise
54
- // get the generic "does not match any declared segment" error. This map turns
55
- // that into a migration pointer [LAW:no-silent-failure] — data, not a per-name
56
- // branch, so a future rename is one row here, not new control flow.
57
- export const RENAMED_SEGMENTS: Readonly<Record<string, string>> = {
58
- gitTaculous: "gitaculous",
59
- };
60
-
61
- // [LAW:single-enforcer] Runs HERE — on `cfg.presets`, the MERGED map — not
62
- // in loader/presets.ts's per-file structural pass (where a round-1 version
63
- // of this check lived): that pass validates one config source at a time
64
- // (the bundled default's own RAW_DEFAULT_DSL_CONFIG, or a user's file,
65
- // independently), so it could only ever catch a collision between two
66
- // preset names declared in ONE source. synthesizeEditChrome — the thing
67
- // this guard protects, which keys its per-preset reset action/segment (and
68
- // the pre-existing per-gap +/- actions) by `ident(presetName)` in a plain
69
- // object accumulator with no re-entrant cross-ref check — runs on the
70
- // MERGED config (dsl-loader.ts), so the collision it can actually produce
71
- // is a merged one: a user preset whose ident collides with a name the
72
- // BUNDLED library or a different file contributed. This is the one place
73
- // that sees that merged set, so it's the one place that can prove no
74
- // collision exists in it.
75
- //
76
- // [LAW:one-source-of-truth] `ident` is imported from ../ident.ts — the ONE
77
- // collapse rule menu-keys.ts, edit-chrome.ts, and this guard all now share,
78
- // so a future tweak to the rule can't silently desync the guard from the
79
- // thing it checks.
80
- //
81
- // [LAW:no-silent-failure] Two preset names that collapse to the SAME
82
- // synthesis identifier (e.g. "quick-look" and "quick_look" both → "quick_
83
- // look") would silently steal each other's synthesized artifacts: the
84
- // SECOND preset processed overwrites the first's entries, leaving the
85
- // first preset's already-built tree holding a segment ref to a name that
86
- // now points at the second preset's reset action. A user clicking "reset"
87
- // on preset A would silently reset preset B instead.
88
- function presetIdentCollisions(
89
- ctx: ValidateCtx,
90
- presets: Readonly<Record<string, PresetDecl>>,
91
- ): void {
92
- const byIdent = new Map<string, string[]>();
93
- for (const name of Object.keys(presets)) {
94
- const id = ident(name);
95
- const names = byIdent.get(id);
96
- if (names) names.push(name);
97
- else byIdent.set(id, [name]);
98
- }
99
- for (const [id, names] of byIdent) {
100
- if (names.length < 2) continue;
101
- ctx.issues.push({
102
- path: "presets",
103
- message: `preset names ${names.map((n) => JSON.stringify(n)).join(" and ")} both collapse to the same synthesis identifier "${id}" — edit mode's synthesized reset affordance would silently steal one preset's action for the other. Rename one.`,
104
- line: findKeyLine(ctx.source, ["presets"]),
105
- });
106
- }
107
- }
108
-
109
- export function validateCrossReferences(
110
- ctx: ValidateCtx,
111
- cfg: DslConfig,
112
- ): void {
113
- // [LAW:locality-or-seam] globals.look names a member of the MERGED looks
114
- // block (a user's default may be a bundled look — same reason every cross-ref
115
- // runs post-merge). Same existence-check shape as layout→segments; an unknown
116
- // name is a load error, never a silent identity fallback.
117
- if (
118
- cfg.globals.look !== undefined &&
119
- !Object.prototype.hasOwnProperty.call(cfg.looks, cfg.globals.look)
120
- ) {
121
- ctx.issues.push({
122
- path: "globals.look",
123
- message: `globals.look "${cfg.globals.look}" does not match any declared look (have: ${Object.keys(cfg.looks).join(", ")})`,
124
- line: findKeyLine(ctx.source, ["globals", "look"]),
125
- });
126
- }
127
- // [LAW:one-type-per-behavior] globals.preset is globals.look one dimension
128
- // over — the same post-merge membership check against the same kind of
129
- // per-config block, for the same reason (a user's default may name a
130
- // bundled preset). A typo'd DEFAULT is a load error even though a stale
131
- // SESSION pick collapses silently to the floor: the config file is authored
132
- // and re-readable, so naming a preset that does not exist is a mistake we can
133
- // point at; a session pick is a click made against a config that has since
134
- // changed, which is not [LAW:no-silent-failure].
135
- if (
136
- cfg.globals.preset !== undefined &&
137
- !Object.prototype.hasOwnProperty.call(cfg.presets, cfg.globals.preset)
138
- ) {
139
- ctx.issues.push({
140
- path: "globals.preset",
141
- message: `globals.preset "${cfg.globals.preset}" does not match any declared preset (have: ${Object.keys(cfg.presets).join(", ")})`,
142
- line: findKeyLine(ctx.source, ["globals", "preset"]),
143
- });
144
- }
145
- presetIdentCollisions(ctx, cfg.presets);
146
- // [LAW:one-source-of-truth] A `set … from` NAME must resolve — checked
147
- // against this config's per-config domains ("looks", the merged looks:
148
- // block) plus the global registry (themes/styles, and any future
149
- // registration), the SAME set resolveOptionDomain consults at render and
150
- // gate-derivation time. An inline array `from` is its own domain — nothing
151
- // to resolve. Runs post-merge for the same reason globals.look does above:
152
- // "looks" isn't fully known until the user's looks: block has merged onto
153
- // the bundled stdlib.
154
- const optionDomains = perConfigDomainsFor(cfg);
155
- for (const [name, a] of Object.entries(cfg.actions)) {
156
- if (!("set" in a) || !("from" in a) || typeof a.from !== "string") continue;
157
- if (!knownOptionDomainNames(optionDomains).includes(a.from)) {
158
- ctx.issues.push({
159
- path: `actions.${name}.from`,
160
- message: `actions.${name} from: references unknown option domain "${a.from}" (have: ${knownOptionDomainNames(optionDomains).join(", ")})`,
161
- line: findKeyLine(ctx.source, ["actions", name, "from"]),
162
- });
163
- }
164
- }
165
- // [LAW:no-silent-failure] A dual action's `persistWhen` must name a key some
166
- // `state` variable declares. The structural pass proves only that it is a
167
- // deliverable wire key; whether it RESOLVES is a cross-ref concern, exactly
168
- // as `from`'s domain is above.
169
- //
170
- // Without this, a typo loads perfectly cleanly and then does nothing
171
- // forever: compileDual falls back to the raw key name, activeDestination
172
- // reads it, finds nothing, and `parseSessionBoolean` answers null — which
173
- // means "session". So the checkbox the author wired to the real key flips a
174
- // value no control reads, the destination never changes, and there is no
175
- // error anywhere to explain why. A silently-permanent session write is the
176
- // worst possible shape for this failure, since it looks exactly like
177
- // working software.
178
- for (const [name, a] of Object.entries(cfg.actions)) {
179
- if (!actionIsDual(a)) continue;
180
- const selector = a[PERSIST_WHEN];
181
- if (!declaresStateKey(cfg, selector)) {
182
- ctx.issues.push({
183
- path: `actions.${name}.${PERSIST_WHEN}`,
184
- message: `actions.${name} ${PERSIST_WHEN}: "${selector}" is not a declared state key — a dual action's selector must name a { kind: "state", key: "${selector}" } variable, or the destination can never change`,
185
- line: findKeyLine(ctx.source, ["actions", name, PERSIST_WHEN]),
186
- });
187
- }
188
- }
189
- // [LAW:no-silent-failure] A `persist`/`reset` target must name a REAL
190
- // Globals field OR a declared segment's `palette` (candybar-config-engine-
191
- // 71o.6 — `segments.<name>.palette`, parsed by the one shared authority in
192
- // persist-target.ts) — the loader's structural pass (loader/actions.ts)
193
- // only proves the key is non-empty/slash-free, the same shape a `set` key
194
- // needs for the wire, but a persist/reset key additionally has to land
195
- // somewhere real. Catching a typo (`persist: "pallete"`) or a dangling
196
- // segment name here turns a confusing click-time "registration invariant
197
- // broken" error into a clear load-time one naming the actual allowed
198
- // targets — the same species of fix as the `from` domain check just above.
199
- for (const [name, a] of Object.entries(cfg.actions)) {
200
- const key = "persist" in a ? a.persist : "reset" in a ? a.reset : null;
201
- if (key === null) continue;
202
- const discriminator = "persist" in a ? "persist" : "reset";
203
- const target = parsePersistTarget(key);
204
- if (target === null) {
205
- ctx.issues.push({
206
- path: `actions.${name}.${discriminator}`,
207
- message: `actions.${name}: "${key}" is not a config globals field (have: ${listGlobalsFieldNames().join(", ")}), a "segments.<name>.palette" target, or a "presets.<name>.rootOps" target`,
208
- line: findKeyLine(ctx.source, ["actions", name, discriminator]),
209
- });
210
- continue;
211
- }
212
- if (target.scope === "preset-root-ops") {
213
- checkPresetRootOpsTarget(
214
- ctx,
215
- cfg,
216
- name,
217
- discriminator,
218
- key,
219
- target.preset,
220
- a,
221
- );
222
- continue;
223
- }
224
- if (target.scope !== "segment-palette") continue;
225
- if (!Object.prototype.hasOwnProperty.call(cfg.segments, target.segment)) {
226
- ctx.issues.push({
227
- path: `actions.${name}.${discriminator}`,
228
- message: `actions.${name}: "${key}" names segment "${target.segment}" which is not declared (have segments: ${Object.keys(cfg.segments).join(", ")})`,
229
- line: findKeyLine(ctx.source, ["actions", name, discriminator]),
230
- });
231
- continue;
232
- }
233
- // [LAW:types-are-the-program] A palette is a NAME, not a number — a
234
- // bounded stepper (`min`/`max`/`by`) has no meaning over it, unlike a
235
- // Globals field where nothing today enforces value/field-kind agreement
236
- // either way. Rejecting it here (rather than tolerating a numeric-string
237
- // palette name that only fails later at `paletteForThemeName`) keeps
238
- // the failure at load time, next to the typo it actually is.
239
- if ("min" in a) {
240
- ctx.issues.push({
241
- path: `actions.${name}.${discriminator}`,
242
- message: `actions.${name}: "${key}" is a segment palette target and cannot use a bounded stepper (min/max/by) — use "to", "from", or "cycle" instead`,
243
- line: findKeyLine(ctx.source, ["actions", name, discriminator]),
244
- });
245
- }
246
- }
247
- // [LAW:one-source-of-truth] THE set of resolvable variable names — a
248
- // faithful mirror of the runtime store's key set (declareOne in
249
- // src/dsl/render.ts registers globals under their bare names and segment
250
- // locals under segName.varName, nothing else). The runtime scope proxy
251
- // (src/template-engine/scope.ts) resolves only keys literally present in
252
- // the store, and the depends_on reaction (src/var-system/sources.ts) calls
253
- // store.read with each listed name verbatim — so exactly the names in this
254
- // set exist at runtime. One set for every reference surface, template refs
255
- // and depends_on lists alike: a name's meaning is a pure function of the
256
- // name string, never of which segment declares or renders it.
257
- const templateScope = new Set<string>(Object.keys(cfg.variables));
258
- for (const [segName, seg] of Object.entries(cfg.segments)) {
259
- if (!seg.vars) continue;
260
- for (const v of Object.keys(seg.vars)) templateScope.add(`${segName}.${v}`);
261
- }
262
-
263
- // [LAW:single-enforcer] ONE pre-order walk over the canonical node tree owns
264
- // every layout cross-ref: each cells node's segment names must resolve to a
265
- // declared segment, and any node's `when` predicate (a template like any
266
- // other) must reference only existing variables. Cross-ref runs on the MERGED
267
- // config so a node can name default-provided segments without re-declaring
268
- // them. It traverses the canonical tree — the raw `layout`-vs-`root` authoring
269
- // form is already collapsed and unrecoverable post-merge — so the path
270
- // describes the tree and `line` points at whichever layout key the user wrote.
271
- // [LAW:one-source-of-truth] Which top-level layout surface the user authored
272
- // is read from the PARSED structure, not a text probe: a nested key named
273
- // `root` (a variable, a segment) — or `layout` (a `time` var's `layout`
274
- // field) — would fool a raw `findKeyLine` search and misclassify the config.
275
- // Validation is cold-path, so reading the source's top-level keys is exact.
276
- // The reported path/message then point at the surface the user wrote.
277
- //
278
- // [LAW:one-type-per-behavior] A PRESET's `root` is a root: it gets this exact
279
- // walk, not a reduced copy. The only thing that varies between the config's
280
- // own tree and a preset's is the diagnostic key + line — data threaded in,
281
- // never a second traversal that could learn a different idea of what a valid
282
- // layout is. This is what makes `cc-candybar check` catch a preset staging a
283
- // segment nobody declared.
284
- //
285
- // [LAW:single-enforcer] The menu precondition is asked once, of the same
286
- // predicate synthesizeSettingsMenu gates on, and read by every tree walked.
287
- const menuWillSynthesize = canHostSessionState(cfg);
288
- const checkLayoutTree = (
289
- root: LayoutNode,
290
- layoutKey: string,
291
- layoutLine: number | undefined,
292
- ): void => {
293
- // [LAW:one-source-of-truth] The global settings menu's anchor is a POSITION
294
- // an author may place and this walk must therefore accept, even though no
295
- // config declares a segment by that name — synthesizeSettingsMenu provides
296
- // it unconditionally, immediately after these checks pass. Two placements is
297
- // the real error: one state key holds one open state, so a second anchor
298
- // would be a second toggle writing one disclosure, with two bodies claiming
299
- // to be it. Counted over the SAME census the synthesis reads, so "placed"
300
- // means one thing [LAW:single-enforcer].
301
- if (countAnchors(root) > 1) {
302
- ctx.issues.push({
303
- path: layoutKey,
304
- message: `${layoutKey} places the global settings menu anchor "${SETTINGS_ANCHOR}" ${countAnchors(root)} times — it may appear at most once per layout (it is one disclosure, and one state key holds one open state). Remove all but the placement you want; removing every placement puts the menu at its default position.`,
305
- line: layoutLine,
306
- });
307
- }
308
- for (const node of walkNodes(root)) {
309
- // [LAW:locality-or-seam] A node's `when` reads the global scope (bare
310
- // globals + namespaced segment vars) — the same existence-check shape as a
311
- // segment template, surfaced at load time.
312
- if (node.when !== undefined) {
313
- checkTemplateRefs(ctx, `${layoutKey}.when`, node.when, templateScope, {
314
- line: layoutLine,
315
- });
316
- }
317
- if (node.kind !== "segment") continue;
318
- if (isSettingsAnchor(node.name)) {
319
- // [LAW:one-source-of-truth] Accepting the anchor asserts that
320
- // synthesizeSettingsMenu WILL declare a segment by this name, so the
321
- // acceptance reads the very predicate that pass decides by rather than
322
- // assuming its answer. When it is false the reference is genuinely
323
- // dangling, and the error names the unmet precondition: the author
324
- // placed a documented anchor, they did not typo a segment name, and the
325
- // generic "does not match any declared segment" would teach the wrong
326
- // lesson [LAW:no-silent-failure].
327
- if (!menuWillSynthesize) {
328
- ctx.issues.push({
329
- path: layoutKey,
330
- message: `${layoutKey} places the global settings menu anchor "${SETTINGS_ANCHOR}", but this config declares no "${SESSION_ID_VAR}" variable — the menu is a click surface and every click composes a URL from "${SESSION_ID_VAR}", so it is not synthesized for a config without it. Declare a "${SESSION_ID_VAR}" variable (any config merged onto the bundled default inherits one) or remove the anchor placement.`,
331
- line: layoutLine,
332
- });
333
- }
334
- continue;
335
- }
336
- if (!Object.prototype.hasOwnProperty.call(cfg.segments, node.name)) {
337
- const renamed = RENAMED_SEGMENTS[node.name];
338
- const hint =
339
- renamed !== undefined
340
- ? ` (the built-in segment "${node.name}" was renamed to "${renamed}" — update this reference)`
341
- : "";
342
- ctx.issues.push({
343
- path: layoutKey,
344
- message: `${layoutKey} entry "${node.name}" does not match any declared segment${hint}`,
345
- line: layoutLine,
346
- });
347
- }
348
- }
349
- };
350
- const layoutKey = authoredLayoutKey(ctx.source);
351
- checkLayoutTree(cfg.root, layoutKey, findKeyLine(ctx.source, [layoutKey]));
352
- for (const [name, preset] of Object.entries(cfg.presets)) {
353
- if (preset.root === undefined) continue;
354
- checkLayoutTree(
355
- preset.root,
356
- `presets.${name}.root`,
357
- findKeyLine(ctx.source, ["presets", name, "root"]),
358
- );
359
- }
360
-
361
- // For each variable's template/cache.key, every dotted ref must exist
362
- // (full path OR a prefix that matches an existing variable's namespace).
363
- for (const [name, v] of Object.entries(cfg.variables)) {
364
- checkVarRefs(ctx, `variables.${name}`, v, templateScope);
365
- }
366
-
367
- for (const [segName, seg] of Object.entries(cfg.segments)) {
368
- // [LAW:one-source-of-truth] Segment templates check against the SAME
369
- // templateScope as everything else — segment locals resolve via the
370
- // namespaced segName.varName form only, exactly as the runtime store
371
- // keys them. The segment name is passed purely as a diagnostic hint: a
372
- // bare ref to an own local is rejected with a message naming the
373
- // namespaced form the author should write.
374
- if (seg.vars) {
375
- for (const [vName, vDecl] of Object.entries(seg.vars)) {
376
- checkVarRefs(
377
- ctx,
378
- `segments.${segName}.vars.${vName}`,
379
- vDecl,
380
- templateScope,
381
- segName,
382
- );
383
- }
384
- }
385
- // [LAW:locality-or-seam] Variable refs AND `{{ action }}`/`{{ picker }}` refs
386
- // are checked across EVERY template-bearing field, not just `template` —
387
- // bg/fg/when are templates too, so an unknown ref in them is a load error, not
388
- // a render-time surprise. Same existence-check shape as layout→segments; runs
389
- // on the merged config so a segment can reference a default-provided action.
390
- for (const field of ["template", "bg", "fg", "when"] as const) {
391
- const tpl = seg[field];
392
- if (typeof tpl !== "string") continue;
393
- checkTemplateRefs(
394
- ctx,
395
- `segments.${segName}.${field}`,
396
- tpl,
397
- templateScope,
398
- {
399
- segCtx: segName,
400
- },
401
- );
402
- // [LAW:locality-or-seam] `{{ action "name" … }}` refs resolve against the
403
- // action table on the merged config so a segment can reference a
404
- // default-provided action.
405
- for (const aref of extractActionRefs(tpl)) {
406
- if (!Object.prototype.hasOwnProperty.call(cfg.actions, aref)) {
407
- ctx.issues.push({
408
- path: `segments.${segName}.${field}`,
409
- message: `${field} references unknown action "${aref}"`,
410
- line: findKeyLine(ctx.source, ["segments", segName, field]),
411
- });
412
- }
413
- }
414
- // [LAW:locality-or-seam] A `{{ picker "apply" "page" … }}` OR `{{ menu
415
- // "apply" "page" … }}` references two named actions — both resolve against
416
- // the action table at load, same existence-check shape as a bare action ref.
417
- // A menu binds the same pair as a picker, so it routes through the SAME
418
- // check rather than failing only when the disclosure is opened.
419
- for (const pref of extractPickerMenuRefs(tpl)) {
420
- if (!Object.prototype.hasOwnProperty.call(cfg.actions, pref)) {
421
- ctx.issues.push({
422
- path: `segments.${segName}.${field}`,
423
- message: `${field} references unknown action "${pref}" (in a picker or menu)`,
424
- line: findKeyLine(ctx.source, ["segments", segName, field]),
425
- });
426
- }
427
- }
428
- }
429
- }
430
-
431
- // depends_on lists must point at declared variables — checked against the
432
- // SAME templateScope as template refs, since both resolve against the one
433
- // runtime store. The segment name is a diagnostic hint only, never a
434
- // resolution rule, exactly as for templates.
435
- for (const [name, v] of Object.entries(cfg.variables)) {
436
- checkDependsOn(ctx, `variables.${name}`, v, templateScope);
437
- }
438
- for (const [segName, seg] of Object.entries(cfg.segments)) {
439
- if (!seg.vars) continue;
440
- for (const [vName, vDecl] of Object.entries(seg.vars)) {
441
- checkDependsOn(
442
- ctx,
443
- `segments.${segName}.vars.${vName}`,
444
- vDecl,
445
- templateScope,
446
- segName,
447
- );
448
- }
449
- }
450
-
451
- // [LAW:verifiable-goals] state-kind variables have an implicit dependency
452
- // on the canonical session-id input variable. Same shape as the
453
- // depends_on / template-ref existence checks above — surface a missing
454
- // anchor at load time so the user fixes the config from a config-file
455
- // error message, not from a render-time ReferenceError.
456
- //
457
- // [LAW:types-are-the-program] Check against `cfg.variables` directly: the
458
- // accept/reject table for this predicate is "GLOBAL session.id declared".
459
- // A segment-local declaration named "session.id" registers at runtime as
460
- // `<seg>.session.id` and does NOT satisfy declareState's read of the
461
- // global `session.id` box.
462
- // [LAW:verifiable-goals] A widget `set` action composes a set-state click URL
463
- // whose first segment is `session.id` (read from the store at render). Without
464
- // a global session.id the URL is malformed and the daemon rejects the click
465
- // (requireSessionId is the single enforcer — it rejects empty/slash session
466
- // ids loudly, so there is no silent corruption; this surfaces the SAME
467
- // requirement at load instead of at first click). Same anchor + same shape as
468
- // the state-kind requirement above; OR them so either trigger fires it once.
469
- // [LAW:dataflow-not-control-flow] A `set` action composes a set-state click URL
470
- // whose first segment is session.id. OR it into the same requirement so an
471
- // actions-only config (no state vars) still demands the anchor it needs. A
472
- // picker's ✕/←/→/apply-close all go through `set` actions, so this covers them.
473
- if (
474
- (hasStateKind(cfg) || hasActionSetAction(cfg)) &&
475
- !Object.prototype.hasOwnProperty.call(cfg.variables, "session.id")
476
- ) {
477
- ctx.issues.push({
478
- path: "variables.session.id",
479
- message: `state reads and action set-writes require a global "session.id" variable (segment-local declarations do not satisfy this — declareState/set-state both read the global box; conventionally { kind: "input", path: "session_id" })`,
480
- line: findKeyLine(ctx.source, ["variables"]),
481
- });
482
- }
483
- }
484
-
485
- // [LAW:no-silent-failure] brandon-layout-edit-2gc.1's structural-edit target
486
- // check, one arm of the persist/reset key cross-ref above. Three things must
487
- // hold at load time, same spirit as the segment-palette check just above it:
488
- // the preset name must be real (mirrors globals.preset's check earlier in
489
- // this function), the arm pairing must make sense for this scope (only
490
- // removeSegment/insertSegment address a tree — a `to`/`from`/cycle/bounded
491
- // literal has no meaning as "the current op log"), and every segment name
492
- // the op names must be declared.
493
- function checkPresetRootOpsTarget(
494
- ctx: ValidateCtx,
495
- cfg: DslConfig,
496
- name: string,
497
- discriminator: "persist" | "reset",
498
- key: string,
499
- presetName: string,
500
- a: ActionDecl,
501
- ): void {
502
- const at = `actions.${name}.${discriminator}`;
503
- const line = findKeyLine(ctx.source, ["actions", name, discriminator]);
504
- if (!presetNames(cfg.presets).includes(presetName)) {
505
- ctx.issues.push({
506
- path: at,
507
- message: `actions.${name}: "${key}" names preset "${presetName}" which is not declared (have: ${presetNames(cfg.presets).join(", ")})`,
508
- line,
509
- });
510
- return;
511
- }
512
- // [LAW:one-source-of-truth] `reset` has no value-source arm to check — its
513
- // shape is a bare `{ reset: key }` — so the arm-pairing/segment checks
514
- // below are `persist`-only, exactly as the "reset" action's clean-slate
515
- // undo is meant to be: it clears the whole op log regardless of what wrote
516
- // it.
517
- if (discriminator === "reset") return;
518
- const hasRemove = "removeSegment" in a;
519
- const hasInsert = "insertSegment" in a;
520
- // [LAW:one-source-of-truth] brandon-layout-edit-2gc.3's domain-sourced
521
- // sibling — the segment name is picked at render, so only `anchor` (still
522
- // literal at author time) needs the declared-segment check below.
523
- const hasInsertFrom = "insertSegmentFrom" in a;
524
- if (!hasRemove && !hasInsert && !hasInsertFrom) {
525
- ctx.issues.push({
526
- path: at,
527
- message: `actions.${name}: "${key}" is a "presets.<name>.rootOps" target and can only be paired with "removeSegment", "insertSegment", or "insertSegmentFrom" (not "to"/"from"/"cycle"/bounded — those have no meaning as a tree op)`,
528
- line,
529
- });
530
- return;
531
- }
532
- const missing = (segName: string): boolean =>
533
- !Object.prototype.hasOwnProperty.call(cfg.segments, segName);
534
- if (hasRemove && "removeSegment" in a && missing(a.removeSegment)) {
535
- ctx.issues.push({
536
- path: at,
537
- message: `actions.${name}: removeSegment "${a.removeSegment}" is not a declared segment (have: ${Object.keys(cfg.segments).join(", ")})`,
538
- line,
539
- });
540
- }
541
- if (hasInsert && "insertSegment" in a) {
542
- if (missing(a.insertSegment)) {
543
- ctx.issues.push({
544
- path: at,
545
- message: `actions.${name}: insertSegment "${a.insertSegment}" is not a declared segment (have: ${Object.keys(cfg.segments).join(", ")})`,
546
- line,
547
- });
548
- }
549
- if (missing(a.anchor)) {
550
- ctx.issues.push({
551
- path: at,
552
- message: `actions.${name}: anchor "${a.anchor}" is not a declared segment (have: ${Object.keys(cfg.segments).join(", ")})`,
553
- line,
554
- });
555
- }
556
- }
557
- if (hasInsertFrom && "insertSegmentFrom" in a && missing(a.anchor)) {
558
- ctx.issues.push({
559
- path: at,
560
- message: `actions.${name}: anchor "${a.anchor}" is not a declared segment (have: ${Object.keys(cfg.segments).join(", ")})`,
561
- line,
562
- });
563
- }
564
- }
565
-
566
- // [LAW:one-source-of-truth] Every `state` variable a config declares, in BOTH
567
- // scopes — global `variables` and each segment's own `vars`. A segment-local
568
- // state variable is fully legitimate: src/dsl/render.ts's stateKeyToVar
569
- // registers them (as `<segment>.<var>`) and it is the map a dual's selector is
570
- // resolved through at render, so a load-time check that scanned only the
571
- // global scope would reject configs that work.
572
- function stateVars(cfg: DslConfig): VariableDecl[] {
573
- return [
574
- ...Object.values(cfg.variables),
575
- ...Object.values(cfg.segments).flatMap((seg) =>
576
- Object.values(seg.vars ?? {}),
577
- ),
578
- ].filter((v) => v.kind === "state");
579
- }
580
-
581
- function hasStateKind(cfg: DslConfig): boolean {
582
- return stateVars(cfg).length > 0;
583
- }
584
-
585
- // Does any declared `state` variable hold this key? The question a dual's
586
- // `persistWhen` selector has to answer, asked over the same two scopes.
587
- function declaresStateKey(cfg: DslConfig, key: string): boolean {
588
- return stateVars(cfg).some((v) => v.kind === "state" && v.key === key);
589
- }
590
-
591
- // [LAW:dataflow-not-control-flow] A config emits a set-state, set-config,
592
- // reset-config, undo, OR redo click — and so needs session.id — when any
593
- // declared action is a `set` (literal/option/bounded/cycle), a `persist`
594
- // (its config-overrides twin), a `reset` (persist's gated undo), or an
595
- // `undo`/`redo` (the overrides layer's global history step) — all five
596
- // carry session.id on the wire for click-error surfacing (an empty history
597
- // stack is a loud, session-scoped miss, not a silent no-op). copy/open
598
- // actions write nothing, so they embed no session.id.
599
- function hasActionSetAction(cfg: DslConfig): boolean {
600
- return Object.values(cfg.actions).some(
601
- (a) =>
602
- actionBindsSet(a) ||
603
- actionBindsPersist(a) ||
604
- actionBindsReset(a) ||
605
- actionBindsUndo(a) ||
606
- actionBindsRedo(a),
607
- );
608
- }
609
-
610
- function checkVarRefs(
611
- ctx: ValidateCtx,
612
- declPath: string,
613
- v: VariableDecl,
614
- allVars: Set<string>,
615
- segCtx?: string,
616
- ): void {
617
- if (v.kind === "template") {
618
- checkTemplateRefs(ctx, `${declPath}.template`, v.template, allVars, {
619
- segCtx,
620
- });
621
- }
622
- if (hasCacheField(v)) {
623
- if (v.cache && "key" in v.cache) {
624
- checkTemplateRefs(ctx, `${declPath}.cache.key`, v.cache.key, allVars, {
625
- segCtx,
626
- });
627
- }
628
- }
629
- }
630
-
631
- function checkDependsOn(
632
- ctx: ValidateCtx,
633
- declPath: string,
634
- v: VariableDecl,
635
- allVars: Set<string>,
636
- segCtx?: string,
637
- ): void {
638
- if (!hasCacheField(v)) return;
639
- if (!v.cache) return;
640
- if (!("depends_on" in v.cache)) return;
641
- for (let i = 0; i < v.cache.depends_on.length; i++) {
642
- const target = v.cache.depends_on[i]!;
643
- // [LAW:one-source-of-truth] Exact membership, not refResolves: the
644
- // depends_on reaction calls store.read(name) with each listed name
645
- // verbatim, and the store is an exact-key map. A dotted prefix that
646
- // merely navigates INTO a value (resolvable in a template) is not a
647
- // store key and would throw at runtime.
648
- if (allVars.has(target)) continue;
649
- const namespaced = segCtx !== undefined ? `${segCtx}.${target}` : undefined;
650
- const hint =
651
- namespaced !== undefined && allVars.has(namespaced)
652
- ? ` (segment-local vars are namespaced — write "${namespaced}")`
653
- : "";
654
- ctx.issues.push({
655
- path: `${declPath}.cache.depends_on[${i}]`,
656
- message: `cache.depends_on references unknown variable "${target}"${hint}`,
657
- line: findKeyLine(ctx.source, [
658
- ...declPath.split("."),
659
- "cache",
660
- "depends_on",
661
- ]),
662
- });
663
- }
664
- }
665
-
666
- function checkTemplateRefs(
667
- ctx: ValidateCtx,
668
- declPath: string,
669
- template: string,
670
- allVars: Set<string>,
671
- opts?: {
672
- // [LAW:one-source-of-truth] Callers whose `declPath` is not a literal key
673
- // path into the source (a node `when`, whose canonical tree position no
674
- // longer maps to a source key after the layout/root merge) pass the
675
- // already-resolved line explicitly. Absent, the line is derived from the
676
- // dotted declPath as before.
677
- line?: number;
678
- // The segment whose template is being checked — a diagnostic hint only,
679
- // never a resolution rule. When a failing bare ref would resolve under
680
- // this segment's namespace, the message names the namespaced form.
681
- segCtx?: string;
682
- },
683
- ): void {
684
- for (const ref of extractTemplateRefs(template)) {
685
- if (refResolves(ref, allVars)) continue;
686
- const namespaced =
687
- opts?.segCtx !== undefined ? `${opts.segCtx}.${ref}` : undefined;
688
- const hint =
689
- namespaced !== undefined && refResolves(namespaced, allVars)
690
- ? ` (segment-local vars are namespaced — write ".${namespaced}")`
691
- : "";
692
- ctx.issues.push({
693
- path: declPath,
694
- message: `Template references unknown variable ".${ref}"${hint}`,
695
- line: opts?.line ?? findKeyLine(ctx.source, declPath.split(".")),
696
- });
697
- }
698
- }
699
-
700
- // [LAW:one-source-of-truth] The authored top-level layout surface, read from the
701
- // PARSED top-level keys (`root` wins; the loader already rejects authoring both).
702
- // A structural read — not a text search — so a nested key named `root`/`layout`
703
- // can never misclassify the config. Empty/unparseable source (the bundled
704
- // default, no file) has no surface; defaults to the historical `layout` label.
705
- function authoredLayoutKey(source: string): "root" | "layout" {
706
- try {
707
- const parsed = JSON5.parse(source);
708
- if (isPlainObject(parsed) && "root" in parsed) return "root";
709
- } catch {
710
- // No source to read (default config) or unparseable — fall through. A real
711
- // syntax error is already reported by parseDslConfig before cross-ref runs.
712
- }
713
- return "layout";
714
- }