@go-to-k/cdkd 0.290.7 → 0.290.9

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.
@@ -1,8 +1,8 @@
1
- import { $ as secretSafeKeyDisplay, $n as isCrossRegionRedirect, $r as CdkdError, $t as applyRoleArnIfSet, A as planOrphanAdoption, An as forceQuitRecoveryClause, Ar as resolveStateBucketWithDefaultAndSource, At as isSettledInstanceState, B as formatResourceLine, Bn as shouldRetainResource, Br as expectedOwnerParam, Bt as readConfigString, C as createPreDeleteFinalSnapshot, Ci as isRetryableTransientError, Cn as redactSecretsForState, Cr as getDefaultStateBucketName, Ct as cfnRefValueFromPhysicalId, D as makeCanonicalizePropertiesFn, Di as markRedactedCause, Dn as rebuildClientForBucketRegion, Dr as resolveCaptureObservedState, Dt as isUnboundTemplateParameter, E as unsupportedFinalSnapshotError, Ei as markNonRetryable, En as S3StateBackend, Er as resolveAutoAssetStorage, Et as isImpossibleEmptyStoredAttribute, F as WARM_THROUGHPUT_MEMBERS, Fn as exportNamesCarriedFrom, Fr as CFN_TEMPLATE_URL_LIMIT, Ft as coerceCfnBoolean, G as red, Gn as createAssetRedirectResolver, Gr as processStackMessages, Gt as classifyReplaySecretRegion, H as cyan, Hn as stringifyValue, Hr as canonicalizeRegion, Ht as requireConfigArray, I as coerceWarmThroughput$1, In as importableOutputKeys, Ir as MIGRATE_TMP_PREFIX, It as coerceCfnInteger, J as collectPublishedOutputNames, Jn as AssetModeResolver, Jr as AwsClients, Jt as s3BucketArn, K as yellow, Kn as loadPublishableAssetManifest, Kt as producerRegionsFromState, L as isWarmThroughputDecrease, Ln as importableOutputs, Lr as findLargeInlineResources, Lt as configBooleanRefusal, M as isStatefulRecreateTargetForReplace, Mn as shellQuote, Mr as stateBucketExistenceConfirmed, Mt as normalizeAwsTagsToCfn, N as isStatefulRecreateTargetSync, Nn as CUSTOM_RESOURCE_RESPONSE_PREFIX, Nr as warnDeprecatedNoPrefixCliFlag, Nt as resolveExplicitPhysicalId, O as extractDeploymentEventError, Oi as retryClassificationText, On as buildForceUnlockCommand, Or as resolveSkipPrefix, Ot as parameterTypeMayLoseSecretIdentity, P as renderStatefulReason, Pn as DEFAULT_STATE_PREFIX, Pr as CFN_TEMPLATE_BODY_LIMIT, Pt as assertRegionMatch, Q as secretBearingStateKeyWarning, Qn as getBootstrapMarkerKey, Qt as s3BucketWebsiteUrl, R as toCfnInteger, Rn as orphansAfterRollback, Rr as uploadCfnTemplate, Rt as configIntegerRefusal, S as ccRoutedFinalSnapshotError, Si as isMarkedNonRetryable, Sn as recoverMaskedOutput, Sr as synthesisStatusMessage, St as carriesDynamicReference, T as refusesFinalSnapshot, Ti as isTransientServerError, Tn as LockManager, Tr as resolveApp, Tt as getAccountInfo, U as gray, Un as WorkGraph, Ur as derivePartitionAndUrlSuffix, Ut as requireConfigObject, V as bold, Vn as AssetPublisher, Vr as PARTITION_TABLE, Vt as replayWarn, W as green, Wn as buildAssetRedirectMap, Wr as AssemblyReader, Wt as requireConfigString, X as isExportAliasCollision, Xn as assertAssetBucketRegion, Xr as resetAwsClients, Xt as s3BucketDualStackDomainName, Y as exportAliasCollisionScrubWarning, Yn as BOOTSTRAP_MARKER_PREFIX, Yr as getAwsClients, Yt as s3BucketDomainName, Z as secretBearing, Zn as ensureAssetStorage, Zr as setAwsClients, Zt as s3BucketRegionalDomainName, _ as isSecretBearingReferenceString, _n as isSingleDynamicReferenceToken, _r as escapeRegExp, _t as deleteIndeterminateGuards, a as DeploymentEventsStore, ai as IntrinsicResolutionRefusalError, an as withRetry, ar as describeAwsFailure, at as wouldReturnToSdkProvider, b as PRE_DELETE_SNAPSHOT_TYPES, bi as normalizeAwsError, bn as maskSecretsInText, br as getDockerImageBySourceHash, bt as isTerminationProtectionPropagationError, c as replayFailedOperations, cn as STATE_SOURCED_BASELINE_RULES, cr as describeDockerExecFailure, ct as maskerOrIdentity, d as updatePartialReason, di as ProvisioningError, dn as TEMPLATE_SOURCED_RULES, dr as formatDockerLoginError, dt as interruptWatchListenerCount, en as DiffCalculator, er as parseBootstrapMarker, et as getCurrentResourceSecrets, f as withResourceDeadline, fi as ResourceTimeoutError, fn as carriesSecretMask, fr as getDockerCmd, ft as isInterruptedWaitError, g as bagHoldsSecretExpression, gn as identityKeyFor, gr as runDockerStreaming, gt as UNSPECIFIED_SKIP_REASON, h as computeImplicitDeleteEdges, hi as StackTerminationProtectionError, hn as errorCauseChain, hr as runDockerForeground, ht as slowCcOperationTimeoutMs, i as DeploymentEventsReader, ii as DynamicReferenceRegionAmbiguousError, in as describeTypeWithThrottleRetry, ir as buildDenyExternalAccessPolicy, it as ProviderRegistry, j as MULTI_REGION_RECREATE_BLOCKED_TYPES, jn as formatLockExpiry, jr as resolveUseCdkBootstrapAssets, jt as WAFv2WebACLProvider, k as makeSiblingClaimReader, kn as buildLockContentionMessage, kr as resolveStateBucketWithDefault, kt as refStateLookupFromResource, l as replayRollback, li as NestedStackChildDirectDestroyError, ln as STATE_SOURCED_CROSS_GENERATION_RULES, lr as describeDockerFailure, lt as beginCommandInterruptScope, m as IMPLICIT_DELETE_DEPENDENCIES, mi as StackHasActiveImportsError, mn as dynamicReferenceTokens, mr as redactDockerArgvValues, mt as CloudControlProvider, n as DEFAULT_RESOURCE_WARN_AFTER_MS, nn as findActionableSilentDrops, nr as validateAssetBucketName, nt as collectInlinePolicyNamesManagedBySiblings, o as planFailedOps, oi as LocalInvokeBuildError$1, on as DagBuilder, or as buildDockerImage, ot as createMaskedRetryLogger, p as maskingRetryLogger, pi as ResourceUpdateNotSupportedError, pn as createSecretMasker, pr as partitionSensitiveEnv, pt as startInterruptWatch, q as collectDeclaredOutputNames, qn as rewriteTemplateAssetReferences, qr as resolveBucketRegion, qt as withSharedDrainBudget, r as DeployEngine, ri as DeployCancelledError, rn as findSilentDropProperties, rr as validateContainerRepoName, rt as clearOnUpdateRemoval, s as planRollback, si as LocalStartServiceError, sn as TemplateParser, sr as describeDockerCapturedOutput, st as maskDeep, t as DEFAULT_RESOURCE_TIMEOUT_MS, ti as CrossAccountSecretRefusalError, tn as INTRINSIC_KEYS, tr as readBootstrapMarkerBody, tt as IAMRoleProvider, u as updatePartialMessage, ui as PartialFailureError, un as STATE_SOURCED_READBACK_RULES, ur as dockerSpawnEnvWithSensitive, ut as endCommandInterruptScope, v as bindingSkippedOutputs, vn as markSameGenerationBag, vr as stripControlChars, vt as deleteSkipReason, w as isFinalSnapshotError, wi as isThrottlingError, wn as scrubResourceRecord, wr as getLegacyStateBucketName, wt as coerceParameterTypedValue, x as buildFinalSnapshotIdentifier, xi as withErrorHandling, xn as recordMaskOnlyValue, xr as Synthesizer, xt as IntrinsicFunctionResolver, y as ATOMIC_FINAL_SNAPSHOT_TYPES, yn as maskSecretsInError, yr as AssetManifestLoader, yt as disableInstanceApiTermination, z as toFiniteNumber, zn as orphansCarriedFrom, zr as CUSTOM_RESOURCE_RESPONSE_OBJECT_DESCRIPTION, zt as configStringRefusal } from "./deploy-engine-BMp4b4Vl.js";
2
- import { S as truncateCodePoints, _ as withSkipPrefix, b as displayIdent, c as PATTERN_B_NAME_OPTIONS, f as explicitNamePropertyFor, h as getCurrentStackName, i as reserveStdoutForPayload, l as PATTERN_B_NAME_PROPERTIES, m as generateResourceNameWithFallback, n as getLogger, o as runStackBuffered, p as generateResourceName, s as getLiveRenderer, u as PATTERN_B_RESOURCE_TYPES, v as withStackName, x as displaySafe, y as UNRENDERABLE } from "./logger-th1myfgh.js";
1
+ import { $ as secretBearing, $n as ensureAssetStorage, $r as setAwsClients, $t as s3BucketRegionalDomainName, A as extractDeploymentEventError, Ai as retryClassificationText, An as buildForceUnlockCommand, Ar as resolveSkipPrefix, At as parameterTypeMayLoseSecretIdentity, B as toCfnInteger, Bn as orphansAfterRollback, Br as uploadCfnTemplate, Bt as configIntegerRefusal, C as buildFinalSnapshotIdentifier, Ci as withErrorHandling, Cn as recordMaskOnlyValue, Cr as Synthesizer, Ct as IntrinsicFunctionResolver, D as refusesFinalSnapshot, Di as isTransientServerError, Dn as LockManager, Dr as resolveApp, Dt as getAccountInfo, E as isFinalSnapshotError, Ei as isThrottlingError, En as scrubResourceRecord, Er as getLegacyStateBucketName, Et as coerceParameterTypedValue, F as isStatefulRecreateTargetSync, Fn as CUSTOM_RESOURCE_RESPONSE_PREFIX, Fr as warnDeprecatedNoPrefixCliFlag, Ft as resolveExplicitPhysicalId, G as gray, Gn as WorkGraph, Gr as derivePartitionAndUrlSuffix, Gt as requireConfigObject, H as formatResourceLine, Hn as shouldRetainResource, Hr as expectedOwnerParam, Ht as readConfigString, I as renderStatefulReason, In as DEFAULT_STATE_PREFIX, Ir as CFN_TEMPLATE_BODY_LIMIT, It as assertRegionMatch, J as yellow, Jn as loadPublishableAssetManifest, Jt as producerRegionsFromState, K as green, Kn as buildAssetRedirectMap, Kr as AssemblyReader, Kt as requireConfigString, L as WARM_THROUGHPUT_MEMBERS, Ln as exportNamesCarriedFrom, Lr as CFN_TEMPLATE_URL_LIMIT, Lt as coerceCfnBoolean, M as planOrphanAdoption, Mn as forceQuitRecoveryClause, Mr as resolveStateBucketWithDefaultAndSource, Mt as isSettledInstanceState, N as MULTI_REGION_RECREATE_BLOCKED_TYPES, Nn as formatLockExpiry, Nr as resolveUseCdkBootstrapAssets, Nt as WAFv2WebACLProvider, O as unsupportedFinalSnapshotError, Oi as markNonRetryable, On as S3StateBackend, Or as resolveAutoAssetStorage, Ot as isImpossibleEmptyStoredAttribute, P as isStatefulRecreateTargetForReplace, Pn as shellQuote, Pr as stateBucketExistenceConfirmed, Pt as normalizeAwsTagsToCfn, Q as isExportAliasCollision, Qn as assertAssetBucketRegion, Qr as resetAwsClients, Qt as s3BucketDualStackDomainName, R as coerceWarmThroughput$1, Rn as importableOutputKeys, Rr as MIGRATE_TMP_PREFIX, Rt as coerceCfnInteger, S as PRE_DELETE_SNAPSHOT_TYPES, Si as normalizeAwsError, Sn as maskSecretsInText, Sr as getDockerImageBySourceHash, St as isTerminationProtectionPropagationError, T as createPreDeleteFinalSnapshot, Ti as isRetryableTransientError, Tn as redactSecretsForState, Tr as getDefaultStateBucketName, Tt as cfnRefValueFromPhysicalId, U as bold, Un as AssetPublisher, Ur as PARTITION_TABLE, Ut as replayWarn, V as toFiniteNumber, Vn as orphansCarriedFrom, Vr as CUSTOM_RESOURCE_RESPONSE_OBJECT_DESCRIPTION, Vt as configStringRefusal, W as cyan, Wn as stringifyValue, Wr as canonicalizeRegion, Wt as requireConfigArray, X as collectPublishedOutputNames, Xn as AssetModeResolver, Xr as AwsClients, Xt as s3BucketArn, Y as collectDeclaredOutputNames, Yn as rewriteTemplateAssetReferences, Yr as resolveBucketRegion, Yt as withSharedDrainBudget, Z as exportAliasCollisionScrubWarning, Zn as BOOTSTRAP_MARKER_PREFIX, Zr as getAwsClients, Zt as s3BucketDomainName, _ as isSecretBearingReferenceString, _i as StackTerminationProtectionError, _n as errorCauseChain, _r as runDockerForeground, _t as slowCcOperationTimeoutMs, a as DeploymentEventsStore, ai as DeployCancelledError, an as findSilentDropProperties, ar as validateContainerRepoName, at as clearOnUpdateRemoval, b as bindingSkippedOutputs, bn as markSameGenerationBag, br as stripControlChars, bt as deleteSkipReason, c as replayFailedOperations, ci as LocalInvokeBuildError$1, cn as DagBuilder, cr as buildDockerImage, ct as createMaskedRetryLogger, d as updatePartialReason, di as NestedStackChildDirectDestroyError, dn as STATE_SOURCED_CROSS_GENERATION_RULES, dr as describeDockerFailure, dt as beginCommandInterruptScope, en as s3BucketWebsiteUrl, er as getBootstrapMarkerKey, et as secretBearingStateKeyWarning, f as withResourceDeadline, fi as PartialFailureError, fn as STATE_SOURCED_READBACK_RULES, fr as dockerSpawnEnvWithSensitive, ft as endCommandInterruptScope, g as bagHoldsSecretExpression, gi as StackHasActiveImportsError, gn as dynamicReferenceTokens, gr as redactDockerArgvValues, gt as CloudControlProvider, h as computeImplicitDeleteEdges, hi as ResourceUpdateNotSupportedError, hn as createSecretMasker, hr as partitionSensitiveEnv, ht as startInterruptWatch, i as DeploymentEventsReader, in as findActionableSilentDrops, ir as validateAssetBucketName, it as collectInlinePolicyNamesManagedBySiblings, j as makeSiblingClaimReader, jn as buildLockContentionMessage, jr as resolveStateBucketWithDefault, jt as refStateLookupFromResource, k as makeCanonicalizePropertiesFn, ki as markRedactedCause, kn as rebuildClientForBucketRegion, kr as resolveCaptureObservedState, kt as isUnboundTemplateParameter, l as replayRollback, li as LocalStartServiceError, ln as TemplateParser, lr as describeDockerCapturedOutput, lt as maskDeep, m as IMPLICIT_DELETE_DEPENDENCIES, mi as ResourceTimeoutError, mn as carriesSecretMask, mr as getDockerCmd, mt as isInterruptedWaitError, n as DEFAULT_RESOURCE_WARN_AFTER_MS, nn as DiffCalculator, nr as parseBootstrapMarker, nt as getCurrentResourceSecrets, o as planFailedOps, oi as DynamicReferenceRegionAmbiguousError, on as describeTypeWithThrottleRetry, or as buildDenyExternalAccessPolicy, ot as ProviderRegistry, p as maskingRetryLogger, pi as ProvisioningError, pn as TEMPLATE_SOURCED_RULES, pr as formatDockerLoginError, pt as interruptWatchListenerCount, q as red, qn as createAssetRedirectResolver, qr as processStackMessages, qt as classifyReplaySecretRegion, r as DeployEngine, ri as CrossAccountSecretRefusalError, rn as INTRINSIC_KEYS, rr as readBootstrapMarkerBody, rt as IAMRoleProvider, s as planRollback, si as IntrinsicResolutionRefusalError, sn as withRetry, sr as describeAwsFailure, st as wouldReturnToSdkProvider, t as DEFAULT_RESOURCE_TIMEOUT_MS, ti as CdkdError, tn as applyRoleArnIfSet, tr as isCrossRegionRedirect, tt as secretSafeKeyDisplay, u as updatePartialMessage, un as STATE_SOURCED_BASELINE_RULES, ur as describeDockerExecFailure, ut as maskerOrIdentity, v as keptWholeReasonText, vn as identityKeyFor, vr as runDockerStreaming, vt as UNSPECIFIED_SKIP_REASON, w as ccRoutedFinalSnapshotError, wi as isMarkedNonRetryable, wn as recoverMaskedOutput, wr as synthesisStatusMessage, wt as carriesDynamicReference, x as ATOMIC_FINAL_SNAPSHOT_TYPES, xn as maskSecretsInError, xr as AssetManifestLoader, xt as disableInstanceApiTermination, y as mergeNoChangeOutputs, yn as isSingleDynamicReferenceToken, yr as escapeRegExp, yt as deleteIndeterminateGuards, z as isWarmThroughputDecrease, zn as importableOutputs, zr as findLargeInlineResources, zt as configBooleanRefusal } from "./deploy-engine-B3yP0pHa.js";
2
+ import { C as truncateCodePoints, S as displaySafe, _ as withSkipPrefix, b as UNRENDERABLE, c as PATTERN_B_NAME_OPTIONS, f as explicitNamePropertyFor, h as getCurrentStackName, i as reserveStdoutForPayload, l as PATTERN_B_NAME_PROPERTIES, m as generateResourceNameWithFallback, n as getLogger, o as runStackBuffered, p as generateResourceName, s as getLiveRenderer, u as PATTERN_B_RESOURCE_TYPES, v as withStackName, x as displayIdent, y as STACK_REF_MAX_CODE_POINTS } from "./logger-H8n5Qjhq.js";
3
3
  import { t as awsClientDefaults } from "./aws-client-defaults-D-iYiIJ6.js";
