wyvrnpm 2.11.0 → 2.12.2

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 (49) hide show
  1. package/README.md +1914 -1905
  2. package/bin/{wyvrn.js → wyvrnpm.js} +31 -0
  3. package/cmake/cpp.cmake +9 -9
  4. package/cmake/functions.cmake +224 -224
  5. package/cmake/macros.cmake +284 -284
  6. package/package.json +3 -2
  7. package/src/auth.js +66 -66
  8. package/src/binary-dir.js +95 -0
  9. package/src/bootstrap/cookbook.js +196 -196
  10. package/src/bootstrap/detect.js +150 -150
  11. package/src/bootstrap/index.js +220 -220
  12. package/src/bootstrap/version.js +72 -72
  13. package/src/build/cache.js +344 -344
  14. package/src/build/clone.js +170 -170
  15. package/src/build/cmake.js +342 -342
  16. package/src/build/index.js +299 -297
  17. package/src/build/msvc-env.js +260 -260
  18. package/src/build/recipe.js +188 -188
  19. package/src/commands/add.js +141 -141
  20. package/src/commands/bootstrap.js +96 -96
  21. package/src/commands/build.js +482 -452
  22. package/src/commands/cache.js +189 -189
  23. package/src/commands/clean.js +80 -80
  24. package/src/commands/configure.js +92 -92
  25. package/src/commands/init.js +70 -70
  26. package/src/commands/install-skill.js +115 -115
  27. package/src/commands/install.js +730 -674
  28. package/src/commands/link.js +320 -320
  29. package/src/commands/profile.js +237 -237
  30. package/src/commands/publish.js +584 -555
  31. package/src/commands/show.js +252 -252
  32. package/src/commands/version.js +187 -187
  33. package/src/compat.js +273 -273
  34. package/src/conf/index.js +415 -391
  35. package/src/conf/namespaces.js +94 -94
  36. package/src/context.js +230 -230
  37. package/src/http-fetch.js +53 -53
  38. package/src/ignore.js +118 -118
  39. package/src/logger.js +122 -122
  40. package/src/options.js +303 -303
  41. package/src/settings-overrides.js +152 -152
  42. package/src/toolchain/deps.js +164 -164
  43. package/src/toolchain/index.js +212 -212
  44. package/src/toolchain/presets.js +356 -340
  45. package/src/toolchain/template.cmake +77 -77
  46. package/src/upload-built.js +265 -265
  47. package/src/version-range.js +301 -301
  48. package/src/zip-safe.js +52 -52
  49. package/src/zip-stream.js +126 -0
