@telorun/analyzer 0.48.0 → 0.49.1

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 (50) hide show
  1. package/dist/analysis-registry.d.ts +22 -11
  2. package/dist/analysis-registry.d.ts.map +1 -1
  3. package/dist/analysis-registry.js +36 -39
  4. package/dist/analyzer.d.ts +38 -1
  5. package/dist/analyzer.d.ts.map +1 -1
  6. package/dist/analyzer.js +115 -83
  7. package/dist/builtins.d.ts.map +1 -1
  8. package/dist/builtins.js +72 -1
  9. package/dist/extends-resolution.d.ts +41 -0
  10. package/dist/extends-resolution.d.ts.map +1 -1
  11. package/dist/extends-resolution.js +68 -0
  12. package/dist/index.d.ts +4 -2
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +2 -1
  15. package/dist/invocation-contract.d.ts +100 -0
  16. package/dist/invocation-contract.d.ts.map +1 -0
  17. package/dist/invocation-contract.js +208 -0
  18. package/dist/manifest-loader.d.ts +11 -0
  19. package/dist/manifest-loader.d.ts.map +1 -1
  20. package/dist/manifest-loader.js +20 -0
  21. package/dist/schema-compat.d.ts +12 -4
  22. package/dist/schema-compat.d.ts.map +1 -1
  23. package/dist/schema-compat.js +185 -9
  24. package/dist/validate-base-mapping.js +11 -1
  25. package/dist/validate-cel-context.d.ts +0 -6
  26. package/dist/validate-cel-context.d.ts.map +1 -1
  27. package/dist/validate-cel-context.js +51 -4
  28. package/dist/validate-invocation-contract.d.ts +30 -0
  29. package/dist/validate-invocation-contract.d.ts.map +1 -0
  30. package/dist/validate-invocation-contract.js +394 -0
  31. package/dist/validate-step-inputs.d.ts +24 -0
  32. package/dist/validate-step-inputs.d.ts.map +1 -0
  33. package/dist/validate-step-inputs.js +87 -0
  34. package/dist/validate-throws-coverage.d.ts +1 -1
  35. package/dist/validate-throws-coverage.d.ts.map +1 -1
  36. package/dist/validate-throws-coverage.js +9 -1
  37. package/package.json +3 -3
  38. package/src/analysis-registry.ts +44 -34
  39. package/src/analyzer.ts +171 -100
  40. package/src/builtins.ts +74 -1
  41. package/src/extends-resolution.ts +86 -0
  42. package/src/index.ts +13 -1
  43. package/src/invocation-contract.ts +275 -0
  44. package/src/manifest-loader.ts +20 -0
  45. package/src/schema-compat.ts +191 -8
  46. package/src/validate-base-mapping.ts +14 -1
  47. package/src/validate-cel-context.ts +49 -4
  48. package/src/validate-invocation-contract.ts +450 -0
  49. package/src/validate-step-inputs.ts +117 -0
  50. package/src/validate-throws-coverage.ts +12 -2
@@ -29,6 +29,10 @@ interface DefinitionBody {
29
29
  base?: Record<string, unknown>;
30
30
  schema?: Record<string, any>;
31
31
  status?: Record<string, any>;
32
+ inputType?: unknown;
33
+ outputType?: unknown;
34
+ inputs?: unknown;
35
+ result?: unknown;
32
36
  }
33
37
 
34
38
  const body = (def: ResourceDefinition | undefined): DefinitionBody =>
@@ -133,6 +137,88 @@ export function effectiveAuthorSchema(
133
137
  return mergeTypeSchemas([parentSchema, own]) as Record<string, any>;
134
138
  }
135
139
 
