@telorun/analyzer 0.59.0 → 0.60.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 +9 -2
- package/dist/analyzer.d.ts.map +1 -1
- package/dist/analyzer.js +38 -10
- package/dist/builtins.d.ts.map +1 -1
- package/dist/builtins.js +16 -42
- package/dist/cel-environment.d.ts.map +1 -1
- package/dist/cel-environment.js +35 -0
- package/dist/flatten-for-analyzer.d.ts +4 -0
- package/dist/flatten-for-analyzer.d.ts.map +1 -1
- package/dist/flatten-for-analyzer.js +8 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -0
- package/dist/kernel-globals.d.ts +1 -1
- package/dist/kernel-globals.d.ts.map +1 -1
- package/dist/kernel-globals.js +15 -1
- package/dist/manifest-schemas.d.ts +407 -0
- package/dist/manifest-schemas.d.ts.map +1 -0
- package/dist/manifest-schemas.js +396 -0
- package/dist/module-file-claims.d.ts +6 -0
- package/dist/module-file-claims.d.ts.map +1 -1
- package/dist/module-file-claims.js +4 -0
- package/dist/module-metadata-scope.d.ts +44 -0
- package/dist/module-metadata-scope.d.ts.map +1 -0
- package/dist/module-metadata-scope.js +82 -0
- package/dist/parse-loaded-file.d.ts.map +1 -1
- package/dist/parse-loaded-file.js +16 -0
- package/dist/release/bump-level.d.ts +46 -0
- package/dist/release/bump-level.d.ts.map +1 -0
- package/dist/release/bump-level.js +80 -0
- package/dist/release/changelog.d.ts +36 -0
- package/dist/release/changelog.d.ts.map +1 -0
- package/dist/release/changelog.js +53 -0
- package/dist/release/fragment.d.ts +48 -0
- package/dist/release/fragment.d.ts.map +1 -0
- package/dist/release/fragment.js +84 -0
- package/dist/release/index.d.ts +26 -0
- package/dist/release/index.d.ts.map +1 -0
- package/dist/release/index.js +18 -0
- package/dist/release/ledger.d.ts +53 -0
- package/dist/release/ledger.d.ts.map +1 -0
- package/dist/release/ledger.js +109 -0
- package/dist/release/payload-digest.d.ts +63 -0
- package/dist/release/payload-digest.d.ts.map +1 -0
- package/dist/release/payload-digest.js +65 -0
- package/dist/release/release-plan.d.ts +128 -0
- package/dist/release/release-plan.d.ts.map +1 -0
- package/dist/release/release-plan.js +277 -0
- package/dist/release/version-stamp.d.ts +50 -0
- package/dist/release/version-stamp.d.ts.map +1 -0
- package/dist/release/version-stamp.js +119 -0
- package/dist/release/workspace-config.d.ts +41 -0
- package/dist/release/workspace-config.d.ts.map +1 -0
- package/dist/release/workspace-config.js +60 -0
- package/dist/schema-compat.js +2 -1
- package/dist/validate-step-inputs.d.ts +1 -1
- package/dist/validate-step-inputs.d.ts.map +1 -1
- package/dist/validate-step-inputs.js +99 -8
- package/package.json +3 -3
- package/src/analyzer.ts +41 -10
- package/src/builtins.ts +16 -42
- package/src/cel-environment.ts +37 -0
- package/src/flatten-for-analyzer.ts +12 -0
- package/src/index.ts +9 -1
- package/src/kernel-globals.ts +23 -1
- package/src/manifest-schemas.ts +408 -0
- package/src/module-file-claims.ts +10 -0
- package/src/module-metadata-scope.ts +88 -0
- package/src/parse-loaded-file.ts +16 -0
- package/src/release/bump-level.ts +95 -0
- package/src/release/changelog.ts +62 -0
- package/src/release/fragment.ts +130 -0
- package/src/release/index.ts +66 -0
- package/src/release/ledger.ts +142 -0
- package/src/release/payload-digest.ts +83 -0
- package/src/release/release-plan.ts +392 -0
- package/src/release/version-stamp.ts +142 -0
- package/src/release/workspace-config.ts +81 -0
- package/src/schema-compat.ts +1 -1
- package/src/validate-step-inputs.ts +109 -9
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turning evidence into a release plan: which modules bump, to what, and why.
|
|
3
|
+
*
|
|
4
|
+
* Three questions, three mechanisms, and keeping them apart is the whole design:
|
|
5
|
+
*
|
|
6
|
+
* - **Whether** a module bumps is decided by the **payload digest** — exact,
|
|
7
|
+
* from the bytes, so it sees an inlined sibling and a lockfile-only transitive
|
|
8
|
+
* bump that no path-scoped rule can.
|
|
9
|
+
* - **At what level** is decided by the **edge graph** — a declared fragment is a
|
|
10
|
+
* root, and a dependent mirrors its dependency's level, joined as the maximum
|
|
11
|
+
* over paths, because a module that inlines a breaking change is breaking for
|
|
12
|
+
* its own consumers.
|
|
13
|
+
* - **Whether a changelog line is requested** is decided by the path-scoped
|
|
14
|
+
* changed-files rule, which used to decide the version. Demoted, its guesswork
|
|
15
|
+
* costs one sentence rather than a spurious republish.
|
|
16
|
+
*
|
|
17
|
+
* The seam between digest and graph is *reported*, never papered over: a payload
|
|
18
|
+
* that moved with nothing to attribute it to — a third-party dependency, a
|
|
19
|
+
* changesets-owned package inlined into a module, a toolchain bump — resolves to
|
|
20
|
+
* `patch` and says so.
|
|
21
|
+
*
|
|
22
|
+
* Pure data in, plan out. Everything Node-shaped — building payloads, running
|
|
23
|
+
* the controller builder, reading git — is the CLI's half, so the editor can
|
|
24
|
+
* answer "what does changing this library bump?" from the same model.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import {
|
|
28
|
+
applyBump,
|
|
29
|
+
maxLevel,
|
|
30
|
+
type BumpLevel,
|
|
31
|
+
type FragmentKind,
|
|
32
|
+
levelOfKind,
|
|
33
|
+
} from "./bump-level.js";
|
|
34
|
+
import type { ModuleKey, ReleaseFragment } from "./fragment.js";
|
|
35
|
+
import type { Ledger } from "./ledger.js";
|
|
36
|
+
import { diffLayerDigests, type LayerChange, type LayerDigests } from "./payload-digest.js";
|
|
37
|
+
|
|
38
|
+
/** What a module ships. A `Dockerfile` beside the manifest makes it an image
|
|
39
|
+
* module; its absence, a registry artifact module. Derived rather than
|
|
40
|
+
* declared, so discovery stays configuration-free. */
|
|
41
|
+
export type ArtifactKind = "registry" | "image";
|
|
42
|
+
|
|
43
|
+
export interface ModuleEvidence {
|
|
44
|
+
/** Workspace-relative directory path — the module's key everywhere. */
|
|
45
|
+
readonly key: ModuleKey;
|
|
46
|
+
/** `metadata.name`, for display only. */
|
|
47
|
+
readonly name: string;
|
|
48
|
+
/** `metadata.version` as it stands in the working copy. */
|
|
49
|
+
readonly version: string;
|
|
50
|
+
readonly artifactKind: ArtifactKind;
|
|
51
|
+
/** Per-layer integrity of the payload built from the working copy. */
|
|
52
|
+
readonly layers: LayerDigests;
|
|
53
|
+
/**
|
|
54
|
+
* Files this module's build inlined that belong to ANOTHER workspace module,
|
|
55
|
+
* grouped by owner. From the build's own metafile: a declared-dependency graph
|
|
56
|
+
* cannot see `--external`, so `@telorun/sdk` — declared by 54 modules, inlined
|
|
57
|
+
* by none — would otherwise bump the whole standard library on every SDK
|
|
58
|
+
* change.
|
|
59
|
+
*/
|
|
60
|
+
readonly inlines: ReadonlyMap<ModuleKey, readonly string[]>;
|
|
61
|
+
/**
|
|
62
|
+
* Modules reached by an in-repo **relative** `imports:` source. A pinned
|
|
63
|
+
* registry ref is deliberately not an edge: pinning is the statement "I am not
|
|
64
|
+
* affected until I choose to be", and moving it is `telo upgrade`'s job.
|
|
65
|
+
*/
|
|
66
|
+
readonly imports: readonly ModuleKey[];
|
|
67
|
+
/** Whether a file under this module's own directory that reaches the artifact
|
|
68
|
+
* changed. Decides only whether a changelog line is requested. */
|
|
69
|
+
readonly ownFilesChanged: boolean;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface ReleaseEvidence {
|
|
73
|
+
readonly modules: readonly ModuleEvidence[];
|
|
74
|
+
readonly ledger: Ledger;
|
|
75
|
+
readonly fragments: readonly ReleaseFragment[];
|
|
76
|
+
/** The publish destination base the digests above were built against. */
|
|
77
|
+
readonly registry?: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Why a module is in the plan. A module usually carries several. */
|
|
81
|
+
export type BumpReason =
|
|
82
|
+
| { readonly kind: "declared"; readonly fragment: string; readonly as: FragmentKind }
|
|
83
|
+
| { readonly kind: "inlines"; readonly module: ModuleKey; readonly files: readonly string[] }
|
|
84
|
+
| { readonly kind: "imports"; readonly module: ModuleKey }
|
|
85
|
+
| { readonly kind: "unattributed" };
|
|
86
|
+
|
|
87
|
+
export interface ChangelogEntry {
|
|
88
|
+
readonly kind: FragmentKind;
|
|
89
|
+
readonly body: string;
|
|
90
|
+
/** The fragment this line came from, so `apply` can report what it consumed. */
|
|
91
|
+
readonly fragment: string;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface PlannedModule {
|
|
95
|
+
readonly key: ModuleKey;
|
|
96
|
+
readonly name: string;
|
|
97
|
+
readonly from: string;
|
|
98
|
+
readonly to: string;
|
|
99
|
+
readonly level: BumpLevel;
|
|
100
|
+
readonly reasons: readonly BumpReason[];
|
|
101
|
+
/** Layers whose digest differs from the ledger's reading. Empty for a module
|
|
102
|
+
* bumping purely because a dependency's version moves into its manifest. */
|
|
103
|
+
readonly changed: readonly LayerChange[];
|
|
104
|
+
readonly entries: readonly ChangelogEntry[];
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface ReleaseDiagnostic {
|
|
108
|
+
readonly severity: "error" | "warning";
|
|
109
|
+
readonly code: string;
|
|
110
|
+
readonly message: string;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export interface ReleasePlan {
|
|
114
|
+
/** Bumping modules in dependency order — a dependency precedes its
|
|
115
|
+
* dependents, which is also the order `telo publish` must push them. */
|
|
116
|
+
readonly modules: readonly PlannedModule[];
|
|
117
|
+
/** Fragments this plan consumes, for `apply` to delete. */
|
|
118
|
+
readonly fragments: readonly string[];
|
|
119
|
+
readonly diagnostics: readonly ReleaseDiagnostic[];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function planRelease(evidence: ReleaseEvidence): ReleasePlan {
|
|
123
|
+
const diagnostics: ReleaseDiagnostic[] = [];
|
|
124
|
+
const byKey = new Map(evidence.modules.map((module) => [module.key, module]));
|
|
125
|
+
|
|
126
|
+
checkRegistryAgreement(evidence, diagnostics);
|
|
127
|
+
|
|
128
|
+
// What the digest says, per module. A module with no ledger entry has never
|
|
129
|
+
// been published, which is not drift — there is nothing to differ from.
|
|
130
|
+
const drift = new Map<ModuleKey, LayerChange[]>();
|
|
131
|
+
for (const module of evidence.modules) {
|
|
132
|
+
const recorded = evidence.ledger.modules.get(module.key);
|
|
133
|
+
if (!recorded) continue;
|
|
134
|
+
if (recorded.version !== module.version) {
|
|
135
|
+
diagnostics.push({
|
|
136
|
+
severity: "error",
|
|
137
|
+
code: "LEDGER_VERSION_MISMATCH",
|
|
138
|
+
message:
|
|
139
|
+
`${module.key} declares version ${module.version} but the ledger records ` +
|
|
140
|
+
`${recorded.version} as published. The digests beside it were taken at that version, ` +
|
|
141
|
+
`so nothing here can be compared. Reconcile with \`telo release verify\` — or, if the ` +
|
|
142
|
+
`version was moved by hand, restore it and let \`telo release apply\` move it.`,
|
|
143
|
+
});
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
const changes = diffLayerDigests(recorded.layers, module.layers);
|
|
147
|
+
if (changes.length > 0) drift.set(module.key, changes);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const levels = new Map<ModuleKey, BumpLevel>();
|
|
151
|
+
const reasons = new Map<ModuleKey, BumpReason[]>();
|
|
152
|
+
const addReason = (key: ModuleKey, reason: BumpReason) => {
|
|
153
|
+
const list = reasons.get(key);
|
|
154
|
+
if (list) list.push(reason);
|
|
155
|
+
else reasons.set(key, [reason]);
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
// Declared roots.
|
|
159
|
+
const consumed = new Set<string>();
|
|
160
|
+
for (const fragment of evidence.fragments) {
|
|
161
|
+
for (const [key, kind] of fragment.modules) {
|
|
162
|
+
if (!byKey.has(key)) {
|
|
163
|
+
diagnostics.push({
|
|
164
|
+
severity: "error",
|
|
165
|
+
code: "FRAGMENT_UNKNOWN_MODULE",
|
|
166
|
+
message:
|
|
167
|
+
`${fragment.source} names '${key}', which is not a module in this workspace. ` +
|
|
168
|
+
`A fragment names a module by its workspace-relative directory path.`,
|
|
169
|
+
});
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
const level = levelOfKind(kind);
|
|
173
|
+
if (level === "major") {
|
|
174
|
+
diagnostics.push({
|
|
175
|
+
severity: "error",
|
|
176
|
+
code: "MAJOR_BUMP_REJECTED",
|
|
177
|
+
message:
|
|
178
|
+
`${fragment.source} declares '${key}: ${kind}', which induces a major bump. ` +
|
|
179
|
+
`Modules are intentionally pre-1.0 — a breaking change ships as a minor. ` +
|
|
180
|
+
`Use Added and describe the break in the body.`,
|
|
181
|
+
});
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
consumed.add(fragment.source);
|
|
185
|
+
levels.set(key, maxLevel(levels.get(key) ?? level, level));
|
|
186
|
+
addReason(key, { kind: "declared", fragment: fragment.source, as: kind });
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Edges already credited, so a second propagation pass does not repeat an
|
|
191
|
+
// explanation the author has already been given.
|
|
192
|
+
const credited = new Set<string>();
|
|
193
|
+
propagateToFixedPoint(evidence.modules, drift, levels, credited, addReason);
|
|
194
|
+
|
|
195
|
+
// The seam. A payload that moved with nothing to attribute it to still has to
|
|
196
|
+
// ship — the fix would otherwise reach nobody — so it takes a patch and is
|
|
197
|
+
// named. Seeded after propagation and then propagated again, because an
|
|
198
|
+
// unattributed bump is itself a dependency move for anything importing it.
|
|
199
|
+
for (;;) {
|
|
200
|
+
let seeded = false;
|
|
201
|
+
for (const module of evidence.modules) {
|
|
202
|
+
if (levels.has(module.key) || !drift.has(module.key)) continue;
|
|
203
|
+
levels.set(module.key, "patch");
|
|
204
|
+
addReason(module.key, { kind: "unattributed" });
|
|
205
|
+
seeded = true;
|
|
206
|
+
}
|
|
207
|
+
if (!seeded) break;
|
|
208
|
+
propagateToFixedPoint(evidence.modules, drift, levels, credited, addReason);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const entriesByModule = collectChangelogEntries(evidence.fragments, byKey);
|
|
212
|
+
requestMissingChangelogEntries(evidence.modules, entriesByModule, diagnostics);
|
|
213
|
+
|
|
214
|
+
const planned: PlannedModule[] = [];
|
|
215
|
+
for (const module of orderByImports(evidence.modules)) {
|
|
216
|
+
const level = levels.get(module.key);
|
|
217
|
+
if (!level) continue;
|
|
218
|
+
planned.push({
|
|
219
|
+
key: module.key,
|
|
220
|
+
name: module.name,
|
|
221
|
+
from: module.version,
|
|
222
|
+
to: applyBump(module.version, level),
|
|
223
|
+
level,
|
|
224
|
+
reasons: reasons.get(module.key) ?? [],
|
|
225
|
+
changed: drift.get(module.key) ?? [],
|
|
226
|
+
entries: entriesByModule.get(module.key) ?? [],
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return { modules: planned, fragments: [...consumed].sort(), diagnostics };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Raise levels along release edges until nothing moves.
|
|
235
|
+
*
|
|
236
|
+
* The two edge kinds answer different questions and so are gated differently:
|
|
237
|
+
*
|
|
238
|
+
* - An **import** edge bumps unconditionally. Publishing rewrites a relative
|
|
239
|
+
* `imports:` source to `<base>/<sibling>@<version>`, so when the sibling's
|
|
240
|
+
* version moves this module's manifest layer provably changes — a fact about
|
|
241
|
+
* the plan, which the current digest cannot yet show because it was taken
|
|
242
|
+
* against the sibling's *current* version.
|
|
243
|
+
* - An **inline** edge only explains drift that already showed up. The inlined
|
|
244
|
+
* bytes are in this payload, so if they had changed the digest would say so;
|
|
245
|
+
* attributing without that check would bump every dependent of a module whose
|
|
246
|
+
* fragment covers a docs-only change.
|
|
247
|
+
*/
|
|
248
|
+
function propagateToFixedPoint(
|
|
249
|
+
modules: readonly ModuleEvidence[],
|
|
250
|
+
drift: ReadonlyMap<ModuleKey, LayerChange[]>,
|
|
251
|
+
levels: Map<ModuleKey, BumpLevel>,
|
|
252
|
+
credited: Set<string>,
|
|
253
|
+
addReason: (key: ModuleKey, reason: BumpReason) => void,
|
|
254
|
+
): void {
|
|
255
|
+
for (let moved = true; moved; ) {
|
|
256
|
+
moved = false;
|
|
257
|
+
for (const module of modules) {
|
|
258
|
+
for (const dependency of module.imports) {
|
|
259
|
+
moved = raise(module, dependency, { kind: "imports", module: dependency }) || moved;
|
|
260
|
+
}
|
|
261
|
+
if (!drift.has(module.key)) continue;
|
|
262
|
+
for (const [dependency, files] of module.inlines) {
|
|
263
|
+
moved =
|
|
264
|
+
raise(module, dependency, { kind: "inlines", module: dependency, files }) || moved;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function raise(module: ModuleEvidence, dependency: ModuleKey, reason: BumpReason): boolean {
|
|
270
|
+
const from = levels.get(dependency);
|
|
271
|
+
if (!from) return false;
|
|
272
|
+
const current = levels.get(module.key);
|
|
273
|
+
const next = current ? maxLevel(current, from) : from;
|
|
274
|
+
// The reason is recorded the first time this edge carries anything, even
|
|
275
|
+
// when it does not raise the level: it is the explanation the author reads,
|
|
276
|
+
// and an edge that merely agrees with another still says why this module is
|
|
277
|
+
// in the plan.
|
|
278
|
+
const edge = `${module.key}\0${dependency}\0${reason.kind}`;
|
|
279
|
+
if (!credited.has(edge)) {
|
|
280
|
+
credited.add(edge);
|
|
281
|
+
addReason(module.key, reason);
|
|
282
|
+
}
|
|
283
|
+
if (current === next) return false;
|
|
284
|
+
levels.set(module.key, next);
|
|
285
|
+
return true;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function collectChangelogEntries(
|
|
290
|
+
fragments: readonly ReleaseFragment[],
|
|
291
|
+
byKey: ReadonlyMap<ModuleKey, ModuleEvidence>,
|
|
292
|
+
): Map<ModuleKey, ChangelogEntry[]> {
|
|
293
|
+
const entries = new Map<ModuleKey, ChangelogEntry[]>();
|
|
294
|
+
for (const fragment of fragments) {
|
|
295
|
+
for (const [key, kind] of fragment.modules) {
|
|
296
|
+
if (!byKey.has(key)) continue;
|
|
297
|
+
const list = entries.get(key);
|
|
298
|
+
const entry: ChangelogEntry = { kind, body: fragment.body, fragment: fragment.source };
|
|
299
|
+
if (list) list.push(entry);
|
|
300
|
+
else entries.set(key, [entry]);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
return entries;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Ask for prose where a human made a semantic change.
|
|
308
|
+
*
|
|
309
|
+
* A warning, not an error: `check`'s job is "can a complete, consistent plan be
|
|
310
|
+
* formed", and a module that drifted through propagation or a toolchain bump is
|
|
311
|
+
* planned and released without anyone writing a word. This fires only for a
|
|
312
|
+
* module whose OWN files moved, which is the case where the changelog would
|
|
313
|
+
* otherwise be silent about a real change.
|
|
314
|
+
*/
|
|
315
|
+
function requestMissingChangelogEntries(
|
|
316
|
+
modules: readonly ModuleEvidence[],
|
|
317
|
+
entries: ReadonlyMap<ModuleKey, ChangelogEntry[]>,
|
|
318
|
+
diagnostics: ReleaseDiagnostic[],
|
|
319
|
+
): void {
|
|
320
|
+
const missing = modules
|
|
321
|
+
.filter((module) => module.ownFilesChanged && !entries.has(module.key))
|
|
322
|
+
.map((module) => module.key);
|
|
323
|
+
if (missing.length === 0) return;
|
|
324
|
+
|
|
325
|
+
// ONE diagnostic listing the modules, not one per module. A change that
|
|
326
|
+
// touches every module's build script or a shared config asks the same
|
|
327
|
+
// question about forty of them at once, and forty copies of one sentence bury
|
|
328
|
+
// the plan they are printed beside — the more so because they go to stderr
|
|
329
|
+
// while the plan goes to stdout, so a piped run interleaves them.
|
|
330
|
+
//
|
|
331
|
+
// The fix for all of them is also one fragment, since a fragment names as many
|
|
332
|
+
// modules as it likes.
|
|
333
|
+
diagnostics.push({
|
|
334
|
+
severity: "warning",
|
|
335
|
+
code: "CHANGELOG_ENTRY_REQUESTED",
|
|
336
|
+
message:
|
|
337
|
+
`${missing.length} module(s) have their own changes but no fragment describes them, so ` +
|
|
338
|
+
`their changelogs will not mention this release: ${missing.join(", ")}. ` +
|
|
339
|
+
`One \`telo release add\` can name them all.`,
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
|
|
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
|
+
/**
|
|
366
|
+
* Dependency order over in-repo imports — a dependency before its dependents.
|
|
367
|
+
*
|
|
368
|
+
* This is also the publish order, and it is not optional there: publishing
|
|
369
|
+
* canonicalizes a relative import and then hard-fails when the derived ref does
|
|
370
|
+
* not already resolve, so a sibling has to be pushed first.
|
|
371
|
+
*
|
|
372
|
+
* A cycle keeps its members in key order rather than throwing. A module graph
|
|
373
|
+
* should not have one, but a release is the wrong moment to discover it, and the
|
|
374
|
+
* ordering degrades to "arbitrary among the cycle" rather than to nothing.
|
|
375
|
+
*/
|
|
376
|
+
export function orderByImports(modules: readonly ModuleEvidence[]): ModuleEvidence[] {
|
|
377
|
+
const byKey = new Map(modules.map((module) => [module.key, module]));
|
|
378
|
+
const ordered: ModuleEvidence[] = [];
|
|
379
|
+
const state = new Map<ModuleKey, "visiting" | "done">();
|
|
380
|
+
|
|
381
|
+
const visit = (key: ModuleKey): void => {
|
|
382
|
+
const module = byKey.get(key);
|
|
383
|
+
if (!module || state.get(key)) return;
|
|
384
|
+
state.set(key, "visiting");
|
|
385
|
+
for (const dependency of [...module.imports].sort()) visit(dependency);
|
|
386
|
+
state.set(key, "done");
|
|
387
|
+
ordered.push(module);
|
|
388
|
+
};
|
|
389
|
+
|
|
390
|
+
for (const key of [...byKey.keys()].sort()) visit(key);
|
|
391
|
+
return ordered;
|
|
392
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Writing a module's one version into every manifest it owns.
|
|
3
|
+
*
|
|
4
|
+
* A module has a single version across `telo.yaml`, `nodejs/package.json` and
|
|
5
|
+
* `rust/Cargo.toml`. Three formats, one rule: find the scalar, splice over its
|
|
6
|
+
* span, touch nothing else. That is `yaml-source-edit.ts`'s primitive — the same
|
|
7
|
+
* one the quick fix, `telo migrate` and `telo upgrade`'s pin rewrite use — so a
|
|
8
|
+
* bump lands as a one-line diff instead of a re-serialized file that re-folds
|
|
9
|
+
* every block scalar in a 900-line manifest.
|
|
10
|
+
*
|
|
11
|
+
* Each stamp returns `undefined` when the file carries no version to write,
|
|
12
|
+
* which is not an error: a module may own only a `telo.yaml`, and 42 of the
|
|
13
|
+
* standard library's packages have no Rust crate. A file that *has* a version in
|
|
14
|
+
* a shape this cannot address is a hard error instead, because silently skipping
|
|
15
|
+
* it would publish an artifact whose manifests disagree about what it is.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { defaultCustomTags } from "@telorun/templating";
|
|
19
|
+
import { isScalar, parseAllDocuments, parseDocument, type Scalar } from "yaml";
|
|
20
|
+
import { applyTextEdits, renderFixReplacement } from "../yaml-source-edit.js";
|
|
21
|
+
|
|
22
|
+
export class VersionStampError extends Error {}
|
|
23
|
+
|
|
24
|
+
/** Replace the scalar at `[start, end)` with `version`, re-quoted in the
|
|
25
|
+
* author's own style. */
|
|
26
|
+
function spliceScalar(text: string, node: Scalar, version: string, where: string): string {
|
|
27
|
+
const range = node.range;
|
|
28
|
+
if (!range) {
|
|
29
|
+
throw new VersionStampError(`${where}: the version scalar carries no source range.`);
|
|
30
|
+
}
|
|
31
|
+
const [start, end] = range;
|
|
32
|
+
const source = text.slice(start, end);
|
|
33
|
+
const replacement = renderFixReplacement(source, version);
|
|
34
|
+
if (replacement === undefined) {
|
|
35
|
+
throw new VersionStampError(
|
|
36
|
+
`${where}: the version is written as '${source}', which cannot be rewritten in place. ` +
|
|
37
|
+
`Write it as a plain or quoted scalar on one line.`,
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
return applyTextEdits(text, [{ start, end, newText: replacement }]);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* `metadata.version` on the **module doc** — the first document, the one whose
|
|
45
|
+
* kind is `Telo.Application` or `Telo.Library`. Deliberately not "any
|
|
46
|
+
* `metadata.version` in the file": a `Telo.Definition` further down may carry
|
|
47
|
+
* one, and the regex-replacement changie was configured with matched by line
|
|
48
|
+
* shape rather than by position, which is why it needed a hand-maintained count
|
|
49
|
+
* of how many lines it was allowed to hit.
|
|
50
|
+
*/
|
|
51
|
+
export function stampManifestVersion(
|
|
52
|
+
text: string,
|
|
53
|
+
version: string,
|
|
54
|
+
where: string,
|
|
55
|
+
): string | undefined {
|
|
56
|
+
const docs = parseAllDocuments(text, { customTags: defaultCustomTags() });
|
|
57
|
+
const doc = docs[0];
|
|
58
|
+
if (!doc) return undefined;
|
|
59
|
+
const metadata = doc.get("metadata", true);
|
|
60
|
+
if (!metadata || typeof (metadata as { get?: unknown }).get !== "function") return undefined;
|
|
61
|
+
const node = (metadata as { get(key: string, keepScalar: boolean): unknown }).get(
|
|
62
|
+
"version",
|
|
63
|
+
true,
|
|
64
|
+
);
|
|
65
|
+
if (!isScalar(node)) return undefined;
|
|
66
|
+
return spliceScalar(text, node, version, where);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Read the module doc's `metadata.version` without rewriting it. */
|
|
70
|
+
export function readManifestVersion(text: string): string | undefined {
|
|
71
|
+
const docs = parseAllDocuments(text, { customTags: defaultCustomTags() });
|
|
72
|
+
const first = docs[0]?.toJSON() as { kind?: unknown; metadata?: { version?: unknown } } | undefined;
|
|
73
|
+
if (first?.kind !== "Telo.Application" && first?.kind !== "Telo.Library") return undefined;
|
|
74
|
+
const version = first.metadata?.version;
|
|
75
|
+
return typeof version === "string" ? version : undefined;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The top-level `"version"` of a `package.json`.
|
|
80
|
+
*
|
|
81
|
+
* Parsed with the YAML reader rather than `JSON.parse`, because JSON is a YAML
|
|
82
|
+
* subset and this one needs the node's *source range* — `JSON.parse` discards it,
|
|
83
|
+
* and re-serializing with `JSON.stringify` would reformat a file whose
|
|
84
|
+
* indentation, key order and trailing newline are all conventions someone chose.
|
|
85
|
+
*/
|
|
86
|
+
export function stampPackageVersion(
|
|
87
|
+
text: string,
|
|
88
|
+
version: string,
|
|
89
|
+
where: string,
|
|
90
|
+
): string | undefined {
|
|
91
|
+
const doc = parseDocument(text);
|
|
92
|
+
const node = doc.get("version", true);
|
|
93
|
+
if (!isScalar(node)) return undefined;
|
|
94
|
+
return spliceScalar(text, node, version, where);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* `version` in a `Cargo.toml`'s `[package]` table.
|
|
99
|
+
*
|
|
100
|
+
* Scanned rather than parsed: TOML is not YAML, adding a TOML parser to the
|
|
101
|
+
* browser-safe analyzer for one scalar is not a trade worth making, and the
|
|
102
|
+
* shape being addressed is the canonical one cargo itself writes. The scan is
|
|
103
|
+
* bounded to the `[package]` table so a `version` under `[dependencies.x]`
|
|
104
|
+
* cannot be hit, and a `[package]` whose version is not a simple quoted scalar
|
|
105
|
+
* is refused rather than guessed at.
|
|
106
|
+
*/
|
|
107
|
+
export function stampCrateVersion(
|
|
108
|
+
text: string,
|
|
109
|
+
version: string,
|
|
110
|
+
where: string,
|
|
111
|
+
): string | undefined {
|
|
112
|
+
const table = /^[ \t]*\[package\][ \t]*$/m.exec(text);
|
|
113
|
+
if (!table) return undefined;
|
|
114
|
+
const bodyStart = table.index + table[0].length;
|
|
115
|
+
const next = /^[ \t]*\[/m.exec(text.slice(bodyStart));
|
|
116
|
+
const bodyEnd = next ? bodyStart + next.index : text.length;
|
|
117
|
+
|
|
118
|
+
const entry = /^([ \t]*version[ \t]*=[ \t]*)(".*?"|'.*?')[ \t]*$/m.exec(
|
|
119
|
+
text.slice(bodyStart, bodyEnd),
|
|
120
|
+
);
|
|
121
|
+
if (!entry) {
|
|
122
|
+
// A `[package]` with `version.workspace = true` inherits from the workspace
|
|
123
|
+
// and genuinely has nothing here to stamp; anything else is a shape this
|
|
124
|
+
// cannot address, and writing nothing would leave the crate behind.
|
|
125
|
+
if (/^[ \t]*version[ \t]*\.[ \t]*workspace[ \t]*=/m.test(text.slice(bodyStart, bodyEnd))) {
|
|
126
|
+
return undefined;
|
|
127
|
+
}
|
|
128
|
+
if (/^[ \t]*version[ \t]*=/m.test(text.slice(bodyStart, bodyEnd))) {
|
|
129
|
+
throw new VersionStampError(
|
|
130
|
+
`${where}: [package].version is not a quoted scalar on one line, so it cannot be ` +
|
|
131
|
+
`rewritten in place.`,
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
return undefined;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const start = bodyStart + entry.index + entry[1].length;
|
|
138
|
+
const quote = entry[2][0];
|
|
139
|
+
return applyTextEdits(text, [
|
|
140
|
+
{ start, end: start + entry[2].length, newText: `${quote}${version}${quote}` },
|
|
141
|
+
]);
|
|
142
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `telo-workspace.yaml` — the release anchor.
|
|
3
|
+
*
|
|
4
|
+
* Every path the release system names — a module key, a ledger entry, a
|
|
5
|
+
* fragment's `modules:` — is relative to this file's directory. That is the
|
|
6
|
+
* whole of its job: **its location is the anchor**, and its one field names the
|
|
7
|
+
* subtrees that may hold modules, which is not derivable (a whole-tree scan
|
|
8
|
+
* would read every example and every cached `.telo/manifests` copy as a released
|
|
9
|
+
* module).
|
|
10
|
+
*
|
|
11
|
+
* The file is **optional and read only by `telo release`**. Nothing else — not
|
|
12
|
+
* `run`, `check`, `publish`, `install`, `upgrade`, `migrate`, `module`, and not
|
|
13
|
+
* the kernel — looks for it, so a single-manifest repo, a bare `examples/`
|
|
14
|
+
* directory and a third-party module checkout keep working with nothing added.
|
|
15
|
+
* Any field added later must be true of the whole tree, not derivable from it,
|
|
16
|
+
* and harmless by its absence.
|
|
17
|
+
*
|
|
18
|
+
* Parsing lives here, in the browser-safe half, because the editor answers
|
|
19
|
+
* "what does changing this library bump?" from the same model. Finding the file
|
|
20
|
+
* on disk is the CLI's half — this side takes text.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { parseDocument } from "yaml";
|
|
24
|
+
|
|
25
|
+
export const WORKSPACE_FILENAME = "telo-workspace.yaml";
|
|
26
|
+
|
|
27
|
+
export interface WorkspaceConfig {
|
|
28
|
+
/**
|
|
29
|
+
* Gitignore-style patterns, workspace-relative, naming the subtrees that may
|
|
30
|
+
* hold modules (`modules/*`, `apps/*`). A pattern names a place to look, never
|
|
31
|
+
* a module: what makes a directory a module is its `telo.yaml`.
|
|
32
|
+
*/
|
|
33
|
+
readonly modules: readonly string[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export class WorkspaceConfigError extends Error {}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Parse the marker file's text.
|
|
40
|
+
*
|
|
41
|
+
* Strict about its one field, because there is nothing here to be lenient with:
|
|
42
|
+
* an empty or absent `modules:` names no subtree, so discovery would find no
|
|
43
|
+
* module and every gate would silently pass over a whole repo.
|
|
44
|
+
*/
|
|
45
|
+
export function parseWorkspaceConfig(text: string, where: string): WorkspaceConfig {
|
|
46
|
+
let value: unknown;
|
|
47
|
+
try {
|
|
48
|
+
value = parseDocument(text).toJSON();
|
|
49
|
+
} catch (err) {
|
|
50
|
+
throw new WorkspaceConfigError(
|
|
51
|
+
`${where} is not valid YAML: ${err instanceof Error ? err.message : String(err)}`,
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
55
|
+
throw new WorkspaceConfigError(`${where} must be a YAML mapping.`);
|
|
56
|
+
}
|
|
57
|
+
const record = value as Record<string, unknown>;
|
|
58
|
+
|
|
59
|
+
for (const key of Object.keys(record)) {
|
|
60
|
+
if (key !== "modules") {
|
|
61
|
+
throw new WorkspaceConfigError(
|
|
62
|
+
`${where}: unknown field '${key}'. The workspace marker carries only 'modules:' — ` +
|
|
63
|
+
`the subtrees that may hold modules.`,
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const modules = record.modules;
|
|
69
|
+
if (!Array.isArray(modules) || modules.some((entry) => typeof entry !== "string")) {
|
|
70
|
+
throw new WorkspaceConfigError(
|
|
71
|
+
`${where}: 'modules' must be a list of path patterns, e.g. [modules/*, apps/*].`,
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
if (modules.length === 0) {
|
|
75
|
+
throw new WorkspaceConfigError(
|
|
76
|
+
`${where}: 'modules' is empty, so no directory can ever be discovered as a module. ` +
|
|
77
|
+
`List the subtrees that hold them, e.g. [modules/*, apps/*].`,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
return { modules: modules as string[] };
|
|
81
|
+
}
|
package/src/schema-compat.ts
CHANGED
|
@@ -3,7 +3,6 @@ import addFormats from "ajv-formats";
|
|
|
3
3
|
import {
|
|
4
4
|
isRefSentinel,
|
|
5
5
|
isTaggedSentinel,
|
|
6
|
-
ManifestRootSchema,
|
|
7
6
|
producedTypeOf,
|
|
8
7
|
} from "@telorun/templating";
|
|
9
8
|
import {
|
|
@@ -14,6 +13,7 @@ import {
|
|
|
14
13
|
valueTypeOf,
|
|
15
14
|
valueTypePlaceholder,
|
|
16
15
|
} from "@telorun/sdk";
|
|
16
|
+
import { ManifestRootSchema } from "./manifest-schemas.js";
|
|
17
17
|
import { registerTeloKeywords } from "./value-type-keyword.js";
|
|
18
18
|
|
|
19
19
|
const Ajv = (AjvModule as any).default ?? AjvModule;
|