@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.
- package/LICENSE +21 -0
- package/README.md +145 -0
- package/bin/cc-candybar +6 -0
- package/dist/index.mjs +185 -0
- package/package.json +99 -0
- package/plugin/.claude-plugin/plugin.json +11 -0
- package/plugin/bin/preview.sh +305 -0
- package/plugin/commands/candybar.md +403 -0
- package/plugin/templates/config-essential.json +36 -0
- package/plugin/templates/config-full.json +55 -0
- package/plugin/templates/config-standard.json +39 -0
- package/plugin/templates/config-tui-compact.json +48 -0
- package/plugin/templates/config-tui-full.json +89 -0
- package/plugin/templates/config-tui-standard.json +56 -0
- package/plugin/templates/config-tui.json +18 -0
- package/plugin/templates/nerd-fonts-sample.txt +5 -0
- package/schema/cc-candybar.schema.json +1379 -0
- package/src/click/wire.ts +113 -0
- package/src/config/action.ts +91 -0
- package/src/config/cli.ts +170 -0
- package/src/config/default-dsl-config.ts +661 -0
- package/src/config/dsl-loader.ts +265 -0
- package/src/config/dsl-types.ts +425 -0
- package/src/config/loader/actions.ts +530 -0
- package/src/config/loader/cache.ts +206 -0
- package/src/config/loader/cross-ref.ts +326 -0
- package/src/config/loader/cycles.ts +148 -0
- package/src/config/loader/diagnostics.ts +99 -0
- package/src/config/loader/discovery.ts +182 -0
- package/src/config/loader/emit-schema.ts +63 -0
- package/src/config/loader/globals.ts +42 -0
- package/src/config/loader/helpers.ts +48 -0
- package/src/config/loader/layout.ts +688 -0
- package/src/config/loader/merge.ts +40 -0
- package/src/config/loader/refs.ts +96 -0
- package/src/config/loader/segments.ts +120 -0
- package/src/config/loader/validate-core.ts +674 -0
- package/src/config/loader/variables.ts +260 -0
- package/src/daemon/acquire.ts +411 -0
- package/src/daemon/cache/git.ts +553 -0
- package/src/daemon/cache/render.ts +449 -0
- package/src/daemon/cache/session-usage-store.ts +446 -0
- package/src/daemon/cache/watchers.ts +245 -0
- package/src/daemon/client-debug.ts +120 -0
- package/src/daemon/client-stats.ts +129 -0
- package/src/daemon/client-transport.ts +273 -0
- package/src/daemon/client.ts +75 -0
- package/src/daemon/debug-types.ts +91 -0
- package/src/daemon/debug.ts +264 -0
- package/src/daemon/limits.ts +154 -0
- package/src/daemon/log.ts +69 -0
- package/src/daemon/parent-watchdog.ts +80 -0
- package/src/daemon/paths.ts +127 -0
- package/src/daemon/protocol.ts +235 -0
- package/src/daemon/render-payload.ts +611 -0
- package/src/daemon/server.ts +1103 -0
- package/src/daemon/session-state-file.ts +108 -0
- package/src/daemon/session-state.ts +237 -0
- package/src/daemon/stats.ts +229 -0
- package/src/daemon/verbs/index.ts +458 -0
- package/src/daemon/verbs/state-validators.ts +708 -0
- package/src/demo/dsl.ts +117 -0
- package/src/demo/mock-data.ts +67 -0
- package/src/demo/statusline.json5 +92 -0
- package/src/dsl/node-registry.ts +281 -0
- package/src/dsl/render.ts +558 -0
- package/src/index.ts +206 -0
- package/src/install/index.ts +410 -0
- package/src/proc/launch.ts +451 -0
- package/src/proc/stats-handle.ts +13 -0
- package/src/render/action.ts +458 -0
- package/src/render/diagnostic-style.ts +23 -0
- package/src/render/diagnostic-text.ts +77 -0
- package/src/render/error-glyph.ts +53 -0
- package/src/render/outcome-plan.ts +45 -0
- package/src/render/picker.ts +231 -0
- package/src/render/split-lines.ts +51 -0
- package/src/render/strip.ts +103 -0
- package/src/segments/cache.ts +131 -0
- package/src/segments/context.ts +190 -0
- package/src/segments/git.ts +561 -0
- package/src/segments/metrics.ts +101 -0
- package/src/segments/pricing.ts +452 -0
- package/src/segments/session.ts +188 -0
- package/src/segments/tmux.ts +74 -0
- package/src/template-engine/cells.ts +90 -0
- package/src/template-engine/colors.ts +102 -0
- package/src/template-engine/engine.ts +108 -0
- package/src/template-engine/funcs.ts +216 -0
- package/src/template-engine/index.ts +11 -0
- package/src/template-engine/layout.ts +112 -0
- package/src/template-engine/scope.ts +62 -0
- package/src/themes/index.ts +19 -0
- package/src/themes/palette-resolvers.ts +86 -0
- package/src/themes/policy.ts +79 -0
- package/src/themes/session-random.ts +88 -0
- package/src/utils/cache.ts +206 -0
- package/src/utils/claude.ts +616 -0
- package/src/utils/color-support.ts +118 -0
- package/src/utils/formatters.ts +77 -0
- package/src/utils/logger.ts +5 -0
- package/src/utils/outcome.ts +33 -0
- package/src/utils/schema-validator.ts +126 -0
- package/src/utils/single-flight.ts +57 -0
- package/src/utils/terminal-width.ts +43 -0
- package/src/utils/terminal.ts +11 -0
- package/src/utils/transcript-fs.ts +162 -0
- package/src/var-system/index.ts +24 -0
- package/src/var-system/sources.ts +1038 -0
- package/src/var-system/store.ts +223 -0
- 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
|
+
}
|