@telorun/analyzer 0.61.0 → 0.62.1

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 (73) hide show
  1. package/dist/analyzer.d.ts.map +1 -1
  2. package/dist/analyzer.js +130 -9
  3. package/dist/builtins.d.ts.map +1 -1
  4. package/dist/builtins.js +69 -12
  5. package/dist/cel-bindings.d.ts +0 -6
  6. package/dist/cel-bindings.d.ts.map +1 -1
  7. package/dist/cel-bindings.js +3 -28
  8. package/dist/definition-registry.d.ts +17 -0
  9. package/dist/definition-registry.d.ts.map +1 -1
  10. package/dist/definition-registry.js +31 -2
  11. package/dist/identifier-name.d.ts +114 -0
  12. package/dist/identifier-name.d.ts.map +1 -0
  13. package/dist/identifier-name.js +183 -0
  14. package/dist/index.d.ts +10 -0
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +8 -0
  17. package/dist/manifest-schemas.d.ts +81 -0
  18. package/dist/manifest-schemas.d.ts.map +1 -1
  19. package/dist/manifest-schemas.js +208 -6
  20. package/dist/release/payload-digest.d.ts +7 -3
  21. package/dist/release/payload-digest.d.ts.map +1 -1
  22. package/dist/release/payload-digest.js +7 -3
  23. package/dist/requires-block.d.ts +125 -0
  24. package/dist/requires-block.d.ts.map +1 -0
  25. package/dist/requires-block.js +182 -0
  26. package/dist/schema-keywords.d.ts +68 -0
  27. package/dist/schema-keywords.d.ts.map +1 -0
  28. package/dist/schema-keywords.js +324 -0
  29. package/dist/schema-region.d.ts +12 -1
  30. package/dist/schema-region.d.ts.map +1 -1
  31. package/dist/schema-region.js +12 -1
  32. package/dist/telo-version.d.ts +3 -0
  33. package/dist/telo-version.d.ts.map +1 -0
  34. package/dist/telo-version.js +8 -0
  35. package/dist/types.d.ts +31 -0
  36. package/dist/types.d.ts.map +1 -1
  37. package/dist/validate-identifier-names.d.ts +31 -0
  38. package/dist/validate-identifier-names.d.ts.map +1 -0
  39. package/dist/validate-identifier-names.js +144 -0
  40. package/dist/validate-observed-state.d.ts +9 -2
  41. package/dist/validate-observed-state.d.ts.map +1 -1
  42. package/dist/validate-observed-state.js +9 -2
  43. package/dist/validate-references.d.ts.map +1 -1
  44. package/dist/validate-references.js +5 -26
  45. package/dist/validate-requires.d.ts +49 -0
  46. package/dist/validate-requires.d.ts.map +1 -0
  47. package/dist/validate-requires.js +99 -0
  48. package/dist/value-type-keyword.d.ts +1 -1
  49. package/dist/value-type-keyword.d.ts.map +1 -1
  50. package/dist/value-type-keyword.js +1 -0
  51. package/dist/version-range.d.ts +88 -0
  52. package/dist/version-range.d.ts.map +1 -0
  53. package/dist/version-range.js +173 -0
  54. package/package.json +2 -2
  55. package/src/analyzer.ts +146 -10
  56. package/src/builtins.ts +73 -12
  57. package/src/cel-bindings.ts +3 -28
  58. package/src/definition-registry.ts +30 -2
  59. package/src/identifier-name.ts +228 -0
  60. package/src/index.ts +34 -0
  61. package/src/manifest-schemas.ts +223 -4
  62. package/src/release/payload-digest.ts +7 -3
  63. package/src/requires-block.ts +253 -0
  64. package/src/schema-keywords.ts +359 -0
  65. package/src/schema-region.ts +12 -1
  66. package/src/telo-version.ts +9 -0
  67. package/src/types.ts +32 -0
  68. package/src/validate-identifier-names.ts +173 -0
  69. package/src/validate-observed-state.ts +9 -2
  70. package/src/validate-references.ts +5 -26
  71. package/src/validate-requires.ts +129 -0
  72. package/src/value-type-keyword.ts +1 -0
  73. package/src/version-range.ts +238 -0
