@telorun/analyzer 0.37.0 → 0.39.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 (40) hide show
  1. package/dist/analysis-registry.d.ts +14 -0
  2. package/dist/analysis-registry.d.ts.map +1 -1
  3. package/dist/analysis-registry.js +17 -0
  4. package/dist/analyzer.d.ts.map +1 -1
  5. package/dist/analyzer.js +17 -0
  6. package/dist/builtins.d.ts.map +1 -1
  7. package/dist/builtins.js +169 -0
  8. package/dist/index.d.ts +2 -0
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js +1 -0
  11. package/dist/inline-imports.d.ts.map +1 -1
  12. package/dist/inline-imports.js +1 -0
  13. package/dist/normalize-inline-resources.d.ts.map +1 -1
  14. package/dist/normalize-inline-resources.js +20 -1
  15. package/dist/redaction-path.d.ts +49 -0
  16. package/dist/redaction-path.d.ts.map +1 -0
  17. package/dist/redaction-path.js +133 -0
  18. package/dist/reference-field-map.d.ts +12 -0
  19. package/dist/reference-field-map.d.ts.map +1 -1
  20. package/dist/reference-field-map.js +9 -0
  21. package/dist/schema-compat.d.ts.map +1 -1
  22. package/dist/schema-compat.js +8 -0
  23. package/dist/validate-extends.d.ts +0 -20
  24. package/dist/validate-extends.d.ts.map +1 -1
  25. package/dist/validate-extends.js +7 -1
  26. package/dist/validate-logging.d.ts +22 -0
  27. package/dist/validate-logging.d.ts.map +1 -0
  28. package/dist/validate-logging.js +116 -0
  29. package/package.json +2 -2
  30. package/src/analysis-registry.ts +20 -0
  31. package/src/analyzer.ts +18 -0
  32. package/src/builtins.ts +176 -0
  33. package/src/index.ts +6 -0
  34. package/src/inline-imports.ts +1 -0
  35. package/src/normalize-inline-resources.ts +20 -1
  36. package/src/redaction-path.ts +142 -0
  37. package/src/reference-field-map.ts +20 -0
  38. package/src/schema-compat.ts +7 -0
  39. package/src/validate-extends.ts +8 -1
  40. package/src/validate-logging.ts +136 -0
@@ -10,6 +10,21 @@ const SYSTEM_KINDS = new Set([
10
10
  "Telo.Import",
11
11
  ]);
12
12
 
13
+ /**
14
+ * System kinds are excluded from inline extraction by default, but a single slot
15
+ * may opt back in with `x-telo-inline: true` — `Telo.Application.logging.sinks`
16
+ * is the case this exists for.
17
+ *
18
+ * The opt-in is per slot rather than per kind because this pass runs *upstream*
19
+ * of schema validation on both the analyzer and runtime paths. Admitting the
20
+ * whole Application document would rewrite an inline `{kind, ...}` in `targets`
21
+ * into a valid `{kind, name}` before AJV ever saw it, silently converting a
22
+ * deliberate rejection into a working feature.
23
+ */
24
+ function acceptsInline(resourceKind: string, entry: { inline?: boolean }): boolean {
25
+ return !SYSTEM_KINDS.has(resourceKind) || entry.inline === true;
26
+ }
27
+
13
28
  /** Replaces characters outside [a-zA-Z0-9_] with underscores. */
