@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.
@@ -11,9 +11,12 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
11
11
  * `scanForSecrets`) plus the preflight guard chain (`promotePreflight`)
12
12
  * that runs before any git subprocess exists in this module: the
13
13
  * activation gate, the Phase 24 format validator reused verbatim, the
14
- * secret scan, and the frozen `assertVaultWriteTarget` write-target
15
- * check. Every stage emits the uniform refusal contract (D-16) via the
16
- * `refusal` helper.
14
+ * secret scan, and the `assertVaultWriteTarget` write-target check. That
15
+ * guard's identity is now the resolved `(repo, host)` pair for both
16
+ * tiers (Phase 31, CFG-02/D-10) — it gained a required target-host
17
+ * parameter, and this arm passes one derived from the coordinate
18
+ * resolver rather than a config-only host chain. Every stage emits the
19
+ * uniform refusal contract (D-16) via the `refusal` helper.
17
20
  *
18
21
  * Deliberately a NEW module (not folded into `src/vault.cts` or
19
22
  * `src/vault-command-router.cts`) — the name is load-bearing: it forms a
@@ -62,7 +65,10 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
62
65
  * validator in the write path (D-03). Reused verbatim, never
63
66
  * re-derived.
64
67
  * - `./vault.cjs` (`cfgGet`, `readBindings`, `assertVaultWriteTarget`) —
65
- * `assertVaultWriteTarget` is called verbatim and unmodified (D-05).
68
+ * this module delegates to `assertVaultWriteTarget` and reimplements
69
+ * none of it (D-05); the guard's signature gained a required target
70
+ * host in Phase 31 (D-10), which this call site passes from the
71
+ * coordinate resolver.
66
72
  * - `./io.cjs` (`ERROR_REASON`) — the typed refusal reason codes added
67
73
  * alongside this module.
68
74
  *
@@ -81,6 +87,8 @@ const node_os_1 = __importDefault(require("node:os"));
81
87
  const node_path_1 = __importDefault(require("node:path"));
82
88
  const node_crypto_1 = __importDefault(require("node:crypto"));
83
89
  const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
90
+ const git_provider_cjs_1 = require("./git-provider.cjs");
91
+ const vault_coordinates_cjs_1 = require("./vault-coordinates.cjs");
84
92
  // eslint-disable-next-line @typescript-eslint/no-require-imports
85
93
  const io = require("./io.cjs");
86
94
  const { ERROR_REASON } = io;
@@ -266,17 +274,76 @@ function promotePreflight({ cwd, filePath, _vault, _practices }) {
266
274
  'Fix it in .platform-bindings.json and run the promotion again.');
267
275
  }
268
276
  const targetRef = client && typeof client.vaultRef === 'string' && client.vaultRef.trim() ? client.vaultRef.trim() : 'main';
