wyvrnpm 2.1.0 → 2.3.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.
@@ -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
@@ -137,9 +137,16 @@ function evaluateField(mode, consumerVal, packageVal) {
137
137
  /**
138
138
  * Evaluate a package's compatibility block against a consumer's profile.
139
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
140
+ * If either `consumerProfile` or `packageProfile` carries an `options`
141
+ * sub-object, options participate in compatibility: every option present
142
+ * on either side is evaluated, with mode `exact` by default. The compat
143
+ * block may override a specific option's mode via `options.<name>` (for
144
+ * example `"options.shared": "any"` to say "this build has no ABI
145
+ * dependence on `shared`"). See PLAN-OPTIONS §3.5.
146
+ *
147
+ * @param {Record<string, any>} consumerProfile may include `options: {...}`
148
+ * @param {Record<string, any>} packageProfile may include `options: {...}`
149
+ * @param {object|null|undefined} rawCompat compatibility block from the package manifest
143
150
  * @returns {{
144
151
  * compatible: boolean,
145
152
  * reasons: Array<{ field: string, mode: string, consumer: any, package: any, outcome: string }>,
@@ -151,6 +158,10 @@ function evaluateCompat(consumerProfile, packageProfile, rawCompat) {
151
158
  let compatible = true;
152
159
 
153
160
  for (const [field, mode] of Object.entries(compat)) {
161
+ // `options.<name>` keys in the compat block are handled below,
162
+ // together with any options present on either profile.
163
+ if (field.startsWith('options.')) continue;
164
+
154
165
  const cVal = consumerProfile[field];
155
166
  const pVal = packageProfile[field];
156
167
  const outcome = evaluateField(mode, cVal, pVal);
@@ -158,6 +169,24 @@ function evaluateCompat(consumerProfile, packageProfile, rawCompat) {
158
169
  if (outcome === 'incompatible') compatible = false;
159
170
  }
160
171
 
172
+ // Evaluate every option the consumer or package declares. Default mode
173
+ // is `exact`; the compat block may relax to `any` (and the other modes
174
+ // work when the values are numeric strings). Forward-compat: an option
175
+ // present on one side only is treated as `incompatible` under exact —
176
+ // the recipe shapes differ.
177
+ const consumerOpts = consumerProfile.options ?? {};
178
+ const packageOpts = packageProfile.options ?? {};
179
+ const optionKeys = new Set([...Object.keys(consumerOpts), ...Object.keys(packageOpts)]);
180
+ for (const name of optionKeys) {
181
+ const field = `options.${name}`;
182
+ const mode = compat[field] ?? 'exact';
183
+ const cVal = consumerOpts[name];
184
+ const pVal = packageOpts[name];
185
+ const outcome = evaluateField(mode, cVal, pVal);
186
+ reasons.push({ field, mode, consumer: cVal, package: pVal, outcome });
187
+ if (outcome === 'incompatible') compatible = false;
188
+ }
189
+
161
190
  return { compatible, reasons };
162
191
  }
163
192