@promptctl/cc-candybar 1.37.0 → 1.39.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.
@@ -20,6 +20,7 @@
20
20
 
21
21
  import {
22
22
  ACTION_KEYS,
23
+ PERSIST_WHEN,
23
24
  type ActionDecl,
24
25
  type ActionKey,
25
26
  type OptionDomain,
@@ -103,6 +104,16 @@ function validateActionDecl(
103
104
  );
104
105
  return null;
105
106
  }
107
+ // [LAW:dataflow-not-control-flow] A dual-destination action
108
+ // (candybar-settings-ui-aok.3) carries BOTH `set` and `persist`, so it
109
+ // cannot be reached through the exactly-one-of eliminator below —
110
+ // `persistWhen` is its own discriminator, and its presence selects the arm
111
+ // exactly as the presence of `set` selects that one. The dual arm owns its
112
+ // own siblings (the two destination keys plus its value source), like every
113
+ // other arm here.
114
+ if (PERSIST_WHEN in raw) {
115
+ return valueSourceAction(ctx, path, raw, "dual", DUAL_ARMS);
116
+ }
106
117
  const present = (ACTION_KEYS as readonly string[]).filter((k) => k in raw);
107
118
  if (present.length !== 1) {
108
119
  issue(
@@ -124,24 +135,9 @@ function validateActionDecl(
124
135
  // sibling. The present key indexes this map — the eliminator never branches on
125
136
  // the key name.
126
137
  const ACTION_ARMS: Record<ActionKey, ArmParse<ActionDecl>> = {
127
- set: (ctx, path, raw) =>
128
- valueSourceAction(
129
- ctx,
130
- path,
131
- raw,
132
- "set",
133
- SET_ARMS,
134
- "the SessionState key to write",
135
- ),
138
+ set: (ctx, path, raw) => valueSourceAction(ctx, path, raw, "set", SET_ARMS),
136
139
  persist: (ctx, path, raw) =>
137
- valueSourceAction(
138
- ctx,
139
- path,
140
- raw,
141
- "persist",
142
- PERSIST_ARMS,
143
- "the config globals field to write",
144
- ),
140
+ valueSourceAction(ctx, path, raw, "persist", PERSIST_ARMS),
145
141
  copy: templateArm("copy"),
146
142
  open: templateArm("open"),
147
143
  reset: resetArm,
@@ -169,6 +165,7 @@ function actionDeclJson(): JsonNode {
169
165
  anyOf: [
170
166
  ...SET_ARMS.map((arm) => arm.json),
171
167
  ...PERSIST_ARMS.map((arm) => arm.json),
168
+ ...DUAL_ARMS.map((arm) => arm.json),
172
169
  templateArmJson("copy"),
173
170
  templateArmJson("open"),
174
171
  templateArmJson("reset"),
@@ -305,37 +302,100 @@ function slashFreeString(
305
302
  return v;
306
303
  }
307
304
 
308
- // [LAW:dataflow-not-control-flow] The discriminator key ("set" or "persist")
309
- // is validated once for every value source (it is shared across all arms of
310
- // that discriminator), before the source is detected — so a bad key and an
311
- // ambiguous source both surface in one pass. It is therefore NOT a field of
305
+ // [LAW:types-are-the-program] Which KEYS a value-source action carries beside
306
+ // its value source, as data — one for a single-destination `set`/`persist`,
307
+ // three for a `dual` (both destination keys plus the selector naming which is
308
+ // written). Every consumer below (the key validation, the unknown-key
309
+ // allow-list, the emitted JSON schema, the reconstructed member) reads this
310
+ // one table, so adding the dual arm never meant a second dispatcher: the
311
+ // discriminator stopped being ONE key and became a LIST of them, and the
312
+ // existing machinery folds over the list [LAW:dataflow-not-control-flow].
313
+ type Discriminator = "set" | "persist" | "dual";
314
+
315
+ const DISCRIMINATOR_KEYS: Readonly<
316
+ Record<Discriminator, ReadonlyArray<readonly [string, string]>>
317
+ > = {
318
+ set: [["set", "the SessionState key to write"]],
319
+ persist: [["persist", "the config globals field to write"]],
320
+ dual: [
321
+ ["set", "the SessionState key written while persistWhen is off"],
322
+ ["persist", "the config globals field written while persistWhen is on"],
323
+ [
324
+ PERSIST_WHEN,
325
+ "the SessionState key whose boolean value chooses the destination",
326
+ ],
327
+ ],
328
+ };
329
+
330
+ // [LAW:dataflow-not-control-flow] The discriminator keys are validated once
331
+ // for every value source (they are shared across all arms of that
332
+ // discriminator), before the source is detected — so a bad key and an
333
+ // ambiguous source both surface in one pass. They are therefore NOT fields of
312
334
  // any arm's `fields` map; the arm parses only the value-source payload, and
313
- // the dispatcher re-attaches the discriminator.
314
- function validateValueSourceKey(
335
+ // the dispatcher re-attaches them.
336
+ //
337
+ // [LAW:no-silent-failure] Returns null when ANY key fails, after reporting
338
+ // every one of them — the caller threads that null exactly as it threads a
339
+ // failed payload, so a partly-valid dual never reconstructs into a member
340
+ // missing a destination.
341
+ function validateDiscriminatorKeys(
315
342
  ctx: ValidateCtx,
316
343
  path: string,
317
344
  raw: Record<string, unknown>,
318
- discriminator: "set" | "persist",
319
- keyNoun: string,
320
- ): string | null {
321
- return slashFreeString(
322
- ctx,
323
- path,
324
- discriminator,
325
- raw,
326
- `${discriminator} key must be non-empty (${keyNoun})`,
327
- (v) => `${discriminator} key "${v}" contains "/" — keys must be slash-free`,
328
- );
345
+ discriminator: Discriminator,
346
+ ): Record<string, string> | null {
347
+ const out: Record<string, string> = {};
348
+ const keys = DISCRIMINATOR_KEYS[discriminator];
349
+ let ok = true;
350
+ for (const [key, noun] of keys) {
351
+ // [LAW:no-silent-failure] An ABSENT key gets the shape, not a type
352
+ // mismatch. A single-destination arm cannot reach this (its key is the
353
+ // discriminator that selected the arm), so this only ever fires on a dual
354
+ // that named one destination and not the other — where "persist must be a
355
+ // string, got undefined" describes the symptom and teaches nothing, and
356
+ // the author needs to be told the three keys travel together.
357
+ if (!(key in raw)) {
358
+ issue(
359
+ ctx,
360
+ path,
361
+ `${key} is required here (${noun}) — a dual-destination action declares ${keys
362
+ .map(([k]) => k)
363
+ .join(", ")} together, plus one value source`,
364
+ );
365
+ ok = false;
366
+ continue;
367
+ }
368
+ const value = slashFreeString(
369
+ ctx,
370
+ path,
371
+ key,
372
+ raw,
373
+ `${key} key must be non-empty (${noun})`,
374
+ (v) => `${key} key "${v}" contains "/" — keys must be slash-free`,
375
+ );
376
+ if (value === null) {
377
+ ok = false;
378
+ continue;
379
+ }
380
+ out[key] = value;
381
+ }
382
+ // [LAW:no-silent-failure] Every failing key is reported before returning, so
383
+ // an author who omits two of a dual's three keys sees both in one pass —
384
+ // matching every other multi-issue check in this file, and matching what the
385
+ // comment above promises.
386
+ return ok ? out : null;
329
387
  }
330
388
 
331
389
  // [LAW:one-source-of-truth] The wire verb name a discriminator's writes
332
390
  // travel over — `set-state` for `set` (SessionState), `set-config` for
333
- // `persist` (the config-overrides layer). Threaded into the shared field
334
- // specs below so their "cannot be delivered on the X wire" messages name
335
- // the wire the value actually crosses, and the field/value noun ("set
391
+ // `persist` (the config-overrides layer), and BOTH for a dual, whose one
392
+ // value crosses whichever wire the selector names. Threaded into the shared
393
+ // field specs below so their "cannot be delivered on the X wire" messages
394
+ // name the wire the value actually crosses, and the field/value noun ("set
336
395
  // value" / "persist value") names the actual action kind, not always `set`.
337
- function wireName(discriminator: "set" | "persist"): string {
338
- return discriminator === "set" ? "set-state" : "set-config";
396
+ function wireName(discriminator: Discriminator): string {
397
+ if (discriminator === "set") return "set-state";
398
+ return discriminator === "persist" ? "set-config" : "set-state/set-config";
339
399
  }
340
400
 
341
401
  // [LAW:types-are-the-program] Each value source's payload as a field map — the
@@ -366,6 +426,15 @@ const INT_FIELDS: FieldSpecMap<{ int: true }> = { int: intMarkerSpec() };
366
426
  const CYCLE_FIELDS_SET: FieldSpecMap<{ cycle: readonly string[] }> = {
367
427
  cycle: cycleSpec("set"),
368
428
  };
429
+ const TO_FIELDS_DUAL: FieldSpecMap<{ to: string }> = {
430
+ to: setLiteralSpec("dual"),
431
+ };
432
+ const FROM_FIELDS_DUAL: FieldSpecMap<{ from: OptionDomain }> = {
433
+ from: fromSpec("dual"),
434
+ };
435
+ const CYCLE_FIELDS_DUAL: FieldSpecMap<{ cycle: readonly string[] }> = {
436
+ cycle: cycleSpec("dual"),
437
+ };
369
438
  const CYCLE_FIELDS_PERSIST: FieldSpecMap<{ cycle: readonly string[] }> = {
370
439
  cycle: cycleSpec("persist"),
371
440
  };
@@ -466,13 +535,14 @@ interface ValueSourceArm {
466
535
  // discriminator (the field no sibling arm carries) here; omit it when the
467
536
  // field set already is disjoint from every sibling, as it is everywhere else.
468
537
  function valueSourceArm<P extends object>(
469
- discriminator: "set" | "persist",
538
+ discriminator: Discriminator,
470
539
  fieldMap: FieldSpecMap<P>,
471
540
  checks: ReadonlyArray<Refinement<P>> = [],
472
541
  detectKeys?: readonly string[],
473
542
  ): ValueSourceArm {
474
543
  const fullKeys = Object.keys(fieldMap);
475
544
  const detect = detectKeys ?? fullKeys;
545
+ const keys = DISCRIMINATOR_KEYS[discriminator].map(([k]) => k);
476
546
  const inner: ArmParse<P> = (ctx, path, raw) =>
477
547
  fields(ctx, fieldMap, path, raw);
478
548
  const source = objectJson(fieldMap) as {
@@ -481,12 +551,15 @@ function valueSourceArm<P extends object>(
481
551
  };
482
552
  return {
483
553
  detect,
484
- allowed: [discriminator, ...fullKeys],
554
+ allowed: [...keys, ...fullKeys],
485
555
  label: fullKeys.join("/"),
486
556
  json: {
487
557
  type: "object",
488
- properties: { [discriminator]: { type: "string" }, ...source.properties },
489
- required: [discriminator, ...(source.required ?? [])],
558
+ properties: {
559
+ ...Object.fromEntries(keys.map((k) => [k, { type: "string" }])),
560
+ ...source.properties,
561
+ },
562
+ required: [...keys, ...(source.required ?? [])],
490
563
  additionalProperties: false,
491
564
  },
492
565
  parse: (checks.length
@@ -531,12 +604,25 @@ const PERSIST_ARMS: readonly ValueSourceArm[] = [
531
604
  ),
532
605
  ];
533
606
 
607
+ // [LAW:one-type-per-behavior] A dual declares any value source BOTH
608
+ // destinations share — `set` minus `int` (a page cursor has no durable
609
+ // meaning), which is also `persist` minus its structural-edit arms (those are
610
+ // persist-only by design, so they have no destination to choose between).
611
+ // The field maps are the SET ones with dual wording, so a dual's value obeys
612
+ // exactly the shape a `set` and a `persist` of that source each obey.
613
+ const DUAL_ARMS: readonly ValueSourceArm[] = [
614
+ valueSourceArm("dual", TO_FIELDS_DUAL),
615
+ valueSourceArm("dual", FROM_FIELDS_DUAL),
616
+ valueSourceArm("dual", BOUNDED_FIELDS, [minLessThanMax, byNonZero]),
617
+ valueSourceArm("dual", CYCLE_FIELDS_DUAL),
618
+ ];
619
+
534
620
  // [LAW:one-source-of-truth] The clause list, not the joined string, is the
535
621
  // data that varies per discriminator — the "or" belongs on the LAST clause
536
622
  // only, and which clause is last differs between `set` (ends at cycle) and
537
623
  // `persist` (ends at insertSegment), so building a list and joining it is
538
624
  // what keeps that placement correct without a second copy of the sentence.
539
- function valueSourceClauses(discriminator: "set" | "persist"): string[] {
625
+ function valueSourceClauses(discriminator: Discriminator): string[] {
540
626
  const clauses = [
541
627
  `"to" (a literal value)`,
542
628
  `"from" (an option domain — a registered domain name like "themes"/"styles"/"looks", or an inline array of literal values)`,
@@ -555,7 +641,7 @@ function valueSourceClauses(discriminator: "set" | "persist"): string[] {
555
641
  return clauses;
556
642
  }
557
643
 
558
- function VALUE_SOURCE_MESSAGE(discriminator: "set" | "persist"): string {
644
+ function VALUE_SOURCE_MESSAGE(discriminator: Discriminator): string {
559
645
  const clauses = valueSourceClauses(discriminator);
560
646
  const last = clauses[clauses.length - 1]!;
561
647
  const list =
@@ -576,17 +662,10 @@ function valueSourceAction(
576
662
  ctx: ValidateCtx,
577
663
  path: string,
578
664
  raw: Record<string, unknown>,
579
- discriminator: "set" | "persist",
665
+ discriminator: Discriminator,
580
666
  arms: readonly ValueSourceArm[],
581
- keyNoun: string,
582
667
  ): ActionDecl | null {
583
- const stateKey = validateValueSourceKey(
584
- ctx,
585
- path,
586
- raw,
587
- discriminator,
588
- keyNoun,
589
- );
668
+ const keys = validateDiscriminatorKeys(ctx, path, raw, discriminator);
590
669
 
591
670
  const present = arms.filter((arm) => arm.detect.some((k) => k in raw));
592
671
  if (present.length !== 1) {
@@ -613,9 +692,9 @@ function valueSourceAction(
613
692
  }
614
693
 
615
694
  const payload = arm.parse(ctx, path, raw);
616
- return stateKey === null || payload === null
695
+ return keys === null || payload === null
617
696
  ? null
618
- : ({ [discriminator]: stateKey, ...payload } as unknown as ActionDecl);
697
+ : ({ ...keys, ...payload } as unknown as ActionDecl);
619
698
  }
620
699
 
621
700
  // [LAW:no-silent-fallbacks] A literal `to` and the discriminator key share
@@ -624,7 +703,7 @@ function valueSourceAction(
624
703
  // arm's, the shape is the shared enforcer's. Built once per discriminator
625
704
  // (see TO_FIELDS_SET/TO_FIELDS_PERSIST) so a `persist` action's message names
626
705
  // "persist value" and the set-config wire, never `set`'s wording.
627
- function setLiteralSpec(discriminator: "set" | "persist"): FieldSpec<string> {
706
+ function setLiteralSpec(discriminator: Discriminator): FieldSpec<string> {
628
707
  const wire = wireName(discriminator);
629
708
  return {
630
709
  required: true,
@@ -654,7 +733,7 @@ function setLiteralSpec(discriminator: "set" | "persist"): FieldSpec<string> {
654
733
  // symmetric to how a layout node's segment ref or a `{{ action }}` ref
655
734
  // resolves post-merge. Built once per discriminator, same reason as
656
735
  // setLiteralSpec.
657
- function fromSpec(discriminator: "set" | "persist"): FieldSpec<OptionDomain> {
736
+ function fromSpec(discriminator: Discriminator): FieldSpec<OptionDomain> {
658
737
  const wire = wireName(discriminator);
659
738
  return {
660
739
  required: true,
@@ -733,9 +812,7 @@ function fromSpec(discriminator: "set" | "persist"): FieldSpec<OptionDomain> {
733
812
  // duplicated member is ambiguous). Members double as the derived allow-list
734
813
  // gate, so a member this spec admits is a value the wire delivers, by
735
814
  // construction. Built once per discriminator, same reason as setLiteralSpec.
736
- function cycleSpec(
737
- discriminator: "set" | "persist",
738
- ): FieldSpec<readonly string[]> {
815
+ function cycleSpec(discriminator: Discriminator): FieldSpec<readonly string[]> {
739
816
  const wire = wireName(discriminator);
740
817
  return {
741
818
  required: true,
@@ -19,6 +19,8 @@ import {
19
19
  actionBindsRedo,
20
20
  actionBindsReset,
21
21
  actionBindsSet,
22
+ actionIsDual,
23
+ PERSIST_WHEN,
22
24
  actionBindsUndo,
23
25
  type ActionDecl,
24
26
  } from "../action.js";
@@ -160,6 +162,30 @@ export function validateCrossReferences(
160
162
  });
161
163
  }
162
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
+ }
163
189
  // [LAW:no-silent-failure] A `persist`/`reset` target must name a REAL
164
190
  // Globals field OR a declared segment's `palette` (candybar-config-engine-
165
191
  // 71o.6 — `segments.<name>.palette`, parsed by the one shared authority in
@@ -537,17 +563,29 @@ function checkPresetRootOpsTarget(
537
563
  }
538
564
  }
539
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
+
540
581
  function hasStateKind(cfg: DslConfig): boolean {
541
- for (const v of Object.values(cfg.variables)) {
542
- if (v.kind === "state") return true;
543
- }
544
- for (const seg of Object.values(cfg.segments)) {
545
- if (!seg.vars) continue;
546
- for (const v of Object.values(seg.vars)) {
547
- if (v.kind === "state") return true;
548
- }
549
- }
550
- return false;
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);
551
589
  }
552
590
 
553
591
  // [LAW:dataflow-not-control-flow] A config emits a set-state, set-config,
@@ -7,6 +7,7 @@ import { type Globals } from "../dsl-types.js";
7
7
  import {
8
8
  CHARSETS,
9
9
  COLOR_COMPATIBILITIES,
10
+ DEFAULT_PADDING,
10
11
  PADDING_RANGE,
11
12
  STRIP_STYLES,
12
13
  type ColorCompatibility,
@@ -193,3 +194,42 @@ export function isGlobalsField(key: string): key is keyof Globals {
193
194
  export function listGlobalsFieldNames(): readonly string[] {
194
195
  return [...GLOBALS_FIELD_NAMES];
195
196
  }
197
+
198
+ // [LAW:types-are-the-program] Every NUMERIC globals field with the value that
199
+ // renders when a config declares none, typed TOTAL over those fields — so
200
+ // adding a numeric globals field without a floor is a COMPILE error, not a
201
+ // silent seed-from-`min` (the bug this table exists to prevent, recurring for
202
+ // the new field). It lives here rather than beside DEFAULT_PADDING in
203
+ // themes/policy.ts because it must name `Globals`, and policy.ts is the leaf
204
+ // dsl-types imports FROM — a table that needs both belongs on this side of
205
+ // that edge [LAW:one-way-deps].
206
+ // The direction matters: `NonNullable<Globals[K]> extends number` asks "is this
207
+ // field's type a number", which is what the totality claim needs. The mirror
208
+ // (`number extends …`) happens to agree for a field declared as plain `number`
209
+ // — the two are symmetric there — but silently drops a field typed as a
210
+ // literal union (`zoom?: 1 | 2 | 3`), which is a perfectly ordinary way to
211
+ // declare a bounded numeric setting and exactly the case this guard exists for.
212
+ type NumericGlobalsField = {
213
+ [K in keyof Globals]-?: NonNullable<Globals[K]> extends number ? K : never;
214
+ }[keyof Globals];
215
+
216
+ const NUMERIC_GLOBALS_FLOORS: Readonly<Record<NumericGlobalsField, number>> = {
217
+ padding: DEFAULT_PADDING,
218
+ };
219
+
220
+ // [LAW:single-enforcer] THE seed for a bounded stepper over a globals field:
221
+ // what the bar renders with no write of any kind — the config's own value, or
222
+ // the field's floor when it declares none. Both write gates read it (the
223
+ // SessionState one through stateKeySeeds, the config-overrides one through
224
+ // configKeySeeds), so a session stepper and its durable twin can never start
225
+ // from different numbers, and neither can silently start from `min`.
226
+ export function numericGlobalsSeeds(
227
+ globals: Globals,
228
+ ): ReadonlyMap<string, number> {
229
+ const seeds = new Map<string, number>();
230
+ for (const [field, floor] of Object.entries(NUMERIC_GLOBALS_FLOORS)) {
231
+ const declared = globals[field as keyof Globals];
232
+ seeds.set(field, typeof declared === "number" ? declared : floor);
233
+ }
234
+ return seeds;
235
+ }
@@ -42,6 +42,7 @@ import {
42
42
  type MenuOptions,
43
43
  } from "../menu-keys.js";
44
44
  import {
45
+ cycleDisplayIssue,
45
46
  DISCLOSURE_CLOSED,
46
47
  disclosureCycleAction,
47
48
  disclosureStateVar,
@@ -60,13 +61,16 @@ import { reservedNamespaceCollisions } from "./reserved-namespace.js";
60
61
  const MENU_FUNC = "menu";
61
62
 
62
63
  // [LAW:types-are-the-program] The `{{ menu }}` surface, mirroring the render
63
- // helper's signature `menu "apply" [(dict …)]`: the apply name (identity member,
64
- // a required string literal) and ONE optional trailing options dict —
65
- // closeOnPick / paged / key, all statically readable via `staticDictEntries`.
66
- // The removed positional tail (page-action string, bare bools, 5th-arg key) is
67
- // detected and rejected with a migration-pointing error, never silently
64
+ // helper's signature `menu "apply" display… [(dict …)]`: the apply name
65
+ // (identity member, a required string literal), the trigger's authored display
66
+ // text (one per state or one static — the arity is statically countable, so it
67
+ // is checked here), and ONE optional trailing options dict — closeOnPick /
68
+ // paged / key, all statically readable via `staticDictEntries`. Displays
69
+ // themselves are NOT required to be literals; identity does not depend on
70
+ // them, exactly as a cycle `{{ action }}`'s displays are free. Every removed
71
+ // spelling is rejected with a migration-pointing error, never silently
68
72
  // reinterpreted [LAW:no-silent-failure].
69
- const MIGRATION = `the positional tail ("pageAction" closeOnPick paged "key") was removed: the page cursor is now synthesized from the menu's identity, and rare knobs are named options in ONE trailing dict — write {{ menu "applyTheme" }} or {{ menu "applyTheme" (dict "closeOnPick" true "paged" false "key" "pickers") }} (defaults: closeOnPick false, paged true, no key)`;
73
+ const MIGRATION = `a menu binds its trigger text the way a cycle action binds a display — write {{ menu "applyTheme" "▸" "▾" }} (one per state) or {{ menu "insertHere" "+" }} (one static display for both), with the rare knobs in ONE trailing dict: {{ menu "applyTheme" "▸" "▾" (dict "closeOnPick" true "paged" false "key" "pickers") }} (defaults: closeOnPick false, paged true, no key). The renderer no longer appends ▸/▾ of its own (candybar-settings-ui-aok.4), and the older positional tail ("pageAction" closeOnPick paged "key") was removed — the page cursor is synthesized from the menu's identity`;
70
74
 
71
75
  // [LAW:dataflow-not-control-flow] One total analysis of a `{{ menu }}` call site:
72
76
  // every reachable argument shape lands in exactly one arm — a usable identity
@@ -80,33 +84,80 @@ type MenuAnalysis =
80
84
  }
81
85
  | { readonly kind: "issue"; readonly message: string };
82
86
 
87
+ type ArgExpr = ReferencedCall["argExprs"][number];
88
+
89
+ const isDictCall = (e: ArgExpr): boolean =>
90
+ e.kind === "call" && e.name === "dict";
91
+
92
+ // [LAW:one-source-of-truth] The two sides split the tail on different evidence —
93
+ // exprs here, evaluated values in `parseMenuArgs` — so the loader admits only
94
+ // call sites where those two readings PROVABLY coincide. The renderer's split
95
+ // asks one question of the last value, "is it an object", so a literal answers
96
+ // it here: a parse-time constant evaluates to itself and can never become the
97
+ // options dict. A literal `(dict …)` always does. Everything else in that slot
98
+ // is classified by whatever it happens to evaluate to.
99
+ const isNonObjectLiteral = (e: ArgExpr): boolean =>
100
+ e.kind === "literal" && typeof e.value !== "object";
101
+
102
+ // The display-arity rule used as a predicate; the message is the caller's
103
+ // business, so the subject never surfaces. [LAW:single-enforcer] — legality is
104
+ // read off the disclosure primitive, never restated as a count comparison.
105
+ const legalDisplayCount = (count: number): boolean =>
106
+ cycleDisplayIssue("", count, 2) === undefined;
107
+
83
108
  function analyzeMenuCall(call: ReferencedCall): MenuAnalysis {
84
109
  const issue = (message: string): MenuAnalysis => ({ kind: "issue", message });
85
- const [applyArg, optsArg] = call.argExprs;
110
+ const [applyArg, ...tail] = call.argExprs;
86
111
  if (applyArg === undefined) {
87
112
  return issue(
88
- `with no arguments — it takes an apply-action name (e.g. {{ menu "applyTheme" }})`,
113
+ `with no arguments — it takes an apply-action name and its trigger text (e.g. {{ menu "applyTheme" "▸" "▾" }})`,
89
114
  );
90
115
  }
91
- if (call.argExprs.length > 2) {
92
- return issue(`with more than two arguments — ${MIGRATION}`);
93
- }
94
116
  if (applyArg.kind !== "literal" || typeof applyArg.value !== "string") {
95
117
  return issue(
96
- `whose apply action is not a string literal — a menu's identity is its apply-action name, which must be a literal so it can be gated at load (e.g. {{ menu "applyTheme" }})`,
118
+ `whose apply action is not a string literal — a menu's identity is its apply-action name, which must be a literal so it can be gated at load (e.g. {{ menu "applyTheme" "▸" "▾" }})`,
119
+ );
120
+ }
121
+ // [LAW:types-are-the-program] The dict is the LAST argument when present;
122
+ // everything before it is a display. Splitting on that one position is the
123
+ // whole grammar, and it is the same split `parseMenuArgs` performs on the
124
+ // evaluated tail at render — one shape, read twice from the two things each
125
+ // side has (exprs here, values there).
126
+ const last = tail[tail.length - 1];
127
+ const optsArg = last !== undefined && isDictCall(last) ? last : undefined;
128
+ const displays = optsArg === undefined ? tail : tail.slice(0, -1);
129
+ if (displays.some(isDictCall)) {
130
+ return issue(
131
+ `whose options (dict …) is not its last argument — ${MIGRATION}`,
97
132
  );
98
133
  }
134
+ // [LAW:no-silent-failure] The last slot is the one both readings can claim.
135
+ // When the expr there is not provably one or the other AND dropping it still
136
+ // leaves a legal display count, the renderer's value-based split can land on
137
+ // a DIFFERENT reading than this one — same call, two shapes, no error either
138
+ // side: the options dict skips `staticDictEntries` (so a dynamic `key` derives
139
+ // a state key with no synthesized var behind it, and the menu never opens) or
140
+ // a display vanishes into the static form. Reject that call site; an explicit
141
+ // trailing `(dict …)` disambiguates it and keeps dynamic displays legal.
142
+ // Where the alternate reading is an ILLEGAL count the renderer throws instead
143
+ // of diverging, so it stays accepted — loudness, not refusal, is the bar.
99
144
  if (
100
- optsArg !== undefined &&
101
- (optsArg.kind !== "call" || optsArg.name !== "dict")
145
+ last !== undefined &&
146
+ optsArg === undefined &&
147
+ !isNonObjectLiteral(last) &&
148
+ legalDisplayCount(displays.length - 1)
102
149
  ) {
103
- // A literal (the old page-action string / positional bool), a dynamic value,
104
- // or a non-dict call: none is an options dict — one migration error covers
105
- // the whole family [LAW:one-type-per-behavior].
106
150
  return issue(
107
- `whose second argument is not an options (dict …) — ${MIGRATION}`,
151
+ `whose last argument is neither a literal nor a literal (dict …) — the renderer tells a display from the options dict by the value it evaluates to, so this call could be read as ${displays.length} displays or as ${displays.length - 1} plus options, and both are legal. Make the options explicit as a trailing (dict …) — {{ menu "${applyArg.value}" (printf "…") (printf "…") (dict) }} binds dynamic displays unambiguously — or bind the trigger text as literals`,
108
152
  );
109
153
  }
154
+ // [LAW:single-enforcer] The display-arity rule is the disclosure primitive's,
155
+ // the same one the renderer picks through — checked HERE too because the
156
+ // count is statically known, so an unauthored trigger is a load error naming
157
+ // the fix rather than a diagnostic glyph on the next render.
158
+ // "whose trigger …" completes the caller's `segment "X" has a {{ menu }} `.
159
+ const arity = cycleDisplayIssue("whose trigger", displays.length, 2);
160
+ if (arity !== undefined) return issue(`${arity} — ${MIGRATION}`);
110
161
  const entries = optsArg === undefined ? {} : staticDictEntries(optsArg);
111
162
  if (entries === null) {
112
163
  return issue(