@telorun/analyzer 0.60.0 → 0.62.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 (88) hide show
  1. package/dist/analyzer.d.ts.map +1 -1
  2. package/dist/analyzer.js +130 -9
  3. package/dist/artifact-layer-index.d.ts +13 -5
  4. package/dist/artifact-layer-index.d.ts.map +1 -1
  5. package/dist/artifact-layer-index.js +37 -13
  6. package/dist/artifact-selector.d.ts +10 -3
  7. package/dist/artifact-selector.d.ts.map +1 -1
  8. package/dist/artifact-selector.js +10 -1
  9. package/dist/builtins.d.ts.map +1 -1
  10. package/dist/builtins.js +97 -13
  11. package/dist/cel-bindings.d.ts +0 -6
  12. package/dist/cel-bindings.d.ts.map +1 -1
  13. package/dist/cel-bindings.js +3 -28
  14. package/dist/definition-registry.d.ts +17 -0
  15. package/dist/definition-registry.d.ts.map +1 -1
  16. package/dist/definition-registry.js +31 -2
  17. package/dist/identifier-name.d.ts +114 -0
  18. package/dist/identifier-name.d.ts.map +1 -0
  19. package/dist/identifier-name.js +183 -0
  20. package/dist/index.d.ts +14 -2
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +11 -2
  23. package/dist/manifest-schemas.d.ts +81 -0
  24. package/dist/manifest-schemas.d.ts.map +1 -1
  25. package/dist/manifest-schemas.js +208 -6
  26. package/dist/module-file-claims.d.ts +7 -0
  27. package/dist/module-file-claims.d.ts.map +1 -1
  28. package/dist/module-file-claims.js +22 -2
  29. package/dist/module-library.d.ts +97 -0
  30. package/dist/module-library.d.ts.map +1 -0
  31. package/dist/module-library.js +162 -0
  32. package/dist/requires-block.d.ts +125 -0
  33. package/dist/requires-block.d.ts.map +1 -0
  34. package/dist/requires-block.js +182 -0
  35. package/dist/schema-keywords.d.ts +68 -0
  36. package/dist/schema-keywords.d.ts.map +1 -0
  37. package/dist/schema-keywords.js +324 -0
  38. package/dist/schema-region.d.ts +12 -1
  39. package/dist/schema-region.d.ts.map +1 -1
  40. package/dist/schema-region.js +12 -1
  41. package/dist/telo-version.d.ts +3 -0
  42. package/dist/telo-version.d.ts.map +1 -0
  43. package/dist/telo-version.js +8 -0
  44. package/dist/types.d.ts +31 -0
  45. package/dist/types.d.ts.map +1 -1
  46. package/dist/validate-identifier-names.d.ts +31 -0
  47. package/dist/validate-identifier-names.d.ts.map +1 -0
  48. package/dist/validate-identifier-names.js +144 -0
  49. package/dist/validate-module-artifact.d.ts.map +1 -1
  50. package/dist/validate-module-artifact.js +53 -1
  51. package/dist/validate-observed-state.d.ts +9 -2
  52. package/dist/validate-observed-state.d.ts.map +1 -1
  53. package/dist/validate-observed-state.js +9 -2
  54. package/dist/validate-references.d.ts.map +1 -1
  55. package/dist/validate-references.js +5 -26
  56. package/dist/validate-requires.d.ts +49 -0
  57. package/dist/validate-requires.d.ts.map +1 -0
  58. package/dist/validate-requires.js +99 -0
  59. package/dist/value-type-keyword.d.ts +1 -1
  60. package/dist/value-type-keyword.d.ts.map +1 -1
  61. package/dist/value-type-keyword.js +1 -0
  62. package/dist/version-range.d.ts +88 -0
  63. package/dist/version-range.d.ts.map +1 -0
  64. package/dist/version-range.js +173 -0
  65. package/package.json +2 -2
  66. package/src/analyzer.ts +146 -10
  67. package/src/artifact-layer-index.ts +47 -14
  68. package/src/artifact-selector.ts +15 -4
  69. package/src/builtins.ts +102 -13
  70. package/src/cel-bindings.ts +3 -28
  71. package/src/definition-registry.ts +30 -2
  72. package/src/identifier-name.ts +228 -0
  73. package/src/index.ts +44 -1
  74. package/src/manifest-schemas.ts +223 -4
  75. package/src/module-file-claims.ts +32 -2
  76. package/src/module-library.ts +208 -0
  77. package/src/requires-block.ts +253 -0
  78. package/src/schema-keywords.ts +359 -0
  79. package/src/schema-region.ts +12 -1
  80. package/src/telo-version.ts +9 -0
  81. package/src/types.ts +32 -0
  82. package/src/validate-identifier-names.ts +173 -0
  83. package/src/validate-module-artifact.ts +56 -0
  84. package/src/validate-observed-state.ts +9 -2
  85. package/src/validate-references.ts +5 -26
  86. package/src/validate-requires.ts +129 -0
  87. package/src/value-type-keyword.ts +1 -0
  88. package/src/version-range.ts +238 -0