@@ -0,0 +1,173 @@
1
+ import type { ResourceManifest } from "@telorun/sdk";
2
+
3
+ import type { AliasResolver } from "./alias-resolver.js";
4
+ import type { CallGraph } from "./call-graph.js";
5
+ import type { DefinitionRegistry } from "./definition-registry.js";
6
+ import {
7
+ TYPE_LEVEL_DOC_KINDS,
8
+ checkName,
9
+ type NameLevel,
10
+ type NameViolation,
11
+ } from "./identifier-name.js";
12
+ import { type AnalysisDiagnostic } from "./types.js";
13
+
14
+ const SOURCE = "telo-analyzer";
15
+
16
+ /**
17
+ * Every author-written name in one pass — resource instances, kinds, modules,
18
+ * import aliases, step names and `variables` / `secrets` / `ports` keys. The
19
+ * rules and their rationale are in `identifier-name.ts`; this file only decides
20
+ * what each name IS and where to report it.
21
+ *
22
+ * One pass rather than a check bolted onto each surface's own validator: the
23
+ * rule is identical everywhere, and seven copies of it would drift the way the
24
+ * dot rule already had (enforced for resources, unenforced for steps, aliases
25
+ * and config declarations, which reach CEL through the same identifier space).
26
+ *
27
+ * **Step names come from the call graph, never from a walk of our own.** The
28
+ * graph already owns the analyzer's only step-array recursion and carries each
29
+ * step's name, owner and concrete path, which is exactly what a diagnostic
30
+ * needs. Re-walking would be a second answer to "what steps exist".
31
+ *
32
+ * **Scoped to the entry's own modules, at every tier including the errors.** A
33
+ * published dependency's naming is not the consumer's to fix, and reporting an
34
+ * error they cannot act on is worse than not reporting it — the standing
35
+ * precedent of `X_TELO_REF_UNRESOLVED` and `validate-extends`. The library's
36
+ * own author sees all of it when their module is analyzed as a root.
37
+ *
38
+ * Browser-safe.
39
+ */
40
+ export function validateIdentifierNames(
41
+ manifests: ResourceManifest[],
42
+ registry: DefinitionRegistry,
43
+ aliases: AliasResolver,
44
+ rootModules: Set<string>,
45
+ graph: CallGraph,
46
+ ): AnalysisDiagnostic[] {
47
+ const out: AnalysisDiagnostic[] = [];
48
+
49
+ for (const manifest of manifests) {
50
+ const metadata = manifest.metadata as Record<string, unknown> | undefined;
51
+ const name = typeof metadata?.name === "string" ? metadata.name : undefined;
52
+ if (!manifest.kind || !name) continue;
53
+
54
+ // A name synthesized by inline extraction (`TestAdd_steps_0_invoke`) is
55
+ // derived from the author's own names, not written — reporting its shape
56
+ // would blame them for a spelling this pass's own pipeline chose.
57
+ if (metadata?.xTeloOrigin) continue;
58
+
59
+ const ownModule = metadata?.module as string | undefined;
60
+ if (ownModule && !rootModules.has(ownModule)) continue;
61
+
62
+ const level = levelFor(manifest, registry, aliases);
63
+ push(out, checkName(name, level, surfaceFor(manifest.kind)), {
64
+ kind: manifest.kind,
65
+ name,
66
+ filePath: metadata?.source as string | undefined,
67
+ path: "metadata.name",
68
+ });
69
+
70
+ // The module doc's config contract. Each key becomes `variables.<key>` /
71
+ // `secrets.<key>` / `ports.<key>` in CEL, so it lives in the same
72
+ // identifier space as a resource name and breaks the same way. An
73
+ // import's `variables:` / `secrets:` are deliberately NOT checked — those
74
+ // keys are the imported library's declarations, so a violation there is
75
+ // the library author's, reported when their module is analyzed as a root.
76
+ if (manifest.kind === "Telo.Application" || manifest.kind === "Telo.Library") {
77
+ for (const field of ["variables", "secrets", "ports"] as const) {
78
+ const block = (manifest as Record<string, unknown>)[field];
79
+ if (!block || typeof block !== "object" || Array.isArray(block)) continue;
80
+ for (const key of Object.keys(block as Record<string, unknown>)) {
81
+ push(out, checkName(key, "value", `${singular(field)} name`), {
82
+ kind: manifest.kind,
83
+ name,
84
+ filePath: metadata?.source as string | undefined,
85
+ path: `${field}.${key}`,
86
+ });
87
+ }
88
+ }
89
+ }
90
+ }
91
+
92
+ for (const node of graph.nodes.values()) {
93
+ if (node.type !== "step" || !node.name) continue;
94
+ const owner = graph.nodes.get(node.owner);
95
+ if (owner?.type !== "resource") continue;
96
+ const ownerMeta = owner.manifest.metadata as Record<string, unknown> | undefined;
97
+ if (ownerMeta?.xTeloOrigin) continue;
98
+ const ownModule = ownerMeta?.module as string | undefined;
99
+ if (ownModule && !rootModules.has(ownModule)) continue;
100
+
101
+ push(out, checkName(node.name, "value", "step name"), {
102
+ kind: owner.kind,
103
+ name: owner.name,
104
+ filePath: ownerMeta?.source as string | undefined,
105
+ path: `${node.path}.name`,
106
+ });
107
+ }
108
+
109
+ return out;
110
+ }
111
+
112
+ /**
113
+ * A resource instance is value-level, EXCEPT when its capability is
114
+ * `Telo.Type`: a named shape has no runtime instance, is referenced from
115
+ * `inputType:` / `outputType:` type slots and resolves as
116
+ * `telo:<module>/<Name>`, so its name denotes a type despite being declared as
117
+ * a resource. Capability-driven rather than by kind name, so no resource kind
118
+ * is hardcoded here.
119
+ *
120
+ * An unresolvable kind falls back to value level — the honest default, since
121
+ * `UNDEFINED_KIND` already reports the real problem and guessing type level
122
+ * would stack a case error on top of it.
123
+ */
124
+ function levelFor(
125
+ manifest: ResourceManifest,
126
+ registry: DefinitionRegistry,
127
+ aliases: AliasResolver,
128
+ ): NameLevel {
129
+ if (TYPE_LEVEL_DOC_KINDS.has(manifest.kind as string)) return "type";
130
+ // The root resolver is the right one unconditionally: every manifest
131
+ // reaching here belongs to a root module, the others having been skipped.
132
+ const canonical = aliases.resolveKind(manifest.kind as string) ?? (manifest.kind as string);
133
+ return registry.resolve(canonical)?.capability === "Telo.Type" ? "type" : "value";
134
+ }
135
+
136
+ /** The noun phrase a diagnostic uses as its subject. */
137
+ function surfaceFor(kind: string): string {
138
+ switch (kind) {
139
+ case "Telo.Application":
140
+ case "Telo.Library":
141
+ return "module name";
142
+ case "Telo.Definition":
143
+ case "Telo.Abstract":
144
+ return "kind name";
145
+ case "Telo.Import":
146
+ return "import alias";
147
+ default:
148
+ return "resource name";
149
+ }
150
+ }
151
+
152
+ function singular(field: "variables" | "secrets" | "ports"): string {
153
+ return field === "variables" ? "variable" : field === "secrets" ? "secret" : "port";
154
+ }
155
+
156
+ function push(
157
+ out: AnalysisDiagnostic[],
158
+ violation: NameViolation | undefined,
159
+ at: { kind: string; name: string; filePath: string | undefined; path: string },
160
+ ): void {
161
+ if (!violation) return;
162
+ out.push({
163
+ severity: violation.severity,
164
+ code: violation.code,
165
+ source: SOURCE,
166
+ message: `${at.kind}/${at.name}: ${violation.message}`,
167
+ data: {
168
+ resource: { kind: at.kind, name: at.name },
169
+ filePath: at.filePath,
170
+ path: at.path,
171
+ },
172
+ });
173
+ }
@@ -29,8 +29,15 @@ const SYSTEM_KINDS = new Set([
29
29
  * {@link validateObservedStateDeclarations} rather than here, so the author gets
30
30
  * a message naming the rule and the fix instead of AJV's "must NOT be valid".
31
31
  *
32
- * Exported from the analyzer and re-used by the kernel's manifest schemas, so
33
- * the rule has one definition rather than two kept in sync by hand.
32
+ * THE KERNEL'S SHAPE CHECK, not the analyzer's. The analyzer's builtins point
33
+ * their `status:` slot at the `JsonSchema7` fragment, which describes the same
34
+ * block far more precisely; the kernel keeps this permissive one, and the two do
35
+ * not drift into disagreement because the fragment only ever NARROWS what this
36
+ * accepts. That split is the same one the `required:` rule above draws: the
37
+ * loader answers "is this the right shape at all", and the check that can name
38
+ * the offending keyword and its line stays with `telo check`. Wiring the fragment
39
+ * into the kernel too would turn a check-time diagnostic into a boot failure for
40
+ * every already-published manifest carrying a sloppy keyword.
34
41
  */
35
42
  export const OBSERVED_STATE_SCHEMA = {
36
43
  type: "object",
@@ -195,32 +195,11 @@ export function validateReferences(
195
195
  });
196
196
  }
197
197
  }
