@telorun/templating 0.11.1 → 0.13.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 (49) hide show
  1. package/README.md +5 -3
  2. package/dist/builtins.d.ts.map +1 -1
  3. package/dist/builtins.js +3 -0
  4. package/dist/cel/catalog.d.ts.map +1 -1
  5. package/dist/cel/catalog.js +27 -0
  6. package/dist/cel/diagnose.d.ts +50 -0
  7. package/dist/cel/diagnose.d.ts.map +1 -0
  8. package/dist/cel/diagnose.js +223 -0
  9. package/dist/cel/environment.d.ts +7 -0
  10. package/dist/cel/environment.d.ts.map +1 -1
  11. package/dist/cel/environment.js +19 -5
  12. package/dist/cel/walk.d.ts +17 -1
  13. package/dist/cel/walk.d.ts.map +1 -1
  14. package/dist/cel/walk.js +21 -3
  15. package/dist/engine.d.ts +91 -3
  16. package/dist/engine.d.ts.map +1 -1
  17. package/dist/engines/cel.d.ts +13 -9
  18. package/dist/engines/cel.d.ts.map +1 -1
  19. package/dist/engines/cel.js +86 -28
  20. package/dist/engines/include.d.ts +25 -0
  21. package/dist/engines/include.d.ts.map +1 -0
  22. package/dist/engines/include.js +132 -0
  23. package/dist/engines/literal.js +1 -1
  24. package/dist/engines/ref.js +1 -1
  25. package/dist/engines/sql.d.ts.map +1 -1
  26. package/dist/engines/sql.js +32 -5
  27. package/dist/index.d.ts +6 -4
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +4 -2
  30. package/dist/manifest-schemas.d.ts.map +1 -1
  31. package/dist/manifest-schemas.js +6 -1
  32. package/dist/sentinel.d.ts +19 -0
  33. package/dist/sentinel.d.ts.map +1 -1
  34. package/dist/sentinel.js +22 -0
  35. package/package.json +2 -2
  36. package/src/builtins.ts +3 -0
  37. package/src/cel/catalog.ts +27 -0
  38. package/src/cel/diagnose.ts +293 -0
  39. package/src/cel/environment.ts +21 -5
  40. package/src/cel/walk.ts +37 -4
  41. package/src/engine.ts +96 -3
  42. package/src/engines/cel.ts +91 -31
  43. package/src/engines/include.ts +153 -0
  44. package/src/engines/literal.ts +1 -1
  45. package/src/engines/ref.ts +1 -1
  46. package/src/engines/sql.ts +41 -7
  47. package/src/index.ts +28 -3
  48. package/src/manifest-schemas.ts +6 -1
  49. package/src/sentinel.ts +29 -0
@@ -0,0 +1,153 @@
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): TemplatingEngine {
126
+ return {
127
+ name,
128
+
129
+ compile(source) {
130
+ return makeTaggedSentinel(name, source);
131
+ },
132
+
133
+ analyze(source) {
134
+ const { diagnostic } = normalizeIncludePath(source);
135
+ return { diagnostics: diagnostic ? [diagnostic] : [], calls: [] };
136
+ },
137
+
138
+ fileClaims(source): readonly EngineFileClaim[] {
139
+ const { path } = normalizeIncludePath(source);
140
+ // A malformed path claims nothing. `analyze` is what says why, so
141
+ // claiming a half-understood path here would produce a second, worse
142
+ // report from publish about the same mistake.
143
+ return path ? [{ path }] : [];
144
+ },
145
+ };
146
+ }
147
+
148
+ /** Embeds a file's contents as a UTF-8 string. */
149
+ export const includeTextEngine: TemplatingEngine = includeEngine(INCLUDE_TEXT_ENGINE);
150
+
151
+ /** Embeds a file's contents as raw bytes — a `Uint8Array`, the shape every
152
+ * `x-telo-binary` slot accepts. */
153
+ export const includeBytesEngine: TemplatingEngine = includeEngine(INCLUDE_BYTES_ENGINE);
@@ -11,6 +11,6 @@ export const literalEngine: TemplatingEngine = {
11
11
  },
12
12
 
13
13
  analyze() {
14
- return [];
14
+ return { diagnostics: [], calls: [] };
15
15
  },
16
16
  };
@@ -17,6 +17,6 @@ export const refEngine: TemplatingEngine = {
17
17
  },
18
18
 
19
19
  analyze() {
20
- return [];
20
+ return { diagnostics: [], calls: [] };
21
21
  },
22
22
  };
@@ -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 { TemplatingEngine } from "../engine.js";
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
- return expressionsOf(source).flatMap((expr) => analyzeCelExpression(expr, env));
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
- /** Extract each `${{ expr }}` body from a `!sql` template source. */
38
- function expressionsOf(source: string): string[] {
39
- const exprs: string[] = [];
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
- exprs.push(m[1]!.trim());
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 exprs;
75
+ return out;
44
76
  }
