@telorun/analyzer 0.42.0 → 0.44.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 (44) hide show
  1. package/dist/analysis-registry.d.ts +4 -4
  2. package/dist/analysis-registry.d.ts.map +1 -1
  3. package/dist/analysis-registry.js +3 -3
  4. package/dist/analyzer.d.ts.map +1 -1
  5. package/dist/analyzer.js +92 -3
  6. package/dist/builtins.js +7 -7
  7. package/dist/definition-registry.d.ts +49 -22
  8. package/dist/definition-registry.d.ts.map +1 -1
  9. package/dist/definition-registry.js +67 -61
  10. package/dist/loaded-types.d.ts +13 -6
  11. package/dist/loaded-types.d.ts.map +1 -1
  12. package/dist/manifest-loader.d.ts.map +1 -1
  13. package/dist/manifest-loader.js +1 -0
  14. package/dist/reconcile-module-versions.d.ts +2 -2
  15. package/dist/reconcile-module-versions.d.ts.map +1 -1
  16. package/dist/reconcile-module-versions.js +69 -17
  17. package/dist/reference-field-map.d.ts +2 -1
  18. package/dist/reference-field-map.d.ts.map +1 -1
  19. package/dist/reference-field-map.js +1 -1
  20. package/dist/resolve-schema-ref-kinds.d.ts +62 -0
  21. package/dist/resolve-schema-ref-kinds.d.ts.map +1 -0
  22. package/dist/resolve-schema-ref-kinds.js +81 -0
  23. package/dist/sources/manifest-cache.d.ts +14 -4
  24. package/dist/sources/manifest-cache.d.ts.map +1 -1
  25. package/dist/sources/manifest-cache.js +15 -6
  26. package/dist/validate-nested-inline.d.ts +3 -1
  27. package/dist/validate-nested-inline.d.ts.map +1 -1
  28. package/dist/validate-nested-inline.js +14 -2
  29. package/dist/validate-value-schema.d.ts +13 -0
  30. package/dist/validate-value-schema.d.ts.map +1 -0
  31. package/dist/validate-value-schema.js +114 -0
  32. package/package.json +2 -2
  33. package/src/analysis-registry.ts +4 -4
  34. package/src/analyzer.ts +97 -3
  35. package/src/builtins.ts +7 -7
  36. package/src/definition-registry.ts +67 -66
  37. package/src/loaded-types.ts +13 -6
  38. package/src/manifest-loader.ts +1 -0
  39. package/src/reconcile-module-versions.ts +69 -17
  40. package/src/reference-field-map.ts +3 -2
  41. package/src/resolve-schema-ref-kinds.ts +118 -0
  42. package/src/sources/manifest-cache.ts +26 -8
  43. package/src/validate-nested-inline.ts +13 -1
  44. package/src/validate-value-schema.ts +126 -0
