@telorun/analyzer 0.47.0 → 0.49.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 (58) 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 +121 -83
  7. package/dist/artifact-layer-index.d.ts +55 -0
  8. package/dist/artifact-layer-index.d.ts.map +1 -0
  9. package/dist/artifact-layer-index.js +116 -0
  10. package/dist/artifact-selector.d.ts +81 -0
  11. package/dist/artifact-selector.d.ts.map +1 -0
  12. package/dist/artifact-selector.js +122 -0
  13. package/dist/builtins.d.ts.map +1 -1
  14. package/dist/builtins.js +130 -20
  15. package/dist/extends-resolution.d.ts +41 -0
  16. package/dist/extends-resolution.d.ts.map +1 -1
  17. package/dist/extends-resolution.js +68 -0
  18. package/dist/index.d.ts +9 -2
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +5 -1
  21. package/dist/invocation-contract.d.ts +100 -0
  22. package/dist/invocation-contract.d.ts.map +1 -0
  23. package/dist/invocation-contract.js +208 -0
  24. package/dist/schema-compat.d.ts +12 -4
  25. package/dist/schema-compat.d.ts.map +1 -1
  26. package/dist/schema-compat.js +185 -9
  27. package/dist/validate-base-mapping.js +11 -1
  28. package/dist/validate-cel-context.d.ts +0 -6
  29. package/dist/validate-cel-context.d.ts.map +1 -1
  30. package/dist/validate-cel-context.js +51 -4
  31. package/dist/validate-invocation-contract.d.ts +30 -0
  32. package/dist/validate-invocation-contract.d.ts.map +1 -0
  33. package/dist/validate-invocation-contract.js +394 -0
  34. package/dist/validate-module-artifact.d.ts +27 -0
  35. package/dist/validate-module-artifact.d.ts.map +1 -0
  36. package/dist/validate-module-artifact.js +131 -0
  37. package/dist/validate-step-inputs.d.ts +24 -0
  38. package/dist/validate-step-inputs.d.ts.map +1 -0
  39. package/dist/validate-step-inputs.js +87 -0
  40. package/dist/validate-throws-coverage.d.ts +1 -1
  41. package/dist/validate-throws-coverage.d.ts.map +1 -1
  42. package/dist/validate-throws-coverage.js +9 -1
  43. package/package.json +2 -2
  44. package/src/analysis-registry.ts +44 -34
  45. package/src/analyzer.ts +177 -100
  46. package/src/artifact-layer-index.ts +162 -0
  47. package/src/artifact-selector.ts +171 -0
  48. package/src/builtins.ts +135 -20
  49. package/src/extends-resolution.ts +86 -0
  50. package/src/index.ts +38 -1
  51. package/src/invocation-contract.ts +275 -0
  52. package/src/schema-compat.ts +191 -8
  53. package/src/validate-base-mapping.ts +14 -1
  54. package/src/validate-cel-context.ts +49 -4
  55. package/src/validate-invocation-contract.ts +450 -0
  56. package/src/validate-module-artifact.ts +141 -0
  57. package/src/validate-step-inputs.ts +117 -0
  58. package/src/validate-throws-coverage.ts +12 -2
@@ -1,5 +1,6 @@
1
1
  export { extractAccessChains, validateChainAgainstSchema } from "@telorun/templating";
2
2
  import { mergeTypeSchemas } from "@telorun/sdk";
3
+ import { KERNEL_BUILTINS } from "./builtins.js";
3
4
 
