wyvrnpm 2.3.2 → 2.8.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.
@@ -0,0 +1,391 @@
1
+ 'use strict';
2
+
3
+ // Non-ABI build-time configuration — the `conf` system (PLAN-CONF.md).
4
+ //
5
+ // `conf` is the sibling of `options` with the opposite hashing
6
+ // property: options fold into `profileHash` (ABI-affecting), conf
7
+ // never does (build-time knobs). See plan §2 for the rationale.
8
+ //
9
+ // This module is the pure core. It parses the four layers
10
+ // (CLI, `wyvrn.local.json`, named profile `[conf]`, recipe
11
+ // `wyvrn.json`), merges them with per-leaf precedence, and produces
12
+ // a flat `{ "namespace.leaf": value }` map ready for each sink to
13
+ // consume. It does NOT know about CMake, filesystems beyond reading
14
+ // a given JSON file, or any command — those live one level up in
15
+ // src/commands/* and src/toolchain/*.
16
+
17
+ const fs = require('fs');
18
+ const path = require('path');
19
+
20
+ const { validateFlatConfKeys } = require('./namespaces');
21
+
22
+ const LOCAL_OVERLAY_FILENAME = 'wyvrn.local.json';
23
+
24
+ // Per plan §4.5 "Strict top-level allow-list". Phase-1 accepts only
25
+ // `{ conf }` at the top level of wyvrn.local.json. Adding
26
+ // dependencies/options/etc. to this set is deliberate and plan-2
27
+ // territory.
28
+ const LOCAL_OVERLAY_ALLOWED_TOP_KEYS = new Set(['conf']);
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // Value normalisation
32
+ // ---------------------------------------------------------------------------
33
+
34
+ /**
35
+ * Normalise a single leaf VALUE into the string CMake (or whichever
36
+ * sink consumes it) expects. JSON `true`/`false` become `"ON"`/`"OFF"`;
37
+ * numbers are stringified; strings pass through. Arrays / objects /
38
+ * null at a leaf position are a hard error — those live at branch
39
+ * positions, not leaves.
40
+ *
41
+ * CLI values arrive as strings and are passed through verbatim.
42
+ *
43
+ * @param {unknown} value
44
+ * @param {string} keyForError
45
+ * @returns {string}
46
+ */
47
+ function normalizeLeafValue(value, keyForError) {
48
+ if (value === true) return 'ON';
49
+ if (value === false) return 'OFF';
50
+ if (typeof value === 'number' && Number.isFinite(value)) return String(value);
51
+ if (typeof value === 'string') return value;
52
+ throw new Error(
53
+ `conf value for ${JSON.stringify(keyForError)} must be a string, ` +
54
+ `number, or boolean — got ${JSON.stringify(value)}`,
55
+ );
56
+ }
57
+
58
+ /**
59
+ * Flatten a nested conf object (recipe/local form) into a flat
60
+ * `{ "ns.leaf": stringValue }` map. Keys are joined with `.`. The
61
+ * recursion stops at any non-object (strings, numbers, booleans) —
62
+ * those are leaves.
63
+ *
64
+ * Throws on:
65
+ * - null at a leaf position
66
+ * - arrays anywhere (arrays are not meaningful for cmake cache vars
67
+ * and leaving them as "maybe later" invites footguns)
68
+ * - objects whose keys contain `.` (would clash with our separator)
69
+ *
70
+ * @param {object} nested
71
+ * @param {string} [prefix]
72
+ * @param {Record<string, string>} [out]
73
+ * @returns {Record<string, string>}
74
+ */
75
+ function flattenConf(nested, prefix = '', out = {}) {
76
+ if (nested === null || typeof nested !== 'object') {
77
+ throw new Error(
78
+ `conf block must be an object — got ${JSON.stringify(nested)} at ` +
79
+ `${prefix || '<root>'}`,
80
+ );
81
+ }
82
+ if (Array.isArray(nested)) {
83
+ throw new Error(`conf block must be an object, not an array — at ${prefix || '<root>'}`);
84
+ }
85
+
86
+ for (const [rawKey, value] of Object.entries(nested)) {
87
+ if (rawKey.includes('.')) {
88
+ throw new Error(
89
+ `conf key segment ${JSON.stringify(rawKey)} must not contain a period — ` +
90
+ `use nested objects or the flat "namespace.leaf" form, not both`,
91
+ );
92
+ }
93
+ const fullKey = prefix ? `${prefix}.${rawKey}` : rawKey;
94
+
95
+ if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
96
+ flattenConf(value, fullKey, out);
97
+ } else {
98
+ out[fullKey] = normalizeLeafValue(value, fullKey);
99
+ }
100
+ }
101
+ return out;
102
+ }
103
+
104
+ /**
105
+ * Re-expand a flat `{ "ns.leaf": value }` map back into nested form,
106
+ * for emitting into JSON manifests / lockfiles / payloads in the same
107
+ * shape authors author with. Values round-trip as strings (conf
108
+ * values are always strings post-normalisation).
109
+ *
110
+ * @param {Record<string, string>} flat
111
+ * @returns {object}
112
+ */
113
+ function unflattenConf(flat) {
114
+ const root = {};
115
+ for (const [key, value] of Object.entries(flat)) {
116
+ const parts = key.split('.');
117
+ let node = root;
118
+ for (let i = 0; i < parts.length - 1; i++) {
119
+ const seg = parts[i];
120
+ if (node[seg] === undefined || typeof node[seg] !== 'object') {
121
+ node[seg] = {};
122
+ }
123
+ node = node[seg];
124
+ }
125
+ node[parts[parts.length - 1]] = value;
126
+ }
127
+ return root;
128
+ }
129
+
130
+ // ---------------------------------------------------------------------------
131
+ // CLI parsing
132
+ // ---------------------------------------------------------------------------
133
+
134
+ /**
135
+ * Parse an array of `--conf KEY=VALUE` strings (yargs delivers
136
+ * `argv.conf` when the option is registered as `array: true`) into a
137
+ * flat map. See plan §4.4 for semantics:
138
+ *
139
+ * KEY=VALUE → set the key to VALUE verbatim (string).
140
+ * KEY → alias for KEY=true (matches CMake's -D convention).
141
+ * KEY= → unset (remove the key from lower layers on merge).
142
+ *
143
+ * Returns two maps — set values and unset keys — so the merger can
144
+ * treat the latter specially.
145
+ *
146
+ * @param {string[] | undefined} rawArgs
147
+ * @returns {{ sets: Record<string,string>, unsets: Set<string> }}
148
+ */
149
+ function parseCliConf(rawArgs) {
150
+ const sets = {};
151
+ const unsets = new Set();
152
+
153
+ if (!rawArgs || rawArgs.length === 0) {
154
+ return { sets, unsets };
155
+ }
156
+
157
+ for (const arg of rawArgs) {
158
+ if (typeof arg !== 'string') {
159
+ throw new Error(`--conf expects KEY=VALUE string; got ${JSON.stringify(arg)}`);
160
+ }
161
+ const eqIdx = arg.indexOf('=');
162
+ if (eqIdx < 0) {
163
+ // Bare `KEY` → KEY=true (normalised to "ON").
164
+ const key = arg.trim();
165
+ if (key.length === 0) {
166
+ throw new Error('--conf was given an empty key');
167
+ }
168
+ sets[key] = 'ON';
169
+ } else {
170
+ const key = arg.slice(0, eqIdx).trim();
171
+ const value = arg.slice(eqIdx + 1);
172
+ if (key.length === 0) {
173
+ throw new Error(`--conf ${JSON.stringify(arg)} has an empty key`);
174
+ }
175
+ if (value.length === 0) {
176
+ // KEY= → unset. Distinguish from KEY="" which callers can
177
+ // achieve by quoting the value; here the shell has already
178
+ // stripped the quotes, so an empty string past the `=` means
179
+ // "clear any inherited value for this key."
180
+ unsets.add(key);
181
+ } else {
182
+ sets[key] = value;
183
+ }
184
+ }
185
+ }
186
+
187
+ return { sets, unsets };
188
+ }
189
+
190
+ // ---------------------------------------------------------------------------
191
+ // Local-overlay file
192
+ // ---------------------------------------------------------------------------
193
+
194
+ /**
195
+ * Read and validate `wyvrn.local.json` at the given project root.
196
+ * Returns the parsed `conf` block as a flat map, or an empty map when
197
+ * the file is absent.
198
+ *
199
+ * Enforces plan §4.5: the only top-level key this file may carry in
200
+ * phase 1 is `conf`; anything else is a hard error (not a warning).
201
+ *
202
+ * @param {string} rootDir
203
+ * @returns {{ flat: Record<string,string>, path: string|null }}
204
+ */
205
+ function readLocalOverlay(rootDir) {
206
+ const p = path.join(rootDir, LOCAL_OVERLAY_FILENAME);
207
+ if (!fs.existsSync(p)) return { flat: {}, path: null };
208
+
209
+ let raw;
210
+ try {
211
+ raw = JSON.parse(fs.readFileSync(p, 'utf8'));
212
+ } catch (err) {
213
+ throw new Error(`cannot parse ${LOCAL_OVERLAY_FILENAME}: ${err.message}`);
214
+ }
215
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
216
+ throw new Error(`${LOCAL_OVERLAY_FILENAME} must contain a JSON object at the top level`);
217
+ }
218
+
219
+ for (const key of Object.keys(raw)) {
220
+ if (!LOCAL_OVERLAY_ALLOWED_TOP_KEYS.has(key)) {
221
+ const allowed = [...LOCAL_OVERLAY_ALLOWED_TOP_KEYS].join(', ');
222
+ throw new Error(
223
+ `${LOCAL_OVERLAY_FILENAME}: unknown top-level key ${JSON.stringify(key)} — ` +
224
+ `phase-1 allow-list: [${allowed}]. ` +
225
+ `If you meant to override dependencies or options, that is reserved ` +
226
+ `for a future phase; see claude/PLAN-CONF.md §4.5.`,
227
+ );
228
+ }
229
+ }
230
+
231
+ const confBlock = raw.conf ?? {};
232
+ const flat = flattenConf(confBlock);
233
+ validateFlatConfKeys(flat);
234
+ return { flat, path: p };
235
+ }
236
+
237
+ // ---------------------------------------------------------------------------
238
+ // Recipe extraction
239
+ // ---------------------------------------------------------------------------
240
+
241
+ /**
242
+ * Pull the `conf` block out of an already-loaded recipe manifest and
243
+ * flatten it. A missing block is an empty map.
244
+ *
245
+ * Validation runs against the phase-1 allow-list; if a recipe declares
246
+ * a namespace we don't support yet, the install fails with a clear
247
+ * error rather than silently dropping the config.
248
+ *
249
+ * @param {object} manifest
250
+ * @returns {Record<string,string>}
251
+ */
252
+ function readRecipeConf(manifest) {
253
+ const block = manifest?.conf ?? null;
254
+ if (block === null || block === undefined) return {};
255
+ const flat = flattenConf(block);
256
+ validateFlatConfKeys(flat);
257
+ return flat;
258
+ }
259
+
260
+ // ---------------------------------------------------------------------------
261
+ // Profile extraction
262
+ // ---------------------------------------------------------------------------
263
+
264
+ /**
265
+ * Pull the optional `conf` block out of a loaded named profile JSON
266
+ * (see src/profile.js) and flatten it. A missing block is an empty
267
+ * map — profiles without `conf` round-trip byte-identically to
268
+ * pre-phase-2 behaviour.
269
+ *
270
+ * Validation runs against the phase-1 allow-list; an unknown
271
+ * namespace in a profile fails fast rather than silently dropping
272
+ * the config (CLAUDE.md §2 principle 8).
273
+ *
274
+ * @param {object|null|undefined} profile
275
+ * @returns {Record<string,string>}
276
+ */
277
+ function readProfileConf(profile) {
278
+ const block = profile?.conf ?? null;
279
+ if (block === null || block === undefined) return {};
280
+ const flat = flattenConf(block);
281
+ validateFlatConfKeys(flat);
282
+ return flat;
283
+ }
284
+
285
+ // ---------------------------------------------------------------------------
286
+ // Merge
287
+ // ---------------------------------------------------------------------------
288
+
289
+ /**
290
+ * Merge the four layers with plan §4.2 precedence (post phase-2):
291
+ *
292
+ * CLI unsets — remove named keys from below.
293
+ * CLI sets — highest wins.
294
+ * local — overrides profile.
295
+ * profile — overrides recipe (team-shared team defaults).
296
+ * recipe — base defaults.
297
+ *
298
+ * Per-leaf override, not per-branch replace — so CLI setting only
299
+ * cmake.cache.FOO does not disturb a recipe's cmake.cache.BAR.
300
+ *
301
+ * The `profileFlat` parameter is optional. Pre-phase-2 callers that
302
+ * pass only three layers (recipe, local, cli) still work — profile
303
+ * defaults to an empty map, preserving the three-layer semantics
304
+ * exactly.
305
+ *
306
+ * @param {Record<string,string>} recipeFlat
307
+ * @param {Record<string,string>} localFlat
308
+ * @param {{ sets: Record<string,string>, unsets: Set<string> }} cli
309
+ * @param {Record<string,string>} [profileFlat]
310
+ * @returns {Record<string,string>}
311
+ */
312
+ function mergeConfLayers(recipeFlat, localFlat, cli, profileFlat = {}) {
313
+ const out = { ...recipeFlat, ...profileFlat, ...localFlat, ...cli.sets };
314
+ for (const k of cli.unsets) delete out[k];
315
+ validateFlatConfKeys(out);
316
+ return out;
317
+ }
318
+
319
+ /**
320
+ * Convenience: do the whole four-layer resolution at once. Most
321
+ * callers want this. Individual layer helpers are exported for tests.
322
+ *
323
+ * The `profile` argument is optional. Callers that don't have a
324
+ * resolved profile to hand (e.g. the legacy three-layer call sites in
325
+ * `publish`, which intentionally ignore the profile tier so published
326
+ * artefacts aren't skewed by dev machines) pass nothing and get the
327
+ * three-layer merge (CLI > local > recipe).
328
+ *
329
+ * @param {object} args
330
+ * @param {object} [args.manifest]
331
+ * @param {string} args.rootDir
332
+ * @param {string[]} [args.cliConf]
333
+ * @param {object} [args.profile] Resolved named profile JSON (may carry .conf).
334
+ * @returns {{
335
+ * flat: Record<string,string>,
336
+ * recipeFlat: Record<string,string>,
337
+ * profileFlat: Record<string,string>,
338
+ * localFlat: Record<string,string>,
339
+ * cli: { sets: Record<string,string>, unsets: Set<string> },
340
+ * localOverlayPath: string | null,
341
+ * }}
342
+ */
343
+ function resolveEffectiveConf({ manifest, rootDir, cliConf, profile }) {
344
+ const recipeFlat = readRecipeConf(manifest);
345
+ const profileFlat = readProfileConf(profile);
346
+ const { flat: localFlat, path: localOverlayPath } = readLocalOverlay(rootDir);
347
+ const cli = parseCliConf(cliConf);
348
+ const flat = mergeConfLayers(recipeFlat, localFlat, cli, profileFlat);
349
+ return { flat, recipeFlat, profileFlat, localFlat, cli, localOverlayPath };
350
+ }
351
+
352
+ /**
353
+ * Extract only the `cmake.cache.*` portion of a flat conf map as
354
+ * `{ VARNAME: stringValue }` — the shape
355
+ * `src/toolchain/presets.js` wants for `cacheVariables`. Stable
356
+ * alphabetical key order so preset diffs are deterministic.
357
+ *
358
+ * @param {Record<string,string>} flatConf
359
+ * @returns {Record<string,string>}
360
+ */
361
+ function cmakeCacheVariables(flatConf) {
362
+ const out = {};
363
+ const prefix = 'cmake.cache.';
364
+ const keys = Object.keys(flatConf).filter((k) => k.startsWith(prefix)).sort();
365
+ for (const k of keys) {
366
+ out[k.slice(prefix.length)] = flatConf[k];
367
+ }
368
+ return out;
369
+ }
370
+
371
+ module.exports = {
372
+ // High-level
373
+ resolveEffectiveConf,
374
+
375
+ // Per-layer
376
+ parseCliConf,
377
+ readLocalOverlay,
378
+ readRecipeConf,
379
+ readProfileConf,
380
+ mergeConfLayers,
381
+
382
+ // Shape transforms
383
+ flattenConf,
384
+ unflattenConf,
385
+ normalizeLeafValue,
386
+ cmakeCacheVariables,
387
+
388
+ // Constants (exported for tests + integration)
389
+ LOCAL_OVERLAY_FILENAME,
390
+ LOCAL_OVERLAY_ALLOWED_TOP_KEYS,
391
+ };
@@ -0,0 +1,94 @@
1
+ 'use strict';
2
+
3
+ // Allow-list of recognised `conf` namespaces (PLAN-CONF.md §4.1).
4
+ //
5
+ // Adding a new namespace is an intentional act — the plan commits to
6
+ // expanding this file only when a concrete use case lands, not
7
+ // speculatively. Phase 1 ships `cmake.cache.*` only; every other
8
+ // namespace (env.*, build.*, tool.*) is reserved but not yet valid.
9
+ //
10
+ // Each entry describes how leaf names under that namespace are
11
+ // validated. A namespace with `leaf: true` accepts any non-empty leaf
12
+ // name; a namespace with `leaf: /regex/` enforces the regex at load
13
+ // time. The regex only validates the leaf name — leaf VALUES are
14
+ // normalised separately in ../conf/index.js.
15
+
16
+ /**
17
+ * @typedef {Object} NamespaceSpec
18
+ * @property {RegExp} leaf
19
+ * Pattern that every leaf name under this namespace must satisfy.
20
+ * @property {string} description
21
+ * Human-readable one-liner shown in error messages.
22
+ */
23
+
24
+ /** @type {Record<string, NamespaceSpec>} */
25
+ const NAMESPACES = {
26
+ 'cmake.cache': {
27
+ // CMake variable names: C/C++ identifier rules, no hyphens.
28
+ leaf: /^[A-Za-z_][A-Za-z0-9_]*$/,
29
+ description: 'CMake cache variables — baked into CMakePresets.json cacheVariables.',
30
+ },
31
+ };
32
+
33
+ /**
34
+ * Parse a full dotted conf key (e.g. `cmake.cache.CHROMA_ENABLE_TEST`)
35
+ * into its namespace prefix and leaf name. The namespace is the
36
+ * longest matching prefix registered in NAMESPACES; anything after is
37
+ * the leaf.
38
+ *
39
+ * Returns `{ namespace, leaf }` on success, `null` when the key does
40
+ * not belong to any registered namespace or has no leaf component.
41
+ *
42
+ * @param {string} key
43
+ * @returns {{ namespace: string, leaf: string } | null}
44
+ */
45
+ function splitKey(key) {
46
+ // Try longer namespaces first so "a.b.c" prefers namespace "a.b"
47
+ // over a hypothetical namespace "a".
48
+ const known = Object.keys(NAMESPACES).sort((a, b) => b.length - a.length);
49
+ for (const ns of known) {
50
+ const prefix = `${ns}.`;
51
+ if (key.startsWith(prefix)) {
52
+ const leaf = key.slice(prefix.length);
53
+ if (leaf.length === 0) return null;
54
+ return { namespace: ns, leaf };
55
+ }
56
+ }
57
+ return null;
58
+ }
59
+
60
+ /**
61
+ * Validate a flat `{key: value}` map against the namespace allow-list.
62
+ * Throws on the first offender (fail-fast, matches the Zip Slip
63
+ * validator's style). Leaf VALUES are not type-checked here — values
64
+ * arrive as strings from the CLI, normalised from JSON elsewhere; type
65
+ * enforcement happens in ../conf/index.js at merge time.
66
+ *
67
+ * @param {Record<string, string>} flatConf
68
+ * @throws {Error} if any key's namespace or leaf is rejected.
69
+ */
70
+ function validateFlatConfKeys(flatConf) {
71
+ const knownList = Object.keys(NAMESPACES).sort().join(', ');
72
+ for (const key of Object.keys(flatConf)) {
73
+ const parts = splitKey(key);
74
+ if (!parts) {
75
+ throw new Error(
76
+ `unknown conf namespace in ${JSON.stringify(key)} — ` +
77
+ `phase-1 allow-list: [${knownList}]`,
78
+ );
79
+ }
80
+ const spec = NAMESPACES[parts.namespace];
81
+ if (!spec.leaf.test(parts.leaf)) {
82
+ throw new Error(
83
+ `invalid leaf name ${JSON.stringify(parts.leaf)} for conf namespace ` +
84
+ `"${parts.namespace}" — ${spec.description}`,
85
+ );
86
+ }
87
+ }
88
+ }
89
+
90
+ module.exports = {
91
+ NAMESPACES,
92
+ splitKey,
93
+ validateFlatConfKeys,
94
+ };