package/src/analyzer.ts CHANGED
@@ -8,7 +8,7 @@ import {
8
8
  plainChainOf,
9
9
  type CelSurface,
10
10
  } from "@telorun/templating";
11
- import type { DiagnosticFix } from "./types.js";
11
+ import type { DiagnosticData, DiagnosticFix } from "./types.js";
12
12
  import {
13
13
  AliasResolver,
14
14
  moduleScopedDefResolver,
@@ -89,17 +89,19 @@ import {
89
89
  BINDINGS_ANNOTATION,
90
90
  bindingContextProperties,
91
91
  bindingPathChain,
92
- CEL_RESERVED_WORDS,
93
92
  findBindingSites,
94
93
  resolveBindingOrder,
95
94
  schemaAtChain,
96
95
  type BindingSites,
97
96
  } from "./cel-bindings.js";
97
+ import { CEL_RESERVED_WORDS, checkName } from "./identifier-name.js";
98
+ import { validateIdentifierNames } from "./validate-identifier-names.js";
98
99
  import { validateExtends } from "./validate-extends.js";
99
100
  import { validateLogging } from "./validate-logging.js";
100
101
  import { validateModuleArtifact } from "./validate-module-artifact.js";
101
102
  import { validateIncludePlacement } from "./validate-include-placement.js";
102
103
  import { validateModuleMetadata } from "./validate-module-metadata.js";
104
+ import { validateRequires } from "./validate-requires.js";
103
105
  import { validateBaseMapping } from "./validate-base-mapping.js";
104
106
  import { validateInvocationContract } from "./validate-invocation-contract.js";
105
107
  import { collectStepInputIssues } from "./validate-step-inputs.js";
@@ -1029,6 +1031,86 @@ export interface StaticAnalyzerOptions {
1029
1031
  celHandlers?: CelHandlers;
1030
1032
  }
1031
1033
 
1034
+ /**
1035
+ * Files belonging to a module this runtime declared itself unable to read.
1036
+ *
1037
+ * Attribution is by FILE rather than by resource identity, the same choice
1038
+ * `remapMigratedPaths` makes and for the same reason: a diagnostic carries at
1039
+ * most two routing facts and routinely only one, so indexing by resource would
1040
+ * leave every diagnostic without `data.resource` unreachable.
1041
+ *
1042
+ * **A module NAME is not a graph-unique key.** Names are module-scoped, so two
1043
+ * libraries may both be called `Store` — CLAUDE.md names this exact hazard for
1044
+ * migration provenance ("two libraries declaring a Store would share one
1045
+ * bucket"), where the answer is to narrow or not remap at all. Same answer here:
1046
+ * the gated doc's own `metadata.source` is always suppressed, and a name is used
1047
+ * to reach its `include:` partials only when that name identifies exactly ONE
1048
+ * module doc in the set. Where it does not, the partials keep their diagnostics
1049
+ * rather than risk silencing an unrelated library's — a stray extra diagnostic is
1050
+ * a far cheaper failure than a hidden one.
1051
+ *
1052
+ * A module doc with no `source` contributes nothing — suppressing on a guess
1053
+ * would hide diagnostics belonging to files nobody named.
1054
+ */
1055
+ function filesOfUnreadableModules(
1056
+ manifests: ResourceManifest[],
1057
+ requiresDiagnostics: AnalysisDiagnostic[],
1058
+ ): ReadonlySet<string> {
1059
+ const files = new Set<string>();
1060
+ const gatedNames = new Set<string>();
1061
+
1062
+ for (const d of requiresDiagnostics) {
1063
+ if (d.code !== "MODULE_REQUIRES_NEWER_RUNTIME") continue;
1064
+ const data = d.data as DiagnosticData | undefined;
1065
+ // The gated document itself, addressed by the file it was declared in.
1066
+ if (typeof data?.filePath === "string" && data.filePath) files.add(data.filePath);
1067
+ const name = data?.resource?.name;
1068
+ if (typeof name === "string") gatedNames.add(name);
1069
+ }
1070
+ if (files.size === 0 && gatedNames.size === 0) return files;
1071
+
1072
+ // How many module docs answer to each gated name — the ambiguity test.
1073
+ const docsPerName = new Map<string, number>();
1074
+ for (const m of manifests) {
1075
+ if (m.kind !== "Telo.Application" && m.kind !== "Telo.Library") continue;
1076
+ const name = (m.metadata as { name?: string } | undefined)?.name;
1077
+ if (typeof name === "string" && gatedNames.has(name)) {
1078
+ docsPerName.set(name, (docsPerName.get(name) ?? 0) + 1);
1079
+ }
1080
+ }
1081
+
1082
+ for (const m of manifests) {
1083
+ const metadata = (m.metadata ?? {}) as Record<string, unknown>;
1084
+ const owner = metadata.module;
1085
+ if (typeof owner !== "string" || !gatedNames.has(owner)) continue;
1086
+ if (docsPerName.get(owner) !== 1) continue; // ambiguous — do not guess
1087
+ if (typeof metadata.source === "string" && metadata.source) files.add(metadata.source);
1088
+ }
1089
+ return files;
1090
+ }
1091
+
1092
+ /**
1093
+ * Drop every diagnostic anchored in a file whose module this runtime cannot
1094
+ * read, except the gate diagnostic itself.
1095
+ *
1096
+ * A filter rather than a guard on each validator: threading "skip this module"
1097
+ * through thirty validators would make each one responsible for a rule none of
1098
+ * them owns, and a validator added later would silently opt out of it. A
1099
+ * diagnostic with no `filePath` is KEPT — suppression must never be the default
1100
+ * for something it cannot attribute.
1101
+ */
1102
+ function suppressUnreadableModuleDiagnostics(
1103
+ diagnostics: AnalysisDiagnostic[],
1104
+ unreadableFiles: ReadonlySet<string>,
1105
+ ): AnalysisDiagnostic[] {
1106
+ if (unreadableFiles.size === 0) return diagnostics;
1107
+ return diagnostics.filter((d) => {
1108
+ if (d.code === "MODULE_REQUIRES_NEWER_RUNTIME") return true;
1109
+ const filePath = (d.data as DiagnosticData | undefined)?.filePath;
1110
+ return typeof filePath !== "string" || !unreadableFiles.has(filePath);
1111
+ });
1112
+ }
1113
+
1032
1114
  export class StaticAnalyzer {
1033
1115
  private readonly celEnv: Environment;
1034
1116
 
@@ -1527,6 +1609,25 @@ export class StaticAnalyzer {
1527
1609
  });
1528
1610
  }
