@promptctl/cc-candybar 1.0.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.
Files changed (111) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +145 -0
  3. package/bin/cc-candybar +6 -0
  4. package/dist/index.mjs +185 -0
  5. package/package.json +99 -0
  6. package/plugin/.claude-plugin/plugin.json +11 -0
  7. package/plugin/bin/preview.sh +305 -0
  8. package/plugin/commands/candybar.md +403 -0
  9. package/plugin/templates/config-essential.json +36 -0
  10. package/plugin/templates/config-full.json +55 -0
  11. package/plugin/templates/config-standard.json +39 -0
  12. package/plugin/templates/config-tui-compact.json +48 -0
  13. package/plugin/templates/config-tui-full.json +89 -0
  14. package/plugin/templates/config-tui-standard.json +56 -0
  15. package/plugin/templates/config-tui.json +18 -0
  16. package/plugin/templates/nerd-fonts-sample.txt +5 -0
  17. package/schema/cc-candybar.schema.json +1379 -0
  18. package/src/click/wire.ts +113 -0
  19. package/src/config/action.ts +91 -0
  20. package/src/config/cli.ts +170 -0
  21. package/src/config/default-dsl-config.ts +661 -0
  22. package/src/config/dsl-loader.ts +265 -0
  23. package/src/config/dsl-types.ts +425 -0
  24. package/src/config/loader/actions.ts +530 -0
  25. package/src/config/loader/cache.ts +206 -0
  26. package/src/config/loader/cross-ref.ts +326 -0
  27. package/src/config/loader/cycles.ts +148 -0
  28. package/src/config/loader/diagnostics.ts +99 -0
  29. package/src/config/loader/discovery.ts +182 -0
  30. package/src/config/loader/emit-schema.ts +63 -0
  31. package/src/config/loader/globals.ts +42 -0
  32. package/src/config/loader/helpers.ts +48 -0
  33. package/src/config/loader/layout.ts +688 -0
  34. package/src/config/loader/merge.ts +40 -0
  35. package/src/config/loader/refs.ts +96 -0
  36. package/src/config/loader/segments.ts +120 -0
  37. package/src/config/loader/validate-core.ts +674 -0
  38. package/src/config/loader/variables.ts +260 -0
  39. package/src/daemon/acquire.ts +411 -0
  40. package/src/daemon/cache/git.ts +553 -0
  41. package/src/daemon/cache/render.ts +449 -0
  42. package/src/daemon/cache/session-usage-store.ts +446 -0
  43. package/src/daemon/cache/watchers.ts +245 -0
  44. package/src/daemon/client-debug.ts +120 -0
  45. package/src/daemon/client-stats.ts +129 -0
  46. package/src/daemon/client-transport.ts +273 -0
  47. package/src/daemon/client.ts +75 -0
  48. package/src/daemon/debug-types.ts +91 -0
  49. package/src/daemon/debug.ts +264 -0
  50. package/src/daemon/limits.ts +154 -0
  51. package/src/daemon/log.ts +69 -0
  52. package/src/daemon/parent-watchdog.ts +80 -0
  53. package/src/daemon/paths.ts +127 -0
  54. package/src/daemon/protocol.ts +235 -0
  55. package/src/daemon/render-payload.ts +611 -0
  56. package/src/daemon/server.ts +1103 -0
  57. package/src/daemon/session-state-file.ts +108 -0
  58. package/src/daemon/session-state.ts +237 -0
  59. package/src/daemon/stats.ts +229 -0
  60. package/src/daemon/verbs/index.ts +458 -0
  61. package/src/daemon/verbs/state-validators.ts +708 -0
  62. package/src/demo/dsl.ts +117 -0
  63. package/src/demo/mock-data.ts +67 -0
  64. package/src/demo/statusline.json5 +92 -0
  65. package/src/dsl/node-registry.ts +281 -0
  66. package/src/dsl/render.ts +558 -0
  67. package/src/index.ts +206 -0
  68. package/src/install/index.ts +410 -0
  69. package/src/proc/launch.ts +451 -0
  70. package/src/proc/stats-handle.ts +13 -0
  71. package/src/render/action.ts +458 -0
  72. package/src/render/diagnostic-style.ts +23 -0
  73. package/src/render/diagnostic-text.ts +77 -0
  74. package/src/render/error-glyph.ts +53 -0
  75. package/src/render/outcome-plan.ts +45 -0
  76. package/src/render/picker.ts +231 -0
  77. package/src/render/split-lines.ts +51 -0
  78. package/src/render/strip.ts +103 -0
  79. package/src/segments/cache.ts +131 -0
  80. package/src/segments/context.ts +190 -0
  81. package/src/segments/git.ts +561 -0
  82. package/src/segments/metrics.ts +101 -0
  83. package/src/segments/pricing.ts +452 -0
  84. package/src/segments/session.ts +188 -0
  85. package/src/segments/tmux.ts +74 -0
  86. package/src/template-engine/cells.ts +90 -0
  87. package/src/template-engine/colors.ts +102 -0
  88. package/src/template-engine/engine.ts +108 -0
  89. package/src/template-engine/funcs.ts +216 -0
  90. package/src/template-engine/index.ts +11 -0
  91. package/src/template-engine/layout.ts +112 -0
  92. package/src/template-engine/scope.ts +62 -0
  93. package/src/themes/index.ts +19 -0
  94. package/src/themes/palette-resolvers.ts +86 -0
  95. package/src/themes/policy.ts +79 -0
  96. package/src/themes/session-random.ts +88 -0
  97. package/src/utils/cache.ts +206 -0
  98. package/src/utils/claude.ts +616 -0
  99. package/src/utils/color-support.ts +118 -0
  100. package/src/utils/formatters.ts +77 -0
  101. package/src/utils/logger.ts +5 -0
  102. package/src/utils/outcome.ts +33 -0
  103. package/src/utils/schema-validator.ts +126 -0
  104. package/src/utils/single-flight.ts +57 -0
  105. package/src/utils/terminal-width.ts +43 -0
  106. package/src/utils/terminal.ts +11 -0
  107. package/src/utils/transcript-fs.ts +162 -0
  108. package/src/var-system/index.ts +24 -0
  109. package/src/var-system/sources.ts +1038 -0
  110. package/src/var-system/store.ts +223 -0
  111. package/src/var-system/types.ts +57 -0