198
- // A resource name must contain no dot. The `!ref` resolver splits the tag's source on
199
- // the first dot to separate an import alias from the resource name, so a dotted name
200
- // would mis-resolve into a cross-module lookup. This is the load-bearing invariant of
201
- // the reference grammar, so it is enforced here rather than left to the (unenforced)
202
- // casing convention.
203
- for (const [name, list] of byNameAll) {
204
- if (!name.includes(".")) continue;
205
- for (const r of list) {
206
- const m = r.metadata as { source?: string; sourceLine?: number } | undefined;
207
- const range =
208
- typeof m?.sourceLine === "number"
209
- ? {
210
- start: { line: m.sourceLine, character: 0 },
211
- end: { line: m.sourceLine, character: Number.MAX_SAFE_INTEGER },
212
- }
213
- : undefined;
214
- diagnostics.push({
215
- severity: DiagnosticSeverity.Error,
216
- code: "INVALID_RESOURCE_NAME",
217
- source: SOURCE,
218
- message: `${r.kind}/${name}: resource name must not contain '.' — in a '!ref' the '.' separates an import alias from the resource name`,
219
- ...(range ? { range } : {}),
220
- data: { resource: { kind: r.kind, name }, filePath: m?.source, path: "metadata.name" },
221
- });
222
- }
223
- }
198
+ // The dot rule that used to live here is now the strictest special case of
199
+ // the identifier grammar in `validate-identifier-names.ts` a dot is one of
200
+ // several characters that make a name unreferenceable, and checking one of
201
+ // them here while the rest went unchecked is what let a hyphenated name
202
+ // through to silently evaluate as arithmetic.
224
203
 
