@promptctl/cc-candybar 1.33.0 → 1.34.1
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 +26 -26
- package/package.json +5 -5
- package/src/check.ts +99 -30
- package/src/config/default-dsl-config.ts +11 -0
- package/src/config/edit-chrome.ts +93 -10
- package/src/config/ident.ts +22 -0
- package/src/config/loader/cross-ref.ts +51 -0
- package/src/config/loader/persist-target.ts +20 -1
- package/src/config/loader/presets.ts +14 -2
- package/src/config/menu-keys.ts +2 -8
- package/src/config/presets.ts +72 -0
- package/src/daemon/cache/render.ts +31 -1
- package/src/daemon/render-payload.ts +20 -2
- package/src/daemon/server.ts +14 -1
- package/src/daemon/verbs/config-validators.ts +47 -3
- package/src/demo/statusline.json5 +8 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@promptctl/cc-candybar",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.34.1",
|
|
4
4
|
"description": "Statusline renderer for Claude Code — a JSON5-configurable DSL with daemon-cached data sources, byte-clean palette-aware composition, and OSC8 click verbs.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.mjs",
|
|
@@ -91,9 +91,9 @@
|
|
|
91
91
|
"mobx": "^6.15.0"
|
|
92
92
|
},
|
|
93
93
|
"optionalDependencies": {
|
|
94
|
-
"@promptctl/cc-candybar-darwin-arm64": "1.
|
|
95
|
-
"@promptctl/cc-candybar-darwin-x64": "1.
|
|
96
|
-
"@promptctl/cc-candybar-linux-x64": "1.
|
|
97
|
-
"@promptctl/cc-candybar-linux-arm64": "1.
|
|
94
|
+
"@promptctl/cc-candybar-darwin-arm64": "1.34.1",
|
|
95
|
+
"@promptctl/cc-candybar-darwin-x64": "1.34.1",
|
|
96
|
+
"@promptctl/cc-candybar-linux-x64": "1.34.1",
|
|
97
|
+
"@promptctl/cc-candybar-linux-arm64": "1.34.1"
|
|
98
98
|
}
|
|
99
99
|
}
|
package/src/check.ts
CHANGED
|
@@ -121,6 +121,17 @@ export function checkPayload(
|
|
|
121
121
|
tmux: { session: "work" },
|
|
122
122
|
theme: { effective: effective.theme },
|
|
123
123
|
look: { effective: effective.look },
|
|
124
|
+
// [LAW:one-source-of-truth] Was missing here even though EffectiveGlobals
|
|
125
|
+
// already carried `preset` — a pre-existing gap this ticket's own fixture
|
|
126
|
+
// needs closed: a preset trigger's `.preset.effective` label and
|
|
127
|
+
// brandon-layout-edit-2gc.5's `.preset.customized` gate both silently
|
|
128
|
+
// fell back to their declared defaults ("" / false) rather than the
|
|
129
|
+
// resolved value, exactly the drift the sibling `*.effective` fields
|
|
130
|
+
// already guard against.
|
|
131
|
+
preset: {
|
|
132
|
+
effective: effective.preset,
|
|
133
|
+
customized: effective.presetCustomized,
|
|
134
|
+
},
|
|
124
135
|
style: { effective: effective.style },
|
|
125
136
|
charset: { effective: effective.charset },
|
|
126
137
|
colorCompatibility: { effective: effective.colorCompatibility },
|
|
@@ -294,6 +305,14 @@ function loadRegisterRender(
|
|
|
294
305
|
const globals = presetGlobals(config, preset);
|
|
295
306
|
const effective: EffectiveGlobals = {
|
|
296
307
|
preset,
|
|
308
|
+
// [LAW:no-silent-failure] `check` validates a config file in isolation
|
|
309
|
+
// — it never reads the daemon-owned overrides file, so there is no
|
|
310
|
+
// rootOps log to be customized BY. false is the honest value for THIS
|
|
311
|
+
// (primary, returned) render, not a stand-in default: a fresh session
|
|
312
|
+
// has never customized anything. A second render pass below also
|
|
313
|
+
// exercises `true`, so a `.preset.customized`-gated segment still
|
|
314
|
+
// gets checked — just not through this value.
|
|
315
|
+
presetCustomized: false,
|
|
297
316
|
theme: effectiveThemeName(null, globals.palette),
|
|
298
317
|
look: effectiveLookName(null, globals.look, config.looks),
|
|
299
318
|
style: effectiveStripStyle(null, globals.style),
|
|
@@ -310,40 +329,90 @@ function loadRegisterRender(
|
|
|
310
329
|
// authoring agent is not looking at the bar; check collects the same errors
|
|
311
330
|
// through the render's observer seam and fails the verdict, so exit 0 never
|
|
312
331
|
// blesses a bar that renders ⚠.
|
|
313
|
-
const
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
332
|
+
const renderOnce = (
|
|
333
|
+
payloadEffective: EffectiveGlobals,
|
|
334
|
+
): { rendered: string; segmentErrors: Map<string, string> } => {
|
|
335
|
+
// [LAW:types-are-the-program] Keyed by segment NAME, not appended to a
|
|
336
|
+
// list — a segment errors at most once per pass, so this is the
|
|
337
|
+
// strongest true shape (dedupe-by-construction within one pass) and
|
|
338
|
+
// what makes deduping ACROSS the two passes below a plain key check
|
|
339
|
+
// rather than a message-text comparison.
|
|
340
|
+
const segmentErrors = new Map<string, string>();
|
|
341
|
+
const rendered = renderDsl(
|
|
342
|
+
config,
|
|
343
|
+
compiled,
|
|
344
|
+
store,
|
|
345
|
+
registry,
|
|
346
|
+
checkPayload(payloadEffective),
|
|
347
|
+
paletteForThemeName(payloadEffective.theme),
|
|
348
|
+
{
|
|
349
|
+
style: payloadEffective.style,
|
|
350
|
+
width: CHECK_WIDTH,
|
|
351
|
+
colorCompatibility: payloadEffective.colorCompatibility,
|
|
352
|
+
wrap: payloadEffective.autoWrap,
|
|
353
|
+
padding: payloadEffective.padding,
|
|
354
|
+
charset: payloadEffective.charset,
|
|
355
|
+
},
|
|
356
|
+
{
|
|
357
|
+
onSegmentError: (segName, message) =>
|
|
358
|
+
segmentErrors.set(segName, message),
|
|
359
|
+
},
|
|
360
|
+
{
|
|
361
|
+
look: lookKeyByName(config.looks, payloadEffective.look),
|
|
362
|
+
preset: payloadEffective.preset,
|
|
363
|
+
},
|
|
364
|
+
);
|
|
365
|
+
return { rendered, segmentErrors };
|
|
366
|
+
};
|
|
367
|
+
|
|
368
|
+
const primary = renderOnce(effective);
|
|
369
|
+
// [LAW:verifiable-goals] `.preset.customized` is the ONE gate this
|
|
370
|
+
// config surface adds that a rich, data-driven fixture (checkPayload's
|
|
371
|
+
// own stated design one comment up) can never drive true on its own —
|
|
372
|
+
// every OTHER field a segment might gate on is a VALUE checkPayload can
|
|
373
|
+
// just supply richly; this one is a daemon-resolved FACT about session
|
|
374
|
+
// state, not a hookData field a config author's own file ever carries.
|
|
375
|
+
// Without a second pass, a typo or MissingFieldError inside a user's
|
|
376
|
+
// OWN `when: '{{ .preset.customized }}'`-gated content (docs/
|
|
377
|
+
// interaction-authoring.md's own documented pattern) would pass check
|
|
378
|
+
// clean and only surface later as a live ⚠ error cell. Second pass
|
|
379
|
+
// only — the RETURNED rendering stays the realistic default (a fresh
|
|
380
|
+
// session has never customized anything); this pass exists purely to
|
|
381
|
+
// catch broken content behind the one gate the first pass can't reach.
|
|
382
|
+
const customizedCheck = renderOnce({
|
|
383
|
+
...effective,
|
|
384
|
+
presetCustomized: true,
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
// [LAW:no-silent-failure] An UNCONDITIONAL segment error (one whose
|
|
388
|
+
// `when`, if any, is true in both passes — the two renders share the
|
|
389
|
+
// same config/store/registry and differ only in `presetCustomized`)
|
|
390
|
+
// fires in BOTH passes identically. Deduped by segment NAME rather than
|
|
391
|
+
// concatenated: a customizedCheck error is only genuinely NEW
|
|
392
|
+
// information when primary didn't already report that same segment —
|
|
393
|
+
// reporting it twice would double-count one bug and the "(under
|
|
394
|
+
// .preset.customized = true)" tag would misdirect the reader into
|
|
395
|
+
// thinking it's specific to that gate when it isn't.
|
|
396
|
+
const errors = [
|
|
397
|
+
...[...primary.segmentErrors].map(
|
|
398
|
+
([segName, message]) => `segment "${segName}": ${message}`,
|
|
399
|
+
),
|
|
400
|
+
...[...customizedCheck.segmentErrors]
|
|
401
|
+
.filter(([segName]) => !primary.segmentErrors.has(segName))
|
|
402
|
+
.map(
|
|
403
|
+
([segName, message]) =>
|
|
404
|
+
`segment "${segName}": ${message} (under .preset.customized = true)`,
|
|
405
|
+
),
|
|
406
|
+
];
|
|
407
|
+
if (errors.length > 0) {
|
|
339
408
|
throw new Error(
|
|
340
|
-
`config renders with ${
|
|
341
|
-
|
|
409
|
+
`config renders with ${errors.length} segment error${
|
|
410
|
+
errors.length === 1 ? "" : "s"
|
|
342
411
|
} (the daemon would render ⚠ error cells):\n` +
|
|
343
|
-
|
|
412
|
+
errors.map((m) => ` ${m}`).join("\n"),
|
|
344
413
|
);
|
|
345
414
|
}
|
|
346
|
-
return rendered;
|
|
415
|
+
return primary.rendered;
|
|
347
416
|
} finally {
|
|
348
417
|
// [LAW:single-enforcer] The registry owns every async handle the config
|
|
349
418
|
// declared (timers, fs watchers, git subscriptions); a one-shot check must
|
|
@@ -378,6 +378,17 @@ export const RAW_DEFAULT_DSL_CONFIG = {
|
|
|
378
378
|
path: "preset.effective",
|
|
379
379
|
default: "",
|
|
380
380
|
},
|
|
381
|
+
// [LAW:one-source-of-truth] brandon-layout-edit-2gc.5 — presetIsCustomized
|
|
382
|
+
// over the SAME reload's presetRootOps, resolved alongside preset.effective
|
|
383
|
+
// (RenderPayload.preset.customized). edit-chrome.ts's synthesized "↺ …
|
|
384
|
+
// customized" segment gates on this directly; a hand-authored config can
|
|
385
|
+
// read it too for its own reset affordance.
|
|
386
|
+
"preset.customized": {
|
|
387
|
+
kind: "input",
|
|
388
|
+
path: "preset.customized",
|
|
389
|
+
type: "boolean",
|
|
390
|
+
default: false,
|
|
391
|
+
},
|
|
381
392
|
// [LAW:one-type-per-behavior] style/charset/colorCompatibility/autoWrap/
|
|
382
393
|
// padding are theme/look's twins over the remaining persistable globals
|
|
383
394
|
// (candybar-config-engine-71o.3) — the SAME values BuildLineOptions
|
|
@@ -32,6 +32,8 @@ import type {
|
|
|
32
32
|
} from "./dsl-types.js";
|
|
33
33
|
import { collectSegmentNames } from "./layout-ops.js";
|
|
34
34
|
import { presetByName, presetNames, presetRoot } from "./presets.js";
|
|
35
|
+
import { presetRootOpsKey } from "./loader/persist-target.js";
|
|
36
|
+
import { ident } from "./ident.js";
|
|
35
37
|
import {
|
|
36
38
|
EDIT_MODE_GATE,
|
|
37
39
|
EDIT_NS,
|
|
@@ -51,6 +53,16 @@ import {
|
|
|
51
53
|
disclosureStateVar,
|
|
52
54
|
} from "./disclosure.js";
|
|
53
55
|
|
|
56
|
+
// [LAW:dataflow-not-control-flow] brandon-layout-edit-2gc.5's diagnostic gate
|
|
57
|
+
// — read the SAME way EDIT_MODE_GATE is: a bare boolean input var, false
|
|
58
|
+
// only on the literal text "false" (evaluateWhen's documented contract).
|
|
59
|
+
// `.preset.customized` is a per-render payload fact (presetIsCustomized over
|
|
60
|
+
// entry.state.presetRootOps for whichever preset is ACTIVE), not config-time
|
|
61
|
+
// knowledge, so the banner below is spliced UNCONDITIONALLY for every
|
|
62
|
+
// preset — same shape, every reload — and this predicate is what decides
|
|
63
|
+
// whether it's visible, never a branch in this synthesis pass.
|
|
64
|
+
const PRESET_CUSTOMIZED_GATE = "{{ .preset.customized }}";
|
|
65
|
+
|
|
54
66
|
// [LAW:one-source-of-truth] group/menu-synthesized segments (`groups.`/
|
|
55
67
|
// `menus.`) and edit mode's own trigger/chrome (`edit.`) are structural —
|
|
56
68
|
// removing one via `-` would strand its sibling artifacts (a toggle segment
|
|
@@ -65,13 +77,15 @@ function isChromeExempt(name: string): boolean {
|
|
|
65
77
|
);
|
|
66
78
|
}
|
|
67
79
|
|
|
68
|
-
// [LAW:
|
|
69
|
-
//
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
|
|
74
|
-
|
|
80
|
+
// [LAW:no-silent-failure] Go-template string-literal escaping for a preset
|
|
81
|
+
// NAME spliced into DISPLAY text (prependCustomizedBanner) rather than an
|
|
82
|
+
// identifier — the same hazard loader/layout.ts's group `label` synthesis
|
|
83
|
+
// already guards against, reimplemented here since that copy is
|
|
84
|
+
// module-private and this one small rule doesn't warrant its own shared
|
|
85
|
+
// module the way `ident` (checked for agreement across three sites —
|
|
86
|
+
// see ./ident.ts) did.
|
|
87
|
+
function escapeTemplateLiteral(s: string): string {
|
|
88
|
+
return s.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
75
89
|
}
|
|
76
90
|
|
|
77
91
|
// [LAW:one-source-of-truth] Every synthesized decl this pass produces, keyed
|
|
@@ -258,6 +272,55 @@ function spliceContainer(
|
|
|
258
272
|
return { ...node, children };
|
|
259
273
|
}
|
|
260
274
|
|
|
275
|
+
// brandon-layout-edit-2gc.5's other per-preset affordance: a `+`/`-` sibling
|
|
276
|
+
// that isn't about ONE gap but about the preset's rootOps log as a whole —
|
|
277
|
+
// synthesized the SAME way (one reset action targeting this preset's exact
|
|
278
|
+
// `persist` key, one segment hosting `{{ action }}`), UNCONDITIONALLY, with
|
|
279
|
+
// visibility carried entirely by PRESET_CUSTOMIZED_GATE
|
|
280
|
+
// [LAW:dataflow-not-control-flow]. Prepended as an extra ROW (not spliced
|
|
281
|
+
// into the row-interleaved chrome spliceContainer builds) because it is not
|
|
282
|
+
// bound to any one segment gap — it is a fact about the whole tree — so it
|
|
283
|
+
// gets its own line above it, visible or not by the SAME `when` every other
|
|
284
|
+
// synthesized affordance here already uses.
|
|
285
|
+
function prependCustomizedBanner(
|
|
286
|
+
splicedRoot: LayoutNode,
|
|
287
|
+
presetName: string,
|
|
288
|
+
presetIdent: string,
|
|
289
|
+
rootOpsKey: string,
|
|
290
|
+
artifacts: ChromeArtifacts,
|
|
291
|
+
): LayoutNode {
|
|
292
|
+
const actionName = `${EDIT_NS}${presetIdent}.resetLayout`;
|
|
293
|
+
const chromeSegName = `${EDIT_NS}${presetIdent}.customized`;
|
|
294
|
+
// [LAW:no-silent-failure] `reset` clears `rootOpsKey` outright, restoring
|
|
295
|
+
// presetRoot's own fallback (the config's literal, hand-authored root) on
|
|
296
|
+
// the next reload — the exact undo the ticket's guardrail asked for.
|
|
297
|
+
// `rootOpsKey` is gated the SAME way every other `presets.<name>.rootOps`
|
|
298
|
+
// target is (deriveConfigActionValidators), and is ALWAYS a registered
|
|
299
|
+
// key — config-validators.ts's presetRootOpsContributions registers it
|
|
300
|
+
// for every declared preset UNCONDITIONALLY, specifically so a preset
|
|
301
|
+
// edited down to zero non-exempt segments (no removeChrome/insertChrome
|
|
302
|
+
// persist actions left to register it) doesn't orphan this exact click.
|
|
303
|
+
artifacts.actions[actionName] = { reset: rootOpsKey };
|
|
304
|
+
const label = escapeTemplateLiteral(presetName);
|
|
305
|
+
artifacts.segments[chromeSegName] = {
|
|
306
|
+
template: `{{ action "${actionName}" "↺ ${label} customized" }}`,
|
|
307
|
+
when: PRESET_CUSTOMIZED_GATE,
|
|
308
|
+
};
|
|
309
|
+
// [LAW:no-silent-failure] A preset's root may carry its OWN top-level
|
|
310
|
+
// `when` (the A-grammar's container schemas all permit one) — an author
|
|
311
|
+
// gating the whole preset behind a condition. `spliceContainer` preserves
|
|
312
|
+
// that onto `splicedRoot` via its `{...node, children}` spread, but this
|
|
313
|
+
// new OUTER wrapper is a brand-new node with no `when` of its own; without
|
|
314
|
+
// carrying it up, the reset banner would render even when the author's
|
|
315
|
+
// own condition is false, leaking past a gate they wrote.
|
|
316
|
+
return {
|
|
317
|
+
kind: "container",
|
|
318
|
+
direction: "vertical",
|
|
319
|
+
children: [{ kind: "segment", name: chromeSegName }, splicedRoot],
|
|
320
|
+
...(splicedRoot.when !== undefined && { when: splicedRoot.when }),
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
|
|
261
324
|
// One preset's chrome-spliced root. A bare-segment root (the A-grammar
|
|
262
325
|
// collapses a single top-level segment ref to `{ kind: "segment", name }`
|
|
263
326
|
// with no enclosing container) is wrapped in a synthetic horizontal
|
|
@@ -269,15 +332,28 @@ function spliceEditChromeForPreset(
|
|
|
269
332
|
artifacts: ChromeArtifacts,
|
|
270
333
|
): LayoutNode {
|
|
271
334
|
const { node } = presetRoot(config, presetName);
|
|
272
|
-
const rootOpsKey =
|
|
335
|
+
const rootOpsKey = presetRootOpsKey(presetName);
|
|
273
336
|
const domainName = addableDomainName(presetName);
|
|
274
337
|
const presetIdent = ident(presetName);
|
|
275
338
|
const posCounter = { n: 0 };
|
|
339
|
+
// [LAW:no-silent-failure] The bare-segment-root case (the A-grammar's
|
|
340
|
+
// `{ seg, when }` shorthand is a legal PresetDecl.root) carries its OWN
|
|
341
|
+
// `when` onto this synthetic wrapper too — prependCustomizedBanner's own
|
|
342
|
+
// when-carry-up reads `splicedRoot.when`, which is this wrapper's `when`
|
|
343
|
+
// once spliceContainer's `{...node, children}` passes it through
|
|
344
|
+
// unchanged; without copying it here, a bare-segment preset root's own
|
|
345
|
+
// gate would never reach that carry-up at all, leaking the reset banner
|
|
346
|
+
// past it exactly like the container case did before that fix.
|
|
276
347
|
const container: ContainerNode =
|
|
277
348
|
node.kind === "container"
|
|
278
349
|
? node
|
|
279
|
-
: {
|
|
280
|
-
|
|
350
|
+
: {
|
|
351
|
+
kind: "container",
|
|
352
|
+
direction: "horizontal",
|
|
353
|
+
children: [node],
|
|
354
|
+
...(node.when !== undefined && { when: node.when }),
|
|
355
|
+
};
|
|
356
|
+
const spliced = spliceContainer(
|
|
281
357
|
container,
|
|
282
358
|
presetIdent,
|
|
283
359
|
rootOpsKey,
|
|
@@ -285,6 +361,13 @@ function spliceEditChromeForPreset(
|
|
|
285
361
|
artifacts,
|
|
286
362
|
posCounter,
|
|
287
363
|
);
|
|
364
|
+
return prependCustomizedBanner(
|
|
365
|
+
spliced,
|
|
366
|
+
presetName,
|
|
367
|
+
presetIdent,
|
|
368
|
+
rootOpsKey,
|
|
369
|
+
artifacts,
|
|
370
|
+
);
|
|
288
371
|
}
|
|
289
372
|
|
|
290
373
|
// [LAW:single-enforcer] THE synthesis entry point, called once from
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// [LAW:one-source-of-truth] THE identifier-collapse rule — collapse an
|
|
2
|
+
// arbitrary name (a segment, action, preset, or menu-host name) to an
|
|
3
|
+
// identifier-shaped fragment so synthesized var/action/segment names carry
|
|
4
|
+
// no dots, brackets, or other characters that would break a template field
|
|
5
|
+
// path or a synthesis-time accumulator key. Every non-alphanumeric RUN
|
|
6
|
+
// collapses to a single `_`.
|
|
7
|
+
//
|
|
8
|
+
// [LAW:no-silent-failure] Multiple sites depend on this SAME collapse
|
|
9
|
+
// producing the SAME result for the SAME input: menu-keys.ts derives a
|
|
10
|
+
// menu's synthesized SessionState/action identity from it; edit-chrome.ts
|
|
11
|
+
// keys synthesized per-preset reset/±-chrome artifacts by it;
|
|
12
|
+
// loader/cross-ref.ts's presetIdentCollisions checks that no two preset
|
|
13
|
+
// names collide under it BEFORE edit-chrome.ts ever runs. Before this
|
|
14
|
+
// module existed, three call sites reimplemented the same regex
|
|
15
|
+
// independently (module-privacy taken too far) — a genuine drift risk: if
|
|
16
|
+
// one copy changed without the others, the collision GUARD would stop
|
|
17
|
+
// matching the collision RULE it exists to enforce, silently reopening the
|
|
18
|
+
// exact "second preset steals the first's synthesized action" bug the
|
|
19
|
+
// guard was written to prevent. One function now; every site imports it.
|
|
20
|
+
export function ident(name: string): string {
|
|
21
|
+
return name.replace(/[^A-Za-z0-9]+/g, "_");
|
|
22
|
+
}
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
walkNodes,
|
|
12
12
|
type DslConfig,
|
|
13
13
|
type LayoutNode,
|
|
14
|
+
type PresetDecl,
|
|
14
15
|
type VariableDecl,
|
|
15
16
|
} from "../dsl-types.js";
|
|
16
17
|
import {
|
|
@@ -28,6 +29,7 @@ import {
|
|
|
28
29
|
import { listGlobalsFieldNames } from "./globals.js";
|
|
29
30
|
import { parsePersistTarget } from "./persist-target.js";
|
|
30
31
|
import { presetNames } from "../presets.js";
|
|
32
|
+
import { ident } from "../ident.js";
|
|
31
33
|
import { findKeyLine } from "./diagnostics.js";
|
|
32
34
|
import { isPlainObject, type ValidateCtx } from "./validate-core.js";
|
|
33
35
|
import {
|
|
@@ -47,6 +49,54 @@ export const RENAMED_SEGMENTS: Readonly<Record<string, string>> = {
|
|
|
47
49
|
gitTaculous: "gitaculous",
|
|
48
50
|
};
|
|
49
51
|
|
|
52
|
+
// [LAW:single-enforcer] Runs HERE — on `cfg.presets`, the MERGED map — not
|
|
53
|
+
// in loader/presets.ts's per-file structural pass (where a round-1 version
|
|
54
|
+
// of this check lived): that pass validates one config source at a time
|
|
55
|
+
// (the bundled default's own RAW_DEFAULT_DSL_CONFIG, or a user's file,
|
|
56
|
+
// independently), so it could only ever catch a collision between two
|
|
57
|
+
// preset names declared in ONE source. synthesizeEditChrome — the thing
|
|
58
|
+
// this guard protects, which keys its per-preset reset action/segment (and
|
|
59
|
+
// the pre-existing per-gap +/- actions) by `ident(presetName)` in a plain
|
|
60
|
+
// object accumulator with no re-entrant cross-ref check — runs on the
|
|
61
|
+
// MERGED config (dsl-loader.ts), so the collision it can actually produce
|
|
62
|
+
// is a merged one: a user preset whose ident collides with a name the
|
|
63
|
+
// BUNDLED library or a different file contributed. This is the one place
|
|
64
|
+
// that sees that merged set, so it's the one place that can prove no
|
|
65
|
+
// collision exists in it.
|
|
66
|
+
//
|
|
67
|
+
// [LAW:one-source-of-truth] `ident` is imported from ../ident.ts — the ONE
|
|
68
|
+
// collapse rule menu-keys.ts, edit-chrome.ts, and this guard all now share,
|
|
69
|
+
// so a future tweak to the rule can't silently desync the guard from the
|
|
70
|
+
// thing it checks.
|
|
71
|
+
//
|
|
72
|
+
// [LAW:no-silent-failure] Two preset names that collapse to the SAME
|
|
73
|
+
// synthesis identifier (e.g. "quick-look" and "quick_look" both → "quick_
|
|
74
|
+
// look") would silently steal each other's synthesized artifacts: the
|
|
75
|
+
// SECOND preset processed overwrites the first's entries, leaving the
|
|
76
|
+
// first preset's already-built tree holding a segment ref to a name that
|
|
77
|
+
// now points at the second preset's reset action. A user clicking "reset"
|
|
78
|
+
// on preset A would silently reset preset B instead.
|
|
79
|
+
function presetIdentCollisions(
|
|
80
|
+
ctx: ValidateCtx,
|
|
81
|
+
presets: Readonly<Record<string, PresetDecl>>,
|
|
82
|
+
): void {
|
|
83
|
+
const byIdent = new Map<string, string[]>();
|
|
84
|
+
for (const name of Object.keys(presets)) {
|
|
85
|
+
const id = ident(name);
|
|
86
|
+
const names = byIdent.get(id);
|
|
87
|
+
if (names) names.push(name);
|
|
88
|
+
else byIdent.set(id, [name]);
|
|
89
|
+
}
|
|
90
|
+
for (const [id, names] of byIdent) {
|
|
91
|
+
if (names.length < 2) continue;
|
|
92
|
+
ctx.issues.push({
|
|
93
|
+
path: "presets",
|
|
94
|
+
message: `preset names ${names.map((n) => JSON.stringify(n)).join(" and ")} both collapse to the same synthesis identifier "${id}" — edit mode's synthesized reset affordance would silently steal one preset's action for the other. Rename one.`,
|
|
95
|
+
line: findKeyLine(ctx.source, ["presets"]),
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
50
100
|
export function validateCrossReferences(
|
|
51
101
|
ctx: ValidateCtx,
|
|
52
102
|
cfg: DslConfig,
|
|
@@ -83,6 +133,7 @@ export function validateCrossReferences(
|
|
|
83
133
|
line: findKeyLine(ctx.source, ["globals", "preset"]),
|
|
84
134
|
});
|
|
85
135
|
}
|
|
136
|
+
presetIdentCollisions(ctx, cfg.presets);
|
|
86
137
|
// [LAW:one-source-of-truth] A `set … from` NAME must resolve — checked
|
|
87
138
|
// against this config's per-config domains ("looks", the merged looks:
|
|
88
139
|
// block) plus the global registry (themes/styles, and any future
|
|
@@ -34,7 +34,26 @@ export type PersistTarget =
|
|
|
34
34
|
// `presets.<name>.root` (src/config/presets.ts), a different namespace this
|
|
35
35
|
// key must never be confused with.
|
|
36
36
|
const SEGMENT_PALETTE_KEY = /^segments\.([^.]+)\.palette$/;
|
|
37
|
-
|
|
37
|
+
// [LAW:one-source-of-truth] GREEDY capture, not `[^.]+` — a preset name is
|
|
38
|
+
// validated only non-empty/slash/newline-free (loader/presets.ts), so a dot
|
|
39
|
+
// is a legal preset name (e.g. "v1.compact"). presetRootOpsKey always
|
|
40
|
+
// appends the literal ".rootOps" suffix, so a greedy `(.+)` backtracks to
|
|
41
|
+
// the RIGHTMOST occurrence of that anchored suffix and correctly recovers
|
|
42
|
+
// the full name for ANY preset name, dotted or not — round-tripping
|
|
43
|
+
// presetRootOpsKey exactly, rather than restricting preset names further
|
|
44
|
+
// to dodge the ambiguity a non-greedy/exclusive-dot capture would have.
|
|
45
|
+
const PRESET_ROOT_OPS_KEY = /^presets\.(.+)\.rootOps$/;
|
|
46
|
+
|
|
47
|
+
// [LAW:one-source-of-truth] THE builder for a preset's rootOps key — the
|
|
48
|
+
// inverse of PRESET_ROOT_OPS_KEY's parse. Two independent sites used to
|
|
49
|
+
// spell `presets.${name}.rootOps` themselves (edit-chrome.ts's synthesis,
|
|
50
|
+
// config-validators.ts's always-registered contribution), with only the
|
|
51
|
+
// regex above as a read-side authority and no shared write-side one — they
|
|
52
|
+
// happened to agree, but nothing enforced it. One function now; both call
|
|
53
|
+
// sites import it.
|
|
54
|
+
export function presetRootOpsKey(name: string): string {
|
|
55
|
+
return `presets.${name}.rootOps`;
|
|
56
|
+
}
|
|
38
57
|
|
|
39
58
|
export function parsePersistTarget(key: string): PersistTarget | null {
|
|
40
59
|
if (isGlobalsField(key)) return { scope: "globals", field: key };
|
|
@@ -85,10 +85,22 @@ export function validatePresets(
|
|
|
85
85
|
// config load, not only once an action ranges the "presets" domain (the
|
|
86
86
|
// identical guard looks.ts applies to look names, for the identical
|
|
87
87
|
// reason).
|
|
88
|
-
|
|
88
|
+
//
|
|
89
|
+
// [LAW:one-source-of-truth] Newlines are ALSO rejected — the same reason
|
|
90
|
+
// loader/layout.ts's groupLabelSpec rejects \n/\r in a group's `label`
|
|
91
|
+
// before it ever reaches escapeTemplateLiteral: a preset name is spliced
|
|
92
|
+
// as DISPLAY TEXT into a synthesized Go-template string literal
|
|
93
|
+
// (edit-chrome.ts's "customized" banner), and that escaper only handles
|
|
94
|
+
// backslash/quote — an embedded newline produces an unterminated string
|
|
95
|
+
// literal go-template-js forbids, breaking synthesis for the WHOLE
|
|
96
|
+
// config, not just this one preset. Unlike a label, a preset name is
|
|
97
|
+
// also an identifier used across other seams (the `presets` domain, the
|
|
98
|
+
// `presets.<name>.rootOps` wire key), so this belongs in its general
|
|
99
|
+
// validity check, not a narrower escape-harder fix at the one splice site.
|
|
100
|
+
if (name === "" || name.includes("/") || /[\n\r]/.test(name)) {
|
|
89
101
|
ctx.issues.push({
|
|
90
102
|
path: `presets.${name}`,
|
|
91
|
-
message: `preset name ${JSON.stringify(name)} must be non-empty and
|
|
103
|
+
message: `preset name ${JSON.stringify(name)} must be non-empty, slash-free, and newline-free — a preset picker writes the name on the set-state wire (which rejects empty values and splits on "/"), and edit mode splices it into a synthesized template string`,
|
|
92
104
|
line: findKeyLine(ctx.source, ["presets", name]),
|
|
93
105
|
});
|
|
94
106
|
continue;
|
package/src/config/menu-keys.ts
CHANGED
|
@@ -22,6 +22,8 @@
|
|
|
22
22
|
// the key" position magic: identity depends only on names a reader
|
|
23
23
|
// can see in the template, never on tree position.
|
|
24
24
|
|
|
25
|
+
import { ident } from "./ident.js";
|
|
26
|
+
|
|
25
27
|
// [LAW:one-source-of-truth] The reserved namespace every synthesized menu
|
|
26
28
|
// artifact (state var + cycle action) lives under, mirroring group sugar's
|
|
27
29
|
// `groups.`. A user-authored name under this prefix is a load error so synthesis
|
|
@@ -34,14 +36,6 @@ export const MENU_NS = "menus.";
|
|
|
34
36
|
// drift. This module keeps only the menu's own IDENTITY derivation (member = apply
|
|
35
37
|
// name; key = optional shared key), which group sugar derives differently.
|
|
36
38
|
|
|
37
|
-
// [LAW:types-are-the-program] Collapse an arbitrary name to an identifier-shaped
|
|
38
|
-
// id so the synthesized var/action/SessionState-key names carry no dots or
|
|
39
|
-
// brackets that would break template field paths. Distinct names never collide
|
|
40
|
-
// under this map for the alphanumeric segment/action names the config uses.
|
|
41
|
-
function ident(name: string): string {
|
|
42
|
-
return name.replace(/[^A-Za-z0-9]+/g, "_");
|
|
43
|
-
}
|
|
44
|
-
|
|
45
39
|
// [LAW:single-enforcer] A menu's member name IS its apply-action name. Both the
|
|
46
40
|
// loader and the helper call this so neither restates the rule.
|
|
47
41
|
export function menuMember(applyName: string): string {
|
package/src/config/presets.ts
CHANGED
|
@@ -188,6 +188,36 @@ export function sanitizePersistedPresetOverride(
|
|
|
188
188
|
return rest;
|
|
189
189
|
}
|
|
190
190
|
|
|
191
|
+
// [LAW:no-silent-failure] sanitizePersistedPresetOverride's twin for the
|
|
192
|
+
// SAME machine-global overrides file, one field over: `presets.<name>.
|
|
193
|
+
// rootOps` entries persist by NAME, and that name only has meaning against
|
|
194
|
+
// the config it was clicked against. Loading a DIFFERENT project's config
|
|
195
|
+
// — one that never declared "compact", say — would otherwise reach
|
|
196
|
+
// applyPresetRootOpsOverrides -> presetRoot -> presetByName, which THROWS
|
|
197
|
+
// for an undeclared name (correctly so for a hand-AUTHORED preset root: a
|
|
198
|
+
// typo in a file the author can see). A stale overrides entry is not that
|
|
199
|
+
// — it is the expected shape of a file that outlives any one project's
|
|
200
|
+
// preset names — so it gets the SAME treatment sanitizePersistedPresetOverride
|
|
201
|
+
// already gives `globals.preset`: dropped before replay ever sees it,
|
|
202
|
+
// never a fatal error replacing an unrelated project's entire bar
|
|
203
|
+
// (brandon-layout-edit-2gc.5 PR review).
|
|
204
|
+
export function sanitizePersistedPresetRootOps(
|
|
205
|
+
presetRootOps: Readonly<Record<string, readonly string[]>>,
|
|
206
|
+
declaredPresets: Readonly<Record<string, PresetDecl>>,
|
|
207
|
+
): Readonly<Record<string, readonly string[]>> {
|
|
208
|
+
const known = new Set(presetNames(declaredPresets));
|
|
209
|
+
const entries = Object.entries(presetRootOps).filter(([name]) =>
|
|
210
|
+
known.has(name),
|
|
211
|
+
);
|
|
212
|
+
// [LAW:carrying-cost] Identity return when nothing was dropped — the
|
|
213
|
+
// common case (this daemon serves one project, or every persisted name
|
|
214
|
+
// happens to be declared) costs one Set build and no new allocation.
|
|
215
|
+
if (entries.length === Object.keys(presetRootOps).length) {
|
|
216
|
+
return presetRootOps;
|
|
217
|
+
}
|
|
218
|
+
return Object.fromEntries(entries);
|
|
219
|
+
}
|
|
220
|
+
|
|
191
221
|
// [LAW:one-source-of-truth] brandon-layout-edit-2gc.1's replay step — the
|
|
192
222
|
// SAME "patch an already-merged config" shape applySegmentPaletteOverrides
|
|
193
223
|
// (src/config/loader/merge.ts) uses one field over, run at the SAME point in
|
|
@@ -208,6 +238,48 @@ export function sanitizePersistedPresetOverride(
|
|
|
208
238
|
// already removed it. A malformed individual token (decodeLayoutOp -> null;
|
|
209
239
|
// can only arise from hand-edited or previous-version state, never from this
|
|
210
240
|
// process's own encodeLayoutOp) is filtered the same way, never applied.
|
|
241
|
+
// [LAW:one-source-of-truth] brandon-layout-edit-2gc.5's diagnostic seam — the
|
|
242
|
+
// ONE definition of "does this preset's rendered layout currently differ
|
|
243
|
+
// from what's literally declared in the config file", read from the SAME
|
|
244
|
+
// raw op-log record applyPresetRootOpsOverrides replays (never re-derived by
|
|
245
|
+
// diffing the replayed tree against a fresh presetRoot() call, which would
|
|
246
|
+
// be a second, weaker definition — a structural coincidence between two
|
|
247
|
+
// unrelated edits could equal zero net ops and still disagree with the raw
|
|
248
|
+
// log's own length). A name absent from the record (never edited) and a
|
|
249
|
+
// name present with an empty token list (edited down to nothing, e.g. by
|
|
250
|
+
// undo) are the SAME "not customized" — presence in the record only ever
|
|
251
|
+
// means "this reload's overrides file had an entry", not "and it's non-
|
|
252
|
+
// empty" [LAW:no-defensive-null-guards].
|
|
253
|
+
//
|
|
254
|
+
// Decodes each token the SAME way applyPresetRootOpsOverrides does, rather
|
|
255
|
+
// than trusting the raw token COUNT — a token list where every token
|
|
256
|
+
// decodes to null (malformed, or written by a previous protocol version)
|
|
257
|
+
// is exactly the case applyPresetRootOpsOverrides itself treats as "no ops
|
|
258
|
+
// to replay" (`ops.length === 0 → continue`, leaving the root untouched).
|
|
259
|
+
// Counting raw tokens would disagree with that: "customized" would read
|
|
260
|
+
// true over a tree that is, in fact, byte-identical to the literal
|
|
261
|
+
// declared root — the inverted form of the drift this diagnostic exists to
|
|
262
|
+
// catch.
|
|
263
|
+
//
|
|
264
|
+
// [LAW:carrying-cost] Verifies DECODE only, not whether the decoded op's
|
|
265
|
+
// target/anchor still resolves against the CURRENT tree — that check would
|
|
266
|
+
// need this to duplicate applyLayoutOps' own stale-target resolution
|
|
267
|
+
// (a real walk of the tree) just to COUNT, on every render, a fact only a
|
|
268
|
+
// LATER hand-authored config edit can produce (removing a segment a stored
|
|
269
|
+
// op still names — see the stale-op comment on applyPresetRootOpsOverrides
|
|
270
|
+
// above). A rare, later-edit-triggered false "customized" is the honest,
|
|
271
|
+
// documented cost of keeping this an O(tokens) check rather than an O(tree)
|
|
272
|
+
// one; the doc at docs/interaction-authoring.md's "Knowing when a preset's
|
|
273
|
+
// layout has been edited" section states this same limit.
|
|
274
|
+
export function presetIsCustomized(
|
|
275
|
+
presetRootOps: Readonly<Record<string, readonly string[]>>,
|
|
276
|
+
name: string,
|
|
277
|
+
): boolean {
|
|
278
|
+
const tokens = presetRootOps[name];
|
|
279
|
+
if (tokens === undefined) return false;
|
|
280
|
+
return tokens.some((t) => decodeLayoutOp(t) !== null);
|
|
281
|
+
}
|
|
282
|
+
|
|
211
283
|
export function applyPresetRootOpsOverrides(
|
|
212
284
|
config: DslConfig,
|
|
213
285
|
presetRootOps: Readonly<Record<string, readonly string[]>>,
|