@telorun/analyzer 0.72.0 → 0.73.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 (90) hide show
  1. package/dist/analyzer.d.ts.map +1 -1
  2. package/dist/analyzer.js +50 -13
  3. package/dist/builtins.d.ts.map +1 -1
  4. package/dist/builtins.js +71 -97
  5. package/dist/cel-scope-query.d.ts.map +1 -1
  6. package/dist/cel-scope-query.js +32 -11
  7. package/dist/cel-scope.d.ts.map +1 -1
  8. package/dist/cel-scope.js +1 -0
  9. package/dist/eval-paths.d.ts +36 -5
  10. package/dist/eval-paths.d.ts.map +1 -1
  11. package/dist/eval-paths.js +47 -6
  12. package/dist/extends-resolution.d.ts +12 -0
  13. package/dist/extends-resolution.d.ts.map +1 -1
  14. package/dist/extends-resolution.js +23 -0
  15. package/dist/index.d.ts +1 -1
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +1 -1
  18. package/dist/nearest-name.d.ts +22 -0
  19. package/dist/nearest-name.d.ts.map +1 -0
  20. package/dist/nearest-name.js +43 -0
  21. package/dist/release/destinations.d.ts +59 -0
  22. package/dist/release/destinations.d.ts.map +1 -0
  23. package/dist/release/destinations.js +75 -0
  24. package/dist/release/index.d.ts +6 -2
  25. package/dist/release/index.d.ts.map +1 -1
  26. package/dist/release/index.js +3 -1
  27. package/dist/release/ledger.d.ts +20 -11
  28. package/dist/release/ledger.d.ts.map +1 -1
  29. package/dist/release/ledger.js +43 -14
  30. package/dist/release/release-plan.d.ts +3 -2
  31. package/dist/release/release-plan.d.ts.map +1 -1
  32. package/dist/release/release-plan.js +12 -19
  33. package/dist/release/workspace-config.d.ts +114 -34
  34. package/dist/release/workspace-config.d.ts.map +1 -1
  35. package/dist/release/workspace-config.js +322 -38
  36. package/dist/release/workspace-schema.d.ts +41 -0
  37. package/dist/release/workspace-schema.d.ts.map +1 -0
  38. package/dist/release/workspace-schema.js +77 -0
  39. package/dist/schema-error-report.d.ts +7 -0
  40. package/dist/schema-error-report.d.ts.map +1 -1
  41. package/dist/schema-error-report.js +9 -4
  42. package/dist/telo-version.d.ts +1 -1
  43. package/dist/telo-version.js +1 -1
  44. package/dist/template-body.d.ts +10 -0
  45. package/dist/template-body.d.ts.map +1 -1
  46. package/dist/template-body.js +25 -0
  47. package/dist/validate-cel-context.d.ts +23 -8
  48. package/dist/validate-cel-context.d.ts.map +1 -1
  49. package/dist/validate-cel-context.js +48 -16
  50. package/dist/validate-exports.d.ts +28 -0
  51. package/dist/validate-exports.d.ts.map +1 -0
  52. package/dist/validate-exports.js +146 -0
  53. package/dist/validate-extends.d.ts.map +1 -1
  54. package/dist/validate-extends.js +37 -1
  55. package/dist/validate-provider-coherence.d.ts +4 -8
  56. package/dist/validate-provider-coherence.d.ts.map +1 -1
  57. package/dist/validate-provider-coherence.js +12 -123
  58. package/dist/validate-references.d.ts.map +1 -1
  59. package/dist/validate-references.js +141 -60
  60. package/dist/validate-template-body.d.ts +36 -0
  61. package/dist/validate-template-body.d.ts.map +1 -0
  62. package/dist/validate-template-body.js +246 -0
  63. package/package.json +3 -2
  64. package/src/analyzer.ts +64 -11
  65. package/src/builtins.ts +85 -97
  66. package/src/cel-scope-query.ts +28 -9
  67. package/src/cel-scope.ts +1 -0
  68. package/src/eval-paths.ts +53 -6
  69. package/src/extends-resolution.ts +24 -0
  70. package/src/index.ts +1 -0
  71. package/src/nearest-name.ts +47 -0
  72. package/src/release/destinations.ts +105 -0
  73. package/src/release/index.ts +33 -2
  74. package/src/release/ledger.ts +51 -21
  75. package/src/release/release-plan.ts +16 -26
  76. package/src/release/workspace-config.ts +483 -47
  77. package/src/release/workspace-schema.ts +99 -0
  78. package/src/schema-error-report.ts +16 -4
  79. package/src/telo-version.ts +1 -1
  80. package/src/template-body.ts +25 -0
  81. package/src/validate-cel-context.ts +57 -16
  82. package/src/validate-exports.ts +185 -0
  83. package/src/validate-extends.ts +43 -0
  84. package/src/validate-provider-coherence.ts +17 -127
  85. package/src/validate-references.ts +141 -60
  86. package/src/validate-template-body.ts +274 -0
  87. package/dist/validate-template-dispatch.d.ts +0 -27
  88. package/dist/validate-template-dispatch.d.ts.map +0 -1
  89. package/dist/validate-template-dispatch.js +0 -95
  90. package/src/validate-template-dispatch.ts +0 -99
