@pareto4/pp-core 1.10.0 → 1.11.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,785 @@
1
+ "use strict";
2
+ /**
3
+ * src/compose-bindings.cts — the bindings composer (milestone v1.11.0, Phase 34-03).
4
+ *
5
+ * ADR-457 build-at-publish: this TypeScript file is the SOURCE OF TRUTH. It
6
+ * compiles to the git-ignored `pp-core/bin/lib/compose-bindings.cjs`; do NOT
7
+ * hand-edit the compiled output. Behaviour is locked by
8
+ * `tests/compose-bindings.test.cjs`.
9
+ *
10
+ * This module reads the D-01 seed section an org profile package may carry
11
+ * (`bindings.*`, read via `resolveOrgProfileDocument` — never
12
+ * `readProfileValue`, whose allowlist gate deliberately excludes the seed
13
+ * namespace) plus the project answers `/pp-init` already captured in
14
+ * `.planning/config.json`, composes a `.platform-bindings.json` candidate,
15
+ * delegates every shape rule to `validateBindings` (never a second regex for
16
+ * `platform.repo`/`client.vaultRepo`/`client.slug`/`project.slug`/`platform.tag`),
17
+ * pins `platform.tag` to the seeded platform's latest stable tag over the
18
+ * resolved host, merges absent keys only against any existing file, and
19
+ * writes only when explicitly told to (`opts.write === true`). Dry-run is the
20
+ * no-flag default (D-10) — `composeBindings` and `cmdComposeBindings` share
21
+ * this exact code path with no second implementation for the `/pp-init` call
22
+ * site.
23
+ *
24
+ * D-05 puts this composition in a CLI arm rather than `init.md` shell: the
25
+ * CLI-writer + `.md` shell-glue seam is a known blindspot in this repo —
26
+ * `node:test` covers the writer while the heredoc JSON and the parsed-key
27
+ * round-trip go untested, and that is exactly how the
28
+ * `generate-client-context` CR-02 bug shipped. A file that governs the vault
29
+ * write guard must not be assembled where the suite cannot reach it. Every
30
+ * JSON object this module writes is built in compiled code via
31
+ * `JSON.stringify`/object literals — never by `.md` shell string
32
+ * concatenation.
33
+ *
34
+ * This module sits at the top of the CommonJS graph — nothing imports it —
35
+ * so its top-level imports of `./org-profile.cjs`, `./platform-bindings.cjs`,
36
+ * `./git-provider.cjs`, `./vault-coordinates.cjs`, `./io.cjs`,
37
+ * `./project-root.cjs`, `./package-identity.cjs` and
38
+ * `./shell-command-projection.cjs` create no require cycle: none of those
39
+ * modules imports this one, at load time or lazily.
40
+ *
41
+ * Like `src/org-profile.cts`, this file's own prose and code deliberately
42
+ * never name a concrete client or a concrete org profile package.
43
+ */
44
+ var __importDefault = (this && this.__importDefault) || function (mod) {
45
+ return (mod && mod.__esModule) ? mod : { "default": mod };
46
+ };
47
+ Object.defineProperty(exports, "__esModule", { value: true });
48
+ exports.COMPOSE_BINDINGS_FLAGS = exports.SEED_VALUE_MAX_LENGTH = void 0;
49
+ exports._resetPlatformTagMemoForTests = _resetPlatformTagMemoForTests;
50
+ exports.composeBindings = composeBindings;
51
+ exports.cmdComposeBindings = cmdComposeBindings;
52
+ const node_fs_1 = __importDefault(require("node:fs"));
53
+ const node_path_1 = __importDefault(require("node:path"));
54
+ const node_child_process_1 = __importDefault(require("node:child_process"));
55
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
56
+ const io = require("./io.cjs");
57
+ const { output } = io;
58
+ const project_root_cjs_1 = require("./project-root.cjs");
59
+ const package_identity_cjs_1 = require("./package-identity.cjs");
60
+ const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
61
+ const semver_compare_cjs_1 = require("./semver-compare.cjs");
62
+ const org_profile_cjs_1 = require("./org-profile.cjs");
63
+ const platform_bindings_cjs_1 = require("./platform-bindings.cjs");
64
+ const git_provider_cjs_1 = require("./git-provider.cjs");
65
+ const vault_coordinates_cjs_1 = require("./vault-coordinates.cjs");
66
+ // ─── Constants ───────────────────────────────────────────────────────────────
67
+ /**
68
+ * The maximum accepted length of a seed value. Rejecting a value past this
69
+ * bound is preferred to truncating it: a truncated coordinate that still got
70
+ * written would be a silently wrong clone target.
71
+ */
72
+ exports.SEED_VALUE_MAX_LENGTH = 200;
73
+ /**
74
+ * D-03's required core: any of these failing means compose nothing. Every
75
+ * other field `validateBindings` might reject is OPTIONAL — dropped with a
76
+ * warning rather than blocking the whole compose.
77
+ */
78
+ const REQUIRED_ERROR_PREFIXES = Object.freeze(['root', 'schemaVersion', 'platform', 'platform.repo']);
79
+ /** Maps each D-01 seed key (fully dotted, `ORG_PROFILE_SEED_SECTION`-prefixed)
80
+ * to the short name this module carries it under internally. */
81
+ const SEED_KEY_SHORT_NAMES = Object.freeze({
82
+ [`${org_profile_cjs_1.ORG_PROFILE_SEED_SECTION}.platform.repo`]: 'platformRepo',
83
+ [`${org_profile_cjs_1.ORG_PROFILE_SEED_SECTION}.client.vaultRepo`]: 'clientVaultRepo',
84
+ [`${org_profile_cjs_1.ORG_PROFILE_SEED_SECTION}.client.vaultRef`]: 'clientVaultRef',
85
+ [`${org_profile_cjs_1.ORG_PROFILE_SEED_SECTION}.client.vaultHost`]: 'clientVaultHost',
86
+ [`${org_profile_cjs_1.ORG_PROFILE_SEED_SECTION}.provisioning.group`]: 'provisioningGroup',
87
+ });
88
+ // Compiled once at module load, without the global flag (a global regex
89
+ // carries `lastIndex` state across `test` calls) — the same convention
90
+ // `src/git-provider.cts` and `src/org-profile.cts` both use for their own
91
+ // copies of this array. Imported, never re-typed (T-34-21).
92
+ const compiledRejectedCloneTargetPatterns = org_profile_cjs_1.PROFILE_REJECTED_CLONE_TARGET_PATTERNS.map((source) => new RegExp(source));
93
+ /** The recognized flag set for the `compose-bindings` CLI arm (34-04's
94
+ * fail-fast unrecognized-flag guard reads this). */
95
+ exports.COMPOSE_BINDINGS_FLAGS = Object.freeze(['--write', '--offline']);
96
+ // ─── Private helpers (deliberate per-module duplication, canonical originals
97
+ // named in each comment) ──────────────────────────────────────────────────────
98
+ function isObject(v) {
99
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
100
+ }
101
+ function isUsableString(v) {
102
+ return typeof v === 'string' && v.trim() !== '';
103
+ }
104
+ /** Byte-for-byte mirror of `sanitizeForWarning` in `src/git-provider.cts`
105
+ * (lines 98-100): strip CR/LF, collapse internal whitespace, trim, truncate
106
+ * to 40 characters. */
107
+ function sanitizeForWarning(value) {
108
+ return value.replace(/[\r\n]+/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 40);
109
+ }
110
+ /** Bound a whole warning string to 200 characters, mirroring `bound` in
111
+ * `src/org-profile.cts` (line 196-249). */
112
+ function bound(warning) {
113
+ return warning.slice(0, 200);
114
+ }
115
+ /** Byte-for-byte mirror of `cfgGet` in `src/git-provider.cts` (lines 78-89).
116
+ * Written inline rather than imported for the same require-cycle reason
117
+ * every sibling seam module inlines this reader. */
118
+ function cfgGet(cwd, key, fallback) {
119
+ try {
120
+ const cfg = JSON.parse(node_fs_1.default.readFileSync(node_path_1.default.join(cwd, '.planning', 'config.json'), 'utf-8'));
121
+ const v = key.split('.').reduce((o, k) => (o && typeof o === 'object' ? o[k] : undefined), cfg);
122
+ return v === undefined ? fallback : v;
123
+ }
124
+ catch {
125
+ return fallback;
126
+ }
127
+ }
128
+ /** Append only strings not already present in `target` — mirrors
129
+ * `pushDeduped` in `src/vault-coordinates.cts` (lines 200-204). This module
130
+ * can consult the org profile and the host resolver multiple times per call;
131
+ * D-09 promises one bounded warning per rejected thing, not several copies. */
132
+ function pushDeduped(target, incoming) {
133
+ for (const w of incoming) {
134
+ if (!target.includes(w))
135
+ target.push(w);
136
+ }
137
+ }
138
+ // ─── Dotted-path object helpers (bounded, depth matches the schema's own
139
+ // shallow shape — top-level namespace, one leaf level) ────────────────────────
140
+ function hasNestedOwn(obj, dottedPath) {
141
+ if (!obj)
142
+ return false;
143
+ const parts = dottedPath.split('.');
144
+ let cur = obj;
145
+ for (const p of parts) {
146
+ if (!isObject(cur) || !Object.prototype.hasOwnProperty.call(cur, p))
147
+ return false;
148
+ cur = cur[p];
149
+ }
150
+ return true;
151
+ }
152
+ function getNested(obj, dottedPath) {
153
+ if (!obj)
154
+ return undefined;
155
+ const parts = dottedPath.split('.');
156
+ let cur = obj;
157
+ for (const p of parts) {
158
+ if (!isObject(cur))
159
+ return undefined;
160
+ cur = cur[p];
161
+ }
162
+ return cur;
163
+ }
164
+ function setNested(obj, dottedPath, value) {
165
+ const parts = dottedPath.split('.');
166
+ let cur = obj;
167
+ for (let i = 0; i < parts.length - 1; i++) {
168
+ const key = parts[i];
169
+ if (!isObject(cur[key]))
170
+ cur[key] = {};
171
+ cur = cur[key];
172
+ }
173
+ cur[parts[parts.length - 1]] = value;
174
+ }
175
+ function deleteDottedField(obj, dottedPath) {
176
+ const parts = dottedPath.split('.');
177
+ if (parts.length === 1) {
178
+ delete obj[parts[0]];
179
+ return;
180
+ }
181
+ const parent = obj[parts[0]];
182
+ if (isObject(parent)) {
183
+ delete parent[parts[1]];
184
+ if (Object.keys(parent).length === 0)
185
+ delete obj[parts[0]];
186
+ }
187
+ }
188
+ function deepCloneJsonSafe(value) {
189
+ return JSON.parse(JSON.stringify(value));
190
+ }
191
+ function valuesEqual(a, b) {
192
+ if (a === b)
193
+ return true;
194
+ try {
195
+ return JSON.stringify(a) === JSON.stringify(b);
196
+ }
197
+ catch {
198
+ return false;
199
+ }
200
+ }
201
+ /** Flatten a plain object to dotted leaf paths, bounded at depth 6 (the
202
+ * schema's own real depth is 2; the bound exists only so a pathological
203
+ * hand-edited file cannot recurse unbounded). */
204
+ function walkLeaves(obj, prefix, depthCap, out) {
205
+ if (depthCap <= 0)
206
+ return;
207
+ for (const k of Object.keys(obj)) {
208
+ const dotted = prefix ? `${prefix}.${k}` : k;
209
+ const v = obj[k];
210
+ if (isObject(v)) {
211
+ walkLeaves(v, dotted, depthCap - 1, out);
212
+ }
213
+ else {
214
+ out.push({ path: dotted, value: v });
215
+ }
216
+ }
217
+ }
218
+ function hasAnyNewKey(composed, existing) {
219
+ const leaves = [];
220
+ walkLeaves(composed, '', 6, leaves);
221
+ return leaves.some((leaf) => !hasNestedOwn(existing, leaf.path));
222
+ }
223
+ // ─── dispositionSeedValue (D-03 seed guard) ──────────────────────────────────
224
+ /**
225
+ * Guard a single D-01 seed value, never throwing. Scalar guard first
226
+ * (mirroring `profileValueDisposition` in `src/org-profile.cts`): must be a
227
+ * string; the trimmed form non-empty; the UNTRIMMED value carries no ASCII
228
+ * control character; the TRIMMED value carries no whitespace; the trimmed
229
+ * value does not begin with `-`; the trimmed length is at most
230
+ * `SEED_VALUE_MAX_LENGTH`. Then a per-key disposition built entirely from
231
+ * imported constants — this function authors no new pattern strings.
232
+ *
233
+ * `seedKey` is the FULLY DOTTED key (`ORG_PROFILE_SEED_SECTION`-prefixed,
234
+ * e.g. `bindings.client.vaultHost`), matching the entries of
235
+ * `ORG_PROFILE_SEED_KEYS`.
236
+ */
237
+ function dispositionSeedValue(seedKey, value) {
238
+ try {
239
+ if (typeof value !== 'string') {
240
+ return { ok: false, reason: 'value must be a string' };
241
+ }
242
+ const trimmed = value.trim();
243
+ if (trimmed === '') {
244
+ return { ok: false, reason: 'value must not be blank' };
245
+ }
246
+ if (/[\x00-\x1f\x7f]/.test(value)) {
247
+ return { ok: false, reason: 'value must not contain control characters' };
248
+ }
249
+ if (/\s/.test(trimmed)) {
250
+ return { ok: false, reason: 'value must not contain whitespace' };
251
+ }
252
+ if (trimmed.startsWith('-')) {
253
+ return { ok: false, reason: 'value must not begin with "-"' };
254
+ }
255
+ if (trimmed.length > exports.SEED_VALUE_MAX_LENGTH) {
256
+ return { ok: false, reason: `value exceeds the maximum accepted length of ${exports.SEED_VALUE_MAX_LENGTH} characters` };
257
+ }
258
+ if (seedKey === `${org_profile_cjs_1.ORG_PROFILE_SEED_SECTION}.client.vaultHost`) {
259
+ // Authority-escape guard (T-34-20) — genuinely new surface:
260
+ // `profileValueDisposition` only ever runs inside the allowlist-gated
261
+ // walk and gives a seed key nothing for free.
262
+ for (const ch of org_profile_cjs_1.HOST_FORBIDDEN_CHARS) {
263
+ if (trimmed.includes(ch)) {
264
+ return {
265
+ ok: false,
266
+ reason: `a host must not contain ${ch} — it must be a bare host, optionally with a port`,
267
+ };
268
+ }
269
+ }
270
+ return { ok: true, value: trimmed };
271
+ }
272
+ if (seedKey === `${org_profile_cjs_1.ORG_PROFILE_SEED_SECTION}.platform.repo` ||
273
+ seedKey === `${org_profile_cjs_1.ORG_PROFILE_SEED_SECTION}.client.vaultRepo` ||
274
+ seedKey === `${org_profile_cjs_1.ORG_PROFILE_SEED_SECTION}.provisioning.group`) {
275
+ // T-34-21: a machine-specific clone target is not accepted from a
276
+ // package — a human chose one deliberately and a package did not.
277
+ if (compiledRejectedCloneTargetPatterns.some((re) => re.test(trimmed))) {
278
+ return { ok: false, reason: 'a machine-specific path is not accepted from an org profile' };
279
+ }
280
+ if (seedKey === `${org_profile_cjs_1.ORG_PROFILE_SEED_SECTION}.provisioning.group`) {
281
+ const segments = trimmed.split('/');
282
+ if (trimmed.startsWith('/') || segments.includes('..')) {
283
+ return { ok: false, reason: 'value must not begin with "/" or contain a ".." path segment' };
284
+ }
285
+ }
286
+ return { ok: true, value: trimmed };
287
+ }
288
+ // bindings.client.vaultRef: the scalar guard alone.
289
+ return { ok: true, value: trimmed };
290
+ }
291
+ catch {
292
+ return { ok: false, reason: 'value could not be validated' };
293
+ }
294
+ }
295
+ /**
296
+ * Read the D-01 seed section through `resolveOrgProfileDocument` — never
297
+ * `readProfileValue`, whose allowlist gate would return `undefined` with no
298
+ * filesystem access for a seed key and would misrepresent the boundary.
299
+ * Walks exactly the members of `ORG_PROFILE_SEED_KEYS` (never an open-ended
300
+ * walk), running each present value through `dispositionSeedValue`.
301
+ */
302
+ function readSeed(cwd, opts) {
303
+ const doc = (0, org_profile_cjs_1.resolveOrgProfileDocument)(cwd, opts);
304
+ const profile = {
305
+ resolved: doc.resolved,
306
+ packageName: doc.packageName,
307
+ version: doc.version,
308
+ source: doc.source,
309
+ dataPath: doc.dataPath,
310
+ };
311
+ const warnings = [...doc.warnings];
312
+ const values = {};
313
+ const seedSectionRaw = doc.document[org_profile_cjs_1.ORG_PROFILE_SEED_SECTION];
314
+ if (!doc.resolved) {
315
+ return { values, profile, warnings };
316
+ }
317
+ if (!isObject(seedSectionRaw)) {
318
+ if (seedSectionRaw !== undefined) {
319
+ warnings.push(bound(`org profile "${org_profile_cjs_1.ORG_PROFILE_SEED_SECTION}" section is not a JSON object — no coordinates seeded.`));
320
+ }
321
+ return { values, profile, warnings };
322
+ }
323
+ for (const dottedKey of org_profile_cjs_1.ORG_PROFILE_SEED_KEYS) {
324
+ const relPath = dottedKey.slice(org_profile_cjs_1.ORG_PROFILE_SEED_SECTION.length + 1);
325
+ if (!hasNestedOwn(seedSectionRaw, relPath))
326
+ continue;
327
+ const rawValue = getNested(seedSectionRaw, relPath);
328
+ const disposition = dispositionSeedValue(dottedKey, rawValue);
329
+ const shortName = SEED_KEY_SHORT_NAMES[dottedKey];
330
+ if (disposition.ok) {
331
+ values[shortName] = disposition.value;
332
+ }
333
+ else {
334
+ warnings.push(bound(`org profile seed key "${dottedKey}" rejected — ${disposition.reason}.`));
335
+ }
336
+ }
337
+ return { values, profile, warnings };
338
+ }
339
+ // ─── resolveLatestPlatformTag (D-04) ─────────────────────────────────────────
340
+ /**
341
+ * Per-process memo for the D-04 tag lookup, keyed by the resolved URL, holding
342
+ * the full `{ tag, warnings }` verdict — successes AND failures. One real
343
+ * `pp-tools init` run calls `composeBindings` twice (the dry-run preview in
344
+ * `computeCurrentStep`, then the write-mode call after `compose_confirm`), so
345
+ * without this memo a decline costs a network round-trip for nothing and a
346
+ * failed lookup would print its bounded warning twice.
347
+ */
348
+ const platformTagMemo = new Map();
349
+ /** Test-only seam: clear the per-process platform.tag lookup memo so a test
350
+ * suite that shares a resolved URL across cases sees an empty memo each time
351
+ * (`init-tag-lookup-raw-git-error`). Mirrors the reset-seam convention at
352
+ * `src/active-workstream-store.cts`'s controlling-TTY probe cache. */
353
+ function _resetPlatformTagMemoForTests() {
354
+ platformTagMemo.clear();
355
+ }
356
+ /**
357
+ * Resolve the latest STABLE tag for `repo` over the resolved host, never
358
+ * shell-eval'd (T-34-25): `git ls-remote` is spawned with an argv array via
359
+ * `childProcess.execFileSync`, bounded by `timeout`/`maxBuffer`/`windowsHide`
360
+ * (T-34-26). The host is `resolvePlatformHost(cwd).host` when it resolves,
361
+ * else `defaultHostForProvider(provider)` — the host is authoritative and
362
+ * never derived from the repo string (Phase 31 D-09).
363
+ *
364
+ * The child's stderr is piped into the parent's buffer rather than inherited
365
+ * — an explicit `stdio: ['ignore', 'pipe', 'pipe']` — and deliberately
366
+ * discarded rather than logged: the bounded warning this function returns on
367
+ * failure is the single user-visible surface for a lookup failure. Inheriting
368
+ * the child's stream let a raw git `fatal:` line reach the terminal during an
369
+ * otherwise successful first-run experience (`init-tag-lookup-raw-git-error`).
370
+ *
371
+ * Every candidate ref is filtered with `isStableTripletSemver` and ordered
372
+ * with `compareSemverCore` — the single blessed comparison source, never
373
+ * reimplemented. An empty result degrades to `{ tag: null, warnings: [] }`
374
+ * (tagless is valid, D-01/D-02); any thrown fault (offline, unreachable,
375
+ * DNS failure, non-zero git exit) degrades to `{ tag: null, warnings: [one
376
+ * bounded warning] }` — offline, unreachable and tagless all resolve to the
377
+ * platform being unpinned, never a compose failure.
378
+ */
379
+ function resolveLatestPlatformTag(cwd, repo, provider, opts) {
380
+ let url;
381
+ try {
382
+ const hostRes = (0, vault_coordinates_cjs_1.resolvePlatformHost)(cwd);
383
+ const host = hostRes.host ?? (0, vault_coordinates_cjs_1.defaultHostForProvider)(provider);
384
+ url = `https://${host}/${repo}`;
385
+ }
386
+ catch {
387
+ return {
388
+ tag: null,
389
+ warnings: [
390
+ bound('platform.tag could not be resolved — the tag lookup failed or was unreachable; the platform is unpinned.'),
391
+ ],
392
+ };
393
+ }
394
+ const cached = platformTagMemo.get(url);
395
+ if (cached)
396
+ return cached;
397
+ let result;
398
+ try {
399
+ const lsOutput = typeof opts._lsRemote === 'function'
400
+ ? opts._lsRemote(url)
401
+ : node_child_process_1.default.execFileSync('git', ['ls-remote', '--tags', '--refs', '--sort=-v:refname', url, 'v*'], {
402
+ encoding: 'utf-8',
403
+ timeout: 10_000,
404
+ maxBuffer: 512 * 1024,
405
+ windowsHide: true,
406
+ stdio: ['ignore', 'pipe', 'pipe'],
407
+ });
408
+ const tags = lsOutput
409
+ .split('\n')
410
+ .map((line) => {
411
+ const m = line.match(/\brefs\/tags\/([^\s^]+)$/);
412
+ return m ? m[1].trim() : '';
413
+ })
414
+ .filter((t) => t && (0, semver_compare_cjs_1.isStableTripletSemver)(t));
415
+ if (tags.length === 0) {
416
+ result = { tag: null, warnings: [] };
417
+ }
418
+ else {
419
+ let latest = tags[0];
420
+ for (const t of tags) {
421
+ if ((0, semver_compare_cjs_1.compareSemverCore)(t, latest) > 0)
422
+ latest = t;
423
+ }
424
+ result = { tag: latest, warnings: [] };
425
+ }
426
+ }
427
+ catch {
428
+ result = {
429
+ tag: null,
430
+ warnings: [
431
+ bound('platform.tag could not be resolved — the tag lookup failed or was unreachable; the platform is unpinned.'),
432
+ ],
433
+ };
434
+ }
435
+ platformTagMemo.set(url, result);
436
+ return result;
437
+ }
438
+ /**
439
+ * Read the first readable, semver-shaped `VERSION` file among the installed
440
+ * `pp-core/VERSION` location and this repo's own root `VERSION`, returning
441
+ * `<binName>@<version>`; when neither is readable, `binName` alone.
442
+ */
443
+ function resolveCreatedWith() {
444
+ const candidates = [node_path_1.default.join(__dirname, '..', 'VERSION'), node_path_1.default.join(__dirname, '..', '..', '..', 'VERSION')];
445
+ for (const candidate of candidates) {
446
+ try {
447
+ const raw = node_fs_1.default.readFileSync(candidate, 'utf-8').trim();
448
+ if (/^\d+\.\d+\.\d+/.test(raw)) {
449
+ return `${package_identity_cjs_1.binName}@${raw}`;
450
+ }
451
+ }
452
+ catch {
453
+ // try the next candidate
454
+ }
455
+ }
456
+ return package_identity_cjs_1.binName;
457
+ }
458
+ /**
459
+ * Build the composed candidate object from the seed, the project answers
460
+ * `/pp-init` already captured, the resolved platform host, the D-04 tag pin,
461
+ * the package identity and the contract version. Omits any field whose
462
+ * source value is absent, blank or non-string — never writes an empty
463
+ * string. Every emitted field is one the schema's `additionalProperties:
464
+ * false` property sets already allow.
465
+ */
466
+ function composeCandidate(cwd, seed, provider, opts) {
467
+ const candidate = {};
468
+ const provenance = {};
469
+ const warnings = [];
470
+ const setField = (field, value, source) => {
471
+ if (!isUsableString(value))
472
+ return;
473
+ setNested(candidate, field, value);
474
+ provenance[field] = source;
475
+ };
476
+ // schemaVersion (contract) — always present, never a second '1.1.0' literal.
477
+ candidate.schemaVersion = platform_bindings_cjs_1.CONTRACT_SCHEMA_VERSION;
478
+ provenance.schemaVersion = 'contract';
479
+ // platform.repo (profile-seed)
480
+ setField('platform.repo', seed.platformRepo, 'profile-seed');
481
+ // platform.host (resolved-host) — one of the five runtime-allowlisted keys;
482
+ // sourced from the runtime resolver rather than duplicated into the seed
483
+ // section.
484
+ const hostRes = (0, vault_coordinates_cjs_1.resolvePlatformHost)(cwd);
485
+ pushDeduped(warnings, hostRes.warnings);
486
+ setField('platform.host', hostRes.host ?? undefined, 'resolved-host');
487
+ // platform.tag (resolved-tag, D-04) — only attempted when platform.repo
488
+ // composed and the caller did not opt out via offline.
489
+ const platformObj = candidate.platform;
490
+ const platformRepoValue = isObject(platformObj) ? platformObj.repo : undefined;
491
+ if (isUsableString(platformRepoValue) && opts.offline !== true) {
492
+ const tagResult = resolveLatestPlatformTag(cwd, platformRepoValue, provider, opts);
493
+ pushDeduped(warnings, tagResult.warnings);
494
+ if (tagResult.tag) {
495
+ setField('platform.tag', tagResult.tag, 'resolved-tag');
496
+ }
497
+ }
498
+ // client.name / client.slug (project-answer)
499
+ setField('client.name', cfgGet(cwd, 'project.client_name', undefined), 'project-answer');
500
+ setField('client.slug', cfgGet(cwd, 'project.client_slug', undefined), 'project-answer');
501
+ // client.vaultRepo / vaultRef / vaultHost (profile-seed)
502
+ setField('client.vaultRepo', seed.clientVaultRepo, 'profile-seed');
503
+ setField('client.vaultRef', seed.clientVaultRef, 'profile-seed');
504
+ setField('client.vaultHost', seed.clientVaultHost, 'profile-seed');
505
+ // project.name / project.slug (project-answer)
506
+ setField('project.name', cfgGet(cwd, 'project.name', undefined), 'project-answer');
507
+ setField('project.slug', cfgGet(cwd, 'project.slug', undefined), 'project-answer');
508
+ // project.createdWith (package-identity)
509
+ setField('project.createdWith', resolveCreatedWith(), 'package-identity');
510
+ return { candidate, provenance, warnings };
511
+ }
512
+ // ─── validateAndDispose (D-03) ────────────────────────────────────────────────
513
+ function leadingToken(errorMsg) {
514
+ const idx = errorMsg.indexOf(':');
515
+ return idx === -1 ? errorMsg : errorMsg.slice(0, idx);
516
+ }
517
+ /**
518
+ * Implement D-03 exactly. Every shape rule comes from `validateBindings`;
519
+ * this function contains no second regex for any binding field.
520
+ *
521
+ * - Valid as-is: composable, unchanged.
522
+ * - Any REQUIRED field failing (`root`, `schemaVersion`, `platform`,
523
+ * `platform.repo`): compose nothing. This asymmetry is load-bearing — the
524
+ * presence of `.platform-bindings.json` is itself the gate that flips
525
+ * `/pp-init` and `execute-phase` into Pareto mode, so writing a file with
526
+ * an unusable `platform.repo` would flip that gate into a state nothing
527
+ * downstream can satisfy.
528
+ * - Otherwise (only OPTIONAL fields failing): a field whose provenance is
529
+ * `existing-file` is NEVER deleted (T-30-08) — when only such fields fail,
530
+ * report `composable: false` naming them. Otherwise delete each failing
531
+ * optional field (and its parent when it becomes empty), warn once per
532
+ * field, and re-validate ONCE. If still invalid, `composable: false`.
533
+ */
534
+ function validateAndDispose(candidate, provenanceByField) {
535
+ const first = (0, platform_bindings_cjs_1.validateBindings)(candidate);
536
+ if (first.valid) {
537
+ return { composable: true, composed: candidate, warnings: [] };
538
+ }
539
+ const failingFields = first.errors.map(leadingToken);
540
+ const requiredFailures = failingFields.filter((f) => REQUIRED_ERROR_PREFIXES.includes(f));
541
+ if (requiredFailures.length > 0) {
542
+ const requiredErrorText = first.errors
543
+ .filter((e) => REQUIRED_ERROR_PREFIXES.includes(leadingToken(e)))
544
+ .join('; ');
545
+ return {
546
+ composable: false,
547
+ composed: null,
548
+ warnings: [bound(`binding could not be composed — required field(s) invalid: ${requiredErrorText}`)],
549
+ };
550
+ }
551
+ const uneditableFields = failingFields.filter((f) => provenanceByField[f] === 'existing-file');
552
+ if (uneditableFields.length > 0) {
553
+ return {
554
+ composable: false,
555
+ composed: null,
556
+ warnings: [
557
+ bound(`binding could not be composed — the existing .platform-bindings.json has invalid value(s) for ${sanitizeForWarning(uneditableFields.join(', '))}; fix the committed file directly.`),
558
+ ],
559
+ };
560
+ }
561
+ const mutated = deepCloneJsonSafe(candidate);
562
+ const warnings = [];
563
+ for (let i = 0; i < first.errors.length; i++) {
564
+ const field = failingFields[i];
565
+ warnings.push(bound(`${field} dropped from composed binding — ${first.errors[i]}`));
566
+ deleteDottedField(mutated, field);
567
+ }
568
+ const second = (0, platform_bindings_cjs_1.validateBindings)(mutated);
569
+ if (!second.valid) {
570
+ return {
571
+ composable: false,
572
+ composed: null,
573
+ warnings: [...warnings, bound('binding still invalid after dropping optional fields — composing nothing.')],
574
+ };
575
+ }
576
+ return { composable: true, composed: mutated, warnings };
577
+ }
578
+ /**
579
+ * Read `<cwd>/.platform-bindings.json`. Absent → `present: false`. Present
580
+ * and a parseable JSON object → `present: true, malformed: false`. Present
581
+ * but malformed JSON or not a JSON object → `present: true, malformed: true`
582
+ * with one bounded warning; this deliberately does NOT adopt
583
+ * `cmdPlatformBindings`' hard-error-on-malformed-JSON behaviour — this arm
584
+ * reports a verdict as data on exit 0, and an unreadable file is a reason to
585
+ * do nothing rather than a reason to crash a `/pp-init` run.
586
+ */
587
+ function readExistingBindings(cwd) {
588
+ const bindingsPath = node_path_1.default.join(cwd, '.platform-bindings.json');
589
+ if (!node_fs_1.default.existsSync(bindingsPath)) {
590
+ return { present: false, malformed: false, data: {}, warnings: [] };
591
+ }
592
+ try {
593
+ const raw = node_fs_1.default.readFileSync(bindingsPath, 'utf-8');
594
+ const parsed = JSON.parse(raw);
595
+ if (!isObject(parsed)) {
596
+ return {
597
+ present: true,
598
+ malformed: true,
599
+ data: {},
600
+ warnings: [bound('existing .platform-bindings.json does not contain a JSON object — left untouched.')],
601
+ };
602
+ }
603
+ return { present: true, malformed: false, data: parsed, warnings: [] };
604
+ }
605
+ catch {
606
+ return {
607
+ present: true,
608
+ malformed: true,
609
+ data: {},
610
+ warnings: [bound('existing .platform-bindings.json could not be parsed — left untouched.')],
611
+ };
612
+ }
613
+ }
614
+ /**
615
+ * D-08's absent-only merge. `merged` starts as a deep clone of `existing` —
616
+ * every field the existing file already carries (including the provisional
617
+ * `modules`/`ci` namespaces the composer never produces) is preserved
618
+ * untouched. For each field the composer actually composed (the keys of
619
+ * `candidateProvenance`): if `existing` lacks that key, fill it in from the
620
+ * candidate; if `existing` already has it, the merged value stays the
621
+ * existing one (never overwritten) and — when the candidate's value differs
622
+ * — one drift entry is recorded. A present `(repo, host)` pair can therefore
623
+ * never be redirected by a package; Phase 33 D-05's threat stays closed by
624
+ * construction, not by policy.
625
+ */
626
+ function mergeAbsentOnly(candidate, candidateProvenance, existing) {
627
+ const merged = deepCloneJsonSafe(existing);
628
+ const provenance = [];
629
+ const provenanceByField = {};
630
+ const drift = [];
631
+ const managedFields = Object.keys(candidateProvenance);
632
+ for (const field of managedFields) {
633
+ const candidateValue = getNested(candidate, field);
634
+ if (hasNestedOwn(existing, field)) {
635
+ const existingValue = getNested(existing, field);
636
+ provenance.push({ field, value: existingValue, source: 'existing-file' });
637
+ provenanceByField[field] = 'existing-file';
638
+ if (!valuesEqual(candidateValue, existingValue)) {
639
+ drift.push({ key: field, profileValue: candidateValue, effectiveValue: existingValue });
640
+ }
641
+ }
642
+ else {
643
+ setNested(merged, field, candidateValue);
644
+ const source = candidateProvenance[field];
645
+ provenance.push({ field, value: candidateValue, source });
646
+ provenanceByField[field] = source;
647
+ }
648
+ }
649
+ // Provenance-tag (never overwrite) every leaf the existing file carries
650
+ // that the composer does not manage at all.
651
+ const managedSet = new Set(managedFields);
652
+ const existingLeaves = [];
653
+ walkLeaves(existing, '', 6, existingLeaves);
654
+ for (const leaf of existingLeaves) {
655
+ if (managedSet.has(leaf.path))
656
+ continue;
657
+ provenance.push({ field: leaf.path, value: leaf.value, source: 'existing-file' });
658
+ provenanceByField[leaf.path] = 'existing-file';
659
+ }
660
+ return { merged, provenance, provenanceByField, drift };
661
+ }
662
+ // ─── composeBindings ──────────────────────────────────────────────────────────
663
+ const ABSENT_PROFILE_META = Object.freeze({
664
+ resolved: false,
665
+ packageName: null,
666
+ version: null,
667
+ source: 'none',
668
+ dataPath: null,
669
+ });
670
+ /**
671
+ * Compose a `.platform-bindings.json` candidate for the project at `cwd`,
672
+ * validate it, merge it against any existing file, and write only when
673
+ * `opts.write === true` and the result is composable. Never throws and
674
+ * never exits non-zero — every failure mode produces a bounded warning and
675
+ * continues (D-09).
676
+ */
677
+ function composeBindings(cwd, opts = {}) {
678
+ const warnings = [];
679
+ try {
680
+ const { provider, warnings: providerWarnings } = (0, git_provider_cjs_1.resolveProvider)(cwd);
681
+ pushDeduped(warnings, providerWarnings);
682
+ const seedResult = readSeed(cwd, { _globalRoot: opts._globalRoot });
683
+ pushDeduped(warnings, seedResult.warnings);
684
+ const candidateResult = composeCandidate(cwd, seedResult.values, provider, opts);
685
+ pushDeduped(warnings, candidateResult.warnings);
686
+ const existing = readExistingBindings(cwd);
687
+ pushDeduped(warnings, existing.warnings);
688
+ if (existing.malformed) {
689
+ return {
690
+ profile: seedResult.profile,
691
+ provider,
692
+ seed: seedResult.values,
693
+ present: existing.present,
694
+ composable: false,
695
+ composed: null,
696
+ provenance: [],
697
+ drift: [],
698
+ action: 'none',
699
+ wrote: false,
700
+ warnings,
701
+ };
702
+ }
703
+ const mergeResult = mergeAbsentOnly(candidateResult.candidate, candidateResult.provenance, existing.data);
704
+ const disposeResult = validateAndDispose(mergeResult.merged, mergeResult.provenanceByField);
705
+ pushDeduped(warnings, disposeResult.warnings);
706
+ if (!disposeResult.composable || disposeResult.composed === null) {
707
+ return {
708
+ profile: seedResult.profile,
709
+ provider,
710
+ seed: seedResult.values,
711
+ present: existing.present,
712
+ composable: false,
713
+ composed: null,
714
+ provenance: mergeResult.provenance,
715
+ drift: mergeResult.drift,
716
+ action: 'none',
717
+ wrote: false,
718
+ warnings,
719
+ };
720
+ }
721
+ const composed = disposeResult.composed;
722
+ let action;
723
+ if (!existing.present) {
724
+ action = 'create';
725
+ }
726
+ else if (hasAnyNewKey(composed, existing.data)) {
727
+ action = 'update';
728
+ }
729
+ else {
730
+ action = 'unchanged';
731
+ }
732
+ let wrote = false;
733
+ if (opts.write === true && action !== 'unchanged') {
734
+ const bindingsPath = node_path_1.default.join(cwd, '.platform-bindings.json');
735
+ (0, shell_command_projection_cjs_1.platformWriteSync)(bindingsPath, JSON.stringify(composed, null, 2) + '\n');
736
+ wrote = true;
737
+ }
738
+ return {
739
+ profile: seedResult.profile,
740
+ provider,
741
+ seed: seedResult.values,
742
+ present: existing.present,
743
+ composable: true,
744
+ composed,
745
+ provenance: mergeResult.provenance,
746
+ drift: mergeResult.drift,
747
+ action,
748
+ wrote,
749
+ warnings,
750
+ };
751
+ }
752
+ catch {
753
+ return {
754
+ profile: ABSENT_PROFILE_META,
755
+ provider: git_provider_cjs_1.DEFAULT_PROVIDER,
756
+ seed: {},
757
+ present: false,
758
+ composable: false,
759
+ composed: null,
760
+ provenance: [],
761
+ drift: [],
762
+ action: 'none',
763
+ wrote: false,
764
+ warnings: [...warnings, 'binding composition failed unexpectedly.'],
765
+ };
766
+ }
767
+ }
768
+ // ─── cmdComposeBindings ───────────────────────────────────────────────────────
769
+ /**
770
+ * CLI adapter for `pp-tools query compose-bindings`. Resolves the project
771
+ * root via `findProjectRoot(cwd) || cwd`, exactly as `cmdPlatformBindings`
772
+ * does, then calls `composeBindings` and emits the result as data — a
773
+ * verdict is never a process error.
774
+ */
775
+ function cmdComposeBindings(cwd, options, raw) {
776
+ let root;
777
+ try {
778
+ root = (0, project_root_cjs_1.findProjectRoot)(cwd) || cwd;
779
+ }
780
+ catch {
781
+ root = cwd;
782
+ }
783
+ const result = composeBindings(root, { write: options.write === true, offline: options.offline === true });
784
+ output(result, raw);
785
+ }