wyvrnpm 2.0.4 → 2.1.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.
@@ -12,6 +12,7 @@ const {
12
12
  mergeProfile,
13
13
  } = require('../profile');
14
14
  const { readConfig, writeConfig } = require('../config');
15
+ const log = require('../logger');
15
16
 
16
17
  // ---------------------------------------------------------------------------
17
18
  // list
@@ -27,8 +28,9 @@ function list() {
27
28
  const dir = getProfilesDir();
28
29
  const defName = config.defaultProfile ?? 'default';
29
30
 
30
- console.log(`[wyvrn] Profiles directory: ${dir}`);
31
- console.log(`[wyvrn] Default profile : ${defName}\n`);
31
+ log.info(`Profiles directory: ${dir}`);
32
+ log.info(`Default profile : ${defName}`);
33
+ console.log('');
32
34
 
33
35
  if (names.length === 0) {
34
36
  console.log(' (no profiles saved — run `wyvrnpm configure profile detect` to create one)');
@@ -60,12 +62,12 @@ function show(argv) {
60
62
  const stored = readProfile(name);
61
63
 
62
64
  if (stored) {
63
- console.log(`[wyvrn] Profile "${name}":`);
65
+ log.info(`Profile "${name}":`);
64
66
  console.log(formatProfile(stored));
65
67
  console.log(`\nProfile hash : ${hashProfile(stored)}`);
66
68
  } else {
67
69
  const detected = detectProfile();
68
- console.log(`[wyvrn] Profile "${name}" not found — showing auto-detected environment:`);
70
+ log.info(`Profile "${name}" not found — showing auto-detected environment:`);
69
71
  console.log(formatProfile(detected));
70
72
  console.log(`\nProfile hash : ${hashProfile(detected)}`);
71
73
  console.log(`\nRun \`wyvrnpm configure profile detect --name ${name}\` to save this.`);
@@ -85,13 +87,14 @@ function detect(argv) {
85
87
  const name = argv.name ?? config.defaultProfile ?? 'default';
86
88
  const p = detectProfile();
87
89
 
88
- console.log('[wyvrn] Detected profile:');
90
+ log.info('Detected profile:');
89
91
  console.log(formatProfile(p));
90
92
  console.log(`\nProfile hash : ${hashProfile(p)}`);
91
93
 
92
94
  if (!argv.dryRun) {
93
95
  writeProfile(name, p);
94
- console.log(`\n[wyvrn] Saved as profile "${name}".`);
96
+ console.log('');
97
+ log.success(`Saved as profile "${name}".`);
95
98
  }
96
99
  }
97
100
 
@@ -120,7 +123,7 @@ function set(argv) {
120
123
 
121
124
  writeProfile(name, updated);
122
125
 
123
- console.log(`[wyvrn] Profile "${name}" updated:`);
126
+ log.info(`Profile "${name}" updated:`);
124
127
  console.log(formatProfile(updated));
125
128
  console.log(`\nProfile hash : ${hashProfile(updated)}`);
126
129
  }
@@ -136,13 +139,13 @@ function set(argv) {
136
139
  function setDefault(argv) {
137
140
  const name = argv.name;
138
141
  if (!readProfile(name)) {
139
- console.error(`[wyvrn] Error: profile "${name}" does not exist. Create it first.`);
142
+ log.error(`profile "${name}" does not exist. Create it first.`);
140
143
  process.exit(1);
141
144
  }
142
145
  const config = readConfig();
143
146
  config.defaultProfile = name;
144
147
  writeConfig(config);
145
- console.log(`[wyvrn] Default profile set to "${name}".`);
148
+ log.info(`Default profile set to "${name}".`);
146
149
  }
147
150
 
148
151
  // ---------------------------------------------------------------------------
@@ -156,14 +159,14 @@ function setDefault(argv) {
156
159
  function del(argv) {
157
160
  const name = argv.name;
158
161
  if (name === 'default') {
159
- console.error('[wyvrn] Error: cannot delete the "default" profile.');
162
+ log.error('cannot delete the "default" profile.');
160
163
  process.exit(1);
161
164
  }
162
165
  const removed = deleteProfile(name);
163
166
  if (removed) {
164
- console.log(`[wyvrn] Profile "${name}" deleted.`);
167
+ log.info(`Profile "${name}" deleted.`);
165
168
  } else {
166
- console.error(`[wyvrn] Error: profile "${name}" not found.`);
169
+ log.error(`profile "${name}" not found.`);
167
170
  process.exit(1);
168
171
  }
169
172
  }
@@ -8,7 +8,9 @@ const AdmZip = require('adm-zip');
8
8
  const { readManifest } = require('../manifest');
9
9
  const { getProvider } = require('../providers');
10
10
  const { readConfig } = require('../config');
11
- const { loadProfile, hashProfile, sha256Of, getGitSha, getGitRepo } = require('../profile');
11
+ const { loadProfile, hashProfile, sha256Of, getGitSha, getGitRepo, isShaOnRemote } = require('../profile');
12
+ const { defaultCompatBlock } = require('../compat');
13
+ const log = require('../logger');
12
14
 
13
15
  // ---------------------------------------------------------------------------
14
16
  // .wyvrnignore helpers (unchanged from v1)
@@ -40,7 +42,7 @@ function addFolderFiltered(zip, dir, base, ignoreRe) {
40
42
  const fullPath = path.join(dir, entry);
41
43
  const relPath = path.relative(base, fullPath).replace(/\\/g, '/');
42
44
  if (ignoreRe.some((re) => re.test(relPath))) {
43
- console.log(`[wyvrn] Ignoring: ${relPath}`);
45
+ log.info(` Ignoring: ${relPath}`);
44
46
  continue;
45
47
  }
46
48
  const stat = fs.statSync(fullPath);
@@ -64,21 +66,21 @@ function resolveSource(argv) {
64
66
  if (source) {
65
67
  const named = config.publishSources.find((s) => s.name === source);
66
68
  if (named) {
67
- console.log(`[wyvrn] Using publish source "${named.name}" from config`);
69
+ log.info(`Using publish source "${named.name}" from config`);
68
70
  source = named.url;
69
71
  awsProfile = awsProfile ?? named.profile;
70
72
  token = token ?? named.token;
71
73
  }
72
74
  } else {
73
75
  if (config.publishSources.length === 0) {
74
- console.error(
75
- '[wyvrn] Error: --source is required (or configure one with: ' +
76
+ log.error(
77
+ '--source is required (or configure one with: ' +
76
78
  'wyvrnpm configure add-source --kind publish ...)',
77
79
  );
78
80
  process.exit(1);
79
81
  }
80
82
  const first = config.publishSources[0];
81
- console.log(`[wyvrn] Using publish source "${first.name}" from config`);
83
+ log.info(`Using publish source "${first.name}" from config`);
82
84
  source = first.url;
83
85
  awsProfile = awsProfile ?? first.profile;
84
86
  token = token ?? first.token;
@@ -104,19 +106,19 @@ async function publish(argv) {
104
106
  const manifestPath = path.resolve(manifestArg || './wyvrn.json');
105
107
  const manifest = await readManifest(manifestPath);
106
108
  if (!manifest) {
107
- console.error(`[wyvrn] Error: Could not read manifest at ${manifestPath}`);
109
+ log.error(`Could not read manifest at ${manifestPath}`);
108
110
  process.exit(1);
109
111
  }
110
112
 
111
113
  const { name, version } = manifest;
112
114
  if (!name || !version) {
113
- console.error('[wyvrn] Error: Manifest must have "name" and "version" fields');
115
+ log.error('Manifest must have "name" and "version" fields');
114
116
  process.exit(1);
115
117
  }
116
118
 
117
119
  const srcDir = path.resolve(publishPath || '.');
118
120
  if (!fs.existsSync(srcDir)) {
119
- console.error(`[wyvrn] Error: Publish path does not exist: ${srcDir}`);
121
+ log.error(`Publish path does not exist: ${srcDir}`);
120
122
  process.exit(1);
121
123
  }
122
124
 
@@ -125,7 +127,7 @@ async function publish(argv) {
125
127
  try {
126
128
  provider = getProvider(source);
127
129
  } catch (err) {
128
- console.error(`[wyvrn] Error: ${err.message}`);
130
+ log.error(err.message);
129
131
  process.exit(1);
130
132
  }
131
133
 
@@ -135,25 +137,25 @@ async function publish(argv) {
135
137
  const { profile: buildProfile, fromFile } = loadProfile(activeProfileName);
136
138
  const profileHash = hashProfile(buildProfile);
137
139
 
138
- console.log(
139
- `[wyvrn] Publishing ${name}@${version}\n` +
140
- `[wyvrn] Profile : "${activeProfileName}"${fromFile ? '' : ' (auto-detected — save with `wyvrnpm configure profile detect`)'}\n` +
141
- `[wyvrn] ${buildProfile.os}/${buildProfile.arch} ` +
140
+ log.info(
141
+ `Publishing ${name}@${version}\n` +
142
+ `Profile : "${activeProfileName}"${fromFile ? '' : ' (auto-detected — save with `wyvrnpm configure profile detect`)'}\n` +
143
+ ` ${buildProfile.os}/${buildProfile.arch} ` +
142
144
  `${buildProfile.compiler} ${buildProfile['compiler.version']} ` +
143
145
  `C++${buildProfile['compiler.cppstd']}` +
144
146
  (buildProfile['compiler.runtime'] ? ` [${buildProfile['compiler.runtime']}]` : '') + '\n' +
145
- `[wyvrn] Profile hash: ${profileHash}\n` +
146
- `[wyvrn] Provider : ${provider.constructor.providerName}`,
147
+ `Profile hash: ${profileHash}\n` +
148
+ `Provider : ${provider.constructor.providerName}`,
147
149
  );
148
150
 
149
151
  // ── Check v2 existence ────────────────────────────────────────────────────
150
152
  const v2AlreadyExists = await provider.v2Exists({ source, name, version, profileHash, awsProfile, token });
151
153
  if (v2AlreadyExists) {
152
154
  if (force) {
153
- console.warn(`[wyvrn] Warning: ${name}@${version} [${profileHash}] already exists — overwriting (--force)`);
155
+ log.warn(`${name}@${version} [${profileHash}] already exists — overwriting (--force)`);
154
156
  } else {
155
- console.error(
156
- `[wyvrn] Error: ${name}@${version} [${profileHash}] already exists.\n` +
157
+ log.error(
158
+ `${name}@${version} [${profileHash}] already exists.\n` +
157
159
  ' Bump the version, use a different profile, or pass --force to overwrite.',
158
160
  );
159
161
  process.exit(1);
@@ -170,43 +172,76 @@ async function publish(argv) {
170
172
  const ver = typeof entry === 'string' ? entry : entry.version;
171
173
  if (ver) lockedDependencies[pkgName] = ver;
172
174
  }
173
- console.log(`[wyvrn] Lock file : ${Object.keys(lockedDependencies).length} pinned package(s)`);
175
+ log.info(`Lock file : ${Object.keys(lockedDependencies).length} pinned package(s)`);
174
176
  } catch {
175
- console.warn('[wyvrn] Warning: could not read wyvrn.lock — dependency versions will be published as-is from wyvrn.json');
177
+ log.warn('could not read wyvrn.lock — dependency versions will be published as-is from wyvrn.json');
176
178
  }
177
179
  } else {
178
- console.warn('[wyvrn] Warning: no wyvrn.lock found — run `wyvrnpm install` before publishing to pin dependency versions');
180
+ log.warn('no wyvrn.lock found — run `wyvrnpm install` before publishing to pin dependency versions');
179
181
  }
180
182
 
181
183
  // ── Git metadata ──────────────────────────────────────────────────────────
182
184
  const gitSha = getGitSha(srcDir);
183
185
  const gitRepo = getGitRepo(srcDir);
184
- if (gitSha) console.log(`[wyvrn] Git commit : ${gitSha}`);
185
- if (gitRepo) console.log(`[wyvrn] Git remote : ${gitRepo}`);
186
+ if (gitSha) log.info(`Git commit : ${gitSha}`);
187
+ if (gitRepo) log.info(`Git remote : ${gitRepo}`);
188
+
189
+ // Warn (but don't block) if HEAD isn't reachable from origin. Downstream
190
+ // `--build=missing` consumers will fail to fetch this commit — the fix
191
+ // is almost always `git push` before publishing.
192
+ if (gitSha && gitRepo && !isShaOnRemote(gitSha, srcDir)) {
193
+ log.warn(
194
+ `commit ${gitSha.slice(0, 12)} is not reachable from any remote ` +
195
+ `branch or tag on origin.\n` +
196
+ ` Consumers building this package from source will fail to retrieve it.\n` +
197
+ ` Fix: push your branch (or tag the commit) before publishing.`,
198
+ );
199
+ }
186
200
 
187
201
  // ── .wyvrnignore ──────────────────────────────────────────────────────────
188
202
  const ignoreFile = path.join(srcDir, '.wyvrnignore');
189
203
  const ignorePatterns = loadIgnorePatterns(ignoreFile);
190
204
  const ignoreRe = ignorePatterns.map(patternToRegex);
191
205
  if (ignorePatterns.length > 0) {
192
- console.log(`[wyvrn] Loaded ${ignorePatterns.length} pattern(s) from .wyvrnignore`);
206
+ log.info(`Loaded ${ignorePatterns.length} pattern(s) from .wyvrnignore`);
193
207
  }
194
208
 
195
209
  // ── Build zip ─────────────────────────────────────────────────────────────
196
210
  const tmpZipPath = path.join(os.tmpdir(), `wyvrnpm-${name}-${version}-${Date.now()}.zip`);
211
+
212
+ // ── Inject default `compatibility` block if the source manifest lacks one ──
213
+ // Written to a temp file so the source wyvrn.json is never modified.
214
+ // A published manifest must always carry a compat block so install-time
215
+ // resolution has something to evaluate (default = all fields `exact`,
216
+ // identical semantics to pre-phase-2 strict behaviour).
217
+ const tmpManifestPath = path.join(
218
+ os.tmpdir(),
219
+ `wyvrnpm-manifest-${name}-${version}-${Date.now()}.json`,
220
+ );
221
+ const manifestForPublish = {
222
+ ...manifest,
223
+ compatibility: manifest.compatibility ?? defaultCompatBlock(),
224
+ };
225
+ fs.writeFileSync(tmpManifestPath, JSON.stringify(manifestForPublish, null, 2), 'utf8');
226
+ if (!manifest.compatibility) {
227
+ log.info('Injecting default `compatibility` block (all fields `exact`) — override by adding one to wyvrn.json');
228
+ } else {
229
+ log.info(`Using package-declared compatibility: ${JSON.stringify(manifest.compatibility)}`);
230
+ }
231
+
197
232
  try {
198
- console.log(`[wyvrn] Zipping ${srcDir} ...`);
233
+ log.info(`Zipping ${srcDir} ...`);
199
234
  const zip = new AdmZip();
200
235
  addFolderFiltered(zip, srcDir, srcDir, ignoreRe);
201
236
  zip.writeZip(tmpZipPath);
202
237
 
203
238
  const contentSha256 = sha256Of(tmpZipPath);
204
- console.log(`[wyvrn] Content SHA256 : ${contentSha256}`);
239
+ log.info(`Content SHA256 : ${contentSha256}`);
205
240
 
206
241
  // ── v2 publish ────────────────────────────────────────────────────────
207
- console.log('[wyvrn] Publishing (v2) ...');
242
+ log.info('Publishing (v2) ...');
208
243
  await provider.v2Publish(
209
- { manifest: manifestPath, zip: tmpZipPath },
244
+ { manifest: tmpManifestPath, zip: tmpZipPath },
210
245
  {
211
246
  source,
212
247
  name,
@@ -223,9 +258,10 @@ async function publish(argv) {
223
258
  },
224
259
  );
225
260
 
226
- console.log(`[wyvrn] Successfully published ${name}@${version} [${profileHash}] to ${source}`);
261
+ log.success(`Successfully published ${name}@${version} [${profileHash}] to ${source}`);
227
262
  } finally {
228
- if (fs.existsSync(tmpZipPath)) fs.unlinkSync(tmpZipPath);
263
+ if (fs.existsSync(tmpZipPath)) fs.unlinkSync(tmpZipPath);
264
+ if (fs.existsSync(tmpManifestPath)) fs.unlinkSync(tmpManifestPath);
229
265
  }
230
266
  }
231
267
 
package/src/compat.js ADDED
@@ -0,0 +1,232 @@
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 ALWAYS evaluated
38
+ * as `exact` regardless of what the block says — cross-arch/cross-OS
39
+ * binaries are never ABI-compatible.
40
+ */
41
+
42
+ const OS_ARCH_ALWAYS_EXACT = new Set(['os', 'arch']);
43
+
44
+ const DEFAULT_COMPAT_FIELDS = [
45
+ 'os',
46
+ 'arch',
47
+ 'compiler',
48
+ 'compiler.version',
49
+ 'compiler.cppstd',
50
+ 'compiler.runtime',
51
+ ];
52
+
53
+ const VALID_MODES = new Set(['exact', 'any', 'lte', 'gte', 'exact-or-newer']);
54
+
55
+ /**
56
+ * Normalise a raw compatibility block into `{ field → mode }`.
57
+ * Missing fields fall back to `exact`. Unknown modes fall back to `exact`.
58
+ * `os` and `arch` are forced to `exact`.
59
+ *
60
+ * @param {object|null|undefined} rawCompat
61
+ * @returns {Record<string, string>}
62
+ */
63
+ function normalizeCompat(rawCompat) {
64
+ const result = {};
65
+ for (const field of DEFAULT_COMPAT_FIELDS) result[field] = 'exact';
66
+
67
+ if (!rawCompat || typeof rawCompat !== 'object') return result;
68
+
69
+ for (const [field, spec] of Object.entries(rawCompat)) {
70
+ if (OS_ARCH_ALWAYS_EXACT.has(field)) continue;
71
+ const mode = typeof spec === 'string' ? spec : (spec && spec.mode);
72
+ if (mode && VALID_MODES.has(mode)) result[field] = mode;
73
+ }
74
+ return result;
75
+ }
76
+
77
+ /**
78
+ * Return the default compatibility block — all fields `exact`.
79
+ * Used by `publish` when the source manifest lacks a block.
80
+ * @returns {Record<string, string>}
81
+ */
82
+ function defaultCompatBlock() {
83
+ const result = {};
84
+ for (const field of DEFAULT_COMPAT_FIELDS) result[field] = 'exact';
85
+ return result;
86
+ }
87
+
88
+ /**
89
+ * Evaluate a single field under a given mode.
90
+ *
91
+ * @returns {'match'|'compat-lte'|'compat-gte'|'compat-newer'|'compat-any'|'incompatible'}
92
+ */
93
+ function evaluateField(mode, consumerVal, packageVal) {
94
+ switch (mode) {
95
+ case 'any':
96
+ return 'compat-any';
97
+
98
+ case 'exact':
99
+ return consumerVal === packageVal ? 'match' : 'incompatible';
100
+
101
+ case 'lte': {
102
+ if (consumerVal === packageVal) return 'match';
103
+ const c = Number(consumerVal);
104
+ const p = Number(packageVal);
105
+ if (Number.isFinite(c) && Number.isFinite(p)) {
106
+ return p <= c ? 'compat-lte' : 'incompatible';
107
+ }
108
+ return 'incompatible';
109
+ }
110
+
111
+ case 'gte': {
112
+ if (consumerVal === packageVal) return 'match';
113
+ const c = Number(consumerVal);
114
+ const p = Number(packageVal);
115
+ if (Number.isFinite(c) && Number.isFinite(p)) {
116
+ return p >= c ? 'compat-gte' : 'incompatible';
117
+ }
118
+ return 'incompatible';
119
+ }
120
+
121
+ case 'exact-or-newer': {
122
+ if (consumerVal === packageVal) return 'match';
123
+ const c = Number(consumerVal);
124
+ const p = Number(packageVal);
125
+ if (Number.isFinite(c) && Number.isFinite(p)) {
126
+ return c >= p ? 'compat-newer' : 'incompatible';
127
+ }
128
+ return 'incompatible';
129
+ }
130
+
131
+ default:
132
+ // Unknown mode → fail safe
133
+ return 'incompatible';
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Evaluate a package's compatibility block against a consumer's profile.
139
+ *
140
+ * @param {Record<string, string|null>} consumerProfile
141
+ * @param {Record<string, string|null>} packageProfile - what the package was built with
142
+ * @param {object|null|undefined} rawCompat - compatibility block from the package manifest
143
+ * @returns {{
144
+ * compatible: boolean,
145
+ * reasons: Array<{ field: string, mode: string, consumer: any, package: any, outcome: string }>,
146
+ * }}
147
+ */
148
+ function evaluateCompat(consumerProfile, packageProfile, rawCompat) {
149
+ const compat = normalizeCompat(rawCompat);
150
+ const reasons = [];
151
+ let compatible = true;
152
+
153
+ for (const [field, mode] of Object.entries(compat)) {
154
+ const cVal = consumerProfile[field];
155
+ const pVal = packageProfile[field];
156
+ const outcome = evaluateField(mode, cVal, pVal);
157
+ reasons.push({ field, mode, consumer: cVal, package: pVal, outcome });
158
+ if (outcome === 'incompatible') compatible = false;
159
+ }
160
+
161
+ return { compatible, reasons };
162
+ }
163
+
164
+ /**
165
+ * Deterministic tiebreaker when multiple compatible candidates exist.
166
+ *
167
+ * 1. Prefer candidates whose `compiler` matches the consumer's.
168
+ * 2. Prefer the highest numeric `compiler.version`.
169
+ * 3. Prefer candidates whose `compiler.runtime` matches the consumer's.
170
+ * 4. Prefer the lexicographically earliest profile hash (stable fallback).
171
+ *
172
+ * @param {Array<{ profileHash: string, packageProfile: Record<string, any>, reasons: Array<any> }>} candidates
173
+ * @param {Record<string, string|null>} consumerProfile
174
+ * @returns {object|null} The preferred candidate (or null if list is empty).
175
+ */
176
+ function pickBestCandidate(candidates, consumerProfile) {
177
+ if (!candidates || candidates.length === 0) return null;
178
+ if (candidates.length === 1) return candidates[0];
179
+
180
+ const sorted = candidates.slice().sort((a, b) => {
181
+ const compilerA = a.packageProfile.compiler === consumerProfile.compiler ? 0 : 1;
182
+ const compilerB = b.packageProfile.compiler === consumerProfile.compiler ? 0 : 1;
183
+ if (compilerA !== compilerB) return compilerA - compilerB;
184
+
185
+ const verA = Number(a.packageProfile['compiler.version']) || 0;
186
+ const verB = Number(b.packageProfile['compiler.version']) || 0;
187
+ if (verA !== verB) return verB - verA; // descending
188
+
189
+ const runtimeA = a.packageProfile['compiler.runtime'] === consumerProfile['compiler.runtime'] ? 0 : 1;
190
+ const runtimeB = b.packageProfile['compiler.runtime'] === consumerProfile['compiler.runtime'] ? 0 : 1;
191
+ if (runtimeA !== runtimeB) return runtimeA - runtimeB;
192
+
193
+ return a.profileHash < b.profileHash ? -1 : 1;
194
+ });
195
+
196
+ return sorted[0];
197
+ }
198
+
199
+ /**
200
+ * Render a reasons array into a compact human-readable summary — used for
201
+ * the "chose candidate X because …" log line at install time.
202
+ *
203
+ * @param {Array<{ field, mode, consumer, package, outcome }>} reasons
204
+ * @returns {string}
205
+ */
206
+ function summarizeReasons(reasons) {
207
+ return reasons
208
+ .filter((r) => r.outcome !== 'compat-any' || r.mode !== 'any') // skip trivial any
209
+ .map((r) => {
210
+ switch (r.outcome) {
211
+ case 'match': return `${r.field}=${r.consumer}`;
212
+ case 'compat-lte': return `${r.field} ${r.package}→${r.consumer} (lte)`;
213
+ case 'compat-gte': return `${r.field} ${r.package}←${r.consumer} (gte)`;
214
+ case 'compat-newer': return `${r.field} ${r.package}→${r.consumer} (newer)`;
215
+ case 'compat-any': return `${r.field}=* (any)`;
216
+ case 'incompatible': return `${r.field} INCOMPAT (need ${r.consumer}, have ${r.package})`;
217
+ default: return `${r.field} ${r.outcome}`;
218
+ }
219
+ })
220
+ .join(', ');
221
+ }
222
+
223
+ module.exports = {
224
+ normalizeCompat,
225
+ defaultCompatBlock,
226
+ evaluateCompat,
227
+ evaluateField,
228
+ pickBestCandidate,
229
+ summarizeReasons,
230
+ DEFAULT_COMPAT_FIELDS,
231
+ VALID_MODES,
232
+ };
package/src/config.js CHANGED
@@ -4,6 +4,8 @@ const fs = require('fs');
4
4
  const path = require('path');
5
5
  const os = require('os');
6
6
 
7
+ const log = require('./logger');
8
+
7
9
  /**
8
10
  * Returns the directory where wyvrnpm stores its config.
9
11
  * Windows : %LOCALAPPDATA%\wyvrnpm
@@ -41,7 +43,7 @@ function readConfig() {
41
43
  defaultProfile: raw.defaultProfile ?? 'default',
42
44
  };
43
45
  } catch {
44
- console.warn('[wyvrn] Warning: could not parse config.json — using defaults');
46
+ log.warn('could not parse config.json — using defaults');
45
47
  return { installSources: [], publishSources: [], linkedPackages: {}, defaultProfile: 'default' };
46
48
  }
47
49
  }