@promptctl/cc-candybar 1.18.1 → 1.20.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 +41 -39
- package/package.json +6 -6
- package/src/check.ts +359 -0
- package/src/config/cli.ts +19 -118
- package/src/config/default-dsl-config.ts +16 -19
- package/src/config/disclosure.ts +55 -0
- package/src/config/loader/layout.ts +34 -32
- package/src/config/loader/menu-synth.ts +163 -108
- package/src/config/loader/refs.ts +11 -10
- package/src/config/loader/reserved-namespace.ts +38 -0
- package/src/config/menu-keys.ts +78 -11
- package/src/daemon/verbs/state-validators.ts +41 -44
- package/src/index.ts +12 -5
- package/src/render/action.ts +7 -1
- package/src/render/menu.ts +49 -56
- package/src/render/picker.ts +51 -23
|
@@ -30,7 +30,15 @@ import {
|
|
|
30
30
|
type VariableDecl,
|
|
31
31
|
} from "../dsl-types.js";
|
|
32
32
|
import type { ActionDecl } from "../action.js";
|
|
33
|
+
import {
|
|
34
|
+
DISCLOSURE_CLOSED,
|
|
35
|
+
DISCLOSURE_GLYPH_CLOSED,
|
|
36
|
+
DISCLOSURE_GLYPH_OPEN,
|
|
37
|
+
disclosureCycleAction,
|
|
38
|
+
disclosureStateVar,
|
|
39
|
+
} from "../disclosure.js";
|
|
33
40
|
import { findKeyLine } from "./diagnostics.js";
|
|
41
|
+
import { reservedNamespaceCollisions } from "./reserved-namespace.js";
|
|
34
42
|
import {
|
|
35
43
|
describeType,
|
|
36
44
|
describeValue,
|
|
@@ -385,9 +393,11 @@ export const validateRoot = (
|
|
|
385
393
|
// prefix is rejected so synthesis can never silently collide.
|
|
386
394
|
export const GROUP_NS = "groups.";
|
|
387
395
|
|
|
388
|
-
// The
|
|
389
|
-
//
|
|
390
|
-
|
|
396
|
+
// [LAW:one-source-of-truth] The closed sentinel and ▸/▾ glyphs are the shared
|
|
397
|
+
// disclosure primitive (src/config/disclosure.ts) — a group is one of its two
|
|
398
|
+
// body-kinds, so it reuses DISCLOSURE_CLOSED / DISCLOSURE_GLYPH_* rather than
|
|
399
|
+
// keeping a second copy that could drift from the menu's. Group names are
|
|
400
|
+
// forbidden from equaling the sentinel, so a cycle's two members are distinct.
|
|
391
401
|
|
|
392
402
|
// [LAW:types-are-the-program] A group name must be template-addressable — it is
|
|
393
403
|
// spliced into the synthesized `when` predicate and toggle template as
|
|
@@ -396,9 +406,6 @@ const GROUP_CLOSED = "closed";
|
|
|
396
406
|
// dots, so a name needs no escaping anywhere it is spliced.
|
|
397
407
|
const GROUP_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
398
408
|
|
|
399
|
-
const GROUP_GLYPH_CLOSED = "▸";
|
|
400
|
-
const GROUP_GLYPH_OPEN = "▾";
|
|
401
|
-
|
|
402
409
|
function groupNameSpec(): FieldSpec<string> {
|
|
403
410
|
return {
|
|
404
411
|
required: true,
|
|
@@ -408,11 +415,11 @@ function groupNameSpec(): FieldSpec<string> {
|
|
|
408
415
|
if (
|
|
409
416
|
typeof v !== "string" ||
|
|
410
417
|
!GROUP_NAME_RE.test(v) ||
|
|
411
|
-
v ===
|
|
418
|
+
v === DISCLOSURE_CLOSED
|
|
412
419
|
) {
|
|
413
420
|
ctx.issues.push({
|
|
414
421
|
path: `${path}.${field}`,
|
|
415
|
-
message: `a group "name" must be an identifier (letters, digits, _; not starting with a digit) and not the reserved "${
|
|
422
|
+
message: `a group "name" must be an identifier (letters, digits, _; not starting with a digit) and not the reserved "${DISCLOSURE_CLOSED}", got ${describeValue(v)}`,
|
|
416
423
|
line: findKeyLine(ctx.source, ["root"]),
|
|
417
424
|
});
|
|
418
425
|
return undefined;
|
|
@@ -568,21 +575,18 @@ export function synthesizeGroupDecls(
|
|
|
568
575
|
ctx: ValidateCtx,
|
|
569
576
|
out: Mutable<RawDslConfig>,
|
|
570
577
|
): void {
|
|
578
|
+
// [LAW:single-enforcer] The disclosure primitive's shared reserved-namespace
|
|
579
|
+
// enforcer (mirroring {{ menu }}'s `menus.`) — a user name under `groups.`
|
|
580
|
+
// would silently shadow a synthesized artifact. Reserved UNCONDITIONALLY,
|
|
581
|
+
// before the no-groups early return, so the reservation is a stable contract
|
|
582
|
+
// ("you never author groups.*"), not a rule that only switches on when a
|
|
583
|
+
// group node happens to be declared this load — same placement as the menus
|
|
584
|
+
// pass (synthesizeMenuDecls).
|
|
585
|
+
reservedNamespaceCollisions(ctx, out, GROUP_NS, "group nodes");
|
|
586
|
+
|
|
571
587
|
const groups = ctx.groups;
|
|
572
588
|
if (groups.length === 0) return;
|
|
573
589
|
|
|
574
|
-
for (const section of ["variables", "actions", "segments"] as const) {
|
|
575
|
-
for (const name of Object.keys(out[section] ?? {})) {
|
|
576
|
-
if (name.startsWith(GROUP_NS)) {
|
|
577
|
-
groupIssue(
|
|
578
|
-
ctx,
|
|
579
|
-
`${section}.${name}`,
|
|
580
|
-
`"${name}" is in the reserved "${GROUP_NS}" namespace (synthesized by group nodes) — rename it`,
|
|
581
|
-
);
|
|
582
|
-
}
|
|
583
|
-
}
|
|
584
|
-
}
|
|
585
|
-
|
|
586
590
|
const seen = new Set<string>();
|
|
587
591
|
for (const g of groups) {
|
|
588
592
|
if (seen.has(g.name)) {
|
|
@@ -617,10 +621,10 @@ export function synthesizeGroupDecls(
|
|
|
617
621
|
const defaultByKey = new Map<string, string>();
|
|
618
622
|
for (const g of groups) {
|
|
619
623
|
const key = groupStateKey(g);
|
|
620
|
-
if (!defaultByKey.has(key)) defaultByKey.set(key,
|
|
624
|
+
if (!defaultByKey.has(key)) defaultByKey.set(key, DISCLOSURE_CLOSED);
|
|
621
625
|
if (g.open === true) {
|
|
622
626
|
const prior = defaultByKey.get(key)!;
|
|
623
|
-
if (prior !==
|
|
627
|
+
if (prior !== DISCLOSURE_CLOSED) {
|
|
624
628
|
groupIssue(
|
|
625
629
|
ctx,
|
|
626
630
|
g.path,
|
|
@@ -645,22 +649,20 @@ export function synthesizeGroupDecls(
|
|
|
645
649
|
(other) => other !== g && g.path.startsWith(other.path + "."),
|
|
646
650
|
).length;
|
|
647
651
|
const indent = " ".repeat(depth);
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
// clicks to its own name — expand, auto-closing the sibling on a shared key.
|
|
656
|
-
actions[name] = { set: key, cycle: [GROUP_CLOSED, g.name] };
|
|
652
|
+
// [LAW:one-source-of-truth] The shared disclosure toggle: one state var + one
|
|
653
|
+
// binary cycle action, both from the primitive. Members are ordered default-
|
|
654
|
+
// state-first (closed first): an unset or sibling-held key counts as the first
|
|
655
|
+
// member, so the toggle renders ▸ and clicks to its own name — expand, auto-
|
|
656
|
+
// closing the sibling on a shared key.
|
|
657
|
+
variables[name] = disclosureStateVar(key, defaultByKey.get(key)!);
|
|
658
|
+
actions[name] = disclosureCycleAction(key, g.name);
|
|
657
659
|
// [LAW:representation] The disclosure glyph trails the label it gates, so an
|
|
658
660
|
// arrow reads as belonging to the text on its LEFT — adjacent toggles
|
|
659
661
|
// ("details ▸" "links ▸") stay unambiguous even when abutted. `indent` is a
|
|
660
662
|
// structural left-margin (nesting depth) and stays leading; the glyph is a
|
|
661
663
|
// trailing affordance on the label, never a prefix.
|
|
662
664
|
segments[name] = {
|
|
663
|
-
template: `{{ action "${name}" "${indent}${label} ${
|
|
665
|
+
template: `{{ action "${name}" "${indent}${label} ${DISCLOSURE_GLYPH_CLOSED}" "${indent}${label} ${DISCLOSURE_GLYPH_OPEN}" }}`,
|
|
664
666
|
...(g.bg !== undefined && { bg: g.bg }),
|
|
665
667
|
...(g.fg !== undefined && { fg: g.fg }),
|
|
666
668
|
};
|
|
@@ -3,80 +3,140 @@
|
|
|
3
3
|
// trigger living inside an arbitrary user segment rather than a synthesized
|
|
4
4
|
// toggle segment — so this emits exactly what group sugar does MINUS the segment
|
|
5
5
|
// (the helper IS the trigger): one `state` var per menu state key (default
|
|
6
|
-
// "closed")
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
6
|
+
// "closed"), one `cycle` action per (state key, member), AND — bn5.6 — the
|
|
7
|
+
// picker body's page cursor (a `state` var + int action named by menuPageKey)
|
|
8
|
+
// per state key, all under the reserved `menus.` namespace. Everything lands in
|
|
9
|
+
// the raw sections so it merges over the default and, crucially, so
|
|
10
|
+
// `deriveActionValidators(config.actions)` derives the click gates from them
|
|
11
|
+
// through the ONE existing path — a menu toggle and its page cursor are gated
|
|
10
12
|
// like every other set, no parallel verb. [LAW:single-enforcer]
|
|
11
13
|
//
|
|
12
14
|
// Runs in `parseDslConfig` after group synthesis and after every section parsed,
|
|
13
15
|
// so the reserved-namespace collision check sees the fully-parsed user sections.
|
|
14
16
|
//
|
|
15
17
|
// [LAW:types-are-the-program] WHICH segments host a menu, and with WHAT apply
|
|
16
|
-
// action +
|
|
17
|
-
// not a source-text scan — robust against whitespace,
|
|
18
|
-
// `.menu`/"menu" lookalikes, and it yields each call's literal
|
|
19
|
-
// so a menu's identity (member =
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
18
|
+
// action + options, is read from the parsed AST (`referencedCalls` → `argExprs`
|
|
19
|
+
// + `staticDictEntries`), not a source-text scan — robust against whitespace,
|
|
20
|
+
// pipelines, and `.menu`/"menu" lookalikes, and it yields each call's literal
|
|
21
|
+
// apply name and literal `(dict …)` options so a menu's identity (member =
|
|
22
|
+
// apply name; key = the optional "key" option) is the SAME fact the render
|
|
23
|
+
// helper reads from those same arguments. A parse failure here is treated as
|
|
24
|
+
// "no menu" because the authoritative parse error is surfaced loudly by
|
|
25
|
+
// `registerDslConfig` when it compiles the same template (so this pass never
|
|
26
|
+
// swallows a real error, it just declines to guess).
|
|
24
27
|
|
|
25
|
-
import {
|
|
28
|
+
import {
|
|
29
|
+
createEngine,
|
|
30
|
+
staticDictEntries,
|
|
31
|
+
type ReferencedCall,
|
|
32
|
+
} from "@promptctl/go-template-js";
|
|
26
33
|
import type { ActionDecl } from "../action.js";
|
|
27
34
|
import type { Mutable, ValidateCtx } from "./validate-core.js";
|
|
28
35
|
import {
|
|
29
|
-
MENU_CLOSED,
|
|
30
36
|
MENU_NS,
|
|
31
37
|
menuActionName,
|
|
32
38
|
menuMember,
|
|
39
|
+
menuPageKey,
|
|
33
40
|
menuStateKey,
|
|
41
|
+
parseMenuOptions,
|
|
42
|
+
type MenuOptions,
|
|
34
43
|
} from "../menu-keys.js";
|
|
44
|
+
import {
|
|
45
|
+
DISCLOSURE_CLOSED,
|
|
46
|
+
disclosureCycleAction,
|
|
47
|
+
disclosureStateVar,
|
|
48
|
+
} from "../disclosure.js";
|
|
35
49
|
import {
|
|
36
50
|
walkNodes,
|
|
37
51
|
type RawDslConfig,
|
|
38
52
|
type VariableDecl,
|
|
39
53
|
} from "../dsl-types.js";
|
|
40
54
|
import { findKeyLine } from "./diagnostics.js";
|
|
55
|
+
import { reservedNamespaceCollisions } from "./reserved-namespace.js";
|
|
41
56
|
|
|
42
57
|
// [LAW:single-enforcer] The helper-name a `{{ menu … }}` call uses — the same
|
|
43
58
|
// string the render FuncMap registers. A segment "hosts a menu" iff its template
|
|
44
59
|
// references this function.
|
|
45
60
|
const MENU_FUNC = "menu";
|
|
46
61
|
|
|
47
|
-
// [LAW:types-are-the-program] The `{{ menu }}`
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
|
|
52
|
-
|
|
62
|
+
// [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
|
|
68
|
+
// 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)`;
|
|
70
|
+
|
|
71
|
+
// [LAW:dataflow-not-control-flow] One total analysis of a `{{ menu }}` call site:
|
|
72
|
+
// every reachable argument shape lands in exactly one arm — a usable identity
|
|
73
|
+
// (apply + parsed options) or one load-error message (the tail after
|
|
74
|
+
// `segment "<name>" has a {{ menu }} `). No shape falls through to render time.
|
|
75
|
+
type MenuAnalysis =
|
|
76
|
+
| {
|
|
77
|
+
readonly kind: "ok";
|
|
78
|
+
readonly apply: string;
|
|
79
|
+
readonly options: MenuOptions;
|
|
80
|
+
}
|
|
81
|
+
| { readonly kind: "issue"; readonly message: string };
|
|
53
82
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
83
|
+
function analyzeMenuCall(call: ReferencedCall): MenuAnalysis {
|
|
84
|
+
const issue = (message: string): MenuAnalysis => ({ kind: "issue", message });
|
|
85
|
+
const [applyArg, optsArg] = call.argExprs;
|
|
86
|
+
if (applyArg === undefined) {
|
|
87
|
+
return issue(
|
|
88
|
+
`with no arguments — it takes an apply-action name (e.g. {{ menu "applyTheme" }})`,
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
if (call.argExprs.length > 2) {
|
|
92
|
+
return issue(`with more than two arguments — ${MIGRATION}`);
|
|
93
|
+
}
|
|
94
|
+
if (applyArg.kind !== "literal" || typeof applyArg.value !== "string") {
|
|
95
|
+
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" }})`,
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
if (
|
|
100
|
+
optsArg !== undefined &&
|
|
101
|
+
(optsArg.kind !== "call" || optsArg.name !== "dict")
|
|
102
|
+
) {
|
|
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
|
+
return issue(
|
|
107
|
+
`whose second argument is not an options (dict …) — ${MIGRATION}`,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
const entries = optsArg === undefined ? {} : staticDictEntries(optsArg);
|
|
111
|
+
if (entries === null) {
|
|
112
|
+
return issue(
|
|
113
|
+
`whose options (dict …) is not fully literal — every option value must be a literal so the menu can be gated at load (a dynamic entry like (dict "key" .x) cannot)`,
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
try {
|
|
117
|
+
return {
|
|
118
|
+
kind: "ok",
|
|
119
|
+
apply: applyArg.value,
|
|
120
|
+
options: parseMenuOptions(entries),
|
|
121
|
+
};
|
|
122
|
+
} catch (e) {
|
|
123
|
+
return issue(`with invalid options — ${(e as Error).message}`);
|
|
124
|
+
}
|
|
62
125
|
}
|
|
63
126
|
|
|
64
127
|
// [LAW:no-defensive-null-guards] A bare engine purely for AST introspection: it
|
|
65
128
|
// never evaluates, so `fromString` is identity and no funcs are registered (parse
|
|
66
129
|
// does not resolve function existence — that is an eval-time concern).
|
|
67
|
-
function parseCalls(
|
|
130
|
+
function parseCalls(
|
|
131
|
+
template: string,
|
|
132
|
+
): readonly MenuAnalysis[] | "parse-failed" {
|
|
68
133
|
const engine = createEngine<string>({ fromString: (s) => s });
|
|
69
134
|
try {
|
|
70
135
|
return engine
|
|
71
136
|
.parse(template)
|
|
72
137
|
.referencedCalls()
|
|
73
138
|
.filter((c) => c.name === MENU_FUNC)
|
|
74
|
-
.map(
|
|
75
|
-
apply: c.args[ARG_APPLY] ?? null,
|
|
76
|
-
// `referencedCalls` reports an omitted positional slot as absent and a
|
|
77
|
-
// present non-literal as null; preserve that distinction.
|
|
78
|
-
key: c.args.length > ARG_KEY ? c.args[ARG_KEY] : undefined,
|
|
79
|
-
}));
|
|
139
|
+
.map(analyzeMenuCall);
|
|
80
140
|
} catch {
|
|
81
141
|
// A malformed template can host no usable menu; registerDslConfig re-parses
|
|
82
142
|
// and reports the real error. [LAW:no-silent-failure] — not swallowed, just
|
|
@@ -106,18 +166,9 @@ export function synthesizeMenuDecls(
|
|
|
106
166
|
// user name under it is rejected whether or not any menu is placed this load, so
|
|
107
167
|
// the reservation is a stable contract ("you never author menus.*"), not a rule
|
|
108
168
|
// that only switches on when synthesis happens to collide. Runs before any early
|
|
109
|
-
// return so a `menus.*` user name can never load silently.
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
if (name.startsWith(MENU_NS)) {
|
|
113
|
-
menuIssue(
|
|
114
|
-
ctx,
|
|
115
|
-
`${section}.${name}`,
|
|
116
|
-
`"${name}" is in the reserved "${MENU_NS}" namespace (synthesized by {{ menu }} helpers) — rename it`,
|
|
117
|
-
);
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
}
|
|
169
|
+
// return so a `menus.*` user name can never load silently. The check is the
|
|
170
|
+
// disclosure primitive's shared enforcer (mirroring group sugar's `groups.`).
|
|
171
|
+
reservedNamespaceCollisions(ctx, out, MENU_NS, "{{ menu }} helpers");
|
|
121
172
|
|
|
122
173
|
// [LAW:no-silent-failure] A menu derives its identity from the SEGMENT it sits
|
|
123
174
|
// in (the published segment name) plus its own apply arg; a `{{ define }}`
|
|
@@ -196,9 +247,10 @@ export function synthesizeMenuDecls(
|
|
|
196
247
|
}
|
|
197
248
|
|
|
198
249
|
// One state var per state key (default "closed"); one cycle action per
|
|
199
|
-
// (stateKey, member)
|
|
200
|
-
//
|
|
201
|
-
// key
|
|
250
|
+
// (stateKey, member); one page-cursor var + int action per state key.
|
|
251
|
+
// [LAW:dataflow-not-control-flow] Independent menus each contribute their own
|
|
252
|
+
// key; shared-key menus contribute distinct members to one key, and the
|
|
253
|
+
// same-key validator merge unions them into one accordion gate.
|
|
202
254
|
const stateKeys = new Set<string>();
|
|
203
255
|
const actions: Record<string, ActionDecl> = {};
|
|
204
256
|
// Guard against two menus claiming one identity (same key + same member): for
|
|
@@ -206,53 +258,42 @@ export function synthesizeMenuDecls(
|
|
|
206
258
|
// segment; for shared-key menus it means two menus with the same apply name
|
|
207
259
|
// sharing a key — neither can be addressed distinctly, so reject.
|
|
208
260
|
const claimed = new Set<string>();
|
|
209
|
-
// [LAW:types-are-the-program]
|
|
210
|
-
// no separators; that normalization is lossy (`a-b` and `a_b`
|
|
211
|
-
// DISTINCT declarations could map to one key and silently
|
|
212
|
-
// unintended accordion). Track the raw "owner" each
|
|
213
|
-
//
|
|
214
|
-
//
|
|
215
|
-
//
|
|
216
|
-
//
|
|
217
|
-
|
|
261
|
+
// [LAW:types-are-the-program] A synthesized key is `ident()`-normalized so it
|
|
262
|
+
// carries no separators; that normalization is lossy (`a-b` and `a_b`
|
|
263
|
+
// collapse), so two DISTINCT declarations could map to one key and silently
|
|
264
|
+
// share state (an unintended accordion). Track the raw "owner" each
|
|
265
|
+
// synthesized name (state key AND its derived page key) legitimately belongs
|
|
266
|
+
// to — a shared key is owned by its raw key string (every sibling agrees); an
|
|
267
|
+
// independent menu by its raw (segment, apply). A second owner on the same
|
|
268
|
+
// name is a collision, rejected at load so it is unrepresentable
|
|
269
|
+
// [LAW:no-silent-failure] rather than corrupting grouping. Registering the
|
|
270
|
+
// page key too closes the cross-shape aliasing corner (e.g. an apply action
|
|
271
|
+
// named "page" in segment "s" vs the page cursor of a shared key "s").
|
|
272
|
+
const ownerBySynthKey = new Map<string, string>();
|
|
218
273
|
|
|
219
274
|
for (const [segName, seg] of Object.entries(segments)) {
|
|
220
275
|
if (!segmentReferencesMenu(seg.template)) continue;
|
|
221
276
|
const calls = parseCalls(seg.template);
|
|
222
277
|
if (calls === "parse-failed") continue;
|
|
223
278
|
for (const call of calls) {
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
);
|
|
230
|
-
continue;
|
|
231
|
-
}
|
|
232
|
-
if (call.key === null) {
|
|
279
|
+
// [LAW:no-silent-failure] Every non-ok argument shape (missing apply,
|
|
280
|
+
// non-literal apply, the removed positional tail, a non-literal or
|
|
281
|
+
// malformed options dict) surfaces here as one load error with the
|
|
282
|
+
// analysis's migration-pointing text.
|
|
283
|
+
if (call.kind === "issue") {
|
|
233
284
|
menuIssue(
|
|
234
285
|
ctx,
|
|
235
286
|
`segments.${segName}`,
|
|
236
|
-
`segment "${segName}" has a {{ menu }}
|
|
237
|
-
);
|
|
238
|
-
continue;
|
|
239
|
-
}
|
|
240
|
-
// [LAW:types-are-the-program] An empty shared key collapses the state key to
|
|
241
|
-
// the bare reserved `menus.` namespace (and a `menus..member` action name).
|
|
242
|
-
// Reject it — a shared key, when present, must name a group.
|
|
243
|
-
if (call.key === "") {
|
|
244
|
-
menuIssue(
|
|
245
|
-
ctx,
|
|
246
|
-
`segments.${segName}`,
|
|
247
|
-
`segment "${segName}" has a {{ menu }} with an empty accordion key — a shared key must be a non-empty name (or omit it for an independent menu).`,
|
|
287
|
+
`segment "${segName}" has a {{ menu }} ${call.message}.`,
|
|
248
288
|
);
|
|
249
289
|
continue;
|
|
250
290
|
}
|
|
291
|
+
const { apply, options } = call;
|
|
251
292
|
// [LAW:types-are-the-program] An empty apply name → empty member, and the
|
|
252
293
|
// store returns "" for an absent state key, so `open = read === member`
|
|
253
294
|
// would be true before any click — the menu would render open spuriously.
|
|
254
295
|
// Reject it (the member must never alias the absent-state sentinel).
|
|
255
|
-
if (
|
|
296
|
+
if (apply === "") {
|
|
256
297
|
menuIssue(
|
|
257
298
|
ctx,
|
|
258
299
|
`segments.${segName}`,
|
|
@@ -260,56 +301,62 @@ export function synthesizeMenuDecls(
|
|
|
260
301
|
);
|
|
261
302
|
continue;
|
|
262
303
|
}
|
|
263
|
-
const member = menuMember(
|
|
304
|
+
const member = menuMember(apply);
|
|
264
305
|
// [LAW:types-are-the-program] A member equal to the closed sentinel makes
|
|
265
306
|
// the cycle [closed, "closed"] — two identical members, leaving the menu
|
|
266
307
|
// unopenable. The only apply name that breaks a menu; reject it at load.
|
|
267
|
-
if (member ===
|
|
308
|
+
if (member === DISCLOSURE_CLOSED) {
|
|
268
309
|
menuIssue(
|
|
269
310
|
ctx,
|
|
270
311
|
`segments.${segName}`,
|
|
271
|
-
`segment "${segName}" has a {{ menu }} whose apply action is named "${
|
|
312
|
+
`segment "${segName}" has a {{ menu }} whose apply action is named "${DISCLOSURE_CLOSED}", which collides with the menu's closed-state sentinel and leaves it unopenable. Rename the action.`,
|
|
272
313
|
);
|
|
273
314
|
continue;
|
|
274
315
|
}
|
|
275
|
-
const stateKey = menuStateKey(segName,
|
|
276
|
-
|
|
277
|
-
//
|
|
278
|
-
//
|
|
316
|
+
const stateKey = menuStateKey(segName, apply, options.key);
|
|
317
|
+
const pageKey = menuPageKey(stateKey);
|
|
318
|
+
// The raw declaration these keys legitimately belong to. Shared-key
|
|
319
|
+
// siblings all share one owner (their raw key); an independent menu owns
|
|
320
|
+
// its keys alone (its raw segment+apply, NUL-joined so the two parts
|
|
321
|
+
// can't run together).
|
|
279
322
|
const owner =
|
|
280
|
-
|
|
281
|
-
? `key${
|
|
282
|
-
: `ind${segName}${
|
|
283
|
-
const
|
|
284
|
-
|
|
323
|
+
options.key !== undefined
|
|
324
|
+
? `key${options.key}`
|
|
325
|
+
: `ind${segName}${apply}`;
|
|
326
|
+
const clashKey = [stateKey, pageKey].find((k) => {
|
|
327
|
+
const prior = ownerBySynthKey.get(k);
|
|
328
|
+
return prior !== undefined && prior !== owner;
|
|
329
|
+
});
|
|
330
|
+
if (clashKey !== undefined) {
|
|
285
331
|
menuIssue(
|
|
286
332
|
ctx,
|
|
287
333
|
`segments.${segName}`,
|
|
288
|
-
`two {{ menu }} disclosures normalize to the same state key ("${
|
|
334
|
+
`two {{ menu }} disclosures normalize to the same state key ("${clashKey}") but were declared differently — distinct names that differ only by non-alphanumeric characters (e.g. "a-b" vs "a_b") collapse to one key and would silently share open-state. Rename so they don't collide.`,
|
|
289
335
|
);
|
|
290
336
|
continue;
|
|
291
337
|
}
|
|
292
|
-
|
|
338
|
+
ownerBySynthKey.set(stateKey, owner);
|
|
339
|
+
ownerBySynthKey.set(pageKey, owner);
|
|
293
340
|
const identity = menuActionName(stateKey, member);
|
|
294
341
|
if (claimed.has(identity)) {
|
|
295
342
|
menuIssue(
|
|
296
343
|
ctx,
|
|
297
344
|
`segments.${segName}`,
|
|
298
345
|
`two {{ menu }} disclosures resolve to the same identity ("${identity}") — ${
|
|
299
|
-
|
|
300
|
-
? `menus sharing key "${
|
|
301
|
-
: `a segment cannot contain two menus over the same apply action "${
|
|
346
|
+
options.key !== undefined
|
|
347
|
+
? `menus sharing key "${options.key}" must have distinct apply actions`
|
|
348
|
+
: `a segment cannot contain two menus over the same apply action "${apply}"`
|
|
302
349
|
}.`,
|
|
303
350
|
);
|
|
304
351
|
continue;
|
|
305
352
|
}
|
|
306
353
|
claimed.add(identity);
|
|
307
354
|
stateKeys.add(stateKey);
|
|
308
|
-
// [LAW:one-source-of-truth]
|
|
309
|
-
// value counts as the first member
|
|
310
|
-
// a never-clicked menu renders ▸ and a
|
|
311
|
-
// one member auto-closes its siblings.
|
|
312
|
-
actions[identity] =
|
|
355
|
+
// [LAW:one-source-of-truth] The shared disclosure toggle: members ordered
|
|
356
|
+
// closed-first (an unset/foreign value counts as the first member — the
|
|
357
|
+
// cycle's "unknown ⇒ first" rule — so a never-clicked menu renders ▸ and a
|
|
358
|
+
// click opens it; a shared key holding one member auto-closes its siblings).
|
|
359
|
+
actions[identity] = disclosureCycleAction(stateKey, member);
|
|
313
360
|
}
|
|
314
361
|
}
|
|
315
362
|
|
|
@@ -317,11 +364,19 @@ export function synthesizeMenuDecls(
|
|
|
317
364
|
|
|
318
365
|
const variables: Record<string, VariableDecl> = {};
|
|
319
366
|
for (const stateKey of stateKeys) {
|
|
320
|
-
variables[stateKey] =
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
367
|
+
variables[stateKey] = disclosureStateVar(stateKey, DISCLOSURE_CLOSED);
|
|
368
|
+
// [LAW:one-source-of-truth] The synthesized page cursor — the half a blind
|
|
369
|
+
// author used to hand-declare and forget, silently freezing the picker on
|
|
370
|
+
// page 0 (renderPicker read an unbound key as "" → clamp 0). Both halves
|
|
371
|
+
// are emitted together, named by menuPageKey, so the pairing is a
|
|
372
|
+
// construction, not a convention: the state VAR (named by the key, the
|
|
373
|
+
// disclosure-var convention) is what the renderer reads the live page
|
|
374
|
+
// through; the int ACTION is what deriveActionValidators derives the ←/→/✕
|
|
375
|
+
// wire gate from — the one existing path, no parallel gate
|
|
376
|
+
// [LAW:single-enforcer].
|
|
377
|
+
const pageKey = menuPageKey(stateKey);
|
|
378
|
+
variables[pageKey] = { kind: "state", key: pageKey, default: "0" };
|
|
379
|
+
actions[pageKey] = { set: pageKey, int: true };
|
|
325
380
|
}
|
|
326
381
|
|
|
327
382
|
out.variables = { ...(out.variables ?? {}), ...variables };
|
|
@@ -55,15 +55,15 @@ export function extractActionRefs(template: string): Set<string> {
|
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
// [LAW:dataflow-not-control-flow] Extract the action names a `picker` OR `menu`
|
|
58
|
-
// call references
|
|
59
|
-
//
|
|
60
|
-
// `{{
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
// one
|
|
65
|
-
//
|
|
66
|
-
const PICKER_OR_MENU_ARG_RE = /\b(
|
|
58
|
+
// call references, for the load-time existence check. A `picker` binds an
|
|
59
|
+
// (apply, page) action pair as its first two string-literal args
|
|
60
|
+
// (`{{ picker "applyTheme" "themePage" true true }}`); a `menu` binds ONLY its
|
|
61
|
+
// apply action (`{{ menu "applyTheme" (dict …) }}`) — its page cursor is
|
|
62
|
+
// synthesized from identity, and the dict's option-name literals must never be
|
|
63
|
+
// misread as action refs. A menu's body IS a picker, so the existence check is
|
|
64
|
+
// identical; one extractor arms on either keyword with the keyword's own arg
|
|
65
|
+
// count [LAW:single-enforcer]. Same code/string-span walk as extractActionRefs.
|
|
66
|
+
const PICKER_OR_MENU_ARG_RE = /\b(picker|menu)\s+$/;
|
|
67
67
|
export function extractPickerMenuRefs(template: string): Set<string> {
|
|
68
68
|
const refs = new Set<string>();
|
|
69
69
|
TEMPLATE_BLOCK_RE.lastIndex = 0;
|
|
@@ -75,7 +75,8 @@ export function extractPickerMenuRefs(template: string): Set<string> {
|
|
|
75
75
|
let s: RegExpExecArray | null;
|
|
76
76
|
STRING_LITERAL_RE.lastIndex = 0;
|
|
77
77
|
while ((s = STRING_LITERAL_RE.exec(block)) !== null) {
|
|
78
|
-
|
|
78
|
+
const kw = PICKER_OR_MENU_ARG_RE.exec(block.slice(cursor, s.index));
|
|
79
|
+
if (kw !== null) pending = kw[1] === "picker" ? 2 : 1;
|
|
79
80
|
if (pending > 0) {
|
|
80
81
|
refs.add(s[0].slice(1, -1));
|
|
81
82
|
pending--;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// [LAW:one-source-of-truth] The loader-side half of the disclosure primitive
|
|
2
|
+
// (src/config/disclosure.ts): the ONE reserved-namespace collision check both
|
|
3
|
+
// synthesis passes run. Group sugar reserves `groups.` and the `{{ menu }}`
|
|
4
|
+
// helper reserves `menus.`; each synthesizes its `state` var, `cycle` action, and
|
|
5
|
+
// (for a group) a toggle segment under its prefix, so a user-authored name under
|
|
6
|
+
// that prefix must be a loud load error — never a silent overwrite of a
|
|
7
|
+
// synthesized artifact. The check was duplicated verbatim in both passes; it lives
|
|
8
|
+
// here now, parameterized by the prefix and a human description of what
|
|
9
|
+
// synthesizes it, so the two body-kinds share one enforcer [LAW:single-enforcer].
|
|
10
|
+
|
|
11
|
+
import type { Mutable, ValidateCtx } from "./validate-core.js";
|
|
12
|
+
import type { RawDslConfig } from "../dsl-types.js";
|
|
13
|
+
import { findKeyLine } from "./diagnostics.js";
|
|
14
|
+
|
|
15
|
+
// [LAW:no-silent-failure] Reject every user name under the reserved prefix across
|
|
16
|
+
// all three declaration sections (a synthesized disclosure lands in each), before
|
|
17
|
+
// synthesis writes into them — so a `groups.*`/`menus.*` squatter surfaces as a
|
|
18
|
+
// rename-pointing error rather than being silently shadowed. `synthesizedBy`
|
|
19
|
+
// names the feature in the message (e.g. "group nodes", "{{ menu }} helpers") so
|
|
20
|
+
// the author knows which sugar owns the prefix.
|
|
21
|
+
export function reservedNamespaceCollisions(
|
|
22
|
+
ctx: ValidateCtx,
|
|
23
|
+
out: Mutable<RawDslConfig>,
|
|
24
|
+
ns: string,
|
|
25
|
+
synthesizedBy: string,
|
|
26
|
+
): void {
|
|
27
|
+
for (const section of ["variables", "actions", "segments"] as const) {
|
|
28
|
+
for (const name of Object.keys(out[section] ?? {})) {
|
|
29
|
+
if (name.startsWith(ns)) {
|
|
30
|
+
ctx.issues.push({
|
|
31
|
+
path: `${section}.${name}`,
|
|
32
|
+
message: `"${name}" is in the reserved "${ns}" namespace (synthesized by ${synthesizedBy}) — rename it`,
|
|
33
|
+
line: findKeyLine(ctx.source, ["root"]),
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|