14
29
  function sanitizeName(raw: string): string {
15
30
  return raw.replace(/[^a-zA-Z0-9_]/g, "_");
@@ -65,9 +80,12 @@ export function normalizeInlineResources(
65
80
 
66
81
  // Queue: all non-system resources with a name. Extracted resources are appended.
67
82
  // Filter the CLONES (not the originals) so traversal mutates copies.
83
+ // System kinds join the queue too: their inline-accepting slots are filtered
84
+ // per entry below, so a system document is walked but only its opted-in slots
85
+ // are extracted from.
68
86
  const queue = result.filter(
69
87
  (r): r is ResourceManifest & { metadata: { name: string } } =>
70
- typeof r.metadata?.name === "string" && !!r.kind && !SYSTEM_KINDS.has(r.kind),
88
+ typeof r.metadata?.name === "string" && !!r.kind,
71
89
  );
72
90
 
73
91
  let i = 0;
@@ -97,6 +115,7 @@ export function normalizeInlineResources(
97
115
 
98
116
  for (const [fieldPath, entry] of fieldMap) {
99
117
  if (!isRefEntry(entry)) continue;
118
+ if (!acceptsInline(resource.kind, entry)) continue;
100
119
 
101
120
  const inScope = scopePrefixes.some(
102
121
  (prefix) =>
@@ -0,0 +1,142 @@
1
+ /**
2
+ * The redaction path grammar of `kernel/specs/logging.md` §14 — a hand-written
3
+ * parser over a closed grammar.
4
+ *
5
+ * §14.1 makes this a security requirement rather than a style preference. The
6
+ * implementation this syntax is borrowed from compiles paths through the
7
+ * `Function` constructor and validates them by "evaluate it and see whether it
8
+ * parses", which is exactly why that implementation must forbid user input. A
9
+ * real parser removes the injection surface entirely and, as a bonus, makes
10
+ * paths statically checkable by `telo check`.
11
+ *
12
+ * Browser-safe by construction: this module is imported by both the analyzer's
13
+ * static check and the kernel's runtime redaction pass, so the grammar has one
14
+ * definition rather than two that can drift.
15
+ *
16
+ * Grammar:
17
+ *
18
+ * path := segment ( "." segment | bracket )*
19
+ * segment := bareKey | "*"
20
+ * bracket := "[" ( quoted | integer | "*" ) "]"
21
+ * quoted := '"' ... '"' | "'" ... "'"
22
+ *
23
+ * More than one wildcard per path is supported — `items[*].tokens[*].value` is
24
+ * valid. The one-wildcard limit in the best-known implementation is an artifact
25
+ * of how it compiles accessors, not a property of the grammar.
26
+ */
27
+
28
+ export type RedactionSegment = { kind: "key"; name: string } | { kind: "wildcard" };
29
+
30
+ export class RedactionPathError extends Error {
31
+ readonly code = "INVALID_REDACTION_PATH";
32
+ readonly path: string;
33
+ readonly offset: number;
34
+
35
+ constructor(path: string, offset: number, detail: string) {
36
+ super(`Invalid redaction path "${path}" at position ${offset}: ${detail}`);
37
+ this.name = "RedactionPathError";
38
+ this.path = path;
39
+ this.offset = offset;
40
+ }
41
+ }
42
+
43
+ const BARE_KEY_TERMINATORS = new Set([".", "[", "]"]);
44
+
45
+ /**
46
+ * Parse a redaction path into its segments. Throws {@link RedactionPathError}
47
+ * with the offending offset so `telo check` can point at the character rather
48
+ * than the whole path.
49
+ */
50
+ export function parseRedactionPath(path: string): RedactionSegment[] {
51
+ if (path.length === 0) throw new RedactionPathError(path, 0, "path is empty");
52
+
53
+ const segments: RedactionSegment[] = [];
54
+ let index = 0;
55
+ let expectSegment = true;
56
+
57
+ while (index < path.length) {
58
+ const char = path[index]!;
59
+
60
+ if (char === "[") {
61
+ index = parseBracket(path, index, segments);
62
+ expectSegment = false;
63
+ continue;
64
+ }
65
+
66
+ if (char === ".") {
67
+ if (expectSegment) {
68
+ throw new RedactionPathError(path, index, "expected a key before '.'");
69
+ }
70
+ index += 1;
71
+ expectSegment = true;
72
+ continue;
73
+ }
74
+
75
+ if (char === "]") {
76
+ throw new RedactionPathError(path, index, "unmatched ']'");
77
+ }
78
+
79
+ const start = index;
80
+ while (index < path.length && !BARE_KEY_TERMINATORS.has(path[index]!)) index += 1;
81
+ const raw = path.slice(start, index);
82
+ if (raw.length === 0) throw new RedactionPathError(path, start, "empty key");
83
+ segments.push(raw === "*" ? { kind: "wildcard" } : { kind: "key", name: raw });
84
+ expectSegment = false;
85
+ }
86
+
87
+ if (expectSegment) {
88
+ throw new RedactionPathError(path, path.length, "path ends with a trailing '.'");
89
+ }
90
+ return segments;
91
+ }
92
+
93
+ function parseBracket(path: string, open: number, segments: RedactionSegment[]): number {
94
+ let index = open + 1;
95
+ if (index >= path.length) throw new RedactionPathError(path, open, "unterminated '['");
96
+
97
+ const quote = path[index];
98
+ if (quote === '"' || quote === "'") {
99
+ index += 1;
100
+ const start = index;
101
+ while (index < path.length && path[index] !== quote) index += 1;
102
+ if (index >= path.length) {
103
+ throw new RedactionPathError(path, start, `unterminated ${quote} quoted key`);
104
+ }
105
+ const name = path.slice(start, index);
106
+ if (name.length === 0) throw new RedactionPathError(path, start, "empty quoted key");
107
+ index += 1;
108
+ if (path[index] !== "]") {
109
+ throw new RedactionPathError(path, index, "expected ']' after quoted key");
110
+ }
111
+ segments.push({ kind: "key", name });
112
+ return index + 1;
113
+ }
114
+
115
+ const start = index;
116
+ while (index < path.length && path[index] !== "]") index += 1;
117
+ if (index >= path.length) throw new RedactionPathError(path, open, "unterminated '['");
118
+ const raw = path.slice(start, index);
119
+ if (raw.length === 0) throw new RedactionPathError(path, start, "empty '[]'");
120
+ if (raw === "*") {
121
+ segments.push({ kind: "wildcard" });
122
+ } else if (/^\d+$/.test(raw)) {
123
+ segments.push({ kind: "key", name: raw });
124
+ } else {
125
+ throw new RedactionPathError(
126
+ path,
127
+ start,
128
+ `expected a quoted key, an integer index, or '*', got "${raw}" — quote it as ["${raw}"]`,
129
+ );
130
+ }
131
+ return index + 1;
132
+ }
133
+
134
+ /** `true` when the path contains a wildcard anywhere but its last segment.
135
+ * §14.2 measures intermediate wildcards at 25–55% over plain serialization,
136
+ * against 1–2% for explicit paths, so a runtime may warn when one is used. */
137
+ export function hasIntermediateWildcard(segments: readonly RedactionSegment[]): boolean {
138
+ for (let i = 0; i < segments.length - 1; i += 1) {
139
+ if (segments[i]!.kind === "wildcard") return true;
140
+ }
141
+ return false;
142
+ }
@@ -8,6 +8,18 @@ export interface RefFieldEntry {
8
8
  /** x-telo-context schema declared on this ref slot, if any. Describes the CEL invocation
9
9
  * context available to resources placed in this slot. */
10
10
  context?: Record<string, any>;
11
+ /** `x-telo-inline: true` — this slot accepts an inline `{kind, ...config}`
12
+ * definition, not only a `!ref`.
13
+ *
14
+ * Only meaningful on the *system* kinds (`Telo.Application` and friends),
15
+ * which are otherwise excluded from inline-resource normalization wholesale.
16
+ * Ordinary resource kinds accept inline definitions at every ref slot and
17
+ * need no annotation. The flag exists so `logging.sinks` can opt in without
18
+ * also legalizing an inline definition in `targets`, where the Application
19
+ * schema rejects one deliberately — normalization runs upstream of AJV, so
20
+ * an unconditional opt-in would rewrite the value into a valid shape before
21
+ * the schema ever saw it. */
22
+ inline?: boolean;
11
23
  }
12
24
 
13
25
  /** An entry for a field that declares an execution scope (x-telo-scope). */
@@ -148,6 +160,13 @@ export function buildReferenceFieldMap(schema: Record<string, any>): ReferenceFi
148
160
  return map;
149
161
  }
150
162
 
163
+ /** `x-telo-inline` declared on any `anyOf` branch marks the whole slot as
164
+ * inline-accepting, matching how {@link collectRefs} unions branch refs. */
165
+ function collectInlineFlag(node: Record<string, any>): boolean {
166
+ if (!Array.isArray(node.anyOf)) return false;
167
+ return node.anyOf.some((branch: Record<string, any>) => branch?.["x-telo-inline"] === true);
168
+ }
169
+
151
170
  export function collectRefs(node: Record<string, any>): string[] {
152
171
  const refs: string[] = [];
153
172
  if (typeof node["x-telo-ref"] === "string") {
@@ -212,6 +231,7 @@ function traverseNode(
212
231
  if (refs.length > 0) {
213
232
  const entry: RefFieldEntry = { refs, isArray: path.includes("[]") };
214
233
  if (node["x-telo-context"]) entry.context = node["x-telo-context"] as Record<string, any>;
234
+ if (node["x-telo-inline"] === true || collectInlineFlag(node)) entry.inline = true;
215
235
  map.set(path, entry);
216
236
  // A node can mix item-level ref branches (a bare string / `{kind, name}`)
217
237
  // with object branches that carry their OWN nested refs — e.g. Application
@@ -308,6 +308,13 @@ export function celTypeSatisfiesJsonSchema(celType: string, schema: Record<strin
308
308
  /** Return a literal placeholder value of the correct schema type for AJV. */
309
309
  export function celPlaceholderForSchema(schema: Record<string, any>): unknown {
310
310
  if (schema.default !== undefined) return schema.default;
311
+ // An enum-constrained field needs a placeholder drawn from the enum: the
312
+ // type-based fallbacks below ("" for a string, 0 for a number) satisfy `type`
313
+ // but violate `enum`, so a CEL expression feeding any enum field would report
314
+ // a spurious SCHEMA_VIOLATION against a value the author never wrote. The
315
+ // member chosen is irrelevant — only its acceptability to AJV matters, since
316
+ // the real value is checked at runtime once the expression resolves.
317
+ if (Array.isArray(schema.enum) && schema.enum.length > 0) return schema.enum[0];
311
318
  switch (schema.type) {
312
319
  case "integer":
313
320
  case "number":
@@ -29,6 +29,9 @@ const EXTENDS_ALIAS_RE = /^[A-Z][A-Za-z0-9_]*\.[A-Z][A-Za-z0-9_]*$/;
29
29
  * (metadata.module !== "Telo"). Builtin lifecycle capabilities (Telo.Invocable, etc.)
30
30
  * never trigger this — they're lifecycle roles by design.
31
31
  */
32
+ /** The built-in namespace, resolvable without a `Telo.Import`. */
33
+ const TELO_BUILTIN_ALIAS = "Telo";
34
+
32
35
  export function validateExtends(
33
36
  manifests: ResourceManifest[],
34
37
  registry: DefinitionRegistry,
@@ -88,7 +91,11 @@ export function validateExtends(
88
91
  });
89
92
  } else {
90
93
  const prefix = extendsValue.slice(0, extendsValue.indexOf("."));
91
- if (!aliases.hasAlias(prefix)) {
94
+ // `Telo` needs no import: the kernel built-ins are globally resolvable
95
+ // by design, which is what lets a sink author depend on the kernel
96
+ // contract (`extends: Telo.LogSink`) rather than on a standard-library
97
+ // module version — see kernel/specs/logging.md §10.2.
98
+ if (prefix !== TELO_BUILTIN_ALIAS && !aliases.hasAlias(prefix)) {
92
99
  diagnostics.push({
93
100
  severity: DiagnosticSeverity.Error,
94
101
  code: "EXTENDS_MALFORMED",
@@ -0,0 +1,136 @@
1
+ import type { ResourceManifest } from "@telorun/sdk";
2
+ import type { AliasResolver } from "./alias-resolver.js";
3
+ import type { DefinitionRegistry } from "./definition-registry.js";
4
+ import { parseRedactionPath, RedactionPathError } from "./redaction-path.js";
5
+ import { DiagnosticSeverity, type AnalysisDiagnostic } from "./types.js";
6
+
7
+ const SOURCE = "telo-analyzer";
8
+
9
+ /**
10
+ * Static validation of the `logging:` block — `kernel/specs/logging.md` §14.1
11
+ * and §10.3.
12
+ *
13
+ * Two things the spec explicitly says are statically detectable, so leaving them
14
+ * to a runtime failure would contradict Telo's "manifests must remain statically
15
+ * analyzable" goal:
16
+ *
17
+ * 1. **Redaction paths** parse against the closed §14 grammar. §14.1's whole
18
+ * argument for a hand-written parser is that it makes paths checkable by
19
+ * `telo check`; a bad path must fail here, not silently fail to redact at
20
+ * runtime.
21
+ * 2. **`on_full: block`** is unimplementable on a single-threaded runtime and
22
+ * §10.3 calls it "statically detectable by `telo check`". Catching it here —
23
+ * rather than only at boot — is what lets an operator fix it before shipping.
24
+ */
25
+ export function validateLogging(
26
+ manifests: ResourceManifest[],
27
+ registry: DefinitionRegistry,
28
+ aliases: AliasResolver,
29
+ aliasesByModule?: Map<string, AliasResolver>,
30
+ ): AnalysisDiagnostic[] {
31
+ const diagnostics: AnalysisDiagnostic[] = [];
32
+
33
+ for (const manifest of manifests) {
34
+ const kind = manifest.kind;
35
+
36
+ // Redaction paths live on the `logging:` block of the root Application and on
37
+ // any `Telo.Import` / `Telo.Library` doc carrying a per-import override.
38
+ if (kind === "Telo.Application" || kind === "Telo.Import" || kind === "Telo.Library") {
39
+ validateRedactPaths(manifest, diagnostics);
40
+ }
41
+
42
+ // `on_full` lives on any sink instance — an inline `logging.sinks[]` entry
43
+ // (extracted by Phase 2 into a first-class manifest by now) or a standalone
44
+ // sink resource declared elsewhere and reached via `!ref`.
45
+ if (isSinkKind(manifest, registry, aliases, aliasesByModule)) {
46
+ validateOnFull(manifest, diagnostics);
47
+ }
48
+ }
49
+
50
+ return diagnostics;
51
+ }
52
+
53
+ function validateRedactPaths(manifest: ResourceManifest, out: AnalysisDiagnostic[]): void {
54
+ const name = (manifest.metadata as { name?: string } | undefined)?.name;
55
+ const filePath = (manifest.metadata as { source?: string } | undefined)?.source;
56
+ const resource = { kind: manifest.kind, name };
57
+
58
+ for (const { block, prefix } of loggingBlocks(manifest)) {
59
+ const paths = (block.redact as { paths?: unknown } | undefined)?.paths;
60
+ if (!Array.isArray(paths)) continue;
61
+ paths.forEach((path, index) => {
62
+ // A `!cel` path is a compiled/sentinel node by now, not a string — its
63
+ // value isn't known statically, so there is nothing to parse.
64
+ if (typeof path !== "string") return;
65
+ try {
66
+ parseRedactionPath(path);
67
+ } catch (err) {
68
+ if (!(err instanceof RedactionPathError)) throw err;
69
+ out.push({
70
+ severity: DiagnosticSeverity.Error,
71
+ code: "INVALID_REDACTION_PATH",
72
+ source: SOURCE,
73
+ message: `${manifest.kind}/${name ?? "(unnamed)"}: ${err.message}`,
74
+ data: { resource, filePath, path: `${prefix}redact.paths[${index}]` },
75
+ });
76
+ }
77
+ });
78
+ }
79
+ }
80
+
81
+ function validateOnFull(manifest: ResourceManifest, out: AnalysisDiagnostic[]): void {
82
+ const onFull = (manifest as { on_full?: unknown }).on_full;
83
+ if (onFull !== "block") return;
84
+ const name = (manifest.metadata as { name?: string } | undefined)?.name;
85
+ const filePath = (manifest.metadata as { source?: string } | undefined)?.source;
86
+ out.push({
87
+ severity: DiagnosticSeverity.Error,
88
+ code: "LOG_SINK_ON_FULL_UNSUPPORTED",
89
+ source: SOURCE,
90
+ message:
91
+ `${manifest.kind}/${name ?? "(unnamed)"}: on_full: block is not supported on a ` +
92
+ `single-threaded runtime — blocking the producer would stall the writer. ` +
93
+ `Use drop_new or drop_old.`,
94
+ data: { resource: { kind: manifest.kind, name }, filePath, path: "on_full" },
95
+ });
96
+ }
97
+
98
+ /** The root `logging:` block plus every per-import `logging:` override, each
99
+ * with the dotted path prefix a diagnostic anchors against. */
100
+ function loggingBlocks(
101
+ manifest: ResourceManifest,
102
+ ): Array<{ block: Record<string, unknown>; prefix: string }> {
103
+ const blocks: Array<{ block: Record<string, unknown>; prefix: string }> = [];
104
+ const root = (manifest as { logging?: unknown }).logging;
105
+ if (isObject(root)) blocks.push({ block: root, prefix: "logging." });
106
+
107
+ // Inline imports map: `imports.<Alias>.logging`.
108
+ const imports = (manifest as { imports?: unknown }).imports;
109
+ if (isObject(imports)) {
110
+ for (const [alias, entry] of Object.entries(imports)) {
111
+ if (!isObject(entry)) continue;
112
+ const block = (entry as { logging?: unknown }).logging;
113
+ if (isObject(block)) blocks.push({ block, prefix: `imports.${alias}.logging.` });
114
+ }
115
+ }
116
+ return blocks;
117
+ }
118
+
119
+ function isSinkKind(
120
+ manifest: ResourceManifest,
121
+ registry: DefinitionRegistry,
122
+ aliases: AliasResolver,
123
+ aliasesByModule?: Map<string, AliasResolver>,
124
+ ): boolean {
125
+ if (typeof manifest.kind !== "string") return false;
126
+ if (manifest.kind === "Telo.ConsoleSink" || manifest.kind === "Telo.FileSink") return true;
127
+ const ownModule = (manifest.metadata as { module?: string } | undefined)?.module;
128
+ const resolver =
129
+ (ownModule ? aliasesByModule?.get(ownModule) : undefined) ?? aliases;
130
+ const canonical = resolver.resolveKind(manifest.kind) ?? manifest.kind;
131
+ return registry.resolve(canonical)?.capability === "Telo.Sink";
132
+ }
133
+
134
+ function isObject(value: unknown): value is Record<string, unknown> {
135
+ return typeof value === "object" && value !== null && !Array.isArray(value);
136
+ }