@telorun/analyzer 0.59.0 → 0.61.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 (95) hide show
  1. package/dist/analyzer.d.ts +9 -2
  2. package/dist/analyzer.d.ts.map +1 -1
  3. package/dist/analyzer.js +38 -10
  4. package/dist/artifact-layer-index.d.ts +13 -5
  5. package/dist/artifact-layer-index.d.ts.map +1 -1
  6. package/dist/artifact-layer-index.js +37 -13
  7. package/dist/artifact-selector.d.ts +10 -3
  8. package/dist/artifact-selector.d.ts.map +1 -1
  9. package/dist/artifact-selector.js +10 -1
  10. package/dist/builtins.d.ts.map +1 -1
  11. package/dist/builtins.js +44 -43
  12. package/dist/cel-environment.d.ts.map +1 -1
  13. package/dist/cel-environment.js +35 -0
  14. package/dist/flatten-for-analyzer.d.ts +4 -0
  15. package/dist/flatten-for-analyzer.d.ts.map +1 -1
  16. package/dist/flatten-for-analyzer.js +8 -0
  17. package/dist/index.d.ts +7 -2
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +9 -2
  20. package/dist/kernel-globals.d.ts +1 -1
  21. package/dist/kernel-globals.d.ts.map +1 -1
  22. package/dist/kernel-globals.js +15 -1
  23. package/dist/manifest-schemas.d.ts +407 -0
  24. package/dist/manifest-schemas.d.ts.map +1 -0
  25. package/dist/manifest-schemas.js +396 -0
  26. package/dist/module-file-claims.d.ts +13 -0
  27. package/dist/module-file-claims.d.ts.map +1 -1
  28. package/dist/module-file-claims.js +26 -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/module-metadata-scope.d.ts +44 -0
  33. package/dist/module-metadata-scope.d.ts.map +1 -0
  34. package/dist/module-metadata-scope.js +82 -0
  35. package/dist/parse-loaded-file.d.ts.map +1 -1
  36. package/dist/parse-loaded-file.js +16 -0
  37. package/dist/release/bump-level.d.ts +46 -0
  38. package/dist/release/bump-level.d.ts.map +1 -0
  39. package/dist/release/bump-level.js +80 -0
  40. package/dist/release/changelog.d.ts +36 -0
  41. package/dist/release/changelog.d.ts.map +1 -0
  42. package/dist/release/changelog.js +53 -0
  43. package/dist/release/fragment.d.ts +48 -0
  44. package/dist/release/fragment.d.ts.map +1 -0
  45. package/dist/release/fragment.js +84 -0
  46. package/dist/release/index.d.ts +26 -0
  47. package/dist/release/index.d.ts.map +1 -0
  48. package/dist/release/index.js +18 -0
  49. package/dist/release/ledger.d.ts +53 -0
  50. package/dist/release/ledger.d.ts.map +1 -0
  51. package/dist/release/ledger.js +109 -0
  52. package/dist/release/payload-digest.d.ts +63 -0
  53. package/dist/release/payload-digest.d.ts.map +1 -0
  54. package/dist/release/payload-digest.js +65 -0
  55. package/dist/release/release-plan.d.ts +128 -0
  56. package/dist/release/release-plan.d.ts.map +1 -0
  57. package/dist/release/release-plan.js +277 -0
  58. package/dist/release/version-stamp.d.ts +50 -0
  59. package/dist/release/version-stamp.d.ts.map +1 -0
  60. package/dist/release/version-stamp.js +119 -0
  61. package/dist/release/workspace-config.d.ts +41 -0
  62. package/dist/release/workspace-config.d.ts.map +1 -0
  63. package/dist/release/workspace-config.js +60 -0
  64. package/dist/schema-compat.js +2 -1
  65. package/dist/validate-module-artifact.d.ts.map +1 -1
  66. package/dist/validate-module-artifact.js +53 -1
  67. package/dist/validate-step-inputs.d.ts +1 -1
  68. package/dist/validate-step-inputs.d.ts.map +1 -1
  69. package/dist/validate-step-inputs.js +99 -8
  70. package/package.json +3 -3
  71. package/src/analyzer.ts +41 -10
  72. package/src/artifact-layer-index.ts +47 -14
  73. package/src/artifact-selector.ts +15 -4
  74. package/src/builtins.ts +45 -43
  75. package/src/cel-environment.ts +37 -0
  76. package/src/flatten-for-analyzer.ts +12 -0
  77. package/src/index.ts +19 -2
  78. package/src/kernel-globals.ts +23 -1
  79. package/src/manifest-schemas.ts +408 -0
  80. package/src/module-file-claims.ts +42 -2
  81. package/src/module-library.ts +208 -0
  82. package/src/module-metadata-scope.ts +88 -0
  83. package/src/parse-loaded-file.ts +16 -0
  84. package/src/release/bump-level.ts +95 -0
  85. package/src/release/changelog.ts +62 -0
  86. package/src/release/fragment.ts +130 -0
  87. package/src/release/index.ts +66 -0
  88. package/src/release/ledger.ts +142 -0
  89. package/src/release/payload-digest.ts +83 -0
  90. package/src/release/release-plan.ts +392 -0
  91. package/src/release/version-stamp.ts +142 -0
  92. package/src/release/workspace-config.ts +81 -0
  93. package/src/schema-compat.ts +1 -1
  94. package/src/validate-module-artifact.ts +56 -0
  95. package/src/validate-step-inputs.ts +109 -9