@@ -0,0 +1,118 @@
1
+ import type { ResourceManifest } from "@telorun/sdk";
2
+ import type { AliasResolver } from "./alias-resolver.js";
3
+
4
+ const REF_ANNOTATION = "x-telo-ref";
5
+
6
+ /** Why an `x-telo-ref` constraint did not canonicalize.
7
+ *
8
+ * - `legacy` — the deprecated `<namespace>/<module>#<Kind>` identity form,
9
+ * still resolved through the identity table.
10
+ * - `unknown` — the prefix is not an alias in the declaring module's scope: a
11
+ * typo, a missing `imports:` entry — or a value that was already
12
+ * canonical, which the caller separates by asking the registry.
13
+ * - `gated` — the alias is known and the target owns the kind, but its
14
+ * `exports.kinds` does not list it. */
15
+ export type RefConstraintReason = "legacy" | "unknown" | "gated";
16
+
17
+ /** An `x-telo-ref` constraint that did not canonicalize. */
18
+ export interface RefConstraintIssue {
19
+ /** The constraint string exactly as authored. */
20
+ ref: string;
21
+ /** Dotted path to the annotated schema node within the doc, e.g.
22
+ * `schema.properties.store`. Points the author at the slot, not just the doc. */
23
+ path: string;
24
+ /** The `Telo.Definition` / `Telo.Abstract` doc that declares the slot. */
25
+ manifest: ResourceManifest;
26
+ reason: RefConstraintReason;
27
+ /** For `gated`: the target module and the kinds it does export. */
28
+ gate?: { module: string; exported: string[] };
29
+ /** Aliases the declaring scope does know — the "did you mean" material for
30
+ * an `unknown` prefix. */
31
+ knownAliases?: string[];
32
+ }
33
+
34
+ /** True for the legacy identity form, which is split on `#` by the identity table. */
35
+ export function isLegacyRefIdentity(ref: string): boolean {
36
+ return ref.includes("#");
37
+ }
38
+
39
+ /**
40
+ * Rewrites the `x-telo-ref` constraints on one definition doc from the alias
41
+ * form (`KvStore.Store`, `Self.Store`, `Telo.Invocable`) to the canonical
42
+ * `<module>.<Kind>` key the definition registry is keyed by.
43
+ *
44
+ * `resolver` must be the scope of the module that DECLARES the definition, not
45
+ * the consumer's: an imported library names its dependencies by its own aliases.
46
+ * This mirrors how `extends:` and `capability:` are pre-resolved before
47
+ * registration, so the registry never needs module context to answer a ref query.
48
+ *
49
+ * Every constraint that does not canonicalize is returned, tagged with why. That
50
+ * matters more than it looks: an unresolved constraint leaves a string naming no
51
+ * registered kind, and the reference check treats an unknown target as partial
52
+ * context and skips it — so an unreported one would silently let the slot accept
53
+ * anything. The authored value is left in place either way, so a diagnostic
54
+ * quotes what the author actually wrote.
55
+ *
56
+ * An `unknown` result also covers an already-canonical value (`kv-store.Store`
57
+ * names a module, not an alias), which is what keeps the rewrite idempotent —
58
+ * the caller drops those by checking the definition registry once every kind is
59
+ * registered.
60
+ *
61
+ * The walk covers the whole doc rather than a fixed field list: a constraint can
62
+ * sit in `schema`, `inputType`, `outputType`, or a `$defs` entry nested in any of
63
+ * them, and rewriting one that appears in a template body's inline schema is
64
+ * correct too.
65
+ */
66
+ export function resolveSchemaRefKinds(
67
+ definition: ResourceManifest,
68
+ resolver: Pick<AliasResolver, "resolveKindResult" | "knownAliases">,
69
+ ): RefConstraintIssue[] {
70
+ const issues: RefConstraintIssue[] = [];
71
+
72
+ const record = (ref: string, path: string): void => {
73
+ if (isLegacyRefIdentity(ref)) {
74
+ issues.push({ ref, path, manifest: definition, reason: "legacy" });
75
+ return;
76
+ }
77
+ const result = resolver.resolveKindResult(ref);
78
+ if (result.status === "ok") return;
79
+ issues.push(
80
+ result.status === "gated"
81
+ ? {
82
+ ref,
83
+ path,
84
+ manifest: definition,
85
+ reason: "gated",
86
+ gate: { module: result.module, exported: result.exported },
87
+ }
88
+ : {
89
+ ref,
90
+ path,
91
+ manifest: definition,
92
+ reason: "unknown",
93
+ knownAliases: resolver.knownAliases(),
94
+ },
95
+ );
96
+ };
97
+
98
+ const walk = (value: unknown, path: string): void => {
99
+ if (value === null || typeof value !== "object") return;
100
+ if (Array.isArray(value)) {
101
+ value.forEach((item, i) => walk(item, `${path}[${i}]`));
102
+ return;
103
+ }
104
+ const obj = value as Record<string, unknown>;
105
+ const ref = obj[REF_ANNOTATION];
106
+ if (typeof ref === "string" && ref) {
107
+ const result = isLegacyRefIdentity(ref) ? null : resolver.resolveKindResult(ref);
108
+ if (result?.status === "ok") obj[REF_ANNOTATION] = result.kind;
109
+ else record(ref, path);
110
+ }
111
+ for (const key of Object.keys(obj)) {
112
+ walk(obj[key], path ? `${path}.${key}` : key);
113
+ }
114
+ };
115
+
116
+ walk(definition, "");
117
+ return issues;
118
+ }
@@ -12,12 +12,20 @@ export const MANIFEST_CACHE_BASE_URL = "https://manifests.telo.sh";
12
12
  * stores per version — `{ transport, host, path, version }` — so the tracker's
