@promptctl/cc-candybar 1.18.0 → 1.19.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 +61 -61
- package/package.json +7 -7
- package/src/config/default-dsl-config.ts +16 -19
- package/src/config/disclosure.ts +55 -0
- package/src/config/loader/layout.ts +29 -31
- 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/cache/watchers.ts +5 -1
- package/src/daemon/verbs/state-validators.ts +41 -44
- package/src/render/action.ts +7 -1
- package/src/render/menu.ts +49 -56
- package/src/render/picker.ts +51 -23
- package/src/segments/git.ts +126 -172
|
@@ -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
|
+
}
|
package/src/config/menu-keys.ts
CHANGED
|
@@ -28,17 +28,11 @@
|
|
|
28
28
|
// can never silently collide.
|
|
29
29
|
export const MENU_NS = "menus.";
|
|
30
30
|
|
|
31
|
-
// The
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
// [LAW:representation] Disclosure glyph vocabulary — identical to group sugar so
|
|
38
|
-
// every disclosure across the bar reads the same (trailing the label/content it
|
|
39
|
-
// gates, per pdu.8): collapsed ▸, expanded ▾.
|
|
40
|
-
export const MENU_GLYPH_CLOSED = "▸";
|
|
41
|
-
export const MENU_GLYPH_OPEN = "▾";
|
|
31
|
+
// [LAW:one-source-of-truth] The closed sentinel and the ▸/▾ glyphs are the shared
|
|
32
|
+
// disclosure primitive, not menu-specific — they live in src/config/disclosure.ts
|
|
33
|
+
// (DISCLOSURE_CLOSED / DISCLOSURE_GLYPH_*) so group sugar and {{ menu }} cannot
|
|
34
|
+
// drift. This module keeps only the menu's own IDENTITY derivation (member = apply
|
|
35
|
+
// name; key = optional shared key), which group sugar derives differently.
|
|
42
36
|
|
|
43
37
|
// [LAW:types-are-the-program] Collapse an arbitrary name to an identifier-shaped
|
|
44
38
|
// id so the synthesized var/action/SessionState-key names carry no dots or
|
|
@@ -76,3 +70,76 @@ export function menuStateKey(
|
|
|
76
70
|
export function menuActionName(stateKey: string, member: string): string {
|
|
77
71
|
return stateKey + "." + member;
|
|
78
72
|
}
|
|
73
|
+
|
|
74
|
+
// [LAW:single-enforcer] THE page-cursor key a menu's picker body paginates by —
|
|
75
|
+
// synthesized by the loader (state var + int action, both named by this key)
|
|
76
|
+
// and derived again by the renderer, so neither side hand-declares or restates
|
|
77
|
+
// it. One cursor PER DISCLOSURE STATE KEY, not per menu: a shared (accordion)
|
|
78
|
+
// key holds at most one open member, so its one open body is the only body the
|
|
79
|
+
// cursor can belong to — sharing is exact by construction, not an
|
|
80
|
+
// approximation — and the disclosure toggle's page-0 reset re-seeds it on
|
|
81
|
+
// every open. [LAW:one-source-of-truth] The synthesized state VARIABLE is
|
|
82
|
+
// named by this same string (the disclosure-var convention), so the renderer
|
|
83
|
+
// reads the live page via this one name.
|
|
84
|
+
export function menuPageKey(stateKey: string): string {
|
|
85
|
+
return stateKey + ".page";
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// [LAW:types-are-the-program] The `{{ menu }}` rare-knob options, spelled as ONE
|
|
89
|
+
// trailing `(dict …)` argument. Defaults are the canonical path: paged=true (a
|
|
90
|
+
// drop menu wants bounded height; a short domain paginates to one page and shows
|
|
91
|
+
// no arrows anyway), closeOnPick=false (stay-open so options can be tried in a
|
|
92
|
+
// row), key omitted (an independent menu).
|
|
93
|
+
export interface MenuOptions {
|
|
94
|
+
readonly closeOnPick: boolean;
|
|
95
|
+
readonly paged: boolean;
|
|
96
|
+
readonly key: string | undefined;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// [LAW:one-source-of-truth] THE reader of a menu's options dict — the loader
|
|
100
|
+
// folds it over `staticDictEntries` (gating identity at load) and the renderer
|
|
101
|
+
// folds it over the evaluated dict object (realizing the body), so the option
|
|
102
|
+
// vocabulary, value types, and defaults live exactly once. An unknown name or a
|
|
103
|
+
// mistyped value throws with text naming the legal shape — the blind authoring
|
|
104
|
+
// agent's teaching channel [LAW:no-silent-failure]; the loader attaches segment
|
|
105
|
+
// context, the renderer surfaces it via composeWithDiagnostics.
|
|
106
|
+
export function parseMenuOptions(
|
|
107
|
+
entries: Readonly<Record<string, unknown>>,
|
|
108
|
+
): MenuOptions {
|
|
109
|
+
for (const name of Object.keys(entries)) {
|
|
110
|
+
if (name !== "closeOnPick" && name !== "paged" && name !== "key") {
|
|
111
|
+
throw new Error(
|
|
112
|
+
`unknown {{ menu }} option "${name}" — the options dict takes "closeOnPick" (bool, default false), "paged" (bool, default true), "key" (string, accordion grouping)`,
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
const bool = (name: "closeOnPick" | "paged", def: boolean): boolean => {
|
|
117
|
+
const v = entries[name];
|
|
118
|
+
if (v === undefined) return def;
|
|
119
|
+
if (typeof v !== "boolean") {
|
|
120
|
+
throw new Error(
|
|
121
|
+
`{{ menu }} option "${name}" must be a boolean, got ${JSON.stringify(v)} (e.g. (dict "${name}" ${String(!def)}))`,
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
return v;
|
|
125
|
+
};
|
|
126
|
+
const key = entries["key"];
|
|
127
|
+
if (key !== undefined && typeof key !== "string") {
|
|
128
|
+
throw new Error(
|
|
129
|
+
`{{ menu }} option "key" must be a string naming the accordion group, got ${JSON.stringify(key)}`,
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
// [LAW:types-are-the-program] An empty shared key would collapse the state key
|
|
133
|
+
// to the bare reserved `menus.` namespace (and a `menus..member` action name) —
|
|
134
|
+
// a key, when present, must name a group.
|
|
135
|
+
if (key === "") {
|
|
136
|
+
throw new Error(
|
|
137
|
+
`{{ menu }} has an empty accordion key — a shared key must be a non-empty name (or omit "key" for an independent menu)`,
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
return {
|
|
141
|
+
closeOnPick: bool("closeOnPick", false),
|
|
142
|
+
paged: bool("paged", true),
|
|
143
|
+
key,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
@@ -18,7 +18,11 @@ const defaultLogger: WatcherLogger = (_level, message) => debug(message);
|
|
|
18
18
|
// (git cache, config cache, ...). Scattered watchers across modules would leak
|
|
19
19
|
// FDs and miss cleanup at shutdown.
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
// [LAW:one-source-of-truth] Exported: the invalidation-debounce floor is the
|
|
22
|
+
// single source consumers derive from (e.g. the load harness's git-churn
|
|
23
|
+
// interval must sit above it). Restating "50" anywhere else would let the two
|
|
24
|
+
// drift silently.
|
|
25
|
+
export const DEBOUNCE_MS = 50;
|
|
22
26
|
const DEFAULT_MAX_WATCHERS = 128;
|
|
23
27
|
|
|
24
28
|
// Absolute filesystem paths the consumer wants invalidation for.
|