@@ -1,7 +1,8 @@
1
1
  import { resolveContract } from "./invocation-contract.js";
2
2
  import { checkSchemaCompatibility, navigateSchemaToExprPath, substituteCelFields, validateAgainstSchema, } from "./schema-compat.js";
3
3
  import { plainChainOf } from "@telorun/templating";
4
- import { valueTypeOf } from "@telorun/sdk";
4
+ import { isLiveSlot, valueTypeOf } from "@telorun/sdk";
5
+ import { manifestFragmentOf } from "./manifest-schemas.js";
5
6
  import { analyzerContractScope, containerOf, gatherPropertySchemas, missingRequired, resolveLocalRef, walkStepArray, } from "./analyzer.js";
6
7
  /**
7
8
  * Validate every step's `inputs:` against the invoked target's declared input
@@ -79,16 +80,27 @@ stepContext) {
79
80
  // that silence is exactly where a stream of the wrong element used to
80
81
  // flow. The comparison is covariant and gradual: an omitted argument is
81
82
  // *any* in both directions, so only a definite conflict is reported.
82
- if (stepContext) {
83
+ // The roots a plain chain may name here, each paired with the schema it is
84
+ // navigated against. `steps.` is the step map (analyzer state, supplied by
85
+ // the caller). `inputs.` is the ENCLOSING kind's own declared inputType,
86
+ // which is how a value produced OUTSIDE this resource reaches a step at
87
+ // all: an HTTP route maps `request.body` into its handler's inputs, and the
88
+ // handler forwards `inputs.body` onward — the shape a live value most often
89
+ // arrives in, and the one covering only `steps.` missed entirely. A root
90
+ // this cannot resolve contributes nothing rather than guessing at a schema.
91
+ const roots = [];
92
+ if (stepContext)
93
+ roots.push(["steps.", stepContext]);
94
+ const ownContract = resolveContract("inputType", manifest, contractScope.resolveIn(manifest.kind, readingModule), contractScope);
95
+ if (ownContract)
96
+ roots.push(["inputs.", ownContract.schema]);
97
+ if (roots.length > 0) {
83
98
  for (const [inputName, inputValue] of Object.entries(values)) {
84
99
  const chain = plainChainOf(inputValue);
85
- // The step context is rooted at the STEP MAP, so a `steps.` prefix is
86
- // the namespace name and not a property of it. Only that namespace is
87
- // navigated: `inputs.` and a named binding resolve elsewhere, and
88
- // guessing at a root this does not hold would compare the wrong schema.
89
- if (!chain?.startsWith("steps."))
100
+ const root = chain ? roots.find(([prefix]) => chain.startsWith(prefix)) : undefined;
101
+ if (!chain || !root)
90
102
  continue;
91
- const produced = navigateSchemaToExprPath(stepContext, chain.slice("steps.".length));
103
+ const produced = navigateSchemaToExprPath(root[1], chain.slice(root[0].length));
92
104
  const slotSchema = contract.schema.properties?.[inputName];
93
105
  if (!produced || !slotSchema)
94
106
  continue;
@@ -99,6 +111,27 @@ stepContext) {
99
111
  // broad new Error-severity check hidden behind an argument-specific
100
112
  // name. Both sides must declare a value type for the question to be
101
113
  // about arguments at all.
114
+ // A LIVE value is consumed by reading, so it exists exactly once —
115
+ // that is what `live` says in the vocabulary, and re-attempting a
116
+ // dispatch that already read it re-sends nothing. Reported here rather
117
+ // than through a slot-specific annotation because both facts are
118
+ // already declared: the value's liveness by its value type, and the
119
+ // re-attempt by the retry policy. No kind is named.
120
+ if (isLiveSlot(produced)) {
121
+ const retry = declaredRetry(step, stepItemSchema, invokedManifest, invokedDef);
122
+ if (retry !== undefined) {
123
+ out.push({
124
+ path: `${stepPath}.${inputsField}.${inputName}`,
125
+ targetLabel: invokedName ?? invokedKind ?? "the invoked resource",
126
+ message: `'${inputName}' is a live value, which is consumed by reading and so exists ` +
127
+ `once — but ${retry} re-attempts the dispatch, and a re-attempt would pass ` +
128
+ `nothing. Collect it to a value first, or chunk the work so each attempt ` +
129
+ `carries its own replayable piece.`,
130
+ code: "LIVE_VALUE_RETRIED",
131
+ });
132
+ continue;
133
+ }
134
+ }
102
135
  if (!valueTypeOf(produced) || !valueTypeOf(slotSchema))
103
136
  continue;
104
137
  const { compatible, issues } = checkSchemaCompatibility(produced, slotSchema, (ref) => defs.schemaForId(ref));
@@ -130,3 +163,61 @@ stepContext) {
130
163
  }
131
164
  return out;
132
165
  }
166
+ /**
167
+ * Where a re-attempt is declared for this dispatch, described for a diagnostic,
168
+ * or undefined when none is.
169
+ *
170
+ * A field declares one when its schema was expanded from a shared retry fragment
171
+ * — the shape the author pointed at, rather than a marker they had to remember to
172
+ * write beside it. Which fragment also says WHERE the budget is, so the two
173
+ * spellings a kind may carry (a policy object, or the deprecated bare count) need
174
+ * no guessing between them and no rule about which one wins.
175
+ *
176
+ * Two sites are consulted because there are two real ones: the STEP's own policy
177
+ * — `retry` on the kernel-owned dispatch site — and the TARGET's, a field on an
178
+ * arbitrary kind, because `Http.Request` re-attempts inside its own `invoke()`
179
+ * where only it can tell a 429 from a 500. A live value is equally doomed by
180
+ * either. EVERY retry-bearing field at a site is checked, not the first, since
181
+ * `Http.Request` carries both spellings and property order must not decide which
182
+ * is seen.
183
+ *
184
+ * Only a STATICALLY KNOWN non-zero budget counts. An `attempts` written as CEL
185
+ * says nothing here, and guessing would report a conflict against a manifest that
186
+ * may never retry — the same posture the `use` case-map selector takes.
187
+ */
188
+ function declaredRetry(step, stepItemSchema, invokedManifest, invokedDef) {
189
+ for (const [field, budget] of retryFields(stepItemSchema)) {
190
+ if (budget(step?.[field]) > 0)
191
+ return `the step's \`${field}\``;
192
+ }
193
+ for (const [field, budget] of retryFields(invokedDef?.schema)) {
194
+ if (budget(invokedManifest?.[field]) > 0)
195
+ return `the target's \`${field}\``;
196
+ }
197
+ return undefined;
198
+ }
199
+ /** How each shared retry fragment carries its budget. Keyed on fragment name —
200
+ * the analyzer's own built-ins, never a module's kind — so a kind that adopts a
201
+ * shape is covered without naming it here. */
202
+ const RETRY_BUDGET = {
203
+ RetryPolicy: (value) => {
204
+ if (!value || typeof value !== "object")
205
+ return 0;
206
+ const attempts = value.attempts;
207
+ return typeof attempts === "number" ? attempts : 0;
208
+ },
209
+ RetryAttempts: (value) => (typeof value === "number" ? value : 0),
210
+ };
211
+ /** Every property of `schema` whose shape came from a retry fragment, paired with
212
+ * the reader for that fragment's budget. */
213
+ function retryFields(schema) {
214
+ if (!schema)
215
+ return [];
216
+ const out = [];
217
+ for (const [key, sub] of gatherPropertySchemas(schema)) {
218
+ const budget = RETRY_BUDGET[manifestFragmentOf(sub) ?? ""];
219
+ if (budget)
220
+ out.push([key, budget]);
221
+ }
222
+ return out;
223
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@telorun/analyzer",
3
- "version": "0.59.0",
3
+ "version": "0.61.0",
4
4
  "description": "Telo Analyzer - Static manifest validator for Telo manifests.",
5
5
  "keywords": [
6
6
  "telo",
@@ -43,13 +43,13 @@
43
43
  "jsonpath-plus": "^10.3.0",
44
44
  "packageurl-js": "^2.0.1",
45
45
  "yaml": "^2.8.3",
46
- "@telorun/templating": "0.15.0"
46
+ "@telorun/templating": "0.16.0"
47
47
  },
48
48
  "devDependencies": {
49
49
  "@types/node": "^20.0.0",
50
50
  "typescript": "^5.0.0",
51
51
  "vitest": "^2.1.8",
52
- "@telorun/sdk": "0.74.0"
52
+ "@telorun/sdk": "0.75.0"
53
53
  },
54
54
  "peerDependencies": {
55
55
  "@telorun/sdk": "*"
package/src/analyzer.ts CHANGED
@@ -51,6 +51,7 @@ import { REF_VALIDATION_SKIP_KINDS } from "./system-kinds.js";
51
51
  import { resolveRefSentinels } from "./resolve-ref-sentinels.js";
52
52
  import { resolveSchemaRefKinds, type RefConstraintIssue } from "./resolve-schema-ref-kinds.js";
53
53
  import { runZoneAnalysis, type ZoneExportCache } from "./resolve-zone-requirements.js";
54
+ import { MANIFEST_SCHEMA_URI, ManifestRootSchema } from "./manifest-schemas.js";
54
55
  import { validateZoneSlotDeclarations, type ZoneSlotIssue } from "./validate-zone-slots.js";
55
56
  import {
56
57
  validateDynamicSelectors,
@@ -316,23 +317,50 @@ export function resolveLocalRef(
316
317
  const resolved = root.$defs?.[defName];
317
318
  if (resolved && typeof resolved === "object") return resolved as Record<string, any>;
318
319
  }
320
+ // A kernel-owned structural fragment (`telo://manifest#/$defs/InvokeStep`).
321
+ // Resolved HERE rather than by each walker: this is the one chokepoint every
322
+ // structural walk already goes through — the step-array walks, the call graph,
323
+ // the zone projection, the eval-path collector — so a composer that points at a
324
+ // shared shape stays legible to all of them at once. Nothing is inlined into
325
+ // the stored schema, which keeps validator-cache identity stable and matches
326
+ // what `resolveSchemaTypeRefs` does for a named user type.
327
+ if (typeof ref === "string" && ref.startsWith(BUILTIN_FRAGMENT_PREFIX)) {
328
+ const defName = ref.slice(BUILTIN_FRAGMENT_PREFIX.length);
329
+ const resolved = (ManifestRootSchema.$defs as Record<string, unknown>)[defName];
330
+ if (resolved && typeof resolved === "object") return resolved as Record<string, any>;
331
+ }
319
332
  return schema;
320
333
  }
321
334
 
335
+ const BUILTIN_FRAGMENT_PREFIX = `${MANIFEST_SCHEMA_URI}#/$defs/`;
336
+
322
337
  /** Gather property schemas from a (possibly variant-bearing) object schema:
323
- * top-level `properties` plus every `oneOf` / `anyOf` / `allOf` branch. */
324
- export function gatherPropertySchemas(schema: Record<string, any>): Array<[string, Record<string, any>]> {
338
+ * top-level `properties` plus every `oneOf` / `anyOf` / `allOf` branch.
339
+ *
340
+ * Each branch is resolved through {@link resolveLocalRef} first, so a branch
341
+ * that points at a shared shape — a `oneOf` arm that IS the kernel's dispatch
342
+ * site — contributes its properties like an inline one. Without that, pointing a
343
+ * composer at a shared shape would silently empty every role-driven lookup that
344
+ * reads this (the inputs slot, the retry policy, the eval paths), which is a
345
+ * failure with no diagnostic attached to it. */
346
+ export function gatherPropertySchemas(
347
+ schema: Record<string, any>,
348
+ root?: Record<string, any>,
349
+ ): Array<[string, Record<string, any>]> {
325
350
  const out: Array<[string, Record<string, any>]> = [];
326
- if (schema.properties && typeof schema.properties === "object") {
327
- for (const [k, v] of Object.entries(schema.properties as Record<string, any>)) {
351
+ const base = resolveLocalRef(schema, root ?? schema) ?? schema;
352
+ if (base.properties && typeof base.properties === "object") {
353
+ for (const [k, v] of Object.entries(base.properties as Record<string, any>)) {
328
354
  out.push([k, v as Record<string, any>]);
329
355
  }
330
356
  }
331
357
  for (const variantKey of ["oneOf", "anyOf", "allOf"] as const) {
332
- const arr = schema[variantKey];
358
+ const arr = base[variantKey];
333
359
  if (!Array.isArray(arr)) continue;
334
- for (const variant of arr) {
335
- if (variant && typeof variant === "object" && variant.properties) {
360
+ for (const raw of arr) {
361
+ if (!raw || typeof raw !== "object") continue;
362
+ const variant = resolveLocalRef(raw as Record<string, any>, root ?? schema) ?? raw;
363
+ if (variant.properties) {
336
364
  for (const [k, v] of Object.entries(variant.properties as Record<string, any>)) {
337
365
  out.push([k, v as Record<string, any>]);
338
366
  }
@@ -2012,9 +2040,12 @@ export class StaticAnalyzer {
2012
2040
  severity: DiagnosticSeverity.Error,
2013
2041
  code: issue.code ?? "CONTRACT_INPUTS_MISMATCH",
2014
2042
  source: SOURCE,
2015
- message: issue.code
2016
- ? `${m.kind}/${stepName}: inputs at '${issue.path}' flow into ${issue.targetLabel} with disagreeing type arguments: ${issue.message}`
2017
- : `${m.kind}/${stepName}: inputs at '${issue.path}' do not satisfy ${issue.targetLabel}'s declared inputType: ${issue.message}`,
2043
+ message:
2044
+ issue.code === "LIVE_VALUE_RETRIED"
2045
+ ? `${m.kind}/${stepName}: at '${issue.path}', ${issue.message}`
2046
+ : issue.code
2047
+ ? `${m.kind}/${stepName}: inputs at '${issue.path}' flow into ${issue.targetLabel} with disagreeing type arguments: ${issue.message}`
2048
+ : `${m.kind}/${stepName}: inputs at '${issue.path}' do not satisfy ${issue.targetLabel}'s declared inputType: ${issue.message}`,
2018
2049
  data: {
2019
2050
  resource: { kind: m.kind, name: stepName ?? "" },
2020
2051
  filePath: stepFile,
@@ -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,3 +1,4 @@
1
+ import { manifestFragment } from "./manifest-schemas.js";
1
2
  import type { ResourceDefinition } from "@telorun/sdk";
2
3
  import { OBSERVED_STATE_SCHEMA } from "./validate-observed-state.js";
3
4
 
@@ -32,6 +33,33 @@ const ASSETS_FILES_SCHEMA = {
32
33
  items: { type: "string" },
33
34
  };
34
35
 
36
+ /** `exports.code` — the entry point a sibling module's controller bundle resolves
37
+ * this library's bare specifier to, one per format.
38
+ *
39
+ * Data rather than a package URL: `controllers:` needs a PURL because it can name
40
+ * an ecosystem fetch (`pkg:npm`, `pkg:cargo`), while this always names a file the
41
+ * module already ships, so the type/namespace segments would be constant noise —
42
+ * and a query string is one opaque box to the visual editor. `format` plus the
43
+ * platform axes build the same `ArtifactSelector` a controller candidate does.
44
+ * Semantics and diagnostics live in `analyzer/nodejs/src/module-library.ts`. */
45
+ const LIBRARY_CANDIDATES_SCHEMA = {
46
+ type: "array",
47
+ items: {
48
+ type: "object",
49
+ required: ["specifier", "format", "path"],
50
+ properties: {
51
+ specifier: { type: "string" },
52
+ format: { type: "string" },
53
+ path: { type: "string" },
54
+ source: { type: "string" },
55
+ os: { type: "string" },
56
+ arch: { type: "string" },
57
+ libc: { type: "string" },
58
+ },
59
+ additionalProperties: false,
60
+ },
61
+ };
62
+
35
63
  /** The published layer index, written by `telo publish` (never hand-authored).
36
64
  * One entry per layer except the manifest layer, which cannot list its own hash
37
65
  * inside itself and is pinned by the importer's `#sha256-...` instead. Shape and
@@ -43,7 +71,7 @@ const LAYER_INDEX_SCHEMA = {
43
71
  type: "object",
44
72
  required: ["role", "blob", "integrity"],
45
73
  properties: {
46
- role: { type: "string", enum: ["controller", "assets", "common"] },
74
+ role: { type: "string", enum: ["controller", "library", "assets", "common"] },
47
75
  selector: {
48
76
  type: "object",
49
77
  required: ["format"],
@@ -620,48 +648,21 @@ export const KERNEL_BUILTINS: ResourceDefinition[] = [
620
648
  },
621
649
  additionalProperties: false,
622
650
  },
623
- // Inline flat invoke step: invoke an Invocable / Runnable on boot
624
- // with an optional `name` (for steps.<name>.result plumbing),
625
- // `when` guard, and `inputs`. Discriminated by the `invoke` key.
626
- // Control flow (if/while/switch/try) is not available here
627
- // reach for Run.Sequence. `invoke` is ref-only: a `!ref` that
628
- // resolves to the `{ kind, name }` shape below. Requiring `name`
629
- // rejects an inline `{ kind }` definition (no name) at analysis
630
- // instead of failing at boot with an undefined resource name. The
631
- // Invocable/Runnable kind set mirrors Run.Sequence invoke steps.
632
- {
633
- type: "object",
634
- required: ["invoke"],
635
- properties: {
636
- name: { type: "string" },
637
- invoke: {
638
- "x-telo-topology-role": "invoke",
639
- "x-telo-ref": {
640
- kind: "Telo.Executable",
641
- use: "call",
642
- inputs: "/inputs",
643
- },
644
- type: "object",
645
- required: ["kind", "name"],
646
- properties: {
647
- kind: { type: "string" },
648
- name: { type: "string" },
649
- },
650
- additionalProperties: true,
651
- },
652
- inputs: {
653
- // Same annotation Run.Sequence steps carry: it is what makes
654
- // a boot target's inputs visible to the call-site contract
655
- // check and to the wiring rule. Without it the kernel would
656
- // validate these at dispatch and nothing before it.
657
- "x-telo-topology-role": "inputs",
658
- type: "object",
659
- additionalProperties: true,
660
- },
661
- when: { type: "string" },
662
- },
663
- additionalProperties: false,
664
- },
651
+ // Inline flat invoke step, discriminated by the `invoke` key
652
+ // THE dispatch site, shared with every `Run` step array rather
653
+ // than restated here. Restating it is what made `retry:` a schema
654
+ // error at boot while working one line away in a sequence: not a
655
+ // decision about boot, just a copy that never grew the field.
656
+ // Control flow (if/while/switch/try) is still not available here
657
+ // reach for Run.Sequence.
658
+ // An expanded, stamped COPY. `builtins.ts` is not a manifest, so it
659
+ // never passes through the loader's expansion — embedding the
660
+ // fragment object itself would leave the nested `retry` as an
661
+ // unresolved `$ref` with no `x-telo-fragment` stamp, which is
662
+ // exactly what made LIVE_VALUE_RETRIED silently skip every boot
663
+ // target. A copy, because this is a module-level singleton and
664
+ // `resolveSchemaRefKinds` rewrites the `x-telo-ref` inside it.
665
+ manifestFragment("InvokeStep"),
665
666
  ],
666
667
  },
667
668
  },
@@ -872,6 +873,7 @@ export const KERNEL_BUILTINS: ResourceDefinition[] = [
872
873
  type: "array",
873
874
  items: { type: "string", not: { enum: ["variables", "secrets"] } },
874
875
  },
876
+ code: LIBRARY_CANDIDATES_SCHEMA,
875
877
  },
876
878
  additionalProperties: true,
877
879
  },
@@ -1,5 +1,6 @@
1
1
  import { Environment } from "@marcbachmann/cel-js";
2
2
  import type { ResourceManifest } from "@telorun/sdk";
3
+ import { authoredModuleMetadata, moduleMetadataSchema } from "./module-metadata-scope.js";
3
4
  import { jsonSchemaToCelType, VALUE_BRAND_BASE } from "./schema-compat.js";
4
5
 
5
6
  /** Transport protocol on a `ports` entry → the nominal CEL brand its resolved
@@ -89,6 +90,32 @@ export function buildTypedCelEnvironment(
89
90
  env.registerVariable("secrets", "map");
90
91
  env.registerVariable("resources", "map");
91
92
 
93
+ // `module` — the declaring module's own `metadata`, so a manifest reads its
94
+ // version instead of restating it. A resource forwarded from an imported
95
+ // library reads THAT library's metadata, stamped as
96
+ // `metadata.moduleGlobals.module`.
97
+ //
98
+ // Falls back to an OPEN map, never to `manifest.metadata`: for a resource
99
+ // doc that is the RESOURCE's metadata (`{name: <resource name>}`), and
100
+ // closing `module` over it would turn a `module.version` that resolves
101
+ // perfectly well at runtime into a hard error the author cannot act on. A
102
+ // static check that is wrong in the rejecting direction is the worse
103
+ // polarity.
104
+ const moduleSchema = moduleMetadataSchema(
105
+ ((manifest.metadata as Record<string, any> | undefined)?.moduleGlobals?.module as
106
+ | Record<string, unknown>
107
+ | undefined) ?? (rootModuleManifest?.metadata as Record<string, unknown> | undefined),
108
+ );
109
+ if (moduleSchema) {
110
+ const schema: Record<string, string> = {};
111
+ for (const [key, property] of Object.entries(moduleSchema.properties as Record<string, any>)) {
112
+ schema[key] = jsonSchemaToCelType(property);
113
+ }
114
+ (env as any).registerVariable({ name: "module", schema });
115
+ } else {
116
+ env.registerVariable("module", "map");
117
+ }
118
+
92
119
  if (extraContextSchema?.properties) {
93
120
  for (const [name, propSchema] of Object.entries(
94
121
  extraContextSchema.properties as Record<string, any>,
@@ -159,5 +186,15 @@ export function buildImportInputCelEnvironment(
159
186
  for (const name of ["resources", "ports"]) {
160
187
  (env as any).registerVariable({ name, schema: {} });
161
188
  }
189
+ // `module` IS part of it: the importer's own identity is config, and passing
190
+ // its version down to a child is the case the binding exists for.
191
+ const metadata = authoredModuleMetadata(mod?.metadata as Record<string, unknown> | undefined);
192
+ if (Object.keys(metadata).length > 0) {
193
+ const schema: Record<string, string> = {};
194
+ for (const key of Object.keys(metadata)) schema[key] = "dyn";
195
+ (env as any).registerVariable({ name: "module", schema });
196
+ } else {
197
+ env.registerVariable("module", "map");
198
+ }
162
199
  return env;
163
200
  }
@@ -100,6 +100,10 @@ export interface ModuleGlobals {
100
100
  variables?: Record<string, unknown>;
101
101
  secrets?: Record<string, unknown>;
102
102
  ports?: Record<string, unknown>;
103
+ /** The declaring module's own `metadata` — what `module.<field>` reads. Its
104
+ * values, not a schema, because the fields are literals in the manifest and
105
+ * the module a resource belongs to is fixed at analysis time. */
106
+ module?: Record<string, unknown>;
103
107
  }
104
108
 
105
109
  /** Read the schema-map blocks off a module doc, or `undefined` when it declares
@@ -120,6 +124,14 @@ function readModuleGlobals(libDoc: ResourceManifest | undefined): ModuleGlobals
120
124
  const block = doc[key];
121
125
  if (block && typeof block === "object" && !Array.isArray(block)) globals[key] = block;
122
126
  }
127
+ // `module` is the declaring module's own identity, so it travels with a
128
+ // forwarded manifest for the same reason the config blocks do: a library's
129
+ // `module.version` is the LIBRARY's, and typing it from the consumer's entry
130
+ // doc would report the app's version to the library that asked.
131
+ const metadata = doc.metadata;
132
+ if (metadata && typeof metadata === "object" && !Array.isArray(metadata)) {
133
+ globals.module = metadata as Record<string, unknown>;
134
+ }
123
135
  return Object.keys(globals).length > 0 ? globals : undefined;
124
136
  }
125
137
 
package/src/index.ts CHANGED
@@ -222,12 +222,14 @@ export {
222
222
  } from "./sources/manifest-cache.js";
223
223
  export type { ManifestCacheCoords } from "./sources/manifest-cache.js";
224
224
  export {
225
+ CODE_LAYER_ROLES,
225
226
  LAYER_ROLES,
226
227
  PLATFORM_AXES,
227
228
  ArtifactSelectorError,
228
229
  describeSelector,
229
230
  isLayerRole,
230
231
  normalizeSelector,
232
+ roleCarriesSelector,
231
233
  selectorFromQualifiers,
232
234
  selectorKey,
233
235
  selectorMatches,
@@ -240,9 +242,16 @@ export type {
240
242
  } from "./artifact-selector.js";
241
243
  export { collectModuleFileClaims } from "./module-file-claims.js";
242
244
  export type { ModuleFileClaim } from "./module-file-claims.js";
245
+ export { readLibraryCandidates } from "./module-library.js";
246
+ export type {
247
+ LibraryCandidate,
248
+ LibraryCandidateProblem,
249
+ LibraryCandidates,
250
+ } from "./module-library.js";
243
251
  export {
244
252
  LayerIndexError,
245
- matchControllerLayers,
253
+ codeLayerFor,
254
+ matchCodeLayers,
246
255
  parseLayerIndex,
247
256
  singletonLayer,
248
257
  } from "./artifact-layer-index.js";
@@ -270,4 +279,12 @@ export type {
270
279
  PositionIndex,
271
280
  Range
272
281
  } from "./types.js";
273
-
282
+ export * from "./manifest-schemas.js";
283
+ // The release model — module identity, fragments, the ledger, the edge graph and
284
+ // version planning. Pure data in, plan out, so the editor and the CLI release
285
+ // from one model.
286
+ export {
287
+ DERIVED_METADATA_FIELDS,
288
+ authoredModuleMetadata,
289
+ } from "./module-metadata-scope.js";
290
+ export * from "./release/index.js";