@telorun/analyzer 0.60.0 → 0.62.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 (88) hide show
  1. package/dist/analyzer.d.ts.map +1 -1
  2. package/dist/analyzer.js +130 -9
  3. package/dist/artifact-layer-index.d.ts +13 -5
  4. package/dist/artifact-layer-index.d.ts.map +1 -1
  5. package/dist/artifact-layer-index.js +37 -13
  6. package/dist/artifact-selector.d.ts +10 -3
  7. package/dist/artifact-selector.d.ts.map +1 -1
  8. package/dist/artifact-selector.js +10 -1
  9. package/dist/builtins.d.ts.map +1 -1
  10. package/dist/builtins.js +97 -13
  11. package/dist/cel-bindings.d.ts +0 -6
  12. package/dist/cel-bindings.d.ts.map +1 -1
  13. package/dist/cel-bindings.js +3 -28
  14. package/dist/definition-registry.d.ts +17 -0
  15. package/dist/definition-registry.d.ts.map +1 -1
  16. package/dist/definition-registry.js +31 -2
  17. package/dist/identifier-name.d.ts +114 -0
  18. package/dist/identifier-name.d.ts.map +1 -0
  19. package/dist/identifier-name.js +183 -0
  20. package/dist/index.d.ts +14 -2
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +11 -2
  23. package/dist/manifest-schemas.d.ts +81 -0
  24. package/dist/manifest-schemas.d.ts.map +1 -1
  25. package/dist/manifest-schemas.js +208 -6
  26. package/dist/module-file-claims.d.ts +7 -0
  27. package/dist/module-file-claims.d.ts.map +1 -1
  28. package/dist/module-file-claims.js +22 -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/requires-block.d.ts +125 -0
  33. package/dist/requires-block.d.ts.map +1 -0
  34. package/dist/requires-block.js +182 -0
  35. package/dist/schema-keywords.d.ts +68 -0
  36. package/dist/schema-keywords.d.ts.map +1 -0
  37. package/dist/schema-keywords.js +324 -0
  38. package/dist/schema-region.d.ts +12 -1
  39. package/dist/schema-region.d.ts.map +1 -1
  40. package/dist/schema-region.js +12 -1
  41. package/dist/telo-version.d.ts +3 -0
  42. package/dist/telo-version.d.ts.map +1 -0
  43. package/dist/telo-version.js +8 -0
  44. package/dist/types.d.ts +31 -0
  45. package/dist/types.d.ts.map +1 -1
  46. package/dist/validate-identifier-names.d.ts +31 -0
  47. package/dist/validate-identifier-names.d.ts.map +1 -0
  48. package/dist/validate-identifier-names.js +144 -0
  49. package/dist/validate-module-artifact.d.ts.map +1 -1
  50. package/dist/validate-module-artifact.js +53 -1
  51. package/dist/validate-observed-state.d.ts +9 -2
  52. package/dist/validate-observed-state.d.ts.map +1 -1
  53. package/dist/validate-observed-state.js +9 -2
  54. package/dist/validate-references.d.ts.map +1 -1
  55. package/dist/validate-references.js +5 -26
  56. package/dist/validate-requires.d.ts +49 -0
  57. package/dist/validate-requires.d.ts.map +1 -0
  58. package/dist/validate-requires.js +99 -0
  59. package/dist/value-type-keyword.d.ts +1 -1
  60. package/dist/value-type-keyword.d.ts.map +1 -1
  61. package/dist/value-type-keyword.js +1 -0
  62. package/dist/version-range.d.ts +88 -0
  63. package/dist/version-range.d.ts.map +1 -0
  64. package/dist/version-range.js +173 -0
  65. package/package.json +2 -2
  66. package/src/analyzer.ts +146 -10
  67. package/src/artifact-layer-index.ts +47 -14
  68. package/src/artifact-selector.ts +15 -4
  69. package/src/builtins.ts +102 -13
  70. package/src/cel-bindings.ts +3 -28
  71. package/src/definition-registry.ts +30 -2
  72. package/src/identifier-name.ts +228 -0
  73. package/src/index.ts +44 -1
  74. package/src/manifest-schemas.ts +223 -4
  75. package/src/module-file-claims.ts +32 -2
  76. package/src/module-library.ts +208 -0
  77. package/src/requires-block.ts +253 -0
  78. package/src/schema-keywords.ts +359 -0
  79. package/src/schema-region.ts +12 -1
  80. package/src/telo-version.ts +9 -0
  81. package/src/types.ts +32 -0
  82. package/src/validate-identifier-names.ts +173 -0
  83. package/src/validate-module-artifact.ts +56 -0
  84. package/src/validate-observed-state.ts +9 -2
  85. package/src/validate-references.ts +5 -26
  86. package/src/validate-requires.ts +129 -0
  87. package/src/value-type-keyword.ts +1 -0
  88. package/src/version-range.ts +238 -0