77
+
78
+ const OPEN = "${{";
package/src/index.ts CHANGED
@@ -1,4 +1,9 @@
1
- export { buildCelEnvironment, type CelHandlers } from "./cel/environment.js";
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,9 +24,16 @@ export {
19
24
  INDEX_SEGMENT,
20
25
  validateChainAgainstSchema,
21
26
  } from "./cel/analyze.js";
22
- export { walkCelExpressions } from "./cel/walk.js";
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";
@@ -30,12 +42,25 @@ export { TemplatingEngineRegistry } from "./registry.js";
30
42
  export { builtinEngines, createDefaultRegistry, defaultRegistry } from "./builtins.js";
31
43
  export type {
32
44
  AnalyzeEnv,
45
+ AnalyzeResult,
46
+ CallSite,
33
47
  CompileEnv,
48
+ DiagnosticFix,
34
49
  EngineDiagnostic,
50
+ EngineFileClaim,
35
51
  TemplatingEngine,
36
52
  } from "./engine.js";
37
53
 
38
- export { isRefSentinel, isTaggedSentinel, makeTaggedSentinel, type TaggedSentinel } from "./sentinel.js";
54
+ export {
55
+ INCLUDE_BYTES_ENGINE,
56
+ INCLUDE_ENGINE_NAMES,
57
+ INCLUDE_TEXT_ENGINE,
58
+ isIncludeSentinel,
59
+ isRefSentinel,
60
+ isTaggedSentinel,
61
+ makeTaggedSentinel,
62
+ type TaggedSentinel,
63
+ } from "./sentinel.js";
39
64
  export { buildCustomTags, defaultCustomTags } from "./yaml-tags.js";
40
65
  export {
41
66
  MANIFEST_SCHEMA_URI,
@@ -118,8 +118,13 @@ export function normalizeRefSlots(schema: unknown): unknown {
118
118
  const out: Record<string, unknown> = { ...node };
119
119
  // Reference slot with a stale scalar `type` (legacy string-ref encoding):
120
120
  // drop the constraint so the resolved reference object / sentinel validates.
121
+ //
122
+ // A presence test, not a shape test — deliberately, since `templating` sits
123
+ // BELOW the analyzer in the dependency order and cannot reach the shared
124
+ // `readRefSlot` accessor. Presence is the only thing this rule needs, and it
125
+ // is stable across every annotation shape.
121
126
  if (
122
- typeof node[REF_ANNOTATION] === "string" &&
127
+ node[REF_ANNOTATION] !== undefined &&
123
128
  typeof node.type === "string" &&
124
129
  LEGACY_REF_SCALAR_TYPES.has(node.type)
125
130
  ) {
package/src/sentinel.ts CHANGED
@@ -32,3 +32,32 @@ export function makeTaggedSentinel(engine: string, source: string): TaggedSentin
32
32
  export function isRefSentinel(v: unknown): v is TaggedSentinel & { engine: "ref" } {
33
33
  return isTaggedSentinel(v) && v.engine === "ref";
34
34
  }
35
+
36
+ /** Engine names of the two file-embedding tags. Named here beside the other
37
+ * sentinel predicates so the kernel's resolution pass and the engines
38
+ * themselves agree on one spelling. */
39
+ export const INCLUDE_TEXT_ENGINE = "include-text";
40
+ export const INCLUDE_BYTES_ENGINE = "include-bytes";
41
+
42
+ /** Both file-embedding tag names, for a consumer holding an engine NAME rather
43
+ * than a value (the analyzer's expression walk reports names). */
44
+ export const INCLUDE_ENGINE_NAMES: ReadonlySet<string> = new Set([
45
+ INCLUDE_TEXT_ENGINE,
46
+ INCLUDE_BYTES_ENGINE,
47
+ ]);
48
+
49
+ /** True when `v` is an `!include-text` / `!include-bytes` sentinel — a file
50
+ * embed marked at parse time and still unresolved.
51
+ *
52
+ * The kernel's creation-time resolution keys off this the way Phase-5
53
+ * injection keys off {@link isRefSentinel}. Both tags survive precompile as
54
+ * markers rather than collapsing to a value, because the read is deferred: a
55
+ * manifest load must not pull payload layers, and the analyzer that types the
56
+ * slot cannot open files at all. */
57
+ export function isIncludeSentinel(
58
+ v: unknown,
59
+ ): v is TaggedSentinel & { engine: typeof INCLUDE_TEXT_ENGINE | typeof INCLUDE_BYTES_ENGINE } {
60
+ return (
61
+ isTaggedSentinel(v) && (v.engine === INCLUDE_TEXT_ENGINE || v.engine === INCLUDE_BYTES_ENGINE)
62
+ );
63
+ }