@@ -0,0 +1,99 @@
1
+ // [LAW:single-enforcer] One home for config-error reporting: the public issue/
2
+ // error types, the best-effort source-line lookup every validator calls, and the
3
+ // human-readable formatter ConfigError renders. Changes here are display/source-
4
+ // mapping changes; the schema validators never touch this file.
5
+
6
+ // ─── Public types ────────────────────────────────────────────────────────────
7
+
8
+ export interface ConfigIssue {
9
+ /** Dotted logical path inside the config (e.g., "variables.foo.cache"). */
10
+ readonly path: string;
11
+ /** Short, actionable description of the problem. */
12
+ readonly message: string;
13
+ /** Source line (1-based). For semantic errors, best-effort from the path. */
14
+ readonly line?: number;
15
+ /** Source column (1-based). Present only for parse errors. */
16
+ readonly col?: number;
17
+ }
18
+
19
+ export class ConfigError extends Error {
20
+ readonly file: string;
21
+ readonly issues: readonly ConfigIssue[];
22
+
23
+ constructor(file: string, issues: readonly ConfigIssue[]) {
24
+ super(formatIssues(file, issues));
25
+ this.name = "ConfigError";
26
+ this.file = file;
27
+ this.issues = issues;
28
+ }
29
+ }
30
+
31
+ // ─── Best-effort source-line lookup ──────────────────────────────────────────
32
+
33
+ // Walk source forward, finding each path component as a JSON5 key in turn.
34
+ // JSON5 keys are unquoted identifiers (`foo:`), double-quoted strings, or
35
+ // single-quoted strings. Numeric path parts (e.g., layout indices) are
36
+ // skipped — they point inside arrays where line lookup is less useful.
37
+ //
38
+ // This is "good enough" navigation, not a guarantee. Returns undefined if a
39
+ // path part can't be located — the caller falls back to the logical path.
40
+ export function findKeyLine(
41
+ source: string,
42
+ pathParts: readonly string[],
43
+ ): number | undefined {
44
+ let cursor = 0;
45
+ let foundCursor: number | undefined;
46
+ for (const part of pathParts) {
47
+ if (part === "" || /^\d+$/.test(part)) continue;
48
+ const found = findKeyOccurrence(source, cursor, part);
49
+ if (found === -1) {
50
+ return foundCursor !== undefined
51
+ ? lineFromOffset(source, foundCursor)
52
+ : undefined;
53
+ }
54
+ cursor = found;
55
+ foundCursor = found;
56
+ }
57
+ return foundCursor !== undefined
58
+ ? lineFromOffset(source, foundCursor)
59
+ : undefined;
60
+ }
61
+
62
+ function findKeyOccurrence(source: string, from: number, key: string): number {
63
+ // Match `<key>:` or `"<key>":` or `'<key>':` — any whitespace before the colon
64
+ // is allowed by JSON5. Escape regex specials in key.
65
+ const escaped = key.replace(/[\\^$.*+?()[\]{}|]/g, "\\$&");
66
+ const re = new RegExp(`(?:["']${escaped}["']|\\b${escaped}\\b)\\s*:`, "g");
67
+ re.lastIndex = from;
68
+ const m = re.exec(source);
69
+ return m ? m.index : -1;
70
+ }
71
+
72
+ function lineFromOffset(source: string, offset: number): number {
73
+ let line = 1;
74
+ for (let i = 0; i < offset && i < source.length; i++) {
75
+ if (source.charCodeAt(i) === 0x0a) line++;
76
+ }
77
+ return line;
78
+ }
79
+
80
+ // ─── Error formatting ────────────────────────────────────────────────────────
81
+
82
+ function formatIssues(file: string, issues: readonly ConfigIssue[]): string {
83
+ if (issues.length === 0) return `${file}: invalid config (no details)`;
84
+ const lines: string[] = [
85
+ `Invalid config in ${file} (${issues.length} issue${issues.length === 1 ? "" : "s"}):`,
86
+ ];
87
+ for (const issue of issues) {
88
+ const locParts: string[] = [];
89
+ if (issue.line !== undefined) {
90
+ locParts.push(
91
+ `line ${issue.line}${issue.col !== undefined ? `:${issue.col}` : ""}`,
92
+ );
93
+ }
94
+ if (issue.path) locParts.push(issue.path);
95
+ const loc = locParts.length > 0 ? `[${locParts.join(" • ")}] ` : "";
96
+ lines.push(` ${loc}${issue.message}`);
97
+ }
98
+ return lines.join("\n");
99
+ }
@@ -0,0 +1,182 @@
1
+ // [LAW:single-enforcer] Config-file discovery: where the DSL config can live, in
2
+ // what precedence, and how `~` expands. One candidate-path enumerator feeds the
3
+ // resolver, the watchers, and the collision detector so none can disagree about
4
+ // which files are candidates. This file changes when the resolution rules change.
5
+
6
+ import fs from "node:fs";
7
+ import os from "node:os";
8
+ import path from "node:path";
9
+
10
+ // [LAW:one-source-of-truth] The set of accepted extensions lives here once.
11
+ // Both .json5 and .json are accepted: JSON ⊂ JSON5, so the same parser
12
+ // (JSON5.parse) handles both — only the filename lookup varies. Ordering is
13
+ // load-bearing: .json5 wins over .json at the same location (documented
14
+ // format > compatibility tail).
15
+ const CONFIG_EXTENSIONS = ["json5", "json"] as const;
16
+
17
+ // [LAW:single-enforcer] One implementation of `~`-prefix expansion, called at
18
+ // each trust boundary that takes a user-supplied path. The CLI `--config`
19
+ // value is expanded in `parseRenderArgs` (server.ts) before it ever reaches
20
+ // here; `CC_CANDYBAR_CONFIG` is expanded below where the env var is read.
21
+ // One function, one rule, two callers.
22
+ //
23
+ // [LAW:enumeration-gap] Only the shell-standard home-expansion forms trigger
24
+ // replacement: bare `~`, `~/...`, or `~\...` on Windows. A string like
25
+ // `~alice/cfg` (POSIX named-home lookup) is NOT expanded — we have no way
26
+ // to resolve another user's home and a literal substitution would corrupt
27
+ // the path (`<homedir>alice/cfg`).
28
+ export function expandHome(p: string): string {
29
+ return p === "~" || p.startsWith("~/") || p.startsWith("~\\")
30
+ ? os.homedir() + p.slice(1)
31
+ : p;
32
+ }
33
+
34
+ /**
35
+ * The full ordered list of candidate paths the DSL config could live at,
36
+ * for a given (projectDir, cwd). Returned regardless of which exist — the
37
+ * cache uses this to watch every candidate location so the creation of any
38
+ * file in the resolution chain triggers hot-reload.
39
+ *
40
+ * `configFile` is the highest-precedence entry — the path resolved from the
41
+ * client's `--config` flag (already `~`-expanded at the trust boundary in
42
+ * server.ts). When present, it is the sole candidate and the rest of the
43
+ * precedence chain is bypassed.
44
+ *
45
+ * [LAW:single-enforcer] One enumerator; `resolveDslConfigPath` finds the
46
+ * first that exists, watchers listen on all of them, no second list.
47
+ *
48
+ * [LAW:dataflow-not-control-flow] Location is the dominant precedence axis;
49
+ * extension breaks ties within a location. Encoded as a nested flat-map: each
50
+ * location yields one path per extension in order. No branches on extension.
51
+ */
52
+ export function dslConfigCandidatePaths(
53
+ projectDir?: string,
54
+ cwd?: string,
55
+ configFile?: string,
56
+ ): readonly string[] {
57
+ // CLI --config wins over everything — highest precedence. Pre-expanded at
58
+ // the trust boundary; trust the type here.
59
+ if (configFile) {
60
+ return [configFile];
61
+ }
62
+
63
+ const envPath = process.env.CC_CANDYBAR_CONFIG;
64
+ if (envPath) {
65
+ // [LAW:single-enforcer] env-var is a separate trust boundary; expand here
66
+ // where the env is read, with the shared `expandHome` helper. [LAW:
67
+ // dataflow-not-control-flow] When the env var sets the path, it's the
68
+ // *only* candidate — the precedence chain collapses to one entry.
69
+ return [expandHome(envPath)];
70
+ }
71
+
72
+ const effectiveCwd = cwd ?? process.cwd();
73
+ const xdgConfigHome =
74
+ process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), ".config");
75
+
76
+ return [
77
+ ...(projectDir
78
+ ? CONFIG_EXTENSIONS.map((ext) =>
79
+ path.join(projectDir, `.cc-candybar.${ext}`),
80
+ )
81
+ : []),
82
+ ...CONFIG_EXTENSIONS.map((ext) =>
83
+ path.join(effectiveCwd, `.cc-candybar.${ext}`),
84
+ ),
85
+ ...CONFIG_EXTENSIONS.map((ext) =>
86
+ path.join(xdgConfigHome, "cc-candybar", `config.${ext}`),
87
+ ),
88
+ ];
89
+ }
90
+
91
+ /**
92
+ * Resolution order for the user's DSL config file:
93
+ * 1. `configFile` (the CLI `--config <path>` value, already `~`-expanded)
94
+ * 2. $CC_CANDYBAR_CONFIG env var (literal path, `~`-expanded here)
95
+ * 3. `<projectDir>/.cc-candybar.json5`
96
+ * 4. `<projectDir>/.cc-candybar.json`
97
+ * 5. `<cwd>/.cc-candybar.json5`
98
+ * 6. `<cwd>/.cc-candybar.json`
99
+ * 7. `$XDG_CONFIG_HOME/cc-candybar/config.json5`
100
+ * (defaulting to `~/.config/cc-candybar/config.json5`)
101
+ * 8. `$XDG_CONFIG_HOME/cc-candybar/config.json`
102
+ *
103
+ * Returns the first path that exists, or null if none do.
104
+ *
105
+ * [LAW:dataflow-not-control-flow] The locations array is data; the search is
106
+ * `locations.find(fs.existsSync)`. Adding a layer is a new array entry, not a
107
+ * new branch. Extension support is a property of the candidate list, not the
108
+ * search.
109
+ *
110
+ * [LAW:single-enforcer] Built on top of `dslConfigCandidatePaths` — the
111
+ * precedence list lives in one place.
112
+ */
113
+ export function resolveDslConfigPath(
114
+ projectDir?: string,
115
+ cwd?: string,
116
+ configFile?: string,
117
+ ): string | null {
118
+ return (
119
+ dslConfigCandidatePaths(projectDir, cwd, configFile).find(fs.existsSync) ??
120
+ null
121
+ );
122
+ }
123
+
124
+ /**
125
+ * Detect same-location extension collisions: any location where BOTH
126
+ * `<base>.json5` and `<base>.json` exist simultaneously. The resolver picks
127
+ * .json5 (documented format wins), but the user almost certainly didn't
128
+ * intend to keep two; the duplicate is dead weight that will drift.
129
+ *
130
+ * Returns a human-readable warning naming the conflicting files, or null if
131
+ * no collisions exist. The render path surfaces this through the daemon's
132
+ * diagnostics channel so the user sees it on every render until they remove
133
+ * the duplicate.
134
+ *
135
+ * [LAW:single-enforcer] Consumes `dslConfigCandidatePaths` — same enumerator
136
+ * as the resolver and watcher; collision detection cannot disagree with
137
+ * resolution about which files are candidates.
138
+ *
139
+ * [LAW:dataflow-not-control-flow] Walk candidates, group by parent directory
140
+ * + base name (without extension), find groups with size > 1 whose members
141
+ * all exist. No special-case branches per extension.
142
+ */
143
+ export function detectConfigCollisions(
144
+ projectDir?: string,
145
+ cwd?: string,
146
+ ): string | null {
147
+ const candidates = dslConfigCandidatePaths(projectDir, cwd);
148
+ // [LAW:dataflow-not-control-flow] Dedupe candidates by full path first.
149
+ // When projectDir === cwd (a very common case — the daemon often resolves
150
+ // both from the same hook payload), the enumerator yields the same path
151
+ // at both precedence levels. That is a structural duplicate of *position
152
+ // in the precedence list*, not a same-location duplicate of *files on
153
+ // disk*. The latter is what collision detection is for; the former is
154
+ // noise that would fire a false positive.
155
+ const seen = new Set<string>();
156
+ const uniqueExisting: string[] = [];
157
+ for (const candidate of candidates) {
158
+ if (seen.has(candidate)) continue;
159
+ seen.add(candidate);
160
+ if (!fs.existsSync(candidate)) continue;
161
+ uniqueExisting.push(candidate);
162
+ }
163
+ // Group by (dir + base-without-extension). A group with > 1 existing
164
+ // member is a collision at that logical location.
165
+ const groups = new Map<string, string[]>();
166
+ for (const candidate of uniqueExisting) {
167
+ const dir = path.dirname(candidate);
168
+ const base = path.basename(candidate).replace(/\.(json5|json)$/, "");
169
+ const key = path.join(dir, base);
170
+ if (!groups.has(key)) groups.set(key, []);
171
+ groups.get(key)!.push(candidate);
172
+ }
173
+ const collisions = [...groups.values()].filter((g) => g.length > 1);
174
+ if (collisions.length === 0) return null;
175
+ // Stable, parseable message. The first file in each group is the .json5
176
+ // (the one that wins); the rest are the shadowed siblings.
177
+ const lines = collisions.map((g) => {
178
+ const [winner, ...shadowed] = g;
179
+ return `${winner} shadows ${shadowed.join(", ")}`;
180
+ });
181
+ return `config-extension collision: ${lines.join("; ")} — remove the duplicate`;
182
+ }
@@ -0,0 +1,63 @@
1
+ // [LAW:one-source-of-truth] The JSON Schema emitter — the SECOND interpreter over
2
+ // the declarative loader schemas. `validateConfig` composes the per-module
3
+ // `validate*` functions to validate at runtime; `emitConfigSchema` composes the
4
+ // per-module `*Json` functions to derive the editor-facing JSON Schema. Both read
5
+ // the SAME module-private schema declarations (GLOBALS_SCHEMA, VARIABLE_SCHEMA,
6
+ // SEGMENT_SCHEMA, CACHE_SCHEMA, SET_ARMS, the A-grammar layout grammar), so the
7
+ // published schema and the runtime validator can never describe a different grammar.
8
+ //
9
+ // What a JSON Schema still cannot express (by construction): cross-field
10
+ // refinements (min<max, by≠0, input default matches type), palette-name
11
+ // membership, duration FORMAT, and cross-references between segments/variables/
12
+ // cycles. Those stay SEMANTIC checks the loader carries — schema = shape, lint =
13
+ // meaning, the same complementary boundary `config-schema.test.ts` pins.
14
+
15
+ import { globalsJson } from "./globals.js";
16
+ import { variablesMapJson } from "./variables.js";
17
+ import { segmentsJson } from "./segments.js";
18
+ import { actionsJson } from "./actions.js";
19
+ import {
20
+ layoutNodeJson,
21
+ LAYOUT_NODE_DEF_NAME,
22
+ LAYOUT_NODE_REF,
23
+ } from "./layout.js";
24
+ import type { JsonNode } from "./validate-core.js";
25
+
26
+ // [LAW:one-source-of-truth] The stable published identity. The committed artifact
27
+ // is self-identifying at this URL so an editor loading it via `$schema` resolves.
28
+ export const SCHEMA_ID =
29
+ "https://raw.githubusercontent.com/promptctl/cc-candybar/main/schema/cc-candybar.schema.json";
30
+
31
+ // [LAW:dataflow-not-control-flow] The RawDslConfig schema: every top-level key is
32
+ // optional (a user file declares only what differs from the bundled default), so
33
+ // the object carries no `required`. Each property is one module's emitted shape —
34
+ // the same composition `validateConfig` performs over the validators. `root`
35
+ // references the LayoutNode definition that closes the node recursion via `$ref`.
36
+ export function emitConfigSchema(): JsonNode {
37
+ return {
38
+ $schema: "http://json-schema.org/draft-07/schema#",
39
+ $id: SCHEMA_ID,
40
+ title: "cc-candybar config (.cc-candybar.json5)",
41
+ type: "object",
42
+ additionalProperties: false,
43
+ properties: {
44
+ globals: globalsJson(),
45
+ variables: variablesMapJson(),
46
+ segments: segmentsJson(),
47
+ root: { $ref: LAYOUT_NODE_REF },
48
+ actions: actionsJson(),
49
+ helpers: { type: "object", additionalProperties: { type: "string" } },
50
+ },
51
+ definitions: {
52
+ [LAYOUT_NODE_DEF_NAME]: layoutNodeJson(),
53
+ },
54
+ };
55
+ }
56
+
57
+ // [LAW:single-enforcer] One serialization, shared by `gen:schema` (writes the
58
+ // committed artifact) and `check:schema` (byte-diffs against it) so the two can
59
+ // never disagree on how the schema is produced. Trailing newline + 2-space indent
60
+ // match the committed file's format.
61
+ export function serializeConfigSchema(): string {
62
+ return JSON.stringify(emitConfigSchema(), null, 2) + "\n";
63
+ }
@@ -0,0 +1,42 @@
1
+ // [LAW:types-are-the-program] The globals schema: a fixed set of string fields
2
+ // plus a validated palette name, declared as DATA and interpreted by the record
3
+ // engine. This file changes when a global default field is added or removed —
4
+ // add a key to GLOBALS_SCHEMA and Globals; the engine does the rest.
5
+
6
+ import { type Globals } from "../dsl-types.js";
7
+ import {
8
+ optionalStringSpec,
9
+ paletteSpec,
10
+ record,
11
+ recordJson,
12
+ type JsonNode,
13
+ type RecordSchema,
14
+ type ValidateCtx,
15
+ } from "./validate-core.js";
16
+
17
+ const GLOBALS_SCHEMA: RecordSchema<Globals> = {
18
+ noun: "globals key",
19
+ fields: {
20
+ default_bg: optionalStringSpec(),
21
+ default_fg: optionalStringSpec(),
22
+ default_empty_value: optionalStringSpec(),
23
+ default_separator: optionalStringSpec(),
24
+ default_truncate_marker: optionalStringSpec(),
25
+ palette: paletteSpec(),
26
+ },
27
+ };
28
+
29
+ // An absent globals block is the empty default (no issue); a non-object is a
30
+ // reported error that recovers to the empty default, since parseDslConfig throws
31
+ // once any issue exists so the recovery value never renders [LAW:no-silent-failure].
32
+ export function validateGlobals(ctx: ValidateCtx, raw: unknown): Globals {
33
+ if (raw === undefined) return {};
34
+ return record(ctx, GLOBALS_SCHEMA, "globals", raw) ?? {};
35
+ }
36
+
37
+ // [LAW:one-source-of-truth] The schema emitter derives from the SAME declaration
38
+ // the validator interprets — `globals` emit is `recordJson(GLOBALS_SCHEMA)`,
39
+ // symmetric to `validateGlobals` calling `record(GLOBALS_SCHEMA)`.
40
+ export function globalsJson(): JsonNode {
41
+ return recordJson(GLOBALS_SCHEMA);
42
+ }
@@ -0,0 +1,48 @@
1
+ // [LAW:types-are-the-program] The helpers schema is the simplest possible: a
2
+ // record of name → template-body STRING. Each value is a Go-template source the
3
+ // renderer compiles into a `{{ define }}` block; whether the body PARSES (and
4
+ // whether a `{{ template "name" }}` reference resolves) is a render-time concern
5
+ // (registerDslConfig parses the preamble and throws a per-helper diagnostic).
6
+ // This file changes only if the helper authoring shape changes.
7
+
8
+ import { findKeyLine } from "./diagnostics.js";
9
+ import {
10
+ describeType,
11
+ describeValue,
12
+ isPlainObject,
13
+ type ValidateCtx,
14
+ } from "./validate-core.js";
15
+
16
+ // [LAW:single-enforcer] Structural validation of the `helpers` block: an object
17
+ // whose every value is a string template body. Null-prototype record so a helper
18
+ // named "__proto__"/"constructor" is an ordinary own property, matching actions.
19
+ export function validateHelpers(
20
+ ctx: ValidateCtx,
21
+ raw: unknown,
22
+ ): Record<string, string> {
23
+ if (raw === undefined) return {};
24
+ if (!isPlainObject(raw)) {
25
+ ctx.issues.push({
26
+ path: "helpers",
27
+ message: `helpers must be an object, got ${describeType(raw)}`,
28
+ line: findKeyLine(ctx.source, ["helpers"]),
29
+ });
30
+ return {};
31
+ }
32
+ const out: Record<string, string> = Object.create(null) as Record<
33
+ string,
34
+ string
35
+ >;
36
+ for (const [name, body] of Object.entries(raw)) {
37
+ if (typeof body !== "string") {
38
+ ctx.issues.push({
39
+ path: `helpers.${name}`,
40
+ message: `helpers.${name} must be a string template body, got ${describeValue(body)}`,
41
+ line: findKeyLine(ctx.source, ["helpers", name]),
42
+ });
43
+ continue;
44
+ }
45
+ out[name] = body;
46
+ }
47
+ return out;
48
+ }