@telorun/analyzer 0.59.0 → 0.61.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/dist/analyzer.d.ts +9 -2
  2. package/dist/analyzer.d.ts.map +1 -1
  3. package/dist/analyzer.js +38 -10
  4. package/dist/artifact-layer-index.d.ts +13 -5
  5. package/dist/artifact-layer-index.d.ts.map +1 -1
  6. package/dist/artifact-layer-index.js +37 -13
  7. package/dist/artifact-selector.d.ts +10 -3
  8. package/dist/artifact-selector.d.ts.map +1 -1
  9. package/dist/artifact-selector.js +10 -1
  10. package/dist/builtins.d.ts.map +1 -1
  11. package/dist/builtins.js +44 -43
  12. package/dist/cel-environment.d.ts.map +1 -1
  13. package/dist/cel-environment.js +35 -0
  14. package/dist/flatten-for-analyzer.d.ts +4 -0
  15. package/dist/flatten-for-analyzer.d.ts.map +1 -1
  16. package/dist/flatten-for-analyzer.js +8 -0
  17. package/dist/index.d.ts +7 -2
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +9 -2
  20. package/dist/kernel-globals.d.ts +1 -1
  21. package/dist/kernel-globals.d.ts.map +1 -1
  22. package/dist/kernel-globals.js +15 -1
  23. package/dist/manifest-schemas.d.ts +407 -0
  24. package/dist/manifest-schemas.d.ts.map +1 -0
  25. package/dist/manifest-schemas.js +396 -0
  26. package/dist/module-file-claims.d.ts +13 -0
  27. package/dist/module-file-claims.d.ts.map +1 -1
  28. package/dist/module-file-claims.js +26 -2
  29. package/dist/module-library.d.ts +97 -0
  30. package/dist/module-library.d.ts.map +1 -0
  31. package/dist/module-library.js +162 -0
  32. package/dist/module-metadata-scope.d.ts +44 -0
  33. package/dist/module-metadata-scope.d.ts.map +1 -0
  34. package/dist/module-metadata-scope.js +82 -0
  35. package/dist/parse-loaded-file.d.ts.map +1 -1
  36. package/dist/parse-loaded-file.js +16 -0
  37. package/dist/release/bump-level.d.ts +46 -0
  38. package/dist/release/bump-level.d.ts.map +1 -0
  39. package/dist/release/bump-level.js +80 -0
  40. package/dist/release/changelog.d.ts +36 -0
  41. package/dist/release/changelog.d.ts.map +1 -0
  42. package/dist/release/changelog.js +53 -0
  43. package/dist/release/fragment.d.ts +48 -0
  44. package/dist/release/fragment.d.ts.map +1 -0
  45. package/dist/release/fragment.js +84 -0
  46. package/dist/release/index.d.ts +26 -0
  47. package/dist/release/index.d.ts.map +1 -0
  48. package/dist/release/index.js +18 -0
  49. package/dist/release/ledger.d.ts +53 -0
  50. package/dist/release/ledger.d.ts.map +1 -0
  51. package/dist/release/ledger.js +109 -0
  52. package/dist/release/payload-digest.d.ts +63 -0
  53. package/dist/release/payload-digest.d.ts.map +1 -0
  54. package/dist/release/payload-digest.js +65 -0
  55. package/dist/release/release-plan.d.ts +128 -0
  56. package/dist/release/release-plan.d.ts.map +1 -0
  57. package/dist/release/release-plan.js +277 -0
  58. package/dist/release/version-stamp.d.ts +50 -0
  59. package/dist/release/version-stamp.d.ts.map +1 -0
  60. package/dist/release/version-stamp.js +119 -0
  61. package/dist/release/workspace-config.d.ts +41 -0
  62. package/dist/release/workspace-config.d.ts.map +1 -0
  63. package/dist/release/workspace-config.js +60 -0
  64. package/dist/schema-compat.js +2 -1
  65. package/dist/validate-module-artifact.d.ts.map +1 -1
  66. package/dist/validate-module-artifact.js +53 -1
  67. package/dist/validate-step-inputs.d.ts +1 -1
  68. package/dist/validate-step-inputs.d.ts.map +1 -1
  69. package/dist/validate-step-inputs.js +99 -8
  70. package/package.json +3 -3
  71. package/src/analyzer.ts +41 -10
  72. package/src/artifact-layer-index.ts +47 -14
  73. package/src/artifact-selector.ts +15 -4
  74. package/src/builtins.ts +45 -43
  75. package/src/cel-environment.ts +37 -0
  76. package/src/flatten-for-analyzer.ts +12 -0
  77. package/src/index.ts +19 -2
  78. package/src/kernel-globals.ts +23 -1
  79. package/src/manifest-schemas.ts +408 -0
  80. package/src/module-file-claims.ts +42 -2
  81. package/src/module-library.ts +208 -0
  82. package/src/module-metadata-scope.ts +88 -0
  83. package/src/parse-loaded-file.ts +16 -0
  84. package/src/release/bump-level.ts +95 -0
  85. package/src/release/changelog.ts +62 -0
  86. package/src/release/fragment.ts +130 -0
  87. package/src/release/index.ts +66 -0
  88. package/src/release/ledger.ts +142 -0
  89. package/src/release/payload-digest.ts +83 -0
  90. package/src/release/release-plan.ts +392 -0
  91. package/src/release/version-stamp.ts +142 -0
  92. package/src/release/workspace-config.ts +81 -0
  93. package/src/schema-compat.ts +1 -1
  94. package/src/validate-module-artifact.ts +56 -0
  95. package/src/validate-step-inputs.ts +109 -9