225
204
  // Single-resource map for the resolution / scope lookups below — when a
226
205
  // collision exists, falling back to the first occurrence keeps the rest
@@ -0,0 +1,129 @@
1
+ /**
2
+ * The strict half of `requires-block.ts` — the accessor/validator split
3
+ * `zone-slot.ts` / `validate-zone-slots.ts` established, and for the same reason:
4
+ * the reader must not throw on a malformed block (every consumer would have to
5
+ * guard), while a malformed block must not pass silently either.
6
+ *
7
+ * Two distinct outputs, and conflating them was the whole failure this mechanism
8
+ * addresses:
9
+ *
10
+ * - **`MODULE_REQUIRES_NEWER_RUNTIME`** — the module is fine and *this runtime*
11
+ * is too old. Raised per module before that module's own validation, so the
12
+ * version message wins over the vocabulary errors it would otherwise be buried
13
+ * in (`ZONE_ANNOTATION_INVALID`, an `additionalProperties` violation against a
14
+ * kernel-owned schema, an unknown `use` token). Those errors are true but
15
+ * blame the module author for a version skew.
16
+ * - **`REQUIRES_INVALID`** — the declaration itself is malformed. Severity turns
17
+ * on ownership rather than presence: an ERROR on the entry's own modules,
18
+ * whose author can fix it, and a WARNING on a dependency, whose author is the
19
+ * only one who can. It is not silent on a dependency, because `readRequires`
20
+ * drops an unparseable range and the gate then reads the module as satisfied —
21
+ * so it loads while stating a requirement it failed to state, and `telo
22
+ * upgrade` (which refuses such a version) would be holding it back for a
23
+ * reason the load path never mentions.
24
+ *
25
+ * **Unknown axes are suppressed while the `telo` requirement is unmet.** An older
26
+ * runtime not recognising a newer host axis is a *consequence* of the version
27
+ * skew — the axis exists, this runtime is simply too old to know it — so
28
+ * reporting it beside the gate diagnostic would manufacture a second defect from
29
+ * one cause. Once `telo` is satisfied, an unrecognized axis is a real error: a
30
+ * runtime at or above the declared generation is expected to know every axis that
31
+ * generation defines.
32
+ */
33
+
34
+ import type { ResourceManifest } from "@telorun/sdk";
35
+
36
+ import { evaluateRequires, readRequires, type HostVersions } from "./requires-block.js";
37
+ import { TELO_SURFACE_VERSION } from "./telo-version.js";
38
+ import { DiagnosticSeverity, type AnalysisDiagnostic } from "./types.js";
39
+
40
+ const SOURCE = "telo-analyzer";
41
+
42
+ export interface ValidateRequiresOptions {
43
+ /** The surface generation the analyzing runtime implements. Defaults to this
44
+ * build's own — see `AnalysisOptions.teloVersion`. */
45
+ teloVersion?: string;
46
+ /** Versions the running host can speak for. Absent in a browser, where there
47
+ * is no host to report; supplied by the kernel and CLI. */
48
+ hostVersions?: HostVersions;
49
+ /** Module names owned by the entry, when the caller can distinguish them.
50
+ * `REQUIRES_INVALID` is limited to these; the gate is not, because a
51
+ * dependency this runtime cannot read still stops the consumer dead. */
52
+ entryModules?: ReadonlySet<string>;
53
+ }
54
+
55
+ export function validateRequires(
56
+ manifests: ResourceManifest[],
57
+ options: ValidateRequiresOptions = {},
58
+ ): AnalysisDiagnostic[] {
59
+ const running = options.teloVersion ?? TELO_SURFACE_VERSION;
60
+ const out: AnalysisDiagnostic[] = [];
61
+
62
+ for (const manifest of manifests) {
63
+ if (manifest.kind !== "Telo.Application" && manifest.kind !== "Telo.Library") continue;
64
+
65
+ const doc = manifest as unknown as Record<string, unknown>;
66
+ const { declared, block, issues } = readRequires(doc);
67
+ if (!declared) continue;
68
+
69
+ const metadata = (manifest.metadata ?? {}) as Record<string, unknown>;
70
+ const name = typeof metadata.name === "string" ? metadata.name : undefined;
71
+ const filePath = typeof metadata.source === "string" ? metadata.source : undefined;
72
+ const label = `${manifest.kind}/${name ?? "(unnamed)"}`;
73
+ const resource = { kind: manifest.kind, name };
74
+
75
+ const verdict = evaluateRequires(block, running, options.hostVersions);
76
+
77
+ if (!verdict.satisfied) {
78
+ const axis = verdict.axis === "telo" ? "telo" : `host.${verdict.axis}`;
79
+ const remedy =
80
+ verdict.axis === "telo"
81
+ ? `Upgrade telo, or pin ${name ?? "this module"} to a version whose range accepts ` +
82
+ `${verdict.running}.`
83
+ : `Upgrade ${verdict.axis}, or pin ${name ?? "this module"} to a version whose range ` +
84
+ `accepts ${verdict.running}.`;
85
+ out.push({
86
+ severity: DiagnosticSeverity.Error,
87
+ code: "MODULE_REQUIRES_NEWER_RUNTIME",
88
+ source: SOURCE,
89
+ message:
90
+ `${label} requires ${axis} '${verdict.declared.raw}'; this runtime reports ` +
91
+ `${verdict.running}. ${remedy}`,
92
+ data: { resource, filePath, path: `requires.${axis}` },
93
+ });
94
+ }
95
+
96
+ const owned =
97
+ options.entryModules === undefined ||
98
+ (typeof metadata.module === "string"
99
+ ? options.entryModules.has(metadata.module)
100
+ : name === undefined || options.entryModules.has(name));
101
+
102
+ for (const issue of issues) {
103
+ // See the header: an unknown axis is a symptom while the gate is failing.
104
+ if (issue.unknownAxis && !verdict.satisfied) continue;
105
+
106
+ // A DEPENDENCY's malformed block is not the consumer's to fix, but it must
107
+ // not be silent either. `readRequires` drops an unparseable range from the
108
+ // block, so the gate reads it as satisfied and the module loads while
109
+ // stating a requirement it failed to state — and `telo upgrade`, which
110
+ // refuses to select such a version, would then be holding a version back
111
+ // for a reason nothing on the load path ever mentions. A warning is what
112
+ // makes those two halves agree about the manifest without handing the
113
+ // consumer an error only its publisher can fix.
114
+ out.push({
115
+ severity: owned ? DiagnosticSeverity.Error : DiagnosticSeverity.Warning,
116
+ code: "REQUIRES_INVALID",
117
+ source: SOURCE,
118
+ message: owned
119
+ ? `${label}: ${issue.message}` + (issue.hint ? ` Write '${issue.hint}' instead.` : "")
120
+ : `${label}: ${issue.message} Its declared requirement cannot be read, so it is not ` +
121
+ `enforced here and \`telo upgrade\` will not select this version. Only the module's ` +
122
+ `publisher can fix it.`,
123
+ data: { resource, filePath, path: issue.path },
124
+ });
125
+ }
126
+ }
127
+
128
+ return out;
129
+ }
@@ -50,6 +50,7 @@ export const ANNOTATION_KEYWORDS = [
50
50
  "x-telo-bindings-from",
51
51
  "x-telo-catches-for",
52
52
  "x-telo-context",
53
+ "x-telo-context-collection-from",
53
54
  "x-telo-context-element-from",
54
55
  "x-telo-context-from",
55
56
  "x-telo-context-from-ref-kind",
@@ -0,0 +1,238 @@
1
+ /**
2
+ * The version-range grammar `requires:` declares, and the only one Telo reads.
3
+ *
4
+ * Built on `module-version-order.ts`'s precedence rather than the `semver`
5
+ * package, for that file's reason: the analyzer is browser-safe, so the editor
6
+ * reaches the identical rule the kernel does. A second implementation of "does
7
+ * this version satisfy this range" would eventually disagree about what a
8
+ * manifest means, which is the whole failure this mechanism exists to prevent.
9
+ *
10
+ * **A range is a CONJUNCTION of explicit comparators** — `>=0.80.0`,
11
+ * `>=0.40.0 <0.50.0`, `>=0.40.0,<0.50.0`. That is deliberately narrower than
12
+ * npm's grammar, and every exclusion pays for itself:
13
+ *
14
+ * - **`^` and `~` are rejected**, not reinterpreted. On a `0.x` version both
15
+ * mean a single minor (`^0.40.0` is `>=0.40.0 <0.41.0`, identical to
16
+ * `~0.40.0`), and Telo ships breaking changes as minor bumps deliberately. So
17
+ * the caret reading is *correct* and therefore useless — every module would
18
+ * pin to one breaking-change generation and nobody could move telo without
19
+ * the whole standard library republishing. It is also the spelling semver
20
+ * intuition reaches for first, so accepting it would make the failure both
21
+ * common and silent.
22
+ * - **A bare version is rejected.** npm reads `0.80.0` as an exact pin, which is
23
+ * the same trap in a different costume; reading it as `>=` would contradict
24
+ * every other semver consumer. Refusing it and naming the two spellings is
25
+ * the only answer that cannot be misread.
26
+ * - **`||`, hyphen ranges, `*` and `x` are rejected.** A disjunction has no
27
+ * single low or high edge, and the edges are load-bearing: verification runs
28
+ * the CLI *at* them, and a declared upper bound must be checked for existence.
29
+ * A grammar whose edges are undefined cannot be verified, and an unverifiable
30
+ * bound is what this design forbids everywhere else.
31
+ *
32
+ * **Prereleases compare by plain precedence**, with no equivalent of npm's rule
33
+ * that a prerelease only satisfies a range mentioning one at the same
34
+ * `[major, minor, patch]`. That rule exists to stop a caret range dragging a
35
+ * consumer onto an `-rc` build; here the version being tested is *the runtime the
36
+ * user is already running*, not a candidate being selected for them, so the
37
+ * honest answer is the precedence one — a developer on `0.81.0-rc.1` satisfies
38
+ * `>=0.80.0` because they genuinely are past it.
39
+ */
40
+
41
+ import {
42
+ compareParsedModuleVersions,
43
+ parseModuleVersion,
44
+ type ParsedModuleVersion,
45
+ } from "./module-version-order.js";
46
+
47
+ /** The comparison a single term applies. */
48
+ export type ComparatorOperator = ">=" | ">" | "<=" | "<";
49
+
50
+ export interface VersionComparator {
51
+ operator: ComparatorOperator;
52
+ /** The version as authored, for diagnostics and for the existence check. */
53
+ raw: string;
54
+ parsed: ParsedModuleVersion;
55
+ }
56
+
57
+ /** A parsed range: every comparator must hold. Never empty. */
58
+ export interface VersionRange {
59
+ /** The range exactly as authored, quoted verbatim in diagnostics. */
60
+ raw: string;
61
+ comparators: VersionComparator[];
62
+ }
63
+
64
+ /** Why a range string was refused. `hint` is the spelling to use instead, when
65
+ * there is an unambiguous one — a rejection that cannot say what to write
66
+ * instead is a worse diagnostic than the value it rejects. */
67
+ export interface VersionRangeError {
68
+ message: string;
69
+ hint?: string;
70
+ }
71
+
72
+ export type VersionRangeResult =
73
+ | { ok: true; range: VersionRange }
74
+ | { ok: false; error: VersionRangeError };
75
+
76
+ /** Operators longest-first, so `>=` is matched before `>`. */
77
+ const OPERATORS: ComparatorOperator[] = [">=", "<=", ">", "<"];
78
+
79
+ function refuse(message: string, hint?: string): VersionRangeResult {
80
+ return { ok: false, error: hint === undefined ? { message } : { message, hint } };
81
+ }
82
+
83
+ /**
84
+ * Refuse with `>=<version>` as the hint — but only when that hint would itself
85
+ * parse.
86
+ *
87
+ * A hint is a repair the author is meant to paste, so one this parser would
88
+ * reject on the next run is worse than no hint at all: it converts a diagnostic
89
+ * the author can act on into a loop. Everything unparseable (a two-segment
90
+ * version, a date, a leftover placeholder) falls back to the message alone,
91
+ * which still names what is wrong.
92
+ */
93
+ function hintedRefusal(message: string, version: string): VersionRangeResult {
94
+ return parseModuleVersion(version) ? refuse(message, `>=${version}`) : refuse(message);
95
+ }
96
+
97
+ /**
98
+ * Parse a range, or explain why it cannot be one. Never throws and never
99
+ * degrades to a permissive reading: a range this refuses is reported, not
100
+ * silently treated as "no requirement".
101
+ */
102
+ export function parseVersionRange(raw: unknown): VersionRangeResult {
103
+ if (typeof raw !== "string" || raw.trim() === "") {
104
+ return refuse(`expected a version range string, got ${describe(raw)}`);
105
+ }
106
+ const text = raw.trim();
107
+
108
+ if (text.includes("||")) {
109
+ return refuse(
110
+ `'${text}' is a disjunction. A range must be a conjunction of comparators, because ` +
111
+ `verification runs the CLI at its lowest and highest bound and a disjunction has neither`,
112
+ );
113
+ }
114
+ if (/\s-\s/.test(text)) {
115
+ return refuse(
116
+ `'${text}' is a hyphen range`,
117
+ `${text.split(/\s-\s/)[0]?.trim() ?? ""} rewritten as '>=' and '<=' comparators`,
118
+ );
119
+ }
120
+
121
+ const terms = text.split(/[\s,]+/).filter((t) => t !== "");
122
+ const comparators: VersionComparator[] = [];
123
+
124
+ for (const term of terms) {
125
+ if (term.startsWith("^") || term.startsWith("~")) {
126
+ const version = term.slice(1);
127
+ const parsed = parseModuleVersion(version);
128
+ const upper =
129
+ parsed && parsed.major === 0 ? `0.${parsed.minor + 1}.0` : `${(parsed?.major ?? 0) + 1}.0.0`;
130
+ return refuse(
131
+ `'${term}' is not accepted. Pre-1.0, '${term[0]}' allows only ${version} up to ` +
132
+ `${upper}, and Telo ships breaking changes as minor bumps — so it pins this module to ` +
133
+ `a single release generation, which is almost never what is meant`,
134
+ `>=${version}`,
135
+ );
136
+ }
137
+ // Any `x`/`*` placeholder, at any position — `*`, `x`, `1.x`, `1.2.x`,
138
+ // `1.2.*`. Catching only some of them sent the rest to the no-comparator
139
+ // branch below, which suggested `>=1.2.*` — a hint this very parser rejects,
140
+ // which is worse than the value it was rejecting.
141
+ if (/(^|\.)[x*]$/i.test(term) || /(^|\.)[x*]\./i.test(term)) {
142
+ return refuse(`'${term}' is a wildcard range; write explicit comparators instead`);
143
+ }
144
+ if (term.startsWith("=")) {
145
+ return hintedRefusal(`'${term}' pins one exact version`, term.replace(/^=+/, ""));
146
+ }
147
+
148
+ const operator = OPERATORS.find((op) => term.startsWith(op));
149
+ if (!operator) {
150
+ return hintedRefusal(
151
+ `'${term}' has no comparator. A bare version reads as an exact pin in semver, which is ` +
152
+ `almost never meant here`,
153
+ term,
154
+ );
155
+ }
156
+ const version = term.slice(operator.length).trim();
157
+ const parsed = parseModuleVersion(version);
158
+ if (!parsed) {
159
+ return refuse(
160
+ `'${version}' in '${term}' is not a three-part version (X.Y.Z, optionally -prerelease)`,
161
+ );
162
+ }
163
+ comparators.push({ operator, raw: version, parsed });
164
+ }
165
+
166
+ if (comparators.length === 0) return refuse(`'${text}' declares no comparator`);
167
+ return { ok: true, range: { raw: text, comparators } };
168
+ }
169
+
170
+ /** True when `version` satisfies every comparator. An unparseable `version` is
171
+ * `false` — never a pass, since the caller is asking whether a real runtime is
172
+ * admitted and "cannot tell" must not read as "yes". */
173
+ export function rangeAccepts(range: VersionRange, version: string): boolean {
174
+ const parsed = parseModuleVersion(version);
175
+ if (!parsed) return false;
176
+ return range.comparators.every((c) => {
177
+ const cmp = compareParsedModuleVersions(parsed, c.parsed);
178
+ switch (c.operator) {
179
+ case ">=":
180
+ return cmp >= 0;
181
+ case ">":
182
+ return cmp > 0;
183
+ case "<=":
184
+ return cmp <= 0;
185
+ case "<":
186
+ return cmp < 0;
187
+ }
188
+ });
189
+ }
190
+
191
+ /** The lower-bound comparator, or `undefined` when the range is open below.
192
+ * This is the version verification installs and runs as the low edge. */
193
+ export function lowerBound(range: VersionRange): VersionComparator | undefined {
194
+ return highest(range.comparators.filter((c) => c.operator === ">=" || c.operator === ">"));
195
+ }
196
+
197
+ /** The upper-bound comparator, or `undefined` when the range is open above —
198
+ * the normal case, where the high edge is HEAD and normal CI already covers it. */
199
+ export function upperBound(range: VersionRange): VersionComparator | undefined {
200
+ return lowest(range.comparators.filter((c) => c.operator === "<=" || c.operator === "<"));
201
+ }
202
+
203
+ /** The tightest of several same-direction bounds wins; a range may legitimately
204
+ * state more than one, and only the binding bound is an edge worth testing. */
205
+ function highest(list: VersionComparator[]): VersionComparator | undefined {
206
+ return list.reduce<VersionComparator | undefined>(
207
+ (best, c) =>
208
+ best === undefined || compareParsedModuleVersions(c.parsed, best.parsed) > 0 ? c : best,
209
+ undefined,
210
+ );
211
+ }
212
+
213
+ function lowest(list: VersionComparator[]): VersionComparator | undefined {
214
+ return list.reduce<VersionComparator | undefined>(
215
+ (best, c) =>
216
+ best === undefined || compareParsedModuleVersions(c.parsed, best.parsed) < 0 ? c : best,
217
+ undefined,
218
+ );
219
+ }
220
+
221
+ /** True when no version can satisfy the range — `>=0.90.0 <0.80.0`. Reported
222
+ * rather than left to fail mysteriously at every consumer. */
223
+ export function isUnsatisfiable(range: VersionRange): boolean {
224
+ const low = lowerBound(range);
225
+ const high = upperBound(range);
226
+ if (!low || !high) return false;
227
+ const cmp = compareParsedModuleVersions(low.parsed, high.parsed);
228
+ if (cmp > 0) return true;
229
+ // `>=X <X` and `>X <=X` admit nothing; `>=X <=X` admits exactly X.
230
+ if (cmp === 0) return low.operator === ">" || high.operator === "<";
231
+ return false;
232
+ }
233
+
234
+ function describe(value: unknown): string {
235
+ if (value === null) return "null";
236
+ if (Array.isArray(value)) return "an array";
237
+ return typeof value;
238
+ }