@telorun/templating 0.12.0 → 0.14.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/README.md +2 -2
- package/dist/builtins.d.ts +11 -0
- package/dist/builtins.d.ts.map +1 -1
- package/dist/builtins.js +16 -0
- package/dist/cel/analyze.d.ts +2 -1
- package/dist/cel/analyze.d.ts.map +1 -1
- package/dist/cel/analyze.js +4 -5
- package/dist/cel/catalog.d.ts.map +1 -1
- package/dist/cel/catalog.js +27 -0
- package/dist/cel/diagnose.d.ts +50 -0
- package/dist/cel/diagnose.d.ts.map +1 -0
- package/dist/cel/diagnose.js +223 -0
- package/dist/cel/environment.d.ts +7 -0
- package/dist/cel/environment.d.ts.map +1 -1
- package/dist/cel/environment.js +19 -5
- package/dist/cel/walk.d.ts +17 -1
- package/dist/cel/walk.d.ts.map +1 -1
- package/dist/cel/walk.js +21 -3
- package/dist/engine.d.ts +110 -3
- package/dist/engine.d.ts.map +1 -1
- package/dist/engines/cel.d.ts +13 -9
- package/dist/engines/cel.d.ts.map +1 -1
- package/dist/engines/cel.js +86 -28
- package/dist/engines/include.d.ts +28 -0
- package/dist/engines/include.d.ts.map +1 -0
- package/dist/engines/include.js +142 -0
- package/dist/engines/literal.js +1 -1
- package/dist/engines/ref.js +1 -1
- package/dist/engines/sql.d.ts.map +1 -1
- package/dist/engines/sql.js +32 -5
- package/dist/index.d.ts +8 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -4
- package/dist/manifest-schemas.d.ts +0 -16
- package/dist/manifest-schemas.d.ts.map +1 -1
- package/dist/manifest-schemas.js +0 -95
- package/dist/sentinel.d.ts +37 -0
- package/dist/sentinel.d.ts.map +1 -1
- package/dist/sentinel.js +54 -0
- package/package.json +2 -2
- package/src/builtins.ts +17 -0
- package/src/cel/analyze.ts +4 -7
- package/src/cel/catalog.ts +27 -0
- package/src/cel/diagnose.ts +293 -0
- package/src/cel/environment.ts +21 -5
- package/src/cel/walk.ts +37 -4
- package/src/engine.ts +116 -3
- package/src/engines/cel.ts +91 -31
- package/src/engines/include.ts +164 -0
- package/src/engines/literal.ts +1 -1
- package/src/engines/ref.ts +1 -1
- package/src/engines/sql.ts +41 -7
- package/src/index.ts +36 -5
- package/src/manifest-schemas.ts +0 -98
- package/src/sentinel.ts +62 -0
package/src/cel/walk.ts
CHANGED
|
@@ -1,6 +1,22 @@
|
|
|
1
1
|
import { isTaggedSentinel } from "../sentinel.js";
|
|
2
2
|
import { TEMPLATE_REGEX } from "./compile.js";
|
|
3
3
|
|
|
4
|
+
/** How an emitted expression sits in the scalar it came from. A repair can be
|
|
5
|
+
* applied by replacing the scalar only when the expression covers all of it;
|
|
6
|
+
* otherwise the literal text around it would be lost.
|
|
7
|
+
*
|
|
8
|
+
* `wrapper` is the delimiter text to restore around a corrected expression —
|
|
9
|
+
* empty for a tagged sentinel (whose scalar *is* the expression), `${{` / `}}`
|
|
10
|
+
* for the legacy interpolation form. Carrying it as data rather than letting
|
|
11
|
+
* each consumer re-derive it is what keeps `${{ … }}` a first-class fix site
|
|
12
|
+
* instead of an exclusion: the two surfaces differ only by these delimiters. */
|
|
13
|
+
export interface CelSurface {
|
|
14
|
+
readonly whole: boolean;
|
|
15
|
+
readonly wrapper?: { readonly prefix: string; readonly suffix: string };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const OPEN = "${{";
|
|
19
|
+
|
|
4
20
|
/** Walks `value` and emits each templated source segment with its dotted
|
|
5
21
|
* path (e.g. `routes[0].handler.body`) and the engine that owns it.
|
|
6
22
|
*
|
|
@@ -15,15 +31,32 @@ import { TEMPLATE_REGEX } from "./compile.js";
|
|
|
15
31
|
export function walkCelExpressions(
|
|
16
32
|
value: unknown,
|
|
17
33
|
path: string,
|
|
18
|
-
cb: (source: string, path: string, engineName: string) => void,
|
|
34
|
+
cb: (source: string, path: string, engineName: string, surface: CelSurface) => void,
|
|
19
35
|
): void {
|
|
20
36
|
if (isTaggedSentinel(value)) {
|
|
21
|
-
cb(value.source, path, value.engine);
|
|
37
|
+
cb(value.source, path, value.engine, { whole: true });
|
|
22
38
|
return;
|
|
23
39
|
}
|
|
24
40
|
if (typeof value === "string") {
|
|
25
|
-
|
|
26
|
-
|
|
41
|
+
const matches = [...value.matchAll(TEMPLATE_REGEX)];
|
|
42
|
+
for (const m of matches) {
|
|
43
|
+
const expr = m[1]!.trim();
|
|
44
|
+
// A string that is nothing but one interpolation is as whole as a
|
|
45
|
+
// tagged scalar — the delimiters are the only difference, and they are
|
|
46
|
+
// handed back as the wrapper.
|
|
47
|
+
const only = matches.length === 1 && m.index === 0 && m[0]!.length === value.length;
|
|
48
|
+
const lead = /^\s*/.exec(m[0]!.slice(OPEN.length))![0]!.length;
|
|
49
|
+
cb(expr, path, "cel", {
|
|
50
|
+
whole: only,
|
|
51
|
+
...(only
|
|
52
|
+
? {
|
|
53
|
+
wrapper: {
|
|
54
|
+
prefix: value.slice(0, OPEN.length + lead),
|
|
55
|
+
suffix: value.slice(OPEN.length + lead + expr.length),
|
|
56
|
+
},
|
|
57
|
+
}
|
|
58
|
+
: {}),
|
|
59
|
+
});
|
|
27
60
|
}
|
|
28
61
|
return;
|
|
29
62
|
}
|
package/src/engine.ts
CHANGED
|
@@ -12,18 +12,95 @@ export interface CompileEnv {
|
|
|
12
12
|
* the path-specific effective context (kernel globals merged in, x-telo-context
|
|
13
13
|
* applied) and hands the engine a single closed schema. The engine validates
|
|
14
14
|
* member-access chains against it. `null` means "open context" — no chain
|
|
15
|
-
* validation possible.
|
|
15
|
+
* validation possible.
|
|
16
|
+
*
|
|
17
|
+
* `celEnv` is the environment **typed for this path**, not the bare base one:
|
|
18
|
+
* the engine type-checks against it, so the caller must not check the same
|
|
19
|
+
* expression again against a different environment. One expression, one
|
|
20
|
+
* verdict. */
|
|
16
21
|
export interface AnalyzeEnv {
|
|
17
22
|
readonly celEnv: Environment;
|
|
18
23
|
readonly contextSchema: Record<string, unknown> | null;
|
|
19
24
|
}
|
|
20
25
|
|
|
26
|
+
/** A mechanically applicable repair for a diagnostic. `replacement` is the
|
|
27
|
+
* **whole analyzed source**, corrected — never a fragment — so a consumer can
|
|
28
|
+
* apply it by replacing the scalar node without knowing anything about the
|
|
29
|
+
* language inside it.
|
|
30
|
+
*
|
|
31
|
+
* There is deliberately no sub-range narrowing "what changed". Carrying one
|
|
32
|
+
* beside a whole-value replacement offers two readings of the same field, and
|
|
33
|
+
* the minimal-edit reading — splice `replacement` at `range` — produces
|
|
34
|
+
* garbage, since the two measure different strings. No consumer needed it, so
|
|
35
|
+
* the ambiguity bought nothing.
|
|
36
|
+
*
|
|
37
|
+
* Producers emit a fix only when the repair is decidable. A fix that might not
|
|
38
|
+
* compile is worse than none: the field exists so an IDE can apply it without
|
|
39
|
+
* asking, and an agent can take it without re-deriving it from prose. */
|
|
40
|
+
export interface DiagnosticFix {
|
|
41
|
+
readonly replacement: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
21
44
|
/** A single static-analysis finding produced by an engine. Stable codes match
|
|
22
45
|
* the analyzer's existing diagnostic codes so downstream filtering keeps
|
|
23
46
|
* working unchanged across the engine boundary. */
|
|
24
47
|
export interface EngineDiagnostic {
|
|
25
48
|
readonly message: string;
|
|
26
49
|
readonly code?: string;
|
|
50
|
+
readonly fix?: DiagnosticFix;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** One function call an engine found in the source it analyzed. Reported
|
|
54
|
+
* regardless of whether the call is valid: consumers apply policy the engine
|
|
55
|
+
* cannot know (an `x-telo-eval: compile` field rejecting a non-deterministic
|
|
56
|
+
* call), and policy that depends on manifest context does not belong in a
|
|
57
|
+
* templating engine. */
|
|
58
|
+
export interface CallSite {
|
|
59
|
+
readonly name: string;
|
|
60
|
+
/** How it was written — `f(x)` vs `x.f()`. */
|
|
61
|
+
readonly form: "global" | "receiver";
|
|
62
|
+
/** Argument count as written; excludes the receiver. */
|
|
63
|
+
readonly arity: number;
|
|
64
|
+
/** Offsets of the whole call within the analyzed source. */
|
|
65
|
+
readonly start: number;
|
|
66
|
+
readonly end: number;
|
|
67
|
+
/** Whether the resolved function re-evaluates per call. `undefined` when the
|
|
68
|
+
* name resolves to nothing, or to a function carrying no determinism
|
|
69
|
+
* metadata — absent is not "deterministic". */
|
|
70
|
+
readonly deterministic?: boolean;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** What one `analyze` call establishes about one source. Everything derivable
|
|
74
|
+
* from the expression alone is derived here, once; everything that needs
|
|
75
|
+
* manifest context (the field's declared type, its eval mode, which verdict
|
|
76
|
+
* outranks which) is left to the caller, which is the only side that has it. */
|
|
77
|
+
export interface AnalyzeResult {
|
|
78
|
+
readonly diagnostics: readonly EngineDiagnostic[];
|
|
79
|
+
/** Type the engine's checker resolved, when it type-checks and succeeded. */
|
|
80
|
+
readonly type?: string;
|
|
81
|
+
/** Every function call in the source, in source order. */
|
|
82
|
+
readonly calls: readonly CallSite[];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** One module-relative file a tagged node embeds, reported by the engine that
|
|
86
|
+
* owns the tag.
|
|
87
|
+
*
|
|
88
|
+
* `path` is relative to the module root — the directory holding `telo.yaml` —
|
|
89
|
+
* never to the file the tag was written in. That is the rule every other file
|
|
90
|
+
* reference in a manifest already follows (a controller's `path=` qualifier,
|
|
91
|
+
* `files:` / `assets:` patterns), and it is what makes a claim survive publish:
|
|
92
|
+
* publish deletes `include:` and inlines every partial as an extra document
|
|
93
|
+
* into the single published `telo.yaml`, so the declaring file does not exist
|
|
94
|
+
* in the artifact and a per-file-relative path would change meaning there.
|
|
95
|
+
*
|
|
96
|
+
* The path is ALL an engine reports. Which artifact layer the file belongs in
|
|
97
|
+
* is packaging's vocabulary, from a spec this package otherwise knows nothing
|
|
98
|
+
* about, and the analyzer already owns that assignment for controller
|
|
99
|
+
* candidates — so a new layer role stays a change to one package rather than
|
|
100
|
+
* two. An object rather than a bare string so a future hint (eager/lazy, say)
|
|
101
|
+
* costs no consumer a signature change. */
|
|
102
|
+
export interface EngineFileClaim {
|
|
103
|
+
readonly path: string;
|
|
27
104
|
}
|
|
28
105
|
|
|
29
106
|
/** Per-property templating engine. Matches a YAML tag (`!<name>`); the kernel
|
|
@@ -48,6 +125,42 @@ export interface TemplatingEngine {
|
|
|
48
125
|
compile(source: string, env: CompileEnv): CompiledValue | unknown;
|
|
49
126
|
|
|
50
127
|
/** Static analysis hook. Engines that can't statically check (e.g. `literal`)
|
|
51
|
-
* return
|
|
52
|
-
|
|
128
|
+
* return an empty result. The walker accumulates diagnostics across all
|
|
129
|
+
* values and applies its own policy to `calls` / `type`. */
|
|
130
|
+
analyze(source: string, env: AnalyzeEnv): AnalyzeResult;
|
|
131
|
+
|
|
132
|
+
/** Module-relative files this tagged node embeds, if any.
|
|
133
|
+
*
|
|
134
|
+
* The single seam through which payload membership is discovered: publish
|
|
135
|
+
* asks the registry what each tag claims rather than recognising tags by
|
|
136
|
+
* name, so a future tag that embeds files is a one-file change and no
|
|
137
|
+
* consumer downstream grows a second vocabulary for reading a manifest.
|
|
138
|
+
* This is the `ref-slot.ts` / `zone-slot.ts` precedent applied to tags.
|
|
139
|
+
*
|
|
140
|
+
* Optional, and absent on every engine that embeds nothing (`cel`, `ref`,
|
|
141
|
+
* `literal`, `sql`). Pure string work over the source — it must never read
|
|
142
|
+
* the filesystem, because the analyzer that calls it runs in the browser.
|
|
143
|
+
* A source the engine considers malformed claims nothing; `analyze` is what
|
|
144
|
+
* reports why. */
|
|
145
|
+
fileClaims?(source: string): readonly EngineFileClaim[];
|
|
146
|
+
|
|
147
|
+
/** The type this tag ALWAYS produces, as a JSON Schema fragment.
|
|
148
|
+
*
|
|
149
|
+
* Declared by the engine, never recognised by a consumer — the `fileClaims`
|
|
150
|
+
* precedent applied to the one fact it left behind. Before this, the analyzer
|
|
151
|
+
* hardcoded two tag names to hand an `!include-bytes` a byte placeholder and
|
|
152
|
+
* an `!include-text` a string one; the only place a tag's produced type was
|
|
153
|
+
* written down was in its consumer, so a future tag producing bytes had to be
|
|
154
|
+
* added to a set rather than declaring it.
|
|
155
|
+
*
|
|
156
|
+
* Absent for an engine whose produced type is a function of the SLOT rather
|
|
157
|
+
* than of the tag — `!cel`, whose type is only derivable from the expression,
|
|
158
|
+
* and `!ref`, which is an identity marker. Their values keep taking a
|
|
159
|
+
* slot-shaped placeholder.
|
|
160
|
+
*
|
|
161
|
+
* What falls out is the property this preserves exactly: because an embed's
|
|
162
|
+
* type is a constant of the tag, a byte embed at a string slot and text at a
|
|
163
|
+
* byte slot both fail statically, through the ordinary schema check and with
|
|
164
|
+
* no diagnostic code of their own. */
|
|
165
|
+
producedType?(): Record<string, unknown>;
|
|
53
166
|
}
|
package/src/engines/cel.ts
CHANGED
|
@@ -4,54 +4,114 @@ import {
|
|
|
4
4
|
validateChainAgainstSchema,
|
|
5
5
|
} from "../cel/analyze.js";
|
|
6
6
|
import { compileExpression } from "../cel/compile.js";
|
|
7
|
-
import
|
|
7
|
+
import { auditCalls, explainUnresolved } from "../cel/diagnose.js";
|
|
8
|
+
import type { AnalyzeEnv, AnalyzeResult, EngineDiagnostic, TemplatingEngine } from "../engine.js";
|
|
8
9
|
|
|
9
10
|
/** Statically analyze one CEL expression against the effective context schema:
|
|
10
|
-
* parse →
|
|
11
|
-
* access. Single source of truth shared by the `!cel` engine
|
|
12
|
-
* and the `!sql` engine (one per `${{ }}` interpolation), so
|
|
13
|
-
* can't drift between them.
|
|
14
|
-
|
|
11
|
+
* parse → classify every call → type-check → validate member-access chains →
|
|
12
|
+
* flag nullable access. Single source of truth shared by the `!cel` engine
|
|
13
|
+
* (one expression) and the `!sql` engine (one per `${{ }}` interpolation), so
|
|
14
|
+
* diagnostic wording can't drift between them.
|
|
15
|
+
*
|
|
16
|
+
* The type-check lives here, not in the analyzer, so one expression produces
|
|
17
|
+
* one verdict against one environment. Splitting them let an opaque
|
|
18
|
+
* "no matching overload" survive next to the diagnostic that actually
|
|
19
|
+
* explained it, and left `${{ }}` interpolations chain-validated but never
|
|
20
|
+
* type-checked at all. */
|
|
21
|
+
export function analyzeCelExpression(source: string, env: AnalyzeEnv): AnalyzeResult {
|
|
15
22
|
const out: EngineDiagnostic[] = [];
|
|
16
23
|
|
|
17
24
|
let parsed: ReturnType<typeof env.celEnv.parse>;
|
|
18
25
|
try {
|
|
19
26
|
parsed = env.celEnv.parse(source);
|
|
20
27
|
} catch (e) {
|
|
21
|
-
|
|
22
|
-
code: "CEL_SYNTAX_ERROR",
|
|
23
|
-
|
|
24
|
-
}
|
|
25
|
-
return out;
|
|
28
|
+
return {
|
|
29
|
+
diagnostics: [{ code: "CEL_SYNTAX_ERROR", message: e instanceof Error ? e.message : String(e) }],
|
|
30
|
+
calls: [],
|
|
31
|
+
};
|
|
26
32
|
}
|
|
27
33
|
|
|
28
|
-
|
|
34
|
+
const audit = auditCalls(source, parsed.ast, env.celEnv);
|
|
29
35
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
36
|
+
let type: string | undefined;
|
|
37
|
+
let checkError: string | undefined;
|
|
38
|
+
try {
|
|
39
|
+
const result = env.celEnv.check(source);
|
|
40
|
+
if (result.valid) type = result.type;
|
|
41
|
+
else if (result.error) {
|
|
42
|
+
checkError = String((result.error as { message?: string }).message ?? result.error)
|
|
43
|
+
.split("\n")[0]!
|
|
44
|
+
.trim();
|
|
45
|
+
}
|
|
46
|
+
} catch (e) {
|
|
47
|
+
// The checker is now the ONLY type verdict for every CEL expression, so a
|
|
48
|
+
// crash here silently retires static typing for that expression. Report it
|
|
49
|
+
// instead: degrading is acceptable, degrading invisibly is not.
|
|
50
|
+
return {
|
|
51
|
+
diagnostics: [
|
|
52
|
+
{
|
|
53
|
+
code: "CEL_TYPE_ERROR",
|
|
54
|
+
message: `the CEL type-checker failed on this expression: ${
|
|
55
|
+
e instanceof Error ? e.message : String(e)
|
|
56
|
+
}`,
|
|
57
|
+
},
|
|
58
|
+
],
|
|
59
|
+
calls: audit.calls,
|
|
60
|
+
};
|
|
34
61
|
}
|
|
35
62
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
)
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
63
|
+
// The audit only ever EXPLAINS a rejection — it never overrules acceptance.
|
|
64
|
+
// Its classification is decided from the registry, so a call cel-js accepts
|
|
65
|
+
// but the registry cannot account for (a macro the parser expands and the
|
|
66
|
+
// registry never sees, which a cel-js upgrade can introduce at any time) must
|
|
67
|
+
// not become a hard error on valid CEL. Reporting nothing where cel-js is
|
|
68
|
+
// happy makes an unknown future macro a silent no-op rather than a manifest
|
|
69
|
+
// this analyzer refuses and the kernel would run fine.
|
|
70
|
+
if (checkError !== undefined) {
|
|
71
|
+
// `check()` stops at its first problem; the audit enumerates every bad call,
|
|
72
|
+
// which is the whole reason it exists as more than a message rewriter.
|
|
73
|
+
out.push(...audit.diagnostics);
|
|
74
|
+
if (audit.diagnostics.length === 0) {
|
|
75
|
+
out.push({
|
|
76
|
+
code: "CEL_TYPE_ERROR",
|
|
77
|
+
message: checkError + explainUnresolved(audit.unresolved, env.celEnv) + DYN_HINT(checkError),
|
|
78
|
+
});
|
|
79
|
+
}
|
|
47
80
|
}
|
|
48
|
-
|
|
81
|
+
|
|
82
|
+
if (env.contextSchema) {
|
|
83
|
+
const contextSchema = env.contextSchema as Record<string, any>;
|
|
84
|
+
for (const chain of extractAccessChains(parsed.ast)) {
|
|
85
|
+
const err = validateChainAgainstSchema(chain, contextSchema);
|
|
86
|
+
if (err) out.push({ code: "CEL_UNKNOWN_FIELD", message: err });
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
for (const issue of findNullableAccessIssues(parsed.ast, contextSchema)) {
|
|
90
|
+
// Index access (member "[index]") attaches without a dot; a named field
|
|
91
|
+
// attaches with one — so the suggested CEL stays valid either way.
|
|
92
|
+
const access = issue.member === "[index]" ? issue.member : `.${issue.member}`;
|
|
93
|
+
out.push({
|
|
94
|
+
code: "CEL_NULLABLE_ACCESS",
|
|
95
|
+
message: `'${issue.path}' may be null — guard it (e.g. '${issue.path} != null && …' or '${issue.path} == null ? … : ${issue.path}${access}') before accessing '${access}'`,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return { diagnostics: out, calls: audit.calls, ...(type === undefined ? {} : { type }) };
|
|
49
101
|
}
|
|
50
102
|
|
|
103
|
+
/** `dyn` in a checker message means an operand whose type is unknown here —
|
|
104
|
+
* almost always a step result whose invoked resource declares no
|
|
105
|
+
* `outputType:`. Without this, the reader takes `dyn` for a cast problem. */
|
|
106
|
+
const DYN_HINT = (message: string): string =>
|
|
107
|
+
// Word-bounded: "dynamic" appears in unrelated checker messages, and the
|
|
108
|
+
// hint is wrong for those.
|
|
109
|
+
/\bdyn\b/.test(message)
|
|
110
|
+
? " (`dyn` is a value with no static type here — declare `outputType:` on the resource producing it, or convert at the call site.)"
|
|
111
|
+
: "";
|
|
112
|
+
|
|
51
113
|
/** The `!cel` engine. Treats the entire tagged scalar as a single CEL
|
|
52
|
-
* expression — no `${{ }}` wrapping.
|
|
53
|
-
* as the untagged path: parse → extract member-access chains → validate each
|
|
54
|
-
* chain against the effective context schema. */
|
|
114
|
+
* expression — no `${{ }}` wrapping. */
|
|
55
115
|
export const celEngine: TemplatingEngine = {
|
|
56
116
|
name: "cel",
|
|
57
117
|
language: "cel",
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import type { EngineFileClaim, EngineDiagnostic, TemplatingEngine } from "../engine.js";
|
|
2
|
+
import { INCLUDE_BYTES_ENGINE, INCLUDE_TEXT_ENGINE, makeTaggedSentinel } from "../sentinel.js";
|
|
3
|
+
|
|
4
|
+
/** Characters that would make a path a pattern rather than a name. Globs are
|
|
5
|
+
* excluded deliberately: a claim has to name one file for publish to place it
|
|
6
|
+
* in a layer, and a pattern that matches nothing on the publishing machine
|
|
7
|
+
* would ship an artifact missing a file the manifest reads. */
|
|
8
|
+
const GLOB_CHARS = /[*?[\]{}]/;
|
|
9
|
+
|
|
10
|
+
/** `scheme:` prefix — a URL, or a Windows drive letter. Either way not a
|
|
11
|
+
* module-relative path. */
|
|
12
|
+
const URI_SCHEME = /^[a-z][a-z0-9+.-]*:/i;
|
|
13
|
+
|
|
14
|
+
export interface NormalizedIncludePath {
|
|
15
|
+
/** Module-root-relative path with `./` and `.` segments folded out, `/`
|
|
16
|
+
* separated. Absent when the source is not a usable path. */
|
|
17
|
+
readonly path?: string;
|
|
18
|
+
readonly diagnostic?: EngineDiagnostic;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Normalize an `!include-*` source to a module-root-relative path, or explain
|
|
23
|
+
* why it is not one.
|
|
24
|
+
*
|
|
25
|
+
* Pure string work, deliberately: this runs in the analyzer, which must load in
|
|
26
|
+
* a browser, so confinement is decided from the written path alone and never by
|
|
27
|
+
* asking a filesystem where it lands. That is possible because the path is
|
|
28
|
+
* root-relative by definition — the module root is the one directory every path
|
|
29
|
+
* is measured from, so `..` below depth zero is an escape regardless of where
|
|
30
|
+
* the module happens to sit on disk.
|
|
31
|
+
*/
|
|
32
|
+
export function normalizeIncludePath(source: string): NormalizedIncludePath {
|
|
33
|
+
const raw = source.trim();
|
|
34
|
+
if (raw === "") {
|
|
35
|
+
return {
|
|
36
|
+
diagnostic: {
|
|
37
|
+
code: "INCLUDE_PATH_INVALID",
|
|
38
|
+
message: "the path is empty — name a file relative to the module root.",
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
if (URI_SCHEME.test(raw)) {
|
|
43
|
+
return {
|
|
44
|
+
diagnostic: {
|
|
45
|
+
code: "INCLUDE_PATH_INVALID",
|
|
46
|
+
message:
|
|
47
|
+
`'${raw}' names a location outside the module. An include path is a file that ships ` +
|
|
48
|
+
`inside the module artifact, written relative to the module root. To read a file at ` +
|
|
49
|
+
`runtime from somewhere else, use Fs.File.`,
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
if (GLOB_CHARS.test(raw)) {
|
|
54
|
+
return {
|
|
55
|
+
diagnostic: {
|
|
56
|
+
code: "INCLUDE_PATH_INVALID",
|
|
57
|
+
message:
|
|
58
|
+
`'${raw}' looks like a pattern. An include path names exactly one file, because ` +
|
|
59
|
+
`publish places each claimed file into a layer by name.`,
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
if (raw.startsWith("/") || raw.startsWith("\\")) {
|
|
64
|
+
return {
|
|
65
|
+
diagnostic: {
|
|
66
|
+
code: "INCLUDE_PATH_ESCAPES_MODULE",
|
|
67
|
+
message:
|
|
68
|
+
`'${raw}' is an absolute path. An include path is written relative to the module ` +
|
|
69
|
+
`root, so the same manifest resolves identically from a checkout and from a ` +
|
|
70
|
+
`published artifact.`,
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const out: string[] = [];
|
|
76
|
+
for (const segment of raw.split(/[/\\]+/)) {
|
|
77
|
+
if (segment === "" || segment === ".") continue;
|
|
78
|
+
if (segment !== "..") {
|
|
79
|
+
out.push(segment);
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
// Depth zero is the module root. Popping past it would read a file the
|
|
83
|
+
// artifact cannot contain, so it is an escape rather than a path to
|
|
84
|
+
// normalize — and catching it here is what keeps the check static.
|
|
85
|
+
if (out.length === 0) {
|
|
86
|
+
return {
|
|
87
|
+
diagnostic: {
|
|
88
|
+
code: "INCLUDE_PATH_ESCAPES_MODULE",
|
|
89
|
+
message:
|
|
90
|
+
`'${raw}' points above the module root. An include path may only name a file ` +
|
|
91
|
+
`inside the module, since that is the only thing its artifact can carry.`,
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
out.pop();
|
|
96
|
+
}
|
|
97
|
+
if (out.length === 0) {
|
|
98
|
+
return {
|
|
99
|
+
diagnostic: {
|
|
100
|
+
code: "INCLUDE_PATH_INVALID",
|
|
101
|
+
message: `'${raw}' resolves to the module root, not to a file.`,
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
return { path: out.join("/") };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The `!include-text` / `!include-bytes` engines — a file that ships inside the
|
|
110
|
+
* module artifact, embedded as a manifest value.
|
|
111
|
+
*
|
|
112
|
+
* `compile` returns the sentinel unchanged rather than a value. Two things
|
|
113
|
+
* follow, and both are the point. The read is deferred to resource creation, so
|
|
114
|
+
* loading a manifest does not pull payload layers — the artifact spec makes
|
|
115
|
+
* `telo.yaml` its own layer precisely so that reading a manifest cannot drag the
|
|
116
|
+
* whole artifact in, and an app loads every imported library's manifest. And
|
|
117
|
+
* because the marker survives precompile the way a `!ref` does, the analyzer can
|
|
118
|
+
* type the slot without opening the file, which is what keeps it browser-safe.
|
|
119
|
+
* Unlike `!ref`, no special case is needed in `precompileDoc`: an engine whose
|
|
120
|
+
* `compile` is identity on its own marker passes through the generic path.
|
|
121
|
+
*
|
|
122
|
+
* `analyze` reports why a path is unusable; `fileClaims` reports the path itself
|
|
123
|
+
* so publish can place the file in a layer without recognising the tag by name.
|
|
124
|
+
*/
|
|
125
|
+
function includeEngine(name: string, produced: Record<string, unknown>): TemplatingEngine {
|
|
126
|
+
return {
|
|
127
|
+
name,
|
|
128
|
+
|
|
129
|
+
compile(source) {
|
|
130
|
+
return makeTaggedSentinel(name, source);
|
|
131
|
+
},
|
|
132
|
+
|
|
133
|
+
producedType() {
|
|
134
|
+
return produced;
|
|
135
|
+
},
|
|
136
|
+
|
|
137
|
+
analyze(source) {
|
|
138
|
+
const { diagnostic } = normalizeIncludePath(source);
|
|
139
|
+
return { diagnostics: diagnostic ? [diagnostic] : [], calls: [] };
|
|
140
|
+
},
|
|
141
|
+
|
|
142
|
+
fileClaims(source): readonly EngineFileClaim[] {
|
|
143
|
+
const { path } = normalizeIncludePath(source);
|
|
144
|
+
// A malformed path claims nothing. `analyze` is what says why, so
|
|
145
|
+
// claiming a half-understood path here would produce a second, worse
|
|
146
|
+
// report from publish about the same mistake.
|
|
147
|
+
return path ? [{ path }] : [];
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Embeds a file's contents as a UTF-8 string. */
|
|
153
|
+
export const includeTextEngine: TemplatingEngine = includeEngine(INCLUDE_TEXT_ENGINE, {
|
|
154
|
+
type: "string",
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
/** Embeds a file's contents as raw bytes — the shape every `Telo.Bytes` slot
|
|
158
|
+
* accepts. The name is written as a literal rather than imported from the SDK:
|
|
159
|
+
* templating is the lower package, and a produced type is a schema fragment,
|
|
160
|
+
* which is data. What checks it is the registry the analyzer and the kernel both
|
|
161
|
+
* read. */
|
|
162
|
+
export const includeBytesEngine: TemplatingEngine = includeEngine(INCLUDE_BYTES_ENGINE, {
|
|
163
|
+
"x-telo-type": "Telo.Bytes",
|
|
164
|
+
});
|
package/src/engines/literal.ts
CHANGED
package/src/engines/ref.ts
CHANGED
package/src/engines/sql.ts
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import { isParameterizedSql, type CompiledValue, type ParameterizedSql } from "@telorun/sdk";
|
|
2
2
|
import { analyzeCelExpression } from "./cel.js";
|
|
3
3
|
import { compileString, toParameterized, TEMPLATE_REGEX } from "../cel/compile.js";
|
|
4
|
-
import type {
|
|
4
|
+
import type {
|
|
5
|
+
CallSite,
|
|
6
|
+
DiagnosticFix,
|
|
7
|
+
EngineDiagnostic,
|
|
8
|
+
TemplatingEngine,
|
|
9
|
+
} from "../engine.js";
|
|
5
10
|
|
|
6
11
|
export { isParameterizedSql, type ParameterizedSql };
|
|
7
12
|
|
|
@@ -30,15 +35,44 @@ export const sqlEngine: TemplatingEngine = {
|
|
|
30
35
|
analyze(source, env) {
|
|
31
36
|
// Each `${{ }}` interpolation is its own CEL expression; reuse the shared
|
|
32
37
|
// per-expression analyzer so diagnostics match the `!cel` engine exactly.
|
|
33
|
-
|
|
38
|
+
// Each interpolation's offset is kept so a fix computed against the bare
|
|
39
|
+
// expression is re-anchored to the whole SQL scalar — the only thing that
|
|
40
|
+
// ever made `!sql` fix-less was dropping it here.
|
|
41
|
+
const diagnostics: EngineDiagnostic[] = [];
|
|
42
|
+
const calls: CallSite[] = [];
|
|
43
|
+
for (const { expr, start } of expressionsOf(source)) {
|
|
44
|
+
const result = analyzeCelExpression(expr, env);
|
|
45
|
+
for (const d of result.diagnostics) {
|
|
46
|
+
diagnostics.push(d.fix ? { ...d, fix: reanchor(source, expr, start, d.fix) } : d);
|
|
47
|
+
}
|
|
48
|
+
for (const c of result.calls) {
|
|
49
|
+
calls.push({ ...c, start: start + c.start, end: start + c.end });
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
// No `type`: a SQL template is a string built from many expressions, so
|
|
53
|
+
// there is no single checked type to report.
|
|
54
|
+
return { diagnostics, calls };
|
|
34
55
|
},
|
|
35
56
|
};
|
|
36
57
|
|
|
37
|
-
/**
|
|
38
|
-
function
|
|
39
|
-
|
|
58
|
+
/** Re-anchor a fix computed against one interpolation onto the full source. */
|
|
59
|
+
function reanchor(source: string, expr: string, start: number, fix: DiagnosticFix): DiagnosticFix {
|
|
60
|
+
return {
|
|
61
|
+
replacement: source.slice(0, start) + fix.replacement + source.slice(start + expr.length),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Each `${{ expr }}` body in a `!sql` template, with the body's offset in the
|
|
66
|
+
* source. The offset is derived from the opening delimiter and the leading
|
|
67
|
+
* whitespace the capture group already trims, never by searching for the
|
|
68
|
+
* body text — which a body appearing twice would defeat. */
|
|
69
|
+
function expressionsOf(source: string): { expr: string; start: number }[] {
|
|
70
|
+
const out: { expr: string; start: number }[] = [];
|
|
40
71
|
for (const m of source.matchAll(TEMPLATE_REGEX)) {
|
|
41
|
-
|
|
72
|
+
const lead = /^\s*/.exec(m[0]!.slice(OPEN.length))![0]!.length;
|
|
73
|
+
out.push({ expr: m[1]!, start: m.index! + OPEN.length + lead });
|
|
42
74
|
}
|
|
43
|
-
return
|
|
75
|
+
return out;
|
|
44
76
|
}
|
|
77
|
+
|
|
78
|
+
const OPEN = "${{";
|
package/src/index.ts
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
export {
|
|
1
|
+
export {
|
|
2
|
+
buildCelEnvironment,
|
|
3
|
+
celBuiltinFunctions,
|
|
4
|
+
deriveSignatures,
|
|
5
|
+
type CelHandlers,
|
|
6
|
+
} from "./cel/environment.js";
|
|
2
7
|
export {
|
|
3
8
|
celFunctionCatalog,
|
|
4
9
|
CEL_FUNCTIONS,
|
|
@@ -19,27 +24,53 @@ export {
|
|
|
19
24
|
INDEX_SEGMENT,
|
|
20
25
|
validateChainAgainstSchema,
|
|
21
26
|
} from "./cel/analyze.js";
|
|
22
|
-
export {
|
|
27
|
+
export { auditCalls, explainUnresolved, functionIndex, type CallAudit } from "./cel/diagnose.js";
|
|
28
|
+
export { walkCelExpressions, type CelSurface } from "./cel/walk.js";
|
|
23
29
|
|
|
24
30
|
export { celEngine } from "./engines/cel.js";
|
|
31
|
+
export {
|
|
32
|
+
includeBytesEngine,
|
|
33
|
+
includeTextEngine,
|
|
34
|
+
normalizeIncludePath,
|
|
35
|
+
type NormalizedIncludePath,
|
|
36
|
+
} from "./engines/include.js";
|
|
25
37
|
export { literalEngine } from "./engines/literal.js";
|
|
26
38
|
export { refEngine } from "./engines/ref.js";
|
|
27
39
|
export { sqlEngine, isParameterizedSql, type ParameterizedSql } from "./engines/sql.js";
|
|
28
40
|
|
|
29
41
|
export { TemplatingEngineRegistry } from "./registry.js";
|
|
30
|
-
export {
|
|
42
|
+
export {
|
|
43
|
+
builtinEngines,
|
|
44
|
+
createDefaultRegistry,
|
|
45
|
+
defaultRegistry,
|
|
46
|
+
producedTypeOf,
|
|
47
|
+
} from "./builtins.js";
|
|
31
48
|
export type {
|
|
32
49
|
AnalyzeEnv,
|
|
50
|
+
AnalyzeResult,
|
|
51
|
+
CallSite,
|
|
33
52
|
CompileEnv,
|
|
53
|
+
DiagnosticFix,
|
|
34
54
|
EngineDiagnostic,
|
|
55
|
+
EngineFileClaim,
|
|
35
56
|
TemplatingEngine,
|
|
36
57
|
} from "./engine.js";
|
|
37
58
|
|
|
38
|
-
export {
|
|
59
|
+
export {
|
|
60
|
+
CEL_ENGINE,
|
|
61
|
+
INCLUDE_BYTES_ENGINE,
|
|
62
|
+
INCLUDE_ENGINE_NAMES,
|
|
63
|
+
INCLUDE_TEXT_ENGINE,
|
|
64
|
+
isIncludeSentinel,
|
|
65
|
+
isRefSentinel,
|
|
66
|
+
isTaggedSentinel,
|
|
67
|
+
makeTaggedSentinel,
|
|
68
|
+
plainChainOf,
|
|
69
|
+
type TaggedSentinel,
|
|
70
|
+
} from "./sentinel.js";
|
|
39
71
|
export { buildCustomTags, defaultCustomTags } from "./yaml-tags.js";
|
|
40
72
|
export {
|
|
41
73
|
MANIFEST_SCHEMA_URI,
|
|
42
74
|
ManifestRootSchema,
|
|
43
75
|
ResourceRefSchema,
|
|
44
|
-
normalizeRefSlots,
|
|
45
76
|
} from "./manifest-schemas.js";
|