269
- const platformHost = vault.cfgGet(cwd, 'platform.host', null);
270
- const targetHost = client && typeof client.vaultHost === 'string' && client.vaultHost.trim()
271
- ? client.vaultHost.trim()
272
- : typeof platformHost === 'string' && platformHost.trim()
273
- ? platformHost.trim()
274
- : 'gitlab.com';
275
- // 6. Write-target guard — assertVaultWriteTarget is called verbatim and
276
- // unmodified (D-05); this arm never re-derives, wraps, relaxes, or
277
- // reimplements any of its three refusals.
277
+ // Resolve the provider ONCE, here in the preflight (PROV-02/T-30-16) — both
278
+ // previewPromotion and confirmPromotion re-run this preflight and read the
279
+ // same `provider` field back off the result, so they cannot compose two
280
+ // different remotes for the same promotion. Moved ahead of target-host
281
+ // resolution (Phase 31, D-10): the host now comes from the coordinate
282
+ // resolver below, which itself takes the provider as an argument.
283
+ const providerResolution = (0, git_provider_cjs_1.resolveProvider)(cwd);
284
+ const provider = providerResolution.provider;
285
+ const providerWarnings = providerResolution.warnings.slice();
286
+ // Target host — from the coordinate resolver (Phase 31, D-10), not a
287
+ // config-only host chain. `client.vaultHost` wins when set; otherwise
288
+ // `.platform-bindings.json`'s `platform.host` (D-09), warned; otherwise
289
+ // the provider's default host. The `.planning/config.json`-only
290
+ // `platform.host` read this preflight used to fall back to last is gone
291
+ // — leaving it in place beside the resolver would not fix D-09, it would
292
+ // add a third disagreeing source of the same value.
293
+ const clientCoord = (0, vault_coordinates_cjs_1.resolveClientCoordinates)(cwd, provider);
294
+ const targetHost = clientCoord.host;
295
+ if (clientCoord.warnings.length > 0)
296
+ providerWarnings.push(...clientCoord.warnings);
297
+ // Shared-tier coordinates — resolved ONCE here in the preflight (D-09,
298
+ // T-31-09), carried forward on the success shape below rather than
299
+ // resolved a second time by `previewPromotion`. This is the same
300
+ // construction already used for `provider` and for `clientCoord` above:
301
+ // both `previewPromotion` and `confirmPromotion` re-run this preflight and
302
+ // read fields back off its result, so a promotion cannot end up resolving
303
+ // two disagreeing answers for its own shared-shadow lookup.
304
+ //
305
+ // `applyBuiltInDefault: false` is load-bearing, not a stylistic default.
306
+ // This path has never applied the built-in shared-vault default — it used
307
+ // to read `vault.fallback_repo` with an empty-string fallback, so a
308
+ // project that never set the key performed no shared-shadow clone at all.
309
+ // Passing `true` here would give every existing gitlab install a shared
310
+ // clone it does not perform today, breaking ROADMAP SC3. The read-path
311
+ // ladder and `assertVaultWriteTarget` both pass `true` because both of
312
+ // them already apply the built-in default today (they always have) — this
313
+ // is the one caller that deliberately withholds it.
314
+ const sharedCoord = (0, vault_coordinates_cjs_1.resolveSharedCoordinates)(cwd, provider, { applyBuiltInDefault: false });
315
+ if (sharedCoord.warnings.length > 0)
316
+ providerWarnings.push(...sharedCoord.warnings);
317
+ // D-13/WR-01: read the resolver's OWN verdict straight off `sharedCoord`
318
+ // — no local re-derivation. Before this fix, `sharedCoord.suppressed` was
319
+ // unconditionally `false` on this call regardless of provider, because
320
+ // withholding the built-in default (`applyBuiltInDefault: false`, above)
321
+ // also disabled the resolver's suppression branch. The resolver now
322
+ // reports `suppressed`/`suppressedKind` correctly even with the built-in
323
+ // default withheld, so this module no longer needs to (and must not)
324
+ // approximate the fact from `sharedRepo`/`provider` itself — that
325
+ // approximation was exactly the WR-01 defect (PROJECT.md: an operator who
326
+ // deliberately disabled the shared tier via an empty `vault.fallback_repo`
327
+ // was shown a note asking them to set the very key they already set,
328
+ // because this module could not tell that apart from "never configured").
329
+ const sharedSuppressed = sharedCoord.suppressed;
330
+ const sharedSuppressedKind = sharedCoord.suppressedKind;
331
+ // Any warning composing the primary clone target for THIS
332
+ // provider/repo/host (an operator-supplied `.git` suffix under azure) is
333
+ // folded in here too, alongside the provider and coordinate warnings
334
+ // already collected above, so a single `notes` push in the preview
335
+ // surfaces all of them.
336
+ if (!(0, git_provider_cjs_1.isLiteralCloneTarget)(targetRepo.trim())) {
337
+ const composedForWarnings = (0, git_provider_cjs_1.composeCloneTarget)(provider, targetRepo, targetHost);
338
+ if (composedForWarnings.warnings.length > 0)
339
+ providerWarnings.push(...composedForWarnings.warnings);
340
+ }
341
+ // 6. Write-target guard (D-05, D-10) — this arm delegates to
342
+ // assertVaultWriteTarget and never re-derives, wraps, relaxes, or
343
+ // reimplements any of its three refusals; it passes the resolved target
344
+ // repo and target host straight through.
278
345
  try {
279
- vault.assertVaultWriteTarget(cwd, targetRepo);
346
+ vault.assertVaultWriteTarget(cwd, targetRepo, targetHost);
280
347
  }
