@telorun/analyzer 0.71.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.
- package/dist/analyzer.d.ts.map +1 -1
- package/dist/analyzer.js +92 -15
- package/dist/builtins.d.ts.map +1 -1
- package/dist/builtins.js +71 -97
- package/dist/catch-scope.d.ts +72 -0
- package/dist/catch-scope.d.ts.map +1 -0
- package/dist/catch-scope.js +102 -0
- package/dist/cel-scope-query.d.ts.map +1 -1
- package/dist/cel-scope-query.js +32 -11
- package/dist/cel-scope.d.ts.map +1 -1
- package/dist/cel-scope.js +1 -0
- package/dist/deprecation.d.ts +21 -0
- package/dist/deprecation.d.ts.map +1 -0
- package/dist/deprecation.js +26 -0
- package/dist/eval-paths.d.ts +36 -5
- package/dist/eval-paths.d.ts.map +1 -1
- package/dist/eval-paths.js +47 -6
- package/dist/extends-resolution.d.ts +12 -0
- package/dist/extends-resolution.d.ts.map +1 -1
- package/dist/extends-resolution.js +23 -0
- package/dist/index.d.ts +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -2
- package/dist/manifest-visitor.d.ts +17 -1
- package/dist/manifest-visitor.d.ts.map +1 -1
- package/dist/manifest-visitor.js +5 -1
- package/dist/migrations/report.d.ts +1 -1
- package/dist/migrations/report.d.ts.map +1 -1
- package/dist/migrations/report.js +5 -0
- package/dist/nearest-name.d.ts +22 -0
- package/dist/nearest-name.d.ts.map +1 -0
- package/dist/nearest-name.js +43 -0
- package/dist/ref-slot.d.ts +15 -0
- package/dist/ref-slot.d.ts.map +1 -1
- package/dist/ref-slot.js +7 -0
- package/dist/release/destinations.d.ts +59 -0
- package/dist/release/destinations.d.ts.map +1 -0
- package/dist/release/destinations.js +75 -0
- package/dist/release/index.d.ts +6 -2
- package/dist/release/index.d.ts.map +1 -1
- package/dist/release/index.js +3 -1
- package/dist/release/ledger.d.ts +20 -11
- package/dist/release/ledger.d.ts.map +1 -1
- package/dist/release/ledger.js +43 -14
- package/dist/release/release-plan.d.ts +3 -2
- package/dist/release/release-plan.d.ts.map +1 -1
- package/dist/release/release-plan.js +12 -19
- package/dist/release/workspace-config.d.ts +114 -34
- package/dist/release/workspace-config.d.ts.map +1 -1
- package/dist/release/workspace-config.js +322 -38
- package/dist/release/workspace-schema.d.ts +41 -0
- package/dist/release/workspace-schema.d.ts.map +1 -0
- package/dist/release/workspace-schema.js +77 -0
- package/dist/resolve-throws-union.d.ts +29 -1
- package/dist/resolve-throws-union.d.ts.map +1 -1
- package/dist/resolve-throws-union.js +111 -16
- package/dist/schema-compat.d.ts.map +1 -1
- package/dist/schema-compat.js +13 -1
- package/dist/schema-error-report.d.ts +7 -0
- package/dist/schema-error-report.d.ts.map +1 -1
- package/dist/schema-error-report.js +57 -8
- package/dist/schema-keywords.d.ts.map +1 -1
- package/dist/schema-keywords.js +3 -1
- package/dist/schema-walk.d.ts +27 -0
- package/dist/schema-walk.d.ts.map +1 -1
- package/dist/schema-walk.js +44 -0
- package/dist/telo-version.d.ts +1 -1
- package/dist/telo-version.js +1 -1
- package/dist/template-body.d.ts +10 -0
- package/dist/template-body.d.ts.map +1 -1
- package/dist/template-body.js +25 -0
- package/dist/types.d.ts +17 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +11 -0
- package/dist/validate-cel-context.d.ts +23 -8
- package/dist/validate-cel-context.d.ts.map +1 -1
- package/dist/validate-cel-context.js +48 -16
- package/dist/validate-exports.d.ts +28 -0
- package/dist/validate-exports.d.ts.map +1 -0
- package/dist/validate-exports.js +146 -0
- package/dist/validate-extends.d.ts.map +1 -1
- package/dist/validate-extends.js +37 -1
- package/dist/validate-identifier-names.d.ts +2 -2
- package/dist/validate-identifier-names.d.ts.map +1 -1
- package/dist/validate-identifier-names.js +22 -7
- package/dist/validate-provider-coherence.d.ts +4 -8
- package/dist/validate-provider-coherence.d.ts.map +1 -1
- package/dist/validate-provider-coherence.js +12 -123
- package/dist/validate-ref-slots.d.ts +1 -1
- package/dist/validate-ref-slots.d.ts.map +1 -1
- package/dist/validate-ref-slots.js +34 -0
- package/dist/validate-references.d.ts.map +1 -1
- package/dist/validate-references.js +168 -63
- package/dist/validate-template-body.d.ts +36 -0
- package/dist/validate-template-body.d.ts.map +1 -0
- package/dist/validate-template-body.js +246 -0
- package/dist/validate-throws-coverage.d.ts.map +1 -1
- package/dist/validate-throws-coverage.js +236 -85
- package/package.json +3 -2
- package/src/analyzer.ts +118 -12
- package/src/builtins.ts +85 -97
- package/src/catch-scope.ts +157 -0
- package/src/cel-scope-query.ts +28 -9
- package/src/cel-scope.ts +1 -0
- package/src/deprecation.ts +36 -0
- package/src/eval-paths.ts +53 -6
- package/src/extends-resolution.ts +24 -0
- package/src/index.ts +9 -1
- package/src/manifest-visitor.ts +19 -2
- package/src/migrations/report.ts +5 -1
- package/src/nearest-name.ts +47 -0
- package/src/ref-slot.ts +19 -0
- package/src/release/destinations.ts +105 -0
- package/src/release/index.ts +33 -2
- package/src/release/ledger.ts +51 -21
- package/src/release/release-plan.ts +16 -26
- package/src/release/workspace-config.ts +483 -47
- package/src/release/workspace-schema.ts +99 -0
- package/src/resolve-throws-union.ts +139 -21
- package/src/schema-compat.ts +13 -0
- package/src/schema-error-report.ts +66 -10
- package/src/schema-keywords.ts +4 -1
- package/src/schema-walk.ts +56 -0
- package/src/telo-version.ts +1 -1
- package/src/template-body.ts +25 -0
- package/src/types.ts +18 -0
- package/src/validate-cel-context.ts +57 -16
- package/src/validate-exports.ts +185 -0
- package/src/validate-extends.ts +43 -0
- package/src/validate-identifier-names.ts +28 -9
- package/src/validate-provider-coherence.ts +17 -127
- package/src/validate-ref-slots.ts +41 -1
- package/src/validate-references.ts +174 -63
- package/src/validate-template-body.ts +274 -0
- package/src/validate-throws-coverage.ts +333 -92
- package/dist/validate-template-dispatch.d.ts +0 -27
- package/dist/validate-template-dispatch.d.ts.map +0 -1
- package/dist/validate-template-dispatch.js +0 -95
- package/src/validate-template-dispatch.ts +0 -99
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `metadata.deprecated` — the annotation's single reader.
|
|
3
|
+
*
|
|
4
|
+
* Structural validity belongs to `validate-module-metadata.ts` (the strict half,
|
|
5
|
+
* the `ref-slot.ts` split). This side is deliberately lenient: it reads a
|
|
6
|
+
* well-formed block and treats everything else as absent, so a malformed
|
|
7
|
+
* declaration in a published dependency never becomes a warning at a consumer's
|
|
8
|
+
* use site. The consumer can fix neither one, and reporting the second blames
|
|
9
|
+
* the wrong author.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** A deprecation as declared: why, and optionally what to use instead. */
|
|
13
|
+
export interface Deprecation {
|
|
14
|
+
/** What a consumer reads to know what to do instead. Always non-empty. */
|
|
15
|
+
reason: string;
|
|
16
|
+
/** Alias-qualified kind (kind docs) or module ref (module docs), **as written
|
|
17
|
+
* in the declaring file's own scope** — `Self.Thing` means nothing to a
|
|
18
|
+
* consumer, so a use site resolves it before quoting it. */
|
|
19
|
+
replacedBy?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function readDeprecation(metadata: unknown): Deprecation | undefined {
|
|
23
|
+
if (metadata === null || typeof metadata !== "object" || Array.isArray(metadata)) return undefined;
|
|
24
|
+
const block = (metadata as Record<string, unknown>).deprecated;
|
|
25
|
+
if (block === null || typeof block !== "object" || Array.isArray(block)) return undefined;
|
|
26
|
+
|
|
27
|
+
const { reason, replacedBy } = block as Record<string, unknown>;
|
|
28
|
+
if (typeof reason !== "string" || reason.trim() === "") return undefined;
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
reason: reason.trim(),
|
|
32
|
+
...(typeof replacedBy === "string" && replacedBy.trim() !== ""
|
|
33
|
+
? { replacedBy: replacedBy.trim() }
|
|
34
|
+
: {}),
|
|
35
|
+
};
|
|
36
|
+
}
|
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
|
-
*
|
|
205
|
-
*
|
|
206
|
-
*
|
|
207
|
-
*
|
|
208
|
-
*
|
|
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
|
-
|
|
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,
|
|
@@ -185,6 +186,8 @@ export {
|
|
|
185
186
|
export type { RefSlot, RefUse, RefUseCases } from "./ref-slot.js";
|
|
186
187
|
export { isStepSlot, readStepSlot, STEP_FRAGMENT } from "./step-slot.js";
|
|
187
188
|
export type { StepSlot } from "./step-slot.js";
|
|
189
|
+
export { readDeprecation } from "./deprecation.js";
|
|
190
|
+
export type { Deprecation } from "./deprecation.js";
|
|
188
191
|
export {
|
|
189
192
|
ANNOTATION_KEYWORDS,
|
|
190
193
|
registerTeloKeywords,
|
|
@@ -489,7 +492,12 @@ export type { CelScopeQueryContext, ContextDeclarationSite } from "./cel-scope-q
|
|
|
489
492
|
export { navigateConcretePath } from "./manifest-path.js";
|
|
490
493
|
export { ManifestAnalysis } from "./manifest-analysis.js";
|
|
491
494
|
export type { ManifestRef } from "./manifest-analysis.js";
|
|
492
|
-
export {
|
|
495
|
+
export {
|
|
496
|
+
DEFAULT_MANIFEST_FILENAME,
|
|
497
|
+
DiagnosticSeverity,
|
|
498
|
+
DiagnosticTag,
|
|
499
|
+
diagnosticFix,
|
|
500
|
+
} from "./types.js";
|
|
493
501
|
export type {
|
|
494
502
|
AnalysisDiagnostic,
|
|
495
503
|
AnalysisOptions,
|
package/src/manifest-visitor.ts
CHANGED
|
@@ -66,8 +66,21 @@ export interface ScopeBoundaryEvent {
|
|
|
66
66
|
source: ResourceManifest;
|
|
67
67
|
/** Dot-form prefixes of every `x-telo-scope` field on this resource. */
|
|
68
68
|
scopePrefixes: string[];
|
|
69
|
-
/**
|
|
69
|
+
/** VISIBILITY pointer (an `x-telo-scope` entry, e.g. `/steps`) → the manifests
|
|
70
|
+
* visible there. Keyed by where scoped names may be REFERENCED, which is not
|
|
71
|
+
* where they are DECLARED: `Run.Sequence` declares `x-telo-scope:
|
|
72
|
+
* ["/steps", "/targets"]` on its `with:` field, so one declaration appears
|
|
73
|
+
* under two keys and neither names `with`. Use {@link declarations} for the
|
|
74
|
+
* declaration site. */
|
|
70
75
|
manifestsByPointer: Map<string, ResourceManifest[]>;
|
|
76
|
+
/** Every inline declaration inside this resource's scopes, ONCE each, with the
|
|
77
|
+
* concrete path it is written at in the owner's document (`with[0]`).
|
|
78
|
+
*
|
|
79
|
+
* A diagnostic about a scoped resource has to anchor there: the resource is
|
|
80
|
+
* not a top-level document, so position lookup finds the OWNER and then walks
|
|
81
|
+
* this path into it. Deriving one from a visibility pointer instead names a
|
|
82
|
+
* region the declaration is not in (`steps[0]`). */
|
|
83
|
+
declarations: { manifest: ResourceManifest; path: string }[];
|
|
71
84
|
/** Names of every resource declared inside this resource's scopes. Used by
|
|
72
85
|
* the dependency graph to drop boot edges to scoped (on-demand) targets. */
|
|
73
86
|
enclosedNames: Set<string>;
|
|
@@ -271,6 +284,7 @@ export function visitManifest(
|
|
|
271
284
|
if (refScopeMap && (wantsRefs || wantsScope)) {
|
|
272
285
|
const manifestsByPointer = new Map<string, ResourceManifest[]>();
|
|
273
286
|
const scopeRefEntries: { path: string; refName: string }[] = [];
|
|
287
|
+
const declarations: { manifest: ResourceManifest; path: string }[] = [];
|
|
274
288
|
for (const [fieldPath, entry] of refScopeMap) {
|
|
275
289
|
if (!isScopeEntry(entry)) continue;
|
|
276
290
|
const raw: ResourceManifest[] = [];
|
|
@@ -278,6 +292,7 @@ export function visitManifest(
|
|
|
278
292
|
const items = Array.isArray(fe.value) ? fe.value : [fe.value];
|
|
279
293
|
items.forEach((v, i) => {
|
|
280
294
|
if (!v || typeof v !== "object") return;
|
|
295
|
+
const declarationPath = Array.isArray(fe.value) ? `${fe.path}[${i}]` : fe.path;
|
|
281
296
|
// A scope entry must be an inline resource definition; a `!ref`
|
|
282
297
|
// (tagged sentinel or resolved `{kind, name}`) is not — record it
|
|
283
298
|
// so a static diagnostic flags it instead of registering a
|
|
@@ -288,12 +303,13 @@ export function visitManifest(
|
|
|
288
303
|
(typeof rec.kind === "string" && typeof rec.name === "string")
|
|
289
304
|
) {
|
|
290
305
|
scopeRefEntries.push({
|
|
291
|
-
path:
|
|
306
|
+
path: declarationPath,
|
|
292
307
|
refName: isRefSentinel(v) ? v.source : String(rec.name),
|
|
293
308
|
});
|
|
294
309
|
return;
|
|
295
310
|
}
|
|
296
311
|
raw.push(v as ResourceManifest);
|
|
312
|
+
declarations.push({ manifest: v as ResourceManifest, path: declarationPath });
|
|
297
313
|
});
|
|
298
314
|
}
|
|
299
315
|
const pointers = Array.isArray(entry.scope) ? entry.scope : [entry.scope];
|
|
@@ -313,6 +329,7 @@ export function visitManifest(
|
|
|
313
329
|
source: r,
|
|
314
330
|
scopePrefixes,
|
|
315
331
|
manifestsByPointer,
|
|
332
|
+
declarations,
|
|
316
333
|
enclosedNames,
|
|
317
334
|
scopeRefEntries,
|
|
318
335
|
});
|
package/src/migrations/report.ts
CHANGED
|
@@ -21,7 +21,7 @@ import type {
|
|
|
21
21
|
MigrationPath,
|
|
22
22
|
MigrationRewrite,
|
|
23
23
|
} from "./types.js";
|
|
24
|
-
import type
|
|
24
|
+
import { DiagnosticTag, type AnalysisDiagnostic, type DiagnosticFix } from "../types.js";
|
|
25
25
|
|
|
26
26
|
/** One patch that applied, as the reporting side needs to see it. */
|
|
27
27
|
export interface AppliedPatch {
|
|
@@ -63,6 +63,10 @@ export function toDiagnostic(
|
|
|
63
63
|
severity: applied.entry.severity,
|
|
64
64
|
code: applied.entry.code,
|
|
65
65
|
source: "telo-analyzer",
|
|
66
|
+
// A migration IS a deprecation — a legacy spelling still read, and the one
|
|
67
|
+
// an author is being asked to stop writing — so the range carries the tag
|
|
68
|
+
// whatever severity the entry chose for itself.
|
|
69
|
+
tags: [DiagnosticTag.Deprecated],
|
|
66
70
|
message: `${rewrite.summary}\n${applied.entry.reason}\n${closing}`,
|
|
67
71
|
data: {
|
|
68
72
|
filePath: source,
|
|
@@ -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
|
+
}
|
package/src/ref-slot.ts
CHANGED
|
@@ -94,6 +94,21 @@ export interface RefSlot {
|
|
|
94
94
|
* that carries this call's arguments. Replaces
|
|
95
95
|
* `x-telo-topology-role: inputs`. */
|
|
96
96
|
inputs?: string;
|
|
97
|
+
/** Throws raised by the target — or by anything the target drives — surface
|
|
98
|
+
* through the DECLARING resource, so its own throws union includes the
|
|
99
|
+
* target's and its catch scope encloses the target's.
|
|
100
|
+
*
|
|
101
|
+
* It exists because the relation is real where `use` correctly says nothing:
|
|
102
|
+
* `Http.Server.mounts[].mount` is a `dependency` (the server holds the mount
|
|
103
|
+
* and calls a convention method on it — control reaches a route through the
|
|
104
|
+
* MOUNT's own `trigger.inbound` slot, not through this one), yet a throw from
|
|
105
|
+
* that route is exactly what the server renders. Following every `dependency`
|
|
106
|
+
* edge instead would drag a connection's throws into a router's denominator.
|
|
107
|
+
*
|
|
108
|
+
* Declared by the kind that HOLDS, because only it knows the throws surface
|
|
109
|
+
* through it, and it is the same fact its runtime establishes by rethrowing —
|
|
110
|
+
* so the annotation and the behaviour cannot disagree. */
|
|
111
|
+
throwsThrough?: boolean;
|
|
97
112
|
/** `x-telo-inline: true` on the slot or any `anyOf` branch — accepts an inline
|
|
98
113
|
* `{kind, ...config}` definition, not only a `!ref`. */
|
|
99
114
|
inline: boolean;
|
|
@@ -228,6 +243,7 @@ export function readRefSlot(node: Record<string, any> | undefined): RefSlot | un
|
|
|
228
243
|
const uses = new Set<RefUse>();
|
|
229
244
|
let useCases: RefUseCases | undefined;
|
|
230
245
|
let inputs: string | undefined;
|
|
246
|
+
let throwsThrough = false;
|
|
231
247
|
|
|
232
248
|
for (const carrier of nodes) {
|
|
233
249
|
const annotation = carrier["x-telo-ref"];
|
|
@@ -239,6 +255,7 @@ export function readRefSlot(node: Record<string, any> | undefined): RefSlot | un
|
|
|
239
255
|
for (const use of normalizeUses(obj.use)) uses.add(use);
|
|
240
256
|
useCases ??= readUseCases(obj.use);
|
|
241
257
|
if (typeof obj.inputs === "string") inputs ??= obj.inputs;
|
|
258
|
+
if (obj.throwsThrough === true) throwsThrough = true;
|
|
242
259
|
}
|
|
243
260
|
|
|
244
261
|
const slot: RefSlot = {
|
|
@@ -249,6 +266,7 @@ export function readRefSlot(node: Record<string, any> | undefined): RefSlot | un
|
|
|
249
266
|
};
|
|
250
267
|
if (useCases) slot.useCases = useCases;
|
|
251
268
|
if (inputs !== undefined) slot.inputs = inputs;
|
|
269
|
+
if (throwsThrough) slot.throwsThrough = true;
|
|
252
270
|
return slot;
|
|
253
271
|
}
|
|
254
272
|
|
|
@@ -274,6 +292,7 @@ export function refSlotAnnotation(slot: RefSlot): Record<string, unknown> {
|
|
|
274
292
|
else if (slot.uses.length === 1) annotation.use = slot.uses[0];
|
|
275
293
|
else if (slot.uses.length > 1) annotation.use = slot.uses;
|
|
276
294
|
if (slot.inputs !== undefined) annotation.inputs = slot.inputs;
|
|
295
|
+
if (slot.throwsThrough) annotation.throwsThrough = true;
|
|
277
296
|
return annotation;
|
|
278
297
|
}
|
|
279
298
|
|
|
@@ -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
|
+
}
|
package/src/release/index.ts
CHANGED
|
@@ -63,5 +63,36 @@ export {
|
|
|
63
63
|
stampPackageVersion,
|
|
64
64
|
} from "./version-stamp.js";
|
|
65
65
|
|
|
66
|
-
export {
|
|
67
|
-
export type {
|
|
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";
|
package/src/release/ledger.ts
CHANGED
|
@@ -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
|
-
* **
|
|
18
|
-
* source writes the destination into the manifest layer — so
|
|
19
|
-
*
|
|
20
|
-
* another
|
|
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
|
-
|
|
76
|
-
|
|
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 {
|
|
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
|
-
|
|
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 {
|
|
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] = {
|
|
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" +
|