1529
1611
  }
1612
+ // Declared runtime requirements, FIRST among the validators and suppressing
1613
+ // the rest for any module this runtime cannot read. A module that adopted
1614
+ // newer syntax also produces the vocabulary errors that syntax causes here —
1615
+ // an unknown `use` token, an object where a zone annotation expects a
1616
+ // pointer, an `additionalProperties` violation against a kernel-owned
1617
+ // schema — every one of which is true and blames the module's author for a
1618
+ // version skew. Reporting them beside the gate would bury the one message
1619
+ // that names the actual cause and the actual fix.
1620
+ const requiresDiagnostics = validateRequires(allManifests as unknown as ResourceManifest[], {
1621
+ teloVersion: options?.teloVersion,
1622
+ hostVersions: options?.hostVersions,
1623
+ entryModules: rootModules,
1624
+ });
1625
+ const unreadableFiles = filesOfUnreadableModules(
1626
+ allManifests as unknown as ResourceManifest[],
1627
+ requiresDiagnostics,
1628
+ );
1629
+ diagnostics.push(...requiresDiagnostics);
1630
+
1530
1631
  if (!options?.skipValidation) {
1531
1632
  diagnostics.push(
1532
1633
  ...validateSchemaTypeRefs(allManifests, defs, aliases, aliasesByModule, rootModules),
@@ -1543,6 +1644,19 @@ export class StaticAnalyzer {
1543
1644
  // these fields, which is precisely why they need a check: a mistyped one
1544
1645
  // has no runtime failure mode that would ever surface it.
1545
1646
  diagnostics.push(...validateModuleMetadata(allManifests, defs, aliases));
1647
+ // Every author-written name. Telo has no lexer, so a name's shape is
1648
+ // unchecked where it is declared and its consequences land at whichever
1649
+ // CEL site reads it — for a hyphen, sometimes as silent arithmetic. Takes
1650
+ // the call graph for step names rather than re-walking the step arrays.
1651
+ diagnostics.push(
1652
+ ...validateIdentifierNames(
1653
+ allManifests as unknown as ResourceManifest[],
1654
+ defs,
1655
+ aliases,
1656
+ rootModules,
1657
+ getCallGraph(),
1658
+ ),
1659
+ );
1546
1660
  // A file embed resolves at resource creation, so one written on a doc that
1547
1661
  // is never instantiated is read by nothing and would ship silently.
1548
1662
  diagnostics.push(...validateIncludePlacement(allManifests));
@@ -1556,7 +1670,7 @@ export class StaticAnalyzer {
1556
1670
  // normalisation have already run above; that's all downstream
1557
1671
  // consumers (prepare, init loop) require.
1558
1672
  if (options?.skipValidation) {
1559
- return diagnostics;
1673
+ return suppressUnreadableModuleDiagnostics(diagnostics, unreadableFiles);
1560
1674
  }
1561
1675
 
1562
1676
  // Build a name→manifest map for looking up referenced resources
@@ -2109,14 +2223,33 @@ export class StaticAnalyzer {
2109
2223
 
2110
2224
  for (const name of Object.keys(declared)) {
2111
2225
  const shadows = inScope.has(name);
2112
- if (!shadows && !keywords.has(name)) continue;
2226
+ if (shadows || keywords.has(name)) {
2227
+ diagnostics.push({
2228
+ severity: DiagnosticSeverity.Error,
2229
+ code: "BINDING_NAME_RESERVED",
2230
+ source: SOURCE,
2231
+ message: shadows
2232
+ ? `${m.kind}/${bindingsName}: binding '${name}' shadows a variable already in scope here (${[...inScope].sort().join(", ")}). Rename the binding — a scope variable always wins, so this one would never be read.`
2233
+ : `${m.kind}/${bindingsName}: binding '${name}' is a CEL keyword, so no expression can read it as a reference. Rename the binding.`,
2234
+ data: {
2235
+ resource: resourceRef,
2236
+ filePath: bindingsFile,
2237
+ path: `${celBindingSites.field}.${name}`,
2238
+ },
2239
+ });
2240
+ continue;
2241
+ }
2242
+ // A binding is read by bare name, so it lives in the same
2243
+ // identifier space as a resource or step name and breaks the
2244
+ // same way. The keyword tier is unreachable here — the check
2245
+ // above owns it, and can also say what is being shadowed.
2246
+ const violation = checkName(name, "value", "binding name");
2247
+ if (!violation) continue;
2113
2248
  diagnostics.push({
2114
- severity: DiagnosticSeverity.Error,
2115
- code: "BINDING_NAME_RESERVED",
2249
+ severity: violation.severity,
2250
+ code: violation.code,
2116
2251
  source: SOURCE,
2117
- message: shadows
2118
- ? `${m.kind}/${bindingsName}: binding '${name}' shadows a variable already in scope here (${[...inScope].sort().join(", ")}). Rename the binding — a scope variable always wins, so this one would never be read.`
2119
- : `${m.kind}/${bindingsName}: binding '${name}' is a CEL keyword, so no expression can read it as a reference. Rename the binding.`,
2252
+ message: `${m.kind}/${bindingsName}: ${violation.message}`,
2120
2253
  data: {
2121
2254
  resource: resourceRef,
2122
2255
  filePath: bindingsFile,
@@ -2514,7 +2647,10 @@ export class StaticAnalyzer {
2514
2647
 
2515
2648
  // Reroute diagnostics on synthetic (inline-extracted) resources back to
2516
2649
  // the chain root so position-index lookups land on the parent doc.
2517
- return rewriteSyntheticOrigins(diagnostics, allManifests);
2650
+ return rewriteSyntheticOrigins(
2651
+ suppressUnreadableModuleDiagnostics(diagnostics, unreadableFiles),
2652
+ allManifests,
2653
+ );
2518
2654
  }
2519
2655
 
2520
2656
  analyzeErrors(
@@ -29,8 +29,10 @@
29
29
  */
30
30
 
31
31
  import {
32
+ LAYER_ROLES,
32
33
  isLayerRole,
33
34
  normalizeSelector,
35
+ roleCarriesSelector,
34
36
  selectorKey,
35
37
  selectorMatches,
36
38
  type ArtifactSelector,
@@ -46,7 +48,7 @@ const CONTENT_DIGEST = /^sha256-[A-Za-z0-9_-]{43}$/;
46
48
 
47
49
  export interface ArtifactLayer {
48
50
  role: LayerRole;
49
- /** Present on `controller` layers only. */
51
+ /** Present on the code-bearing roles (`controller`, `library`) only. */
50
52
  selector?: ArtifactSelector;
51
53
  /** OCI blob digest — addresses the layer and verifies the transfer. */
52
54
  blob: string;
@@ -97,25 +99,35 @@ export function parseLayerIndex(value: unknown, describe = "layers"): ArtifactLa
97
99
  throw new LayerIndexError(`${where}: expected an object.`);
98
100
  }
99
101
  const entry = raw as Record<string, unknown>;
100
- if (!isLayerRole(entry.role)) {
102
+ if (typeof entry.role !== "string" || entry.role === "") {
101
103
  throw new LayerIndexError(
102
- `${where}: role must be one of 'controller', 'assets', 'common'; got ` +
103
- `${entry.role === undefined ? "nothing" : `'${String(entry.role)}'`}.`,
104
+ `${where}: role is required and must be one of ${LAYER_ROLES.map((r) => `'${r}'`).join(", ")}; ` +
105
+ `got ${entry.role === undefined ? "nothing" : `'${String(entry.role)}'`}.`,
104
106
  );
105
107
  }
108
+ // A role this runtime does not know is SKIPPED, never rejected. Roles are
109
+ // added over time, and a runtime that cannot name one cannot need it — while
110
+ // throwing would make the whole manifest unreadable, so a module gaining a
111
+ // layer for a newer runtime would stop loading on an older one entirely
112
+ // rather than merely lacking that layer. The error stays for a structurally
113
+ // invalid entry, which is a malformed index rather than a newer one.
114
+ if (!isLayerRole(entry.role)) return;
106
115
  const role = entry.role;
107
116
 
108
117
  let selector: ArtifactSelector | undefined;
109
- if (role === "controller") {
118
+ if (roleCarriesSelector(role)) {
110
119
  if (entry.selector === undefined) {
111
- throw new LayerIndexError(`${where}: a controller layer must declare a selector.`);
120
+ throw new LayerIndexError(`${where}: a ${role} layer must declare a selector.`);
112
121
  }
113
122
  selector = normalizeSelector(entry.selector, where);
114
- const key = selectorKey(selector);
123
+ // Scoped by role: a module's `js` controller layer and its `js` library
124
+ // layer are different layers with the same selector, and only a collision
125
+ // *within* one role means two layers claim one address.
126
+ const key = `${role}\0${selectorKey(selector)}`;
115
127
  if (seenSelectors.has(key)) {
116
128
  throw new LayerIndexError(
117
- `${where}: a second controller layer claims the selector ${key}. ` +
118
- `Each selector addresses exactly one layer.`,
129
+ `${where}: a second ${role} layer claims the selector ${selectorKey(selector)}. ` +
130
+ `Each selector addresses exactly one layer of a role.`,
119
131
  );
120
132
  }
121
133
  seenSelectors.add(key);
@@ -145,18 +157,39 @@ export function parseLayerIndex(value: unknown, describe = "layers"): ArtifactLa
145
157
  /** The singleton layer for a role, or undefined when the artifact has none. */
146
158
  export function singletonLayer(
147
159
  layers: readonly ArtifactLayer[],
148
- role: Exclude<LayerRole, "controller">,
160
+ role: Exclude<LayerRole, "controller" | "library">,
149
161
  ): ArtifactLayer | undefined {
150
162
  return layers.find((l) => l.role === role);
151
163
  }
152
164
 
153
- /** Every controller layer matching `target`, in declaration order. Used by
154
- * `telo install` to warm a cache for one platform. */
155
- export function matchControllerLayers(
165
+ /** The layer of one code role carrying exactly `selector`, or undefined.
166
+ *
167
+ * By exact key rather than by re-matching a host: the candidate being resolved
168
+ * already *is* one selector, and it is by construction the key of the layer
169
+ * that carries it. */
170
+ export function codeLayerFor(
171
+ layers: readonly ArtifactLayer[],
172
+ role: Extract<LayerRole, "controller" | "library">,
173
+ selector: ArtifactSelector,
174
+ ): ArtifactLayer | undefined {
175
+ const key = selectorKey(selector);
176
+ return layers.find(
177
+ (l) => l.role === role && l.selector !== undefined && selectorKey(l.selector) === key,
178
+ );
179
+ }
180
+
181
+ /** Every code layer — controller and library alike — matching `target`, in
182
+ * declaration order. Used by `telo install` to warm a cache for one platform:
183
+ * a library layer is as much a prerequisite of an offline run as the controller
184
+ * layer that imports it. */
185
+ export function matchCodeLayers(
156
186
  layers: readonly ArtifactLayer[],
157
187
  target: PlatformTarget,
158
188
  ): ArtifactLayer[] {
159
189
  return layers.filter(
160
- (l) => l.role === "controller" && l.selector !== undefined && selectorMatches(l.selector, target),
190
+ (l) =>
191
+ (l.role === "controller" || l.role === "library") &&
192
+ l.selector !== undefined &&
193
+ selectorMatches(l.selector, target),
161
194
  );
162
195
  }
@@ -25,16 +25,27 @@
25
25
  * published into OCI descriptors.
26
26
  */
27
27
 
28
- /** The role a layer plays in a module artifact. `controller` layers carry a
29
- * selector; `assets` and `common` are singletons and carry none. */
30
- export type LayerRole = "controller" | "assets" | "common";
28
+ /** The role a layer plays in a module artifact. `controller` and `library` layers
29
+ * carry a selector; `assets` and `common` are singletons and carry none. */
30
+ export type LayerRole = "controller" | "library" | "assets" | "common";
31
31
 
32
- export const LAYER_ROLES: readonly LayerRole[] = ["controller", "assets", "common"];
32
+ export const LAYER_ROLES: readonly LayerRole[] = ["controller", "library", "assets", "common"];
33
33
 
34
34
  export function isLayerRole(value: unknown): value is LayerRole {
35
35
  return typeof value === "string" && (LAYER_ROLES as readonly string[]).includes(value);
36
36
  }
37
37
 
38
+ /** The roles that hold executable code, and are therefore per format rather than
39
+ * singletons. A `library` layer is per selector for the same reason a
40
+ * `controller` layer is: a module's JS entry point and its future Rust one are
41
+ * different files, and a consumer resolves the one its own runtime can import.
42
+ * A singleton would be wrong the moment a second runtime ships. */
43
+ export const CODE_LAYER_ROLES: readonly LayerRole[] = ["controller", "library"];
44
+
45
+ export function roleCarriesSelector(role: LayerRole): boolean {
46
+ return (CODE_LAYER_ROLES as readonly string[]).includes(role);
47
+ }
48
+
38
49
  /** The platform axes, in canonical order. Not a closed vocabulary of *values* —
39
50
  * new architectures appear without a Telo release — only of axis names. */
40
51
  export const PLATFORM_AXES = ["os", "arch", "libc"] as const;
package/src/builtins.ts CHANGED
@@ -1,6 +1,29 @@
1
- import { manifestFragment } from "./manifest-schemas.js";
1
+ import { manifestFragment, manifestFragmentRef, withSchemaFragments } from "./manifest-schemas.js";
2
+
3
+ /** A slot holding author-written JSON Schema. Localized and hoisted by
4
+ * {@link withSchemaFragments} on the enclosing schema, which is what makes the
5
+ * `#/$defs` pointer resolve inside whatever AJV compiles.
6
+ *
7
+ * `KindSchema` and `JsonSchema7` share a body; the name is the discriminator
8
+ * the IDE reads off the `x-telo-fragment` stamp to decide whether the
9
+ * `x-telo-*` vocabulary belongs here. A kind's own `schema:` is where it does;
10
+ * a `status:` block or an `inputType:` describes plain data, where it does not. */
11
+ const kindSchemaSlot = {
12
+ title: "Schema",
13
+ description: "Configuration this kind accepts, as JSON Schema plus `x-telo-*` annotations.",
14
+ $ref: manifestFragmentRef("KindSchema"),
15
+ };
16
+
17
+ /** Observed state a kind reports while running, as a data schema. `required:` is
18
+ * rejected separately by `validateObservedStateDeclarations`, which can say why
19
+ * and what to write instead. */
20
+ const observedStateSlot = {
21
+ title: "Observed state",
22
+ description:
23
+ "What a resource of this kind reports while running, published at `resources.<name>.status.<field>`.",
24
+ $ref: manifestFragmentRef("JsonSchema7"),
25
+ };
2
26
  import type { ResourceDefinition } from "@telorun/sdk";
3
- import { OBSERVED_STATE_SCHEMA } from "./validate-observed-state.js";
4
27
 
5
28
  /** Descriptive provenance a module declares about itself, shared by
6
29
  * `Telo.Application` and `Telo.Library`.
@@ -24,6 +47,32 @@ const PROVENANCE_METADATA = {
24
47
  documentation: { type: "string" },
25
48
  };
26
49
 
50
+ /** The declared runtime requirements block, shared by `Telo.Application` and
51
+ * `Telo.Library` — the two module kinds, whose schemas are otherwise
52
+ * independent and would drift.
53
+ *
54
+ * **Deliberately says only "these are objects", and nothing about the values.**
55
+ * The grammar belongs to `requires-block.ts`, which is the single reader, and
56
+ * every rule that matters — `^` and `~` refused, a bare version refused, bounds
57
+ * that must not exclude each other, an upper bound that must name a version that
58
+ * exists — needs a parse and a comparison, not a schema. Adding `type: "string"`
59
+ * here bought nothing and cost a duplicate: `telo: 80` then produced BOTH a
60
+ * `SCHEMA_VIOLATION` and a `REQUIRES_INVALID` for one node, one of them phrased
61
+ * by a layer that does not know what the value is for.
62
+ *
63
+ * Left open at both tiers for the same reason. An unrecognized axis is reported
64
+ * by the reader with the vocabulary it knows — a far better message than AJV's —
65
+ * and, critically, is SUPPRESSED while the `telo` requirement is itself unmet,
66
+ * since an older runtime not knowing a newer axis is a consequence of the
67
+ * version skew rather than a second defect. AJV cannot express that ordering. */
68
+ const REQUIRES_SCHEMA = {
69
+ type: "object",
70
+ properties: {
71
+ host: { type: "object" },
72
+ },
73
+ additionalProperties: true,
74
+ };
75
+
27
76
  /** Author-declared subset of `files:` that ships in the artifact's lazily
28
77
  * materialized `assets` layer. Optional: an unclaimed file joins the `common`
29
78
  * layer, which is pulled alongside any controller layer, so omitting this costs
@@ -33,6 +82,33 @@ const ASSETS_FILES_SCHEMA = {
33
82
  items: { type: "string" },
34
83
  };
35
84
 
85
+ /** `exports.code` — the entry point a sibling module's controller bundle resolves
86
+ * this library's bare specifier to, one per format.
87
+ *
88
+ * Data rather than a package URL: `controllers:` needs a PURL because it can name
89
+ * an ecosystem fetch (`pkg:npm`, `pkg:cargo`), while this always names a file the
90
+ * module already ships, so the type/namespace segments would be constant noise —
91
+ * and a query string is one opaque box to the visual editor. `format` plus the
92
+ * platform axes build the same `ArtifactSelector` a controller candidate does.
93
+ * Semantics and diagnostics live in `analyzer/nodejs/src/module-library.ts`. */
94
+ const LIBRARY_CANDIDATES_SCHEMA = {
95
+ type: "array",
96
+ items: {
97
+ type: "object",
98
+ required: ["specifier", "format", "path"],
99
+ properties: {
100
+ specifier: { type: "string" },
101
+ format: { type: "string" },
102
+ path: { type: "string" },
103
+ source: { type: "string" },
104
+ os: { type: "string" },
105
+ arch: { type: "string" },
106
+ libc: { type: "string" },
107
+ },
108
+ additionalProperties: false,
109
+ },
110
+ };
111
+
36
112
  /** The published layer index, written by `telo publish` (never hand-authored).
37
113
  * One entry per layer except the manifest layer, which cannot list its own hash
38
114
  * inside itself and is pinned by the importer's `#sha256-...` instead. Shape and
@@ -44,7 +120,7 @@ const LAYER_INDEX_SCHEMA = {
44
120
  type: "object",
45
121
  required: ["role", "blob", "integrity"],
46
122
  properties: {
47
- role: { type: "string", enum: ["controller", "assets", "common"] },
123
+ role: { type: "string", enum: ["controller", "library", "assets", "common"] },
48
124
  selector: {
49
125
  type: "object",
50
126
  required: ["format"],
@@ -251,13 +327,13 @@ export const KERNEL_BUILTINS: ResourceDefinition[] = [
251
327
  // kernel registers this controller directly at boot, before any lazy
252
328
  // resolution — it states truthfully who provides it.
253
329
  controllers: [{ runtime: "kernel", entry: "Telo.JsonSchema" }],
254
- schema: {
330
+ schema: withSchemaFragments({
255
331
  type: "object",
256
332
  properties: {
257
333
  schema: {
258
334
  title: "Schema",
259
335
  description: "JSON Schema definition for the declared data type.",
260
- type: "object",
336
+ $ref: manifestFragmentRef("JsonSchema7"),
261
337
  },
262
338
  extends: {
263
339
  title: "Extends",
@@ -292,13 +368,13 @@ export const KERNEL_BUILTINS: ResourceDefinition[] = [
292
368
  },
293
369
  required: ["schema"],
294
370
  additionalProperties: false,
295
- },
371
+ }),
296
372
  },
297
373
  {
298
374
  kind: "Telo.Definition",
299
375
  metadata: { name: "Abstract", module: "Telo" },
300
376
  capability: "Telo.Template",
301
- schema: {
377
+ schema: withSchemaFragments({
302
378
  type: "object",
303
379
  properties: {
304
380
  kind: { type: "string" },
@@ -309,15 +385,15 @@ export const KERNEL_BUILTINS: ResourceDefinition[] = [
309
385
  additionalProperties: true,
310
386
  },
311
387
  capability: { type: "string" },
312
- schema: { type: "object", additionalProperties: true },
313
- status: OBSERVED_STATE_SCHEMA,
388
+ schema: kindSchemaSlot,
389
+ status: observedStateSlot,
314
390
  },
315
391
  required: ["metadata"],
316
392
  // Telo.Abstract is an extension point by design — it must accept forward-compatible
317
393
  // fields (e.g. inputType/outputType from the typed-abstracts plan) without requiring
318
394
  // the analyzer to enumerate them here.
319
395
  additionalProperties: true,
320
- },
396
+ }),
321
397
  },
322
398
  {
323
399
  kind: "Telo.Definition",
@@ -335,11 +411,17 @@ export const KERNEL_BUILTINS: ResourceDefinition[] = [
335
411
  // matching how Run.Sequence steps factor dispatch from data. The dispatch
336
412
  // entry-point (`invoke` / `provide` / `run`) determines how `inputs`/`result`
337
413
  // are interpreted at runtime. See analyzer/nodejs/plans/template-internal-cel-validation.md.
338
- schema: {
414
+ schema: withSchemaFragments({
339
415
  type: "object",
340
416
  additionalProperties: true,
341
417
  properties: {
342
- status: OBSERVED_STATE_SCHEMA,
418
+ // The kind's own configuration contract. Declared as a slot for the
419
+ // first time here: it was reachable only as an unnamed extra property,
420
+ // so nothing could say what belonged in it — no completion inside a
421
+ // `schema:` block, and a misspelled keyword surviving to a runtime
422
+ // failure that named a different field.
423
+ schema: kindSchemaSlot,
424
+ status: observedStateSlot,
343
425
  resources: {
344
426
  type: "array",
345
427
  items: {
@@ -509,7 +591,7 @@ export const KERNEL_BUILTINS: ResourceDefinition[] = [
509
591
  },
510
592
  },
511
593
  },
512
- },
594
+ }),
513
595
  },
514
596
  {
515
597
  kind: "Telo.Definition",
@@ -760,6 +842,9 @@ export const KERNEL_BUILTINS: ResourceDefinition[] = [
760
842
  // CLI flag — so a level derived from the host environment goes through a
761
843
  // `variables:` entry read with `!cel`. See kernel/specs/logging.md §12.
762
844
  logging: ROOT_LOGGING_SCHEMA,
845
+ // The runtime range this module is verified against. See
846
+ // `analyzer/nodejs/src/requires-block.ts`.
847
+ requires: REQUIRES_SCHEMA,
763
848
  },
764
849
  required: ["metadata"],
765
850
  additionalProperties: false,
@@ -846,9 +931,13 @@ export const KERNEL_BUILTINS: ResourceDefinition[] = [
846
931
  type: "array",
847
932
  items: { type: "string", not: { enum: ["variables", "secrets"] } },
848
933
  },
934
+ code: LIBRARY_CANDIDATES_SCHEMA,
849
935
  },
850
936
  additionalProperties: true,
851
937
  },
938
+ // The runtime range this module is verified against. See
939
+ // `analyzer/nodejs/src/requires-block.ts`.
940
+ requires: REQUIRES_SCHEMA,
852
941
  },
853
942
  required: ["metadata"],
854
943
  additionalProperties: false,
@@ -20,34 +20,9 @@ export interface BindingSites {
20
20
  scopeNames: Set<string>;
21
21
  }
22
22
 
23
- /**
24
- * CEL keywords. A binding named after one is unreachable — `true` lexes as a
25
- * literal, `in` as an operator so it is reserved alongside the scope names,
26
- * which turns silence into a diagnostic.
27
- */
28
- export const CEL_RESERVED_WORDS: readonly string[] = [
29
- "as",
30
- "break",
31
- "const",
32
- "continue",
33
- "else",
34
- "false",
35
- "for",
36
- "function",
37
- "if",
38
- "import",
39
- "in",
40
- "let",
41
- "loop",
42
- "namespace",
43
- "null",
44
- "package",
45
- "return",
46
- "true",
47
- "var",
48
- "void",
49
- "while",
50
- ];
23
+ // CEL keywords live in `identifier-name.ts` — a binding named after one is
24
+ // unreachable for exactly the reason a resource or step named after one is, so
25
+ // the list belongs to the identifier vocabulary rather than to this file.
51
26
 
52
27
  /** Locate a kind's bindings field and the scope names its annotated contexts
53
28
  * declare. Returns undefined for a kind that declares no bindings region. */
@@ -116,11 +116,36 @@ export class DefinitionRegistry {
116
116
  registerNamedTypeSchema(id: string, schema: Record<string, any>): boolean {
117
117
  if (this.definitionSchemaIds.has(id)) return false;
118
118
  if (this.registeredSchemaIds.has(id) || this.ajv.getSchema(id)) return true;
119
- this.ajv.addSchema(schema, id);
119
+ if (!this.tryAddSchema(schema, id)) return true;
120
120
  this.registeredSchemaIds.add(id);
121
121
  return true;
122
122
  }
123
123
 
124
+ /**
125
+ * Register a schema, surviving one AJV refuses.
126
+ *
127
+ * `addSchema` META-VALIDATES and THROWS, and a throw here escapes the whole
128
+ * analyze pass: one author schema with `minimum: "3"` in it aborted the run
129
+ * with AJV's own unanchored text and took every other diagnostic in the file
130
+ * down with it — including the anchored one that says exactly which keyword is
131
+ * wrong. Registration is a lookup table for `$ref` resolution, so failing to
132
+ * fill one entry costs a reference that could not have resolved anyway.
133
+ *
134
+ * Nothing is swallowed: an unregisterable schema is invalid, and the two
135
+ * checks that report it both run afterwards and both anchor on the offending
136
+ * line — `SCHEMA_VIOLATION` from the `KindSchema` / `JsonSchema7` fragment the
137
+ * slot points at, and `SCHEMA_COMPILE_ERROR` from {@link schemaCompileError},
138
+ * which wraps `compile` for this same reason.
139
+ */
140
+ private tryAddSchema(schema: Record<string, any>, id: string): boolean {
141
+ try {
142
+ this.ajv.addSchema(schema, id);
143
+ return true;
144
+ } catch {
145
+ return false;
146
+ }
147
+ }
148
+
124
149
  /** True when a schema is registered under `id` (a canonical `telo://` type id
125
150
  * or a definition `$id`). Used to flag schema `$ref`s that resolve to nothing. */
126
151
  hasSchemaId(id: string): boolean {
@@ -187,7 +212,10 @@ export class DefinitionRegistry {
187
212
  if (this.ajv.getSchema(id)) {
188
213
  throw new Error(`Duplicate definition schema $id: "${id}" is already registered`);
189
214
  }
190
- this.ajv.addSchema(schema, id);
215
+ // A schema AJV refuses is left unregistered rather than aborting the pass —
216
+ // see {@link tryAddSchema}. The id stays claimed either way, so a later
217
+ // named type cannot quietly take a kind's place.
218
+ this.tryAddSchema(schema, id);
191
219
  this.registeredSchemaIds.add(id);
192
220
  this.definitionSchemaIds.add(id);
193
221
  }