281
348
  catch (e) {
282
349
  const msg = (e instanceof Error ? e.message : String(e)).replace(/^vault write refused:\s*/, '');
@@ -293,6 +360,13 @@ function promotePreflight({ cwd, filePath, _vault, _practices }) {
293
360
  ref: targetRef,
294
361
  host: targetHost,
295
362
  destination,
363
+ provider,
364
+ providerWarnings,
365
+ sharedRepo: sharedCoord.repo,
366
+ sharedRef: sharedCoord.ref,
367
+ sharedHost: sharedCoord.host,
368
+ sharedSuppressed,
369
+ sharedSuppressedKind,
296
370
  };
297
371
  }
298
372
  // ─── Preview (read-only half of the write path) ─────────────────────────────────
@@ -319,70 +393,31 @@ function buildExpectToken(localRaw, remoteBaseline) {
319
393
  * never reads or writes the project's own vault read cache (a fresh
320
394
  * mkdtemp working copy is used and always removed, even on a thrown error).
321
395
  */
322
- /**
323
- * True when `value` is already something git can clone directly.
324
- *
325
- * MUST mirror `isLiteralCloneTarget` in `src/vault.cts`.
326
- *
327
- * Security (T-29-26): a value that git would read as an OPTION must never take
328
- * the verbatim branch. Two rules enforce that:
329
- * - the scheme test is anchored at the START (`^scheme://`). An earlier
330
- * version used `value.includes('://')`, so `--upload-pack=…#x://` — a git
331
- * option carrying `://` anywhere — passed through untouched and reached
332
- * `git clone` as a flag.
333
- * - the scp-style test may not begin with `-`. `[\w.-]+@` accepted a leading
334
- * hyphen, so `--config=core.sshCommand=…@x` matched too.
335
- * A leading `-` is additionally refused outright by `flagShapedRepoRefusal`, and
336
- * every clone call site passes `--` before the target, so all three would have
337
- * to fail together for an option to reach git.
338
- */
339
- function isLiteralCloneTarget(value) {
340
- if (value.startsWith('-'))
341
- return false;
342
- return (/^[A-Za-z][A-Za-z0-9+.-]*:\/\//.test(value) ||
343
- /^[\w.][\w.-]*@/.test(value) ||
344
- value.startsWith('/') ||
345
- value.startsWith('./') ||
346
- value.startsWith('../') ||
347
- value.startsWith('~') ||
348
- /^[A-Za-z]:[\\/]/.test(value));
349
- }
350
- /**
351
- * Resolve a configured vault repo value into something `git clone` can reach.
352
- *
353
- * `client.vaultRepo` carries two legitimate shapes, and before this resolver
354
- * existed only the first one worked on the write path:
355
- *
356
- * - a literal clone target — an absolute path, a `file://`/`https://`/`ssh://`
357
- * URL, or scp-style `git@host:group/project` — used verbatim. Every test
358
- * fixture and every disposable bare-repo harness uses this shape, which is
359
- * why the gap below stayed invisible to the suite.
360
- * - a `group/project` slug, which is the ONLY shape
361
- * `schemas/platform-bindings.schema.json` (`vaultRepo.pattern`) and its own
362
- * documented example `pareto4-clients/acme/acme-vault` actually permit. A
363
- * slug handed straight to `git clone` is read as a RELATIVE PATH and fails
364
- * with "repository does not exist" — so the write-back arm could never
365
- * reach a real client vault, and the refusal blamed the operator's access.
366
- *
367
- * The URL built for a slug mirrors `cloneTier` in `src/vault.cts` byte for byte
368
- * (`https://<host>/<repo>.git`), so the read and write paths address the same
369
- * remote instead of disagreeing about what `client.vaultRepo` means.
370
- */
371
- function resolveCloneTarget(repo, host) {
372
- const value = repo.trim();
373
- if (isLiteralCloneTarget(value))
374
- return value;
375
- return `https://${host}/${value}.git`;
376
- }
396
+ // `isLiteralCloneTarget` and clone-target composition — the single
397
+ // definitions live in `src/git-provider.cts` (imported above) and are shared
398
+ // with the read path (`src/vault.cts`, 30-02). Do NOT reintroduce a local
399
+ // copy of either here: a read/write divergence in this predicate was defect
400
+ // P-1 in Phase 29 (the write path used `client.vaultRepo` verbatim while the
401
+ // read path always composed a URL, so a slug broke writing and a path broke
402
+ // reading) — duplicating the fix a second time is exactly how that class of
403
+ // bug recurs.
377
404
  function previewPromotion({ cwd, filePath, _exec, _vault, _practices }) {
378
405
  const gitExec = _exec ?? shell_command_projection_cjs_1.execGit;
379
406
  // 1. Run the full preflight guard chain; refuse unchanged with no clone.
380
407
  const pre = promotePreflight({ cwd, filePath, _vault, _practices });
381
408
  if (pre.status !== 'ok')
382
409
  return pre;
383
- // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment
384
- const vault = _vault ?? require('./vault.cjs');
410
+ // No local `./vault.cjs` handle is needed here any more (Phase 31,
411
+ // Task 1): the shared-shadow lookup below reads its coordinates off `pre`
412
+ // instead of calling `vault.cfgGet` directly, so this function no longer
413
+ // touches the vault module at all — `_vault` is still threaded through to
414
+ // `promotePreflight` above for the test-seam injection convention.
385
415
  const notes = [];
416
+ // Surface any warning resolving the provider or composing the primary
417
+ // clone target produced (an unrecognized `vault.provider` value, or an
418
+ // operator-supplied `.git` suffix under azure) — visible in the preview an
419
+ // operator reads before confirming, never a silent degrade.
420
+ notes.push(...pre.providerWarnings);
386
421
  // The disposable working copy is a fresh mkdtemp directory — never the
387
422
  // read cache, which is often a shallow/sparse clone from a different tier
388
423
  // and is refreshed independently after a push. Removed on every exit path.
@@ -391,10 +426,19 @@ function previewPromotion({ cwd, filePath, _exec, _vault, _practices }) {
391
426
  cloneDir = node_fs_1.default.mkdtempSync(node_path_1.default.join(node_os_1.default.tmpdir(), 'pp-vault-promote-'));
392
427
  // 2. Clone the resolved client repo at the resolved ref — a full
393
428
  // single-branch clone (no shallow-depth flag: the confirmed path needs
394
- // real history to fast-forward against). The target goes through
395
- // resolveCloneTarget so a `group/project` slug becomes a real URL on the
396
- // resolved host instead of a relative path git cannot find.
397
- const cl = gitExec(['clone', '--branch', pre.ref, '--single-branch', '--', resolveCloneTarget(pre.repo, pre.host), cloneDir], { timeout: 120_000 });
429
+ // real history to fast-forward against). The target goes through the
430
+ // provider seam's composeCloneTarget so a `group/project` slug becomes a
431
+ // real URL on the resolved host and provider, instead of a relative path
432
+ // git cannot find.
433
+ const cl = gitExec([
434
+ 'clone',
435
+ '--branch',
436
+ pre.ref,
437
+ '--single-branch',
438
+ '--',
439
+ (0, git_provider_cjs_1.composeCloneTarget)(pre.provider, pre.repo, pre.host).url,
440
+ cloneDir,
441
+ ], { timeout: 120_000 });
398
442
  if (cl.exitCode !== 0) {
399
443
  const cause = boundedToken((cl.stderr || '').split(/\r?\n/)[0] || 'unknown error');
400
444
  return refusal(PROMOTE_GUARD.TRANSPORT, ERROR_REASON.VAULT_PROMOTE_TRANSPORT_FAILED, `This practice was not pushed: git could not reach the vault (${cause}). Check your access to the vault and run the promotion again.`);
@@ -422,28 +466,41 @@ function previewPromotion({ cwd, filePath, _exec, _vault, _practices }) {
422
466
  else {
423
467
  content = pre.raw;
424
468
  }
425
- // 6. Shared-shadow lookup — resolve through the vault module's cfgGet so
426
- // the existing defaults apply. Skipped entirely (flag stays false) when
427
- // the shared repo is unset or equals the client repo.
469
+ // 6. Shared-shadow lookup — the shared coordinates are resolved exactly
470
+ // ONCE, in the preflight above, with the built-in default explicitly
471
+ // withheld (D-09, T-31-09); this function reads them back off `pre`
472
+ // rather than resolving them a second time. Skipped entirely (flag stays
473
+ // false) when no shared repo resolved, or it equals the client repo —
474
+ // the same skip shape as before, now expressed against the resolved
475
+ // field: a `null` `pre.sharedRepo` is treated the way the empty string
476
+ // used to be treated.
477
+ //
478
+ // The shared vault is addressed on the resolved shared host, in D-03's
479
+ // order: `vault.fallback_host` first, then the authoritative
480
+ // `platform.host` (`.platform-bindings.json` first, `.planning/config.json`
481
+ // as a warned fallback per D-09), then the provider-derived default —
482
+ // never the client's own `client.vaultHost`, mirroring the fallback tier
483
+ // in `src/vault.cts`'s `cloneTier` so a client with a custom vaultHost
484
+ // never sends the shared-shadow lookup to their own host. Composed under
485
+ // the SAME resolved provider as the client clone (deliberate and
486
+ // symmetric with `cloneTier`'s one-provider-per-fetch rule).
487
+ //
488
+ // Per-tier provider selection does NOT arrive with this phase's CFG-01:
489
+ // D-01 answered that with a deliberate, permanent no. One provider per
490
+ // project stands — a client does not straddle two providers — and the
491
+ // fix for the azure-composing-a-gitlab-URL hazard is that the shared
492
+ // coordinates became per-client config, which is exactly what resolving
493
+ // them once in the preflight, above, does.
428
494
  let sharedShadow = false;
429
495
  let sharedShadowNote = null;
430
- const sharedRepoRaw = vault.cfgGet(cwd, 'vault.fallback_repo', '');
431
- const sharedRefRaw = vault.cfgGet(cwd, 'vault.fallback_ref', 'main');
432
- const sharedRepo = typeof sharedRepoRaw === 'string' ? sharedRepoRaw : '';
433
- const sharedRef = typeof sharedRefRaw === 'string' && sharedRefRaw ? sharedRefRaw : 'main';
496
+ const sharedRepo = pre.sharedRepo;
497
+ const sharedRef = pre.sharedRef;
498
+ const sharedHost = pre.sharedHost;
434
499
  if (sharedRepo && sharedRepo !== pre.repo) {
435
500
  let sharedDir = null;
436
501
  try {
437
502
  sharedDir = node_fs_1.default.mkdtempSync(node_path_1.default.join(node_os_1.default.tmpdir(), 'pp-vault-shared-'));
438
503
  // Shallow single-branch: only file presence matters, no history needed.
439
- // The shared vault is addressed on the PLATFORM host, not the client's
440
- // — mirroring the fallback tier in src/vault.cts's cloneTier, so a
441
- // client with a custom vaultHost never sends the shared-shadow lookup
442
- // to their own host.
443
- const sharedPlatformHostRaw = vault.cfgGet(cwd, 'platform.host', null);
444
- const sharedHost = typeof sharedPlatformHostRaw === 'string' && sharedPlatformHostRaw.trim()
445
- ? sharedPlatformHostRaw.trim()
446
- : 'gitlab.com';
447
504
  const sc = gitExec([
448
505
  'clone',
449
506
  '--depth',
@@ -452,7 +509,7 @@ function previewPromotion({ cwd, filePath, _exec, _vault, _practices }) {
452
509
  sharedRef,
453
510
  '--single-branch',
454
511
  '--',
455
- resolveCloneTarget(sharedRepo, sharedHost),
512
+ (0, git_provider_cjs_1.composeCloneTarget)(pre.provider, sharedRepo, sharedHost).url,
456
513
  sharedDir,
457
514
  ], { timeout: 120_000 });
458
515
  if (sc.exitCode === 0) {
@@ -476,6 +533,27 @@ function previewPromotion({ cwd, filePath, _exec, _vault, _practices }) {
476
533
  node_fs_1.default.rmSync(sharedDir, { recursive: true, force: true });
477
534
  }
478
535
  }
536
+ // Suppression note (D-02, D-11, D-13/WR-01) — pushed ONLY when the
537
+ // shared resolution reports suppression AND the kind is
538
+ // 'provider-default-unavailable'. A gitlab project with no
539
+ // `vault.fallback_repo` resolves `sharedSuppressed: false` (nothing was
540
+ // suppressed — no built-in default was withheld from a provider that
541
+ // would otherwise have gotten one) and emits nothing here, exactly as it
542
+ // did before this phase — this note is SC3-safe by construction.
543
+ //
544
+ // D-13/WR-01: an operator who set `vault.fallback_repo` to an empty
545
+ // string deliberately disabled the shared tier — `sharedSuppressedKind`
546
+ // is `'explicit-opt-out'` for that case, and telling them to set the key
547
+ // they already set is exactly the defect this closes. NO second note is
548
+ // added for the opt-out case: silence is the correct output for a
549
+ // choice the operator already made, and inventing a new note would be
550
+ // new behaviour rather than a fix. D-11's requirement to surface the
551
+ // skipped shared check is already met by the write guard's own refusal
552
+ // sentence (plan 03); this note additionally explains why, in the
553
+ // read-only preview an operator reads before confirming.
554
+ if (pre.sharedSuppressed && pre.sharedSuppressedKind === 'provider-default-unavailable') {
555
+ notes.push(`No shared vault is configured for the ${pre.provider} provider — the built-in Pareto shared default applies only to the gitlab provider. Set vault.fallback_repo to enable the shared tier.`);
556
+ }
479
557
  // 7 & 8. Pin both sides in the opaque expect token, then return.
480
558
  const expect = buildExpectToken(pre.raw, baseline);
481
559
  return {
@@ -558,7 +636,15 @@ function confirmPromotion({ cwd, filePath, expect, _exec, _vault, _practices })
558
636
  let cloneDir = null;
559
637
  try {
560
638
  cloneDir = node_fs_1.default.mkdtempSync(node_path_1.default.join(node_os_1.default.tmpdir(), 'pp-vault-confirm-'));
561
- const cl = gitExec(['clone', '--branch', pre.ref, '--single-branch', '--', resolveCloneTarget(pre.repo, pre.host), cloneDir], { timeout: 120_000 });
639
+ const cl = gitExec([
640
+ 'clone',
641
+ '--branch',
642
+ pre.ref,
643
+ '--single-branch',
644
+ '--',
645
+ (0, git_provider_cjs_1.composeCloneTarget)(pre.provider, pre.repo, pre.host).url,
646
+ cloneDir,
647
+ ], { timeout: 120_000 });
562
648
  if (cl.exitCode !== 0)
563
649
  return transportRefusal(cl);
564
650
  // 6. Fetch the resolved ref from origin, then absorb any unrelated