4
- import { t as getCdkdVersion } from "./version-XcHj8rQZ.js";
5
- import { a as renderDisableCommand, i as protectedReplacementAdvice, r as UNNAMEABLE_ID_CLAUSE, t as ASGProvider } from "./asg-provider-JRuQeFtl.js";
4
+ import { t as getCdkdVersion } from "./version-wXpog0Ci.js";
5
+ import { a as renderDisableCommand, i as protectedReplacementAdvice, r as UNNAMEABLE_ID_CLAUSE, t as ASGProvider } from "./asg-provider-BTsQjH_E.js";
6
6
  import { AsyncLocalStorage } from "node:async_hooks";
7
7
  import { createHash, randomBytes, randomUUID } from "node:crypto";
8
8
  import { CopyObjectCommand, CreateBucketCommand, DeleteBucketAnalyticsConfigurationCommand, DeleteBucketCommand, DeleteBucketCorsCommand, DeleteBucketEncryptionCommand, DeleteBucketIntelligentTieringConfigurationCommand, DeleteBucketInventoryConfigurationCommand, DeleteBucketLifecycleCommand, DeleteBucketMetricsConfigurationCommand, DeleteBucketOwnershipControlsCommand, DeleteBucketPolicyCommand, DeleteBucketReplicationCommand, DeleteBucketTaggingCommand, DeleteBucketWebsiteCommand, DeleteObjectsCommand, GetBucketAccelerateConfigurationCommand, GetBucketCorsCommand, GetBucketEncryptionCommand, GetBucketLifecycleConfigurationCommand, GetBucketLocationCommand, GetBucketLoggingCommand, GetBucketNotificationConfigurationCommand, GetBucketOwnershipControlsCommand, GetBucketPolicyCommand, GetBucketReplicationCommand, GetBucketTaggingCommand, GetBucketVersioningCommand, GetBucketWebsiteCommand, GetObjectCommand, GetObjectLockConfigurationCommand, GetPublicAccessBlockCommand, HeadBucketCommand, ListBucketAnalyticsConfigurationsCommand, ListBucketIntelligentTieringConfigurationsCommand, ListBucketInventoryConfigurationsCommand, ListBucketMetricsConfigurationsCommand, ListObjectVersionsCommand, ListObjectsV2Command, NoSuchBucket, NotFound, PutBucketAccelerateConfigurationCommand, PutBucketAnalyticsConfigurationCommand, PutBucketCorsCommand, PutBucketEncryptionCommand, PutBucketIntelligentTieringConfigurationCommand, PutBucketInventoryConfigurationCommand, PutBucketLifecycleConfigurationCommand, PutBucketLoggingCommand, PutBucketMetricsConfigurationCommand, PutBucketNotificationConfigurationCommand, PutBucketOwnershipControlsCommand, PutBucketPolicyCommand, PutBucketReplicationCommand, PutBucketTaggingCommand, PutBucketVersioningCommand, PutBucketWebsiteCommand, PutObjectCommand, PutObjectLockConfigurationCommand, PutPublicAccessBlockCommand, S3Client, S3ServiceException } from "@aws-sdk/client-s3";