@@ -0,0 +1,208 @@
1
+ /**
2
+ * A module's **exported code** — the `exports.code:` block on a `Telo.Library`
3
+ * doc, which names the entry point a *sibling module's* controller bundle
4
+ * resolves this module's bare specifier to.
5
+ *
6
+ * ```yaml
7
+ * exports:
8
+ * kinds:
9
+ * - Store
10
+ * code:
11
+ * - specifier: "@telorun/kv-store"
12
+ * format: js
13
+ * path: ./nodejs/kv-store.mjs
14
+ * source: ./nodejs/src/index.ts
15
+ * ```
16
+ *
17
+ * ## Why it sits under `exports:`
18
+ *
19
+ * A library already declares what crosses its boundary — the kinds importers may
20
+ * name, the resource instances they may `!ref`. This is the same statement about
21
+ * its *code*, and it gates the same way: a specifier nobody declares resolves to
22
+ * nothing. Putting it beside them keeps one block for "reachable from outside"
23
+ * rather than a second top-level key whose name (`library:` on a `Telo.Library`)
24
+ * meant a different thing from the kind one line above it.
25
+ *
26
+ * ## Why it is not a package URL
27
+ *
28
+ * `controllers:` names a PURL because it must be able to say `pkg:npm/…` or
29
+ * `pkg:cargo/…` — an ecosystem fetch. This entry never fetches: it names a file
30
+ * the module already ships, so `pkg:telo/local/` would be three constant segments
31
+ * before the first real datum. What is left after removing them is exactly these
32
+ * fields, and as data they are visually editable, where a query string is one
33
+ * opaque text box.
34
+ *
35
+ * The **model** is unchanged: `format` plus the optional platform axes build the
36
+ * same `ArtifactSelector` a controller candidate does, so layer matching, platform
37
+ * fallthrough and lazy materialization are inherited whole.
38
+ *
39
+ * ## Why the specifier is declared here
40
+ *
41
+ * A bundle imports the bare specifier `@telorun/sql`; the consumer's manifest
42
+ * declares the dependency as `Sql: ../sql`. Something has to connect the two, and
43
+ * it is the *library* that says so, once, rather than each of its consumers:
44
+ *
45
+ * - the specifier is a property of the library — its name in a host language's
46
+ * ecosystem — not of the relationship, so N consumers cannot disagree about it
47
+ * and adding a consumer restates nothing;
48
+ * - it sits beside the format, which keeps runtime **derived, never declared**:
49
+ * the entry says `format: js`, and a Rust entry carries `specifier:
50
+ * telorun-sql` with no runtime-keyed map anywhere.
51
+ *
52
+ * **One specifier, one entry point.** Subpaths are deliberately not
53
+ * representable: reproducing npm's `exports` map inside the artifact would pull a
54
+ * package manager's resolution semantics into Telo, which is what the "only
55
+ * workspace modules are de-inlined" rule refuses on `kysely`'s behalf.
56
+ *
57
+ * `Telo.Application` has no `exports:` block at all — an application is a root
58
+ * with no importer, so nothing could resolve a specifier to it.
59
+ *
60
+ * Browser-safe: string work only. Whether the named file EXISTS is a separate
61
+ * question, asked by the Node-side caller that has a directory.
62
+ */
63
+
64
+ import {
65
+ ArtifactSelectorError,
66
+ PLATFORM_AXES,
67
+ selectorFromQualifiers,
68
+ type ArtifactSelector,
69
+ } from "./artifact-selector.js";
70
+
71
+ /** Every key an entry may carry: the two locators, plus the selector axes. */
72
+ const KNOWN_KEYS = new Set<string>(["specifier", "path", "source", "format", ...PLATFORM_AXES]);
73
+
74
+ export interface LibraryCandidate {
75
+ /** The bare specifier a sibling's controller bundle imports this library by. */
76
+ readonly specifier: string;
77
+ /** Module-root-relative path of the built entry point. */
78
+ readonly path: string;
79
+ /** Module-root-relative TypeScript source it is built from (`source:`), when
80
+ * the entry names one. Present only while the module is a working copy; a
81
+ * published artifact ships no `src/`. */
82
+ readonly localPath?: string;
83
+ readonly selector: ArtifactSelector;
84
+ /** Where the entry was written, for diagnostics. */
85
+ readonly origin: string;
86
+ }
87
+
88
+ /** Why an `exports.code` entry could not be read. Returned rather than thrown so
89
+ * the analyzer can report every entry of a block, and so a reader on the load
90
+ * path can carry on with the entries that are well-formed. */
91
+ export interface LibraryCandidateProblem {
92
+ readonly origin: string;
93
+ readonly detail: string;
94
+ }
95
+
96
+ export interface LibraryCandidates {
97
+ readonly candidates: LibraryCandidate[];
98
+ readonly problems: LibraryCandidateProblem[];
99
+ }
100
+
101
+ /** Normalize a `path` / `source` value to the manifest-relative POSIX form the
102
+ * file selector returns, so membership is a string comparison. */
103
+ function normalizeRelative(value: string): string {
104
+ return value.replace(/^\.\//, "").replace(/\\/g, "/");
105
+ }
106
+
107
+ function requiredString(
108
+ entry: Record<string, unknown>,
109
+ key: string,
110
+ ): { value: string } | { detail: string } {
111
+ const raw = entry[key];
112
+ if (typeof raw !== "string" || raw.trim() === "") {
113
+ return { detail: `'${key}' is required and must be a non-empty string.` };
114
+ }
115
+ return { value: raw.trim() };
116
+ }
117
+
118
+ /**
119
+ * Read the `exports.code:` block off an owner document's JSON projection.
120
+ *
121
+ * Everything malformed is a problem rather than a silent skip: an entry that
122
+ * cannot be read names no entry point, so a consumer's bundle falls back to
123
+ * *inlining* the library — the duplicated module scope this whole mechanism
124
+ * exists to remove — and it does so on someone else's machine.
125
+ */
126
+ export function readLibraryCandidates(ownerJson: unknown): LibraryCandidates {
127
+ const declared = (ownerJson as { exports?: { code?: unknown } } | null)?.exports?.code;
128
+ const candidates: LibraryCandidate[] = [];
129
+ const problems: LibraryCandidateProblem[] = [];
130
+ if (declared === undefined) return { candidates, problems };
131
+ if (!Array.isArray(declared)) {
132
+ return {
133
+ candidates,
134
+ problems: [{ origin: "exports.code", detail: "expected a list of entries." }],
135
+ };
136
+ }
137
+
138
+ declared.forEach((raw, index) => {
139
+ const origin = `exports.code[${index}]`;
140
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
141
+ problems.push({ origin, detail: "expected an object." });
142
+ return;
143
+ }
144
+ const entry = raw as Record<string, unknown>;
145
+
146
+ const unknown = Object.keys(entry).filter((key) => !KNOWN_KEYS.has(key));
147
+ if (unknown.length > 0) {
148
+ // Reported, never ignored: an unrecognized platform axis would leave the
149
+ // entry platform-neutral and offer a single-platform file to every host.
150
+ problems.push({
151
+ origin,
152
+ detail:
153
+ `unknown ${unknown.length === 1 ? "key" : "keys"} ${unknown.map((k) => `'${k}'`).join(", ")}. ` +
154
+ `Known: ${[...KNOWN_KEYS].join(", ")}.`,
155
+ });
156
+ return;
157
+ }
158
+
159
+ const specifier = requiredString(entry, "specifier");
160
+ if ("detail" in specifier) {
161
+ problems.push({ origin, detail: specifier.detail });
162
+ return;
163
+ }
164
+ const file = requiredString(entry, "path");
165
+ if ("detail" in file) {
166
+ problems.push({ origin: `${origin} ('${specifier.value}')`, detail: file.detail });
167
+ return;
168
+ }
169
+ // Explicit rather than inferred from the file extension: a `.mjs` can be
170
+ // wasm glue, and an inference rule is something every other runtime's reader
171
+ // would have to copy exactly.
172
+ const format = requiredString(entry, "format");
173
+ if ("detail" in format) {
174
+ problems.push({ origin: `${origin} ('${specifier.value}')`, detail: format.detail });
175
+ return;
176
+ }
177
+
178
+ let selector: ArtifactSelector;
179
+ try {
180
+ selector = selectorFromQualifiers(format.value, entry, `${origin} ('${specifier.value}')`);
181
+ } catch (err) {
182
+ problems.push({
183
+ origin: `${origin} ('${specifier.value}')`,
184
+ detail: err instanceof ArtifactSelectorError ? err.message : String(err),
185
+ });
186
+ return;
187
+ }
188
+
189
+ const source = entry.source;
190
+ if (source !== undefined && (typeof source !== "string" || source.trim() === "")) {
191
+ problems.push({
192
+ origin: `${origin} ('${specifier.value}')`,
193
+ detail: "'source' must be a non-empty string when present.",
194
+ });
195
+ return;
196
+ }
197
+
198
+ candidates.push({
199
+ specifier: specifier.value,
200
+ path: normalizeRelative(file.value),
201
+ ...(typeof source === "string" ? { localPath: normalizeRelative(source.trim()) } : {}),
202
+ selector,
203
+ origin: `${origin} ('${specifier.value}')`,
204
+ });
205
+ });
206
+
207
+ return { candidates, problems };
208
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * What `module.<field>` may read: the metadata an AUTHOR wrote, never the
3
+ * loader's own stamps.
4
+ *
5
+ * A manifest reaching its own `metadata` is a small binding with one sharp edge.
6
+ * By the time analysis or the runtime sees a module doc, its `metadata` also
7
+ * carries fields nothing authored — `source` and `sourceLine` (the loader's
8
+ * provenance), `module`, `moduleGlobals`, `exportedKinds`, `reExportedKinds`,
9
+ * `forwardedExport` (derived indices the analyzer stamps). Exposing those would
10
+ * publish loader internals as a manifest surface, where they would be read,
11
+ * depended on, and then unchangeable.
12
+ *
13
+ * A DENYLIST rather than an allowlist, because the metadata vocabulary is
14
+ * deliberately open: a module may declare a field the standard library has never
15
+ * heard of, and an allowlist would silently hide it. The stamps, by contrast,
16
+ * are a closed set this repo controls — so the thing that can be enumerated is
17
+ * the thing enumerated.
18
+ */
19
+
20
+ /** Fields written by the loader or the analyzer, not by the module's author. */
21
+ export const DERIVED_METADATA_FIELDS: ReadonlySet<string> = new Set([
22
+ "source",
23
+ "sourceLine",
24
+ "module",
25
+ "moduleGlobals",
26
+ "exportedKinds",
27
+ "reExportedKinds",
28
+ "forwardedExport",
29
+ ]);
30
+
31
+ /** The author-written half of a module doc's `metadata`. */
32
+ export function authoredModuleMetadata(
33
+ metadata: Record<string, unknown> | undefined | null,
34
+ ): Record<string, unknown> {
35
+ if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) return {};
36
+ const authored: Record<string, unknown> = {};
37
+ for (const [key, value] of Object.entries(metadata)) {
38
+ if (DERIVED_METADATA_FIELDS.has(key)) continue;
39
+ authored[key] = value;
40
+ }
41
+ return authored;
42
+ }
43
+
44
+ /**
45
+ * The `module` namespace as a JSON Schema, or `undefined` when there is nothing
46
+ * to type it from.
47
+ *
48
+ * **One derivation, two consumers.** `cel-environment.ts` needs CEL type strings
49
+ * and `kernel-globals.ts` needs JSON Schema, and they used to reach the same
50
+ * conclusion through two hand-written ternary chains that had to agree forever
51
+ * about which values are open and which are closed. The schema is the richer of
52
+ * the two shapes, so it is what is derived; the CEL side converts with
53
+ * `jsonSchemaToCelType`, which every other namespace already goes through.
54
+ *
55
+ * Typed from the VALUES because a module doc's metadata is literals, not a
56
+ * schema map — the module a resource belongs to is fixed, so there is nothing to
57
+ * resolve.
58
+ *
59
+ * `undefined` means **open**, and the distinction matters in the rejecting
60
+ * direction: a set with no module doc must leave `module.*` unconstrained rather
61
+ * than close it over whatever metadata happened to be at hand, or a valid
62
+ * `module.version` becomes a hard error nobody can act on.
63
+ */
64
+ export function moduleMetadataSchema(
65
+ metadata: Record<string, unknown> | undefined | null,
66
+ ): Record<string, any> | undefined {
67
+ const authored = authoredModuleMetadata(metadata);
68
+ const keys = Object.keys(authored);
69
+ if (keys.length === 0) return undefined;
70
+
71
+ const properties: Record<string, any> = {};
72
+ for (const key of keys) {
73
+ const value = authored[key];
74
+ properties[key] = Array.isArray(value)
75
+ ? { type: "array" }
76
+ : value !== null && typeof value === "object"
77
+ ? { type: "object", additionalProperties: true }
78
+ : {
79
+ type:
80
+ typeof value === "number"
81
+ ? "number"
82
+ : typeof value === "boolean"
83
+ ? "boolean"
84
+ : "string",
85
+ };
86
+ }
87
+ return { type: "object", properties, additionalProperties: false };
88
+ }
@@ -7,6 +7,7 @@ import type { LoadedFile, ParseError } from "./loaded-types.js";
7
7
  import { migrateManifests, NO_MIGRATIONS } from "./migrations/driver.js";
