@promptctl/cc-candybar 1.42.1 → 1.44.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 +98 -84
- package/package.json +5 -6
- package/src/check.ts +0 -478
- package/src/cli-flags.ts +0 -8
- package/src/click/wire.ts +0 -158
- package/src/config/action.ts +0 -329
- package/src/config/cli.ts +0 -71
- package/src/config/default-dsl-config.ts +0 -1645
- package/src/config/disclosure.ts +0 -170
- package/src/config/dsl-loader.ts +0 -339
- package/src/config/dsl-types.ts +0 -581
- package/src/config/edit-chrome.ts +0 -559
- package/src/config/help.ts +0 -151
- package/src/config/ident.ts +0 -22
- package/src/config/layout-ops.ts +0 -177
- package/src/config/loader/actions.ts +0 -972
- package/src/config/loader/cache.ts +0 -206
- package/src/config/loader/cross-ref.ts +0 -714
- package/src/config/loader/cycles.ts +0 -148
- package/src/config/loader/diagnostics.ts +0 -99
- package/src/config/loader/discovery.ts +0 -182
- package/src/config/loader/edit-mode.ts +0 -137
- package/src/config/loader/emit-schema.ts +0 -68
- package/src/config/loader/globals.ts +0 -269
- package/src/config/loader/helpers.ts +0 -48
- package/src/config/loader/layout.ts +0 -693
- package/src/config/loader/looks.ts +0 -96
- package/src/config/loader/menu-synth.ts +0 -435
- package/src/config/loader/merge.ts +0 -115
- package/src/config/loader/persist-target.ts +0 -67
- package/src/config/loader/presets.ts +0 -119
- package/src/config/loader/refs.ts +0 -100
- package/src/config/loader/reserved-namespace.ts +0 -38
- package/src/config/loader/segments.ts +0 -120
- package/src/config/loader/validate-core.ts +0 -737
- package/src/config/loader/variables.ts +0 -260
- package/src/config/menu-keys.ts +0 -139
- package/src/config/option-domain.ts +0 -164
- package/src/config/presets.ts +0 -326
- package/src/config/settings-menu.ts +0 -775
- package/src/daemon/acquire.ts +0 -684
- package/src/daemon/cache/git.ts +0 -649
- package/src/daemon/cache/render.ts +0 -623
- package/src/daemon/cache/session-usage-store.ts +0 -720
- package/src/daemon/cache/watchers.ts +0 -249
- package/src/daemon/client-debug.ts +0 -120
- package/src/daemon/client-stats.ts +0 -130
- package/src/daemon/client-transport.ts +0 -273
- package/src/daemon/client.ts +0 -78
- package/src/daemon/config-overrides-store.ts +0 -663
- package/src/daemon/debug-types.ts +0 -91
- package/src/daemon/debug.ts +0 -264
- package/src/daemon/fork-bomb-breaker.ts +0 -351
- package/src/daemon/limits.ts +0 -211
- package/src/daemon/log.ts +0 -81
- package/src/daemon/parent-watchdog.ts +0 -87
- package/src/daemon/paths.ts +0 -211
- package/src/daemon/process-fingerprint.ts +0 -146
- package/src/daemon/protocol.ts +0 -292
- package/src/daemon/render-payload.ts +0 -1256
- package/src/daemon/server.ts +0 -1330
- package/src/daemon/session-state-file.ts +0 -108
- package/src/daemon/session-state.ts +0 -237
- package/src/daemon/socket-lease.ts +0 -209
- package/src/daemon/socket-ownership.ts +0 -209
- package/src/daemon/stats.ts +0 -235
- package/src/daemon/verbs/config-validators.ts +0 -250
- package/src/daemon/verbs/index.ts +0 -706
- package/src/daemon/verbs/state-validators.ts +0 -249
- package/src/daemon/verbs/validator-registry.ts +0 -457
- package/src/demo/dsl.ts +0 -143
- package/src/demo/mock-data.ts +0 -67
- package/src/demo/statusline.json5 +0 -94
- package/src/dsl/node-registry.ts +0 -374
- package/src/dsl/render.ts +0 -803
- package/src/help-text.ts +0 -90
- package/src/index.ts +0 -210
- package/src/install/currency.ts +0 -197
- package/src/install/index.ts +0 -557
- package/src/proc/launch.ts +0 -459
- package/src/proc/stats-handle.ts +0 -13
- package/src/render/action.ts +0 -883
- package/src/render/active-segment.ts +0 -78
- package/src/render/diagnostic-style.ts +0 -23
- package/src/render/diagnostic-text.ts +0 -77
- package/src/render/error-glyph.ts +0 -53
- package/src/render/menu.ts +0 -257
- package/src/render/outcome-plan.ts +0 -45
- package/src/render/picker.ts +0 -372
- package/src/render/segment-color.ts +0 -74
- package/src/render/split-lines.ts +0 -51
- package/src/render/strip.ts +0 -228
- package/src/segments/cache.ts +0 -131
- package/src/segments/context.ts +0 -190
- package/src/segments/git.ts +0 -1084
- package/src/segments/metrics.ts +0 -187
- package/src/segments/pricing.ts +0 -452
- package/src/segments/session.ts +0 -23
- package/src/segments/tmux.ts +0 -74
- package/src/template-engine/cells.ts +0 -90
- package/src/template-engine/colors.ts +0 -124
- package/src/template-engine/engine.ts +0 -108
- package/src/template-engine/funcs.ts +0 -232
- package/src/template-engine/index.ts +0 -11
- package/src/template-engine/layout.ts +0 -133
- package/src/template-engine/scope.ts +0 -62
- package/src/template-engine/sparkline.ts +0 -79
- package/src/themes/index.ts +0 -20
- package/src/themes/palette-resolvers.ts +0 -84
- package/src/themes/policy.ts +0 -393
- package/src/utils/cache.ts +0 -206
- package/src/utils/claude.ts +0 -683
- package/src/utils/color-support.ts +0 -118
- package/src/utils/formatters.ts +0 -99
- package/src/utils/logger.ts +0 -5
- package/src/utils/outcome.ts +0 -33
- package/src/utils/schema-validator.ts +0 -126
- package/src/utils/single-flight.ts +0 -57
- package/src/utils/terminal-width.ts +0 -51
- package/src/utils/terminal.ts +0 -11
- package/src/utils/transcript-fs.ts +0 -279
- package/src/var-system/index.ts +0 -24
- package/src/var-system/sources.ts +0 -1047
- package/src/var-system/store.ts +0 -223
- package/src/var-system/types.ts +0 -57
- package/src/version.ts +0 -17
|
@@ -1,96 +0,0 @@
|
|
|
1
|
-
// [LAW:types-are-the-program] The `looks` schema: each look is a named rich-js
|
|
2
|
-
// ThemeKey — an ADAPTATION applied on top of whatever base theme is active (a
|
|
3
|
-
// transform, not a palette), so one look composes with every theme. The config
|
|
4
|
-
// spelling mirrors ThemeKey's field names VERBATIM (hueShift / chromaScale /
|
|
5
|
-
// lightnessScale / lightnessShift) and the parsed output IS a rich-js ThemeKey —
|
|
6
|
-
// no translation layer to drift, and a rich-js field rename fails this module's
|
|
7
|
-
// compile instead of silently diverging. [LAW:one-source-of-truth]
|
|
8
|
-
//
|
|
9
|
-
// The adaptation vocabulary is CAPPED at ThemeKey's four axes. Role remap (the
|
|
10
|
-
// old surface/button "role emphasis") is deferred; its exit plan is a future
|
|
11
|
-
// rich-js resolver-level role→role operation carried as an additive `roles`
|
|
12
|
-
// field here — growing the vocabulary means growing rich-js, never adding color
|
|
13
|
-
// math to cc-candybar.
|
|
14
|
-
|
|
15
|
-
import type { ThemeKey } from "@promptctl/rich-js";
|
|
16
|
-
import { IDENTITY } from "@promptctl/rich-js";
|
|
17
|
-
import {
|
|
18
|
-
describeType,
|
|
19
|
-
isPlainObject,
|
|
20
|
-
optionalNumberSpec,
|
|
21
|
-
record,
|
|
22
|
-
recordJson,
|
|
23
|
-
type JsonNode,
|
|
24
|
-
type RecordSchema,
|
|
25
|
-
type ValidateCtx,
|
|
26
|
-
} from "./validate-core.js";
|
|
27
|
-
import { findKeyLine } from "./diagnostics.js";
|
|
28
|
-
|
|
29
|
-
// [LAW:types-are-the-program] The AUTHORING shape: every axis optional, absent =
|
|
30
|
-
// identity. Distinct from ThemeKey (all fields required) so the record engine's
|
|
31
|
-
// omit-absent output is honestly typed; validateLooks normalizes each parsed
|
|
32
|
-
// spec onto IDENTITY, and past this module a partial look is unrepresentable.
|
|
33
|
-
interface LookSpec {
|
|
34
|
-
readonly hueShift?: number;
|
|
35
|
-
readonly chromaScale?: number;
|
|
36
|
-
readonly lightnessScale?: number;
|
|
37
|
-
readonly lightnessShift?: number;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
// [LAW:one-source-of-truth] The four axes, declared once as DATA the record
|
|
41
|
-
// engine interprets for both validation and schema emit. chromaScale is a
|
|
42
|
-
// multiplier on saturation — negative chroma is not a color, so the one bound.
|
|
43
|
-
const LOOK_SCHEMA: RecordSchema<LookSpec> = {
|
|
44
|
-
noun: "look key",
|
|
45
|
-
fields: {
|
|
46
|
-
hueShift: optionalNumberSpec(),
|
|
47
|
-
chromaScale: optionalNumberSpec({ min: 0 }),
|
|
48
|
-
lightnessScale: optionalNumberSpec(),
|
|
49
|
-
lightnessShift: optionalNumberSpec(),
|
|
50
|
-
},
|
|
51
|
-
};
|
|
52
|
-
|
|
53
|
-
// An absent looks block is handled by the caller (absence survives the parse);
|
|
54
|
-
// a non-object is a reported error recovering to empty — parseDslConfig throws
|
|
55
|
-
// once any issue exists, so the recovery value never renders.
|
|
56
|
-
export function validateLooks(
|
|
57
|
-
ctx: ValidateCtx,
|
|
58
|
-
raw: unknown,
|
|
59
|
-
): Readonly<Record<string, ThemeKey>> {
|
|
60
|
-
if (!isPlainObject(raw)) {
|
|
61
|
-
ctx.issues.push({
|
|
62
|
-
path: "looks",
|
|
63
|
-
message: `looks must be an object mapping look names to adaptation objects, got ${describeType(raw)}`,
|
|
64
|
-
line: findKeyLine(ctx.source, ["looks"]),
|
|
65
|
-
});
|
|
66
|
-
return {};
|
|
67
|
-
}
|
|
68
|
-
const out: Record<string, ThemeKey> = {};
|
|
69
|
-
for (const [name, value] of Object.entries(raw)) {
|
|
70
|
-
// [LAW:no-silent-fallbacks] A look name is a deliverable set-state value —
|
|
71
|
-
// a look picker writes it on the wire, which rejects empty values and
|
|
72
|
-
// splits on "/". Rejecting the shape HERE surfaces the error on every
|
|
73
|
-
// config load, not only once an action ranges the "looks" domain (the same
|
|
74
|
-
// wire shape cycle members and `to` literals enforce in actions.ts).
|
|
75
|
-
if (name === "" || name.includes("/")) {
|
|
76
|
-
ctx.issues.push({
|
|
77
|
-
path: `looks.${name}`,
|
|
78
|
-
message: `look name ${JSON.stringify(name)} must be non-empty and slash-free — a look picker writes the name on the set-state wire, which rejects empty values and splits on "/"`,
|
|
79
|
-
line: findKeyLine(ctx.source, ["looks", name]),
|
|
80
|
-
});
|
|
81
|
-
continue;
|
|
82
|
-
}
|
|
83
|
-
const parsed = record(ctx, LOOK_SCHEMA, `looks.${name}`, value);
|
|
84
|
-
// [LAW:one-source-of-truth] Normalization onto IDENTITY is the single
|
|
85
|
-
// "absent axis = identity" site — downstream consumers receive a total
|
|
86
|
-
// ThemeKey and never re-default a missing axis.
|
|
87
|
-
if (parsed !== null) out[name] = { ...IDENTITY, ...parsed };
|
|
88
|
-
}
|
|
89
|
-
return out;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
// [LAW:one-source-of-truth] The schema emitter derives from the SAME declaration
|
|
93
|
-
// the validator interprets — a map of look names to the closed four-axis object.
|
|
94
|
-
export function looksJson(): JsonNode {
|
|
95
|
-
return { type: "object", additionalProperties: recordJson(LOOK_SCHEMA) };
|
|
96
|
-
}
|
|
@@ -1,435 +0,0 @@
|
|
|
1
|
-
// [LAW:one-source-of-truth] The menu synthesis pass: the load-side mirror of the
|
|
2
|
-
// `{{ menu }}` render helper. A menu is the group-accordion mechanism with its
|
|
3
|
-
// trigger living inside an arbitrary user segment rather than a synthesized
|
|
4
|
-
// toggle segment — so this emits exactly what group sugar does MINUS the segment
|
|
5
|
-
// (the helper IS the trigger): one `state` var per menu state key (default
|
|
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
|
|
12
|
-
// like every other set, no parallel verb. [LAW:single-enforcer]
|
|
13
|
-
//
|
|
14
|
-
// Runs in `parseDslConfig` after group synthesis and after every section parsed,
|
|
15
|
-
// so the reserved-namespace collision check sees the fully-parsed user sections.
|
|
16
|
-
//
|
|
17
|
-
// [LAW:types-are-the-program] WHICH segments host a menu, and with WHAT apply
|
|
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).
|
|
27
|
-
|
|
28
|
-
import {
|
|
29
|
-
createEngine,
|
|
30
|
-
staticDictEntries,
|
|
31
|
-
type ReferencedCall,
|
|
32
|
-
} from "@promptctl/go-template-js";
|
|
33
|
-
import type { ActionDecl } from "../action.js";
|
|
34
|
-
import type { Mutable, ValidateCtx } from "./validate-core.js";
|
|
35
|
-
import {
|
|
36
|
-
MENU_NS,
|
|
37
|
-
menuActionName,
|
|
38
|
-
menuMember,
|
|
39
|
-
menuPageKey,
|
|
40
|
-
menuStateKey,
|
|
41
|
-
parseMenuOptions,
|
|
42
|
-
type MenuOptions,
|
|
43
|
-
} from "../menu-keys.js";
|
|
44
|
-
import {
|
|
45
|
-
cycleDisplayIssue,
|
|
46
|
-
DISCLOSURE_CLOSED,
|
|
47
|
-
disclosureCycleAction,
|
|
48
|
-
disclosureStateVar,
|
|
49
|
-
} from "../disclosure.js";
|
|
50
|
-
import {
|
|
51
|
-
walkNodes,
|
|
52
|
-
type RawDslConfig,
|
|
53
|
-
type VariableDecl,
|
|
54
|
-
} from "../dsl-types.js";
|
|
55
|
-
import { findKeyLine } from "./diagnostics.js";
|
|
56
|
-
import { reservedNamespaceCollisions } from "./reserved-namespace.js";
|
|
57
|
-
|
|
58
|
-
// [LAW:single-enforcer] The helper-name a `{{ menu … }}` call uses — the same
|
|
59
|
-
// string the render FuncMap registers. A segment "hosts a menu" iff its template
|
|
60
|
-
// references this function.
|
|
61
|
-
const MENU_FUNC = "menu";
|
|
62
|
-
|
|
63
|
-
// [LAW:types-are-the-program] The `{{ menu }}` surface, mirroring the render
|
|
64
|
-
// helper's signature `menu "apply" display… [(dict …)]`: the apply name
|
|
65
|
-
// (identity member, a required string literal), the trigger's authored display
|
|
66
|
-
// text (one per state or one static — the arity is statically countable, so it
|
|
67
|
-
// is checked here), and ONE optional trailing options dict — closeOnPick /
|
|
68
|
-
// paged / key, all statically readable via `staticDictEntries`. Displays
|
|
69
|
-
// themselves are NOT required to be literals; identity does not depend on
|
|
70
|
-
// them, exactly as a cycle `{{ action }}`'s displays are free. Every removed
|
|
71
|
-
// spelling is rejected with a migration-pointing error, never silently
|
|
72
|
-
// reinterpreted [LAW:no-silent-failure].
|
|
73
|
-
const MIGRATION = `a menu binds its trigger text the way a cycle action binds a display — write {{ menu "applyTheme" "▸" "▾" }} (one per state) or {{ menu "insertHere" "+" }} (one static display for both), with the rare knobs in ONE trailing dict: {{ menu "applyTheme" "▸" "▾" (dict "closeOnPick" true "paged" false "key" "pickers") }} (defaults: closeOnPick false, paged true, no key). The renderer no longer appends ▸/▾ of its own (candybar-settings-ui-aok.4), and the older positional tail ("pageAction" closeOnPick paged "key") was removed — the page cursor is synthesized from the menu's identity`;
|
|
74
|
-
|
|
75
|
-
// [LAW:dataflow-not-control-flow] One total analysis of a `{{ menu }}` call site:
|
|
76
|
-
// every reachable argument shape lands in exactly one arm — a usable identity
|
|
77
|
-
// (apply + parsed options) or one load-error message (the tail after
|
|
78
|
-
// `segment "<name>" has a {{ menu }} `). No shape falls through to render time.
|
|
79
|
-
type MenuAnalysis =
|
|
80
|
-
| {
|
|
81
|
-
readonly kind: "ok";
|
|
82
|
-
readonly apply: string;
|
|
83
|
-
readonly options: MenuOptions;
|
|
84
|
-
}
|
|
85
|
-
| { readonly kind: "issue"; readonly message: string };
|
|
86
|
-
|
|
87
|
-
type ArgExpr = ReferencedCall["argExprs"][number];
|
|
88
|
-
|
|
89
|
-
const isDictCall = (e: ArgExpr): boolean =>
|
|
90
|
-
e.kind === "call" && e.name === "dict";
|
|
91
|
-
|
|
92
|
-
// [LAW:one-source-of-truth] The two sides split the tail on different evidence —
|
|
93
|
-
// exprs here, evaluated values in `parseMenuArgs` — so the loader admits only
|
|
94
|
-
// call sites where those two readings PROVABLY coincide. The renderer's split
|
|
95
|
-
// asks one question of the last value, "is it an object", so a literal answers
|
|
96
|
-
// it here: a parse-time constant evaluates to itself and can never become the
|
|
97
|
-
// options dict. A literal `(dict …)` always does. Everything else in that slot
|
|
98
|
-
// is classified by whatever it happens to evaluate to.
|
|
99
|
-
const isNonObjectLiteral = (e: ArgExpr): boolean =>
|
|
100
|
-
e.kind === "literal" && typeof e.value !== "object";
|
|
101
|
-
|
|
102
|
-
// The display-arity rule used as a predicate; the message is the caller's
|
|
103
|
-
// business, so the subject never surfaces. [LAW:single-enforcer] — legality is
|
|
104
|
-
// read off the disclosure primitive, never restated as a count comparison.
|
|
105
|
-
const legalDisplayCount = (count: number): boolean =>
|
|
106
|
-
cycleDisplayIssue("", count, 2) === undefined;
|
|
107
|
-
|
|
108
|
-
function analyzeMenuCall(call: ReferencedCall): MenuAnalysis {
|
|
109
|
-
const issue = (message: string): MenuAnalysis => ({ kind: "issue", message });
|
|
110
|
-
const [applyArg, ...tail] = call.argExprs;
|
|
111
|
-
if (applyArg === undefined) {
|
|
112
|
-
return issue(
|
|
113
|
-
`with no arguments — it takes an apply-action name and its trigger text (e.g. {{ menu "applyTheme" "▸" "▾" }})`,
|
|
114
|
-
);
|
|
115
|
-
}
|
|
116
|
-
if (applyArg.kind !== "literal" || typeof applyArg.value !== "string") {
|
|
117
|
-
return issue(
|
|
118
|
-
`whose apply action is not a string literal — a menu's identity is its apply-action name, which must be a literal so it can be gated at load (e.g. {{ menu "applyTheme" "▸" "▾" }})`,
|
|
119
|
-
);
|
|
120
|
-
}
|
|
121
|
-
// [LAW:types-are-the-program] The dict is the LAST argument when present;
|
|
122
|
-
// everything before it is a display. Splitting on that one position is the
|
|
123
|
-
// whole grammar, and it is the same split `parseMenuArgs` performs on the
|
|
124
|
-
// evaluated tail at render — one shape, read twice from the two things each
|
|
125
|
-
// side has (exprs here, values there).
|
|
126
|
-
const last = tail[tail.length - 1];
|
|
127
|
-
const optsArg = last !== undefined && isDictCall(last) ? last : undefined;
|
|
128
|
-
const displays = optsArg === undefined ? tail : tail.slice(0, -1);
|
|
129
|
-
if (displays.some(isDictCall)) {
|
|
130
|
-
return issue(
|
|
131
|
-
`whose options (dict …) is not its last argument — ${MIGRATION}`,
|
|
132
|
-
);
|
|
133
|
-
}
|
|
134
|
-
// [LAW:no-silent-failure] The last slot is the one both readings can claim.
|
|
135
|
-
// When the expr there is not provably one or the other AND dropping it still
|
|
136
|
-
// leaves a legal display count, the renderer's value-based split can land on
|
|
137
|
-
// a DIFFERENT reading than this one — same call, two shapes, no error either
|
|
138
|
-
// side: the options dict skips `staticDictEntries` (so a dynamic `key` derives
|
|
139
|
-
// a state key with no synthesized var behind it, and the menu never opens) or
|
|
140
|
-
// a display vanishes into the static form. Reject that call site; an explicit
|
|
141
|
-
// trailing `(dict …)` disambiguates it and keeps dynamic displays legal.
|
|
142
|
-
// Where the alternate reading is an ILLEGAL count the renderer throws instead
|
|
143
|
-
// of diverging, so it stays accepted — loudness, not refusal, is the bar.
|
|
144
|
-
if (
|
|
145
|
-
last !== undefined &&
|
|
146
|
-
optsArg === undefined &&
|
|
147
|
-
!isNonObjectLiteral(last) &&
|
|
148
|
-
legalDisplayCount(displays.length - 1)
|
|
149
|
-
) {
|
|
150
|
-
return issue(
|
|
151
|
-
`whose last argument is neither a literal nor a literal (dict …) — the renderer tells a display from the options dict by the value it evaluates to, so this call could be read as ${displays.length} displays or as ${displays.length - 1} plus options, and both are legal. Make the options explicit as a trailing (dict …) — {{ menu "${applyArg.value}" (printf "…") (printf "…") (dict) }} binds dynamic displays unambiguously — or bind the trigger text as literals`,
|
|
152
|
-
);
|
|
153
|
-
}
|
|
154
|
-
// [LAW:single-enforcer] The display-arity rule is the disclosure primitive's,
|
|
155
|
-
// the same one the renderer picks through — checked HERE too because the
|
|
156
|
-
// count is statically known, so an unauthored trigger is a load error naming
|
|
157
|
-
// the fix rather than a diagnostic glyph on the next render.
|
|
158
|
-
// "whose trigger …" completes the caller's `segment "X" has a {{ menu }} `.
|
|
159
|
-
const arity = cycleDisplayIssue("whose trigger", displays.length, 2);
|
|
160
|
-
if (arity !== undefined) return issue(`${arity} — ${MIGRATION}`);
|
|
161
|
-
const entries = optsArg === undefined ? {} : staticDictEntries(optsArg);
|
|
162
|
-
if (entries === null) {
|
|
163
|
-
return issue(
|
|
164
|
-
`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)`,
|
|
165
|
-
);
|
|
166
|
-
}
|
|
167
|
-
try {
|
|
168
|
-
return {
|
|
169
|
-
kind: "ok",
|
|
170
|
-
apply: applyArg.value,
|
|
171
|
-
options: parseMenuOptions(entries),
|
|
172
|
-
};
|
|
173
|
-
} catch (e) {
|
|
174
|
-
return issue(`with invalid options — ${(e as Error).message}`);
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
// [LAW:no-defensive-null-guards] A bare engine purely for AST introspection: it
|
|
179
|
-
// never evaluates, so `fromString` is identity and no funcs are registered (parse
|
|
180
|
-
// does not resolve function existence — that is an eval-time concern).
|
|
181
|
-
function parseCalls(
|
|
182
|
-
template: string,
|
|
183
|
-
): readonly MenuAnalysis[] | "parse-failed" {
|
|
184
|
-
const engine = createEngine<string>({ fromString: (s) => s });
|
|
185
|
-
try {
|
|
186
|
-
return engine
|
|
187
|
-
.parse(template)
|
|
188
|
-
.referencedCalls()
|
|
189
|
-
.filter((c) => c.name === MENU_FUNC)
|
|
190
|
-
.map(analyzeMenuCall);
|
|
191
|
-
} catch {
|
|
192
|
-
// A malformed template can host no usable menu; registerDslConfig re-parses
|
|
193
|
-
// and reports the real error. [LAW:no-silent-failure] — not swallowed, just
|
|
194
|
-
// not the place that reports it.
|
|
195
|
-
return "parse-failed";
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
function segmentReferencesMenu(template: string): boolean {
|
|
200
|
-
const engine = createEngine<string>({ fromString: (s) => s });
|
|
201
|
-
try {
|
|
202
|
-
return engine.parse(template).referencedFunctions().has(MENU_FUNC);
|
|
203
|
-
} catch {
|
|
204
|
-
return false;
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
function menuIssue(ctx: ValidateCtx, path: string, message: string): void {
|
|
209
|
-
ctx.issues.push({ path, message, line: findKeyLine(ctx.source, ["root"]) });
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
export function synthesizeMenuDecls(
|
|
213
|
-
ctx: ValidateCtx,
|
|
214
|
-
out: Mutable<RawDslConfig>,
|
|
215
|
-
): void {
|
|
216
|
-
// [LAW:single-enforcer] The `menus.` namespace is reserved UNCONDITIONALLY — a
|
|
217
|
-
// user name under it is rejected whether or not any menu is placed this load, so
|
|
218
|
-
// the reservation is a stable contract ("you never author menus.*"), not a rule
|
|
219
|
-
// that only switches on when synthesis happens to collide. Runs before any early
|
|
220
|
-
// return so a `menus.*` user name can never load silently. The check is the
|
|
221
|
-
// disclosure primitive's shared enforcer (mirroring group sugar's `groups.`).
|
|
222
|
-
reservedNamespaceCollisions(ctx, out, MENU_NS, "{{ menu }} helpers");
|
|
223
|
-
|
|
224
|
-
// [LAW:no-silent-failure] A menu derives its identity from the SEGMENT it sits
|
|
225
|
-
// in (the published segment name) plus its own apply arg; a `{{ define }}`
|
|
226
|
-
// helper is shared across segments, so the synthesis pass — which scans each
|
|
227
|
-
// segment's own template — cannot see a menu reached only through `{{ template
|
|
228
|
-
// }}` and would never synthesize its backing state var/cycle action, failing at
|
|
229
|
-
// render. Reject it loudly at load, pointing the author to inline the menu.
|
|
230
|
-
// [LAW:no-mode-explosion] We reject rather than resolve the helper call graph
|
|
231
|
-
// speculatively; revisit only if a real shared-menu need appears.
|
|
232
|
-
for (const [name, body] of Object.entries(out.helpers ?? {})) {
|
|
233
|
-
if (segmentReferencesMenu(body)) {
|
|
234
|
-
ctx.issues.push({
|
|
235
|
-
path: `helpers.${name}`,
|
|
236
|
-
message: `helper "${name}" uses {{ menu }}, but a menu must live directly in a segment template — its identity is derived from the segment it sits in, which a shared helper does not have. Inline the {{ menu }} call into each segment that needs it.`,
|
|
237
|
-
line: findKeyLine(ctx.source, ["helpers", name]),
|
|
238
|
-
});
|
|
239
|
-
}
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
const segments = out.segments ?? {};
|
|
243
|
-
|
|
244
|
-
// [LAW:locality-or-seam] A menu publishes its placement ONLY around the segment
|
|
245
|
-
// `template` eval; bg/fg evaluate after that window and a node/segment `when`
|
|
246
|
-
// before it, so a {{ menu }} in any of them throws at render. The template is
|
|
247
|
-
// the menu's one valid seam — reject it anywhere else at load, rather than
|
|
248
|
-
// admit a config that parses but crashes on render.
|
|
249
|
-
for (const [segName, seg] of Object.entries(segments)) {
|
|
250
|
-
for (const field of ["bg", "fg", "when"] as const) {
|
|
251
|
-
const tpl = seg[field];
|
|
252
|
-
if (typeof tpl === "string" && segmentReferencesMenu(tpl)) {
|
|
253
|
-
menuIssue(
|
|
254
|
-
ctx,
|
|
255
|
-
`segments.${segName}.${field}`,
|
|
256
|
-
`segment "${segName}" uses {{ menu }} in its "${field}" — a menu is only valid in a segment's "template" (its placement is published only there; "${field}" needs a ${field === "when" ? "predicate" : "color"}). Move the {{ menu }} into the template.`,
|
|
257
|
-
);
|
|
258
|
-
}
|
|
259
|
-
}
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
// One walk over the layout: reject {{ menu }} in node `when` predicates (same
|
|
263
|
-
// template-only rule), and count each segment's placements for the reuse check.
|
|
264
|
-
const placementCounts = new Map<string, number>();
|
|
265
|
-
if (out.root !== undefined) {
|
|
266
|
-
for (const node of walkNodes(out.root)) {
|
|
267
|
-
if (typeof node.when === "string" && segmentReferencesMenu(node.when)) {
|
|
268
|
-
menuIssue(
|
|
269
|
-
ctx,
|
|
270
|
-
"root",
|
|
271
|
-
`a layout node's "when" predicate uses {{ menu }} — a menu is only valid in a segment's "template", not a node predicate. Move it into a segment.`,
|
|
272
|
-
);
|
|
273
|
-
}
|
|
274
|
-
if (node.kind === "segment") {
|
|
275
|
-
placementCounts.set(
|
|
276
|
-
node.name,
|
|
277
|
-
(placementCounts.get(node.name) ?? 0) + 1,
|
|
278
|
-
);
|
|
279
|
-
}
|
|
280
|
-
}
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
// [LAW:types-are-the-program] A menu-bearing segment placed in more than one
|
|
284
|
-
// layout slot is ambiguous: its disclosure open-state is keyed by segment name
|
|
285
|
-
// (one state for the segment), so two placements would share it and a click on
|
|
286
|
-
// one would toggle both. Reject the repeated placement — the ambiguity is
|
|
287
|
-
// unrepresentable, and identity stays name-derived (no placement path threaded
|
|
288
|
-
// into the key). Two independent disclosures = two named segments.
|
|
289
|
-
for (const [segName, seg] of Object.entries(segments)) {
|
|
290
|
-
if (!segmentReferencesMenu(seg.template)) continue;
|
|
291
|
-
if ((placementCounts.get(segName) ?? 0) > 1) {
|
|
292
|
-
menuIssue(
|
|
293
|
-
ctx,
|
|
294
|
-
`segments.${segName}`,
|
|
295
|
-
`segment "${segName}" hosts a {{ menu }} and is placed in the layout more than once — a menu's open-state is keyed by segment name, so the copies would share one state (clicking one would toggle both). Give each placement its own named segment.`,
|
|
296
|
-
);
|
|
297
|
-
}
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
// One state var per state key (default "closed"); one cycle action per
|
|
301
|
-
// (stateKey, member); one page-cursor var + int action per state key.
|
|
302
|
-
// [LAW:dataflow-not-control-flow] Independent menus each contribute their own
|
|
303
|
-
// key; shared-key menus contribute distinct members to one key, and the
|
|
304
|
-
// same-key validator merge unions them into one accordion gate.
|
|
305
|
-
const stateKeys = new Set<string>();
|
|
306
|
-
const actions: Record<string, ActionDecl> = {};
|
|
307
|
-
// Guard against two menus claiming one identity (same key + same member): for
|
|
308
|
-
// independent menus that means the literal same `{{ menu }}` twice in a
|
|
309
|
-
// segment; for shared-key menus it means two menus with the same apply name
|
|
310
|
-
// sharing a key — neither can be addressed distinctly, so reject.
|
|
311
|
-
const claimed = new Set<string>();
|
|
312
|
-
// [LAW:types-are-the-program] A synthesized key is `ident()`-normalized so it
|
|
313
|
-
// carries no separators; that normalization is lossy (`a-b` and `a_b`
|
|
314
|
-
// collapse), so two DISTINCT declarations could map to one key and silently
|
|
315
|
-
// share state (an unintended accordion). Track the raw "owner" each
|
|
316
|
-
// synthesized name (state key AND its derived page key) legitimately belongs
|
|
317
|
-
// to — a shared key is owned by its raw key string (every sibling agrees); an
|
|
318
|
-
// independent menu by its raw (segment, apply). A second owner on the same
|
|
319
|
-
// name is a collision, rejected at load so it is unrepresentable
|
|
320
|
-
// [LAW:no-silent-failure] rather than corrupting grouping. Registering the
|
|
321
|
-
// page key too closes the cross-shape aliasing corner (e.g. an apply action
|
|
322
|
-
// named "page" in segment "s" vs the page cursor of a shared key "s").
|
|
323
|
-
const ownerBySynthKey = new Map<string, string>();
|
|
324
|
-
|
|
325
|
-
for (const [segName, seg] of Object.entries(segments)) {
|
|
326
|
-
if (!segmentReferencesMenu(seg.template)) continue;
|
|
327
|
-
const calls = parseCalls(seg.template);
|
|
328
|
-
if (calls === "parse-failed") continue;
|
|
329
|
-
for (const call of calls) {
|
|
330
|
-
// [LAW:no-silent-failure] Every non-ok argument shape (missing apply,
|
|
331
|
-
// non-literal apply, the removed positional tail, a non-literal or
|
|
332
|
-
// malformed options dict) surfaces here as one load error with the
|
|
333
|
-
// analysis's migration-pointing text.
|
|
334
|
-
if (call.kind === "issue") {
|
|
335
|
-
menuIssue(
|
|
336
|
-
ctx,
|
|
337
|
-
`segments.${segName}`,
|
|
338
|
-
`segment "${segName}" has a {{ menu }} ${call.message}.`,
|
|
339
|
-
);
|
|
340
|
-
continue;
|
|
341
|
-
}
|
|
342
|
-
const { apply, options } = call;
|
|
343
|
-
// [LAW:types-are-the-program] An empty apply name → empty member, and the
|
|
344
|
-
// store returns "" for an absent state key, so `open = read === member`
|
|
345
|
-
// would be true before any click — the menu would render open spuriously.
|
|
346
|
-
// Reject it (the member must never alias the absent-state sentinel).
|
|
347
|
-
if (apply === "") {
|
|
348
|
-
menuIssue(
|
|
349
|
-
ctx,
|
|
350
|
-
`segments.${segName}`,
|
|
351
|
-
`segment "${segName}" has a {{ menu }} with an empty apply-action name — an empty member aliases the absent-state sentinel ("") so the menu would render open before any click. Name the apply action.`,
|
|
352
|
-
);
|
|
353
|
-
continue;
|
|
354
|
-
}
|
|
355
|
-
const member = menuMember(apply);
|
|
356
|
-
// [LAW:types-are-the-program] A member equal to the closed sentinel makes
|
|
357
|
-
// the cycle [closed, "closed"] — two identical members, leaving the menu
|
|
358
|
-
// unopenable. The only apply name that breaks a menu; reject it at load.
|
|
359
|
-
if (member === DISCLOSURE_CLOSED) {
|
|
360
|
-
menuIssue(
|
|
361
|
-
ctx,
|
|
362
|
-
`segments.${segName}`,
|
|
363
|
-
`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.`,
|
|
364
|
-
);
|
|
365
|
-
continue;
|
|
366
|
-
}
|
|
367
|
-
const stateKey = menuStateKey(segName, apply, options.key);
|
|
368
|
-
const pageKey = menuPageKey(stateKey);
|
|
369
|
-
// The raw declaration these keys legitimately belong to. Shared-key
|
|
370
|
-
// siblings all share one owner (their raw key); an independent menu owns
|
|
371
|
-
// its keys alone (its raw segment+apply, NUL-joined so the two parts
|
|
372
|
-
// can't run together).
|
|
373
|
-
const owner =
|
|
374
|
-
options.key !== undefined
|
|
375
|
-
? `key${options.key}`
|
|
376
|
-
: `ind${segName}${apply}`;
|
|
377
|
-
const clashKey = [stateKey, pageKey].find((k) => {
|
|
378
|
-
const prior = ownerBySynthKey.get(k);
|
|
379
|
-
return prior !== undefined && prior !== owner;
|
|
380
|
-
});
|
|
381
|
-
if (clashKey !== undefined) {
|
|
382
|
-
menuIssue(
|
|
383
|
-
ctx,
|
|
384
|
-
`segments.${segName}`,
|
|
385
|
-
`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.`,
|
|
386
|
-
);
|
|
387
|
-
continue;
|
|
388
|
-
}
|
|
389
|
-
ownerBySynthKey.set(stateKey, owner);
|
|
390
|
-
ownerBySynthKey.set(pageKey, owner);
|
|
391
|
-
const identity = menuActionName(stateKey, member);
|
|
392
|
-
if (claimed.has(identity)) {
|
|
393
|
-
menuIssue(
|
|
394
|
-
ctx,
|
|
395
|
-
`segments.${segName}`,
|
|
396
|
-
`two {{ menu }} disclosures resolve to the same identity ("${identity}") — ${
|
|
397
|
-
options.key !== undefined
|
|
398
|
-
? `menus sharing key "${options.key}" must have distinct apply actions`
|
|
399
|
-
: `a segment cannot contain two menus over the same apply action "${apply}"`
|
|
400
|
-
}.`,
|
|
401
|
-
);
|
|
402
|
-
continue;
|
|
403
|
-
}
|
|
404
|
-
claimed.add(identity);
|
|
405
|
-
stateKeys.add(stateKey);
|
|
406
|
-
// [LAW:one-source-of-truth] The shared disclosure toggle: members ordered
|
|
407
|
-
// closed-first (an unset/foreign value counts as the first member — the
|
|
408
|
-
// cycle's "unknown ⇒ first" rule — so a never-clicked menu renders ▸ and a
|
|
409
|
-
// click opens it; a shared key holding one member auto-closes its siblings).
|
|
410
|
-
actions[identity] = disclosureCycleAction(stateKey, member);
|
|
411
|
-
}
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
if (stateKeys.size === 0) return;
|
|
415
|
-
|
|
416
|
-
const variables: Record<string, VariableDecl> = {};
|
|
417
|
-
for (const stateKey of stateKeys) {
|
|
418
|
-
variables[stateKey] = disclosureStateVar(stateKey, DISCLOSURE_CLOSED);
|
|
419
|
-
// [LAW:one-source-of-truth] The synthesized page cursor — the half a blind
|
|
420
|
-
// author used to hand-declare and forget, silently freezing the picker on
|
|
421
|
-
// page 0 (renderPicker read an unbound key as "" → clamp 0). Both halves
|
|
422
|
-
// are emitted together, named by menuPageKey, so the pairing is a
|
|
423
|
-
// construction, not a convention: the state VAR (named by the key, the
|
|
424
|
-
// disclosure-var convention) is what the renderer reads the live page
|
|
425
|
-
// through; the int ACTION is what deriveActionValidators derives the ←/→/✕
|
|
426
|
-
// wire gate from — the one existing path, no parallel gate
|
|
427
|
-
// [LAW:single-enforcer].
|
|
428
|
-
const pageKey = menuPageKey(stateKey);
|
|
429
|
-
variables[pageKey] = { kind: "state", key: pageKey, default: "0" };
|
|
430
|
-
actions[pageKey] = { set: pageKey, int: true };
|
|
431
|
-
}
|
|
432
|
-
|
|
433
|
-
out.variables = { ...(out.variables ?? {}), ...variables };
|
|
434
|
-
out.actions = { ...(out.actions ?? {}), ...actions };
|
|
435
|
-
}
|
|
@@ -1,115 +0,0 @@
|
|
|
1
|
-
// [LAW:one-source-of-truth] The single point that merges a raw user config
|
|
2
|
-
// onto a default DslConfig to fill missing keys. A user file declares only
|
|
3
|
-
// what differs; the cascade here (shallow-merge globals, by-name merge
|
|
4
|
-
// variables/segments/actions, wholesale root replacement) is the one place
|
|
5
|
-
// "absent means inherit" is decided. This file changes when the merge
|
|
6
|
-
// semantics change.
|
|
7
|
-
//
|
|
8
|
-
// [LAW:one-way-deps] `dflt` is a required parameter — this module is generic
|
|
9
|
-
// merge machinery and does not know about DEFAULT_DSL_CONFIG, the specific
|
|
10
|
-
// bundled instance built ON TOP of it (default-dsl-config.ts imports this
|
|
11
|
-
// function to synthesize itself). A default param pointing back at
|
|
12
|
-
// DEFAULT_DSL_CONFIG would make this generic module depend on its own
|
|
13
|
-
// specific consumer, a cycle. Callers who want "the bundled default" import
|
|
14
|
-
// DEFAULT_DSL_CONFIG from default-dsl-config.ts and pass it explicitly.
|
|
15
|
-
|
|
16
|
-
import {
|
|
17
|
-
type DslConfig,
|
|
18
|
-
type RawDslConfig,
|
|
19
|
-
type SegmentDecl,
|
|
20
|
-
} from "../dsl-types.js";
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* Merge a RawDslConfig on top of a default DslConfig. Pure function.
|
|
24
|
-
*
|
|
25
|
-
* globals : shallow merge per field (user wins per-field)
|
|
26
|
-
* variables : merge by name (user wins per-name)
|
|
27
|
-
* segments : merge by name (user wins per-name)
|
|
28
|
-
* root : the canonical layout tree. Authored via the A-grammar (`root`)
|
|
29
|
-
* replaces wholesale; absent → default's tree.
|
|
30
|
-
* [LAW:one-source-of-truth] `layout:` is rejected at parse time
|
|
31
|
-
* with a migration error (removed in 2de.19), so only `root`
|
|
32
|
-
* ever reaches this function.
|
|
33
|
-
*/
|
|
34
|
-
export function mergeWithDefault(
|
|
35
|
-
raw: RawDslConfig,
|
|
36
|
-
dflt: DslConfig,
|
|
37
|
-
): DslConfig {
|
|
38
|
-
return {
|
|
39
|
-
globals: { ...dflt.globals, ...(raw.globals ?? {}) },
|
|
40
|
-
variables: { ...dflt.variables, ...(raw.variables ?? {}) },
|
|
41
|
-
segments: { ...dflt.segments, ...(raw.segments ?? {}) },
|
|
42
|
-
root: raw.root !== undefined ? raw.root : dflt.root,
|
|
43
|
-
// [LAW:one-source-of-truth] actions merge by name, same cascade — a user
|
|
44
|
-
// declares only the actions that differ from the bundled default (which
|
|
45
|
-
// ships none).
|
|
46
|
-
actions: { ...dflt.actions, ...(raw.actions ?? {}) },
|
|
47
|
-
// [LAW:one-source-of-truth] looks merge by name, same cascade — a user
|
|
48
|
-
// overrides one adaptation by re-declaring its name; the bundled stdlib
|
|
49
|
-
// (incl. the "none" identity floor) survives every merge by construction.
|
|
50
|
-
looks: { ...dflt.looks, ...(raw.looks ?? {}) },
|
|
51
|
-
// [LAW:one-source-of-truth] presets merge by name, same cascade — a user
|
|
52
|
-
// overrides one arrangement by re-declaring its name; the bundled stdlib
|
|
53
|
-
// (incl. the "default" empty-fragment floor effectivePresetName collapses
|
|
54
|
-
// to) survives every merge by construction, exactly as looks' "none" does.
|
|
55
|
-
presets: { ...dflt.presets, ...(raw.presets ?? {}) },
|
|
56
|
-
// [LAW:one-source-of-truth] editGlobals merges FIELD by field — the
|
|
57
|
-
// `globals` cascade above, not the by-name cascades around it, because it
|
|
58
|
-
// IS a globals fragment: a user retuning edit mode's separator says nothing
|
|
59
|
-
// about its `style`, exactly as a user setting `globals.padding` says
|
|
60
|
-
// nothing about `globals.charset`.
|
|
61
|
-
editGlobals: { ...dflt.editGlobals, ...(raw.editGlobals ?? {}) },
|
|
62
|
-
// [LAW:one-source-of-truth] helpers merge by name, same cascade — a user
|
|
63
|
-
// overrides one formatter helper by re-declaring its name; the rest inherit
|
|
64
|
-
// from the bundled default.
|
|
65
|
-
helpers: { ...dflt.helpers, ...(raw.helpers ?? {}) },
|
|
66
|
-
};
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
// [LAW:one-source-of-truth] The segment-scoped half of the config-overrides
|
|
70
|
-
// layer's merge (candybar-config-engine-71o.6) — the SAME "changes the
|
|
71
|
-
// DEFAULT, never the hand-authored file" precedence mergeWithDefault's
|
|
72
|
-
// `globals` cascade already applies, but patches ONE field (`palette`)
|
|
73
|
-
// inside an already-merged segment rather than replacing the segment
|
|
74
|
-
// wholesale. mergeWithDefault's `segments` cascade is deliberately per-name
|
|
75
|
-
// WHOLESALE replacement (a user overriding a segment restates it in full,
|
|
76
|
-
// same as any other by-name merge in this file) — routing a one-field
|
|
77
|
-
// override through that cascade would silently drop every other field the
|
|
78
|
-
// segment declares (template, bg, fg, when, vars...). This runs AFTER
|
|
79
|
-
// mergeWithDefault, directly against the already-merged config, so it never
|
|
80
|
-
// fights that cascade; it is its own, later, narrower merge step.
|
|
81
|
-
//
|
|
82
|
-
// [LAW:no-silent-failure] exception: a stale override naming a segment the
|
|
83
|
-
// config no longer declares is not a load-time error — the CONFIG, not the
|
|
84
|
-
// override, is the source of truth for which segments exist. Skipping it is
|
|
85
|
-
// a no-op, not a swallowed failure: a fresh `persist` write can only ever
|
|
86
|
-
// name a segment the config declares (cross-ref checks that at load time),
|
|
87
|
-
// so a dangling entry here only happens after a later config edit removed
|
|
88
|
-
// the segment, and there is nothing left for the override to apply to.
|
|
89
|
-
//
|
|
90
|
-
// [LAW:no-defensive-null-guards] exception: `Object.assign(Object.create(null),
|
|
91
|
-
// ...)` instead of `{ ...config.segments }` — the SAME null-prototype hygiene
|
|
92
|
-
// as the config-overrides-store.ts accumulators above (segment names come
|
|
93
|
-
// from user config and this loop WRITES via bracket assignment, `segments[name]
|
|
94
|
-
// = ...`, not a pure spread). Pure object spread never risks this (it defines
|
|
95
|
-
// every key directly, never invoking an inherited setter), but a stale
|
|
96
|
-
// override naming a since-removed segment `__proto__` hits exactly the
|
|
97
|
-
// "no own property yet, so the read returns the inherited accessor and the
|
|
98
|
-
// write invokes its setter" case a plain accumulator does not guard against.
|
|
99
|
-
export function applySegmentPaletteOverrides(
|
|
100
|
-
config: DslConfig,
|
|
101
|
-
overrides: Readonly<Record<string, string>>,
|
|
102
|
-
): DslConfig {
|
|
103
|
-
const entries = Object.entries(overrides);
|
|
104
|
-
if (entries.length === 0) return config;
|
|
105
|
-
const segments: Record<string, SegmentDecl> = Object.assign(
|
|
106
|
-
Object.create(null) as Record<string, SegmentDecl>,
|
|
107
|
-
config.segments,
|
|
108
|
-
);
|
|
109
|
-
for (const [name, palette] of entries) {
|
|
110
|
-
const seg = segments[name];
|
|
111
|
-
if (seg === undefined) continue;
|
|
112
|
-
segments[name] = { ...seg, palette };
|
|
113
|
-
}
|
|
114
|
-
return { ...config, segments };
|
|
115
|
-
}
|