@@ -64114,6 +64114,40 @@ function containsSecretDynamicReference(value) {
64114
64114
  function templateHasSecretDynamicReference(template) {
64115
64115
  return containsSecretDynamicReference(template);
64116
64116
  }
64117
+ /**
64118
+ * Can a condition verdict reach an output's VALUE in this template?
64119
+ *
64120
+ * The no-change merge preview (issue #3101) must not carry a failed output a
64121
+ * condition verdict could change, because `cdkd diff` evaluates `Conditions`
64122
+ * best-effort and its verdict can differ from the deploy's. An output never
64123
+ * reads a resource's template properties (a `Ref` or `Fn::GetAtt` reads the
64124
+ * stored record), so a verdict reaches its value only through the output's own
64125
+ * `Condition`, or an `Fn::If` / `{ Condition: ... }` outside `Resources`: in an
64126
+ * output, or in a mapping `Fn::FindInMap` returns. `Conditions` is skipped as
64127
+ * well, since a reference there composes a verdict and reaches an output only
64128
+ * through one of those. A verdict that prunes or keeps a resource differently
64129
+ * changes the resource set instead; where that leaves the diff seeing no change
64130
+ * the deploy sees, the warning naming every carried output already says the
64131
+ * next deploy may write a different value for it.
64132
+ *
64133
+ * Not "the template declares `Conditions`": CDK attaches `CDKMetadataAvailable`
64134
+ * to its `AWS::CDK::Metadata` resource in every env-agnostic stack, and that
64135
+ * test refused the preview for the default app shape.
64136
+ */
64137
+ function templateLetsConditionsReachOutputs(template) {
64138
+ for (const output of Object.values(template.Outputs ?? {})) if (output.Condition !== void 0) return true;
64139
+ return Object.entries(template).some(([section, value]) => section !== "Resources" && section !== "Conditions" && readsConditionVerdict(value));
64140
+ }
64141
+ /** An `Fn::If`, or a `{ Condition: ... }` reference, anywhere under `value`. */
64142
+ function readsConditionVerdict(value) {
64143
+ if (value === null || typeof value !== "object") return false;
64144
+ if (Array.isArray(value)) return value.some(readsConditionVerdict);
64145
+ const record = value;
64146
+ if (Object.prototype.hasOwnProperty.call(record, "Fn::If")) return true;
64147
+ const keys = Object.keys(record);
64148
+ if (keys.length === 1 && keys[0] === "Condition") return true;
64149
+ return Object.values(record).some(readsConditionVerdict);
64150
+ }
64117
64151
  /** Structural equality, mirroring the deploy engine's `outputMapsEqual` leaf rule. */
64118
64152
  function valuesEqual(a, b) {
64119
64153
  if (a === b) return true;
@@ -64193,6 +64227,8 @@ async function resolveTemplateOutputs(template, resolveFn, conditions, storedOut
64193
64227
  const outputs = Object.create(null);
64194
64228
  const exportNames = /* @__PURE__ */ new Set();
64195
64229
  const failedKeys = /* @__PURE__ */ new Set();
64230
+ const failedOutputKeys = /* @__PURE__ */ new Set();
64231
+ let failuresMirrorDeploy = true;
64196
64232
  const secretSourceKeys = /* @__PURE__ */ new Set();
64197
64233
  const declaredKeys = /* @__PURE__ */ new Set();
64198
64234
  const templateHasSecretReference = containsSecretDynamicReference(template);
@@ -64202,6 +64238,8 @@ async function resolveTemplateOutputs(template, resolveFn, conditions, storedOut
64202
64238
  outputs,
64203
64239
  exportNames,
64204
64240
  failedKeys,
64241
+ failedOutputKeys,
64242
+ failuresMirrorDeploy,
64205
64243
  secretSourceKeys,
64206
64244
  declaredKeys,
64207
64245
  templateHasSecretReference,
@@ -64226,7 +64264,7 @@ async function resolveTemplateOutputs(template, resolveFn, conditions, storedOut
64226
64264
  }
64227
64265
  for (const [outputKey, output] of Object.entries(template.Outputs)) {
64228
64266
  if (output.Condition !== void 0 && conditions?.[output.Condition] === false) {
64229
- logger.debug(`Skipping output ${outputKey} — condition ${output.Condition} is false`);
64267
+ logger.debug(`Skipping output ${stripControlChars(outputKey)} — condition ${stripControlChars(String(output.Condition))} is false`);
64230
64268
  continue;
64231
64269
  }
64232
64270
  if (skippedOutputKeys?.has(outputKey) && !(storedOutputs !== void 0 && Object.prototype.hasOwnProperty.call(storedOutputs, outputKey))) {
@@ -64238,15 +64276,18 @@ async function resolveTemplateOutputs(template, resolveFn, conditions, storedOut
64238
64276
  try {
64239
64277
  value = await resolveFn(structuredClone(output.Value));
64240
64278
  } catch (error) {
64241
- logger.debug(`Diff could not resolve output ${outputKey}: ${String(error)}`);
64279
+ logger.debug(`Diff could not resolve output ${stripControlChars(outputKey)}: ${stripControlChars(String(error))}`);
64242
64280
  resolutionFailed = true;
64243
64281
  recordFailure(outputKey, output);
64282
+ failedOutputKeys.add(outputKey);
64244
64283
  continue;
64245
64284
  }
64246
64285
  if (isUnresolvedValue(value, sourceUsedSub)) {
64247
- logger.debug(`Diff left output ${outputKey} unresolved (references a pending resource)`);
64286
+ logger.debug(`Diff left output ${stripControlChars(outputKey)} unresolved (references a pending resource)`);
64248
64287
  resolutionFailed = true;
64249
64288
  recordFailure(outputKey, output);
64289
+ if (value === void 0) failedOutputKeys.add(outputKey);
64290
+ else failuresMirrorDeploy = false;
64250
64291
  continue;
64251
64292
  }
64252
64293
  outputs[outputKey] = value;
@@ -64257,8 +64298,9 @@ async function resolveTemplateOutputs(template, resolveFn, conditions, storedOut
64257
64298
  if (typeof exportName !== "string") try {
64258
64299
  exportName = await resolveFn(structuredClone(exportName));
64259
64300
  } catch (error) {
64260
- logger.debug(`Diff could not resolve Export.Name of ${outputKey}: ${String(error)}`);
64301
+ logger.debug(`Diff could not resolve Export.Name of ${stripControlChars(outputKey)}: ${stripControlChars(String(error))}`);
64261
64302
  resolutionFailed = true;
64303
+ failuresMirrorDeploy = false;
64262
64304
  continue;
64263
64305
  }
64264
64306
  if (typeof exportName === "string" && !isUnresolvedValue(exportName, exportSourceUsedSub)) {
@@ -64272,18 +64314,24 @@ async function resolveTemplateOutputs(template, resolveFn, conditions, storedOut
64272
64314
  logger.debug(`Diff cannot decide the export alias of ${stripControlChars(outputKey)} — a literal name may contain a resolved secret and state does not hold that key`);
64273
64315
  failedKeys.add(exportName);
64274
64316
  resolutionFailed = true;
64317
+ failuresMirrorDeploy = false;
64275
64318
  }
64276
64319
  } else {
64277
64320
  outputs[exportName] = value;
64278
64321
  exportNames.add(exportName);
64279
64322
  }
64280
- } else resolutionFailed = true;
64323
+ } else {
64324
+ resolutionFailed = true;
64325
+ failuresMirrorDeploy = false;
64326
+ }
64281
64327
  }
64282
64328
  }
64283
64329
  return {
64284
64330
  outputs,
64285
64331
  exportNames,
64286
64332
  failedKeys,
64333
+ failedOutputKeys,
64334
+ failuresMirrorDeploy,
64287
64335
  secretSourceKeys,
64288
64336
  declaredKeys,
64289
64337
  templateHasSecretReference,
@@ -64325,6 +64373,15 @@ async function resolveTemplateOutputs(template, resolveFn, conditions, storedOut
64325
64373
  * Withholding is therefore record-level. The change is still REPORTED — only
64326
64374
  * the value is withheld — so `--fail` and the exports story are unaffected.
64327
64375
  *
64376
+ * A third input forces the same verdict: `forceLegacyRecord`, set by the
64377
+ * no-change merge preview (issue #3101) when a value it carried from state for
64378
+ * a failed output, an export alias included, is not itself a secret expression.
64379
+ * The first signal reads a carried key's desired side, which is then the stored
64380
+ * value, so evidence only its resolved value held is gone; and where a resolved
64381
+ * secret expression can come from (a stored attribute, a parameter, another
64382
+ * stack's outputs) is not enumerable. Nothing short of the carried key's own stored expression can
64383
+ * excuse it — the one value the per-key veto below would have excused.
64384
+ *
64328
64385
  * ## The key today's template cannot account for (issue #1948)
64329
64386
  *
64330
64387
  * Neither signal above covers an output DELETED from the template, and an
@@ -64383,7 +64440,7 @@ async function resolveTemplateOutputs(template, resolveFn, conditions, storedOut
64383
64440
  function computeOutputsDiff(current, desired, exportNames, secretSourceKeys = /* @__PURE__ */ new Set(), unaccountableScan = {}) {
64384
64441
  const changes = [];
64385
64442
  const currentBag = current ?? {};
64386
- const legacyRecord = Object.entries(currentBag).some(([name, oldValue]) => {
64443
+ const legacyRecord = unaccountableScan.forceLegacyRecord === true || Object.entries(currentBag).some(([name, oldValue]) => {
64387
64444
  if (typeof oldValue === "string" && isSecretBearingReferenceString(oldValue)) return false;
64388
64445
  return containsSecretDynamicReference(desired[name]) || secretSourceKeys.has(name);
64389
64446
  });
@@ -65363,8 +65420,28 @@ const STATE_RESOURCES_MALFORMED = "STATE_RESOURCES_MALFORMED";
65363
65420
  * nowhere. The predicate is the plain-object test the bag's own type implies.
65364
65421
  */
65365
65422
  function hasReadableResources(state) {
65366
- const bag = state.resources;
65367
- return typeof bag === "object" && bag !== null && !Array.isArray(bag);
65423
+ return isReadableBag(state.resources);
65424
+ }
65425
+ /**
65426
+ * The plain-object test itself, without the `resources` field bound to it.
65427
+ *
65428
+ * Exported because {@link hasReadableResources} is not the only caller any
65429
+ * more: `cdkd state show` and `cdkd state resources` walk FOUR more record
65430
+ * containers with `Object.entries` — `outputs`, `skippedOutputs`, and each
65431
+ * resource's `attributes` and `properties` — and a non-object in any of them
65432
+ * fabricates rows exactly as the `resources` bag did (go-to-k/cdkd#3187).
65433
+ * Spelling the same test a second time there is what this export exists to
65434
+ * prevent: the two could then drift, and the one that drifted would fabricate
65435
+ * again while looking guarded.
65436
+ *
65437
+ * What it does NOT decide is whether a given container is malformed. That is a
65438
+ * per-container call, because absence means different things: an absent
65439
+ * `resources` bag is a defect ({@link hasReadableResources} says so), while an
65440
+ * absent `skippedOutputs` is the normal pre-#2740 record. Each caller pairs
65441
+ * this predicate with its own absence rule.
65442
+ */
65443
+ function isReadableBag(container) {
65444
+ return typeof container === "object" && container !== null && !Array.isArray(container);
65368
65445
  }
65369
65446
  /**
65370
65447
  * The shared explanation, in the terms the reader needs: what is wrong, what
@@ -65457,6 +65534,65 @@ function repairMalformedResourcesForReadOnly(state) {
65457
65534
  function malformedStateRefusalMessage(stackName, region) {
65458
65535
  return `${malformedStateDetail(stackName, region)} This command can WRITE state, so it refuses rather than continuing: saving over a record whose resource map could not be read would replace the evidence with a well-formed empty one and lose it permanently. Repair or remove the record first.`;
65459
65536
  }
65537
+ /**
65538
+ * The warning a view emits when it emptied one or more {@link
65539
+ * RenderedStateContainer}s it could not walk (go-to-k/cdkd#3187).
65540
+ *
65541
+ * Deliberately NOT {@link malformedResourcesWarning}'s text, and the reason is
65542
+ * narrower than it looks. That one tells the reader not to run `cdkd deploy` /
65543
+ * `cdkd destroy` because an unreadable `resources` MAP is indistinguishable
65544
+ * from an empty stack: deploy would re-CREATE everything and destroy would
65545
+ * delete nothing. No container named here can produce that specific confusion —
65546
+ * the resource SET is still readable — so borrowing that sentence would attach
65547
+ * a re-create-the-world warning to a record whose resource list is intact.
65548
+ *
65549
+ * It is NOT that these containers are display-only. `properties` is read by the
65550
+ * deploy change calculation (`src/analyzer/diff-calculator.ts`), which imports
65551
+ * nothing from this module, and a non-object there compares unequal to any
65552
+ * desired object and yields a spurious property-change set — filed as
65553
+ * go-to-k/cdkd#3191. An earlier revision of this comment asserted the
65554
+ * display-only premise, which would have read as licence to drop a guard on
65555
+ * that path (review of go-to-k/cdkd#3190).
65556
+ *
65557
+ * What stays the same is the remedy: `--json` is the mode that shows the
65558
+ * stored value.
65559
+ *
65560
+ * ONE warning per record however many containers it names — a stack whose 500
65561
+ * resources all carry a hand-edited `properties` gets one line, not 500. The
65562
+ * caller FILTERS a fixed order (`RENDERED_CONTAINER_ORDER`), so the text is
65563
+ * stable across records. Not `.sort()` — that order is deliberately not
65564
+ * alphabetical, and its own JSDoc says so.
65565
+ *
65566
+ * Both identifiers are sanitized and then shell-quoted, and the command is
65567
+ * emitted LAST and UNWRAPPED, for the reasons {@link safeIdentifier}'s own note
65568
+ * gives.
65569
+ *
65570
+ * The container NAMES take {@link safeIdentifier} too — the SAME helper, so
65571
+ * they cannot drift from it — but NOT `shellQuote`, because they appear in the
65572
+ * PROSE and never inside the command this text tells the reader to run; the
65573
+ * command carries the two identifiers and nothing else.
65574
+ *
65575
+ * Sanitizing them is not dead code written for a case that cannot happen. The
65576
+ * union above is closed at COMPILE time, so without this the guarantee would
65577
+ * live in a comment, and the day a caller derives a name from a record instead
65578
+ * of from a literal an unsanitized element could forge a line, render as empty
65579
+ * quotes naming nothing, or run to kilobytes and push the remedy command off
65580
+ * the reader's screen. `safeIdentifier` closes all three and is the identity on
65581
+ * all four literals, so no user-visible text moves.
65582
+ *
65583
+ * What it does NOT close, stated rather than reassured away: a forged name made
65584
+ * only of printable ASCII still renders verbatim inside its quotes and could
65585
+ * read as prose. That residual is bounded to the PROSE — the command the reader
65586
+ * pastes is built from the two shell-quoted identifiers alone — and closing it
65587
+ * would mean JSON-quoting a name in the one place the text is meant to read as
65588
+ * English (review of go-to-k/cdkd#3190).
65589
+ */
65590
+ function malformedRenderedContainersWarning(stackName, region, containers) {
65591
+ const stack = safeIdentifier(stackName);
65592
+ const reg = safeIdentifier(region);
65593
+ const names = containers.map((name) => `'${safeIdentifier(name)}'`).join(", ");
65594
+ return `State for ${shellQuote(stack)} (${shellQuote(reg)}) has a non-object ${names} — the record is malformed or truncated. 'Object.entries' walks a string or a list as readily as a map, so rendering one INVENTS a row per character or element. Continuing with it EMPTY: this view shows no rows there, which is not the same as the record holding none. A per-resource container is named once however many resources hold one. See the stored values with: cdkd state show ${shellQuote(stack)} --stack-region ${shellQuote(reg)} --json`;
65595
+ }
65460
65596
  /** The warning a caller of {@link repairMalformedResourcesForReadOnly} emits. */
65461
65597
  function malformedResourcesWarning(stackName, region) {
65462
65598
  return `${malformedStateDetail(stackName, region)} Continuing with an EMPTY resource set: this command's output describes zero resources, which is not the same as the stack having none.`;
@@ -65720,7 +65856,7 @@ async function computeStackDiff(currentState, template, region, stackName, state
65720
65856
  const tokenParameterNames = new Set(Object.entries(parameters ?? {}).filter(([, value]) => carriesDynamicReference(value)).map(([name]) => name));
65721
65857
  for (const name of tokenParameterNames) {
65722
65858
  const declaredType = (template.Parameters?.[name])?.Type;
65723
- if (typeof declaredType === "string" && parameterTypeMayLoseSecretIdentity(declaredType)) logger.warn(`Stack ${stackName}: parameter '${name}' is declared 'Type: ${declaredType}' and is fed a secret dynamic reference. 'cdkd deploy' refuses this when the coercion actually destroys the plaintext (a comma-bearing secret in a list-typed parameter) — declare it 'Type: String' — and this diff compares the unresolved reference, shaped by the declared type only where that keeps every part of it a string.`);
65859
+ if (typeof declaredType === "string" && parameterTypeMayLoseSecretIdentity(declaredType)) logger.warn(`Stack ${stripControlChars(stackName)}: parameter '${stripControlChars(name)}' is declared 'Type: ${stripControlChars(declaredType)}' and is fed a secret dynamic reference. 'cdkd deploy' refuses this when the coercion actually destroys the plaintext (a comma-bearing secret in a list-typed parameter) — declare it 'Type: String' — and this diff compares the unresolved reference, shaped by the declared type only where that keeps every part of it a string.`);
65724
65860
  }
65725
65861
  try {
65726
65862
  const userParameters = {};
@@ -65784,16 +65920,36 @@ async function computeStackDiff(currentState, template, region, stackName, state
65784
65920
  const changedLogicalIds = new Set([...changes.values()].filter((c) => c.changeType !== "NO_CHANGE").map((c) => c.logicalId));
65785
65921
  const bindingSkipped = bindingSkippedOutputs(outputsDigestSource, currentState.skippedOutputs, changedLogicalIds);
65786
65922
  const resolved = await resolveTemplateOutputs(effectiveTemplate, resolveFn, conditions, currentState.outputs, bindingSkipped);
65787
- let outputChanges = [];
65788
- if (resolved.resolutionFailed) {
65789
- if (computeOutputsDiff(currentState.outputs, resolved.outputs, resolved.exportNames, resolved.secretSourceKeys, {
65790
- declaredKeys: resolved.declaredKeys,
65791
- templateHasSecretReference: resolved.templateHasSecretReference || inheritSecretBearingTemplate === true
65792
- }).filter((change) => !resolved.failedKeys.has(change.name)).length > 0) logger.warn(`Outputs of stack ${stackName} may have changed, but one or more could not be resolved against current state — omitting the Outputs section from this diff. It is usually an output referencing a resource this deploy has yet to create.`);
65793
- } else outputChanges = computeOutputsDiff(currentState.outputs, resolved.outputs, resolved.exportNames, resolved.secretSourceKeys, {
65923
+ const templateHasSecretReference = resolved.templateHasSecretReference || inheritSecretBearingTemplate === true;
65924
+ const diffOutputsAgainst = (desired, exportNames, forceLegacyRecord = false) => computeOutputsDiff(currentState.outputs, desired, exportNames, resolved.secretSourceKeys, {
65794
65925
  declaredKeys: resolved.declaredKeys,
65795
- templateHasSecretReference: resolved.templateHasSecretReference || inheritSecretBearingTemplate === true
65926
+ templateHasSecretReference,
65927
+ forceLegacyRecord
65796
65928
  });
65929
+ let outputChanges = [];
65930
+ if (!resolved.resolutionFailed) outputChanges = diffOutputsAgainst(resolved.outputs, resolved.exportNames);
65931
+ else {
65932
+ const resourcesChange = diffCalculator.hasChanges(changes);
65933
+ const merge = resolved.failuresMirrorDeploy && conditions !== void 0 && !templateLetsConditionsReachOutputs(template) && !resourcesChange ? mergeNoChangeOutputs({
65934
+ persisted: currentState.outputs ?? {},
65935
+ resolved: withFailedOutputsUndefined(resolved.outputs, resolved.failedOutputKeys),
65936
+ declaredOutputs: effectiveTemplate.Outputs,
65937
+ previousExportNames: new Set(importableOutputKeys(currentState)),
65938
+ resolvedExportNames: [...resolved.exportNames]
65939
+ }) : void 0;
65940
+ if (merge?.kind === "merged") {
65941
+ const storedOutputs = currentState.outputs ?? {};
65942
+ const withheld = merge.carriedKeys.some((key) => !isSecretBearingReferenceString(storedOutputs[key]));
65943
+ outputChanges = diffOutputsAgainst(merge.outputs, new Set(merge.exportNames), withheld);
65944
+ const failed = [...resolved.failedOutputKeys];
65945
+ const isStored = (key) => Object.prototype.hasOwnProperty.call(storedOutputs, key);
65946
+ const compared = failed.filter(isStored);
65947
+ const unstored = failed.filter((key) => !isStored(key));
65948
+ const names = (keys) => keys.map(stripControlChars).join(", ");
65949
+ const explainWithheld = withheld && outputChanges.some((change) => change.oldValueRedacted === true);
65950
+ logger.warn(`Outputs of stack ${stripControlChars(stackName)}: ${failed.length} output(s) could not be resolved for this diff.` + (compared.length > 0 ? ` Compared at their stored values: ${names(compared)}.` : "") + (unstored.length > 0 ? ` No stored value under their own names: ${names(unstored)}.` : "") + ` The next deploy may write a value for any of them it can resolve.` + (explainWithheld ? ` Previous values in this Outputs section are withheld because a value carried from state for a failed output is not a secret reference, so this diff cannot rule out legacy plaintext in state. That reason no longer applies once every failed output resolves, though other legacy-plaintext checks still can withhold them.` : ""));
65951
+ } else if (diffOutputsAgainst(resolved.outputs, resolved.exportNames).filter((change) => !resolved.failedKeys.has(change.name)).length > 0) logger.warn(`Outputs of stack ${stripControlChars(stackName)} may have changed, but one or more could not be resolved against current state — omitting the Outputs section from this diff. ` + (merge?.kind === "kept" ? keptWholeReasonText(merge.reason) : resourcesChange ? `It is usually an output referencing a resource this deploy has yet to create.` : `The stack has no resource change; this diff cannot preview what the deploy does with the outputs that failed here.`));
65952
+ }
65797
65953
  return {
65798
65954
  changes,
65799
65955
  outputChanges,
@@ -65803,6 +65959,21 @@ async function computeStackDiff(currentState, template, region, stackName, state
65803
65959
  };
65804
65960
  }
65805
65961
  /**
65962
+ * The preview's bag in the shape `mergeNoChangeOutputs` reads from the deploy
65963
+ * engine: each failed OUTPUT present with the value `undefined`
65964
+ * (issue #3101). This resolver drops a failed key while the deploy keeps it,
65965
+ * and the merge tells "failed, keep the stored value" from "not produced,
65966
+ * remove it" by exactly that presence. A fresh null-prototype bag, like both
65967
+ * bags it stands between, so a resolved `__proto__` export alias survives the
65968
+ * copy as a data key.
65969
+ */
65970
+ function withFailedOutputsUndefined(outputs, failedOutputKeys) {
65971
+ const bag = Object.create(null);
65972
+ for (const [key, value] of Object.entries(outputs)) bag[key] = value;
65973
+ for (const key of failedOutputKeys) bag[key] = void 0;
65974
+ return bag;
65975
+ }
65976
+ /**
65806
65977
  * Resolve a nested-stack child's input `Parameters` (declared on the parent's
65807
65978
  * `AWS::CloudFormation::Stack` row under `Properties.Parameters`) to scalar
65808
65979
  * values against the PARENT's deployed state + already-resolved parameters.
@@ -66312,7 +66483,7 @@ function renderOutputChangeLines(outputChanges, logFn) {
66312
66483
  */
66313
66484
  function renderDiffTree(node, isRoot, logFn) {
66314
66485
  const hasChanges = nodeHasChanges(node);
66315
- if (hasChanges || node.blocking.length > 0) logFn(isRoot ? `\nStack ${node.stackName}:` : `\nNested stack: ${node.displayName}`);
66486
+ if (hasChanges || node.blocking.length > 0) logFn(isRoot ? `\nStack ${stripControlChars(node.stackName)}:` : `\nNested stack: ${stripControlChars(node.displayName)}`);
66316
66487
  if (hasChanges) {
66317
66488
  const { create, update, delete: del } = renderChangeLines(node.changes, logFn, node.ccApiRoutes, node.adoptedOrphans);
66318
66489
  const outputs = renderOutputChangeLines(node.outputChanges, logFn);
@@ -66877,6 +67048,7 @@ async function scrubCommand(stacks, options) {
66877
67048
  let totalStacksScrubbed = 0;
66878
67049
  let totalStacksWithUnscrubbableKeys = 0;
66879
67050
  let totalStacksWithUnverifiableReads = 0;
67051
+ let totalStacksWithUnverifiableLeaves = 0;
66880
67052
  /**
66881
67053
  * Stacks this `--dry-run` proceeded over with an UNREADABLE resources map
66882
67054
  * (issue go-to-k/cdkd#3018). Tracked exactly like `indexUnreadable`: an
@@ -66958,8 +67130,12 @@ async function scrubCommand(stacks, options) {
66958
67130
  if (scrubbed.recordsChanged > 0) {
66959
67131
  totalStacksScrubbed++;
66960
67132
  logger.info(`${options.dryRun ? "Would scrub" : "Scrubbed"} ${scrubbed.recordsChanged} resource record(s) in ${stack.stackName}`);
66961
- } else if (scrubbed.secretBearingKeys === 0 && scrubbed.unverifiableReads === 0 && indexConverged === 0) logger.info(`No plaintext secrets found in ${stack.stackName}`);
67133
+ } else if (scrubbed.secretBearingKeys === 0 && scrubbed.unverifiableReads === 0 && scrubbed.unverifiableLeaves === 0 && indexConverged === 0) logger.info(`No plaintext secrets found in ${stack.stackName}`);
66962
67134
  if (scrubbed.malformedResources) malformedRecords.push(stack.stackName);
67135
+ if (scrubbed.unverifiableLeaves > 0) {
67136
+ totalStacksWithUnverifiableLeaves++;
67137
+ logger.warn(`${scrubbed.unverifiableLeaves} scan(s) in ${stack.stackName} were ABANDONED mid-value because a {{resolve:...}} reference could not be resolved (see the warnings above). The resolver stops at the first failing token, so a real secret after it in the same value recorded no needle — this stack is not reported clean. The count is per abandoned SCAN, not per secret: one record can contribute more than once (an output whose Export.Name and Value both abandon), and one abandoned scan can hide several unexamined leaves.`);
67138
+ }
66963
67139
  if (scrubbed.unverifiableReads > 0) {
66964
67140
  totalStacksWithUnverifiableReads++;
66965
67141
  logger.warn(`${scrubbed.unverifiableReads} cross-stack read(s) in ${stack.stackName} could NOT be verified — cdkd declines them by design (see the warnings above), so this stack is not reported clean.`);
@@ -66990,7 +67166,7 @@ async function scrubCommand(stacks, options) {
66990
67166
  totalIndexEntriesUnexamined += unexamined.length;
66991
67167
  logger.info(`Exports index (${indexRegion}): ${unexamined.length} entr${unexamined.length === 1 ? "y is" : "ies are"} published by a producer this run did not scrub, so nothing here read ${unexamined.length === 1 ? "its" : "their"} value. Coverage composes across the apps sharing this bucket and region — run each app's own cdkd scrub to reach its entries.`);
66992
67168
  }
66993
- if (totalStacksScrubbed === 0 && totalStacksWithUnscrubbableKeys === 0 && totalStacksWithUnverifiableReads === 0 && totalIndexEntriesConverged === 0 && indexUnwritten.length === 0 && indexUnreadable.length === 0 && malformedRecords.length === 0) {
67169
+ if (totalStacksScrubbed === 0 && totalStacksWithUnscrubbableKeys === 0 && totalStacksWithUnverifiableReads === 0 && totalStacksWithUnverifiableLeaves === 0 && totalIndexEntriesConverged === 0 && indexUnwritten.length === 0 && indexUnreadable.length === 0 && malformedRecords.length === 0) {
66994
67170
  if (failures.length === 0) {
66995
67171
  logger.info("\nNo plaintext secrets found in any target stack state. Nothing to scrub.");
66996
67172
  return;
@@ -67000,6 +67176,7 @@ async function scrubCommand(stacks, options) {
67000
67176
  }
67001
67177
  const failureNote = failures.length > 0 ? ` ${failures.length} stack(s) could NOT be scrubbed at all and are NOT covered by this summary — see the errors above.` : "";
67002
67178
  const keyNote = totalStacksWithUnscrubbableKeys > 0 ? ` ${totalStacksWithUnscrubbableKeys} stack(s) hold plaintext in an output KEY, which cdkd scrub cannot rewrite — rename that output's Export.Name and redeploy.` : "";
67179
+ const leafNote = totalStacksWithUnverifiableLeaves > 0 ? ` ${totalStacksWithUnverifiableLeaves} stack(s) had a {{resolve:...}} scan ABANDONED mid-value, so part of their state was never examined (see the warnings above).` : "";
67003
67180
  const unverifiableNote = totalStacksWithUnverifiableReads > 0 ? ` ${totalStacksWithUnverifiableReads} stack(s) carry a cross-stack read cdkd declines to perform, so their imported values could NOT be checked — export a non-secret value (e.g. the secret's ARN) from the producer, or reference it from within its own account.` : "";
67004
67181
  const indexNote = (() => {
67005
67182
  const parts = [];
@@ -67009,20 +67186,20 @@ async function scrubCommand(stacks, options) {
67009
67186
  return parts.join("");
67010
67187
  })();
67011
67188
  if (options.dryRun) {
67012
- if (totalStacksScrubbed > 0) logger.info(`\nPlan: ${totalStacksScrubbed} stack(s) hold plaintext secrets and would be scrubbed (--dry-run, no state written).${keyNote}${unverifiableNote}${indexNote}${failureNote} ROTATE any exposed secret in Secrets Manager.`);
67013
- else logger.info(`\nPlan: no state record can be rewritten.${keyNote}${unverifiableNote}${indexNote}${failureNote} ROTATE any exposed secret.`);
67189
+ if (totalStacksScrubbed > 0) logger.info(`\nPlan: ${totalStacksScrubbed} stack(s) hold plaintext secrets and would be scrubbed (--dry-run, no state written).${keyNote}${unverifiableNote}${leafNote}${indexNote}${failureNote} ROTATE any exposed secret in Secrets Manager.`);
67190
+ else logger.info(`\nPlan: no state record can be rewritten.${keyNote}${unverifiableNote}${leafNote}${indexNote}${failureNote} ROTATE any exposed secret.`);
67014
67191
  if (failures.length > 0) throw scrubStacksFailedError(failures);
67015
67192
  if (indexUnwritten.length > 0 || indexUnreadable.length > 0) throw exportIndexIncompleteError(indexUnwritten, indexUnreadable, true);
67016
67193
  if (malformedRecords.length > 0) throw malformedRecordsAuditedError(malformedRecords);
67017
67194
  if (options.fail) throw new ScrubNeededError();
67018
67195
  return;
67019
67196
  }
67020
- if (totalStacksScrubbed > 0) logger.info(`\nDone: scrubbed ${totalStacksScrubbed} stack(s). The CURRENT state.json no longer holds the plaintext. Where the state bucket is VERSIONED — which cdkd bootstrap enables — the pre-scrub body survives as a noncurrent version, stays readable with GetObject and a VersionId, and scrub does not purge it. So a value that was ever persisted must be treated as compromised — ROTATE it in Secrets Manager (scrub matches the current value, so scrub BEFORE rotating); rotation is what makes any surviving version harmless.${keyNote}${unverifiableNote}${indexNote}${failureNote}`);
67021
- else logger.info(`\nNo state record was rewritten.${keyNote}${unverifiableNote}${indexNote}${failureNote} ROTATE any exposed secret.`);
67197
+ if (totalStacksScrubbed > 0) logger.info(`\nDone: scrubbed ${totalStacksScrubbed} stack(s). The CURRENT state.json no longer holds the plaintext. Where the state bucket is VERSIONED — which cdkd bootstrap enables — the pre-scrub body survives as a noncurrent version, stays readable with GetObject and a VersionId, and scrub does not purge it. So a value that was ever persisted must be treated as compromised — ROTATE it in Secrets Manager (scrub matches the current value, so scrub BEFORE rotating); rotation is what makes any surviving version harmless.${keyNote}${unverifiableNote}${leafNote}${indexNote}${failureNote}`);
67198
+ else logger.info(`\nNo state record was rewritten.${keyNote}${unverifiableNote}${leafNote}${indexNote}${failureNote} ROTATE any exposed secret.`);
67022
67199
  if (failures.length > 0) throw scrubStacksFailedError(failures);
67023
67200
  if (indexUnwritten.length > 0 || indexUnreadable.length > 0) throw exportIndexIncompleteError(indexUnwritten, indexUnreadable, false);
67024
67201
  if (malformedRecords.length > 0) throw malformedRecordsAuditedError(malformedRecords);
67025
- if (options.fail && (totalStacksWithUnscrubbableKeys > 0 || totalStacksWithUnverifiableReads > 0)) throw new ScrubNeededError();
67202
+ if (options.fail && (totalStacksWithUnscrubbableKeys > 0 || totalStacksWithUnverifiableReads > 0 || totalStacksWithUnverifiableLeaves > 0)) throw new ScrubNeededError();
67026
67203
  }
67027
67204
  /**
67028
67205
  * Every secret plaintext this run recorded, from ANY position in the template
@@ -68306,6 +68483,205 @@ function isNamelessDynamicReferenceFailure(err) {
68306
68483
  return err instanceof Error && errorCauseChain(err).some((link) => link instanceof Error && link.message.includes(DYNAMIC_REFERENCE_PREFIX) && NAMELESS_DYNAMIC_REFERENCE_MARKERS.some((marker) => link.message.includes(marker)));
68307
68484
  }
68308
68485
  /**
68486
+ * The per-record line each counting site prints. Deliberately ONE SHORT
68487
+ * SENTENCE, and that is a fix rather than a style choice: the first cut emitted
68488
+ * a ~440-character paragraph PER RECORD at default verbosity, and the arm it
68489
+ * fires on is one a single `Default`-less parameter triggers for every resource
68490
+ * in the stack at once. Dozens of identical paragraphs bury the record names,
68491
+ * which are the only part a reader cannot reconstruct.
68492
+ *
68493
+ * The explanation is emitted ONCE PER STACK instead, by
68494
+ * {@link abandonedScanStackNote}.
68495
+ *
68496
+ * The CAUSE is absent on both arms: the sibling log at each site already prints
68497
+ * it through `maskSecretsInText`, and a resolver error echoes what it was
68498
+ * handed — which, after `pinCrossRegionSecrets`, can be a foreign plaintext.
68499
+ *
68500
+ * RESIDUAL on the `subject` the call sites pass. They compose
68501
+ * `maskSecretsInText(... displaySafe(id) ...)` — sanitise, then mask — and
68502
+ * `displaySafe` REPLACES a control character with a space rather than deleting
68503
+ * it, so it cannot JOIN a split needle (the go-to-k/cdkd#2874 direction) but it
68504
+ * can BREAK one, and it leaves `U+200B`-`U+200D` / `U+FEFF` untouched. A secret
68505
+ * would have to sit inside a template logical id or Outputs key to be reachable,
68506
+ * which is why `secretSafeKeyDisplay` is not used here — that one is for RESOLVED
68507
+ * state-bag keys. Recorded rather than closed: go-to-k/cdkd#3178 security review.
68508
+ */
68509
+ function abandonedScanWarning(subject, verdict) {
68510
+ return verdict === "count" ? `The {{resolve:...}} scan of ${subject} was ABANDONED — this record is NOT certified clean.` : `The {{resolve:...}} scan of ${subject} was cut short by a TEMPLATE problem — this record is NOT certified clean.`;
68511
+ }
68512
+ /**
68513
+ * The one-per-stack explanation behind {@link abandonedScanWarning}'s lines.
68514
+ *
68515
+ * Two arms, because they need opposite things said. The `count` arm is a
68516
+ * FINDING and gates; the reader needs the cause, which is verbose-only. The
68517
+ * `warn` arm does NOT gate, and a reader seeing "NOT certified clean" beside a
68518
+ * green exit has no way to tell a passing run from a broken one unless it says
68519
+ * so.
68520
+ *
68521
+ * The `warn` arm's remedy is deliberately two-sided. The dominant population is
68522
+ * a parameter with no `Default` — legal CloudFormation, no defect to fix — so
68523
+ * telling the reader only to "fix the template reference" describes a repair
68524
+ * that does not exist for them. It names what they CAN do and then says plainly
68525
+ * that scrub may simply not be able to certify the record.
68526
+ */
68527
+ function abandonedScanStackNote(verdict, records) {
68528
+ const subject = `${records} record(s) above`;
68529
+ return verdict === "count" ? `${subject}: the resolver stops at the first {{resolve:...}} token it cannot fetch, so any secret after it in the same value recorded no needle and could not be rewritten. Re-run with --verbose for the cause of each.` : `${subject}: the scan stopped on something cdkd scrub cannot resolve with template defaults alone — an unresolvable Ref or Fn::GetAtt, a parameter with no Default, or a reference whose own argument still holds an unsubstituted \${...}. These do NOT fail --fail, because scrub takes no --parameters and a gate failure here could not be cleared. Give the parameter a Default, or resolve the reference, to let scrub certify these records; otherwise accept that it cannot.`;
68530
+ }
68531
+ /**
68532
+ * The verdict a counting site acts on: `count` (a finding — warn AND raise the
68533
+ * exit code), `warn` (say it, but do not gate) or `silent`.
68534
+ *
68535
+ * The middle arm exists because the EXCLUSION has a cost in
68536
+ * go-to-k/cdkd#3160's OWN harm direction, and two round-4 reviewers found it
68537
+ * independently. `resolver.resolve` walks the whole properties bag and ONE
68538
+ * throw aborts the rest of it — so a bag holding `{A: {Ref: NoSuchThing},
68539
+ * B: '{{resolve:secretsmanager:prod/db:...}}'}` has `B` abandoned by a failure
68540
+ * that is nothing to do with `B`. Excluding it means `B` records no needle, its
68541
+ * legacy plaintext is never rewritten, and the stack can still print
68542
+ * `No plaintext secrets found` at exit 0. Reachable accidentally (one
68543
+ * `Default`-less parameter empties the bag, so every `{Ref: <param>}` throws)
68544
+ * and, on a repo whose CI runs `--dry-run --fail`, defeatable on purpose by
68545
+ * adding one dangling `Ref` ahead of the secret. That loss is REAL and is not
68546
+ * repaired by making it visible — go-to-k/cdkd#3196 tracks scoping the resolve
68547
+ * per property so the sibling reference is still scanned.
68548
+ *
68549
+ * Counting it is not the answer — that is the round-2 blocker, an unclearable
68550
+ * red gate on a healthy stack. What the two failure modes do NOT share is
68551
+ * VISIBILITY: an operator can act on a warning naming the record, and a warning
68552
+ * gates nothing. So the excluded arm still speaks, and only the exit code is
68553
+ * withheld.
68554
+ *
68555
+ * **The ORDER of the three tests is load-bearing, and getting it wrong is how
68556
+ * round 5 nearly shipped a silent regression.** An earlier cut asked
68557
+ * `carriesFetchableDynamicReference` FIRST and returned `silent` on a miss,
68558
+ * which made a gate-downgrade test into a universal silencer. Because
68559
+ * `DYNAMIC_REFERENCE_INNER_CHAR` is `[^}]`, a token with a placeholder
68560
+ * ANYWHERE in it fails that test — including the dominant CDK spelling, where
68561
+ * the reference is assembled by an `Fn::Sub` over parameters that DO have
68562
+ * defaults:
68563
+ *
68564
+ * ```json
68565
+ * {"A": {"Fn::Sub": "{{resolve:ssm-secure:/deleted/${Env}/p}}"},
68566
+ * "B": {"Fn::Sub": "{{resolve:secretsmanager:${Db}:SecretString:password}}"}}
68567
+ * ```
68568
+ *
68569
+ * Those assemble fine, `A` raises a RAW `ParameterNotFound`, `B` is abandoned
68570
+ * and records no needle — and the earlier order printed nothing at all. That is
68571
+ * go-to-k/cdkd#3160's own headline repro, silenced by the fix for a different
68572
+ * false positive.
68573
+ *
68574
+ * So the tests are separated by what each one is EVIDENCE of:
68575
+ *
68576
+ * - `carriesDynamicReference` — did this bag hold a reference at all? If not,
68577
+ * nothing was lost and the pre-existing `logger.debug` is the right level.
68578
+ * This, and only this, decides SILENCE.
68579
+ * - `isTemplateShapeResolutionFailure` / `carriesFetchableDynamicReference` —
68580
+ * is the abandonment one an operator could act on? If not, it still gets said;
68581
+ * it just does not gate. These decide the EXIT CODE, never the visibility.
68582
+ */
68583
+ function abandonedScanVerdict(source, err) {
68584
+ if (!carriesDynamicReference(source)) return "silent";
68585
+ if (isTemplateShapeResolutionFailure(err) || !carriesFetchableDynamicReference(source)) return "warn";
68586
+ return "count";
68587
+ }
68588
+ /**
68589
+ * `source` carries a `{{resolve:...}}` token cdkd could actually have FETCHED —
68590
+ * i.e. one whose argument holds no surviving `${...}` placeholder.
68591
+ *
68592
+ * `carriesDynamicReference` alone is too generous here, and the gap is not
68593
+ * theoretical: scrub resolves with `bestEffort`, under which
68594
+ * `rethrowStructuralSubFailure` returns EARLY, so an `Fn::Sub` over an unbound
68595
+ * placeholder does not throw — it warn-and-KEEPS the literal `${Field}`. The
68596
+ * assembled token is then a request for a JSON key literally named `${Field}`,
68597
+ * which fails as `Dynamic reference: key '${Field}' not found in secret '<id>'`.
68598
+ * That is not a fetch that failed; it is a token that was never fetchable,
68599
+ * because scrub has only template DEFAULTS and accepts no `--parameters`. It is
68600
+ * ALSO not excludable by message, since `key ... not found` is a class
68601
+ * go-to-k/cdkd#3160 asks to COUNT when the key was real. So the discriminator
68602
+ * has to be the TOKEN, not the error.
68603
+ *
68604
+ * `some`, not `every`: a bag holding one placeholder-bearing token and one
68605
+ * fully substituted token still counts, because the second one genuinely was
68606
+ * fetchable and its scan genuinely stopped.
68607
+ *
68608
+ * WHAT THIS DOES AND DOES NOT BUY, since round 5 shipped a cut that confused
68609
+ * the two. A `false` here downgrades the GATE only — the record is still named
68610
+ * at default verbosity by the `warn` arm. It must never decide SILENCE: the
68611
+ * inner class is `[^}]`, so a placeholder ANYWHERE in a token fails this test,
68612
+ * and the reference assembled by an `Fn::Sub` over DEFAULTED parameters is the
68613
+ * dominant CDK spelling rather than an edge case.
68614
+ *
68615
+ * STATED RESIDUALS, both UNDER-counts against the gate, neither a silence:
68616
+ *
68617
+ * - A two-argument `Fn::Sub` whose placeholder IS bound
68618
+ * (`{'Fn::Sub': ['{{resolve:ssm:${N}}}', {N: 'x'}]}`) substitutes fine and
68619
+ * yields a fetchable token, but the raw bag still spells `${`, so a real
68620
+ * fetch failure there warns instead of gating.
68621
+ * - The same for a single-argument `Fn::Sub` over a defaulted parameter, and
68622
+ * for a placeholder mid-token, which yields no token at all.
68623
+ *
68624
+ * And one OVER-count, recorded for symmetry: this predicate is BAG-scoped while
68625
+ * the failure is TOKEN-scoped, so a bag holding a placeholder-bearing token
68626
+ * BESIDE a genuinely fetchable one returns `true`, and a `key '${Field}' not
68627
+ * found` raised for the first is gated as if it came from the second. Safety
68628
+ * is in the right direction (a gate that fires, not a plaintext under `clean`),
68629
+ * and same-bag co-location bounds it.
68630
+ *
68631
+ * go-to-k/cdkd#3181 is the fix that removes the need for any of these proxies.
68632
+ */
68633
+ function carriesFetchableDynamicReference(source) {
68634
+ if (typeof source === "string") return dynamicReferenceTokens(source).some((token) => !token.includes("${"));
68635
+ if (Array.isArray(source)) return source.some(carriesFetchableDynamicReference);
68636
+ if (source !== null && typeof source === "object") return Object.values(source).some(carriesFetchableDynamicReference);
68637
+ return false;
68638
+ }
68639
+ /**
68640
+ * `err` is cdkd's OWN refusal to resolve a template SHAPE — a `Ref`, an
68641
+ * `Fn::GetAtt`, a parameter binding — rather than a failure to fetch a
68642
+ * `{{resolve:...}}` reference.
68643
+ *
68644
+ * Matched on cdkd-authored prose, which is sound here for the reason the
68645
+ * inverse is not: every string below is built by `intrinsic-function-resolver.ts`
68646
+ * itself, so it changes only when this repo changes it, and the fence in
68647
+ * `tests/unit/cli/scrub-abandoned-scan-origin.test.ts` reds when a throw site
68648
+ * there stops matching. An AWS-authored message could never carry that
68649
+ * guarantee.
68650
+ */
68651
+ function isTemplateShapeResolutionFailure(err) {
68652
+ if (!(err instanceof Error)) return false;
68653
+ return TEMPLATE_SHAPE_FAILURE_PATTERNS.some((pattern) => pattern.test(err.message));
68654
+ }
68655
+ /**
68656
+ * The cdkd-authored messages {@link isTemplateShapeResolutionFailure} excludes,
68657
+ * ANCHORED. A substring test cannot be used here and the reason is concrete:
68658
+ * `'Parameter '` alone also matches the SDK's own `Parameter /deleted not
68659
+ * found.` — `ParameterNotFound`, this issue's HEADLINE repro — so the loose
68660
+ * spelling would have excluded the very case the positional half exists to
68661
+ * catch. Each pattern must stay tight enough that no AWS-authored message can
68662
+ * satisfy it.
68663
+ *
68664
+ * Deliberately just these THREE, not every shape failure the resolver can
68665
+ * raise. They are the ones that fire EN MASSE on a healthy stack:
68666
+ * `resolveParameters` is caught wholesale by `scrubStack`, so one
68667
+ * `Default`-less parameter empties the whole bag and every `{Ref: <param>}`
68668
+ * throws; and `resolveGetAtt` refuses on the same condition as `resolveRef`,
68669
+ * which a branch adding a not-yet-deployed resource hits for every
68670
+ * `Fn::GetAtt` to it. A rarer shape failure (an `Fn::Select` over a
68671
+ * non-array, say) is a genuine template defect, and counting a leaf whose
68672
+ * scan it abandoned is not wrong — the scan really did stop. Over-counting
68673
+ * THERE costs a warn on a broken template; over-counting on the three below
68674
+ * would cost a red CI gate on a working one.
68675
+ *
68676
+ * Per-pattern throw sites, and the fence that keeps them true, are in
68677
+ * `tests/unit/cli/scrub-abandoned-scan-origin.test.ts`.
68678
+ */
68679
+ const TEMPLATE_SHAPE_FAILURE_PATTERNS = [
68680
+ /^Ref \S+ not found$/,
68681
+ /^Resource \S+ not found for Fn::GetAtt$/,
68682
+ /^Parameter \S+ is required but no value was provided/
68683
+ ];
68684
+ /**
68309
68685
  * A secrets map with its OWN identity whose entries ARE `target`'s (issue
68310
68686
  * #2531): every read and write goes to `target`; nothing is stored here.
68311
68687
  *
@@ -68736,6 +69112,33 @@ async function scrubStack(stack, region, stateBackend, lockManager, opts) {
68736
69112
  */
68737
69113
  const orphanSecrets = /* @__PURE__ */ new Map();
68738
69114
  const prePassFindings = { unverifiable: [] };
69115
+ /**
69116
+ * Leaves whose dynamic-reference scan was ABANDONED mid-token (issue
69117
+ * go-to-k/cdkd#3160). A counted finding, not a refusal — see
69118
+ * `abandonedScanVerdict` for why these cannot refuse.
69119
+ */
69120
+ let unverifiableLeaves = 0;
69121
+ let ungateableAbandonedScans = 0;
69122
+ /**
69123
+ * ONE note per stack per arm. The per-record lines are deliberately short
69124
+ * (see `abandonedScanWarning`), so this is where the explanation lives, and
69125
+ * emitting it after the loops rather than at the first occurrence is what
69126
+ * lets it say HOW MANY records it is explaining.
69127
+ *
69128
+ * A CLOSURE called at BOTH returns, not a block at the late one. The early
69129
+ * `totalSecrets === 0` return is the DOMINANT path for an abandoned scan --
69130
+ * an abandoned scan records no needle, which is the whole defect -- so a
69131
+ * single late emission left the note unreachable for exactly the case it
69132
+ * exists to explain. `emitted` because the two call sites are exclusive
69133
+ * today and a third would not be.
69134
+ */
69135
+ let notesEmitted = false;
69136
+ const emitAbandonedScanNotes = () => {
69137
+ if (notesEmitted) return;
69138
+ notesEmitted = true;
69139
+ if (unverifiableLeaves > 0) logger.warn(abandonedScanStackNote("count", unverifiableLeaves));
69140
+ if (ungateableAbandonedScans > 0) logger.warn(abandonedScanStackNote("warn", ungateableAbandonedScans));
69141
+ };
68739
69142
  try {
68740
69143
  const loaded = await stateBackend.getState(stack.stackName, region);
68741
69144
  if (!loaded) {
@@ -68745,6 +69148,7 @@ async function scrubStack(stack, region, stateBackend, lockManager, opts) {
68745
69148
  secretsFound: 0,
68746
69149
  secretBearingKeys: 0,
68747
69150
  unverifiableReads: 0,
69151
+ unverifiableLeaves: 0,
68748
69152
  exportNameDisplay: (name) => secretSafeKeyDisplay(name, outputSecrets)
68749
69153
  };
68750
69154
  }
@@ -68818,6 +69222,12 @@ async function scrubStack(stack, region, stateBackend, lockManager, opts) {
68818
69222
  await resolver.resolve(resolveInput, resourceContext);
68819
69223
  } catch (err) {
68820
69224
  if (isRegionAmbiguousRefusal(err) || isNamelessDynamicReferenceFailure(err)) throw err;
69225
+ const leafVerdict = abandonedScanVerdict(resolveInput, err);
69226
+ if (leafVerdict !== "silent") {
69227
+ if (leafVerdict === "count") unverifiableLeaves++;
69228
+ else ungateableAbandonedScans++;
69229
+ logger.warn(maskSecretsInText(abandonedScanWarning(`resource '${displaySafe(logicalId)}'`, leafVerdict), recordedSecretValues));
69230
+ }
68821
69231
  logger.debug(`Resolution of ${logicalId} during scrub was partial: ${maskSecretsInText(err instanceof Error ? err.message : String(err), recordedSecretValues)}`);
68822
69232
  }
68823
69233
  perResourceTemplateProps.set(logicalId, templateResource.Properties);
@@ -68840,6 +69250,12 @@ async function scrubStack(stack, region, stateBackend, lockManager, opts) {
68840
69250
  await resolver.resolve(resolveInput, resolverContext(recordedSecretValues));
68841
69251
  } catch (err) {
68842
69252
  if (isRegionAmbiguousRefusal(err) || isNamelessDynamicReferenceFailure(err)) throw err;
69253
+ const leafVerdict = abandonedScanVerdict(resolveInput, err);
69254
+ if (leafVerdict !== "silent") {
69255
+ if (leafVerdict === "count") unverifiableLeaves++;
69256
+ else ungateableAbandonedScans++;
69257
+ logger.warn(maskSecretsInText(abandonedScanWarning(`orphan record '${displaySafe(record.logicalId)}'`, leafVerdict), recordedSecretValues));
69258
+ }
68843
69259
  logger.debug(`Resolution of orphan record ${record.logicalId} during scrub was partial: ${maskSecretsInText(err instanceof Error ? err.message : String(err), recordedSecretValues)}`);
68844
69260
  }
68845
69261
  }
@@ -68863,6 +69279,12 @@ async function scrubStack(stack, region, stateBackend, lockManager, opts) {
68863
69279
  exportName = await resolver.resolve(nameSource, nameContext);
68864
69280
  } catch (err) {
68865
69281
  if (isRegionAmbiguousRefusal(err) || isNamelessDynamicReferenceFailure(err)) throw err;
69282
+ const leafVerdict = abandonedScanVerdict(nameSource, err);
69283
+ if (leafVerdict !== "silent") {
69284
+ if (leafVerdict === "count") unverifiableLeaves++;
69285
+ else ungateableAbandonedScans++;
69286
+ logger.warn(maskSecretsInText(abandonedScanWarning(`the Export.Name of output '${displaySafe(name)}'`, leafVerdict), outputSecrets));
69287
+ }
68866
69288
  nameFailed = true;
68867
69289
  nameError = err;
68868
69290
  }
@@ -68901,6 +69323,12 @@ async function scrubStack(stack, region, stateBackend, lockManager, opts) {
68901
69323
  await resolver.resolve(valueSource, valueContext);
68902
69324
  } catch (err) {
68903
69325
  if (isRegionAmbiguousRefusal(err) || isNamelessDynamicReferenceFailure(err)) throw err;
69326
+ const leafVerdict = abandonedScanVerdict(valueSource, err);
69327
+ if (leafVerdict !== "silent") {
69328
+ if (leafVerdict === "count") unverifiableLeaves++;
69329
+ else ungateableAbandonedScans++;
69330
+ logger.warn(maskSecretsInText(abandonedScanWarning(`output '${displaySafe(name)}'`, leafVerdict), outputSecrets));
69331
+ }
68904
69332
  logger.debug(`Resolution of output ${name} during scrub was partial: ${maskSecretsInText(err instanceof Error ? err.message : String(err), outputSecrets)}`);
68905
69333
  }
68906
69334
  }
@@ -68914,15 +69342,19 @@ async function scrubStack(stack, region, stateBackend, lockManager, opts) {
68914
69342
  logger.warn(secretBearingStateKeyWarning(stack.stackName, display));
68915
69343
  }
68916
69344
  const totalSecrets = outputSecrets.size + [...perResourceSecrets.values()].reduce((n, m) => n + m.size, 0) + [...orphanSecrets.values()].reduce((n, m) => n + m.size, 0);
68917
- if (totalSecrets === 0) return {
68918
- recordsChanged: 0,
68919
- secretsFound: 0,
68920
- secretBearingKeys: secretBearingKeys.length,
68921
- unverifiableReads: prePassFindings.unverifiable.length,
68922
- ...malformedResources ? { malformedResources } : {},
68923
- outputs: state.outputs,
68924
- exportNameDisplay: (name) => secretSafeKeyDisplay(name, outputSecrets)
68925
- };
69345
+ if (totalSecrets === 0) {
69346
+ emitAbandonedScanNotes();
69347
+ return {
69348
+ recordsChanged: 0,
69349
+ secretsFound: 0,
69350
+ secretBearingKeys: secretBearingKeys.length,
69351
+ unverifiableReads: prePassFindings.unverifiable.length,
69352
+ unverifiableLeaves,
69353
+ ...malformedResources ? { malformedResources } : {},
69354
+ outputs: state.outputs,
69355
+ exportNameDisplay: (name) => secretSafeKeyDisplay(name, outputSecrets)
69356
+ };
69357
+ }
68926
69358
  let recordsChanged = 0;
68927
69359
  const newResources = {};
68928
69360
  for (const [logicalId, record] of Object.entries(state.resources ?? {})) {
@@ -68951,6 +69383,7 @@ async function scrubStack(stack, region, stateBackend, lockManager, opts) {
68951
69383
  };
68952
69384
  });
68953
69385
  const newOutputs = redactUnaccountedOutputs(outputSecrets.size > 0 ? redactSecretsForState(state.outputs, outputSecrets, outputsSourceUntrusted ? void 0 : outputsTemplateSource, TEMPLATE_SOURCED_RULES) : state.outputs, state.outputs, accountedOutputKeys, allRecordedSecrets(outputSecrets, perResourceSecrets, orphanSecrets));
69386
+ emitAbandonedScanNotes();
68954
69387
  if (JSON.stringify(newOutputs) !== JSON.stringify(state.outputs)) recordsChanged++;
68955
69388
  if (recordsChanged > 0 && !opts.dryRun) {
68956
69389
  const { skippedOutputs: _droppedByScrub, ...carriedState } = state;
@@ -68968,6 +69401,7 @@ async function scrubStack(stack, region, stateBackend, lockManager, opts) {
68968
69401
  secretsFound: totalSecrets,
68969
69402
  secretBearingKeys: secretBearingKeys.length,
68970
69403
  unverifiableReads: prePassFindings.unverifiable.length,
69404
+ unverifiableLeaves,
68971
69405
  ...malformedResources ? { malformedResources } : {},
68972
69406
  outputs: newOutputs,
68973
69407
  exportNameDisplay: (name) => secretSafeKeyDisplay(name, outputSecrets)
@@ -75430,7 +75864,13 @@ async function buildCdkdStateStackTree(rootStackName, region, stateBackend, pref
75430
75864
  }
75431
75865
  async function walkCdkdStateStackTree(stackName, region, state, stateBackend) {
75432
75866
  const nestedChildren = /* @__PURE__ */ new Map();
75433
- for (const [logicalId, entry] of Object.entries(state.resources ?? {})) {
75867
+ if (!hasReadableResources(state)) return {
75868
+ stackName,
75869
+ region,
75870
+ state,
75871
+ nestedChildren
75872
+ };
75873
+ for (const [logicalId, entry] of Object.entries(state.resources)) {
75434
75874
  if (entry?.resourceType !== "AWS::CloudFormation::Stack") continue;
75435
75875
  const childStackName = `${stackName}~${logicalId}`;
75436
75876
  const childResult = await stateBackend.getState(childStackName, region);
@@ -77556,14 +77996,74 @@ function isoTimestampOrNull(value) {
77556
77996
  * PROMPT is strictly worse than one in an error, because the sentence it
77557
77997
  * corrupts is the one the operator answers `y` to.
77558
77998
  *
77559
- * `asciiOnly` matches the lock error's own call on these same values — a
77560
- * stack name and an AWS region both have a known charset — and is a no-op on
77561
- * every ordinary input, so the shipped prompt strings are byte-identical.
77562
- * `UNRENDERABLE` stands in when sanitising leaves nothing, since an empty
77563
- * `()` would read as "no region" rather than "a region cdkd will not print".
77999
+ * BOTH halves go through `displayIdent`, not the bare `safe()` allowlist below
78000
+ * (issue #3164). The allowlist closes line FORGERY and nothing else: it is the
78001
+ * identity on printable ASCII, and this function's ` (` / `)` is cdkd's OWN
78002
+ * annotation of the line, so a planted 2-segment legacy key
78003
+ * `cdkd/ProdStack (us-east-1)/state.json` yields a region-LESS ref whose
78004
+ * `stackName` is literally `ProdStack (us-east-1)` and renders BYTE-EQUAL to
78005
+ * the genuine `ProdStack` in `us-east-1`. A `while read -r ref` cleanup loop —
78006
+ * the consumer the sanitization exists for — cannot tell the two apart, so it
78007
+ * acts on the reference the operator believes is real and the planted record
78008
+ * survives the sweep. The join in the two prompt callers is a second boundary
78009
+ * of the same kind: they `join(', ')`, which a name carrying `, ` forges an
78010
+ * extra entry in -- and quoting closes that, since a space is outside
78011
+ * `PLAIN_IDENT`.
78012
+ *
78013
+ * A BARE `,` is NOT closed, and an earlier revision of this comment was wrong
78014
+ * to call it harmless: the formatter supplies the space that completes the
78015
+ * separator, so `ProdStack,` renders `ProdStack, (us-east-1)` and a two-target
78016
+ * list reads as THREE entries. Only `state refresh-observed` prints a count
78017
+ * beside its list; `state orphan`'s banner prints none, so there nothing on
78018
+ * screen contradicts the forged entry -- the worse of the two. Removing `,`
78019
+ * from `PLAIN_IDENT` would close it and was tried; it regresses a legitimate
78020
+ * IAM role ARN, whose role-name segment allows `[\w+=,.@-]`. Recorded on
78021
+ * go-to-k/cdkd#3179 rather than traded for that.
78022
+ *
78023
+ * `displayIdent` makes the boundary VISIBLE by JSON-quoting anything that is
78024
+ * not a plain identifier, so the row above reads `"ProdStack (us-east-1)"` and
78025
+ * no longer collides. It is applied to the REGION half too: a region is an S3
78026
+ * key segment (or, for a legacy record, the state body via `readLegacyRegion`),
78027
+ * so it is attacker-chosen in exactly the same way, and a planted region alone
78028
+ * renders `Decoy (x) (us-east-1)` — the same spoof from the right-hand side.
78029
+ *
78030
+ * Every legitimate row is byte-identical, because `displayIdent` is the
78031
+ * identity on a plain identifier and CloudFormation stack names
78032
+ * (`Parent~Child` included) and AWS region codes all are — so no fixture, no
78033
+ * script's grep and no round-trip into `cdkd state show` changes. It keeps
78034
+ * `safe()`'s `UNRENDERABLE` fallback for a value sanitising leaves empty, since
78035
+ * an empty `()` would read as "no region" rather than "a region cdkd will not
78036
+ * print".
78037
+ *
78038
+ * The NAME half passes `STACK_REF_MAX_CODE_POINTS` rather than taking
78039
+ * `displayIdent`'s 255 default, and that is load-bearing for the paragraph
78040
+ * above rather than a tuning knob: a cdkd state-record name is not a
78041
+ * CloudFormation stack name, because `deriveChildStackName` appends
78042
+ * `~<logicalId>` per nesting level, so a legitimate deep nested-stack child
78043
+ * exceeds 255 and would be rendered CUT — a byte change on a legitimate row, in
78044
+ * the middle of a line `while read -r ref` consumes. The REGION half keeps the
78045
+ * default; an AWS region code is at most 25 characters.
78046
+ *
78047
+ * The consequence is taken at ALL SIX callers, the confirmation prompt
78048
+ * included, rather than at the listing alone: guarding one site and leaving
78049
+ * five is the per-site spelling `safe()`'s own comment below records having
78050
+ * failed twice. In the prompt a quoted spoof is the most valuable of the six —
78051
+ * that sentence is the one an operator answers `y` to.
78052
+ *
78053
+ * What the quoting is NOT: shell-safe. A JSON string literal is visually
78054
+ * indistinguishable from a shell DOUBLE-quoted argument, in which `$(...)`,
78055
+ * backticks and `!` still expand — so `"Prod$(touch /tmp/pwn)" (us-east-1)`
78056
+ * must not be pasted into a command line, and this rendering makes no claim
78057
+ * that it may be. The repo's answer for a value that has to survive a command
78058
+ * line is elsewhere and is SUPPRESSION, not quoting: `buildForceUnlockCommand`
78059
+ * declines to print a command at all unless sanitisation was the identity, and
78060
+ * `rollback-executor.ts`'s `PASTEABLE_LOGICAL_ID` records why identity alone is
78061
+ * still not enough (`~user` and `=x` expand). What this function provides is a
78062
+ * visible BOUNDARY for a value a human is reading, which is a different job.
77564
78063
  */
77565
78064
  function formatStackRefSafe(ref) {
77566
- return ref.region ? `${safe$1(ref.stackName)} (${safe$1(ref.region)})` : safe$1(ref.stackName);
78065
+ const name = displayIdent(ref.stackName, { maxCodePoints: STACK_REF_MAX_CODE_POINTS });
78066
+ return ref.region ? `${name} (${displayIdent(ref.region)})` : name;
77567
78067
  }
77568
78068
  /**
77569
78069
  * One spelling of "this value came from an S3 key or a state record, and is
@@ -77588,6 +78088,29 @@ function formatStackRefSafe(ref) {
77588
78088
  * no-op on every legitimate input while an S3 key admits any UTF-8. Free-form
77589
78089
  * text (a parser's own message, an AWS error) takes the denylist class
77590
78090
  * instead; `display-safe.ts`'s header draws that line.
78091
+ *
78092
+ * It is the WEAKER of this file's two spellings and stays so deliberately.
78093
+ * `formatStackRefSafe` above uses `displayIdent` instead, because it renders
78094
+ * cdkd's own ` (region)` annotation right beside the value and the allowlist
78095
+ * cannot stop a value from carrying that annotation itself (issue #3164).
78096
+ * Widening this helper is tracked as go-to-k/cdkd#3179 rather than done here,
78097
+ * for reasons that are per-SITE rather than uniform, so read that issue's
78098
+ * table rather than generalising from this paragraph: most of these sites are
78099
+ * REFUSALS, where the surrounding `'...'` is at least SOME boundary -- but not
78100
+ * all of them are, and ` Region: ${safe(...)}` in the `--long` view below has
78101
+ * no surrounding anything -- while `Run 'cdkd deploy <stack>'` sites are
78102
+ * COMMAND HINTS, where quoting is not this repo's answer at all:
78103
+ * `buildForceUnlockCommand` SUPPRESSES the whole command instead.
78104
+ *
78105
+ * `grep safe(` does NOT enumerate the class, in BOTH directions. Some sites
78106
+ * spell `displaySafe(..., { asciiOnly: true })` inline rather than calling this
78107
+ * helper (`warnOnLiveForeignLock` renders its own `stack (region)` that way,
78108
+ * inside the same `state orphan` output); others sanitise NOTHING
78109
+ * (`stateRefreshObservedCommand` interpolates raw `listStacks` values into its
78110
+ * refusals, including a third `cdkd deploy` hint, and `stateDestroyCommand`
78111
+ * writes raw names into its `--all` confirmation list); and `describeStateKey`
78112
+ * (`state-file-keys.ts`) renders the same shape from raw key segments.
78113
+ * go-to-k/cdkd#3179 enumerates them all; a grep of this helper does not.
77591
78114
  */
77592
78115
  function safe$1(value) {
77593
78116
  return displaySafe(value, { asciiOnly: true }) || "<unrenderable>";
@@ -77841,7 +78364,9 @@ async function stateResourcesCommand(stackName, options) {
77841
78364
  if (!ref.region) throw new Error(`Stack '${safe$1(stackName)}' has only a legacy state record without a region. Run 'cdkd deploy ${safe$1(stackName)}' (or any cdkd write) to migrate it to the region-scoped layout, then re-run this command.`);
77842
78365
  const stateResult = await setup.stateBackend.getState(stackName, ref.region);
77843
78366
  if (!stateResult) throw new Error(`No state found for stack '${safe$1(stackName)}' (${safe$1(ref.region)}) in s3://${setup.bucket}/${setup.prefix}/. Run 'cdkd state list' to see available stacks.`);
77844
- const resources = stateResult.state.resources ?? {};
78367
+ if (repairMalformedResourcesForReadOnly(stateResult.state)) logger.warn(malformedResourcesWarning(stackName, ref.region));
78368
+ if (options.long || options.json) repairRenderedContainers(stateResult.state, RESOURCES_RENDERED_CONTAINERS, stackName, ref.region, logger);
78369
+ const resources = stateResult.state.resources;
77845
78370
  const details = Object.entries(resources).map(([logicalId, entry]) => {
77846
78371
  const resource = entry ?? {};
77847
78372
  return {
@@ -78049,9 +78574,11 @@ async function stateShowCommand(stackName, options) {
78049
78574
  if (options.showNested) {
78050
78575
  const treeWithLocks = await loadLocksForTree(await buildCdkdStateStackTree(stackName, ref.region, setup.stateBackend, stateResult.state), setup.lockManager, lockInfo);
78051
78576
  if (options.json) {
78577
+ warnUnreadableTreeNodes(treeWithLocks, logger);
78052
78578
  process.stdout.write(`${JSON.stringify(treeToShowJson(treeWithLocks), null, 2)}\n`);
78053
78579
  return;
78054
78580
  }
78581
+ repairTreeForTextRender(treeWithLocks, logger);
78055
78582
  const lines = renderTreeWithChildren(treeWithLocks);
78056
78583
  process.stdout.write(`${lines.join("\n")}\n`);
78057
78584
  return;
@@ -78063,6 +78590,7 @@ async function stateShowCommand(stackName, options) {
78063
78590
  }, null, 2)}\n`);
78064
78591
  return;
78065
78592
  }
78593
+ repairRecordForTextRender(stateResult.state, stackName, ref.region, logger);
78066
78594
  process.stdout.write(`${renderStateBlock(stateResult.state, lockInfo, true).join("\n")}\n`);
78067
78595
  } finally {
78068
78596
  setup.dispose();
@@ -78079,6 +78607,11 @@ const SKIPPED_DIGEST_PREVIEW_LEN = 12;
78079
78607
  const UNSERIALIZABLE = "(unserializable)";
78080
78608
  /**
78081
78609
  * The `skippedOutputs` rows a state record owes, sorted by key.
78610
+ *
78611
+ * The `?? {}` covers an ABSENT field — every pre-#2740 record, plus the writers
78612
+ * that drop it. A non-object one is emptied at the render entry instead
78613
+ * (`repairRenderedContainers`, issue go-to-k/cdkd#3187), so this walk and
78614
+ * {@link rendersSkippedBlock}'s cannot disagree about how many rows exist.
78082
78615
  */
78083
78616
  function sortedSkippedOutputs(state) {
78084
78617
  return Object.entries(state.skippedOutputs ?? {}).sort(([a], [b]) => a.localeCompare(b));
@@ -78124,9 +78657,186 @@ function skippedOutputsLegend() {
78124
78657
  ];
78125
78658
  }
78126
78659
  /**
78660
+ * The order {@link repairRenderedContainers} names repaired containers in.
78661
+ *
78662
+ * Fixed here rather than taken from the order they were found in, so two
78663
+ * records that lost the same containers produce the same warning line and a
78664
+ * test can assert the text rather than a set.
78665
+ */
78666
+ const RENDERED_CONTAINER_ORDER = [
78667
+ "outputs",
78668
+ "skippedOutputs",
78669
+ "attributes",
78670
+ "properties"
78671
+ ];
78672
+ /** Every container {@link renderStateBlock}'s text walks. */
78673
+ const SHOW_RENDERED_CONTAINERS = new Set(RENDERED_CONTAINER_ORDER);
78674
+ /**
78675
+ * What `cdkd state resources` walks: the attribute bag alone.
78676
+ *
78677
+ * `properties` is deliberately absent — that command excludes them from every
78678
+ * mode (`docs/cli-state.md` says so and `stateResourcesCommand`'s own doc
78679
+ * repeats it), so warning about a container it never renders would report a
78680
+ * defect the user cannot see in the output in front of them. `outputs` and
78681
+ * `skippedOutputs` are absent for the same reason.
78682
+ */
78683
+ const RESOURCES_RENDERED_CONTAINERS = /* @__PURE__ */ new Set(["attributes"]);
78684
+ /**
78685
+ * Empty every container this view walks but cannot read, and warn ONCE naming
78686
+ * the ones that were emptied (issue go-to-k/cdkd#3187).
78687
+ *
78688
+ * `renderStateBlock` and `stateResourcesCommand --long` walk four more
78689
+ * containers with `Object.entries` than the `resources` bag go-to-k/cdkd#3185
78690
+ * guarded, and `Object.entries` takes a string, a list, a number and a boolean
78691
+ * as readily as an object. Measured on the shipped bundle: `cdkd state show`
78692
+ * over a record whose `outputs` is a 5,000,000-character string and whose
78693
+ * `resources` bag is HEALTHY costs ~1616 ms and ~1010 MB RSS and emits
78694
+ * 5,000,002 lines; the same shape in `skippedOutputs` costs ~2436 ms and
78695
+ * ~2148 MB. `--show-nested` pays that per node.
78696
+ *
78697
+ * AT THE RENDER ENTRY, not at the four loops, which is the rule
78698
+ * `src/state/malformed-resources-bag.ts`'s header records and the measurement
78699
+ * behind it. Here the reason the entry wins is narrower but the same in kind:
78700
+ * the per-resource containers are reached from inside the resource loop, so a
78701
+ * guard written there is one decision repeated per resource per container, and
78702
+ * the `?? {}` already sitting at each of them is exactly the spelling that
78703
+ * looks like a guard and is not.
78704
+ *
78705
+ * **Absent is NOT malformed here, and that is a different call from the one
78706
+ * {@link hasReadableResources} makes.** `undefined` and `null` both mean "no
78707
+ * such container" for all four, and the `?? {}` at each walk has always
78708
+ * rendered the nullish case as empty, so none of them can fabricate a row.
78709
+ * An unreadable `resources` bag is judged the other way because
78710
+ * `Object.entries(null)` THROWS and takes the whole render with it.
78711
+ *
78712
+ * The stronger half of the reason covers TWO of the four, not all of them, and
78713
+ * saying so is the point: `skippedOutputs?` and `attributes?` are OPTIONAL in
78714
+ * `src/types/state.ts`, so cdkd itself writes records without them and warning
78715
+ * would be a false positive on healthy state. `outputs` and `properties` are
78716
+ * REQUIRED there, so a nullish one IS a malformed record and this exemption
78717
+ * rests on the weaker reason alone — it cannot invent a row, and it renders
78718
+ * identically to the empty bag the reader would otherwise see. Warning on a
78719
+ * nullish REQUIRED container would be defensible; it is not done because the
78720
+ * harm this guard exists for is fabrication (review of go-to-k/cdkd#3190).
78721
+ *
78722
+ * Callers must have repaired the `resources` bag first; both do — one line up
78723
+ * in `repairRecordForTextRender`, and ~30 lines up in `stateResourcesCommand`,
78724
+ * where the mode gate sits between them.
78725
+ * The bag test below is that ORDER made local rather than a fallback: an
78726
+ * unreadable bag is emptied by the repair, so the record renders zero resources
78727
+ * and owns no per-resource container to repair. It reads
78728
+ * {@link isReadableBag} rather than {@link hasReadableResources} on purpose —
78729
+ * the latter is the probe `tests/unit/cli/state-record-shape.test.ts` counts to
78730
+ * measure the nested walk, and a second caller here would silently inflate it.
78731
+ */
78732
+ function repairRenderedContainers(state, walked, stackName, region, logger) {
78733
+ const repaired = /* @__PURE__ */ new Set();
78734
+ const emptyIfUnwalkable = (owner, name) => {
78735
+ if (!walked.has(name)) return;
78736
+ const container = owner[name];
78737
+ if (container === void 0 || container === null) return;
78738
+ if (isReadableBag(container)) return;
78739
+ owner[name] = {};
78740
+ repaired.add(name);
78741
+ };
78742
+ const record = state;
78743
+ emptyIfUnwalkable(record, "outputs");
78744
+ emptyIfUnwalkable(record, "skippedOutputs");
78745
+ if ((walked.has("attributes") || walked.has("properties")) && isReadableBag(state.resources)) for (const entry of Object.values(state.resources)) {
78746
+ if (!isReadableBag(entry)) continue;
78747
+ const fields = entry;
78748
+ emptyIfUnwalkable(fields, "attributes");
78749
+ emptyIfUnwalkable(fields, "properties");
78750
+ }
78751
+ if (repaired.size === 0) return;
78752
+ logger.warn(malformedRenderedContainersWarning(stackName, region, RENDERED_CONTAINER_ORDER.filter((name) => repaired.has(name))));
78753
+ }
78754
+ /**
78755
+ * Give the TEXT render a readable `resources` bag and readable value
78756
+ * containers, and warn when either was a repair rather than a no-op.
78757
+ *
78758
+ * {@link renderStateBlock} walks the bag with `Object.entries`, which takes a
78759
+ * string, a list, a number and a boolean as readily as an object. A planted
78760
+ * `"abcdef"` rendered six `[index, character]` pairs as six resources that do
78761
+ * not exist, under a `Resources (6):` header — and the cost grows with the
78762
+ * planted length, since one pair is allocated per character.
78763
+ *
78764
+ * Called AFTER every `--json` branch rather than at the load, which is this
78765
+ * command's one departure from the rule `src/state/malformed-resources-bag.ts`
78766
+ * states, and is deliberate: `cdkd state show --json` is the command
78767
+ * `cdkd state list --long` names as the way to SEE a record it could not count,
78768
+ * so repairing above that branch would hand the operator a well-formed
78769
+ * `"resources": {}` and delete the evidence they came for.
78770
+ *
78771
+ * That placement is only safe because ONE thing reads the bag earlier — the
78772
+ * `--show-nested` walker — and `walkCdkdStateStackTree` now returns a childless
78773
+ * node on an unreadable bag before it dereferences one. The comment at that
78774
+ * guard carries what the walker did with such a bag before, in both directions:
78775
+ * a scalar-element bag yielded no children but still allocated per element, and
78776
+ * a LIST of resource objects hard-failed. Do not move this call up, and do not
78777
+ * drop that guard: either one alone leaves the other half live.
78778
+ *
78779
+ * The guard is in the WALKER and not at its caller because the walker recurses.
78780
+ * A caller-side test covers the root record only, which is what an earlier cut
78781
+ * of this fix shipped — a healthy root naming a nested child still walked that
78782
+ * child's bag unguarded.
78783
+ *
78784
+ * The container pass runs SECOND and under the same `--json` placement, for one
78785
+ * reason each. Second, because {@link repairRenderedContainers} reads the
78786
+ * resource entries and the repair above is what guarantees there is a map to
78787
+ * read them from. Under the same placement, because `--json` emits the record
78788
+ * as parsed and a non-object `outputs` is evidence there exactly as a
78789
+ * non-object `resources` bag is — and because the payload carries the stored
78790
+ * value whole, it fabricates nothing to emit it.
78791
+ */
78792
+ function repairRecordForTextRender(state, stackName, region, logger) {
78793
+ if (repairMalformedResourcesForReadOnly(state)) logger.warn(malformedResourcesWarning(stackName, region));
78794
+ repairRenderedContainers(state, SHOW_RENDERED_CONTAINERS, stackName, region, logger);
78795
+ }
78796
+ /**
78797
+ * The same repair for every node {@link renderTreeWithChildren} will render,
78798
+ * the root included. Each node carries its own record, so a CHILD with a
78799
+ * malformed bag fabricates rows exactly as the root does; one warning per
78800
+ * repaired record names which stack it came from.
78801
+ *
78802
+ * Both identifiers are strings by construction rather than record fields: the
78803
+ * walker builds a child's name as `` `${parent}~${logicalId}` `` and threads the
78804
+ * region it walked against, having already refused any child whose recorded
78805
+ * region disagrees.
78806
+ */
78807
+ function repairTreeForTextRender(node, logger) {
78808
+ repairRecordForTextRender(node.state, node.stackName, node.region, logger);
78809
+ for (const child of node.children) repairTreeForTextRender(child, logger);
78810
+ }
78811
+ /**
78812
+ * Say which nodes the walk could not read, WITHOUT repairing any of them.
78813
+ *
78814
+ * `--show-nested --json` is the one mode that must not repair — it exists to
78815
+ * show the operator the stored record — but "cannot repair" is not "must stay
78816
+ * silent". An unreadable bag makes the walk return that node childless, and a
78817
+ * childless node is byte-indistinguishable from a genuine leaf: a consumer
78818
+ * enumerating `children` reads a CUT subtree as a complete one. The text views
78819
+ * do not have this problem, because a cut node renders `Resources (0):` where
78820
+ * the operator expected rows.
78821
+ *
78822
+ * `logger.warn` writes to stderr, and `stateShowCommand` has already called
78823
+ * `reserveStdoutForPayload()` for this branch, so this cannot corrupt the JSON.
78824
+ */
78825
+ function warnUnreadableTreeNodes(node, logger) {
78826
+ if (!hasReadableResources(node.state)) logger.warn(malformedResourcesWarning(node.stackName, node.region));
78827
+ for (const child of node.children) warnUnreadableTreeNodes(child, logger);
78828
+ }
78829
+ /**
78127
78830
  * Render one stack's state record as a human-readable multi-line block —
78128
78831
  * the shared body used by both the single-stack default output and each
78129
78832
  * nested child rendered under `--show-nested`.
78833
+ *
78834
+ * Every caller must have run {@link repairRecordForTextRender} over the
78835
+ * record first (`repairTreeForTextRender` does it per node for the tree): the
78836
+ * resource walk below is what fabricates rows from a non-object bag, and the
78837
+ * `state.ts names renderStateBlock in CODE exactly 1 + 3 times` case in
78838
+ * `tests/unit/cli/state-record-shape.test.ts` refuses a fourth call site so
78839
+ * that choice cannot be made silently.
78130
78840
  */
78131
78841
  function renderStateBlock(state, lockInfo, withLegend) {
78132
78842
  const lines = [];
@@ -78157,7 +78867,7 @@ function renderStateBlock(state, lockInfo, withLegend) {
78157
78867
  }
78158
78868
  if (withLegend) lines.push(...skippedOutputsLegend());
78159
78869
  }
78160
- const resourceEntries = Object.entries(state.resources ?? {}).map(([logicalId, entry]) => [logicalId, entry ?? {}]).sort(([a], [b]) => a.localeCompare(b));
78870
+ const resourceEntries = Object.entries(state.resources).map(([logicalId, entry]) => [logicalId, entry ?? {}]).sort(([a], [b]) => a.localeCompare(b));
78161
78871
  lines.push("");
78162
78872
  lines.push(`Resources (${resourceEntries.length}):`);
78163
78873
  for (const [logicalId, resource] of resourceEntries) {
@@ -79238,11 +79948,39 @@ function snapshotNote(resourceType, effectiveProvisionedBy, skipFinalSnapshot) {
79238
79948
  * one calls `displaySafe()` directly and takes the DENYLIST -- the same class
79239
79949
  * `formatError` picks for a `cause`, and for the same reason. `grep displaySafe(`
79240
79950
  * answers how many; a count written here was wrong on its first revision.
79951
+ *
79952
+ * Scope is answered by grepping BOTH `safe(` and `safeStack(` -- the latter is
79953
+ * not matched by the former, and a sentence naming only one understates the
79954
+ * population by twelve.
79241
79955
  */
79242
79956
  function safe(value) {
79243
79957
  return displayIdent(value);
79244
79958
  }
79245
79959
  /**
79960
+ * `safe()` for a cdkd STATE-RECORD STACK NAME specifically, which is the one
79961
+ * value class in this file whose legitimate grammar runs past `displayIdent`'s
79962
+ * 255-code-point default (issue #3164).
79963
+ *
79964
+ * A stack name here is not a CloudFormation stack name: `deriveChildStackName`
79965
+ * appends `~<logicalId>` per nesting level, and with CDK's ~60-character
79966
+ * generated nested-stack logical ids a legitimate child passes 255 around the
79967
+ * fourth level. Cutting one is a byte change on a LEGITIMATE value, and this
79968
+ * file is the worst place for it -- three of its renders are
79969
+ * `re-run 'cdkd rollback <stack>'` COPY-PASTE hints and one is the
79970
+ * confirmation prompt, so a cut name hands an operator an unrunnable command
79971
+ * mid-incident.
79972
+ *
79973
+ * It is a NAMED helper rather than a `maxCodePoints` argument repeated per
79974
+ * site, because a per-site spelling of exactly this rule is what issue #3164
79975
+ * exists to stop: the first cut of that fix widened ONE of this file's twelve
79976
+ * stack-name renders and left eleven cut. Every value that is NOT a stack name --
79977
+ * a region (at most 25 characters), a logical id, a resource type, a change
79978
+ * type -- keeps `safe()` and its tighter default.
79979
+ */
79980
+ function safeStack(value) {
79981
+ return displayIdent(value, { maxCodePoints: STACK_REF_MAX_CODE_POINTS });
79982
+ }
79983
+ /**
79246
79984
  * Human label for a planned rollback action (plan preview). `skipFinalSnapshot`
79247
79985
  * is threaded in because the classifier is pure (it cannot see CLI flags) and
79248
79986
  * the Snapshot label would otherwise promise a final snapshot the run is about
@@ -79321,7 +80059,7 @@ async function rollbackCommand(stackArg, options) {
79321
80059
  return;
79322
80060
  }
79323
80061
  if (scoped.length > 1) {
79324
- const list = scoped.map((c) => ` - ${safe(c.stackName)} (${safe(c.region)})`).join("\n");
80062
+ const list = scoped.map((c) => ` - ${safeStack(c.stackName)} (${safe(c.region)})`).join("\n");
79325
80063
  throw new Error(`Multiple stacks have a rollback journal. Pick one:\n${list}\nRe-run 'cdkd rollback <stack>' (add --stack-region if the same name spans regions).`);
79326
80064
  }
79327
80065
  ref = scoped[0];
@@ -79354,8 +80092,8 @@ async function rollbackCommand(stackArg, options) {
79354
80092
  try {
79355
80093
  const stateData = await setup.stateBackend.getState(stackName, region);
79356
80094
  const journal = await setup.stateBackend.loadRollbackJournal(stackName, region);
79357
- if (!journal || journal.segments.length === 0) throw new Error(`Nothing to roll back for '${safe(stackName)}' (${safe(region)}). Run 'cdkd deploy' to (re)deploy, or 'cdkd destroy' to clean up.`);
79358
- if (!stateData) throw new Error(`Rollback journal exists for '${safe(stackName)}' (${safe(region)}) but its state.json is missing (keys: ${safe(`${setup.prefix}/${stackName}/${region}`)}/state.json and .../rollback-journal.json). State appears corrupted — inspect the bucket manually.`);
80095
+ if (!journal || journal.segments.length === 0) throw new Error(`Nothing to roll back for '${safeStack(stackName)}' (${safe(region)}). Run 'cdkd deploy' to (re)deploy, or 'cdkd destroy' to clean up.`);
80096
+ if (!stateData) throw new Error(`Rollback journal exists for '${safeStack(stackName)}' (${safe(region)}) but its state.json is missing (keys: ${safe(setup.prefix)}/${safeStack(stackName)}/${safe(region)}/state.json and .../rollback-journal.json). State appears corrupted — inspect the bucket manually.`);
79359
80097
  const baseState = stateData.state;
79360
80098
  refuseMalformedState(baseState, stackName, region);
79361
80099
  const stateResources = { ...baseState.resources };
@@ -79363,7 +80101,7 @@ async function rollbackCommand(stackArg, options) {
79363
80101
  const orphanLogicalIds = new Set(options.orphan ?? []);
79364
80102
  const newestSegment = journal.segments[journal.segments.length - 1];
79365
80103
  if (newestSegment.roleArn && !options.roleArn) logger.info(`Note: the failed deploy ran with --role-arn ${safe(newestSegment.roleArn)}; this rollback is running with ambient credentials (pass --role-arn to match).`);
79366
- logger.info(`\nRollback plan for '${safe(stackName)}' (${safe(region)}):`);
80104
+ logger.info(`\nRollback plan for '${safeStack(stackName)}' (${safe(region)}):`);
79367
80105
  const planStateView = { ...stateResources };
79368
80106
  for (let s = journal.segments.length - 1; s >= 0; s--) {
79369
80107
  const segment = journal.segments[s];
@@ -79381,7 +80119,7 @@ async function rollbackCommand(stackArg, options) {
79381
80119
  }
79382
80120
  logger.info("");
79383
80121
  if (!skipConfirmation) {
79384
- if (!await confirm(`Roll back '${safe(stackName)}' (${safe(region)})?`)) {
80122
+ if (!await confirm(`Roll back '${safeStack(stackName)}' (${safe(region)})?`)) {
79385
80123
  logger.info("Rollback cancelled");
79386
80124
  return;
79387
80125
  }
@@ -79419,7 +80157,7 @@ async function rollbackCommand(stackArg, options) {
79419
80157
  const fresh = await setup.stateBackend.getState(stackName, region);
79420
80158
  currentEtag = await setup.stateBackend.saveState(stackName, region, next(), { ...fresh?.etag !== void 0 && { expectedEtag: fresh.etag } });
79421
80159
  } catch (retryError) {
79422
- logger.warn(`Failed to persist state after a rollback operation: ${displaySafe(retryError instanceof Error ? retryError.message : String(retryError))}. The resource was reverted in AWS; re-run 'cdkd rollback ${safe(stackName)}' to reconcile state.`);
80160
+ logger.warn(`Failed to persist state after a rollback operation: ${displaySafe(retryError instanceof Error ? retryError.message : String(retryError))}. The resource was reverted in AWS; re-run 'cdkd rollback ${safeStack(stackName)}' to reconcile state.`);
79423
80161
  }
79424
80162
  }
79425
80163
  };
@@ -79498,16 +80236,16 @@ async function rollbackCommand(stackArg, options) {
79498
80236
  const survivingOrphans = orphansAfterRollback(baseState, mintedOrphans).orphans ?? [];
79499
80237
  if (journal.segments.length === 0 && oldestInitialDeploy && Object.keys(stateResources).length === 0 && survivingOrphans.length === 0) {
79500
80238
  await setup.stateBackend.deleteState(stackName, region);
79501
- logger.info(`State for '${safe(stackName)}' (${safe(region)}) removed (stack fully rolled back).`);
80239
+ logger.info(`State for '${safeStack(stackName)}' (${safe(region)}) removed (stack fully rolled back).`);
79502
80240
  }
79503
- if (interrupted) throw new PartialFailureError(`Rollback interrupted. Journal preserved — re-run 'cdkd rollback ${safe(stackName)}' to finish.`);
79504
- if (totalFailures > 0) throw new PartialFailureError(`Rollback completed with ${totalFailures} failed operation(s). Journal preserved — re-run 'cdkd rollback ${safe(stackName)}' to retry.`);
80241
+ if (interrupted) throw new PartialFailureError(`Rollback interrupted. Journal preserved — re-run 'cdkd rollback ${safeStack(stackName)}' to finish.`);
80242
+ if (totalFailures > 0) throw new PartialFailureError(`Rollback completed with ${totalFailures} failed operation(s). Journal preserved — re-run 'cdkd rollback ${safeStack(stackName)}' to retry.`);
79505
80243
  if (totalWarnings > 0) throw new PartialFailureError(`Rollback completed with ${totalWarnings} skipped/unrecoverable operation(s) (see warnings above).`);
79506
- logger.info(`\nRollback of '${safe(stackName)}' (${safe(region)}) complete.`);
80244
+ logger.info(`\nRollback of '${safeStack(stackName)}' (${safe(region)}) complete.`);
79507
80245
  } finally {
79508
80246
  try {
79509
80247
  await setup.lockManager.releaseLock(stackName, region).catch((err) => {
79510
- logger.warn(`Failed to release lock for '${safe(stackName)}' (${safe(region)}): ${displaySafe(err instanceof Error ? err.message : String(err))}`);
80248
+ logger.warn(`Failed to release lock for '${safeStack(stackName)}' (${safe(region)}): ${displaySafe(err instanceof Error ? err.message : String(err))}`);
79511
80249
  });
79512
80250
  } finally {
79513
80251
  process.removeListener("SIGINT", sigintHandler);
@@ -92998,4 +93736,4 @@ function buildProgram() {
92998
93736
 
92999
93737
  //#endregion
93000
93738
  export { buildProgram };
93001
- //# sourceMappingURL=program-Ckl60p0E.js.map
93739
+ //# sourceMappingURL=program-BslZXdJR.js.map