@checkstack/template-engine 0.2.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/CHANGELOG.md ADDED
@@ -0,0 +1,114 @@
1
+ # @checkstack/template-engine
2
+
3
+ ## 0.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 41c77f4: feat(automation): Phase 11 — editor primitives + context type generation
8
+
9
+ Lays the UI + type-generation groundwork for Phase 12's visual automation
10
+ editor. Every primitive reuses the existing Monaco wrapper / template
11
+ engine / `jsonSchemaToTypeScript` helper rather than building parallel
12
+ infrastructure.
13
+
14
+ **`@checkstack/automation-common` — `resolveVariableScope`**
15
+
16
+ Pure walker that returns the in-scope `{{ … }}` paths at a given action
17
+ position. Conservative scoping rules: linear-upstream variables /
18
+ artifacts only (no leaking across `choose` / `parallel` / `repeat`
19
+ branches), `repeat.index` / `repeat.item` exposed only inside a `repeat`,
20
+ and trigger.payload modelled as a **discriminated union over
21
+ `trigger.event`** — every payload field surfaces; ones that come from a
22
+ subset of subscribed triggers carry a `conditionalOnTriggers` annotation
23
+ so the picker can render an "Only when …" hint. Earlier draft used
24
+ schema-intersection; switched to discriminated unions per review
25
+ feedback so Monaco can narrow correctly inside event-gated branches.
26
+
27
+ **Condition-aware narrowing.** When the path descends through a
28
+ `choose-when`, the resolver parses the branch's `when:` expression and
29
+ statically pins `trigger.event` to the set the condition allows —
30
+ patterns covered are `trigger.event == "X"` (either operand order),
31
+ `trigger.event != "X"`, `||`/`&&` of those, and `{ and: [...] }` /
32
+ `{ or: [...] }` combinators. So an action inside
33
+ `when: 'trigger.event == "incident.created"'` sees only the
34
+ `incident.created` variant in scope, the `conditionalOnTriggers`
35
+ annotation disappears, and other-trigger fields drop out entirely.
36
+ Nested choose branches compound (intersection). Anything outside the
37
+ covered patterns falls back to the full union — better to show every
38
+ field than guess wrong.
39
+
40
+ **`@checkstack/template-engine`**
41
+
42
+ The expression AST (`Expr`, `BinaryExpr`, `MemberExpr`, etc.) is now a
43
+ public export — the resolver's condition-narrowing walker needs to
44
+ inspect parsed condition trees. `ParsedCondition.root` is tightened
45
+ from `unknown` to `Expr` so consumers don't need to cast.
46
+
47
+ **`@checkstack/automation-frontend` — `generateAutomationContextTypes`**
48
+
49
+ Consumes `resolveVariableScope`'s output + the trigger / artifact
50
+ registries and emits the `declare const context: { … }` TS declaration
51
+ that `integration-script.run_script`'s Monaco editor injects via
52
+ `addExtraLib`. The emitted shape:
53
+
54
+ ```ts
55
+ type AutomationTrigger =
56
+ | { event: "incident.created"; payload: { … } }
57
+ | { event: "incident.resolved"; payload: { … } };
58
+
59
+ declare const context: {
60
+ trigger: AutomationTrigger;
61
+ artifacts: { "jira.issue"?: { key: string; … }; … };
62
+ var: { foo?: string; … };
63
+ repeat: { index: number; item: unknown }; // only when inside a repeat
64
+ };
65
+ ```
66
+
67
+ `jsonSchemaToTypeScript` from `@checkstack/ui` is reused via a deep
68
+ import (rather than the barrel) so the bun test runner doesn't try to
69
+ load Monaco's Vite-only `?worker` modules during unit tests.
70
+
71
+ **`@checkstack/ui` — new editor primitives**
72
+
73
+ - `TemplateValueInput` — single-line `{{ }}` autocomplete input.
74
+ Extracted from `DynamicForm/KeyValueEditor`'s previously-private
75
+ `TemplateInput` so other editor surfaces can share it without
76
+ rebuilding the picker UX. `KeyValueEditor` is now a one-line
77
+ delegation; `detectTemplateContext` is also exported.
78
+ - `VariablePicker` — hierarchical popover for the explicit "fx" /
79
+ "Insert variable" workflow. Renders a filterable tree of
80
+ `VariableNode`s with type chips and `Only when …` hints sourced from
81
+ the resolver's `conditionalOnTriggers`. Defaults to a small "fx" pill
82
+ trigger; callers can pass a custom one.
83
+ - `TemplateInput` — high-level mode switcher: `text` mode delegates to
84
+ `TemplateValueInput`, all other modes (`code` / `bash` / `json` /
85
+ `yaml`) delegate to `CodeEditor` with the matching language so the
86
+ action editor can swap widgets purely from the action's
87
+ `x-editor-types` annotation without touching the consuming code.
88
+ - `TemplateInputToggle` — the small "fx" pill that flips a typed input
89
+ (number / select / date / …) into template mode and back. Auto-infers
90
+ template mode when the saved value already starts with `{{`, so
91
+ round-tripping a previously-templated automation works out of the
92
+ box. Render-prop API for the typed editor so consumers keep control
93
+ over their own input shape.
94
+ - `ActionCard` — collapsible card that hosts a single action in the
95
+ visual editor. Decoupled from `DynamicForm` so container blocks
96
+ (`ChooseBlock` / `ParallelBlock` / `RepeatBlock` in Phase 12) can use
97
+ it as a structural shell over their own children. Toggle / delete /
98
+ drag handle are conditionally rendered on their callback's presence.
99
+
100
+ Storybook stories shipped for each of the new primitives.
101
+
102
+ **`@checkstack/integration-script-backend`**
103
+
104
+ `ScriptContext` docstring and the `scriptRunConfigSchema.script` field
105
+ description now point at `generateAutomationContextTypes` so the Phase
106
+ 12 editor wiring is unambiguous — the runtime payload type stays
107
+ `Record<string, unknown>` (the runner can't know the trigger schema),
108
+ but the **editor** narrows it per-automation from the subscribed
109
+ triggers' payload schemas.
110
+
111
+ ### Patch Changes
112
+
113
+ - Updated dependencies [6d52276]
114
+ - @checkstack/common@0.12.0
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "@checkstack/template-engine",
3
+ "version": "0.2.0",
4
+ "license": "Elastic-2.0",
5
+ "type": "module",
6
+ "exports": {
7
+ ".": {
8
+ "types": "./src/index.ts",
9
+ "import": "./src/index.ts"
10
+ }
11
+ },
12
+ "dependencies": {
13
+ "@checkstack/common": "0.11.0",
14
+ "zod": "^4.0.0"
15
+ },
16
+ "devDependencies": {
17
+ "@checkstack/scripts": "0.3.3",
18
+ "@checkstack/tsconfig": "0.0.7",
19
+ "typescript": "^5.7.2"
20
+ },
21
+ "scripts": {
22
+ "typecheck": "tsgo -b",
23
+ "lint": "bun run lint:code",
24
+ "lint:code": "eslint . --max-warnings 0",
25
+ "test": "bun test"
26
+ },
27
+ "checkstack": {
28
+ "type": "common"
29
+ }
30
+ }
@@ -0,0 +1,74 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { parseCondition, parseTemplate } from "../parser";
3
+ import { TemplateParseError } from "../errors";
4
+
5
+ describe("parseTemplate", () => {
6
+ it("parses pure literal text", () => {
7
+ const t = parseTemplate("hello world");
8
+ expect(t.nodes).toHaveLength(1);
9
+ });
10
+
11
+ it("parses a single expression", () => {
12
+ const t = parseTemplate("{{ foo.bar }}");
13
+ expect(t.nodes).toHaveLength(1);
14
+ });
15
+
16
+ it("parses interleaved text and expressions", () => {
17
+ const t = parseTemplate("Hello {{ name }}, you have {{ count }} messages.");
18
+ expect(t.nodes).toHaveLength(5);
19
+ });
20
+
21
+ it("rejects unterminated expression", () => {
22
+ expect(() => parseTemplate("hello {{ foo")).toThrow(TemplateParseError);
23
+ });
24
+
25
+ it("rejects unterminated string", () => {
26
+ expect(() => parseTemplate('{{ "unterminated }}')).toThrow(
27
+ TemplateParseError,
28
+ );
29
+ });
30
+
31
+ it("handles escape sequences in strings", () => {
32
+ const t = parseTemplate('{{ "line1\\nline2" }}');
33
+ expect(t.nodes).toHaveLength(1);
34
+ });
35
+
36
+ it("parses string-literal index access with hyphen and dot in the key", () => {
37
+ const t = parseTemplate(
38
+ '{{ artifacts["integration-jira.issue"].issueKey }}',
39
+ );
40
+ expect(t.nodes).toHaveLength(1);
41
+ });
42
+
43
+ it("rejects unbracketed hyphenated member access", () => {
44
+ expect(() =>
45
+ parseTemplate("{{ artifacts.integration-jira.issue }}"),
46
+ ).toThrow(TemplateParseError);
47
+ });
48
+ });
49
+
50
+ describe("parseCondition", () => {
51
+ it("parses a simple equality", () => {
52
+ const c = parseCondition("trigger.eventId == 'incident.created'");
53
+ expect(c.source).toBe("trigger.eventId == 'incident.created'");
54
+ });
55
+
56
+ it("parses logical expressions", () => {
57
+ const c = parseCondition("a && b || c");
58
+ expect(c).toBeDefined();
59
+ });
60
+
61
+ it("parses ternaries", () => {
62
+ const c = parseCondition("severity == 'critical' ? 'high' : 'normal'");
63
+ expect(c).toBeDefined();
64
+ });
65
+
66
+ it("parses pipes with filter calls", () => {
67
+ const c = parseCondition("title | truncate(40) | upper");
68
+ expect(c).toBeDefined();
69
+ });
70
+
71
+ it("rejects malformed input", () => {
72
+ expect(() => parseCondition("foo ==")).toThrow(TemplateParseError);
73
+ });
74
+ });
@@ -0,0 +1,183 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { parseCondition, parseTemplate } from "../parser";
3
+ import {
4
+ evaluate,
5
+ evaluateBoolean,
6
+ render,
7
+ } from "../renderer";
8
+ import { TemplateRenderError, UnknownFilterError } from "../errors";
9
+
10
+ describe("render", () => {
11
+ it("renders literal text unchanged", () => {
12
+ expect(render(parseTemplate("hello"), {})).toBe("hello");
13
+ });
14
+
15
+ it("substitutes simple identifiers", () => {
16
+ const out = render(parseTemplate("Hello {{ name }}!"), {
17
+ name: "Nico",
18
+ });
19
+ expect(out).toBe("Hello Nico!");
20
+ });
21
+
22
+ it("resolves nested member access", () => {
23
+ const out = render(parseTemplate("{{ trigger.payload.title }}"), {
24
+ trigger: { payload: { title: "Database down" } },
25
+ });
26
+ expect(out).toBe("Database down");
27
+ });
28
+
29
+ it("resolves array index access", () => {
30
+ const out = render(parseTemplate("{{ items[0] }}"), {
31
+ items: ["first", "second"],
32
+ });
33
+ expect(out).toBe("first");
34
+ });
35
+
36
+ it("resolves a numeric array index after a bracket-key chain", () => {
37
+ const out = render(
38
+ parseTemplate('{{ artifacts["x"].tags[0] }}'),
39
+ { artifacts: { x: { tags: ["first", "second"] } } },
40
+ );
41
+ expect(out).toBe("first");
42
+ });
43
+
44
+ it("resolves bracket access with string", () => {
45
+ const out = render(parseTemplate('{{ nodes["jira-create"].issueKey }}'), {
46
+ nodes: { "jira-create": { issueKey: "PROJ-42" } },
47
+ });
48
+ expect(out).toBe("PROJ-42");
49
+ });
50
+
51
+ it("renders empty string for missing references (non-strict)", () => {
52
+ const out = render(parseTemplate("Hello {{ missing }}!"), {});
53
+ expect(out).toBe("Hello !");
54
+ });
55
+
56
+ it("throws in strict mode for missing references", () => {
57
+ expect(() =>
58
+ render(parseTemplate("Hello {{ missing }}!"), {}, { strict: true }),
59
+ ).toThrow(TemplateRenderError);
60
+ });
61
+
62
+ it("supports filter pipes", () => {
63
+ const out = render(parseTemplate("{{ title | upper }}"), {
64
+ title: "fire",
65
+ });
66
+ expect(out).toBe("FIRE");
67
+ });
68
+
69
+ it("supports filter pipes with arguments", () => {
70
+ const out = render(parseTemplate('{{ x | default("none") }}'), {});
71
+ expect(out).toBe("none");
72
+ });
73
+
74
+ it("chains multiple filters", () => {
75
+ const out = render(parseTemplate("{{ x | default('fallback') | upper }}"), {});
76
+ expect(out).toBe("FALLBACK");
77
+ });
78
+
79
+ it("renders ternaries", () => {
80
+ const out = render(
81
+ parseTemplate("{{ severity == 'critical' ? 'PAGE' : 'log' }}"),
82
+ { severity: "critical" },
83
+ );
84
+ expect(out).toBe("PAGE");
85
+ });
86
+
87
+ it("renders objects as JSON", () => {
88
+ const out = render(parseTemplate("{{ obj }}"), {
89
+ obj: { a: 1, b: 2 },
90
+ });
91
+ expect(out).toBe('{"a":1,"b":2}');
92
+ });
93
+
94
+ it("throws UnknownFilterError for missing filters", () => {
95
+ expect(() => render(parseTemplate("{{ x | unknown_filter }}"), { x: 1 })).toThrow(
96
+ UnknownFilterError,
97
+ );
98
+ });
99
+
100
+ it("supports truncate filter", () => {
101
+ const out = render(parseTemplate("{{ msg | truncate(5) }}"), {
102
+ msg: "Hello World",
103
+ });
104
+ expect(out).toBe("Hello…");
105
+ });
106
+ });
107
+
108
+ describe("string-literal index access (frontend regression contract)", () => {
109
+ it("resolves a quoted index key containing a hyphen and dot", () => {
110
+ const out = render(
111
+ parseTemplate('{{ artifacts["integration-jira.issue"].issueKey }}'),
112
+ { artifacts: { "integration-jira.issue": { issueKey: "ABC-1" } } },
113
+ );
114
+ expect(out).toBe("ABC-1");
115
+ });
116
+
117
+ it("resolves dotted member access on variables", () => {
118
+ const out = render(parseTemplate("{{ variables.foo }}"), {
119
+ variables: { foo: "bar" },
120
+ });
121
+ expect(out).toBe("bar");
122
+ });
123
+
124
+ it("resolves the artifacts.<id>.<name>.<field> reference form", () => {
125
+ const out = render(
126
+ parseTemplate("{{ artifacts.create_issue.issue.issueKey }}"),
127
+ {
128
+ artifacts: {
129
+ create_issue: { issue: { issueKey: "PROJ-7" } },
130
+ },
131
+ },
132
+ );
133
+ expect(out).toBe("PROJ-7");
134
+ });
135
+ });
136
+
137
+ describe("evaluate / evaluateBoolean", () => {
138
+ it("evaluates equality conditions", () => {
139
+ expect(
140
+ evaluateBoolean(parseCondition("a == b"), { a: 1, b: 1 }),
141
+ ).toBe(true);
142
+ });
143
+
144
+ it("evaluates string equality with quoted RHS", () => {
145
+ expect(
146
+ evaluateBoolean(
147
+ parseCondition("trigger.eventId == 'incident.created'"),
148
+ { trigger: { eventId: "incident.created" } },
149
+ ),
150
+ ).toBe(true);
151
+ });
152
+
153
+ it("evaluates logical AND/OR short-circuit", () => {
154
+ expect(evaluateBoolean(parseCondition("false && missing.foo"), {})).toBe(
155
+ false,
156
+ );
157
+ expect(evaluateBoolean(parseCondition("true || missing.foo"), {})).toBe(
158
+ true,
159
+ );
160
+ });
161
+
162
+ it("evaluates negation", () => {
163
+ expect(evaluateBoolean(parseCondition("!enabled"), { enabled: false })).toBe(
164
+ true,
165
+ );
166
+ });
167
+
168
+ it("evaluates comparison operators", () => {
169
+ expect(evaluateBoolean(parseCondition("3 < 4"), {})).toBe(true);
170
+ expect(evaluateBoolean(parseCondition("4 >= 4"), {})).toBe(true);
171
+ });
172
+
173
+ it("returns raw value from evaluate", () => {
174
+ expect(evaluate(parseCondition("severity"), { severity: "critical" })).toBe(
175
+ "critical",
176
+ );
177
+ });
178
+
179
+ it("treats empty arrays and objects as falsy (consistent with truthiness rule)", () => {
180
+ expect(evaluateBoolean(parseCondition("items"), { items: [] })).toBe(false);
181
+ expect(evaluateBoolean(parseCondition("obj"), { obj: {} })).toBe(false);
182
+ });
183
+ });
package/src/ast.ts ADDED
@@ -0,0 +1,101 @@
1
+ import type { SourceRange } from "./types";
2
+
3
+ /**
4
+ * AST node kinds for the expression sublanguage. Templates are made up of
5
+ * `TextNode` + `ExpressionNode` at the top level; expressions form their
6
+ * own tree of `Expr` nodes.
7
+ */
8
+ export type TemplateNode = TextNode | ExpressionNode;
9
+
10
+ export interface TextNode {
11
+ kind: "text";
12
+ value: string;
13
+ range: SourceRange;
14
+ }
15
+
16
+ export interface ExpressionNode {
17
+ kind: "expression";
18
+ expression: Expr;
19
+ range: SourceRange;
20
+ }
21
+
22
+ /**
23
+ * Expression AST. Discriminated union over `kind`. Every node carries a
24
+ * source range for error reporting.
25
+ */
26
+ export type Expr =
27
+ | LiteralExpr
28
+ | IdentifierExpr
29
+ | MemberExpr
30
+ | IndexExpr
31
+ | TernaryExpr
32
+ | BinaryExpr
33
+ | UnaryExpr
34
+ | PipeExpr;
35
+
36
+ export interface LiteralExpr {
37
+ kind: "literal";
38
+ value: string | number | boolean | null;
39
+ range: SourceRange;
40
+ }
41
+
42
+ export interface IdentifierExpr {
43
+ kind: "identifier";
44
+ name: string;
45
+ range: SourceRange;
46
+ }
47
+
48
+ export interface MemberExpr {
49
+ kind: "member";
50
+ object: Expr;
51
+ property: string;
52
+ range: SourceRange;
53
+ }
54
+
55
+ export interface IndexExpr {
56
+ kind: "index";
57
+ object: Expr;
58
+ index: Expr;
59
+ range: SourceRange;
60
+ }
61
+
62
+ export interface TernaryExpr {
63
+ kind: "ternary";
64
+ condition: Expr;
65
+ consequent: Expr;
66
+ alternate: Expr;
67
+ range: SourceRange;
68
+ }
69
+
70
+ export type BinaryOp =
71
+ | "=="
72
+ | "!="
73
+ | "<"
74
+ | ">"
75
+ | "<="
76
+ | ">="
77
+ | "&&"
78
+ | "||";
79
+
80
+ export interface BinaryExpr {
81
+ kind: "binary";
82
+ op: BinaryOp;
83
+ left: Expr;
84
+ right: Expr;
85
+ range: SourceRange;
86
+ }
87
+
88
+ export interface UnaryExpr {
89
+ kind: "unary";
90
+ op: "!";
91
+ operand: Expr;
92
+ range: SourceRange;
93
+ }
94
+
95
+ export interface PipeExpr {
96
+ kind: "pipe";
97
+ value: Expr;
98
+ filter: string;
99
+ args: Expr[];
100
+ range: SourceRange;
101
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,74 @@
1
+ import type { SourcePosition, SourceRange } from "./types";
2
+
3
+ /**
4
+ * Base error for all template-engine failures. Always carries a source
5
+ * range so editors can render an inline marker.
6
+ */
7
+ export class TemplateError extends Error {
8
+ readonly range: SourceRange;
9
+ readonly source: string;
10
+
11
+ constructor(args: {
12
+ message: string;
13
+ source: string;
14
+ range: SourceRange;
15
+ }) {
16
+ super(args.message);
17
+ this.name = "TemplateError";
18
+ this.range = args.range;
19
+ this.source = args.source;
20
+ }
21
+ }
22
+
23
+ export class TemplateParseError extends TemplateError {
24
+ constructor(args: {
25
+ message: string;
26
+ source: string;
27
+ range: SourceRange;
28
+ }) {
29
+ super(args);
30
+ this.name = "TemplateParseError";
31
+ }
32
+ }
33
+
34
+ export class TemplateRenderError extends TemplateError {
35
+ /** Path of access that failed, when the failure was a missing reference. */
36
+ readonly path?: string;
37
+
38
+ constructor(args: {
39
+ message: string;
40
+ source: string;
41
+ range: SourceRange;
42
+ path?: string;
43
+ }) {
44
+ super(args);
45
+ this.name = "TemplateRenderError";
46
+ this.path = args.path;
47
+ }
48
+ }
49
+
50
+ export class UnknownFilterError extends TemplateRenderError {
51
+ readonly filterName: string;
52
+
53
+ constructor(args: {
54
+ filterName: string;
55
+ source: string;
56
+ range: SourceRange;
57
+ }) {
58
+ super({
59
+ message: `Unknown filter "${args.filterName}"`,
60
+ source: args.source,
61
+ range: args.range,
62
+ });
63
+ this.name = "UnknownFilterError";
64
+ this.filterName = args.filterName;
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Construct a single-point range — useful when the parser knows where
70
+ * something went wrong but not where the offending construct ended.
71
+ */
72
+ export function pointRange(pos: SourcePosition): SourceRange {
73
+ return { start: pos, end: pos };
74
+ }