@pareto4/pp-core 1.10.0 → 1.11.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.
@@ -0,0 +1,485 @@
1
+ "use strict";
2
+ /**
3
+ * src/vault-coordinates.cts — the coordinate seam (milestone v1.11.0, Phase
4
+ * 31-01; org-profile layer added Phase 33-03).
5
+ *
6
+ * ADR-457 build-at-publish: this TypeScript file is the SOURCE OF TRUTH. It
7
+ * compiles to the git-ignored `pp-core/bin/lib/vault-coordinates.cjs`; do NOT
8
+ * hand-edit the compiled output. Behaviour is locked by
9
+ * `tests/vault-coordinates.test.cjs`.
10
+ *
11
+ * This module is the single place every vault coordinate decision in pp-core
12
+ * routes through — the host, repo and ref for the client tier, and the host,
13
+ * repo and ref for the shared tier — for both the read path (`src/vault.cts`)
14
+ * and the write path (`src/vault-write-back.cts`). Before this module
15
+ * existed, `DEFAULT_FALLBACK_REPO` was a `const` in `src/vault.cts` and four
16
+ * separate `'gitlab.com'` fallback chains were hand-written across the two
17
+ * files, two of which read `platform.host` from `.platform-bindings.json`
18
+ * and two of which read it from `.planning/config.json` (D-09) — the same
19
+ * one-value-two-owners shape that produced defect P-1 in Phase 29 for
20
+ * clone-target composition, and that Phase 30 closed for that concern by
21
+ * building `src/git-provider.cts`. This module is the equivalent owner for
22
+ * coordinates.
23
+ *
24
+ * It imports ONLY node builtins plus `./git-provider.cjs` and
25
+ * `./org-profile.cjs` — no `vault.cjs`, no `vault-write-back.cjs`, no
26
+ * `io.cjs`, no `shell-command-projection.cjs` — so that both `src/vault.cts`
27
+ * and `src/vault-write-back.cts` can import it without forming a require
28
+ * cycle in the CommonJS graph `require()` loads. `git-provider.cjs` and
29
+ * `org-profile.cjs` are both safe to import here: neither imports this
30
+ * module back at their own top level (org-profile.cjs's only reference back
31
+ * to git-provider.cjs is a call-time lazy require, never load-time), so
32
+ * depending on either does not risk a cycle the way depending on `vault.cjs`
33
+ * would.
34
+ *
35
+ * Every coordinate this module resolves now consults an org profile (D-07)
36
+ * strictly between the config read and the built-in default —
37
+ * `resolvePlatformHost` and `resolveSharedCoordinates` call
38
+ * `readProfileValue` directly; `resolveClientCoordinates` gains no direct
39
+ * call of its own because its host chain already reaches
40
+ * `resolvePlatformHost` transitively. This supersedes the tier-keyed
41
+ * `orgProfileCoordinates(cwd, tier)` stub Phase 31 froze here — see the
42
+ * supersession note above `resolvePlatformHost` for the full rationale.
43
+ *
44
+ * Export style: named `export function` / `export const` declarations,
45
+ * matching `src/git-provider.cts`'s export surface rather than
46
+ * `src/vault.cts`'s `export =` object. Both consumers already import
47
+ * `./git-provider.cjs` with a named-import block, so this module is imported
48
+ * the identical way and no consumer needs a different call convention for
49
+ * the two seams.
50
+ */
51
+ var __importDefault = (this && this.__importDefault) || function (mod) {
52
+ return (mod && mod.__esModule) ? mod : { "default": mod };
53
+ };
54
+ Object.defineProperty(exports, "__esModule", { value: true });
55
+ exports.SHARED_OPT_OUT_REASON = exports.PLATFORM_HOST_CONFIG_KEY = exports.SHARED_HOST_CONFIG_KEY = exports.SHARED_REF_CONFIG_KEY = exports.SHARED_REPO_CONFIG_KEY = exports.DEFAULT_SHARED_REF = exports.DEFAULT_SHARED_REPO = exports.DEFAULT_HOST_FOR_PROVIDER = void 0;
56
+ exports.defaultHostForProvider = defaultHostForProvider;
57
+ exports.resolvePlatformHost = resolvePlatformHost;
58
+ exports.resolveClientCoordinates = resolveClientCoordinates;
59
+ exports.resolveSharedCoordinates = resolveSharedCoordinates;
60
+ const node_fs_1 = __importDefault(require("node:fs"));
61
+ const node_path_1 = __importDefault(require("node:path"));
62
+ const org_profile_cjs_1 = require("./org-profile.cjs");
63
+ // ─── Constants ───────────────────────────────────────────────────────────────
64
+ /**
65
+ * D-08: the last-resort host, derived from the resolved provider. New code,
66
+ * not a relocation — structured the way `PROVIDERS`/`DEFAULT_PROVIDER` are
67
+ * structured in `src/git-provider.cts`. Byte-identical for every gitlab
68
+ * install (ROADMAP SC3); correct out of the box for Azure DevOps cloud.
69
+ */
70
+ exports.DEFAULT_HOST_FOR_PROVIDER = Object.freeze({
71
+ gitlab: 'gitlab.com',
72
+ azure: 'dev.azure.com',
73
+ });
74
+ /** The values moving out of `src/vault.cts`'s `DEFAULT_FALLBACK_REPO`/`DEFAULT_FALLBACK_REF`. */
75
+ exports.DEFAULT_SHARED_REPO = 'pareto4/pareto-vault';
76
+ exports.DEFAULT_SHARED_REF = 'main';
77
+ /** Dotted `.planning/config.json` keys, named so no consumer hand-writes a key string. */
78
+ exports.SHARED_REPO_CONFIG_KEY = 'vault.fallback_repo';
79
+ exports.SHARED_REF_CONFIG_KEY = 'vault.fallback_ref';
80
+ exports.SHARED_HOST_CONFIG_KEY = 'vault.fallback_host';
81
+ exports.PLATFORM_HOST_CONFIG_KEY = 'platform.host';
82
+ /**
83
+ * D-13/WR-01: the `suppressedReason` for an `explicit-opt-out`. Worded so an
84
+ * operator reading a degraded manifest recognises their OWN decision in it —
85
+ * names the key and states the tier was disabled on purpose — rather than
86
+ * being told to act on something they already did. Exported so
87
+ * `tests/vault-coordinates.test.cjs` asserts the shipped sentence itself
88
+ * (read from this module) instead of a restated literal that could drift
89
+ * from it.
90
+ */
91
+ exports.SHARED_OPT_OUT_REASON = `the shared vault tier was intentionally disabled (${exports.SHARED_REPO_CONFIG_KEY} is set to an empty string)`;
92
+ // ─── Private readers ─────────────────────────────────────────────────────────
93
+ /**
94
+ * Read a dotted config key from `.planning/config.json`, returning `fallback`
95
+ * on any miss/unreadable-file/parse-error/non-object. Never throws.
96
+ *
97
+ * This is a byte-for-byte mirror of `cfgGet` in `src/git-provider.cts` (lines
98
+ * 78-89), which itself mirrors the canonical original at `src/vault.cts`
99
+ * (lines 124-135). It is written inline rather than imported: `src/vault.cts`
100
+ * will import THIS module, so importing a reader back out of `vault.cjs`
101
+ * would be the require cycle this whole leaf-module shape exists to avoid.
102
+ *
103
+ * Importing the reader from `git-provider.cjs` instead was also considered
104
+ * and rejected — not because of a require cycle (this module already
105
+ * depends on the provider seam, and the seam imports nothing back, so
106
+ * exporting a value from it would be safe), but because the reader is not
107
+ * the provider seam's responsibility to publish. Exporting it would make a
108
+ * private implementation detail of one seam part of another seam's public
109
+ * contract, so a future change to either module's reader would become a
110
+ * cross-module API change instead of a local one. What actually prevents
111
+ * drift is that all copies are byte-identical and each carries a comment
112
+ * naming the canonical original — that is the property to preserve, not a
113
+ * shared import.
114
+ */
115
+ function cfgGet(cwd, key, fallback) {
116
+ try {
117
+ const cfg = JSON.parse(node_fs_1.default.readFileSync(node_path_1.default.join(cwd, '.planning', 'config.json'), 'utf-8'));
118
+ const v = key.split('.').reduce((o, k) => (o && typeof o === 'object' ? o[k] : undefined), cfg);
119
+ return v === undefined ? fallback : v;
120
+ }
121
+ catch {
122
+ return fallback;
123
+ }
124
+ }
125
+ function isObject(v) {
126
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
127
+ }
128
+ /**
129
+ * Read `.platform-bindings.json` at the project root. Mirrors `readBindings`
130
+ * in `src/vault.cts` (lines 141-150) exactly: any problem
131
+ * (absent/malformed/non-object) → `{}`. Written inline for the same
132
+ * require-cycle reason `cfgGet` above is inline.
133
+ */
134
+ function readBindings(cwd) {
135
+ try {
136
+ const p = node_path_1.default.join(cwd, '.platform-bindings.json');
137
+ if (!node_fs_1.default.existsSync(p))
138
+ return {};
139
+ const parsed = JSON.parse(node_fs_1.default.readFileSync(p, 'utf-8'));
140
+ return isObject(parsed) ? parsed : {};
141
+ }
142
+ catch {
143
+ return {};
144
+ }
145
+ }
146
+ /**
147
+ * Bound a rejected config value before it is echoed into a warning.
148
+ * Byte-for-byte mirror of `sanitizeForWarning` in `src/git-provider.cts`
149
+ * (lines 98-100): strip CR/LF, collapse internal whitespace, trim, truncate
150
+ * to 40 characters. Every echoed config value in this module goes through
151
+ * it — no second, weaker bounding rule is written (T-31-05).
152
+ */
153
+ function sanitizeForWarning(value) {
154
+ return value.replace(/[\r\n]+/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 40);
155
+ }
156
+ /**
157
+ * Append only strings not already present in `target`, mirroring the
158
+ * exact-string de-duplication `src/vault.cts` already performs when folding
159
+ * two resolutions' warnings (lines 436-439). `resolveSharedCoordinates` can
160
+ * consult the org profile up to three times per call and `resolvePlatformHost`
161
+ * a fourth time within it — D-09 promises one bounded warning per rejected
162
+ * thing, not four copies of the same string.
163
+ */
164
+ function pushDeduped(target, incoming) {
165
+ for (const w of incoming) {
166
+ if (!target.includes(w))
167
+ target.push(w);
168
+ }
169
+ }
170
+ // ─── defaultHostForProvider ──────────────────────────────────────────────────
171
+ /** Read the D-08 host table for `provider`. */
172
+ function defaultHostForProvider(provider) {
173
+ return exports.DEFAULT_HOST_FOR_PROVIDER[provider];
174
+ }
175
+ // ─── D-07 supersession: the tier-keyed stub is gone ──────────────────────────
176
+ //
177
+ // Phase 31 froze `orgProfileCoordinates(cwd, tier)` — a stub taking a TIER
178
+ // (`'client' | 'shared'`) as the contract Phase 33 would fill. Phase 33
179
+ // (D-07) supersedes that shape rather than filling it: D-06's allowlist is a
180
+ // set of config KEYS (`vault.provider`, `vault.fallback_repo`,
181
+ // `vault.fallback_ref`, `vault.fallback_host`, `platform.host`), not tiers,
182
+ // and neither `vault.provider` nor `platform.host` fits a per-tier return at
183
+ // all. One key-keyed function — `readProfileValue(cwd, key)` in
184
+ // `src/org-profile.cts` — now serves all three consuming resolvers
185
+ // (`resolveProvider` in `src/git-provider.cts`, plus `resolvePlatformHost`
186
+ // and `resolveSharedCoordinates` below), where the tier-keyed shape could
187
+ // only ever have served two of them.
188
+ //
189
+ // Nothing is lost by this supersession: the client tier needs no direct call
190
+ // of its own, because its host chain already runs
191
+ // `client.vaultHost → resolvePlatformHost → defaultHostForProvider`, so a
192
+ // profile-supplied `platform.host` reaches it transitively through
193
+ // `resolvePlatformHost` below.
194
+ // ─── resolvePlatformHost (D-09, D-07) ────────────────────────────────────────
195
+ /**
196
+ * Resolve `platform.host` for the project at `cwd`, in four steps (widened
197
+ * from three by D-07): `.platform-bindings.json` → `.planning/config.json` →
198
+ * the org profile → `none`. `.platform-bindings.json` stays authoritative
199
+ * (Phase 31 D-09) — an org profile is below config, never above it (D-05).
200
+ *
201
+ * Read `.platform-bindings.json` first: when its `platform` block carries a
202
+ * string `host` whose trimmed value is non-empty, return that trimmed value
203
+ * with source `bindings` and no warnings — bindings win, and the org profile
204
+ * is never consulted on this branch. Otherwise read `platform.host` from
205
+ * `.planning/config.json`: when that trimmed value is non-empty, return it
206
+ * with source `config` and exactly one warning; the org profile is not
207
+ * consulted on this branch either — an explicit config value is the
208
+ * operator's, even from an older layer, and outranks a profile precisely
209
+ * because D-05 never lets a profile outrank anything explicitly set. The
210
+ * warning states that `platform.host` was read from `.planning/config.json`,
211
+ * that `.platform-bindings.json` is authoritative, and that the value should
212
+ * be moved there; it does not echo the host value at all, so this warning
213
+ * carries no injected-content surface. The whole warning is capped at 200
214
+ * characters.
215
+ *
216
+ * Only when BOTH bindings and config are silent is the org profile
217
+ * consulted, via `readProfileValue(cwd, PLATFORM_HOST_CONFIG_KEY)`. When its
218
+ * value is defined, return `{ host: value, source: 'profile', warnings }`;
219
+ * otherwise return `{ host: null, source: 'none', warnings }`. Both of these
220
+ * final returns carry the SAME profile warnings, so a broken profile is
221
+ * reported whether or not it happened to supply this particular key.
222
+ */
223
+ function resolvePlatformHost(cwd) {
224
+ const bindings = readBindings(cwd);
225
+ const platform = isObject(bindings.platform) ? bindings.platform : null;
226
+ const bindingsHostRaw = platform && typeof platform.host === 'string' ? platform.host.trim() : '';
227
+ if (bindingsHostRaw) {
228
+ return { host: bindingsHostRaw, source: 'bindings', warnings: [] };
229
+ }
230
+ const configHostRaw = cfgGet(cwd, exports.PLATFORM_HOST_CONFIG_KEY, undefined);
231
+ const configHost = typeof configHostRaw === 'string' ? configHostRaw.trim() : '';
232
+ if (configHost) {
233
+ const warning = `${exports.PLATFORM_HOST_CONFIG_KEY} was read from .planning/config.json — .platform-bindings.json is authoritative for ${exports.PLATFORM_HOST_CONFIG_KEY}; move the value there.`.slice(0, 200);
234
+ return { host: configHost, source: 'config', warnings: [warning] };
235
+ }
236
+ const profile = (0, org_profile_cjs_1.readProfileValue)(cwd, exports.PLATFORM_HOST_CONFIG_KEY);
237
+ if (profile.value !== undefined) {
238
+ return { host: profile.value, source: 'profile', warnings: profile.warnings };
239
+ }
240
+ return { host: null, source: 'none', warnings: profile.warnings };
241
+ }
242
+ // ─── resolveClientCoordinates ────────────────────────────────────────────────
243
+ /**
244
+ * Resolve the client-tier coordinates for `provider` at `cwd`.
245
+ *
246
+ * `repo` is `client.vaultRepo` when it is a string, else `null`, passed
247
+ * through with no trimming and no rewriting — the raw value is what the
248
+ * T-29-26 flag-shaped refusal and `isLiteralCloneTarget` inspect downstream,
249
+ * so this function must not touch it. `ref` is `client.vaultRef` trimmed
250
+ * when it is a non-blank string, else `'main'`. `host` is, in order:
251
+ * `client.vaultHost` trimmed when non-blank; else `resolvePlatformHost(cwd)`;
252
+ * else `defaultHostForProvider(provider)`. `resolvePlatformHost`'s warnings
253
+ * are folded into the returned `warnings` only when it is actually
254
+ * consulted (i.e. when no client-supplied host wins), so a project with an
255
+ * explicit `client.vaultHost` never surfaces a D-09 warning it has no
256
+ * bearing on.
257
+ *
258
+ * Trimming `host` and `ref` is the one deliberate unification in this
259
+ * function — the write path trimmed these values and the read path did not,
260
+ * so the two paths disagreed for a whitespace-padded value. For any value
261
+ * with no surrounding whitespace, which is every real install, the result is
262
+ * byte-identical to both paths' previous behaviour.
263
+ *
264
+ * This function deliberately gains no direct org-profile call (D-07): its
265
+ * host chain already runs `client.vaultHost → resolvePlatformHost →
266
+ * defaultHostForProvider`, so a profile-supplied `platform.host` reaches it
267
+ * transitively through `resolvePlatformHost` above. A future reader should
268
+ * not "fix" this apparent omission by adding a direct `readProfileValue`
269
+ * call here — that would consult the profile twice for the same key on a
270
+ * project that also resolves the shared tier in the same request.
271
+ */
272
+ function resolveClientCoordinates(cwd, provider) {
273
+ const bindings = readBindings(cwd);
274
+ const client = isObject(bindings.client) ? bindings.client : null;
275
+ const repo = client && typeof client.vaultRepo === 'string' ? client.vaultRepo : null;
276
+ const rawRef = client && typeof client.vaultRef === 'string' ? client.vaultRef.trim() : '';
277
+ const ref = rawRef || 'main';
278
+ const rawClientHost = client && typeof client.vaultHost === 'string' ? client.vaultHost.trim() : '';
279
+ let host;
280
+ let warnings = [];
281
+ if (rawClientHost) {
282
+ host = rawClientHost;
283
+ }
284
+ else {
285
+ const platformRes = resolvePlatformHost(cwd);
286
+ if (platformRes.host !== null) {
287
+ host = platformRes.host;
288
+ warnings = warnings.concat(platformRes.warnings);
289
+ }
290
+ else {
291
+ host = defaultHostForProvider(provider);
292
+ }
293
+ }
294
+ return { repo, ref, host, warnings };
295
+ }
296
+ // ─── resolveSharedCoordinates ────────────────────────────────────────────────
297
+ /**
298
+ * Resolve the shared-tier coordinates for `provider` at `cwd`.
299
+ *
300
+ * `ref`: `vault.fallback_ref` trimmed when a non-blank string; else (D-07) the
301
+ * org profile's `vault.fallback_ref`; else `DEFAULT_SHARED_REF`.
302
+ *
303
+ * `host`, in D-03's order, widened by D-07: `vault.fallback_host` trimmed
304
+ * when a non-blank string; else the org profile's `vault.fallback_host`; else
305
+ * `resolvePlatformHost(cwd)`; else `defaultHostForProvider(provider)`. When
306
+ * `vault.fallback_host` is present but is not a usable string (a number, an
307
+ * array, an object, or a blank string), it is skipped and exactly one bounded
308
+ * warning is added naming `vault.fallback_host` and the bounded echo of the
309
+ * rejected value, in the same sentence shape `resolveProvider`
310
+ * (`src/git-provider.cts`) uses for an unrecognized provider. A usable
311
+ * operator-supplied host is never rewritten or "fixed" — passed through as
312
+ * written, warned about only if something is worth saying. That is the same
313
+ * rule `composeCloneTarget` follows for an operator-supplied `.git` suffix
314
+ * (T-30-08). Ordering matters and is not arbitrary: a profile's
315
+ * `vault.fallback_host` is a statement about the shared tier specifically, so
316
+ * it outranks the more general `platform.host` for this tier exactly the way
317
+ * `vault.fallback_host` config outranks it.
318
+ *
319
+ * `repo`, in the config-then-org-profile-then-built-in-default order D-06
320
+ * fixes, now reporting suppression through the D-13/WR-01 `suppressedKind`
321
+ * discriminator rather than a single always-derivable boolean:
322
+ *
323
+ * - `vault.fallback_repo` present as a string and non-empty: that value
324
+ * wins, returned as written with no trimming. `suppressed: false`,
325
+ * `suppressedKind: null`, `suppressedReason: null`. The org profile is
326
+ * not consulted on this branch at all (D-05).
327
+ * - `vault.fallback_repo` present as the EXACT empty string `''`: an
328
+ * explicit, deliberate "shared tier disabled" — a decision, not an
329
+ * absence. Returns `repo: null`, `suppressed: TRUE`,
330
+ * `suppressedKind: 'explicit-opt-out'`, `suppressedReason:
331
+ * SHARED_OPT_OUT_REASON`. True on both providers and both values of
332
+ * `applyBuiltInDefault`; no warning is added (there is nothing wrong to
333
+ * report — the operator did this on purpose). The org profile is NOT
334
+ * consulted on this branch either, for the same D-05 reason as the
335
+ * non-empty case: an explicit `''` IS a set value, not an absence a
336
+ * profile may fill.
337
+ * - `vault.fallback_repo` present but not a string: treated as absent,
338
+ * falling through with one bounded warning naming the key and the
339
+ * bounded echo of the rejected value.
340
+ * - Key absent (or fell through): the org profile is consulted for
341
+ * `vault.fallback_repo` (D-07's successor to the frozen
342
+ * `orgProfileCoordinates(cwd, 'shared')` stub — see the supersession note
343
+ * above `resolvePlatformHost`). When it supplies a value, that value wins
344
+ * with `suppressed: false`, `suppressedKind: null`, `suppressedReason:
345
+ * null`, and the whole `applyBuiltInDefault` ladder below is skipped —
346
+ * `applyBuiltInDefault` withholds only the BUILT-IN default, never a
347
+ * configured profile value, so a project with a profile-supplied shared
348
+ * repo now performs a shared-shadow lookup on the write path where an
349
+ * unconfigured project still performs none. That is correct (the profile
350
+ * is a configured value, not a built-in) and cannot affect any existing
351
+ * install, because an install with no profile takes an unchanged path.
352
+ * - Nothing resolved, `provider` is `gitlab`: `DEFAULT_SHARED_REPO` when
353
+ * `applyBuiltInDefault` is true, else `null` — but `suppressed: false`
354
+ * and `suppressedKind: null` under BOTH flag values. Nothing was
355
+ * suppressed either way: with the flag true the built-in default
356
+ * applied, and with it false the caller asked for no default and got
357
+ * none. This must stay `false` under both flag values — making it `true`
358
+ * would put a suppression note on every GitLab project's promotion
359
+ * preview, exactly the regression ROADMAP SC3 exists to prevent.
360
+ * - Nothing resolved, `provider` is anything other than `gitlab`:
361
+ * `repo: null`, `suppressed: TRUE`, `suppressedKind:
362
+ * 'provider-default-unavailable'`, `suppressedReason` reading exactly
363
+ * `no shared vault configured for provider "<provider>"` — regardless of
364
+ * `applyBuiltInDefault`. This is the WR-01 fix itself: withholding the
365
+ * built-in default is why the write path could not previously see this
366
+ * state, and the fix is for the resolver to describe the state rather
367
+ * than describe what it did about it. The existing gitlab-only WARNING
368
+ * stays gated on `applyBuiltInDefault` exactly as before — the read
369
+ * path's warning list is byte-identical and the write path (which passes
370
+ * `applyBuiltInDefault: false`) gains no new warning.
371
+ *
372
+ * Two callers deliberately pass different values for `applyBuiltInDefault`,
373
+ * and this is not a divergence to unify: the read-path ladder and
374
+ * `assertVaultWriteTarget` both apply the built-in default because both do
375
+ * so today, while the write path's shared-shadow lookup does not because it
376
+ * never has (it reads the key with an empty-string default) — making it
377
+ * start applying the default would give existing gitlab installs a shared
378
+ * clone they do not perform today, breaking ROADMAP SC3.
379
+ */
380
+ function resolveSharedCoordinates(cwd, provider, opts) {
381
+ const applyBuiltInDefault = opts?.applyBuiltInDefault ?? true;
382
+ const warnings = [];
383
+ // ─ ref ─
384
+ const rawRefCfg = cfgGet(cwd, exports.SHARED_REF_CONFIG_KEY, undefined);
385
+ let ref;
386
+ if (typeof rawRefCfg === 'string' && rawRefCfg.trim()) {
387
+ ref = rawRefCfg.trim();
388
+ }
389
+ else {
390
+ const refProfile = (0, org_profile_cjs_1.readProfileValue)(cwd, exports.SHARED_REF_CONFIG_KEY);
391
+ pushDeduped(warnings, refProfile.warnings);
392
+ ref = refProfile.value !== undefined ? refProfile.value : exports.DEFAULT_SHARED_REF;
393
+ }
394
+ // ─ host (D-03 order, widened by D-07) ─
395
+ const rawHostCfg = cfgGet(cwd, exports.SHARED_HOST_CONFIG_KEY, undefined);
396
+ let host;
397
+ if (typeof rawHostCfg === 'string' && rawHostCfg.trim()) {
398
+ host = rawHostCfg.trim();
399
+ }
400
+ else {
401
+ if (rawHostCfg !== undefined) {
402
+ const rawStr = typeof rawHostCfg === 'string' ? rawHostCfg : JSON.stringify(rawHostCfg);
403
+ const echoed = sanitizeForWarning(rawStr ?? '');
404
+ warnings.push(`Unrecognized ${exports.SHARED_HOST_CONFIG_KEY} value "${echoed}" — expected a non-blank string. Falling back to ${exports.PLATFORM_HOST_CONFIG_KEY} or the provider default.`.slice(0, 200));
405
+ }
406
+ const hostProfile = (0, org_profile_cjs_1.readProfileValue)(cwd, exports.SHARED_HOST_CONFIG_KEY);
407
+ pushDeduped(warnings, hostProfile.warnings);
408
+ if (hostProfile.value !== undefined) {
409
+ host = hostProfile.value;
410
+ }
411
+ else {
412
+ const platformRes = resolvePlatformHost(cwd);
413
+ if (platformRes.host !== null) {
414
+ host = platformRes.host;
415
+ pushDeduped(warnings, platformRes.warnings);
416
+ }
417
+ else {
418
+ host = defaultHostForProvider(provider);
419
+ }
420
+ }
421
+ }
422
+ // ─ repo (D-06 order, D-13/WR-01 suppression kinds) ─
423
+ let repo;
424
+ let suppressed = false;
425
+ let suppressedKind = null;
426
+ let suppressedReason = null;
427
+ const rawRepoCfg = cfgGet(cwd, exports.SHARED_REPO_CONFIG_KEY, undefined);
428
+ if (typeof rawRepoCfg === 'string') {
429
+ if (rawRepoCfg === '') {
430
+ // D-13/WR-01: an explicit empty string is a DECISION, not an absence —
431
+ // the org profile must NOT be consulted here (D-05 never lets a
432
+ // profile outrank an explicitly set value, and an explicit '' is a set
433
+ // value). No warning: there is nothing wrong to report.
434
+ repo = null;
435
+ suppressed = true;
436
+ suppressedKind = 'explicit-opt-out';
437
+ suppressedReason = exports.SHARED_OPT_OUT_REASON;
438
+ }
439
+ else {
440
+ repo = rawRepoCfg;
441
+ }
442
+ }
443
+ else {
444
+ if (rawRepoCfg !== undefined) {
445
+ const echoed = sanitizeForWarning(JSON.stringify(rawRepoCfg) ?? '');
446
+ warnings.push(`Unrecognized ${exports.SHARED_REPO_CONFIG_KEY} value "${echoed}" — expected a string. Falling through to the org profile / built-in default.`.slice(0, 200));
447
+ }
448
+ // D-07: the org profile fills the key when config left it absent — this
449
+ // is the real consultation that supersedes the frozen stub call.
450
+ const repoProfile = (0, org_profile_cjs_1.readProfileValue)(cwd, exports.SHARED_REPO_CONFIG_KEY);
451
+ pushDeduped(warnings, repoProfile.warnings);
452
+ if (repoProfile.value !== undefined) {
453
+ repo = repoProfile.value;
454
+ // suppressed/suppressedKind/suppressedReason stay at their
455
+ // false/null/null defaults: applyBuiltInDefault withholds only the
456
+ // BUILT-IN default, never a configured profile value.
457
+ }
458
+ else if (provider === 'gitlab') {
459
+ // Nothing was suppressed either way: with the flag true the built-in
460
+ // default applied, and with it false the caller asked for no default
461
+ // and got none. This MUST stay false under both flag values — making
462
+ // it true would put a suppression note on every GitLab project's
463
+ // promotion preview, precisely the regression ROADMAP SC3 exists to
464
+ // prevent.
465
+ repo = applyBuiltInDefault ? exports.DEFAULT_SHARED_REPO : null;
466
+ }
467
+ else {
468
+ // D-13/WR-01 fix: withholding the built-in default is why the write
469
+ // path could not previously see this state — the resolver now
470
+ // describes the state itself, regardless of applyBuiltInDefault,
471
+ // rather than describing what it did about it. The WARNING stays
472
+ // gated on applyBuiltInDefault exactly as before, so the read path's
473
+ // warning list is byte-identical and the write path (which passes
474
+ // applyBuiltInDefault: false) gains no new warning.
475
+ repo = null;
476
+ suppressed = true;
477
+ suppressedKind = 'provider-default-unavailable';
478
+ suppressedReason = `no shared vault configured for provider "${provider}"`;
479
+ if (applyBuiltInDefault) {
480
+ warnings.push(`The built-in shared vault default applies only to the gitlab provider — set ${exports.SHARED_REPO_CONFIG_KEY} explicitly to use a shared vault on this provider.`.slice(0, 200));
481
+ }
482
+ }
483
+ }
484
+ return { repo, ref, host, warnings, suppressed, suppressedKind, suppressedReason };
485
+ }