8
8
  import type { MigrationEntry } from "./migrations/types.js";
9
9
  import { buildDocumentPositions } from "./position-metadata.js";
10
+ import { expandManifestFragments } from "./manifest-schemas.js";
10
11
  import { precompileDoc } from "./precompile.js";
11
12
  import { documentToAst } from "./yaml-ast.js";
12
13
 
@@ -89,6 +90,21 @@ export function parseLoadedFile(
89
90
  ? migrateManifests({ source, manifests, entries: options.migrations })
90
91
  : NO_MIGRATIONS;
91
92
 
93
+ // Shared structural fragments (`telo://manifest#/$defs/InvokeStep`) are
94
+ // expanded for EVERY consumer, deliberately ungated.
95
+ //
96
+ // They are not authoring sugar the way `imports:` or a `!ref` tag is — they are
97
+ // the analyzer's own closed set, and nothing downstream is equipped to meet one
98
+ // unresolved: the editor's schema resolver handles document-local `#/` refs
99
+ // only and THROWS on anything else, so a gated expansion took every canvas that
100
+ // renders a `Run` step down. Round-tripping is unaffected because a save writes
101
+ // the YAML documents the editor holds separately (`saveModuleFromDocuments`),
102
+ // never a manifest object — so unlike `migrate`, no expansion here can reach an
103
+ // author's file.
104
+ for (const manifest of manifests) {
105
+ if (manifest) expandManifestFragments(manifest);
106
+ }
107
+
92
108
  let env: Environment | undefined;
93
109
  if (options?.compile) {
94
110
  for (let i = 0; i < manifests.length; i++) {
@@ -0,0 +1,95 @@
1
+ /**
2
+ * The two vocabularies a release speaks, and the arithmetic between them.
3
+ *
4
+ * A **kind** is what an author writes in a fragment (`Added`, `Fixed`, …). It is
5
+ * changie's vocabulary, kept because it drives two things at once: the semantic
6
+ * level of the bump, and the heading the entry lands under in the changelog. A
7
+ * **level** is what the version arithmetic consumes.
8
+ *
9
+ * The mapping is the only place the two meet, and it is deliberately total: an
10
+ * unrecognized kind is refused rather than defaulted, because a typo that
11
+ * degraded to `patch` would silently under-release a breaking change.
12
+ */
13
+
14
+ /** Semantic level of a version move. */
15
+ export type BumpLevel = "major" | "minor" | "patch";
16
+
17
+ /** Ordering used whenever several levels reach one module — the maximum wins,
18
+ * because a module that inlines a breaking change is breaking for its own
19
+ * consumers. */
20
+ const LEVEL_RANK: Record<BumpLevel, number> = { patch: 0, minor: 1, major: 2 };
21
+
22
+ export function maxLevel(a: BumpLevel, b: BumpLevel): BumpLevel {
23
+ return LEVEL_RANK[a] >= LEVEL_RANK[b] ? a : b;
24
+ }
25
+
26
+ /**
27
+ * Fragment kinds, in changelog section order, each with the level it induces.
28
+ *
29
+ * `Changed` / `Removed` induce `major`, which the pre-1.0 guard then rejects —
30
+ * they are kept in the vocabulary rather than dropped so the rejection can name
31
+ * what was written and say why, instead of reporting an unknown kind.
32
+ */
33
+ export const FRAGMENT_KINDS = {
34
+ Added: "minor",
35
+ Changed: "major",
36
+ Deprecated: "minor",
37
+ Removed: "major",
38
+ Fixed: "patch",
39
+ Security: "patch",
40
+ } as const satisfies Record<string, BumpLevel>;
41
+
42
+ export type FragmentKind = keyof typeof FRAGMENT_KINDS;
43
+
44
+ /** Declaration order, which is also the order sections appear in a changelog
45
+ * release block. */
46
+ export const FRAGMENT_KIND_ORDER: readonly FragmentKind[] = Object.keys(
47
+ FRAGMENT_KINDS,
48
+ ) as FragmentKind[];
49
+
50
+ export function isFragmentKind(value: unknown): value is FragmentKind {
51
+ return typeof value === "string" && value in FRAGMENT_KINDS;
52
+ }
53
+
54
+ export function levelOfKind(kind: FragmentKind): BumpLevel {
55
+ return FRAGMENT_KINDS[kind];
56
+ }
57
+
58
+ /** A `major.minor.patch` triple. Pre-release and build metadata are not accepted:
59
+ * a module version is the tag an artifact publishes under and the value a pin
60
+ * resolves, and neither has a meaning for a suffix today. */
61
+ const VERSION = /^(\d+)\.(\d+)\.(\d+)$/;
62
+
63
+ export function isReleaseVersion(value: unknown): value is string {
64
+ return typeof value === "string" && VERSION.test(value);
65
+ }
66
+
67
+ /** Apply `level` to `version`. Throws on a version this system cannot represent,
68
+ * rather than returning something plausible — every caller has already read the
69
+ * value out of a manifest it is about to rewrite. */
70
+ export function applyBump(version: string, level: BumpLevel): string {
71
+ const match = VERSION.exec(version);
72
+ if (!match) {
73
+ throw new Error(
74
+ `'${version}' is not a major.minor.patch version, so no ${level} bump can be derived from it.`,
75
+ );
76
+ }
77
+ const [major, minor, patch] = match.slice(1, 4).map(Number) as [number, number, number];
78
+ if (level === "major") return `${major + 1}.0.0`;
79
+ if (level === "minor") return `${major}.${minor + 1}.0`;
80
+ return `${major}.${minor}.${patch + 1}`;
81
+ }
82
+
83
+ /** Numeric comparison, so `0.10.0` sorts after `0.9.0`. Returns a negative
84
+ * number when `a` precedes `b`. An unparseable version sorts last rather than
85
+ * throwing — this is used for display ordering, not for decisions. */
86
+ export function compareVersions(a: string, b: string): number {
87
+ const pa = VERSION.exec(a);
88
+ const pb = VERSION.exec(b);
89
+ if (!pa || !pb) return pa ? -1 : pb ? 1 : a.localeCompare(b);
90
+ for (let i = 1; i <= 3; i++) {
91
+ const diff = Number(pa[i]) - Number(pb[i]);
92
+ if (diff !== 0) return diff;
93
+ }
94
+ return 0;
95
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Rendering a module's `CHANGELOG.md` entry, and splicing it into the file.
3
+ *
4
+ * The output shape is the one changie was configured to emit — `## <version> -
5
+ * <date>`, `### <Kind>`, `* <body>` — so a module's history stays one document
6
+ * across the handover rather than changing format mid-file.
7
+ *
8
+ * Prepending rather than rewriting: a changelog is append-only history, and the
9
+ * only edit ever made to it is inserting the newest release above the previous
10
+ * one. Everything already written is untouched bytes.
11
+ */
12
+
13
+ import { FRAGMENT_KIND_ORDER, type FragmentKind } from "./bump-level.js";
14
+
15
+ export const CHANGELOG_HEADER = "# Changelog";
16
+
17
+ export interface ChangelogRelease {
18
+ readonly version: string;
19
+ /** `YYYY-MM-DD`. Passed in rather than read from a clock, so a plan renders
20
+ * identically whenever it is rendered and a test needs no clock control. */
21
+ readonly date: string;
22
+ readonly entries: readonly { readonly kind: FragmentKind; readonly body: string }[];
23
+ }
24
+
25
+ /** One release block. Entries group under their kind, in the vocabulary's own
26
+ * order, so two releases never disagree about where `Fixed` sits. */
27
+ export function renderChangelogRelease(release: ChangelogRelease): string {
28
+ const byKind = new Map<FragmentKind, string[]>();
29
+ for (const entry of release.entries) {
30
+ const list = byKind.get(entry.kind);
31
+ if (list) list.push(entry.body);
32
+ else byKind.set(entry.kind, [entry.body]);
33
+ }
34
+
35
+ const lines = [`## ${release.version} - ${release.date}`];
36
+ for (const kind of FRAGMENT_KIND_ORDER) {
37
+ const bodies = byKind.get(kind);
38
+ if (!bodies) continue;
39
+ lines.push(`### ${kind}`);
40
+ for (const body of bodies) lines.push(`* ${body}`);
41
+ }
42
+ return lines.join("\n") + "\n";
43
+ }
44
+
45
+ /**
46
+ * Insert a release block below the file's `# Changelog` header, creating the
47
+ * file's skeleton when it has none.
48
+ *
49
+ * A module with no changelog yet is the ordinary case for a new module, so this
50
+ * writes the header rather than failing — the alternative is a release that
51
+ * stops to ask for an empty file to be created by hand.
52
+ */
53
+ export function prependChangelogRelease(existing: string | undefined, block: string): string {
54
+ const text = existing ?? "";
55
+ const header = text.match(/^#\s+Changelog[^\n]*\n/);
56
+ if (!header) {
57
+ const rest = text.trim();
58
+ return `${CHANGELOG_HEADER}\n\n${block}${rest ? `\n${rest}\n` : ""}`;
59
+ }
60
+ const after = text.slice(header[0].length).replace(/^\n+/, "");
61
+ return `${header[0]}\n${block}${after ? `\n${after}` : ""}`;
62
+ }
@@ -0,0 +1,130 @@
1
+ /**
2
+ * A release fragment — one pending change, written by an author.
3
+ *
4
+ * The shape is changesets' (one file, several modules, one body) carrying
5
+ * changie's kind vocabulary, which is what lets one cross-cutting change be one
6
+ * file while the kind still drives both the level and the changelog section a
7
+ * line lands under. Changie's own file is the other arrangement — one module per
8
+ * file — so a change touching five modules meant five files repeating one
9
+ * sentence, and its key was the module's bare directory NAME, which two
10
+ * directories in different subtrees can share.
11
+ *
12
+ * A fragment names modules by **workspace-relative path**, the same key the
13
+ * ledger and every diagnostic use.
14
+ *
15
+ * They are plain YAML validated here, not a `Telo.*` kind: build-time repo state
16
+ * with no controller and no capability, which the runtime never loads.
17
+ */
18
+
19
+ import { Document, parseDocument } from "yaml";
20
+ import { isFragmentKind, type FragmentKind } from "./bump-level.js";
21
+
22
+ /** A module's workspace-relative directory path — `modules/sql`, `apps/hub`. */
23
+ export type ModuleKey = string;
24
+
25
+ export interface ReleaseFragment {
26
+ /**
27
+ * Where this fragment was read from, workspace-relative. Carried so a
28
+ * diagnostic can name the file to fix and `apply` can delete the file it
29
+ * consumed.
30
+ */
31
+ readonly source: string;
32
+ /** The directly-changed modules this fragment declares, each with its kind. */
33
+ readonly modules: ReadonlyMap<ModuleKey, FragmentKind>;
34
+ /** The changelog line. */
35
+ readonly body: string;
36
+ }
37
+
38
+ export class FragmentError extends Error {
39
+ constructor(
40
+ readonly source: string,
41
+ message: string,
42
+ ) {
43
+ super(message);
44
+ }
45
+ }
46
+
47
+ export function parseFragment(text: string, source: string): ReleaseFragment {
48
+ let value: unknown;
49
+ try {
50
+ value = parseDocument(text).toJSON();
51
+ } catch (err) {
52
+ throw new FragmentError(
53
+ source,
54
+ `${source} is not valid YAML: ${err instanceof Error ? err.message : String(err)}`,
55
+ );
56
+ }
57
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
58
+ throw new FragmentError(source, `${source} must be a YAML mapping.`);
59
+ }
60
+ const record = value as Record<string, unknown>;
61
+
62
+ for (const key of Object.keys(record)) {
63
+ if (key !== "modules" && key !== "body") {
64
+ throw new FragmentError(
65
+ source,
66
+ `${source}: unknown field '${key}'. A fragment carries 'modules:' and 'body:'.`,
67
+ );
68
+ }
69
+ }
70
+
71
+ const rawModules = record.modules;
72
+ if (rawModules === null || typeof rawModules !== "object" || Array.isArray(rawModules)) {
73
+ throw new FragmentError(
74
+ source,
75
+ `${source}: 'modules' must be a mapping of module path to kind, ` +
76
+ `e.g. { modules/sql: Fixed }.`,
77
+ );
78
+ }
79
+ const modules = new Map<ModuleKey, FragmentKind>();
80
+ for (const [key, kind] of Object.entries(rawModules as Record<string, unknown>)) {
81
+ if (!isFragmentKind(kind)) {
82
+ throw new FragmentError(
83
+ source,
84
+ `${source}: '${key}' declares kind '${String(kind)}', which is not a release kind. ` +
85
+ `Use Added, Changed, Deprecated, Removed, Fixed or Security.`,
86
+ );
87
+ }
88
+ modules.set(normalizeModuleKey(key), kind);
89
+ }
90
+ if (modules.size === 0) {
91
+ throw new FragmentError(
92
+ source,
93
+ `${source}: 'modules' is empty, so this fragment releases nothing. ` +
94
+ `Name at least one module by its workspace-relative path.`,
95
+ );
96
+ }
97
+
98
+ const body = record.body;
99
+ if (typeof body !== "string" || body.trim() === "") {
100
+ throw new FragmentError(
101
+ source,
102
+ `${source}: 'body' must be the changelog line for this change.`,
103
+ );
104
+ }
105
+
106
+ return { source, modules, body: body.trim() };
107
+ }
108
+
109
+ /**
110
+ * Render a fragment, for `telo release add`.
111
+ *
112
+ * Serialized rather than templated so a long body folds the way the YAML writer
113
+ * folds it, and so a module path needing quotes gets them.
114
+ */
115
+ export function serializeFragment(
116
+ modules: ReadonlyMap<ModuleKey, FragmentKind>,
117
+ body: string,
118
+ ): string {
119
+ const doc = new Document({
120
+ modules: Object.fromEntries([...modules].map(([key, kind]) => [key, kind])),
121
+ body,
122
+ });
123
+ return doc.toString({ lineWidth: 88 });
124
+ }
125
+
126
+ /** Module keys are POSIX-separated and carry no leading `./` or trailing slash,
127
+ * so the same directory written three ways is one key. */
128
+ export function normalizeModuleKey(key: string): ModuleKey {
129
+ return key.replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/+$/, "");
130
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * The release model: module identity, fragments, the ledger, the edge graph,
3
+ * level propagation and version planning.
4
+ *
5
+ * Browser-safe by construction — pure data in, plan out — so the telo editor can
6
+ * answer "what does changing this library bump?" from the same model the CLI
7
+ * releases from. Everything Node-shaped (finding the workspace, running the
8
+ * controller builder, building payloads, reading git, writing files) is
9
+ * `cli/nodejs/src/release/`.
10
+ */
11
+
12
+ export {
13
+ FRAGMENT_KINDS,
14
+ FRAGMENT_KIND_ORDER,
15
+ applyBump,
16
+ compareVersions,
17
+ isFragmentKind,
18
+ isReleaseVersion,
19
+ levelOfKind,
20
+ maxLevel,
21
+ } from "./bump-level.js";
22
+ export type { BumpLevel, FragmentKind } from "./bump-level.js";
23
+
24
+ export {
25
+ CHANGELOG_HEADER,
26
+ prependChangelogRelease,
27
+ renderChangelogRelease,
28
+ } from "./changelog.js";
29
+ export type { ChangelogRelease } from "./changelog.js";
30
+
31
+ export { FragmentError, normalizeModuleKey, parseFragment, serializeFragment } from "./fragment.js";
32
+ export type { ModuleKey, ReleaseFragment } from "./fragment.js";
33
+
34
+ export { EMPTY_LEDGER, LEDGER_PATH, LedgerError, parseLedger, serializeLedger } from "./ledger.js";
35
+ export type { Ledger, LedgerEntry } from "./ledger.js";
36
+
37
+ export {
38
+ LOCALLY_DERIVED_LAYERS,
39
+ MANIFEST_LAYER,
40
+ diffLayerDigests,
41
+ layerDigestKey,
42
+ } from "./payload-digest.js";
43
+ export type { LayerChange, LayerDigests } from "./payload-digest.js";
44
+
45
+ export { orderByImports, planRelease } from "./release-plan.js";
46
+ export type {
47
+ ArtifactKind,
48
+ BumpReason,
49
+ ChangelogEntry,
50
+ ModuleEvidence,
51
+ PlannedModule,
52
+ ReleaseDiagnostic,
53
+ ReleaseEvidence,
54
+ ReleasePlan,
55
+ } from "./release-plan.js";
56
+
57
+ export {
58
+ VersionStampError,
59
+ readManifestVersion,
60
+ stampCrateVersion,
61
+ stampManifestVersion,
62
+ stampPackageVersion,
63
+ } from "./version-stamp.js";
64
+
65
+ export { WORKSPACE_FILENAME, WorkspaceConfigError, parseWorkspaceConfig } from "./workspace-config.js";
66
+ export type { WorkspaceConfig } from "./workspace-config.js";