@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,206 @@
1
+ // [LAW:types-are-the-program] The cache-policy schema: a CacheDecl is exactly one
2
+ // of ttl / watch_file / depends_on / key / never, declared as DATA (CACHE_SCHEMA)
3
+ // and interpreted by the tag-by-present-key engine (oneOfPresent).
4
+ // requireCache/optionalCache gate presence by source kind. This file changes when
5
+ // the cache vocabulary changes — add an arm to CACHE_SCHEMA and CacheDecl.
6
+
7
+ import {
8
+ CACHE_KEYS,
9
+ SOURCES_REQUIRING_CACHE,
10
+ type CacheDecl,
11
+ type SourceKind,
12
+ type TtlCacheDecl,
13
+ } from "../dsl-types.js";
14
+ import { findKeyLine } from "./diagnostics.js";
15
+ import {
16
+ describeValue,
17
+ oneOfPresent,
18
+ oneOfPresentJson,
19
+ type FieldSpec,
20
+ type JsonNode,
21
+ type OneOfPresentSchema,
22
+ type ValidateCtx,
23
+ } from "./validate-core.js";
24
+
25
+ export function requireCache(
26
+ ctx: ValidateCtx,
27
+ path: string,
28
+ raw: Record<string, unknown>,
29
+ kind: SourceKind,
30
+ ): CacheDecl | null {
31
+ if (raw.cache === undefined) {
32
+ if (SOURCES_REQUIRING_CACHE.includes(kind)) {
33
+ ctx.issues.push({
34
+ path: `${path}.cache`,
35
+ message: `${kind} variables must declare a cache policy (one of: ${CACHE_KEYS.join(", ")})`,
36
+ line: findKeyLine(ctx.source, path.split(".")),
37
+ });
38
+ return null;
39
+ }
40
+ // For kinds where cache is optional and absent, this path is unreachable
41
+ // because callers use optionalCache; keep narrow.
42
+ return null;
43
+ }
44
+ return validateCache(ctx, `${path}.cache`, raw.cache);
45
+ }
46
+
47
+ export function optionalCache(
48
+ ctx: ValidateCtx,
49
+ path: string,
50
+ raw: Record<string, unknown>,
51
+ ): CacheDecl | undefined {
52
+ if (raw.cache === undefined) return undefined;
53
+ const c = validateCache(ctx, `${path}.cache`, raw.cache);
54
+ return c ?? undefined;
55
+ }
56
+
57
+ // [LAW:dataflow-not-control-flow] The `cache` field as a record-field spec, so a
58
+ // per-kind variable schema declares its cache policy as DATA. `kind` selects the
59
+ // requiredness: file/shell/git require it (a missing cache reports the per-kind
60
+ // message and fails the arm); template leaves it optional; time is optional but
61
+ // ttl-only (ttlOnlyCacheSpec below). The field key is conventionally "cache",
62
+ // read directly by requireCache/optionalCache.
63
+ export function requireCacheSpec(kind: SourceKind): FieldSpec<CacheDecl> {
64
+ return {
65
+ required: true,
66
+ json: cacheJson(),
67
+ parse: (ctx, path, _field, raw) =>
68
+ requireCache(ctx, path, raw, kind) ?? undefined,
69
+ };
70
+ }
71
+
72
+ export function optionalCacheSpec(): FieldSpec<CacheDecl> {
73
+ return {
74
+ required: false,
75
+ json: cacheJson(),
76
+ parse: (ctx, path, _field, raw) => optionalCache(ctx, path, raw),
77
+ };
78
+ }
79
+
80
+ // [LAW:single-enforcer] One arm helper to push a variant's bespoke message and
81
+ // drop — the message is the only thing that varies per arm, carried as DATA.
82
+ function reject<M>(ctx: ValidateCtx, path: string, message: string): M | null {
83
+ ctx.issues.push({
84
+ path,
85
+ message,
86
+ line: findKeyLine(ctx.source, path.split(".")),
87
+ });
88
+ return null;
89
+ }
90
+
91
+ // [LAW:types-are-the-program] The cache schema declared as DATA: arm keys in
92
+ // CACHE_KEYS order (the structural messages join them), each arm carrying its
93
+ // value-validation predicate and bespoke message. The literal "cache.<key>"
94
+ // prefix is the contract text, independent of the runtime path used for line.
95
+ // [LAW:one-source-of-truth] Each arm's `json` is the schema for the VALUE at its
96
+ // present key — duration/path/key are strings, depends_on a string array, never
97
+ // the literal true; the duration FORMAT (and non-empty) is a semantic check the
98
+ // validator keeps (a JSON Schema `pattern` could mirror it, but the loader's
99
+ // duration grammar is the single authority, so the schema stays at `type:string`).
100
+ const CACHE_SCHEMA: OneOfPresentSchema<CacheDecl> = {
101
+ noun: "cache",
102
+ arms: {
103
+ ttl: {
104
+ json: { type: "string" },
105
+ parse: (ctx, path, value) =>
106
+ typeof value === "string" && isValidDuration(value)
107
+ ? { ttl: value }
108
+ : reject(
109
+ ctx,
110
+ path,
111
+ `cache.ttl must be a duration string like "5s", "100ms", "2m", "1h"; got ${describeValue(value)}`,
112
+ ),
113
+ },
114
+ watch_file: {
115
+ json: { type: "string" },
116
+ parse: (ctx, path, value) =>
117
+ typeof value === "string" && value !== ""
118
+ ? { watch_file: value }
119
+ : reject(
120
+ ctx,
121
+ path,
122
+ `cache.watch_file must be a non-empty path string, got ${describeValue(value)}`,
123
+ ),
124
+ },
125
+ depends_on: {
126
+ json: { type: "array", items: { type: "string" } },
127
+ parse: (ctx, path, value) =>
128
+ Array.isArray(value) && value.every((v) => typeof v === "string")
129
+ ? { depends_on: value as string[] }
130
+ : reject(
131
+ ctx,
132
+ path,
133
+ `cache.depends_on must be an array of variable-name strings, got ${describeValue(value)}`,
134
+ ),
135
+ },
136
+ key: {
137
+ json: { type: "string" },
138
+ parse: (ctx, path, value) =>
139
+ typeof value === "string" && value !== ""
140
+ ? { key: value }
141
+ : reject(
142
+ ctx,
143
+ path,
144
+ `cache.key must be a non-empty template string, got ${describeValue(value)}`,
145
+ ),
146
+ },
147
+ never: {
148
+ json: { const: true },
149
+ parse: (ctx, path, value) =>
150
+ value === true
151
+ ? { never: true }
152
+ : reject(
153
+ ctx,
154
+ path,
155
+ `cache.never must be the literal boolean true, got ${describeValue(value)}`,
156
+ ),
157
+ },
158
+ },
159
+ };
160
+
161
+ function validateCache(
162
+ ctx: ValidateCtx,
163
+ path: string,
164
+ raw: unknown,
165
+ ): CacheDecl | null {
166
+ return oneOfPresent(ctx, CACHE_SCHEMA, path, raw);
167
+ }
168
+
169
+ // [LAW:types-are-the-program] The ttl-only subset for kinds whose runtime
170
+ // honors no other invalidation (time vars refresh on a clock; declareTime
171
+ // always registers a TTL timer). OneOfPresentSchema<TtlCacheDecl> forces
172
+ // exactly the ttl arm at compile time, and the arm is CACHE_SCHEMA's own —
173
+ // a subset of the vocabulary, never a parallel grammar. A non-ttl form is a
174
+ // load-time diagnostic naming ttl as the only supported key, replacing the
175
+ // runtime's former silent coercion to the default TTL [LAW:no-silent-failure].
176
+ const TTL_ONLY_CACHE_SCHEMA: OneOfPresentSchema<TtlCacheDecl> = {
177
+ noun: "time-variable cache",
178
+ arms: { ttl: CACHE_SCHEMA.arms.ttl },
179
+ };
180
+
181
+ export function ttlOnlyCacheSpec(): FieldSpec<TtlCacheDecl> {
182
+ return {
183
+ required: false,
184
+ json: oneOfPresentJson(TTL_ONLY_CACHE_SCHEMA),
185
+ parse: (ctx, path, _field, raw) =>
186
+ raw.cache === undefined
187
+ ? undefined
188
+ : (oneOfPresent(
189
+ ctx,
190
+ TTL_ONLY_CACHE_SCHEMA,
191
+ `${path}.cache`,
192
+ raw.cache,
193
+ ) ?? undefined),
194
+ };
195
+ }
196
+
197
+ // [LAW:one-source-of-truth] The cache emitter derives from the SAME CACHE_SCHEMA
198
+ // the validator interprets — shared by the per-kind variable cache fields.
199
+ export function cacheJson(): JsonNode {
200
+ return oneOfPresentJson(CACHE_SCHEMA);
201
+ }
202
+
203
+ const DURATION_RE = /^(\d+(?:\.\d+)?)(ms|s|m|h)$/;
204
+ function isValidDuration(s: string): boolean {
205
+ return DURATION_RE.test(s);
206
+ }
@@ -0,0 +1,326 @@
1
+ // [LAW:single-enforcer] All cross-reference resolution on the MERGED config:
2
+ // layout nodes name declared segments, every template-bearing field references
3
+ // only existing variables/actions, depends_on points at declared variables, and
4
+ // state/set-action configs declare the session.id anchor. Runs after merge so a
5
+ // user surface can reference default-provided segments/actions. This file changes
6
+ // when the visibility/scoping rules between config parts change.
7
+
8
+ import JSON5 from "json5";
9
+ import {
10
+ hasCacheField,
11
+ walkNodes,
12
+ type DslConfig,
13
+ type VariableDecl,
14
+ } from "../dsl-types.js";
15
+ import { actionBindsSet } from "../action.js";
16
+ import { findKeyLine } from "./diagnostics.js";
17
+ import { isPlainObject, type ValidateCtx } from "./validate-core.js";
18
+ import {
19
+ extractActionRefs,
20
+ extractPickerRefs,
21
+ extractTemplateRefs,
22
+ refResolves,
23
+ } from "./refs.js";
24
+
25
+ export function validateCrossReferences(
26
+ ctx: ValidateCtx,
27
+ cfg: DslConfig,
28
+ ): void {
29
+ // [LAW:one-source-of-truth] THE set of resolvable variable names — a
30
+ // faithful mirror of the runtime store's key set (declareOne in
31
+ // src/dsl/render.ts registers globals under their bare names and segment
32
+ // locals under segName.varName, nothing else). The runtime scope proxy
33
+ // (src/template-engine/scope.ts) resolves only keys literally present in
34
+ // the store, and the depends_on reaction (src/var-system/sources.ts) calls
35
+ // store.read with each listed name verbatim — so exactly the names in this
36
+ // set exist at runtime. One set for every reference surface, template refs
37
+ // and depends_on lists alike: a name's meaning is a pure function of the
38
+ // name string, never of which segment declares or renders it.
39
+ const templateScope = new Set<string>(Object.keys(cfg.variables));
40
+ for (const [segName, seg] of Object.entries(cfg.segments)) {
41
+ if (!seg.vars) continue;
42
+ for (const v of Object.keys(seg.vars)) templateScope.add(`${segName}.${v}`);
43
+ }
44
+
45
+ // [LAW:single-enforcer] ONE pre-order walk over the canonical node tree owns
46
+ // every layout cross-ref: each cells node's segment names must resolve to a
47
+ // declared segment, and any node's `when` predicate (a template like any
48
+ // other) must reference only existing variables. Cross-ref runs on the MERGED
49
+ // config so a node can name default-provided segments without re-declaring
50
+ // them. It traverses the canonical tree — the raw `layout`-vs-`root` authoring
51
+ // form is already collapsed and unrecoverable post-merge — so the path
52
+ // describes the tree and `line` points at whichever layout key the user wrote.
53
+ // [LAW:one-source-of-truth] Which top-level layout surface the user authored
54
+ // is read from the PARSED structure, not a text probe: a nested key named
55
+ // `root` (a variable, a segment) — or `layout` (a `time` var's `layout`
56
+ // field) — would fool a raw `findKeyLine` search and misclassify the config.
57
+ // Validation is cold-path, so reading the source's top-level keys is exact.
58
+ // The reported path/message then point at the surface the user wrote.
59
+ const layoutKey = authoredLayoutKey(ctx.source);
60
+ const layoutLine = findKeyLine(ctx.source, [layoutKey]);
61
+ for (const node of walkNodes(cfg.root)) {
62
+ // [LAW:locality-or-seam] A node's `when` reads the global scope (bare
63
+ // globals + namespaced segment vars) — the same existence-check shape as a
64
+ // segment template, surfaced at load time.
65
+ if (node.when !== undefined) {
66
+ checkTemplateRefs(ctx, `${layoutKey}.when`, node.when, templateScope, {
67
+ line: layoutLine,
68
+ });
69
+ }
70
+ if (node.kind !== "segment") continue;
71
+ if (!Object.prototype.hasOwnProperty.call(cfg.segments, node.name)) {
72
+ ctx.issues.push({
73
+ path: layoutKey,
74
+ message: `${layoutKey} entry "${node.name}" does not match any declared segment`,
75
+ line: layoutLine,
76
+ });
77
+ }
78
+ }
79
+
80
+ // For each variable's template/cache.key, every dotted ref must exist
81
+ // (full path OR a prefix that matches an existing variable's namespace).
82
+ for (const [name, v] of Object.entries(cfg.variables)) {
83
+ checkVarRefs(ctx, `variables.${name}`, v, templateScope);
84
+ }
85
+
86
+ for (const [segName, seg] of Object.entries(cfg.segments)) {
87
+ // [LAW:one-source-of-truth] Segment templates check against the SAME
88
+ // templateScope as everything else — segment locals resolve via the
89
+ // namespaced segName.varName form only, exactly as the runtime store
90
+ // keys them. The segment name is passed purely as a diagnostic hint: a
91
+ // bare ref to an own local is rejected with a message naming the
92
+ // namespaced form the author should write.
93
+ if (seg.vars) {
94
+ for (const [vName, vDecl] of Object.entries(seg.vars)) {
95
+ checkVarRefs(
96
+ ctx,
97
+ `segments.${segName}.vars.${vName}`,
98
+ vDecl,
99
+ templateScope,
100
+ segName,
101
+ );
102
+ }
103
+ }
104
+ // [LAW:locality-or-seam] Variable refs AND `{{ action }}`/`{{ picker }}` refs
105
+ // are checked across EVERY template-bearing field, not just `template` —
106
+ // bg/fg/when are templates too, so an unknown ref in them is a load error, not
107
+ // a render-time surprise. Same existence-check shape as layout→segments; runs
108
+ // on the merged config so a segment can reference a default-provided action.
109
+ for (const field of ["template", "bg", "fg", "when"] as const) {
110
+ const tpl = seg[field];
111
+ if (typeof tpl !== "string") continue;
112
+ checkTemplateRefs(
113
+ ctx,
114
+ `segments.${segName}.${field}`,
115
+ tpl,
116
+ templateScope,
117
+ {
118
+ segCtx: segName,
119
+ },
120
+ );
121
+ // [LAW:locality-or-seam] `{{ action "name" … }}` refs resolve against the
122
+ // action table on the merged config so a segment can reference a
123
+ // default-provided action.
124
+ for (const aref of extractActionRefs(tpl)) {
125
+ if (!Object.prototype.hasOwnProperty.call(cfg.actions, aref)) {
126
+ ctx.issues.push({
127
+ path: `segments.${segName}.${field}`,
128
+ message: `${field} references unknown action "${aref}"`,
129
+ line: findKeyLine(ctx.source, ["segments", segName, field]),
130
+ });
131
+ }
132
+ }
133
+ // [LAW:locality-or-seam] A `{{ picker "apply" "page" … }}` references two
134
+ // named actions — both resolve against the action table at load, same
135
+ // existence-check shape as a bare action ref.
136
+ for (const pref of extractPickerRefs(tpl)) {
137
+ if (!Object.prototype.hasOwnProperty.call(cfg.actions, pref)) {
138
+ ctx.issues.push({
139
+ path: `segments.${segName}.${field}`,
140
+ message: `${field} references unknown action "${pref}" (in a picker)`,
141
+ line: findKeyLine(ctx.source, ["segments", segName, field]),
142
+ });
143
+ }
144
+ }
145
+ }
146
+ }
147
+
148
+ // depends_on lists must point at declared variables — checked against the
149
+ // SAME templateScope as template refs, since both resolve against the one
150
+ // runtime store. The segment name is a diagnostic hint only, never a
151
+ // resolution rule, exactly as for templates.
152
+ for (const [name, v] of Object.entries(cfg.variables)) {
153
+ checkDependsOn(ctx, `variables.${name}`, v, templateScope);
154
+ }
155
+ for (const [segName, seg] of Object.entries(cfg.segments)) {
156
+ if (!seg.vars) continue;
157
+ for (const [vName, vDecl] of Object.entries(seg.vars)) {
158
+ checkDependsOn(
159
+ ctx,
160
+ `segments.${segName}.vars.${vName}`,
161
+ vDecl,
162
+ templateScope,
163
+ segName,
164
+ );
165
+ }
166
+ }
167
+
168
+ // [LAW:verifiable-goals] state-kind variables have an implicit dependency
169
+ // on the canonical session-id input variable. Same shape as the
170
+ // depends_on / template-ref existence checks above — surface a missing
171
+ // anchor at load time so the user fixes the config from a config-file
172
+ // error message, not from a render-time ReferenceError.
173
+ //
174
+ // [LAW:types-are-the-program] Check against `cfg.variables` directly: the
175
+ // accept/reject table for this predicate is "GLOBAL session.id declared".
176
+ // A segment-local declaration named "session.id" registers at runtime as
177
+ // `<seg>.session.id` and does NOT satisfy declareState's read of the
178
+ // global `session.id` box.
179
+ // [LAW:verifiable-goals] A widget `set` action composes a set-state click URL
180
+ // whose first segment is `session.id` (read from the store at render). Without
181
+ // a global session.id the URL is malformed and the daemon rejects the click
182
+ // (requireSessionId is the single enforcer — it rejects empty/slash session
183
+ // ids loudly, so there is no silent corruption; this surfaces the SAME
184
+ // requirement at load instead of at first click). Same anchor + same shape as
185
+ // the state-kind requirement above; OR them so either trigger fires it once.
186
+ // [LAW:dataflow-not-control-flow] A `set` action composes a set-state click URL
187
+ // whose first segment is session.id. OR it into the same requirement so an
188
+ // actions-only config (no state vars) still demands the anchor it needs. A
189
+ // picker's ✕/←/→/apply-close all go through `set` actions, so this covers them.
190
+ if (
191
+ (hasStateKind(cfg) || hasActionSetAction(cfg)) &&
192
+ !Object.prototype.hasOwnProperty.call(cfg.variables, "session.id")
193
+ ) {
194
+ ctx.issues.push({
195
+ path: "variables.session.id",
196
+ message: `state reads and action set-writes require a global "session.id" variable (segment-local declarations do not satisfy this — declareState/set-state both read the global box; conventionally { kind: "input", path: "session_id" })`,
197
+ line: findKeyLine(ctx.source, ["variables"]),
198
+ });
199
+ }
200
+ }
201
+
202
+ function hasStateKind(cfg: DslConfig): boolean {
203
+ for (const v of Object.values(cfg.variables)) {
204
+ if (v.kind === "state") return true;
205
+ }
206
+ for (const seg of Object.values(cfg.segments)) {
207
+ if (!seg.vars) continue;
208
+ for (const v of Object.values(seg.vars)) {
209
+ if (v.kind === "state") return true;
210
+ }
211
+ }
212
+ return false;
213
+ }
214
+
215
+ // [LAW:dataflow-not-control-flow] A config emits a set-state click — and so needs
216
+ // session.id — when any declared action is a `set` (literal, option, or bounded).
217
+ // copy/open actions write nothing, so they embed no session.id.
218
+ function hasActionSetAction(cfg: DslConfig): boolean {
219
+ return Object.values(cfg.actions).some(actionBindsSet);
220
+ }
221
+
222
+ function checkVarRefs(
223
+ ctx: ValidateCtx,
224
+ declPath: string,
225
+ v: VariableDecl,
226
+ allVars: Set<string>,
227
+ segCtx?: string,
228
+ ): void {
229
+ if (v.kind === "template") {
230
+ checkTemplateRefs(ctx, `${declPath}.template`, v.template, allVars, {
231
+ segCtx,
232
+ });
233
+ }
234
+ if (hasCacheField(v)) {
235
+ if (v.cache && "key" in v.cache) {
236
+ checkTemplateRefs(ctx, `${declPath}.cache.key`, v.cache.key, allVars, {
237
+ segCtx,
238
+ });
239
+ }
240
+ }
241
+ }
242
+
243
+ function checkDependsOn(
244
+ ctx: ValidateCtx,
245
+ declPath: string,
246
+ v: VariableDecl,
247
+ allVars: Set<string>,
248
+ segCtx?: string,
249
+ ): void {
250
+ if (!hasCacheField(v)) return;
251
+ if (!v.cache) return;
252
+ if (!("depends_on" in v.cache)) return;
253
+ for (let i = 0; i < v.cache.depends_on.length; i++) {
254
+ const target = v.cache.depends_on[i]!;
255
+ // [LAW:one-source-of-truth] Exact membership, not refResolves: the
256
+ // depends_on reaction calls store.read(name) with each listed name
257
+ // verbatim, and the store is an exact-key map. A dotted prefix that
258
+ // merely navigates INTO a value (resolvable in a template) is not a
259
+ // store key and would throw at runtime.
260
+ if (allVars.has(target)) continue;
261
+ const namespaced = segCtx !== undefined ? `${segCtx}.${target}` : undefined;
262
+ const hint =
263
+ namespaced !== undefined && allVars.has(namespaced)
264
+ ? ` (segment-local vars are namespaced — write "${namespaced}")`
265
+ : "";
266
+ ctx.issues.push({
267
+ path: `${declPath}.cache.depends_on[${i}]`,
268
+ message: `cache.depends_on references unknown variable "${target}"${hint}`,
269
+ line: findKeyLine(ctx.source, [
270
+ ...declPath.split("."),
271
+ "cache",
272
+ "depends_on",
273
+ ]),
274
+ });
275
+ }
276
+ }
277
+
278
+ function checkTemplateRefs(
279
+ ctx: ValidateCtx,
280
+ declPath: string,
281
+ template: string,
282
+ allVars: Set<string>,
283
+ opts?: {
284
+ // [LAW:one-source-of-truth] Callers whose `declPath` is not a literal key
285
+ // path into the source (a node `when`, whose canonical tree position no
286
+ // longer maps to a source key after the layout/root merge) pass the
287
+ // already-resolved line explicitly. Absent, the line is derived from the
288
+ // dotted declPath as before.
289
+ line?: number;
290
+ // The segment whose template is being checked — a diagnostic hint only,
291
+ // never a resolution rule. When a failing bare ref would resolve under
292
+ // this segment's namespace, the message names the namespaced form.
293
+ segCtx?: string;
294
+ },
295
+ ): void {
296
+ for (const ref of extractTemplateRefs(template)) {
297
+ if (refResolves(ref, allVars)) continue;
298
+ const namespaced =
299
+ opts?.segCtx !== undefined ? `${opts.segCtx}.${ref}` : undefined;
300
+ const hint =
301
+ namespaced !== undefined && refResolves(namespaced, allVars)
302
+ ? ` (segment-local vars are namespaced — write ".${namespaced}")`
303
+ : "";
304
+ ctx.issues.push({
305
+ path: declPath,
306
+ message: `Template references unknown variable ".${ref}"${hint}`,
307
+ line: opts?.line ?? findKeyLine(ctx.source, declPath.split(".")),
308
+ });
309
+ }
310
+ }
311
+
312
+ // [LAW:one-source-of-truth] The authored top-level layout surface, read from the
313
+ // PARSED top-level keys (`root` wins; the loader already rejects authoring both).
314
+ // A structural read — not a text search — so a nested key named `root`/`layout`
315
+ // can never misclassify the config. Empty/unparseable source (the bundled
316
+ // default, no file) has no surface; defaults to the historical `layout` label.
317
+ function authoredLayoutKey(source: string): "root" | "layout" {
318
+ try {
319
+ const parsed = JSON5.parse(source);
320
+ if (isPlainObject(parsed) && "root" in parsed) return "root";
321
+ } catch {
322
+ // No source to read (default config) or unparseable — fall through. A real
323
+ // syntax error is already reported by parseDslConfig before cross-ref runs.
324
+ }
325
+ return "layout";
326
+ }
@@ -0,0 +1,148 @@
1
+ // [LAW:types-are-the-program] Dependency-cycle detection over the variable graph.
2
+ // Edges come from three sources (template refs, cache.key refs, cache.depends_on);
3
+ // a single DFS catches mixed cycles spanning edge types. This file changes when
4
+ // what constitutes a runtime dependency edge changes.
5
+
6
+ import {
7
+ hasCacheField,
8
+ type DslConfig,
9
+ type VariableDecl,
10
+ } from "../dsl-types.js";
11
+ import { findKeyLine } from "./diagnostics.js";
12
+ import { type ValidateCtx } from "./validate-core.js";
13
+ import { extractTemplateRefs } from "./refs.js";
14
+
15
+ // Carries declaration metadata for each graph node so cycle errors report the
16
+ // correct config path (variables.X vs segments.S.vars.X) and correct line.
17
+ interface NodeInfo {
18
+ readonly declarationPath: string;
19
+ readonly linePathParts: readonly string[];
20
+ }
21
+
22
+ export function validateNoCycles(ctx: ValidateCtx, cfg: DslConfig): void {
23
+ const { graph, nodeInfo } = buildTemplateGraph(cfg);
24
+
25
+ const WHITE = 0;
26
+ const GRAY = 1;
27
+ const BLACK = 2;
28
+ const color = new Map<string, number>();
29
+ const stack: string[] = [];
30
+
31
+ for (const node of graph.keys()) color.set(node, WHITE);
32
+
33
+ for (const start of graph.keys()) {
34
+ if (color.get(start) !== WHITE) continue;
35
+ if (dfs(start)) return; // first cycle is enough — report and stop walking
36
+ }
37
+
38
+ function dfs(node: string): boolean {
39
+ color.set(node, GRAY);
40
+ stack.push(node);
41
+ for (const next of graph.get(node) ?? []) {
42
+ const c = color.get(next);
43
+ if (c === GRAY) {
44
+ const cycleStart = stack.indexOf(next);
45
+ const cycle = [...stack.slice(cycleStart), next];
46
+ const firstNode = cycle[0]!;
47
+ const info = nodeInfo.get(firstNode);
48
+ ctx.issues.push({
49
+ path: info?.declarationPath ?? `variables.${firstNode}`,
50
+ message: `Dependency cycle: ${cycle.join(" → ")}`,
51
+ line: findKeyLine(
52
+ ctx.source,
53
+ info?.linePathParts ?? ["variables", firstNode],
54
+ ),
55
+ });
56
+ return true;
57
+ }
58
+ if (c === WHITE && dfs(next)) return true;
59
+ }
60
+ color.set(node, BLACK);
61
+ stack.pop();
62
+ return false;
63
+ }
64
+ }
65
+
66
+ // [LAW:types-are-the-program] Build the full variable dependency graph: edges
67
+ // are X → Y for any of three edge kinds:
68
+ // 1. template-kind vars: template string references Y (eval dependency)
69
+ // 2. any var with cache.key: key template references Y (cache-key dependency)
70
+ // 3. any var with cache.depends_on: each listed name is Y (invalidation dep)
71
+ // All three kinds can form infinite loops at runtime; a single DFS catches
72
+ // mixed cycles that span multiple edge types.
73
+ //
74
+ // Segment vars use the namespaced form (segName.varName) as their sole graph
75
+ // node — eliminates bare-name collisions when two segments both declare a var
76
+ // named e.g. "local". Global vars keep their bare names.
77
+ function buildTemplateGraph(cfg: DslConfig): {
78
+ graph: Map<string, Set<string>>;
79
+ nodeInfo: Map<string, NodeInfo>;
80
+ } {
81
+ const allVarNames = new Set<string>(Object.keys(cfg.variables));
82
+ const nodeInfo = new Map<string, NodeInfo>();
83
+
84
+ for (const name of Object.keys(cfg.variables)) {
85
+ nodeInfo.set(name, {
86
+ declarationPath: `variables.${name}`,
87
+ linePathParts: ["variables", name],
88
+ });
89
+ }
90
+ for (const [segName, seg] of Object.entries(cfg.segments)) {
91
+ if (!seg.vars) continue;
92
+ for (const vName of Object.keys(seg.vars)) {
93
+ const canonical = `${segName}.${vName}`;
94
+ allVarNames.add(canonical);
95
+ nodeInfo.set(canonical, {
96
+ declarationPath: `segments.${segName}.vars.${vName}`,
97
+ linePathParts: ["segments", segName, "vars", vName],
98
+ });
99
+ }
100
+ }
101
+
102
+ const graph = new Map<string, Set<string>>();
103
+ for (const name of allVarNames) graph.set(name, new Set());
104
+
105
+ // [LAW:one-source-of-truth] Edges resolve refs exactly as the runtime scope
106
+ // proxy does: a ref is the literal store key (globals bare, segment locals
107
+ // namespaced as segName.varName) — never re-derived per segment. Bare
108
+ // own-segment refs are not aliased here because the runtime has no such
109
+ // aliasing; cross-ref rejects them at load with the namespaced suggestion.
110
+ const addTemplateEdges = (from: string, template: string): void => {
111
+ for (const ref of extractTemplateRefs(template)) {
112
+ if (allVarNames.has(ref)) {
113
+ graph.get(from)!.add(ref);
114
+ continue;
115
+ }
116
+ // Resolve "first identifier" — `.session.id` may indicate dependence on
117
+ // `session` if that's the declared var (matches scope.ts proxy walk).
118
+ const head = ref.split(".")[0]!;
119
+ if (head !== ref && allVarNames.has(head)) {
120
+ graph.get(from)!.add(head);
121
+ }
122
+ }
123
+ };
124
+
125
+ const addVarEdges = (name: string, v: VariableDecl): void => {
126
+ if (v.kind === "template") addTemplateEdges(name, v.template);
127
+ if (hasCacheField(v)) {
128
+ if (v.cache && "key" in v.cache) addTemplateEdges(name, v.cache.key);
129
+ if (v.cache && "depends_on" in v.cache) {
130
+ for (const dep of v.cache.depends_on) {
131
+ if (allVarNames.has(dep)) graph.get(name)!.add(dep);
132
+ }
133
+ }
134
+ }
135
+ };
136
+
137
+ for (const [name, v] of Object.entries(cfg.variables)) {
138
+ addVarEdges(name, v);
139
+ }
140
+ for (const [segName, seg] of Object.entries(cfg.segments)) {
141
+ if (!seg.vars) continue;
142
+ for (const [vName, vDecl] of Object.entries(seg.vars)) {
143
+ addVarEdges(`${segName}.${vName}`, vDecl);
144
+ }
145
+ }
146
+
147
+ return { graph, nodeInfo };
148
+ }