13
13
  * write key and the editor's read key come from the same function and never
14
14
  * drift. `path` is the slash-separated repo/module path (multi-segment OCI
15
- * repos nest as prefixes). */
15
+ * repos nest as prefixes), empty for a module addressed at a host's root. */
16
16
  export interface ManifestCacheCoords {
17
17
  transport: string;
18
18
  host: string;
19
19
  path: string;
20
- version: string;
20
+ /** Omit only when the ref carries no version to key by — a direct `https://`
21
+ * URL addresses exactly one file, so its path alone is already unambiguous.
22
+ * Every ref whose grammar has a version segment must supply it, or two
23
+ * versions of one module would collide on a single key. */
24
+ version?: string;
25
+ /** File within the version directory. Defaults to the module manifest, which
26
+ * is the only file the hub's bucket stores; the local install cache also
27
+ * holds each `include:` partial, which is named here. */
28
+ file?: string;
21
29
  }
22
30
 
23
31
  /** True for a segment that would corrupt or escape the cache key space. */
@@ -26,15 +34,25 @@ function invalidSegment(segment: string): boolean {
26
34
  }
27
35
 
28
36
  /** Deterministic cache key for one module version:
29
- * `<transport>/<host>/<path…>/<version>/telo.yaml`. Returns `null` when any
30
- * coordinate is empty or would traverse out of the key space. */
37
+ * `<transport>/<host>/<path…>/<version>/<file>`, where `version` is omitted
38
+ * when the coordinates carry none and `file` defaults to the module manifest.
39
+ * Returns `null` when any coordinate is empty or would traverse out of the key
40
+ * space. */
31
41
  export function manifestCacheKey(coords: ManifestCacheCoords): string | null {
32
- const { transport, host, path, version } = coords;
33
- const segments = [transport, host, ...path.split("/"), version];
34
- if (segments.some(invalidSegment) || transport.includes("/") || host.includes("/") || version.includes("/")) {
42
+ const { transport, host, path, version, file } = coords;
43
+ const segments = [transport, host, ...(path ? path.split("/") : [])];
44
+ if (version !== undefined) segments.push(version);
45
+ segments.push(file ?? DEFAULT_MANIFEST_FILENAME);
46
+ if (
47
+ segments.some(invalidSegment) ||
48
+ transport.includes("/") ||
49
+ host.includes("/") ||
50
+ (version !== undefined && version.includes("/")) ||
51
+ (file !== undefined && file.includes("/"))
52
+ ) {
35
53
  return null;
36
54
  }
37
- return `${segments.join("/")}/${DEFAULT_MANIFEST_FILENAME}`;
55
+ return segments.join("/");
38
56
  }
39
57
 
40
58
  /** Cache coordinates for an `oci://host/repo@tag` ref. Returns `null` when the
@@ -7,6 +7,7 @@ import {
7
7
  validateAgainstSchema,
8
8
  } from "./schema-compat.js";
9
9
  import { DiagnosticSeverity, type AnalysisDiagnostic } from "./types.js";
10
+ import { collectValueSchemaIssues } from "./validate-value-schema.js";
10
11
 
11
12
  const SOURCE = "telo-analyzer";
12
13
 
@@ -39,6 +40,8 @@ export function validateNestedInlineResources(
39
40
  manifest: ResourceManifest,
40
41
  rootSchema: Record<string, any>,
41
42
  lookupDefinition: InlineDefinitionLookup,
43
+ /** Needed to resolve a `telo#Type` field a value slot is validated against. */
44
+ allManifests: Record<string, any>[] = [],
42
45
  ): AnalysisDiagnostic[] {
43
46
  const diagnostics: AnalysisDiagnostic[] = [];
44
47
  const resource = { kind: manifest.kind, name: manifest.metadata?.name as string };
@@ -87,7 +90,16 @@ export function validateNestedInlineResources(
87
90
  : {};
88
91
  const data = { ...inline, metadata: { name: "__inline__", ...existingMeta } };
89
92
  const substituted = substituteCelFields(data, effectiveSchema, effectiveSchema);
90
- for (const issue of validateAgainstSchema(substituted, effectiveSchema)) {
93
+ // The same two passes the top-level resource loop runs, so a kind's
94
+ // guarantees don't depend on whether the author wrote it standalone or
95
+ // inline (under a step's `invoke:`, or in a `with:` scope). `data` carries
96
+ // the synthesized metadata; `x-telo-value-schema-from` reads sibling fields
97
+ // off the resource, which are present either way.
98
+ const inlineIssues = [
99
+ ...validateAgainstSchema(substituted, effectiveSchema),
100
+ ...collectValueSchemaIssues(data, schema, allManifests),
101
+ ];
102
+ for (const issue of inlineIssues) {
91
103
  diagnostics.push({
92
104
  severity: DiagnosticSeverity.Error,
93
105
  code: "SCHEMA_VIOLATION",
@@ -0,0 +1,126 @@
1
+ import { substituteCelFields, validateAgainstSchema, type SchemaIssue } from "./schema-compat.js";
2
+ import { resolveTypeFieldToSchema } from "./validate-cel-context.js";
3
+
4
+ /**
5
+ * `x-telo-value-schema-from: "<field>"` — the value written at the annotated
6
+ * node must satisfy the type declared at the resource's `<field>`.
7
+ *
8
+ * The motivating shape is a kind with ONE declared output contract and SEVERAL
9
+ * places that must each produce it — a decision table's rows, a switch's arms.
10
+ * Only the branch that wins at runtime gets checked there, so a mistyped branch
11
+ * ships and fails on the one input that selects it. This annotation checks every
12
+ * branch statically instead.
13
+ *
14
+ * Generic and topology-driven: the analyzer hardcodes no kind. Any definition
15
+ * with a `telo#Type` field and value-producing slots opts in by annotating those
16
+ * slots. `<field>` is resolved with the same `telo#Type` semantics as
17
+ * `inputType` / `outputType` everywhere else, so an inline
18
+ * `{ kind: Type.JsonSchema, schema: … }` and a named type reference both work.
19
+ *
20
+ * A field that resolves to no schema — the common case of an optional
21
+ * `outputType` left undeclared — is skipped, not reported: declaring the
22
+ * contract is what opts into the check.
23
+ */
24
+ const ANNOTATION = "x-telo-value-schema-from";
25
+
26
+ interface Annotation {
27
+ /** JSONPath-ish scope into the manifest, e.g. `$.choices[*].value`. */
28
+ scope: string;
29
+ /** Resource field naming the type to validate against, e.g. `outputType`. */
30
+ from: string;
31
+ }
32
+
33
+ /** Walk a definition schema collecting every `x-telo-value-schema-from`, in the
34
+ * same scope form the CEL-context walker produces. */
35
+ function collectAnnotations(schema: Record<string, any>, path: string): Annotation[] {
36
+ if (!schema || typeof schema !== "object") return [];
37
+ const out: Annotation[] = [];
38
+
39
+ const from = schema[ANNOTATION];
40
+ if (typeof from === "string" && from.length > 0) out.push({ scope: path, from });
41
+
42
+ if (schema.properties) {
43
+ for (const [key, value] of Object.entries(schema.properties as Record<string, any>)) {
44
+ out.push(...collectAnnotations(value, `${path}.${key}`));
45
+ }
46
+ }
47
+ if (schema.items && typeof schema.items === "object") {
48
+ out.push(...collectAnnotations(schema.items, `${path}[*]`));
49
+ }
50
+ for (const key of ["oneOf", "anyOf", "allOf"] as const) {
51
+ if (Array.isArray(schema[key])) {
52
+ for (const sub of schema[key]) out.push(...collectAnnotations(sub, path));
53
+ }
54
+ }
55
+ return out;
56
+ }
57
+
58
+ /** Expand a scope into the concrete values present in this manifest, carrying
59
+ * each one's real path so a diagnostic points at the row the author wrote. */
60
+ function resolveScopeValues(
61
+ manifest: Record<string, any>,
62
+ scope: string,
63
+ ): Array<{ path: string; value: unknown }> {
64
+ const stripped = scope.startsWith("$.") ? scope.slice(2) : scope;
65
+ if (!stripped) return [];
66
+
67
+ let frontier: Array<{ path: string; value: unknown }> = [{ path: "", value: manifest }];
68
+ // Segments look like `choices[*]` or `default` — a name plus optional wildcard.
69
+ for (const segment of stripped.split(".")) {
70
+ const wildcard = segment.endsWith("[*]");
71
+ const name = wildcard ? segment.slice(0, -3) : segment;
72
+ const next: Array<{ path: string; value: unknown }> = [];
73
+ for (const entry of frontier) {
74
+ const container = entry.value as Record<string, unknown> | undefined;
75
+ if (!container || typeof container !== "object") continue;
76
+ const child = container[name];
77
+ if (child === undefined) continue;
78
+ const childPath = entry.path ? `${entry.path}.${name}` : name;
79
+ if (!wildcard) {
80
+ next.push({ path: childPath, value: child });
81
+ continue;
82
+ }
83
+ if (!Array.isArray(child)) continue;
84
+ child.forEach((item, i) => next.push({ path: `${childPath}[${i}]`, value: item }));
85
+ }
86
+ frontier = next;
87
+ if (frontier.length === 0) break;
88
+ }
89
+ return frontier;
90
+ }
91
+
92
+ /**
93
+ * Validate every `x-telo-value-schema-from` slot in one resource.
94
+ *
95
+ * CEL leaves are replaced with schema-shaped placeholders before AJV runs
96
+ * (`substituteCelFields`), so an expression is accepted wherever its slot's
97
+ * declared type would be — a static pass cannot know what it evaluates to. What
98
+ * this DOES catch is structural disagreement that no runtime value can fix: a
99
+ * missing required property, an unknown property under
100
+ * `additionalProperties: false`, or a literal of the wrong type.
101
+ */
102
+ export function collectValueSchemaIssues(
103
+ manifest: Record<string, any>,
104
+ defSchema: Record<string, any> | undefined,
105
+ allManifests: Record<string, any>[],
106
+ ): SchemaIssue[] {
107
+ if (!defSchema) return [];
108
+ const annotations = collectAnnotations(defSchema, "$");
109
+ if (annotations.length === 0) return [];
110
+
111
+ const issues: SchemaIssue[] = [];
112
+ for (const { scope, from } of annotations) {
113
+ const target = resolveTypeFieldToSchema(manifest[from], allManifests);
114
+ if (!target || typeof target !== "object") continue;
115
+
116
+ for (const { path, value } of resolveScopeValues(manifest, scope)) {
117
+ for (const issue of validateAgainstSchema(substituteCelFields(value, target), target)) {
118
+ issues.push({
119
+ message: `\`${path}\` does not satisfy the type declared at \`${from}\`: ${issue.message}`,
120
+ path: issue.path ? `${path}.${issue.path}` : path,
121
+ });
122
+ }
123
+ }
124
+ }
125
+ return issues;
126
+ }