@@ -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,253 @@
1
+ /**
2
+ * The single reader of a module doc's `requires:` block — the version ranges of
3
+ * runtime a module declares itself verified against. The load gate, the CLI's
4
+ * publish preflight and `upgrade`'s candidate filter all recognise the block
5
+ * here and nowhere else, the one-accessor rule `ref-slot.ts` and `zone-slot.ts`
6
+ * established. Browser-safe: no Node built-ins, so the editor reaches the
7
+ * identical rule the kernel does.
8
+ *
9
+ * ```yaml
10
+ * requires:
11
+ * telo: ">=0.80.0"
12
+ * host:
13
+ * node: ">=20.0.0"
14
+ * ```
15
+ *
16
+ * **Two tiers, and the split is not cosmetic.** `telo` names the *manifest
17
+ * surface generation* a runtime implements — one scale shared by every kernel,
18
+ * Node, Rust or Go, independent of each kernel's own release identity — and it
19
+ * is the one axis verified by EXECUTION, by running the CLI at each edge of the
20
+ * declared range. Host axes cannot be edge-verified by any CI; they are asserted
21
+ * by the author and compared against the version the running host reports
22
+ * ({@link HostVersions}). A flat map would imply one semantics for both.
23
+ *
24
+ * Nesting is also what disambiguates the names: `nodejs` and `rust` are already
25
+ * *kernel labels* in this repo (`LABEL_TO_PURL_TYPE`, an `imports:` entry's
26
+ * `runtime:`), so a top-level `node:` reads as the Node kernel rather than the
27
+ * Node.js runtime — and no word escapes that, because the host runtime and the
28
+ * kernel implementation genuinely share a name. Under `host:` position carries
29
+ * the disambiguation and no word has to.
30
+ *
31
+ * **Ordering is normative: `telo` is checked before `host`, and before any
32
+ * unknown-axis complaint.** A module using an axis introduced in telo 0.85 also
33
+ * declares telo `>=0.85`, so an older runtime fails on the telo axis first and
34
+ * never has to decide what an axis it has never heard of means. That is what
35
+ * makes the block safely extensible; consumers get the order from
36
+ * {@link evaluateRequires} rather than re-deriving it.
37
+ */
38
+
39
+ import { parseModuleVersion } from "./module-version-order.js";
40
+ import {
41
+ isUnsatisfiable,
42
+ parseVersionRange,
43
+ rangeAccepts,
44
+ type VersionRange,
45
+ } from "./version-range.js";
46
+
47
+ /**
48
+ * Host axes this analyzer knows.
49
+ *
50
+ * **An axis is in this list only when something checks it.** A declared
51
+ * requirement nothing compares is worse than no requirement at all: it validates,
52
+ * it reads as protection, and it silently protects nobody — the exact failure
53
+ * class this whole mechanism exists to remove, reintroduced inside it. So `rustc`
54
+ * is deliberately absent until the slice that builds controller crates can
55
+ * compare it; adding it there is a one-line change here plus a supplier in
56
+ * {@link HostVersions}, and until then an author writing it is told it is not a
57
+ * known axis rather than quietly reassured.
58
+ *
59
+ * Extending the set is a telo release, which is exactly why a module using a new
60
+ * axis must also raise its `telo` bound — and why `telo` is checked first.
61
+ */
62
+ export const KNOWN_HOST_AXES = ["node"] as const;
63
+ export type HostAxis = (typeof KNOWN_HOST_AXES)[number];
64
+
65
+ /** Top-level keys of the block. `host` is a container, `telo` a range. */
66
+ const KNOWN_AXES = ["telo", "host"] as const;
67
+
68
+ export interface RequiresBlock {
69
+ /** The surface generation range, when declared. */
70
+ telo?: VersionRange;
71
+ /** Declared host axes, keyed by axis name. Empty when `host:` is absent. */
72
+ host: Partial<Record<HostAxis, VersionRange>>;
73
+ }
74
+
75
+ export interface RequiresIssue {
76
+ /** Dotted path within the doc, e.g. `requires.telo` or `requires.host.node`. */
77
+ path: string;
78
+ message: string;
79
+ /** The spelling to use instead, when there is an unambiguous one. */
80
+ hint?: string;
81
+ /** True for an unrecognized axis, which consumers suppress while the `telo`
82
+ * requirement itself is unmet — an older runtime not knowing a newer axis is
83
+ * a consequence of the version skew, not a second defect to report. */
84
+ unknownAxis?: boolean;
85
+ }
86
+
87
+ export interface ReadRequiresResult {
88
+ /** Present whenever the doc carries a `requires:` key at all, even a malformed
89
+ * one — so a consumer can tell "declared nothing" from "declared badly". */
90
+ declared: boolean;
91
+ block: RequiresBlock;
92
+ issues: RequiresIssue[];
93
+ }
94
+
95
+ const EMPTY: RequiresBlock = { host: {} };
96
+
97
+ /**
98
+ * Read and parse the block off a module doc. Never throws. A malformed entry
99
+ * yields an issue AND is omitted from the block, so a consumer enforcing the
100
+ * block never silently treats garbage as a satisfied requirement — the issue is
101
+ * what makes the manifest fail, exactly as a malformed zone annotation does.
102
+ */
103
+ export function readRequires(doc: Record<string, unknown> | undefined): ReadRequiresResult {
104
+ const raw = doc?.requires;
105
+ if (raw === undefined) return { declared: false, block: EMPTY, issues: [] };
106
+
107
+ const issues: RequiresIssue[] = [];
108
+ const block: RequiresBlock = { host: {} };
109
+
110
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
111
+ issues.push({
112
+ path: "requires",
113
+ message: `'requires' must be a mapping of axes, got ${describe(raw)}.`,
114
+ });
115
+ return { declared: true, block, issues };
116
+ }
117
+
118
+ const entries = raw as Record<string, unknown>;
119
+
120
+ for (const key of Object.keys(entries)) {
121
+ if (!(KNOWN_AXES as readonly string[]).includes(key)) {
122
+ issues.push({
123
+ path: `requires.${key}`,
124
+ message:
125
+ `'requires.${key}' is not a known axis. This runtime knows ` +
126
+ `${KNOWN_AXES.map((a) => `'${a}'`).join(" and ")}; host requirements go under 'host'.`,
127
+ unknownAxis: true,
128
+ });
129
+ }
130
+ }
131
+
132
+ if (entries.telo !== undefined) {
133
+ const range = parseRangeAt(entries.telo, "requires.telo", issues);
134
+ if (range) block.telo = range;
135
+ }
136
+
137
+ if (entries.host !== undefined) {
138
+ const host = entries.host;
139
+ if (host === null || typeof host !== "object" || Array.isArray(host)) {
140
+ issues.push({
141
+ path: "requires.host",
142
+ message: `'requires.host' must be a mapping of host axes, got ${describe(host)}.`,
143
+ });
144
+ } else {
145
+ for (const [axis, value] of Object.entries(host as Record<string, unknown>)) {
146
+ if (!(KNOWN_HOST_AXES as readonly string[]).includes(axis)) {
147
+ issues.push({
148
+ path: `requires.host.${axis}`,
149
+ message:
150
+ `'requires.host.${axis}' is not a known host axis. This runtime knows ` +
151
+ `${KNOWN_HOST_AXES.map((a) => `'${a}'`).join(", ")}.`,
152
+ unknownAxis: true,
153
+ });
154
+ continue;
155
+ }
156
+ const range = parseRangeAt(value, `requires.host.${axis}`, issues);
157
+ if (range) block.host[axis as HostAxis] = range;
158
+ }
159
+ }
160
+ }
161
+
162
+ return { declared: true, block, issues };
163
+ }
164
+
165
+ function parseRangeAt(
166
+ value: unknown,
167
+ path: string,
168
+ issues: RequiresIssue[],
169
+ ): VersionRange | undefined {
170
+ const result = parseVersionRange(value);
171
+ if (!result.ok) {
172
+ issues.push({ path, message: `'${path}': ${result.error.message}.`, hint: result.error.hint });
173
+ return undefined;
174
+ }
175
+ if (isUnsatisfiable(result.range)) {
176
+ issues.push({
177
+ path,
178
+ message: `'${path}': '${result.range.raw}' admits no version — its bounds exclude each other.`,
179
+ });
180
+ return undefined;
181
+ }
182
+ return result.range;
183
+ }
184
+
185
+ /** What a runtime concluded about a module's declared requirements. */
186
+ export type RequiresVerdict =
187
+ | { satisfied: true }
188
+ /** An axis whose declared range excludes the version this runtime reported. */
189
+ | {
190
+ satisfied: false;
191
+ axis: "telo" | HostAxis;
192
+ declared: VersionRange;
193
+ running: string;
194
+ };
195
+
196
+ /** The versions a host can speak for. Absent entries are not checked — the
197
+ * editor has no host to report, and an axis nothing supplies is skipped rather
198
+ * than guessed. Every axis in {@link KNOWN_HOST_AXES} has a supplier; an axis
199
+ * with none does not belong in the vocabulary (see the note there). */
200
+ export interface HostVersions {
201
+ node?: string;
202
+ }
203
+
204
+ /**
205
+ * Evaluate a module's declared requirements against the runtime performing the
206
+ * analysis.
207
+ *
208
+ * `telo` is checked FIRST and short-circuits: a module using a host axis
209
+ * introduced in a later telo also declares that telo, so an older runtime must
210
+ * report the version skew rather than a host axis it may not even know. Absent
211
+ * declarations are satisfied — the bootstrap rule, permanent for everything
212
+ * published before the mechanism existed.
213
+ *
214
+ * **A version this cannot PARSE is satisfied, on every axis.** A runtime that
215
+ * cannot name its own version must not start rejecting modules on the strength
216
+ * of a number it could not read — the refusal-to-guess `module-version-order.ts`
217
+ * makes, pointed in the safe direction. The test is a parse, not a shape: `0.76`
218
+ * and `2024.1` look like versions and are not three-part ones, so a cheaper
219
+ * check (a leading digit, say) would fail them CLOSED and gate every module in
220
+ * the graph on a number nothing could compare. `AnalysisOptions.teloVersion` is
221
+ * hand-written by definition, so that is exactly where such a value arrives.
222
+ */
223
+ export function evaluateRequires(
224
+ block: RequiresBlock,
225
+ running: string | undefined,
226
+ host: HostVersions = {},
227
+ ): RequiresVerdict {
228
+ const telo = check("telo", block.telo, running);
229
+ if (telo) return telo;
230
+ for (const axis of KNOWN_HOST_AXES) {
231
+ const verdict = check(axis, block.host[axis], host[axis]);
232
+ if (verdict) return verdict;
233
+ }
234
+ return { satisfied: true };
235
+ }
236
+
237
+ /** One axis, or `undefined` when it is satisfied / undeclared / unreportable. */
238
+ function check(
239
+ axis: "telo" | HostAxis,
240
+ declared: VersionRange | undefined,
241
+ running: string | undefined,
242
+ ): RequiresVerdict | undefined {
243
+ if (!declared || !running) return undefined;
244
+ if (!parseModuleVersion(running)) return undefined;
245
+ if (rangeAccepts(declared, running)) return undefined;
246
+ return { satisfied: false, axis, declared, running };
247
+ }
248
+
249
+ function describe(value: unknown): string {
250
+ if (value === null) return "null";
251
+ if (Array.isArray(value)) return "an array";
252
+ return typeof value;
253
+ }