@@ -21,6 +21,7 @@ import { buildKernelGlobalsIndex } from "./kernel-globals.js";
21
21
  import { moduleAliasScope } from "./module-alias-scope.js";
22
22
  import { isModuleKind } from "./module-kinds.js";
23
23
  import { navigateConcretePath } from "./manifest-path.js";
24
+ import { kindAtPath } from "./validate-cel-context.js";
24
25
  import { findManifest } from "./find-manifest.js";
25
26
  import { resolveLocalRef, walkStepArray } from "./schema-walk.js";
26
27
  import { readStepSlot } from "./step-slot.js";
@@ -269,14 +270,27 @@ export class CelScopeQuery {
269
270
  }
270
271
 
271
272
  // A kind's own declaration: the target is the `Telo.Definition` document,
272
- // which is an ordinary manifest in the set.
273
+ // which is an ordinary manifest in the set. The slot holds a `!ref` to a
274
+ // `resources:` entry; the entry's own field wins where it narrows one, else
275
+ // the declaration is the entry's KIND. The first slot that resolves to an
276
+ // entry is the one the type resolver reads, so it is the one navigated.
273
277
  const fromRefKind = annotated["x-telo-context-from-ref-kind"];
274
- const first = Array.isArray(fromRefKind) ? fromRefKind[0] : fromRefKind;
275
- if (typeof first === "string") {
276
- const hash = first.indexOf("#");
277
- if (hash <= 0) return undefined;
278
- const kindValue = navigateConcretePath(root, first.slice(0, hash).split("/").join("."));
279
- if (typeof kindValue !== "string") return undefined;
278
+ const slots = Array.isArray(fromRefKind) ? fromRefKind : [fromRefKind];
279
+ for (const slotSpec of slots) {
280
+ if (typeof slotSpec !== "string") continue;
281
+ const hash = slotSpec.indexOf("#");
282
+ // `continue`, never `return`: a malformed spec is one entry of a list the
283
+ // type resolver walks to the end, and aborting here made the two answer
284
+ // differently for the same annotation.
285
+ if (hash <= 0) continue;
286
+ const field = slotSpec.slice(hash + 1);
287
+ const namedKind = kindAtPath(root, slotSpec.slice(0, hash));
288
+ if (!namedKind) continue;
289
+ if (namedKind.entry?.[field] !== undefined) {
290
+ const index = (root.resources as unknown[]).indexOf(namedKind.entry);
291
+ return { manifest: resource, path: `resources[${index}].${field}`, propertyMap: false };
292
+ }
293
+ const kindValue = namedKind.kind;
280
294
  // The kind was read off THIS resource, so it is spelled in the alias scope
281
295
  // of the module that declared it — not the entry's.
282
296
  const scope = moduleAliasScope(resource.metadata, this.ctx.aliases, this.ctx.aliasesByModule);
@@ -300,9 +314,14 @@ export class CelScopeQuery {
300
314
  (owningModule
301
315
  ? named.find((m) => (m.metadata as { module?: string } | undefined)?.module === owningModule)
302
316
  : undefined) ?? (owningModule && named.length > 1 ? undefined : named[0]);
303
- if (!target) return undefined;
304
- return { manifest: target, path: first.slice(hash + 1), propertyMap: false };
317
+ // Fall through to the next spec when this one names no kind, or names one
318
+ // that does not declare the field the same order the TYPE resolver
319
+ // takes, so go-to-declaration lands where the type came from instead of on
320
+ // a field the target never had.
321
+ if (!target || target[field] === undefined) continue;
322
+ return { manifest: target, path: field, propertyMap: false };
305
323
  }
324
+ if (slots.some((s) => typeof s === "string")) return undefined;
306
325
 
307
326
  // `x-telo-context-element-from` / `-collection-from` type a binding from an
308
327
  // EXPRESSION, so there is no declaration to navigate to.
package/src/cel-scope.ts CHANGED
@@ -651,6 +651,7 @@ export class CelScopeResolver {
651
651
  manifestRoot: rootForResolver,
652
652
  defs,
653
653
  aliases,
654
+ aliasesByModule: scopes?.aliasesByModule,
654
655
  allManifests: allManifests as Record<string, any>[],
655
656
  });
656
657
  return mergeKernelGlobalsIntoContext(
package/src/eval-paths.ts CHANGED
@@ -186,6 +186,36 @@ export function celEvalSites(schema: Record<string, any> | undefined): CelEvalSi
186
186
  return { compile, runtime, regions: extractCelRegionScopes(schema) };
187
187
  }
188
188
 
189
+ /**
190
+ * A BASE-FORM CHILD'S OWN FIELDS ARE COMPILE-EVAL WITHOUT ANNOTATION.
191
+ *
192
+ * A definition with `base:` has no controller of its own: the kernel evaluates
193
+ * the mapping once, at `create()`, against `self` — the instance's config — and
194
+ * hands the result to the inherited controller as the parent's config. The
195
+ * child's own schema fields never reach a controller; they exist to be read by
196
+ * `base:`, and the mapping expands whatever compiled value it reads. So every
197
+ * own field is evaluated exactly once at creation against the startup scope,
198
+ * which is what compile-eval IS. Declared here as the rule rather than left as a
199
+ * property of the mapping walk — the `Telo.Provider` posture, where a
200
+ * construction-time-only surface declares compile-eval once for all its fields
201
+ * rather than per field. Without it the rule was off for every inheritance kind
202
+ * (its capability is inherited, so the gate read `undefined`) and the
203
+ * expressions were never typed either: a `!cel "variables.whoo"` passed `telo
204
+ * check` and failed at boot.
205
+ *
206
+ * Read by the kernel's instance production and the analyzer's coverage decision,
207
+ * so the two cannot disagree about which fields a base child evaluates. An
208
+ * explicitly `runtime` own field still wins, through the same overlap rule a
209
+ * root `x-telo-eval: compile` follows.
210
+ */
211
+ export const IMPLICIT_COMPILE_SITES: CelEvalSites = { compile: ["**"], runtime: [], regions: [] };
212
+
213
+ export function implicitEvalSites(
214
+ definition: { base?: unknown } | undefined,
215
+ ): CelEvalSites {
216
+ return definition?.base != null ? IMPLICIT_COMPILE_SITES : NO_CEL_EVAL_SITES;
217
+ }
218
+
189
219
  /** The union of several schemas' sites — a kind's own and its capability
190
220
  * abstract's, which is how a `Telo.Provider`'s implicit compile-eval reaches
191
221
  * fields the provider never annotated. */
@@ -201,11 +231,16 @@ export function mergeCelEvalSites(...sites: CelEvalSites[]): CelEvalSites {
201
231
  * Whether the value at `path` is evaluated, and when — null for a field whose
202
232
  * value is read as a literal.
203
233
  *
204
- * `compile` wins over `runtime`, and both win over a region: a field's own
205
- * annotation is more specific than the region it sits in, which is the same
206
- * precedence a nested annotation has over an enclosing one. A region resolves to
207
- * `runtime` because that is what a region IS a per-invocation scope naming
208
- * what its expressions can read.
234
+ * An annotated field wins over the region it sits in a field's own annotation
235
+ * is more specific than an enclosing one, and a region resolves to `runtime`
236
+ * because that is what a region IS, a per-invocation scope naming what its
237
+ * expressions can read. Between the two annotations, `runtime` wins wherever
238
+ * they OVERLAP and `compile` answers everywhere else.
239
+ *
240
+ * That overlap rule is the kernel's, read back: its compile expansion skips any
241
+ * compile path a runtime path contains or is contained by, and under a root
242
+ * `**` it skips per top-level key — so a runtime-annotated field under an
243
+ * implicit compile root stays runtime, here as at dispatch.
209
244
  *
210
245
  * `path` is the `walkCelExpressions` spelling (`routes[0].returns[1].when`).
211
246
  */
@@ -213,8 +248,20 @@ export function celEvalModeAt(
213
248
  sites: CelEvalSites,
214
249
  path: string,
215
250
  ): "compile" | "runtime" | null {
216
- if (evalPathsCover(sites.compile, path)) return "compile";
251
+ const compiled = sites.compile.some((p) => {
252
+ if (!evalPathCovers(p, path)) return false;
253
+ const effective = p === "**" ? topLevelKey(path) : p;
254
+ return !sites.runtime.some(
255
+ (rp) => evalPathCovers(rp, effective) || evalPathCovers(effective, rp),
256
+ );
257
+ });
258
+ if (compiled) return "compile";
217
259
  if (evalPathsCover(sites.runtime, path)) return "runtime";
218
260
  if (sites.regions.some((scope) => pathMatchesScope(path, scope))) return "runtime";
219
261
  return null;
220
262
  }
263
+
264
+ function topLevelKey(path: string): string {
265
+ const end = path.search(/[.[]/);
266
+ return end === -1 ? path : path.slice(0, end);
267
+ }
@@ -137,6 +137,30 @@ export function effectiveAuthorSchema(
137
137
  return mergeTypeSchemas([parentSchema, own]) as Record<string, any>;
138
138
  }
139
139
 
140
+ /**
141
+ * The parent's required fields that a merge-form child leaves on its OWN author
142
+ * surface — inherited by the rule above, and not redeclared by the child.
143
+ *
144
+ * The one fact behind a confusing diagnostic: a kind written to wire a field
145
+ * internally still demands it from its consumer, because without `base:` the
146
+ * child is authored against merge(parent, own) and the parent's `required` comes
147
+ * along. Empty for a child with `base:` (its surface is its own schema) and for
148
+ * a kind that extends nothing, so a caller can use a non-empty result as the
149
+ * condition itself.
150
+ */
151
+ export function inheritedRequiredFields(
152
+ def: ResourceDefinition | undefined,
153
+ resolve: DefResolver,
154
+ ): string[] {
155
+ if (!body(def).extends || body(def).base) return [];
156
+ const parent = resolveParent(def, resolve);
157
+ if (!parent) return [];
158
+ const required = effectiveAuthorSchema(parent, resolve).required;
159
+ if (!Array.isArray(required)) return [];
160
+ const own = ((body(def).schema ?? {}).properties ?? {}) as Record<string, unknown>;
161
+ return required.filter((f): f is string => typeof f === "string" && !(f in own));
162
+ }
163
+
140
164
  /**
141
165
  * The fields a merge-form inheriting child publishes over its parent's reading.
142
166
  *
package/src/index.ts CHANGED
@@ -33,6 +33,7 @@ export {
33
33
  celEvalSites,
34
34
  declaresCelRegion,
35
35
  evalPathCovers,
36
+ implicitEvalSites,
36
37
  mergeCelEvalSites,
37
38
  pathMatchesScope,
38
39
  NO_CEL_EVAL_SITES,
@@ -0,0 +1,47 @@
1
+ import { distance } from "./levenshtein.js";
2
+
3
+ /**
4
+ * THE closest candidate to a misspelled name, or undefined.
5
+ *
6
+ * One rule, because a suggestion here is emitted as a `DiagnosticFix` — a
7
+ * whole-value replacement an editor applies in one click — so "closest" has to
8
+ * mean the same thing wherever it is offered. Two properties carry that:
9
+ *
10
+ * - **A TIE returns undefined.** Two candidates at equal distance make the pick
11
+ * arbitrary, and an arbitrary pick that is one click from being applied is
12
+ * worse than no suggestion: it resolves by whatever order the candidate set
13
+ * happened to be built in, which is a `Set`'s insertion order.
14
+ * - **The threshold scales but is CAPPED** (`min(3, len/3)`, and nothing under
15
+ * 1). Without the cap a long name accepts a distant match; without the floor
16
+ * a two-character name gets a "correction" sharing nothing with it.
17
+ *
18
+ * Both are `computeSuggestKind`'s rule, lifted here so the kind-suggestion, the
19
+ * template-body and the export checks cannot drift into three answers — which
20
+ * they had, two of them byte-identical copies differing from this one in both
21
+ * properties.
22
+ */
23
+ export function nearestName(
24
+ target: string,
25
+ candidates: Iterable<string>,
26
+ ): string | undefined {
27
+ if (!target) return undefined;
28
+ const threshold = Math.min(3, Math.floor(target.length / 3));
29
+ if (threshold < 1) return undefined;
30
+
31
+ let best: string | undefined;
32
+ let bestDist = threshold + 1;
33
+ let tied = false;
34
+
35
+ for (const candidate of candidates) {
36
+ const d = distance(target, candidate);
37
+ if (d < bestDist) {
38
+ best = candidate;
39
+ bestDist = d;
40
+ tied = false;
41
+ } else if (d === bestDist) {
42
+ tied = true;
43
+ }
44
+ }
45
+
46
+ return !best || bestDist > threshold || tied ? undefined : best;
47
+ }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Where each module publishes, and the two ways a set of destinations can be
3
+ * inconsistent.
4
+ *
5
+ * A destination is the module's resolved registry base plus its own directory
6
+ * name. That rule held silently for as long as every module sat at one depth
7
+ * under one base with a unique directory name; per-subtree bases remove that
8
+ * coincidence, so both failures it was hiding become reachable and are checked
9
+ * here — **at plan time, before any payload is built**, because the payload
10
+ * builder's own refusal speaks about a manifest published to two places rather
11
+ * than about the workspace file that said so.
12
+ *
13
+ * Pure data in, diagnostics out: the destinations are already computed for
14
+ * `telo release order`, so one derivation feeds that payload and both checks.
15
+ */
16
+
17
+ import type { ModuleKey } from "./fragment.js";
18
+ import type { ReleaseDiagnostic } from "./release-plan.js";
19
+
20
+ export interface ModuleDestination {
21
+ readonly key: ModuleKey;
22
+ /** `<registry>/<the module's own directory name>`. */
23
+ readonly destination: string;
24
+ }
25
+
26
+ /**
27
+ * A relative import between two workspace modules, with both answers about
28
+ * where the target publishes.
29
+ *
30
+ * `derived` is what the importer's own destination yields when the import path
31
+ * is applied to it — the transport's rule, and what ref canonicalization writes
32
+ * into the published manifest. `assigned` is what discovery independently gives
33
+ * that module.
34
+ */
35
+ export interface ImportDestination {
36
+ readonly from: ModuleKey;
37
+ readonly to: ModuleKey;
38
+ readonly derived: string;
39
+ readonly assigned: string;
40
+ }
41
+
42
+ /**
43
+ * Two modules must not resolve to one ref.
44
+ *
45
+ * Nothing else catches it: the payload builder is keyed by manifest, so it sees
46
+ * one module claimed by two destinations and never two modules claiming one,
47
+ * while the ledger keys by module — both entries would record digests for a
48
+ * single published artifact and reconciliation could never settle.
49
+ */
50
+ export function checkDestinationCollisions(
51
+ destinations: readonly ModuleDestination[],
52
+ ): ReleaseDiagnostic[] {
53
+ const byDestination = new Map<string, ModuleKey[]>();
54
+ for (const { key, destination } of destinations) {
55
+ const sharing = byDestination.get(destination);
56
+ if (sharing) sharing.push(key);
57
+ else byDestination.set(destination, [key]);
58
+ }
59
+
60
+ const diagnostics: ReleaseDiagnostic[] = [];
61
+ for (const [destination, keys] of byDestination) {
62
+ if (keys.length < 2) continue;
63
+ diagnostics.push({
64
+ severity: "error",
65
+ code: "DESTINATION_COLLISION",
66
+ message:
67
+ `${keys.sort().join(" and ")} both publish to '${destination}'. A module's ref is its ` +
68
+ `registry base plus its own directory name, so they would overwrite one artifact and ` +
69
+ `their ledger entries could never reconcile. Rename one directory, or give one of the ` +
70
+ `subtrees its own 'registry:' in telo-workspace.yaml.`,
71
+ });
72
+ }
73
+ return diagnostics;
74
+ }
75
+
76
+ /**
77
+ * A relative import is valid only where the importer's derived ref for its
78
+ * target equals the destination that module is independently assigned.
79
+ *
80
+ * Equal registries are necessary and not sufficient — two modules under one base
81
+ * at different directory depths derive differently too. Making the builder take
82
+ * the assigned destination instead is not the fix: it would put a ref in the
83
+ * artifact that the transport's resolution rule does not produce, so the
84
+ * manifest would say one thing and every consumer resolving relatively would
85
+ * compute another.
86
+ */
87
+ export function checkImportDestinations(
88
+ edges: readonly ImportDestination[],
89
+ ): ReleaseDiagnostic[] {
90
+ const diagnostics: ReleaseDiagnostic[] = [];
91
+ for (const edge of edges) {
92
+ if (edge.derived === edge.assigned) continue;
93
+ diagnostics.push({
94
+ severity: "error",
95
+ code: "IMPORT_DESTINATION_CONFLICT",
96
+ message:
97
+ `${edge.from} imports ${edge.to} by relative path, which canonicalizes to ` +
98
+ `'${edge.derived}' — but ${edge.to} publishes to '${edge.assigned}'. Publishing rewrites ` +
99
+ `the import to the ref its own path yields, so the artifact would name a module nobody ` +
100
+ `pushes. Give the two the same registry base and the same directory depth, or make it a ` +
101
+ `pinned remote import, which is what a dependency across a publish boundary is.`,
102
+ });
103
+ }
104
+ return diagnostics;
105
+ }
@@ -63,5 +63,36 @@ export {
63
63
  stampPackageVersion,
64
64
  } from "./version-stamp.js";
65
65
 
66
- export { WORKSPACE_FILENAME, WorkspaceConfigError, parseWorkspaceConfig } from "./workspace-config.js";
67
- export type { WorkspaceConfig } from "./workspace-config.js";
66
+ export { checkDestinationCollisions, checkImportDestinations } from "./destinations.js";
67
+ export type { ImportDestination, ModuleDestination } from "./destinations.js";
68
+
69
+ export {
70
+ DEFAULT_ENV_FILES,
71
+ DEFAULT_RELEASE_IGNORE,
72
+ WORKSPACE_FILENAME,
73
+ WorkspaceConfigError,
74
+ diagnosticsFor,
75
+ hasError,
76
+ matchesPatterns,
77
+ readWorkspaceConfig,
78
+ requireReleaseSettings,
79
+ settingsForModule,
80
+ } from "./workspace-config.js";
81
+ export type {
82
+ EnvSettings,
83
+ ModuleEntry,
84
+ ModuleSettings,
85
+ PatternMatch,
86
+ ReleaseSettings,
87
+ WorkspaceConfig,
88
+ WorkspaceDiagnostic,
89
+ WorkspaceDiagnosticCode,
90
+ WorkspaceRead,
91
+ } from "./workspace-config.js";
92
+
93
+ export {
94
+ MODULE_ENTRY_KEYS,
95
+ WORKSPACE_BLOCKS,
96
+ WORKSPACE_SCHEMA,
97
+ } from "./workspace-schema.js";
98
+ export type { WorkspaceBlockSchema, WorkspaceKeySchema } from "./workspace-schema.js";
@@ -14,10 +14,22 @@
14
14
  * drift: it means nothing is published, which is the correct reading for a
15
15
  * module that has never shipped.
16
16
  *
17
- * **It records the registry base**, because canonicalizing a relative `imports:`
18
- * source writes the destination into the manifest layer — so the digests below
19
- * are digests *against that base*, and comparing them to digests taken against
20
- * another one would be comparing two different artifacts.
17
+ * **Every entry records its own registry base**, because canonicalizing a
18
+ * relative `imports:` source writes the destination into the manifest layer — so
19
+ * each digest is a digest *against that base*, and comparing it to one taken
20
+ * against another would be comparing two different artifacts.
21
+ *
22
+ * Per entry rather than one top-level base with per-entry deltas: a workspace
23
+ * whose `release.modules` entries each author a destination has no meaningful
24
+ * top-level base, an absent one already means *nothing has been published yet*,
25
+ * and the agreement check returns early on that — so the multi-destination
26
+ * workspace would be the one whose bases are never compared. The file is
27
+ * generated and never hand-maintained, so the redundancy costs nothing and it
28
+ * removes the "differs from what?" question and the absent-versus-unknown
29
+ * conflation together. A **top-level `registry:` is a legacy form the reader
30
+ * accepts and never writes**, applied to every entry: the credential-free PR
31
+ * gate reads whatever ledger is committed on the branch, and that stays in the
32
+ * old shape until a release regenerates it.
21
33
  */
22
34
 
23
35
  import { Document, parseDocument } from "yaml";
@@ -31,17 +43,14 @@ export interface LedgerEntry {
31
43
  /** The version these digests were taken at — the tag the artifact published
32
44
  * under. */
33
45
  readonly version: string;
46
+ /** The publish destination base these digests were taken against
47
+ * (`oci://ghcr.io/telorun`). Absent only in a ledger written before the base
48
+ * was recorded at all. */
49
+ readonly registry?: string;
34
50
  readonly layers: LayerDigests;
35
51
  }
36
52
 
37
53
  export interface Ledger {
38
- /**
39
- * The publish destination base the digests were taken against
40
- * (`oci://ghcr.io/telorun`). Absent in a workspace that has published nothing
41
- * yet, which is why it is optional rather than required — but a `check` that
42
- * has entries and no base cannot reproduce them, and says so.
43
- */
44
- readonly registry?: string;
45
54
  readonly modules: ReadonlyMap<ModuleKey, LedgerEntry>;
46
55
  }
47
56
 
@@ -72,13 +81,14 @@ export function parseLedger(text: string, where: string): Ledger {
72
81
  }
73
82
  }
74
83
 
75
- const registry = record.registry;
76
- if (registry !== undefined && typeof registry !== "string") {
84
+ // The legacy top-level base: read and applied to every entry, never written.
85
+ const inherited = record.registry;
86
+ if (inherited !== undefined && typeof inherited !== "string") {
77
87
  throw new LedgerError(`${where}: 'registry' must be the publish destination base, as a string.`);
78
88
  }
79
89
 
80
90
  const rawModules = record.modules;
81
- if (rawModules === undefined || rawModules === null) return { ...(registry ? { registry } : {}), modules: new Map() };
91
+ if (rawModules === undefined || rawModules === null) return { modules: new Map() };
82
92
  if (typeof rawModules !== "object" || Array.isArray(rawModules)) {
83
93
  throw new LedgerError(`${where}: 'modules' must be a mapping of module path to entry.`);
84
94
  }
@@ -89,6 +99,20 @@ export function parseLedger(text: string, where: string): Ledger {
89
99
  throw new LedgerError(`${where}: entry '${key}' must be a mapping.`);
90
100
  }
91
101
  const entry = raw as Record<string, unknown>;
102
+ for (const field of Object.keys(entry)) {
103
+ if (field !== "version" && field !== "registry" && field !== "layers") {
104
+ throw new LedgerError(
105
+ `${where}: entry '${key}' has unknown field '${field}'. An entry carries 'version:', ` +
106
+ `'registry:' and 'layers:'.`,
107
+ );
108
+ }
109
+ }
110
+ if (entry.registry !== undefined && typeof entry.registry !== "string") {
111
+ throw new LedgerError(
112
+ `${where}: entry '${key}' has a non-string 'registry'. It is the publish destination base ` +
113
+ `the digests beside it were taken against.`,
114
+ );
115
+ }
92
116
  if (!isReleaseVersion(entry.version)) {
93
117
  throw new LedgerError(
94
118
  `${where}: entry '${key}' has no major.minor.patch 'version'. The ledger records the ` +
@@ -108,10 +132,15 @@ export function parseLedger(text: string, where: string): Ledger {
108
132
  layers[layer] = digest;
109
133
  }
110
134
  }
111
- modules.set(normalizeModuleKey(key), { version: entry.version, layers });
135
+ const registry = (entry.registry as string | undefined) ?? inherited;
136
+ modules.set(normalizeModuleKey(key), {
137
+ version: entry.version,
138
+ ...(registry ? { registry } : {}),
139
+ layers,
140
+ });
112
141
  }
113
142
 
114
- return { ...(registry ? { registry } : {}), modules };
143
+ return { modules };
115
144
  }
116
145
 
117
146
  /**
@@ -127,12 +156,13 @@ export function serializeLedger(ledger: Ledger): string {
127
156
  const entry = ledger.modules.get(key)!;
128
157
  const layers: Record<string, string> = {};
129
158
  for (const layer of Object.keys(entry.layers).sort()) layers[layer] = entry.layers[layer];
130
- modules[key] = { version: entry.version, layers };
159
+ modules[key] = {
160
+ version: entry.version,
161
+ ...(entry.registry ? { registry: entry.registry } : {}),
162
+ layers,
163
+ };
131
164
  }
132
- const doc = new Document({
133
- ...(ledger.registry ? { registry: ledger.registry } : {}),
134
- modules,
135
- });
165
+ const doc = new Document({ modules });
136
166
  return (
137
167
  "# Generated by `telo release apply` — what each module looks like as published.\n" +
138
168
  "# A cache of the registry's answer, so a PR gate needs no credentials.\n" +
@@ -67,14 +67,15 @@ export interface ModuleEvidence {
67
67
  /** Whether a file under this module's own directory that reaches the artifact
68
68
  * changed. Decides only whether a changelog line is requested. */
69
69
  readonly ownFilesChanged: boolean;
70
+ /** The publish destination base THIS module's payload was built against —
71
+ * per module, because a workspace may declare one per subtree. */
72
+ readonly registry: string;
70
73
  }
71
74
 
72
75
  export interface ReleaseEvidence {
73
76
  readonly modules: readonly ModuleEvidence[];
74
77
  readonly ledger: Ledger;
75
78
  readonly fragments: readonly ReleaseFragment[];
76
- /** The publish destination base the digests above were built against. */
77
- readonly registry?: string;
78
79
  }
79
80
 
80
81
  /** Why a module is in the plan. A module usually carries several. */
@@ -123,14 +124,25 @@ export function planRelease(evidence: ReleaseEvidence): ReleasePlan {
123
124
  const diagnostics: ReleaseDiagnostic[] = [];
124
125
  const byKey = new Map(evidence.modules.map((module) => [module.key, module]));
125
126
 
126
- checkRegistryAgreement(evidence, diagnostics);
127
-
128
127
  // What the digest says, per module. A module with no ledger entry has never
129
128
  // been published, which is not drift — there is nothing to differ from.
130
129
  const drift = new Map<ModuleKey, LayerChange[]>();
131
130
  for (const module of evidence.modules) {
132
131
  const recorded = evidence.ledger.modules.get(module.key);
133
132
  if (!recorded) continue;
133
+ if (recorded.registry && recorded.registry !== module.registry) {
134
+ diagnostics.push({
135
+ severity: "error",
136
+ code: "LEDGER_REGISTRY_MISMATCH",
137
+ message:
138
+ `${module.key}: its ledger digests were taken against '${recorded.registry}', but this ` +
139
+ `run built against '${module.registry}'. Publishing rewrites each relative import to ` +
140
+ `'<base>/<sibling>@<version>', so the manifest layers of the two are different bytes ` +
141
+ `and comparing them would report the module as changed. Publish to the recorded base, ` +
142
+ `or re-record with \`telo release verify --write\`.`,
143
+ });
144
+ continue;
145
+ }
134
146
  if (recorded.version !== module.version) {
135
147
  diagnostics.push({
136
148
  severity: "error",
@@ -340,28 +352,6 @@ function requestMissingChangelogEntries(
340
352
  });
341
353
  }
342
354
 
343
- /**
344
- * The base the digests were built against has to be the base they were recorded
345
- * against, or the manifest layers are not comparable: canonicalization writes
346
- * the destination into them.
347
- */
348
- function checkRegistryAgreement(
349
- evidence: ReleaseEvidence,
350
- diagnostics: ReleaseDiagnostic[],
351
- ): void {
352
- const recorded = evidence.ledger.registry;
353
- if (!recorded || !evidence.registry || recorded === evidence.registry) return;
354
- diagnostics.push({
355
- severity: "error",
356
- code: "LEDGER_REGISTRY_MISMATCH",
357
- message:
358
- `The ledger's digests were taken against '${recorded}', but this run built against ` +
359
- `'${evidence.registry}'. Publishing rewrites each relative import to ` +
360
- `'<base>/<sibling>@<version>', so the manifest layers of the two are different bytes ` +
361
- `and comparing them would report every module as changed.`,
362
- });
363
- }
364
-
365
355
  /**
366
356
  * Dependency order over in-repo imports — a dependency before its dependents.
367
357
  *