package/src/compat.js CHANGED
@@ -1,273 +1,273 @@
1
- 'use strict';
2
-
3
- /**
4
- * Declarative compatibility evaluator for v2 build profiles.
5
- *
6
- * Publishers declare a `compatibility` block in their package's `wyvrn.json`
7
- * stating which profile fields must match exactly vs which may differ.
8
- * At install time, when no exact profileHash match exists, this module
9
- * evaluates each registry-side build against the consumer's profile and
10
- * picks a compatible one if any.
11
- *
12
- * The block format is per-field: either a string shorthand or an object.
13
- *
14
- * "compatibility": {
15
- * "compiler.cppstd": "lte",
16
- * "compiler.runtime": "exact",
17
- * "compiler.version": "exact-or-newer"
18
- * }
19
- *
20
- * equivalent object form (allows future extensions):
21
- *
22
- * "compatibility": {
23
- * "compiler.cppstd": { "mode": "lte" }
24
- * }
25
- *
26
- * Modes:
27
- * exact | Package and consumer values must be equal.
28
- * any | Field is ignored.
29
- * lte | Package value ≤ consumer value (consumer is at least as
30
- * | new/high — e.g. cppstd=20 package usable by cppstd=23).
31
- * gte | Package value ≥ consumer value (symmetry; rarely useful).
32
- * exact-or-newer | Semantically identical to `lte` at MVP — kept as a
33
- * | distinct name because it reads naturally for compiler
34
- * | version fields and leaves room for future "same major"
35
- * | semantics without renaming.
36
- *
37
- * Missing fields default to `exact`. `os` and `arch` are normally forced
38
- * to `exact` regardless of what the block says — cross-arch/cross-OS
39
- * binaries are never ABI-compatible. The one exception is
40
- * `kind: "HeaderOnlyLib"`: header-only packages ship no binary, so the
41
- * publisher's declared modes for `os`/`arch` are respected. Binary kinds
42
- * (`StaticLib`, `DynamicLib`, `ConsoleApp`, …) keep the hard guarantee.
43
- */
44
-
45
- const OS_ARCH_FIELDS = new Set(['os', 'arch']);
46
-
47
- const DEFAULT_COMPAT_FIELDS = [
48
- 'os',
49
- 'arch',
50
- 'compiler',
51
- 'compiler.version',
52
- 'compiler.cppstd',
53
- 'compiler.runtime',
54
- ];
55
-
56
- const VALID_MODES = new Set(['exact', 'any', 'lte', 'gte', 'exact-or-newer']);
57
-
58
- /**
59
- * Normalise a raw compatibility block into `{ field → mode }`.
60
- * Missing fields fall back to `exact`. Unknown modes fall back to `exact`.
61
- *
62
- * For binary kinds (`StaticLib`, `DynamicLib`, `ConsoleApp`, …) `os` and
63
- * `arch` are forced to `exact` — publisher-declared modes are silently
64
- * dropped because cross-arch binaries are never ABI-safe. For
65
- * `kind: "HeaderOnlyLib"` the publisher's declared modes are respected,
66
- * since there is no binary to be ABI-incompatible with.
67
- *
68
- * @param {object|null|undefined} rawCompat
69
- * @param {string|null|undefined} [packageKind] Package manifest `kind` field.
70
- * @returns {Record<string, string>}
71
- */
72
- function normalizeCompat(rawCompat, packageKind) {
73
- const result = {};
74
- for (const field of DEFAULT_COMPAT_FIELDS) result[field] = 'exact';
75
-
76
- if (!rawCompat || typeof rawCompat !== 'object') return result;
77
-
78
- const headerOnly = packageKind === 'HeaderOnlyLib';
79
-
80
- for (const [field, spec] of Object.entries(rawCompat)) {
81
- if (!headerOnly && OS_ARCH_FIELDS.has(field)) continue;
82
- const mode = typeof spec === 'string' ? spec : (spec && spec.mode);
83
- if (mode && VALID_MODES.has(mode)) result[field] = mode;
84
- }
85
- return result;
86
- }
87
-
88
- /**
89
- * Return the default compatibility block — all fields `exact`.
90
- * Used by `publish` when the source manifest lacks a block.
91
- * @returns {Record<string, string>}
92
- */
93
- function defaultCompatBlock() {
94
- const result = {};
95
- for (const field of DEFAULT_COMPAT_FIELDS) result[field] = 'exact';
96
- return result;
97
- }
98
-
99
- /**
100
- * Evaluate a single field under a given mode.
101
- *
102
- * @returns {'match'|'compat-lte'|'compat-gte'|'compat-newer'|'compat-any'|'incompatible'}
103
- */
104
- function evaluateField(mode, consumerVal, packageVal) {
105
- switch (mode) {
106
- case 'any':
107
- return 'compat-any';
108
-
109
- case 'exact':
110
- return consumerVal === packageVal ? 'match' : 'incompatible';
111
-
112
- case 'lte': {
113
- if (consumerVal === packageVal) return 'match';
114
- const c = Number(consumerVal);
115
- const p = Number(packageVal);
116
- if (Number.isFinite(c) && Number.isFinite(p)) {
117
- return p <= c ? 'compat-lte' : 'incompatible';
118
- }
119
- return 'incompatible';
120
- }
121
-
122
- case 'gte': {
123
- if (consumerVal === packageVal) return 'match';
124
- const c = Number(consumerVal);
125
- const p = Number(packageVal);
126
- if (Number.isFinite(c) && Number.isFinite(p)) {
127
- return p >= c ? 'compat-gte' : 'incompatible';
128
- }
129
- return 'incompatible';
130
- }
131
-
132
- case 'exact-or-newer': {
133
- if (consumerVal === packageVal) return 'match';
134
- const c = Number(consumerVal);
135
- const p = Number(packageVal);
136
- if (Number.isFinite(c) && Number.isFinite(p)) {
137
- return c >= p ? 'compat-newer' : 'incompatible';
138
- }
139
- return 'incompatible';
140
- }
141
-
142
- default:
143
- // Unknown mode → fail safe
144
- return 'incompatible';
145
- }
146
- }
147
-
148
- /**
149
- * Evaluate a package's compatibility block against a consumer's profile.
150
- *
151
- * If either `consumerProfile` or `packageProfile` carries an `options`
152
- * sub-object, options participate in compatibility: every option present
153
- * on either side is evaluated, with mode `exact` by default. The compat
154
- * block may override a specific option's mode via `options.<name>` (for
155
- * example `"options.shared": "any"` to say "this build has no ABI
156
- * dependence on `shared`"). See PLAN-OPTIONS §3.5.
157
- *
158
- * @param {Record<string, any>} consumerProfile may include `options: {...}`
159
- * @param {Record<string, any>} packageProfile may include `options: {...}`
160
- * @param {object|null|undefined} rawCompat compatibility block from the package manifest
161
- * @param {string|null|undefined} [packageKind] package manifest `kind` field — unlocks `os`/`arch` modes for HeaderOnlyLib
162
- * @returns {{
163
- * compatible: boolean,
164
- * reasons: Array<{ field: string, mode: string, consumer: any, package: any, outcome: string }>,
165
- * }}
166
- */
167
- function evaluateCompat(consumerProfile, packageProfile, rawCompat, packageKind) {
168
- const compat = normalizeCompat(rawCompat, packageKind);
169
- const reasons = [];
170
- let compatible = true;
171
-
172
- for (const [field, mode] of Object.entries(compat)) {
173
- // `options.<name>` keys in the compat block are handled below,
174
- // together with any options present on either profile.
175
- if (field.startsWith('options.')) continue;
176
-
177
- const cVal = consumerProfile[field];
178
- const pVal = packageProfile[field];
179
- const outcome = evaluateField(mode, cVal, pVal);
180
- reasons.push({ field, mode, consumer: cVal, package: pVal, outcome });
181
- if (outcome === 'incompatible') compatible = false;
182
- }
183
-
184
- // Evaluate every option the consumer or package declares. Default mode
185
- // is `exact`; the compat block may relax to `any` (and the other modes
186
- // work when the values are numeric strings). Forward-compat: an option
187
- // present on one side only is treated as `incompatible` under exact —
188
- // the recipe shapes differ.
189
- const consumerOpts = consumerProfile.options ?? {};
190
- const packageOpts = packageProfile.options ?? {};
191
- const optionKeys = new Set([...Object.keys(consumerOpts), ...Object.keys(packageOpts)]);
192
- for (const name of optionKeys) {
193
- const field = `options.${name}`;
194
- const mode = compat[field] ?? 'exact';
195
- const cVal = consumerOpts[name];
196
- const pVal = packageOpts[name];
197
- const outcome = evaluateField(mode, cVal, pVal);
198
- reasons.push({ field, mode, consumer: cVal, package: pVal, outcome });
199
- if (outcome === 'incompatible') compatible = false;
200
- }
201
-
202
- return { compatible, reasons };
203
- }
204
-
205
- /**
206
- * Deterministic tiebreaker when multiple compatible candidates exist.
207
- *
208
- * 1. Prefer candidates whose `compiler` matches the consumer's.
209
- * 2. Prefer the highest numeric `compiler.version`.
210
- * 3. Prefer candidates whose `compiler.runtime` matches the consumer's.
211
- * 4. Prefer the lexicographically earliest profile hash (stable fallback).
212
- *
213
- * @param {Array<{ profileHash: string, packageProfile: Record<string, any>, reasons: Array<any> }>} candidates
214
- * @param {Record<string, string|null>} consumerProfile
215
- * @returns {object|null} The preferred candidate (or null if list is empty).
216
- */
217
- function pickBestCandidate(candidates, consumerProfile) {
218
- if (!candidates || candidates.length === 0) return null;
219
- if (candidates.length === 1) return candidates[0];
220
-
221
- const sorted = candidates.slice().sort((a, b) => {
222
- const compilerA = a.packageProfile.compiler === consumerProfile.compiler ? 0 : 1;
223
- const compilerB = b.packageProfile.compiler === consumerProfile.compiler ? 0 : 1;
224
- if (compilerA !== compilerB) return compilerA - compilerB;
225
-
226
- const verA = Number(a.packageProfile['compiler.version']) || 0;
227
- const verB = Number(b.packageProfile['compiler.version']) || 0;
228
- if (verA !== verB) return verB - verA; // descending
229
-
230
- const runtimeA = a.packageProfile['compiler.runtime'] === consumerProfile['compiler.runtime'] ? 0 : 1;
231
- const runtimeB = b.packageProfile['compiler.runtime'] === consumerProfile['compiler.runtime'] ? 0 : 1;
232
- if (runtimeA !== runtimeB) return runtimeA - runtimeB;
233
-
234
- return a.profileHash < b.profileHash ? -1 : 1;
235
- });
236
-
237
- return sorted[0];
238
- }
239
-
240
- /**
241
- * Render a reasons array into a compact human-readable summary — used for
242
- * the "chose candidate X because …" log line at install time.
243
- *
244
- * @param {Array<{ field, mode, consumer, package, outcome }>} reasons
245
- * @returns {string}
246
- */
247
- function summarizeReasons(reasons) {
248
- return reasons
249
- .filter((r) => r.outcome !== 'compat-any' || r.mode !== 'any') // skip trivial any
250
- .map((r) => {
251
- switch (r.outcome) {
252
- case 'match': return `${r.field}=${r.consumer}`;
253
- case 'compat-lte': return `${r.field} ${r.package}→${r.consumer} (lte)`;
254
- case 'compat-gte': return `${r.field} ${r.package}←${r.consumer} (gte)`;
255
- case 'compat-newer': return `${r.field} ${r.package}→${r.consumer} (newer)`;
256
- case 'compat-any': return `${r.field}=* (any)`;
257
- case 'incompatible': return `${r.field} INCOMPAT (need ${r.consumer}, have ${r.package})`;
258
- default: return `${r.field} ${r.outcome}`;
259
- }
260
- })
261
- .join(', ');
262
- }
263
-
264
- module.exports = {
265
- normalizeCompat,
266
- defaultCompatBlock,
267
- evaluateCompat,
268
- evaluateField,
269
- pickBestCandidate,
270
- summarizeReasons,
271
- DEFAULT_COMPAT_FIELDS,
272
- VALID_MODES,
273
- };
1
+ 'use strict';
2
+
3
+ /**
4
+ * Declarative compatibility evaluator for v2 build profiles.
5
+ *
6
+ * Publishers declare a `compatibility` block in their package's `wyvrn.json`
7
+ * stating which profile fields must match exactly vs which may differ.
8
+ * At install time, when no exact profileHash match exists, this module
9
+ * evaluates each registry-side build against the consumer's profile and
10
+ * picks a compatible one if any.
11
+ *
12
+ * The block format is per-field: either a string shorthand or an object.
13
+ *
14
+ * "compatibility": {
15
+ * "compiler.cppstd": "lte",
16
+ * "compiler.runtime": "exact",
17
+ * "compiler.version": "exact-or-newer"
18
+ * }
19
+ *
20
+ * equivalent object form (allows future extensions):
21
+ *
22
+ * "compatibility": {
23
+ * "compiler.cppstd": { "mode": "lte" }
24
+ * }
25
+ *
26
+ * Modes:
27
+ * exact | Package and consumer values must be equal.
28
+ * any | Field is ignored.
29
+ * lte | Package value ≤ consumer value (consumer is at least as
30
+ * | new/high — e.g. cppstd=20 package usable by cppstd=23).
31
+ * gte | Package value ≥ consumer value (symmetry; rarely useful).
32
+ * exact-or-newer | Semantically identical to `lte` at MVP — kept as a
33
+ * | distinct name because it reads naturally for compiler
34
+ * | version fields and leaves room for future "same major"
35
+ * | semantics without renaming.
36
+ *
37
+ * Missing fields default to `exact`. `os` and `arch` are normally forced
38
+ * to `exact` regardless of what the block says — cross-arch/cross-OS
39
+ * binaries are never ABI-compatible. The one exception is
40
+ * `kind: "HeaderOnlyLib"`: header-only packages ship no binary, so the
41
+ * publisher's declared modes for `os`/`arch` are respected. Binary kinds
42
+ * (`StaticLib`, `DynamicLib`, `ConsoleApp`, …) keep the hard guarantee.
43
+ */
44
+
45
+ const OS_ARCH_FIELDS = new Set(['os', 'arch']);
46
+
47
+ const DEFAULT_COMPAT_FIELDS = [
48
+ 'os',
49
+ 'arch',
50
+ 'compiler',
51
+ 'compiler.version',
52
+ 'compiler.cppstd',
53
+ 'compiler.runtime',
54
+ ];
55
+
56
+ const VALID_MODES = new Set(['exact', 'any', 'lte', 'gte', 'exact-or-newer']);
57
+
58
+ /**
59
+ * Normalise a raw compatibility block into `{ field → mode }`.
60
+ * Missing fields fall back to `exact`. Unknown modes fall back to `exact`.
61
+ *
62
+ * For binary kinds (`StaticLib`, `DynamicLib`, `ConsoleApp`, …) `os` and
63
+ * `arch` are forced to `exact` — publisher-declared modes are silently
64
+ * dropped because cross-arch binaries are never ABI-safe. For
65
+ * `kind: "HeaderOnlyLib"` the publisher's declared modes are respected,
66
+ * since there is no binary to be ABI-incompatible with.
67
+ *
68
+ * @param {object|null|undefined} rawCompat
69
+ * @param {string|null|undefined} [packageKind] Package manifest `kind` field.
70
+ * @returns {Record<string, string>}
71
+ */
72
+ function normalizeCompat(rawCompat, packageKind) {
73
+ const result = {};
74
+ for (const field of DEFAULT_COMPAT_FIELDS) result[field] = 'exact';
75
+
76
+ if (!rawCompat || typeof rawCompat !== 'object') return result;
77
+
78
+ const headerOnly = packageKind === 'HeaderOnlyLib';
79
+
80
+ for (const [field, spec] of Object.entries(rawCompat)) {
81
+ if (!headerOnly && OS_ARCH_FIELDS.has(field)) continue;
82
+ const mode = typeof spec === 'string' ? spec : (spec && spec.mode);
83
+ if (mode && VALID_MODES.has(mode)) result[field] = mode;
84
+ }
85
+ return result;
86
+ }
87
+
88
+ /**
89
+ * Return the default compatibility block — all fields `exact`.
90
+ * Used by `publish` when the source manifest lacks a block.
91
+ * @returns {Record<string, string>}
92
+ */
93
+ function defaultCompatBlock() {
94
+ const result = {};
95
+ for (const field of DEFAULT_COMPAT_FIELDS) result[field] = 'exact';
96
+ return result;
97
+ }
98
+
99
+ /**
100
+ * Evaluate a single field under a given mode.
101
+ *
102
+ * @returns {'match'|'compat-lte'|'compat-gte'|'compat-newer'|'compat-any'|'incompatible'}
103
+ */
104
+ function evaluateField(mode, consumerVal, packageVal) {
105
+ switch (mode) {
106
+ case 'any':
107
+ return 'compat-any';
108
+
109
+ case 'exact':
110
+ return consumerVal === packageVal ? 'match' : 'incompatible';
111
+
112
+ case 'lte': {
113
+ if (consumerVal === packageVal) return 'match';
114
+ const c = Number(consumerVal);
115
+ const p = Number(packageVal);
116
+ if (Number.isFinite(c) && Number.isFinite(p)) {
117
+ return p <= c ? 'compat-lte' : 'incompatible';
118
+ }
119
+ return 'incompatible';
120
+ }
121
+
122
+ case 'gte': {
123
+ if (consumerVal === packageVal) return 'match';
124
+ const c = Number(consumerVal);
125
+ const p = Number(packageVal);
126
+ if (Number.isFinite(c) && Number.isFinite(p)) {
127
+ return p >= c ? 'compat-gte' : 'incompatible';
128
+ }
129
+ return 'incompatible';
130
+ }
131
+
132
+ case 'exact-or-newer': {
133
+ if (consumerVal === packageVal) return 'match';
134
+ const c = Number(consumerVal);
135
+ const p = Number(packageVal);
136
+ if (Number.isFinite(c) && Number.isFinite(p)) {
137
+ return c >= p ? 'compat-newer' : 'incompatible';
138
+ }
139
+ return 'incompatible';
140
+ }
141
+
142
+ default:
143
+ // Unknown mode → fail safe
144
+ return 'incompatible';
145
+ }
146
+ }
147
+
148
+ /**
149
+ * Evaluate a package's compatibility block against a consumer's profile.
150
+ *
151
+ * If either `consumerProfile` or `packageProfile` carries an `options`
152
+ * sub-object, options participate in compatibility: every option present
153
+ * on either side is evaluated, with mode `exact` by default. The compat
154
+ * block may override a specific option's mode via `options.<name>` (for
155
+ * example `"options.shared": "any"` to say "this build has no ABI
156
+ * dependence on `shared`"). See PLAN-OPTIONS §3.5.
157
+ *
158
+ * @param {Record<string, any>} consumerProfile may include `options: {...}`
159
+ * @param {Record<string, any>} packageProfile may include `options: {...}`
160
+ * @param {object|null|undefined} rawCompat compatibility block from the package manifest
161
+ * @param {string|null|undefined} [packageKind] package manifest `kind` field — unlocks `os`/`arch` modes for HeaderOnlyLib
162
+ * @returns {{
163
+ * compatible: boolean,
164
+ * reasons: Array<{ field: string, mode: string, consumer: any, package: any, outcome: string }>,
165
+ * }}
166
+ */
167
+ function evaluateCompat(consumerProfile, packageProfile, rawCompat, packageKind) {
168
+ const compat = normalizeCompat(rawCompat, packageKind);
169
+ const reasons = [];
170
+ let compatible = true;
171
+
172
+ for (const [field, mode] of Object.entries(compat)) {
173
+ // `options.<name>` keys in the compat block are handled below,
174
+ // together with any options present on either profile.
175
+ if (field.startsWith('options.')) continue;
176
+
177
+ const cVal = consumerProfile[field];
178
+ const pVal = packageProfile[field];
179
+ const outcome = evaluateField(mode, cVal, pVal);
180
+ reasons.push({ field, mode, consumer: cVal, package: pVal, outcome });
181
+ if (outcome === 'incompatible') compatible = false;
182
+ }
183
+
184
+ // Evaluate every option the consumer or package declares. Default mode
185
+ // is `exact`; the compat block may relax to `any` (and the other modes
186
+ // work when the values are numeric strings). Forward-compat: an option
187
+ // present on one side only is treated as `incompatible` under exact —
188
+ // the recipe shapes differ.
189
+ const consumerOpts = consumerProfile.options ?? {};
190
+ const packageOpts = packageProfile.options ?? {};
191
+ const optionKeys = new Set([...Object.keys(consumerOpts), ...Object.keys(packageOpts)]);
192
+ for (const name of optionKeys) {
193
+ const field = `options.${name}`;
194
+ const mode = compat[field] ?? 'exact';
195
+ const cVal = consumerOpts[name];
196
+ const pVal = packageOpts[name];
197
+ const outcome = evaluateField(mode, cVal, pVal);
198
+ reasons.push({ field, mode, consumer: cVal, package: pVal, outcome });
199
+ if (outcome === 'incompatible') compatible = false;
200
+ }
201
+
202
+ return { compatible, reasons };
203
+ }
204
+
205
+ /**
206
+ * Deterministic tiebreaker when multiple compatible candidates exist.
207
+ *
208
+ * 1. Prefer candidates whose `compiler` matches the consumer's.
209
+ * 2. Prefer the highest numeric `compiler.version`.
210
+ * 3. Prefer candidates whose `compiler.runtime` matches the consumer's.
211
+ * 4. Prefer the lexicographically earliest profile hash (stable fallback).
212
+ *
213
+ * @param {Array<{ profileHash: string, packageProfile: Record<string, any>, reasons: Array<any> }>} candidates
214
+ * @param {Record<string, string|null>} consumerProfile
215
+ * @returns {object|null} The preferred candidate (or null if list is empty).
216
+ */
217
+ function pickBestCandidate(candidates, consumerProfile) {
218
+ if (!candidates || candidates.length === 0) return null;
219
+ if (candidates.length === 1) return candidates[0];
220
+
221
+ const sorted = candidates.slice().sort((a, b) => {
222
+ const compilerA = a.packageProfile.compiler === consumerProfile.compiler ? 0 : 1;
223
+ const compilerB = b.packageProfile.compiler === consumerProfile.compiler ? 0 : 1;
224
+ if (compilerA !== compilerB) return compilerA - compilerB;
225
+
226
+ const verA = Number(a.packageProfile['compiler.version']) || 0;
227
+ const verB = Number(b.packageProfile['compiler.version']) || 0;
228
+ if (verA !== verB) return verB - verA; // descending
229
+
230
+ const runtimeA = a.packageProfile['compiler.runtime'] === consumerProfile['compiler.runtime'] ? 0 : 1;
231
+ const runtimeB = b.packageProfile['compiler.runtime'] === consumerProfile['compiler.runtime'] ? 0 : 1;
232
+ if (runtimeA !== runtimeB) return runtimeA - runtimeB;
233
+
234
+ return a.profileHash < b.profileHash ? -1 : 1;
235
+ });
236
+
237
+ return sorted[0];
238
+ }
239
+
240
+ /**
241
+ * Render a reasons array into a compact human-readable summary — used for
242
+ * the "chose candidate X because …" log line at install time.
243
+ *
244
+ * @param {Array<{ field, mode, consumer, package, outcome }>} reasons
245
+ * @returns {string}
246
+ */
247
+ function summarizeReasons(reasons) {
248
+ return reasons
249
+ .filter((r) => r.outcome !== 'compat-any' || r.mode !== 'any') // skip trivial any
250
+ .map((r) => {
251
+ switch (r.outcome) {
252
+ case 'match': return `${r.field}=${r.consumer}`;
253
+ case 'compat-lte': return `${r.field} ${r.package}→${r.consumer} (lte)`;
254
+ case 'compat-gte': return `${r.field} ${r.package}←${r.consumer} (gte)`;
255
+ case 'compat-newer': return `${r.field} ${r.package}→${r.consumer} (newer)`;
256
+ case 'compat-any': return `${r.field}=* (any)`;
257
+ case 'incompatible': return `${r.field} INCOMPAT (need ${r.consumer}, have ${r.package})`;
258
+ default: return `${r.field} ${r.outcome}`;
259
+ }
260
+ })
261
+ .join(', ');
262
+ }
263
+
264
+ module.exports = {
265
+ normalizeCompat,
266
+ defaultCompatBlock,
267
+ evaluateCompat,
268
+ evaluateField,
269
+ pickBestCandidate,
270
+ summarizeReasons,
271
+ DEFAULT_COMPAT_FIELDS,
272
+ VALID_MODES,
273
+ };