140
+ /** The two directions of a kind's invocation contract. `inputType` is what a
141
+ * caller sends to `invoke()`; `outputType` is what `invoke()` / `provide()`
142
+ * returns. */
143
+ export type ContractDirection = "inputType" | "outputType";
144
+
145
+ /**
146
+ * The **nearest declaration** of an invocation contract along the `extends`
147
+ * chain, self first — the raw type-field value, still to be resolved to a schema
148
+ * by the caller (which is what keeps this module free of manifest lookup).
149
+ *
150
+ * Contracts RESOLVE, they never merge. A definition that declares one fully
151
+ * replaces its ancestor's; one that declares none inherits its ancestor's
152
+ * verbatim, at any depth. This is deliberately unlike {@link
153
+ * effectiveAuthorSchema} and {@link effectiveStatusSchema}: construction config
154
+ * and observed state are additive, a call signature is not. Folding a child's
155
+ * required fields into its parent's yields a union no caller can satisfy, and it
156
+ * would reject the very remapping `base:` + `inputs:` exists for — the point of
157
+ * a child declaring a signature is that it accepts something *different*.
158
+ *
159
+ * Substitutability is not weakened by that, because `extends` never carried the
160
+ * dispatch contract: it decides which slots accept a resource. Whether a
161
+ * particular slot may hold a resource whose contract differs from the slot's
162
+ * declared kind is a wiring question, answered per slot by
163
+ * `validate-invocation-contract`'s wiring rule.
164
+ */
165
+ export function effectiveContractField(
166
+ def: ResourceDefinition | undefined,
167
+ resolve: DefResolver,
168
+ direction: ContractDirection,
169
+ ): unknown {
170
+ const own = body(def)[direction];
171
+ if (own !== undefined && own !== null) return own;
172
+ for (const a of ancestorChain(def, resolve)) {
173
+ const inherited = body(a)[direction];
174
+ if (inherited !== undefined && inherited !== null) return inherited;
175
+ }
176
+ return undefined;
177
+ }
178
+
179
+ /** The definition in the `extends` chain (self first) that actually DECLARES the
180
+ * contract for `direction` — the one whose scope its `telo#Type` references
181
+ * resolve in, and the one a diagnostic should name. Undefined when nothing in
182
+ * the chain declares it. */
183
+ export function contractDeclarer(
184
+ def: ResourceDefinition | undefined,
185
+ resolve: DefResolver,
186
+ direction: ContractDirection,
187
+ ): ResourceDefinition | undefined {
188
+ if (!def) return undefined;
189
+ const own = body(def)[direction];
190
+ if (own !== undefined && own !== null) return def;
191
+ for (const a of ancestorChain(def, resolve)) {
192
+ const inherited = body(a)[direction];
193
+ if (inherited !== undefined && inherited !== null) return a;
194
+ }
195
+ return undefined;
196
+ }
197
+
198
+ /** True when this definition declares its own contract for `direction` while
199
+ * inheriting the controller that will execute it — the case that REQUIRES a
200
+ * bridging mapping (`inputs:` for inputs, `result:` for outputs), because the
201
+ * inherited controller only understands the ancestor's shape. A definition with
202
+ * its own controller or template body is exempt: its controller *is* the
203
+ * implementation of whatever it declares. */
204
+ export function needsContractMapping(
205
+ def: ResourceDefinition | undefined,
206
+ resolve: DefResolver,
207
+ direction: ContractDirection,
208
+ ): boolean {
209
+ const own = body(def)[direction];
210
+ if (own === undefined || own === null) return false;
211
+ if (hasOwnControllerOrTemplate(def)) return false;
212
+ return controllerBearingAncestor(def, resolve) !== undefined;
213
+ }
214
+
215
+ /** The mapping field that bridges a replaced contract back to the inherited
216
+ * controller: `inputs:` maps the child's signature onto the parent's call,
217
+ * `result:` maps the parent's result back to the child's declared output. */
218
+ export function mappingFieldFor(direction: ContractDirection): "inputs" | "result" {
219
+ return direction === "inputType" ? "inputs" : "result";
220
+ }
221
+
136
222
  /** The observed state a kind reports (`status:`), folded through `extends`:
137
223
  * - with `base:` present → the **parent's** effective status unchanged; the
138
224
  * child delegates to the parent's controller and *is* a parent instance, so
package/src/index.ts CHANGED
@@ -38,15 +38,27 @@ export { moduleScopedDefResolver, scopeResolverForModule } from "./alias-resolve
38
38
  export type { ModuleScopes } from "./alias-resolver.js";
39
39
  export {
40
40
  ancestorChain,
41
+ contractDeclarer,
41
42
  controllerBearingAncestor,
42
43
  effectiveAuthorSchema,
44
+ effectiveContractField,
43
45
  effectiveStatusSchema,
44
46
  hasOwnControllerOrTemplate,
45
47
  inheritedCapability,
46
48
  isInheritedDelegation,
49
+ mappingFieldFor,
50
+ needsContractMapping,
47
51
  resolveParent,
48
52
  } from "./extends-resolution.js";
49
- export type { DefResolver } from "./extends-resolution.js";
53
+ export type { ContractDirection, DefResolver } from "./extends-resolution.js";
54
+ export {
55
+ defaultBearingPaths,
56
+ PERMISSIVE_CONTRACT,
57
+ resolveContract,
58
+ resolveContractSchema,
59
+ withStreamPropertiesSkipped,
60
+ } from "./invocation-contract.js";
61
+ export type { ContractOrigin, ContractScope, ResolvedContract } from "./invocation-contract.js";
50
62
  export {
51
63
  hasIntermediateWildcard,
52
64
  parseRedactionPath,
@@ -0,0 +1,275 @@
1
+ import type { ResourceDefinition } from "@telorun/sdk";
2
+ import {
3
+ type ContractDirection,
4
+ contractDeclarer,
5
+ type DefResolver,
6
+ effectiveContractField,
7
+ } from "./extends-resolution.js";
8
+ import { resolveTypeFieldToSchema } from "./validate-cel-context.js";
9
+
10
+ export type { ContractDirection };
11
+
12
+ /**
13
+ * The one answer to "what is this target's input / output schema".
14
+ *
15
+ * Both halves of Telo consume it: `telo check` validates call sites against it,
16
+ * and the kernel binds it to the instance at creation. It lives here rather than
17
+ * in the kernel because it must be browser-safe and because a second
18
+ * implementation would drift — the same split already used for
19
+ * `buildEvalPaths` / `evalPathCovers` and the redaction path parser. Before this
20
+ * there were three one-hop lookups (the analysis registry's editor helpers, the
21
+ * template-body `inputs` typing, the step-context definition fallback) and a
22
+ * runtime that consulted only an explicitly-passed type ref, so static analysis
23
+ * and dispatch could disagree about the very contract they were both checking.
24
+ */
25
+
26
+ /** Where a resolved contract was declared. Instance-level declarations let one
27
+ * call site narrow a kind's contract (`JS.Script` does this); kind-level ones
28
+ * are the kind's own signature. */
29
+ export type ContractOrigin = "instance" | "kind";
30
+
31
+ export interface ResolvedContract {
32
+ /** The JSON Schema a value is validated against. */
33
+ schema: Record<string, any>;
34
+ origin: ContractOrigin;
35
+ /** The definition that declared it, when `origin` is `"kind"` — the scope its
36
+ * named type references resolved in, and what a diagnostic should name. */
37
+ declaredBy?: ResourceDefinition;
38
+ }
39
+
40
+ export interface ContractScope {
41
+ /** Resolves a kind to its definition. Must be scoped to the module that
42
+ * DECLARED the definition being walked — `extends` aliases are lexical, so a
43
+ * chain crossing module boundaries re-scopes at each hop. */
44
+ resolveDefinition: DefResolver;
45
+ /**
46
+ * Manifests a named `telo#Type` reference resolves against, given the
47
+ * definition that DECLARED the type field.
48
+ *
49
+ * The parameter exists for a caller that keeps types per module. The analyzer
50
+ * does not: it works from one flattened list where names are already unique
51
+ * per module, so it ignores the argument and returns that list. A caller
52
+ * holding several scopes uses it to avoid resolving a bare name against the
53
+ * wrong module's type of the same name.
54
+ */
55
+ typeManifestsFor(def: ResourceDefinition | undefined): Record<string, any>[];
56
+ }
57
+
58
+ /** The fallback for a target that declares no contract: anything goes. Not
59
+ * `additionalProperties: false` — an undeclared contract is an absence of a
60
+ * claim, not a claim of emptiness. */
61
+ export const PERMISSIVE_CONTRACT: Record<string, any> = {
62
+ type: "object",
63
+ additionalProperties: true,
64
+ };
65
+
66
+ /**
67
+ * Resolve a dispatch target's contract, layering:
68
+ * 1. the **instance manifest's** own `inputType:` / `outputType:` — per-call-site
69
+ * narrowing, opted into simply by the kind declaring the property;
70
+ * 2. the **kind's** contract, resolved to the nearest declaration along
71
+ * `extends` (see {@link effectiveContractField} — nearest wins, no merge);
72
+ * 3. undefined — the caller decides whether that means permissive.
73
+ *
74
+ * Returns undefined rather than {@link PERMISSIVE_CONTRACT} so a caller can tell
75
+ * "declared nothing" from "declared anything", which the wiring rule and the
76
+ * `run()` guard both need.
77
+ */
78
+ export function resolveContract(
79
+ direction: ContractDirection,
80
+ manifest: Record<string, any> | undefined,
81
+ definition: ResourceDefinition | undefined,
82
+ scope: ContractScope,
83
+ ): ResolvedContract | undefined {
84
+ const own = manifest?.[direction];
85
+ if (own !== undefined && own !== null) {
86
+ const schema = resolveTypeFieldToSchema(own, scope.typeManifestsFor(definition));
87
+ if (schema) return { schema, origin: "instance" };
88
+ }
89
+
90
+ const declared = effectiveContractField(definition, scope.resolveDefinition, direction);
91
+ if (declared === undefined || declared === null) return undefined;
92
+ const declarer = contractDeclarer(definition, scope.resolveDefinition, direction);
93
+ const schema = resolveTypeFieldToSchema(declared, scope.typeManifestsFor(declarer));
94
+ if (!schema) return undefined;
95
+ return { schema, origin: "kind", declaredBy: declarer };
96
+ }
97
+
98
+ /** {@link resolveContract}, falling back to {@link PERMISSIVE_CONTRACT}. For
99
+ * callers that need a schema unconditionally (CEL context typing), as opposed
100
+ * to needing to know whether one was declared. */
101
+ export function resolveContractSchema(
102
+ direction: ContractDirection,
103
+ manifest: Record<string, any> | undefined,
104
+ definition: ResourceDefinition | undefined,
105
+ scope: ContractScope,
106
+ ): Record<string, any> {
107
+ return resolveContract(direction, manifest, definition, scope)?.schema ?? PERMISSIVE_CONTRACT;
108
+ }
109
+
110
+ /**
111
+ * A copy of `schema` with every `x-telo-stream`-marked property removed from
112
+ * `properties` and `required`, for validating a runtime value against.
113
+ *
114
+ * Streams travel in BOTH directions — `Codec.Encoder` marks `input` on its
115
+ * `inputType` and lists it in `required`, and `Record.Stream`, `Ai`, `Tar` and
116
+ * `Console` do the same — so a one-directional skip would walk a live `Stream`
117
+ * with AJV on the hottest path in the runtime. That is the same defect as
118
+ * `stripCompiledValues` walking a live `ResourceInstance` in a ref slot: a live
119
+ * object in a declared slot is not data to be traversed. The annotation already
120
+ * marks exactly the properties to leave alone.
121
+ *
122
+ * Structural (returns a new object, never mutates), and shared so the analyzer
123
+ * and the kernel exempt the same set.
124
+ */
125
+ export function withStreamPropertiesSkipped(
126
+ schema: Record<string, any>,
127
+ /** Resolves a `$ref` to the schema it names. Required to see through the
128
+ * reference form the runtime deliberately KEEPS intact for its validator: a
129
+ * contract written as `{ $ref: "telo:mod/Type" }` has none of its own
130
+ * properties, so a walk that cannot follow the reference exempts nothing and
131
+ * the stream is traversed after all. */
132
+ resolveRef?: (ref: string) => Record<string, any> | undefined,
133
+ ): Record<string, any> {
134
+ return stripStreams(schema, [], resolveRef);
135
+ }
136
+
137
+ function stripStreams(
138
+ node: unknown,
139
+ // A PATH-scoped guard, not a global memo: a schema object reached twice from
140
+ // different parents must be stripped twice (a global `seen` would hand the
141
+ // second parent the unstripped original), while a cycle must still terminate.
142
+ path: readonly object[],
143
+ resolveRef?: (ref: string) => Record<string, any> | undefined,
144
+ ): any {
145
+ if (Array.isArray(node)) {
146
+ let changed = false;
147
+ const items = node.map((item) => {
148
+ const next = stripStreams(item, path, resolveRef);
149
+ if (next !== item) changed = true;
150
+ return next;
151
+ });
152
+ return changed ? items : node;
153
+ }
154
+ if (!node || typeof node !== "object") return node;
155
+ let schema = node as Record<string, any>;
156
+ if (path.includes(schema)) return schema;
157
+
158
+ // Follow a whole-document reference to SEE the annotations behind it, but
159
+ // return the original node when nothing behind it was stripped. Substituting
160
+ // the resolved target unconditionally would break schema identity — the
161
+ // compiled-validator cache is keyed on it — and would move the target out of
162
+ // the document whose `$defs` its own internal `$ref`s resolve against.
163
+ if (resolveRef && typeof schema.$ref === "string") {
164
+ const target = resolveRef(schema.$ref);
165
+ if (target && !path.includes(target)) {
166
+ const stripped = stripStreams(target, [...path, schema], resolveRef);
167
+ if (stripped === target) return node;
168
+ const { $ref: _ref, ...siblings } = schema;
169
+ return Object.keys(siblings).length > 0 ? { ...stripped, ...siblings } : stripped;
170
+ }
171
+ }
172
+ const here = [...path, schema];
173
+
174
+ let out: Record<string, any> = schema;
175
+ const properties = schema.properties as Record<string, any> | undefined;
176
+ if (properties) {
177
+ // A stream can be contributed by an `allOf` branch too (how type inheritance
178
+ // is expressed before the branches are merged), so the marked set is read
179
+ // from the folded view while the removal is applied here.
180
+ const streamed = Object.keys(properties).filter(
181
+ (key) => (properties[key] as Record<string, any> | undefined)?.["x-telo-stream"],
182
+ );
183
+ if (streamed.length > 0) {
184
+ const kept: Record<string, any> = {};
185
+ for (const [key, value] of Object.entries(properties)) {
186
+ if (!streamed.includes(key)) kept[key] = value;
187
+ }
188
+ // The key stays DECLARED but unconstrained, rather than being deleted.
189
+ // Deleting it would force `additionalProperties: false` open, and a closed
190
+ // contract would stop rejecting unknown keys the moment it grew a stream —
191
+ // trading one exemption for a hole across the whole shape.
192
+ for (const key of streamed) kept[key] = {};
193
+ out = { ...schema, properties: kept };
194
+ }
195
+ }
196
+
197
+ // Recurse: a stream one level down (an item, a branch, a nested object) is as
198
+ // live as one at the root, and walking it with AJV is the same defect.
199
+ //
200
+ // `properties` and `$defs` are MAPS of schemas, not schemas — descending into
201
+ // them as if they were would visit nothing, since a map has none of the
202
+ // keywords this walk looks for.
203
+ let changed = out !== schema;
204
+ const result: Record<string, any> = { ...out };
205
+ for (const key of ["properties", "$defs"] as const) {
206
+ const map = out[key] as Record<string, any> | undefined;
207
+ if (!map || typeof map !== "object") continue;
208
+ let mapChanged = false;
209
+ const next: Record<string, any> = {};
210
+ for (const [name, child] of Object.entries(map)) {
211
+ const stripped = stripStreams(child, here, resolveRef);
212
+ if (stripped !== child) mapChanged = true;
213
+ next[name] = stripped;
214
+ }
215
+ if (mapChanged) {
216
+ result[key] = next;
217
+ changed = true;
218
+ }
219
+ }
220
+ for (const key of ["items", "allOf", "anyOf", "oneOf"] as const) {
221
+ const child = out[key];
222
+ if (child === undefined) continue;
223
+ const next = stripStreams(child, here, resolveRef);
224
+ if (next !== child) {
225
+ result[key] = next;
226
+ changed = true;
227
+ }
228
+ }
229
+ return changed ? result : schema;
230
+ }
231
+
232
+ /** Every property path in `schema` that can receive a `default:` — the paths a
233
+ * defaults pass may write to, and therefore exactly how far the caller's inputs
234
+ * must be copied before AJV's `useDefaults` runs. A flat shallow copy would not
235
+ * do: `useDefaults` writes at every level it finds a default, so a nested
236
+ * default would mutate the structure the caller still holds. Bounded by the
237
+ * schema's defaults rather than by the size of the payload. */
238
+ export function defaultBearingPaths(
239
+ schema: Record<string, any>,
240
+ /** See {@link withStreamPropertiesSkipped} — a contract kept in `$ref` form
241
+ * declares its defaults behind the reference, and a walk that cannot follow
242
+ * it would report none, leaving the caller's data shared where a fill lands. */
243
+ resolveRef?: (ref: string) => Record<string, any> | undefined,
244
+ ): string[][] {
245
+ const out: string[][] = [];
246
+
247
+ // Path-scoped, for the same reason as the stream walk: a shared subschema
248
+ // reached from two parents contributes a path under each.
249
+ const walk = (node: unknown, path: string[], chain: readonly object[]): void => {
250
+ if (!node || typeof node !== "object") return;
251
+ let s = node as Record<string, any>;
252
+ if (chain.includes(s)) return;
253
+ if (resolveRef && typeof s.$ref === "string") {
254
+ const target = resolveRef(s.$ref);
255
+ if (!target || chain.includes(target)) return;
256
+ s = { ...target, ...s, $ref: undefined };
257
+ }
258
+ const here = [...chain, node as object];
259
+
260
+ if ("default" in s && path.length > 0) out.push(path);
261
+
262
+ const properties = s.properties as Record<string, any> | undefined;
263
+ if (properties) {
264
+ for (const [key, child] of Object.entries(properties)) walk(child, [...path, key], here);
265
+ }
266
+ for (const branch of ["allOf", "anyOf", "oneOf"] as const) {
267
+ const list = s[branch];
268
+ if (Array.isArray(list)) for (const child of list) walk(child, path, here);
269
+ }
270
+ if (s.items) walk(s.items, [...path, "[]"], here);
271
+ };
272
+
273
+ walk(schema, [], []);
274
+ return out;
275
+ }
@@ -117,6 +117,26 @@ export class Loader {
117
117
  return this.urlToSource.get(url);
118
118
  }
119
119
 
120
+ /** Drop every memo for `url` so the next `loadFile` reads it from the source
121
+ * chain again — the parsed file in each variant, plus every request URL that
122
+ * canonicalised to it (a module reached under several refs must not stay
123
+ * reachable through one of them).
124
+ *
125
+ * `loadFile`'s fast path assumes a file's contents do not change under a
126
+ * single Loader, which holds until something invalidates one deliberately:
127
+ * `telo check` dropping a manifest whose upstream tag has moved, and watch
128
+ * mode when it returns. Without this the only way to un-cache one file is to
129
+ * discard the whole Loader, taking every unrelated file's memo with it. */
130
+ forget(url: string): void {
131
+ const source = this.urlToSource.get(url) ?? url;
132
+ for (const [requestUrl, canonical] of this.urlToSource) {
133
+ if (canonical === source) this.urlToSource.delete(requestUrl);
134
+ }
135
+ for (const variant of CACHE_VARIANTS) {
136
+ this.fileCache.delete(`${variant}:${source}`);
137
+ }
138
+ }
139
+
120
140
  // --- New API: returns LoadedFile / LoadedModule / LoadedGraph ----------
121
141
 
122
142
  /** Read one file via the source chain and parse it into a LoadedFile.