wyvrnpm 2.1.0 → 2.4.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.
@@ -10,44 +10,37 @@ const { getProvider } = require('../provide
10
10
  const { readConfig } = require('../config');
11
11
  const { loadProfile, hashProfile, sha256Of, getGitSha, getGitRepo, isShaOnRemote } = require('../profile');
12
12
  const { defaultCompatBlock } = require('../compat');
13
+ const {
14
+ normalizeOptionsDeclaration,
15
+ resolveEffectiveOptions,
16
+ parseCliOptions,
17
+ } = require('../options');
18
+ const { loadIgnorePatterns, isIgnored } = require('../ignore');
19
+ const { hasBuildRecipe, normalizeRecipe } = require('../build/recipe');
13
20
  const log = require('../logger');
14
21
 
15
- // ---------------------------------------------------------------------------
16
- // .wyvrnignore helpers (unchanged from v1)
17
- // ---------------------------------------------------------------------------
18
-
19
- function loadIgnorePatterns(ignoreFile) {
20
- if (!fs.existsSync(ignoreFile)) return [];
21
- return fs
22
- .readFileSync(ignoreFile, 'utf8')
23
- .split(/\r?\n/)
24
- .map((l) => l.trim())
25
- .filter((l) => l.length > 0 && !l.startsWith('#'));
26
- }
27
-
28
- function patternToRegex(pattern) {
29
- const anchored = pattern.startsWith('/');
30
- const p = anchored ? pattern.slice(1) : pattern;
31
- let re = p
32
- .replace(/[.+^${}()|[\]\\]/g, '\\$&')
33
- .replace(/\*\*/g, '\x00')
34
- .replace(/\*/g, '[^/]*')
35
- .replace(/\?/g, '[^/]')
36
- .replace(/\x00/g, '.*');
37
- return anchored ? new RegExp(`^${re}(/|$)`) : new RegExp(`(^|/)${re}(/|$)`);
38
- }
39
-
40
- function addFolderFiltered(zip, dir, base, ignoreRe) {
22
+ /**
23
+ * Walk `dir` recursively and add every file to `zip`, skipping anything that
24
+ * matches `.wyvrnignore` patterns OR whose zip-relative path is already in
25
+ * `reservedRelPaths`. The reserved set lets the install-tree overlay win
26
+ * on path collisions (e.g. `include/foo.h` shipped by both source and
27
+ * install trees).
28
+ */
29
+ function addFolderFiltered(zip, dir, base, patterns, reservedRelPaths = new Set()) {
41
30
  for (const entry of fs.readdirSync(dir)) {
42
31
  const fullPath = path.join(dir, entry);
43
32
  const relPath = path.relative(base, fullPath).replace(/\\/g, '/');
44
- if (ignoreRe.some((re) => re.test(relPath))) {
45
- log.info(` Ignoring: ${relPath}`);
33
+ const stat = fs.statSync(fullPath);
34
+ const isDir = stat.isDirectory();
35
+ if (isIgnored(relPath, isDir, patterns)) {
36
+ log.info(` Ignoring: ${relPath}${isDir ? '/' : ''}`);
46
37
  continue;
47
38
  }
48
- const stat = fs.statSync(fullPath);
49
- if (stat.isDirectory()) {
50
- addFolderFiltered(zip, fullPath, base, ignoreRe);
39
+ if (isDir) {
40
+ addFolderFiltered(zip, fullPath, base, patterns, reservedRelPaths);
41
+ } else if (reservedRelPaths.has(relPath)) {
42
+ // Install tree has a canonical version of this file; let that win.
43
+ log.info(` Install-tree wins: ${relPath}`);
51
44
  } else {
52
45
  const zipDir = path.dirname(relPath).replace(/\\/g, '/');
53
46
  zip.addLocalFile(fullPath, zipDir === '.' ? '' : zipDir);
@@ -55,6 +48,42 @@ function addFolderFiltered(zip, dir, base, ignoreRe) {
55
48
  }
56
49
  }
57
50
 
51
+ /**
52
+ * Collect every file under `dir` as a `{ fullPath, relPath }` pair. `relPath`
53
+ * is forward-slash-separated and relative to `dir` — so the install tree's
54
+ * `install/cmake/FooConfig.cmake` lands as `cmake/FooConfig.cmake` at the
55
+ * zip root, which is where `find_package(Foo CONFIG)` expects it on the
56
+ * consumer side after extraction into `wyvrn_internal/<name>/`.
57
+ */
58
+ function collectInstallTreeFiles(dir) {
59
+ const files = [];
60
+ function walk(current) {
61
+ for (const entry of fs.readdirSync(current)) {
62
+ const full = path.join(current, entry);
63
+ const stat = fs.statSync(full);
64
+ if (stat.isDirectory()) {
65
+ walk(full);
66
+ } else {
67
+ const rel = path.relative(dir, full).replace(/\\/g, '/');
68
+ files.push({ fullPath: full, relPath: rel });
69
+ }
70
+ }
71
+ }
72
+ walk(dir);
73
+ return files;
74
+ }
75
+
76
+ function addInstallTree(zip, installDir) {
77
+ const files = collectInstallTreeFiles(installDir);
78
+ const added = new Set();
79
+ for (const { fullPath, relPath } of files) {
80
+ const zipDir = path.dirname(relPath).replace(/\\/g, '/');
81
+ zip.addLocalFile(fullPath, zipDir === '.' ? '' : zipDir);
82
+ added.add(relPath);
83
+ }
84
+ return added;
85
+ }
86
+
58
87
  // ---------------------------------------------------------------------------
59
88
  // Source resolution
60
89
  // ---------------------------------------------------------------------------
@@ -101,6 +130,8 @@ async function publish(argv) {
101
130
  force,
102
131
  manifest: manifestArg,
103
132
  } = argv;
133
+ const jsonOut = argv.format === 'json';
134
+ if (jsonOut) log.setJsonMode(true);
104
135
 
105
136
  // ── Load manifest ─────────────────────────────────────────────────────────
106
137
  const manifestPath = path.resolve(manifestArg || './wyvrn.json');
@@ -135,7 +166,23 @@ async function publish(argv) {
135
166
  const config = readConfig();
136
167
  const activeProfileName = profileName ?? config.defaultProfile ?? 'default';
137
168
  const { profile: buildProfile, fromFile } = loadProfile(activeProfileName);
138
- const profileHash = hashProfile(buildProfile);
169
+
170
+ // ── Resolve this package's own declared options ─────────────────────────
171
+ // Publishing needs to commit to a specific value for every option the
172
+ // recipe declares. Sources for overrides: CLI `-o <pkg>:...` flags
173
+ // (scoped to the package being published), else the recipe's defaults.
174
+ const ownDeclaration = manifest.options
175
+ ? normalizeOptionsDeclaration(manifest.options, `${name}@${version}`)
176
+ : null;
177
+ const cliOptionsByPkg = parseCliOptions(argv.option);
178
+ const effectiveOptions = resolveEffectiveOptions(
179
+ ownDeclaration,
180
+ {}, // consumer-side overrides don't apply to self-publish
181
+ cliOptionsByPkg[name] ?? {},
182
+ `${name}@${version}`,
183
+ );
184
+
185
+ const profileHash = hashProfile(buildProfile, effectiveOptions);
139
186
 
140
187
  log.info(
141
188
  `Publishing ${name}@${version}\n` +
@@ -144,6 +191,7 @@ async function publish(argv) {
144
191
  `${buildProfile.compiler} ${buildProfile['compiler.version']} ` +
145
192
  `C++${buildProfile['compiler.cppstd']}` +
146
193
  (buildProfile['compiler.runtime'] ? ` [${buildProfile['compiler.runtime']}]` : '') + '\n' +
194
+ (effectiveOptions ? `Options : ${JSON.stringify(effectiveOptions)}\n` : '') +
147
195
  `Profile hash: ${profileHash}\n` +
148
196
  `Provider : ${provider.constructor.providerName}`,
149
197
  );
@@ -201,7 +249,6 @@ async function publish(argv) {
201
249
  // ── .wyvrnignore ──────────────────────────────────────────────────────────
202
250
  const ignoreFile = path.join(srcDir, '.wyvrnignore');
203
251
  const ignorePatterns = loadIgnorePatterns(ignoreFile);
204
- const ignoreRe = ignorePatterns.map(patternToRegex);
205
252
  if (ignorePatterns.length > 0) {
206
253
  log.info(`Loaded ${ignorePatterns.length} pattern(s) from .wyvrnignore`);
207
254
  }
@@ -229,10 +276,55 @@ async function publish(argv) {
229
276
  log.info(`Using package-declared compatibility: ${JSON.stringify(manifest.compatibility)}`);
230
277
  }
231
278
 
279
+ // ── Install-tree overlay ──────────────────────────────────────────────────
280
+ // Publish zips the source tree (filtered by .wyvrnignore), but consumers
281
+ // actually need the install tree — `lib/cmake/<Name>/<Name>Config.cmake`,
282
+ // the installed headers, compiled libs — so find_package() works after
283
+ // extraction. When a build recipe is declared, locate the per-profile
284
+ // install dir (populated by `wyvrnpm build --config <cfg> --install`) and
285
+ // overlay its contents on top of the source zip. Install-tree files win
286
+ // on path collisions (e.g. `include/`).
287
+ let installOverlayDir = null;
288
+ let installOverlayPaths = new Set();
289
+ if (hasBuildRecipe(manifest)) {
290
+ try {
291
+ const recipe = normalizeRecipe(manifest.build, effectiveOptions);
292
+ const candidate = path.join(
293
+ srcDir, 'build', `wyvrn-${activeProfileName}`, recipe.installDir,
294
+ );
295
+ if (fs.existsSync(candidate) && fs.statSync(candidate).isDirectory()) {
296
+ installOverlayDir = candidate;
297
+ } else {
298
+ log.warn(
299
+ `build recipe is declared but no install tree found at ${candidate}.\n` +
300
+ ' Consumers\' find_package() will likely fail. Fix: run\n' +
301
+ ' wyvrnpm build --config Debug --install\n' +
302
+ ' wyvrnpm build --config Release --install\n' +
303
+ ' wyvrnpm build --config RelWithDebInfo --install\n' +
304
+ ' wyvrnpm build --config MinSizeRel --install\n' +
305
+ ' then re-run publish.',
306
+ );
307
+ }
308
+ } catch (err) {
309
+ log.warn(`could not locate install tree: ${err.message} — publishing source tree only`);
310
+ }
311
+ }
312
+
232
313
  try {
233
314
  log.info(`Zipping ${srcDir} ...`);
234
315
  const zip = new AdmZip();
235
- addFolderFiltered(zip, srcDir, srcDir, ignoreRe);
316
+ if (installOverlayDir) {
317
+ // Collect install-tree paths first so the source-tree walker can skip
318
+ // anything the install tree will add canonically.
319
+ const peek = collectInstallTreeFiles(installOverlayDir);
320
+ installOverlayPaths = new Set(peek.map((f) => f.relPath));
321
+ }
322
+ addFolderFiltered(zip, srcDir, srcDir, ignorePatterns, installOverlayPaths);
323
+ if (installOverlayDir) {
324
+ log.info(`Overlaying install tree: ${installOverlayDir}`);
325
+ const added = addInstallTree(zip, installOverlayDir);
326
+ log.info(` Added ${added.size} file(s) from install tree`);
327
+ }
236
328
  zip.writeZip(tmpZipPath);
237
329
 
238
330
  const contentSha256 = sha256Of(tmpZipPath);
@@ -248,7 +340,9 @@ async function publish(argv) {
248
340
  version,
249
341
  profileHash,
250
342
  // buildSettings stored in metadata for debugging — NOT wyvrn.json
251
- buildSettings: buildProfile,
343
+ buildSettings: effectiveOptions
344
+ ? { ...buildProfile, options: effectiveOptions }
345
+ : buildProfile,
252
346
  contentSha256,
253
347
  gitSha,
254
348
  gitRepo,
@@ -259,6 +353,23 @@ async function publish(argv) {
259
353
  );
260
354
 
261
355
  log.success(`Successfully published ${name}@${version} [${profileHash}] to ${source}`);
356
+
357
+ if (jsonOut) {
358
+ const payload = {
359
+ command: 'publish',
360
+ wyvrnpmVersion: require('../../package.json').version,
361
+ name,
362
+ version,
363
+ profileHash,
364
+ source,
365
+ contentSha256,
366
+ options: effectiveOptions ?? null,
367
+ gitSha: gitSha ?? null,
368
+ gitRepo: gitRepo ?? null,
369
+ publishedAt: new Date().toISOString(),
370
+ };
371
+ process.stdout.write(JSON.stringify(payload, null, 2) + '\n');
372
+ }
262
373
  } finally {
263
374
  if (fs.existsSync(tmpZipPath)) fs.unlinkSync(tmpZipPath);
264
375
  if (fs.existsSync(tmpManifestPath)) fs.unlinkSync(tmpManifestPath);
@@ -266,3 +377,7 @@ async function publish(argv) {
266
377
  }
267
378
 
268
379
  module.exports = publish;
380
+ // Exported for tests only
381
+ module.exports.addFolderFiltered = addFolderFiltered;
382
+ module.exports.addInstallTree = addInstallTree;
383
+ module.exports.collectInstallTreeFiles = collectInstallTreeFiles;
@@ -0,0 +1,237 @@
1
+ 'use strict';
2
+
3
+ const { readConfig } = require('../config');
4
+ const { getProvider } = require('../providers');
5
+ const log = require('../logger');
6
+
7
+ /**
8
+ * `wyvrnpm show <pkg>` — surface registry metadata for a package.
9
+ *
10
+ * Accepts:
11
+ * name → list every published version + profile.
12
+ * name@version → list every profile for that version,
13
+ * fetch full metadata for each, highlight
14
+ * consumer (`uploadedBy`) vs author builds.
15
+ * name@version@profileHash → dump full metadata for one build.
16
+ *
17
+ * Sources: CLI `--source` overrides config.installSources. First source
18
+ * that returns data wins (same first-match policy as `install`).
19
+ *
20
+ * Added for PLAN-UPLOAD-BUILT phase 3 so maintainers can spot-check which
21
+ * profileHashes were uploaded by consumers vs published by an author. The
22
+ * `uploadedBy` block is the signal; its absence means "author publish"
23
+ * by convention.
24
+ *
25
+ * @param {object} argv
26
+ * @param {string} argv.pkg e.g. "zlib", "zlib@1.3.0.0", "zlib@1.3.0.0@a1b2..."
27
+ * @param {string[]} [argv.source] source URLs
28
+ * @param {string} [argv.awsProfile]
29
+ * @param {string} [argv.token]
30
+ * @returns {Promise<void>}
31
+ */
32
+ async function show(argv) {
33
+ const { name, version, profileHash } = parseSpec(argv.pkg);
34
+ if (!name) {
35
+ log.error('Usage: wyvrnpm show <name>[@<version>[@<profileHash>]]');
36
+ process.exit(1);
37
+ }
38
+
39
+ const jsonOut = argv.format === 'json';
40
+ if (jsonOut) log.setJsonMode(true);
41
+
42
+ const { sources, awsProfile, token } = resolveSources(argv);
43
+ if (sources.length === 0) {
44
+ log.error(
45
+ 'no sources configured. ' +
46
+ 'Pass --source <url> or run: wyvrnpm configure add-source --kind install ...',
47
+ );
48
+ process.exit(1);
49
+ }
50
+
51
+ for (const source of sources) {
52
+ let provider;
53
+ try { provider = getProvider(source); } catch { continue; }
54
+
55
+ const idx = await provider.v2GetVersionsIndex({ source, name, awsProfile, token });
56
+ if (!idx?.versions) continue;
57
+
58
+ const versionKeys = version
59
+ ? (idx.versions[version] ? [version] : [])
60
+ : Object.keys(idx.versions).sort();
61
+
62
+ if (version && versionKeys.length === 0) {
63
+ log.error(`Version ${version} not found on ${source}`);
64
+ process.exit(1);
65
+ }
66
+
67
+ // Collect a single structured result as we walk. Text rendering happens
68
+ // inline in the legacy path; JSON mode emits the whole object at the
69
+ // end of the first source that answered.
70
+ const result = {
71
+ command: 'show',
72
+ wyvrnpmVersion: require('../../package.json').version,
73
+ source,
74
+ package: name,
75
+ latest: idx.latest ?? null,
76
+ versions: {},
77
+ };
78
+
79
+ if (!jsonOut) {
80
+ console.log(`${name} (source: ${source})`);
81
+ if (idx.latest) console.log(` latest: ${idx.latest}`);
82
+ }
83
+
84
+ for (const v of versionKeys) {
85
+ const ventry = idx.versions[v];
86
+ const profiles = ventry?.profiles ?? {};
87
+ const profileKeys = profileHash
88
+ ? (profiles[profileHash] ? [profileHash] : [])
89
+ : Object.keys(profiles).sort();
90
+
91
+ if (profileHash && profileKeys.length === 0) {
92
+ log.error(`ProfileHash ${profileHash} not found for ${name}@${v}`);
93
+ process.exit(1);
94
+ }
95
+
96
+ if (!jsonOut) {
97
+ console.log(`\n ${name}@${v} (${profileKeys.length} profile${profileKeys.length === 1 ? '' : 's'})`);
98
+ if (ventry.source?.gitSha) {
99
+ console.log(` git: ${ventry.source.gitRepo ?? '?'} @ ${ventry.source.gitSha}`);
100
+ }
101
+ }
102
+
103
+ // For bare `name` queries we'd fetch O(M×N) manifests (versions ×
104
+ // profiles) — too noisy. Only deep-fetch when the user has narrowed
105
+ // to a specific version.
106
+ const deepFetch = Boolean(version);
107
+
108
+ const profileArr = [];
109
+ for (const hash of profileKeys) {
110
+ const entry = profiles[hash];
111
+ if (!jsonOut) {
112
+ console.log(`\n [${hash}]`);
113
+ if (entry.buildSettings) {
114
+ console.log(` profile : ${formatBuildSettings(entry.buildSettings)}`);
115
+ if (entry.buildSettings.options && Object.keys(entry.buildSettings.options).length > 0) {
116
+ console.log(` options : ${formatOptions(entry.buildSettings.options)}`);
117
+ }
118
+ }
119
+ console.log(` sha256 : ${entry.contentSha256 ?? '?'}`);
120
+ if (entry.publishedAt) console.log(` published: ${entry.publishedAt}`);
121
+ }
122
+
123
+ let meta = null;
124
+ if (deepFetch) {
125
+ meta = await provider.v2GetMeta({
126
+ source, name, version: v, profileHash: hash, awsProfile, token,
127
+ });
128
+ if (!jsonOut) {
129
+ if (meta?.uploadedBy) {
130
+ console.log(` origin : consumer upload (${meta.uploadedBy.kind})`);
131
+ console.log(` uploadedAt : ${meta.uploadedBy.uploadedAt}`);
132
+ console.log(` wyvrnpmVersion : ${meta.uploadedBy.wyvrnpmVersion ?? '?'}`);
133
+ if (meta.uploadedBy.builtFromGit) {
134
+ console.log(` builtFromGit : ${meta.uploadedBy.builtFromGit}`);
135
+ }
136
+ if (meta.uploadedBy.builtFromSha) {
137
+ console.log(` builtFromSha : ${meta.uploadedBy.builtFromSha}`);
138
+ }
139
+ } else if (meta) {
140
+ console.log(` origin : author publish`);
141
+ }
142
+ if (meta?.compatibility) {
143
+ console.log(` compat : ${JSON.stringify(meta.compatibility)}`);
144
+ }
145
+ if (meta?.options && Object.keys(meta.options).length > 0) {
146
+ console.log(` declared: ${formatDeclaredOptions(meta.options)}`);
147
+ }
148
+ }
149
+ }
150
+
151
+ profileArr.push({
152
+ profileHash: hash,
153
+ contentSha256: entry.contentSha256 ?? null,
154
+ publishedAt: entry.publishedAt ?? null,
155
+ buildSettings: entry.buildSettings ?? null,
156
+ origin: meta?.uploadedBy ? 'consumer-source-build' : (meta ? 'author-publish' : null),
157
+ uploadedBy: meta?.uploadedBy ?? null,
158
+ compatibility: meta?.compatibility ?? null,
159
+ declaredOptions: meta?.options ?? null,
160
+ });
161
+ }
162
+
163
+ result.versions[v] = {
164
+ source: ventry.source ?? null,
165
+ profiles: profileArr,
166
+ };
167
+ }
168
+
169
+ if (jsonOut) {
170
+ process.stdout.write(JSON.stringify(result, null, 2) + '\n');
171
+ }
172
+ return;
173
+ }
174
+
175
+ log.error(`Package ${name} not found in any configured source`);
176
+ process.exit(1);
177
+ }
178
+
179
+ /**
180
+ * Parse a package spec "name[@version[@profileHash]]".
181
+ * @param {string} spec
182
+ * @returns {{ name?: string, version?: string, profileHash?: string }}
183
+ */
184
+ function parseSpec(spec) {
185
+ if (!spec || typeof spec !== 'string') return {};
186
+ const parts = spec.split('@');
187
+ const [name, version, profileHash] = parts;
188
+ return {
189
+ name: name || undefined,
190
+ version: version || undefined,
191
+ profileHash: profileHash || undefined,
192
+ };
193
+ }
194
+
195
+ function resolveSources(argv) {
196
+ const config = readConfig();
197
+ const sources = (argv.source && argv.source.length > 0)
198
+ ? argv.source
199
+ : config.installSources.map((s) => s.url);
200
+
201
+ const firstConfig = config.installSources?.[0];
202
+ return {
203
+ sources,
204
+ awsProfile: argv.awsProfile ?? firstConfig?.profile,
205
+ token: argv.token ?? firstConfig?.token,
206
+ };
207
+ }
208
+
209
+ function formatBuildSettings(bs) {
210
+ const parts = [
211
+ `${bs.os}/${bs.arch}`,
212
+ `${bs.compiler} ${bs['compiler.version']}`,
213
+ `C++${bs['compiler.cppstd']}`,
214
+ ];
215
+ if (bs['compiler.runtime']) parts.push(`[${bs['compiler.runtime']}]`);
216
+ return parts.join(' ');
217
+ }
218
+
219
+ function formatOptions(options) {
220
+ return Object.entries(options)
221
+ .sort(([a], [b]) => a.localeCompare(b))
222
+ .map(([k, v]) => `${k}=${typeof v === 'string' ? v : String(v)}`)
223
+ .join(', ');
224
+ }
225
+
226
+ function formatDeclaredOptions(declared) {
227
+ return Object.entries(declared)
228
+ .sort(([a], [b]) => a.localeCompare(b))
229
+ .map(([name, spec]) => {
230
+ const allowed = Array.isArray(spec?.allowed) ? spec.allowed : [];
231
+ return `${name} (default=${String(spec?.default)}, allowed=${allowed.map(String).join('|')})`;
232
+ })
233
+ .join('; ');
234
+ }
235
+
236
+ module.exports = show;
237
+ module.exports.parseSpec = parseSpec;
package/src/compat.js CHANGED
@@ -34,12 +34,15 @@
34
34
  * | version fields and leaves room for future "same major"
35
35
  * | semantics without renaming.
36
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.
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.
40
43
  */
41
44
 
42
- const OS_ARCH_ALWAYS_EXACT = new Set(['os', 'arch']);
45
+ const OS_ARCH_FIELDS = new Set(['os', 'arch']);
43
46
 
44
47
  const DEFAULT_COMPAT_FIELDS = [
45
48
  'os',
@@ -55,19 +58,27 @@ const VALID_MODES = new Set(['exact', 'any', 'lte', 'gte', 'exact-or-newer']);
55
58
  /**
56
59
  * Normalise a raw compatibility block into `{ field → mode }`.
57
60
  * Missing fields fall back to `exact`. Unknown modes fall back to `exact`.
58
- * `os` and `arch` are forced 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.
59
67
  *
60
68
  * @param {object|null|undefined} rawCompat
69
+ * @param {string|null|undefined} [packageKind] Package manifest `kind` field.
61
70
  * @returns {Record<string, string>}
62
71
  */
63
- function normalizeCompat(rawCompat) {
72
+ function normalizeCompat(rawCompat, packageKind) {
64
73
  const result = {};
65
74
  for (const field of DEFAULT_COMPAT_FIELDS) result[field] = 'exact';
66
75
 
67
76
  if (!rawCompat || typeof rawCompat !== 'object') return result;
68
77
 
78
+ const headerOnly = packageKind === 'HeaderOnlyLib';
79
+
69
80
  for (const [field, spec] of Object.entries(rawCompat)) {
70
- if (OS_ARCH_ALWAYS_EXACT.has(field)) continue;
81
+ if (!headerOnly && OS_ARCH_FIELDS.has(field)) continue;
71
82
  const mode = typeof spec === 'string' ? spec : (spec && spec.mode);
72
83
  if (mode && VALID_MODES.has(mode)) result[field] = mode;
73
84
  }
@@ -137,20 +148,32 @@ function evaluateField(mode, consumerVal, packageVal) {
137
148
  /**
138
149
  * Evaluate a package's compatibility block against a consumer's profile.
139
150
  *
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
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
143
162
  * @returns {{
144
163
  * compatible: boolean,
145
164
  * reasons: Array<{ field: string, mode: string, consumer: any, package: any, outcome: string }>,
146
165
  * }}
147
166
  */
148
- function evaluateCompat(consumerProfile, packageProfile, rawCompat) {
149
- const compat = normalizeCompat(rawCompat);
167
+ function evaluateCompat(consumerProfile, packageProfile, rawCompat, packageKind) {
168
+ const compat = normalizeCompat(rawCompat, packageKind);
150
169
  const reasons = [];
151
170
  let compatible = true;
152
171
 
153
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
+
154
177
  const cVal = consumerProfile[field];
155
178
  const pVal = packageProfile[field];
156
179
  const outcome = evaluateField(mode, cVal, pVal);
@@ -158,6 +181,24 @@ function evaluateCompat(consumerProfile, packageProfile, rawCompat) {
158
181
  if (outcome === 'incompatible') compatible = false;
159
182
  }
160
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
+
161
202
  return { compatible, reasons };
162
203
  }
163
204