4
5
  export interface ContextResolveOpts {
5
6
  /** When provided, used to resolve `x-telo-context-from-root` annotations against the
@@ -23,6 +24,42 @@ export interface ContextResolveOpts {
23
24
  * - Object with `kind` + `schema`: inline type definition → return the `schema`
24
25
  * - Object with `type` or `properties`: raw JSON Schema, return as-is
25
26
  */
27
+ /**
28
+ * Kind names that DECLARE `capability: Telo.Type` — the kernel built-ins plus
29
+ * every definition in scope.
30
+ *
31
+ * Derived from the declared capability, never from the kind's spelling: which
32
+ * kinds are data shapes is a topology fact the analyzer must read off
33
+ * `Telo.Definition` docs, not guess from a name. A name test would silently miss
34
+ * any third-party type kind and would have to be edited every time one is added.
35
+ *
36
+ * Names, not fully-qualified kinds, because a resource writes its kind through
37
+ * whatever alias its file declares (`Type.JsonSchema`, `Telo.JsonSchema`,
38
+ * `Shapes.JsonSchema`) while the definition knows only its own module and name.
39
+ * Memoized per manifest list — this runs on every type-field resolution.
40
+ */
41
+ const typeKindNames = new WeakMap<object, Set<string>>();
42
+
43
+ function typeCapableNames(allManifests: Record<string, any>[]): Set<string> {
44
+ const cached = typeKindNames.get(allManifests);
45
+ if (cached) return cached;
46
+ const names = new Set<string>();
47
+ for (const def of [...KERNEL_BUILTINS, ...allManifests] as Record<string, any>[]) {
48
+ if (def?.kind !== "Telo.Definition" && def?.kind !== "Telo.Abstract") continue;
49
+ if (def.capability !== "Telo.Type") continue;
50
+ const name = def.metadata?.name;
51
+ if (typeof name === "string") names.add(name);
52
+ }
53
+ typeKindNames.set(allManifests, names);
54
+ return names;
55
+ }
56
+
57
+ function isTypeKind(kind: unknown, allManifests: Record<string, any>[]): boolean {
58
+ if (typeof kind !== "string") return false;
59
+ const suffix = kind.slice(kind.lastIndexOf(".") + 1);
60
+ return typeCapableNames(allManifests).has(suffix);
61
+ }
62
+
26
63
  export function resolveTypeFieldToSchema(
27
64
  value: unknown,
28
65
  allManifests: Record<string, any>[],
@@ -37,8 +74,7 @@ export function resolveTypeFieldToSchema(
37
74
  const typeManifest = allManifests.find(
38
75
  (m) =>
39
76
  (m.metadata as any)?.name === value &&
40
- typeof m.kind === "string" &&
41
- /\bType\b/.test(m.kind) &&
77
+ isTypeKind(m.kind, allManifests) &&
42
78
  typeof m.schema === "object" &&
43
79
  m.schema !== null,
44
80
  );
@@ -248,11 +284,20 @@ export function resolveContextAnnotations(
248
284
 
249
285
  const from = schema["x-telo-context-from"] as string | undefined;
250
286
  if (from) {
251
- const resolved = navigatePath(manifestItem, from.split("/")) as Record<string, any> | undefined;
252
- // `resolved` is a map of property names → sub-schemas (e.g. { query: {...}, body: {...} })
287
+ const navigated = navigatePath(manifestItem, from.split("/")) as Record<string, any> | undefined;
288
+ // The navigated value is either a plain map of property names → sub-schemas
289
+ // (a transport scope's `request/schema` → `{ query, body, params }`), or a
290
+ // `telo#Type` field naming one contract (`inputType:`). The second form has
291
+ // to be resolved first: the standard library writes it as the inline
292
+ // `{ kind: Type.JsonSchema, schema: … }` wrapper, so merging it verbatim
293
+ // would type the variable as `{ kind, schema }` instead of its properties.
294
+ const asType = resolveTypeFieldToSchema(navigated, allManifests ?? []);
295
+ const resolved = asType?.properties ?? navigated;
296
+ const required = Array.isArray(asType?.required) ? asType.required : undefined;
253
297
  return {
254
298
  ...schema,
255
299
  properties: { ...(schema.properties ?? {}), ...(resolved ?? {}) },
300
+ ...(required ? { required } : {}),
256
301
  additionalProperties: false,
257
302
  };
258
303
  }
@@ -0,0 +1,450 @@
1
+ import type { ResourceDefinition, ResourceManifest } from "@telorun/sdk";
2
+ import type { AliasResolver } from "./alias-resolver.js";
3
+ import type { DefinitionRegistry } from "./definition-registry.js";
4
+ import {
5
+ type ContractDirection,
6
+ type DefResolver,
7
+ effectiveContractField,
8
+ mappingFieldFor,
9
+ needsContractMapping,
10
+ } from "./extends-resolution.js";
11
+ import { buildReferenceFieldMap, isRefEntry } from "./reference-field-map.js";
12
+ import { DiagnosticSeverity, type AnalysisDiagnostic } from "./types.js";
13
+
14
+ const SOURCE = "telo-analyzer";
15
+
16
+ /**
17
+ * Phase 3 — static checks on declared invocation contracts.
18
+ *
19
+ * The runtime binds a contract to every instance and enforces it at dispatch;
20
+ * these are the failures worth catching before anything runs, and the ones the
21
+ * runtime cannot see at all (a declaration that is inert, an input nobody can
22
+ * supply).
23
+ *
24
+ * Diagnostics:
25
+ * - CONTRACT_MISSING_MAPPING: a definition that inherits its controller declares
26
+ * its own `inputType` / `outputType` without the `inputs:` / `result:` mapping
27
+ * that bridges it back to the inherited controller.
28
+ * - CONTRACT_INPUTS_SCHEMA_FORM: a leftover `inputs:` property map on a kind
29
+ * whose input contract is now `inputType:`.
30
+ * - CONTRACT_TYPE_NOT_FOUND: a contract names a type that is not declared in
31
+ * scope, so every call through it would fail at dispatch.
32
+ *
33
+ * Deliberately NOT diagnosed: an input that is neither `required:` nor
34
+ * defaulted. It is indistinguishable from a genuinely optional one — `Ai.Text`
35
+ * takes `prompt` OR `messages`, and `system` is optional on purpose — so the
36
+ * check fired ~40 times across the standard library on correct manifests with no
37
+ * way for an author to record the intent. A warning that cannot be silenced on
38
+ * correct code teaches people to ignore warnings.
39
+ */
40
+ export function validateInvocationContract(
41
+ manifests: ResourceManifest[],
42
+ registry: DefinitionRegistry,
43
+ aliases: AliasResolver,
44
+ aliasesByModule: Map<string, AliasResolver> = new Map(),
45
+ ): AnalysisDiagnostic[] {
46
+ const diagnostics: AnalysisDiagnostic[] = [];
47
+ const resolveDef: DefResolver = (kind, from) => {
48
+ const module = (from?.metadata as { module?: string } | undefined)?.module;
49
+ const scope = (module ? aliasesByModule.get(module) : undefined) ?? aliases;
50
+ return registry.resolve(kind) ?? registry.resolve(scope.resolveKind(kind) ?? kind);
51
+ };
52
+
53
+ // A published dependency's declarations are not the consumer's to fix.
54
+ const importedModules = new Set<string>();
55
+ for (const m of manifests) {
56
+ if (m.kind !== "Telo.Import") continue;
57
+ const resolved = (m.metadata as { resolvedModuleName?: string } | undefined)?.resolvedModuleName;
58
+ if (resolved) importedModules.add(resolved);
59
+ }
60
+ const isOwn = (m: ResourceManifest): boolean => {
61
+ const ownModule = (m.metadata as { module?: string } | undefined)?.module;
62
+ return !ownModule || !importedModules.has(ownModule);
63
+ };
64
+
65
+ for (const m of manifests) {
66
+ if (!isOwn(m)) continue;
67
+ const name = m.metadata?.name as string | undefined;
68
+ if (!name) continue;
69
+ const filePath = (m.metadata as { source?: string } | undefined)?.source;
70
+ const resource = { kind: m.kind, name };
71
+ const md = m as unknown as Record<string, unknown>;
72
+
73
+ if (m.kind === "Telo.Definition" || m.kind === "Telo.Abstract") {
74
+ checkMappingRequired(m, resource, filePath, resolveDef, diagnostics);
75
+ checkContractResolves(m, md, manifests, resource, filePath, diagnostics);
76
+ continue;
77
+ }
78
+
79
+ // A RESOURCE (an instance of some kind) — a leftover `inputs:` map is only
80
+ // meaningful against a kind whose schema no longer declares one.
81
+ const definition = resolveDef(m.kind, m as unknown as ResourceDefinition);
82
+ if (!definition) continue;
83
+ checkContractResolves(m, md, manifests, resource, filePath, diagnostics);
84
+ checkLeftoverInputsSchema(m, definition, md, resource, filePath, diagnostics);
85
+ checkRefSlotWiring(m, definition, manifests, resolveDef, resource, filePath, diagnostics);
86
+ }
87
+
88
+ return diagnostics;
89
+ }
90
+
91
+ /**
92
+ * The wiring rule: whether a ref slot may hold a resource whose input contract
93
+ * differs from the slot's declared kind.
94
+ *
95
+ * `extends` decides which resources a slot ACCEPTS; it never carried the
96
+ * dispatch contract. What matters per slot is whether the caller can supply the
97
+ * target's arguments at all:
98
+ *
99
+ * - the slot's declared kind declares no `inputType` and is not a run site →
100
+ * nothing to violate, accept;
101
+ * - the wiring site takes a paired author `inputs:` → the author supplies the
102
+ * arguments and can see both sides, so the call site check covers it;
103
+ * - the consumer's controller builds the arguments and knows only the slot's
104
+ * kind → the wired resource must not require anything that kind does not
105
+ * declare, because nothing could ever supply it.
106
+ *
107
+ * The run-site case is the same rule with an empty argument set: a `run()`
108
+ * dispatch passes nothing at all, so a target requiring any input can never be
109
+ * satisfied there. Both are keyed on declared capability and declared contracts,
110
+ * never on a kind's name.
111
+ */
112
+ function checkRefSlotWiring(
113
+ m: ResourceManifest,
114
+ definition: ResourceDefinition,
115
+ manifests: ResourceManifest[],
116
+ resolveDef: DefResolver,
117
+ resource: { kind: string; name: string },
118
+ filePath: string | undefined,
119
+ diagnostics: AnalysisDiagnostic[],
120
+ ): void {
121
+ const schema = definition.schema as Record<string, any> | undefined;
122
+ if (!schema) return;
123
+
124
+ for (const [path, entry] of buildReferenceFieldMap(schema)) {
125
+ if (!isRefEntry(entry)) continue;
126
+ // A slot that takes a paired `inputs:` is the author's to fill; its values
127
+ // are checked at the call site instead, against the target's own contract.
128
+ if (slotTakesPairedInputs(schema, path)) continue;
129
+
130
+ const slotDeclares = slotDeclaredInputs(entry.refs, resolveDef, manifests);
131
+ const runSite = isRunOnlySlot(entry.refs, resolveDef);
132
+ if (!runSite && slotDeclares === undefined) continue;
133
+
134
+ const ownModule = (m.metadata as { module?: string } | undefined)?.module;
135
+ for (const name of refValuesAt(m as Record<string, any>, path)) {
136
+ // Scoped to the declaring module: a resource of the same name in another
137
+ // module is a different resource, and checking against its contract would
138
+ // report on something the author never wired.
139
+ const target = findInModule(manifests, name, ownModule);
140
+ if (!target) continue;
141
+ const targetDef = resolveDef(target.kind, target as unknown as ResourceDefinition);
142
+ const targetRequired = requiredInputsOf(
143
+ contractSchemaFor(target, targetDef, resolveDef, manifests),
144
+ );
145
+ if (!targetRequired || targetRequired.length === 0) continue;
146
+
147
+ const unsatisfiable = runSite
148
+ ? targetRequired
149
+ : targetRequired.filter((key) => !(slotDeclares ?? []).includes(key));
150
+ if (unsatisfiable.length === 0) continue;
151
+
152
+ diagnostics.push({
153
+ severity: DiagnosticSeverity.Error,
154
+ code: runSite ? "CONTRACT_INPUTS_AT_RUN_SITE" : "CONTRACT_SLOT_INPUTS_UNSATISFIABLE",
155
+ source: SOURCE,
156
+ message: runSite
157
+ ? `${m.kind}/${resource.name}: '${name}' is wired at '${path}', which starts it with \`run()\` — ` +
158
+ `a dispatch that passes no arguments — but its contract requires ${list(unsatisfiable)}. ` +
159
+ `Nothing can supply them there. Invoke it from a step instead, or drop the requirement.`
160
+ : `${m.kind}/${resource.name}: '${name}' is wired at '${path}', where the consumer builds the ` +
161
+ `arguments from the slot's declared kind alone, but its contract requires ${list(unsatisfiable)} ` +
162
+ `which that kind does not declare. Nothing could supply them.`,
163
+ data: { resource, filePath, path },
164
+ });
165
+ }
166
+ }
167
+ }
168
+
169
+ const list = (keys: string[]): string => keys.map((k) => `'${k}'`).join(", ");
170
+
171
+ /** The inputs a consumer can supply knowing only the slot — the UNION of every
172
+ * accepted kind's declared inputs.
173
+ *
174
+ * Union rather than the first match: a slot accepting several kinds may see any
175
+ * of them, and a name any accepted kind declares is one a consumer could
176
+ * plausibly supply. Taking the first kind's contract would make the check
177
+ * depend on the order `anyOf` branches happen to be written in. Undefined when
178
+ * no accepted kind declares a contract at all — the "nothing to violate" case. */
179
+ function slotDeclaredInputs(
180
+ refs: string[],
181
+ resolveDef: DefResolver,
182
+ manifests: ResourceManifest[],
183
+ ): string[] | undefined {
184
+ let seen: Set<string> | undefined;
185
+ for (const ref of refs) {
186
+ const def = resolveDef(ref);
187
+ if (!def) continue;
188
+ const schema = contractSchemaFor(undefined, def, resolveDef, manifests);
189
+ if (!schema) continue;
190
+ seen ??= new Set<string>();
191
+ for (const key of Object.keys((schema.properties ?? {}) as Record<string, unknown>)) {
192
+ seen.add(key);
193
+ }
194
+ }
195
+ return seen ? [...seen] : undefined;
196
+ }
197
+
198
+ /** True when every kind a slot accepts is started rather than invoked — the
199
+ * capabilities whose dispatch verb is `run()`, which passes no arguments.
200
+ * Keyed on the declared capability, so a user-defined abstract resolves the
201
+ * same way a built-in does. */
202
+ function isRunOnlySlot(refs: string[], resolveDef: DefResolver): boolean {
203
+ if (refs.length === 0) return false;
204
+ return refs.every((ref) => {
205
+ const def = resolveDef(ref);
206
+ const capability = def?.capability ?? ref;
207
+ return capability === "Telo.Runnable" || capability === "Telo.Service";
208
+ });
209
+ }
210
+
211
+ /** The resolved contract schema for a target: its own declaration first, then
212
+ * the nearest along `extends`. Inline schemas only — a named reference resolves
213
+ * through machinery this pass does not carry, and half-resolving would be worse
214
+ * than not reporting. */
215
+ function contractSchemaFor(
216
+ manifest: ResourceManifest | undefined,
217
+ definition: ResourceDefinition | undefined,
218
+ resolveDef: DefResolver,
219
+ manifests: ResourceManifest[],
220
+ direction: ContractDirection = "inputType",
221
+ ): Record<string, any> | undefined {
222
+ const declaringModule = ((manifest ?? definition)?.metadata as { module?: string } | undefined)
223
+ ?.module;
224
+ const own = manifest ? (manifest as unknown as Record<string, unknown>)[direction] : undefined;
225
+ const declared =
226
+ own !== undefined && own !== null
227
+ ? own
228
+ : effectiveContractField(definition, resolveDef, direction);
229
+ const inline = inlineSchemaOf(declared);
230
+ if (inline) return inline;
231
+ // A `!ref` / bare name: resolve it to the named type resource in scope.
232
+ const named =
233
+ typeof declared === "string"
234
+ ? declared
235
+ : declared && typeof declared === "object" && typeof (declared as any).name === "string"
236
+ ? (declared as any).name
237
+ : undefined;
238
+ if (!named) return undefined;
239
+ const typeManifest = findInModule(manifests, named, declaringModule);
240
+ return typeManifest ? inlineSchemaOf(typeManifest) : undefined;
241
+ }
242
+
243
+ /** The input names a contract makes mandatory. Undefined means "no contract
244
+ * declared" — distinct from an empty list, which means "declared, requires
245
+ * nothing". */
246
+ function requiredInputsOf(schema: Record<string, any> | undefined): string[] | undefined {
247
+ if (!schema) return undefined;
248
+ return Array.isArray(schema.required) ? (schema.required as string[]) : [];
249
+ }
250
+
251
+ /** Whether the object containing this ref slot also declares an inputs field —
252
+ * the `invoke`/`inputs` pairing, recognised through the topology role rather
253
+ * than a field name, so a composer spelling it differently still counts. */
254
+ function slotTakesPairedInputs(schema: Record<string, any>, path: string): boolean {
255
+ const parentPath = path.slice(0, Math.max(0, path.lastIndexOf(".")));
256
+ const parent = parentPath ? navigateSchema(schema, parentPath) : schema;
257
+ const properties = (parent?.properties ?? {}) as Record<string, Record<string, any>>;
258
+ return Object.values(properties).some((p) => p?.["x-telo-topology-role"] === "inputs");
259
+ }
260
+
261
+ /** Follow a field-map path (`a.b[].c`) through a schema's properties/items. */
262
+ function navigateSchema(
263
+ schema: Record<string, any>,
264
+ path: string,
265
+ ): Record<string, any> | undefined {
266
+ let node: Record<string, any> | undefined = schema;
267
+ for (const raw of path.split(".")) {
268
+ if (!node) return undefined;
269
+ const key = raw.replace(/\[\]|\{\}/g, "");
270
+ let next = (node.properties ?? {})[key] as Record<string, any> | undefined;
271
+ if (!next) return undefined;
272
+ if (raw.includes("[]")) next = (next.items ?? {}) as Record<string, any>;
273
+ node = next;
274
+ }
275
+ return node;
276
+ }
277
+
278
+ /** The `{kind, name}` references actually written at a field-map path. */
279
+ function refValuesAt(manifest: Record<string, any>, path: string): string[] {
280
+ const out: string[] = [];
281
+ const walk = (node: unknown, segments: string[]): void => {
282
+ if (node == null) return;
283
+ if (segments.length === 0) {
284
+ const items = Array.isArray(node) ? node : [node];
285
+ for (const item of items) {
286
+ // A reference is `{kind, name}` — BOTH fields. Requiring only `name`
287
+ // would read an inline invoke step (`{ name, invoke, inputs }`) as a
288
+ // reference to a resource called after the step, which it is not: that
289
+ // `name` labels the step, and the step is an invoke site anyway.
290
+ if (
291
+ item &&
292
+ typeof item === "object" &&
293
+ typeof (item as any).name === "string" &&
294
+ typeof (item as any).kind === "string"
295
+ ) {
296
+ out.push((item as any).name);
297
+ }
298
+ }
299
+ return;
300
+ }
301
+ const [head, ...rest] = segments;
302
+ const key = head!.replace(/\[\]|\{\}/g, "");
303
+ const value = (node as Record<string, any>)[key];
304
+ if (head!.includes("[]") && Array.isArray(value)) {
305
+ for (const item of value) walk(item, rest);
306
+ } else if (head!.includes("{}") && value && typeof value === "object") {
307
+ for (const item of Object.values(value)) walk(item, rest);
308
+ } else {
309
+ walk(value, rest);
310
+ }
311
+ };
312
+ walk(manifest, path.split("."));
313
+ return out;
314
+ }
315
+
316
+ /**
317
+ * A named contract must name a type that exists.
318
+ *
319
+ * The runtime raises `ERR_CONTRACT_UNRESOLVABLE` on the first dispatch through
320
+ * an unresolvable contract, which is exactly the failure a checker should have
321
+ * caught: nothing about it depends on runtime values. Instance-level slots were
322
+ * already covered, because a module kind declares its `inputType` property with
323
+ * `x-telo-ref: Telo.Type`; the KIND-level fields are on `Telo.Definition`, which
324
+ * is deliberately excluded from reference validation, so they had no check at
325
+ * all and the same typo behaved differently depending on where it was written.
326
+ */
327
+ function checkContractResolves(
328
+ m: ResourceManifest,
329
+ md: Record<string, unknown>,
330
+ manifests: ResourceManifest[],
331
+ resource: { kind: string; name: string },
332
+ filePath: string | undefined,
333
+ diagnostics: AnalysisDiagnostic[],
334
+ ): void {
335
+ const declaringModule = (m.metadata as { module?: string } | undefined)?.module;
336
+ for (const direction of ["inputType", "outputType"] as ContractDirection[]) {
337
+ const named = namedTypeReference(md[direction]);
338
+ if (!named) continue;
339
+ if (findInModule(manifests, named, declaringModule)) continue;
340
+ diagnostics.push({
341
+ severity: DiagnosticSeverity.Error,
342
+ code: "CONTRACT_TYPE_NOT_FOUND",
343
+ source: SOURCE,
344
+ message:
345
+ `${m.kind}/${resource.name}: \`${direction}\` names the type '${named}', which is not declared ` +
346
+ `in scope. The contract cannot be enforced, so every call through it would fail at dispatch. ` +
347
+ `Declare a \`Telo.JsonSchema\` with that name, or inline the shape.`,
348
+ data: { resource, filePath, path: direction },
349
+ });
350
+ }
351
+ }
352
+
353
+ /** The name a contract field references, when it is a reference at all. An
354
+ * inline shape or a raw schema names nothing and resolves on its own. */
355
+ function namedTypeReference(value: unknown): string | undefined {
356
+ if (typeof value === "string") return value;
357
+ if (!value || typeof value !== "object") return undefined;
358
+ const ref = value as Record<string, unknown>;
359
+ if (ref.schema && typeof ref.schema === "object") return undefined;
360
+ return typeof ref.name === "string" ? ref.name : undefined;
361
+ }
362
+
363
+ /** A child that inherits its controller and REPLACES a contract must bridge it:
364
+ * contracts resolve to the nearest declaration and never merge, so the
365
+ * inherited controller only understands its own shape. Without the mapping the
366
+ * declaration is inert — precisely the silent no-op this rule exists to end. */
367
+ function checkMappingRequired(
368
+ m: ResourceManifest,
369
+ resource: { kind: string; name: string },
370
+ filePath: string | undefined,
371
+ resolveDef: DefResolver,
372
+ diagnostics: AnalysisDiagnostic[],
373
+ ): void {
374
+ const def = m as unknown as ResourceDefinition;
375
+ const body = m as unknown as Record<string, unknown>;
376
+ for (const direction of ["inputType", "outputType"] as ContractDirection[]) {
377
+ if (!needsContractMapping(def, resolveDef, direction)) continue;
378
+ const mappingField = mappingFieldFor(direction);
379
+ if (body[mappingField] != null) continue;
380
+ diagnostics.push({
381
+ severity: DiagnosticSeverity.Error,
382
+ code: "CONTRACT_MISSING_MAPPING",
383
+ source: SOURCE,
384
+ message:
385
+ `${m.kind}/${resource.name}: declares its own \`${direction}\` but inherits its controller, ` +
386
+ `and no \`${mappingField}:\` mapping bridges the two. The inherited controller only understands ` +
387
+ `the kind it came from, so without a mapping the declaration would never be applied. Add a ` +
388
+ `\`${mappingField}:\` mapping, or drop \`${direction}\` to inherit the contract unchanged.`,
389
+ data: { resource, filePath, path: direction },
390
+ });
391
+ }
392
+ }
393
+
394
+ /** `inputs:` on a resource once meant "a JSON Schema property map" on the run
395
+ * kinds. It is values everywhere else, and now means values everywhere — so a
396
+ * leftover map against a kind that declares no `inputs` property is a migration
397
+ * the author has not finished, not an unknown field. */
398
+ function checkLeftoverInputsSchema(
399
+ m: ResourceManifest,
400
+ definition: ResourceDefinition,
401
+ md: Record<string, unknown>,
402
+ resource: { kind: string; name: string },
403
+ filePath: string | undefined,
404
+ diagnostics: AnalysisDiagnostic[],
405
+ ): void {
406
+ const value = md.inputs;
407
+ if (!value || typeof value !== "object" || Array.isArray(value)) return;
408
+ const properties = (definition.schema?.properties ?? {}) as Record<string, unknown>;
409
+ if ("inputs" in properties) return;
410
+ if (!("inputType" in properties)) return;
411
+ diagnostics.push({
412
+ severity: DiagnosticSeverity.Error,
413
+ code: "CONTRACT_INPUTS_SCHEMA_FORM",
414
+ source: SOURCE,
415
+ message:
416
+ `${m.kind}/${resource.name}: \`inputs:\` no longer declares an input contract — it always means ` +
417
+ `values now. Move the property map to \`inputType:\` (a \`Telo.JsonSchema\` shape, a named type ` +
418
+ `reference, or an inline schema).`,
419
+ data: { resource, filePath, path: "inputs" },
420
+ });
421
+ }
422
+
423
+ /** The schema behind an INLINE type declaration, which is all this pass can read
424
+ * without a manifest lookup. A named reference resolves elsewhere; skipping it
425
+ * here keeps the check total rather than half-informed. */
426
+ function inlineSchemaOf(value: unknown): Record<string, any> | undefined {
427
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
428
+ const obj = value as Record<string, any>;
429
+ if (obj.schema && typeof obj.schema === "object") return obj.schema;
430
+ if (obj.properties && typeof obj.properties === "object") return obj;
431
+ return undefined;
432
+ }
433
+
434
+ /** Find a manifest by name within a module, falling back to a unique global
435
+ * match. Names are unique per module, not per flattened graph, so an unscoped
436
+ * `find` can silently return another module's resource; an ambiguous global
437
+ * match resolves to nothing rather than to a guess. */
438
+ function findInModule(
439
+ manifests: ResourceManifest[],
440
+ name: string,
441
+ module: string | undefined,
442
+ ): ResourceManifest | undefined {
443
+ const byName = manifests.filter((t) => (t.metadata as any)?.name === name);
444
+ if (byName.length === 0) return undefined;
445
+ const scoped = byName.filter(
446
+ (t) => (t.metadata as { module?: string } | undefined)?.module === module,
447
+ );
448
+ if (scoped.length === 1) return scoped[0];
449
+ return byName.length === 1 ? byName[0] : undefined;
450
+ }
@@ -0,0 +1,141 @@
1
+ import type { ResourceManifest } from "@telorun/sdk";
2
+
3
+ import { parseLayerIndex, LayerIndexError } from "./artifact-layer-index.js";
4
+ import {
5
+ ArtifactSelectorError,
6
+ PLATFORM_AXES,
7
+ selectorFromQualifiers,
8
+ } from "./artifact-selector.js";
9
+ import { DiagnosticSeverity, type AnalysisDiagnostic } from "./types.js";
10
+
11
+ const SOURCE = "telo-analyzer";
12
+
13
+ /**
14
+ * Static validation of the module-artifact surface — `kernel/specs/module-artifact.md`.
15
+ *
16
+ * Everything here is decidable from the manifest text alone, and every case would
17
+ * otherwise surface on a *consumer's* machine at controller-resolve time (or, worse,
18
+ * not at all). That is the whole argument: an author who mistypes a platform axis
19
+ * gets a platform-neutral candidate, publish emits one layer, and every host
20
+ * happily loads a binary built for one architecture — silently, forever.
21
+ *
22
+ * Two checks. Note that several candidates *sharing* one selector is not among
23
+ * them: a controller layer holds the entry points of every candidate with that
24
+ * selector (spec §1), which is what every module with two `js` controllers relies
25
+ * on.
26
+ *
27
+ * 1. **Controller selector qualifiers.** `os` / `arch` / `libc` / `siblings` are
28
+ * authored surface. An unknown qualifier is reported rather than ignored, since
29
+ * ignoring is what makes a typo invisible; an invalid value is reported here
30
+ * instead of throwing from the loader later.
31
+ * 2. **The published layer index.** The owner doc's JSON Schema covers shape; the
32
+ * semantic rules — controller-requires-selector, singletons carry none, no
33
+ * duplicate selector, the token grammar (`os: Linux` passes the schema and
34
+ * throws at runtime) — live in the parser, so run it.
35
+ */
36
+ export function validateModuleArtifact(manifests: ResourceManifest[]): AnalysisDiagnostic[] {
37
+ const out: AnalysisDiagnostic[] = [];
38
+ for (const manifest of manifests) {
39
+ validateLayerIndex(manifest, out);
40
+ validateControllerSelectors(manifest, out);
41
+ }
42
+ return out;
43
+ }
44
+
45
+ const KNOWN_QUALIFIERS = new Set<string>(["path", "siblings", ...PLATFORM_AXES]);
46
+
47
+ /** `pkg:telo/local/<format>?…` — the bundled-controller delivery mode. Parsed by
48
+ * hand rather than with a PURL library: the analyzer must stay browser-safe and
49
+ * dependency-light, and the only thing needed here is the qualifier map. */
50
+ function parseBundledPurl(
51
+ purl: string,
52
+ ): { format: string; qualifiers: Record<string, string> } | null {
53
+ if (!purl.startsWith("pkg:telo/local/")) return null;
54
+ const withoutFragment = purl.split("#")[0];
55
+ const [head, query = ""] = withoutFragment.split("?");
56
+ const format = head.slice("pkg:telo/local/".length);
57
+ if (format === "") return null;
58
+ const qualifiers: Record<string, string> = {};
59
+ for (const pair of query.split("&")) {
60
+ if (pair === "") continue;
61
+ const eq = pair.indexOf("=");
62
+ if (eq < 0) continue;
63
+ qualifiers[decodeURIComponent(pair.slice(0, eq))] = decodeURIComponent(pair.slice(eq + 1));
64
+ }
65
+ return { format, qualifiers };
66
+ }
67
+
68
+ function validateControllerSelectors(
69
+ manifest: ResourceManifest,
70
+ out: AnalysisDiagnostic[],
71
+ ): void {
72
+ const controllers = (manifest as { controllers?: unknown }).controllers;
73
+ if (!Array.isArray(controllers)) return;
74
+ const metadata = manifest.metadata as
75
+ | { name?: string; module?: string; source?: string }
76
+ | undefined;
77
+ const name = metadata?.name;
78
+ const filePath = metadata?.source;
79
+ const resource = { kind: manifest.kind, name };
80
+
81
+ controllers.forEach((candidate, index) => {
82
+ if (typeof candidate !== "string") return;
83
+ const parsed = parseBundledPurl(candidate);
84
+ if (!parsed) return;
85
+ const at = `controllers[${index}]`;
86
+
87
+ const unknown = Object.keys(parsed.qualifiers).filter((k) => !KNOWN_QUALIFIERS.has(k));
88
+ for (const key of unknown) {
89
+ out.push({
90
+ severity: DiagnosticSeverity.Error,
91
+ code: "CONTROLLER_UNKNOWN_QUALIFIER",
92
+ source: SOURCE,
93
+ message:
94
+ `${manifest.kind}/${name ?? "(unnamed)"}: bundled controller qualifier '${key}' is not ` +
95
+ `recognized. Known qualifiers: ${[...KNOWN_QUALIFIERS].sort().join(", ")}. An ` +
96
+ `unrecognized platform axis is ignored, which would make this candidate ` +
97
+ `platform-neutral and offer a single-platform binary to every host.`,
98
+ data: { resource, filePath, path: `${at}?${key}` },
99
+ });
100
+ }
101
+
102
+ // Validate the selector; the value is not otherwise needed here, since
103
+ // candidates sharing a selector legitimately share a layer.
104
+ try {
105
+ selectorFromQualifiers(parsed.format, parsed.qualifiers, candidate);
106
+ } catch (err) {
107
+ if (!(err instanceof ArtifactSelectorError)) throw err;
108
+ out.push({
109
+ severity: DiagnosticSeverity.Error,
110
+ code: "CONTROLLER_INVALID_SELECTOR",
111
+ source: SOURCE,
112
+ message: `${manifest.kind}/${name ?? "(unnamed)"}: ${err.message}`,
113
+ data: { resource, filePath, path: at },
114
+ });
115
+ }
116
+ });
117
+ }
118
+
119
+ function validateLayerIndex(manifest: ResourceManifest, out: AnalysisDiagnostic[]): void {
120
+ if (manifest.kind !== "Telo.Application" && manifest.kind !== "Telo.Library") return;
121
+ const layers = (manifest as { layers?: unknown }).layers;
122
+ if (layers === undefined) return;
123
+ const metadata = manifest.metadata as { name?: string; source?: string } | undefined;
124
+ const name = metadata?.name;
125
+ try {
126
+ parseLayerIndex(layers);
127
+ } catch (err) {
128
+ if (!(err instanceof LayerIndexError) && !(err instanceof ArtifactSelectorError)) throw err;
129
+ out.push({
130
+ severity: DiagnosticSeverity.Error,
131
+ code: "INVALID_LAYER_INDEX",
132
+ source: SOURCE,
133
+ message: `${manifest.kind}/${name ?? "(unnamed)"}: ${err.message}`,
134
+ data: {
135
+ resource: { kind: manifest.kind, name },
136
+ filePath: metadata?.source,
137
+ path: "layers",
138
+ },
139
+ });
140
+ }
141
+ }