@thejoseki/clawform 2.3.1 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +141 -120
  2. package/bin/clawform.js +161 -151
  3. package/package.json +65 -65
  4. package/src/aws/catalog.js +116 -116
  5. package/src/aws/changeset.js +231 -231
  6. package/src/aws/drift.js +59 -59
  7. package/src/aws/exports.js +213 -213
  8. package/src/aws/inventory.js +156 -156
  9. package/src/commands/activate.js +137 -105
  10. package/src/commands/ci-init.js +393 -212
  11. package/src/commands/cost-report.js +163 -163
  12. package/src/commands/deactivate.js +9 -2
  13. package/src/commands/deploy.js +413 -413
  14. package/src/commands/diff.js +39 -39
  15. package/src/commands/drift.js +35 -35
  16. package/src/commands/init-plugin.js +6 -6
  17. package/src/commands/init.js +360 -360
  18. package/src/commands/rollback.js +126 -126
  19. package/src/commands/status.js +147 -147
  20. package/src/commands/sync.js +50 -50
  21. package/src/commands/update.js +24 -0
  22. package/src/commands/validate.js +349 -349
  23. package/src/hooks/block-dangerous.js +62 -62
  24. package/src/hooks/cfn-lint-after-edit-eval.js +30 -30
  25. package/src/hooks/cfn-lint-after-edit.js +81 -81
  26. package/src/hooks/check-catalog-fresh-eval.js +63 -63
  27. package/src/hooks/check-catalog-fresh.js +33 -33
  28. package/src/hooks/session-context-eval.js +81 -81
  29. package/src/hooks/session-context.js +41 -41
  30. package/src/hooks/subagent-context-eval.js +44 -44
  31. package/src/hooks/subagent-context.js +48 -48
  32. package/src/lib/audit-synthesis.js +24 -24
  33. package/src/lib/aws-client.js +15 -15
  34. package/src/lib/cfn-yaml.js +92 -92
  35. package/src/lib/config.js +76 -76
  36. package/src/lib/constants.js +85 -85
  37. package/src/lib/defaults.js +16 -16
  38. package/src/lib/install-workflow.js +28 -28
  39. package/src/lib/kit-source.js +43 -5
  40. package/src/lib/license-client.js +84 -84
  41. package/src/lib/license-config.js +162 -162
  42. package/src/lib/license-store.js +43 -8
  43. package/src/lib/license.js +163 -150
  44. package/src/lib/lint-overrides.js +226 -226
  45. package/src/lib/logger.js +16 -16
  46. package/src/lib/project-config.js +145 -145
  47. package/src/lib/providers/polar.js +215 -215
  48. package/src/lib/session-state.js +91 -91
@@ -1,213 +1,213 @@
1
- // Pre-flight checks on a stack's CloudFormation exports.
2
- //
3
- // Two day-2 traps live here, both computed from the LIVE stack's Outputs (which
4
- // CloudFormation reports with resolved OutputKey + ExportName) so neither needs
5
- // to resolve template intrinsics:
6
- //
7
- // 1. Export-lock (C1) — removing or un-exporting an Output whose export is
8
- // still imported by another stack. CloudFormation rejects this at execute
9
- // time with "Export <name> ... in use by <stack>", AFTER partial work and a
10
- // rollback. We detect it before the changeset so the operator gets the
11
- // two-step migration guidance instead of a wedged stack. See rules/limits.md.
12
- //
13
- // 2. Frozen-export (C2) — a stack whose NAME doesn't match a legacy prefix but
14
- // that emits legacy-prefixed EXPORT names (e.g. stack `d-cfn-aao-vpc`
15
- // exporting `OLDNET-VPC`). The name-only legacy gate in the PreToolUse hook
16
- // can't see this (it's pure, no AWS access); the CLI can, because it has
17
- // already fetched the live Outputs. See rules/legacy-freeze.md.
18
-
19
- import { ListImportsCommand } from '@aws-sdk/client-cloudformation';
20
- import { logger } from '../lib/logger.js';
21
- import { readEnv, ENV_PREFIX } from '../lib/rename-compat.js';
22
-
23
- // --- C1: export-lock --------------------------------------------------------
24
-
25
- /**
26
- * Live exports that the new template would remove.
27
- *
28
- * An export is "removed" when a live Output that carries an ExportName:
29
- * - has an OutputKey NOT in `templateExportingKeys` (the set of Output keys
30
- * the new template still exports — from extractExportingOutputKeys()), AND
31
- * - has an ExportName NOT in `templateLiteralExportNames` (the literal
32
- * Export.Name strings in the new template — from extractLiteralExportNames()).
33
- *
34
- * The second condition kills the OutputKey-rename false positive: renaming the
35
- * logical key while keeping the same literal Export.Name moves the export, it
36
- * doesn't remove it, and CloudFormation executes that fine. Intrinsic export
37
- * names (`!Sub …`) can't be matched offline — an export moved to a key whose
38
- * name became an intrinsic may still be flagged; ListImports then decides.
39
- *
40
- * Catches both "Output deleted entirely" and "Output kept but Export block
41
- * dropped". NOT caught (documented in rules/limits.md): an in-place rename that
42
- * keeps the OutputKey but changes Export.Name.
43
- *
44
- * @param liveOutputs stack.Outputs from DescribeStacks: [{OutputKey, OutputValue, ExportName}]
45
- * @param templateExportingKeys Set<string> of OutputKeys the new template exports
46
- * @param templateLiteralExportNames Set<string> of literal Export.Name strings in the new template
47
- * @returns Array<{ outputKey, exportName }>
48
- */
49
- export function findRemovedExports(liveOutputs, templateExportingKeys, templateLiteralExportNames = new Set()) {
50
- const keys = templateExportingKeys instanceof Set ? templateExportingKeys : new Set(templateExportingKeys ?? []);
51
- const out = [];
52
- for (const o of liveOutputs ?? []) {
53
- if (!o || !o.ExportName) continue; // only exported outputs can hit the lock
54
- if (keys.has(o.OutputKey)) continue; // still exported under the same key
55
- if (templateLiteralExportNames.has(o.ExportName)) continue; // moved to another key, same export name
56
- out.push({ outputKey: o.OutputKey, exportName: o.ExportName });
57
- }
58
- return out;
59
- }
60
-
61
- /**
62
- * Paginate ListImports for one export name. Importers found so far are pushed
63
- * into `sink` page-by-page, so a caller that catches a mid-pagination error
64
- * still sees the importers already confirmed — checkExportLock uses this to
65
- * fail CLOSED on partial proof.
66
- *
67
- * Hand-rolled NextToken loop instead of the SDK's paginateListImports on
68
- * purpose: the generated paginators hard-check `client instanceof
69
- * CloudFormationClient`, which would break every mocked-client test at this
70
- * seam (catalog.js/inventory.js can use them because they only ever run
71
- * against the real client).
72
- */
73
- export async function listImportsFor(client, exportName, sink = []) {
74
- let nextToken;
75
- do {
76
- const resp = await client.send(new ListImportsCommand({ ExportName: exportName, NextToken: nextToken }));
77
- for (const imp of resp.Imports ?? []) sink.push(imp);
78
- nextToken = resp.NextToken;
79
- } while (nextToken);
80
- return sink;
81
- }
82
-
83
- /**
84
- * For each removed export, ask CloudFormation who still imports it. The
85
- * per-export checks are independent, so they run concurrently.
86
- *
87
- * Failure posture:
88
- * - A confirmed in-use export → fail CLOSED (caller blocks the deploy): the
89
- * changeset is guaranteed to fail, so blocking pre-flight strictly helps.
90
- * This includes PARTIAL proof — importers seen on page 1 before page 2
91
- * threw still block.
92
- * - Our own inability to check (ListImports throws with zero importers seen —
93
- * e.g. missing cloudformation:ListImports permission) → fail OPEN: record
94
- * the error and let the deploy proceed rather than block on a check we
95
- * couldn't run. CloudFormation still refuses a genuinely in-use removal.
96
- *
97
- * AWS quirk: ListImports throws a ValidationError ("Export ... is not imported
98
- * by any stack") when an export has zero importers. That's a CLEAN result, not
99
- * an error — treated as "no importers", not fail-open.
100
- *
101
- * @returns { blocked: Array<{exportName, importers}>, unchecked: Array<{exportName, error}> }
102
- */
103
- export async function checkExportLock(client, removedExports) {
104
- const results = await Promise.all((removedExports ?? []).map(async ({ exportName }) => {
105
- const importers = [];
106
- try {
107
- await listImportsFor(client, exportName, importers);
108
- return importers.length > 0 ? { blocked: { exportName, importers } } : {};
109
- } catch (err) {
110
- if (importers.length > 0) return { blocked: { exportName, importers } }; // partial proof → fail closed
111
- if (isNotImportedError(err)) return {}; // zero importers — safe to remove
112
- return { unchecked: { exportName, error: err?.message ?? String(err) } };
113
- }
114
- }));
115
- return {
116
- blocked: results.map((r) => r.blocked).filter(Boolean),
117
- unchecked: results.map((r) => r.unchecked).filter(Boolean),
118
- };
119
- }
120
-
121
- function isNotImportedError(err) {
122
- const msg = err?.message ?? '';
123
- return err?.name === 'ValidationError' && /is not imported by any stack/i.test(msg);
124
- }
125
-
126
- // --- C2: frozen-export ------------------------------------------------------
127
-
128
- /**
129
- * Live export names matching the consumer's legacy prefixes.
130
- *
131
- * `legacyRe` must be the ANCHORED regex (buildAnchoredLegacyPrefixRegex) — the
132
- * prefix has to be the export name's LEADING segment. The hook's unanchored
133
- * command-line regex would false-positive on legacy tokens mid-name
134
- * (`p-acme-pri-lmd-payroll-OLDAPP-arn`). Null regex = no gate (fresh consumer).
135
- *
136
- * @returns Array<{ outputKey, exportName }>
137
- */
138
- export function findFrozenExports(liveOutputs, legacyRe) {
139
- if (!legacyRe) return [];
140
- const out = [];
141
- for (const o of liveOutputs ?? []) {
142
- if (!o || !o.ExportName) continue;
143
- if (legacyRe.test(o.ExportName)) {
144
- out.push({ outputKey: o.OutputKey, exportName: o.ExportName });
145
- }
146
- }
147
- return out;
148
- }
149
-
150
- // Break-glass for the frozen-export gates (mirrors CLAWFORM_SKIP_ACCOUNT_CHECK):
151
- // intentionally a noisy env var, not a CLI flag. Needed because a frozen-named
152
- // export, once live, would otherwise also block the remediation deploy that
153
- // removes it — and unlike the stack-NAME freeze there is no `# FORCE LEGACY`
154
- // hook path here (the hook never sees export names).
155
- // Suffix for the lookup, full name for anything a human reads.
156
- const SKIP_FROZEN_SUFFIX = 'SKIP_FROZEN_EXPORT_CHECK';
157
- const SKIP_FROZEN_ENV = `${ENV_PREFIX}${SKIP_FROZEN_SUFFIX}`;
158
-
159
- export function frozenExportCheckSkipped(action) {
160
- if (readEnv(SKIP_FROZEN_SUFFIX) !== '1') return false;
161
- logger.warn(
162
- `${SKIP_FROZEN_ENV}=1 — bypassing the frozen-export gate for this ${action}. ` +
163
- `Break-glass only; unset to re-enable.`,
164
- );
165
- return true;
166
- }
167
-
168
- /**
169
- * Shared C2 gate for deploy AND rollback: refuse a stack that EMITS
170
- * legacy-prefixed export names, even when the stack NAME isn't frozen. Closes
171
- * the "stack-name-only" gap in rules/legacy-freeze.md.
172
- *
173
- * @param action 'deploy' | 'recovery' — only changes the refusal wording.
174
- * Throws on a frozen export. Returns void when clean / skipped via break-glass.
175
- */
176
- export function assertNoFrozenExports({ liveOutputs, legacyRe, stackName, action = 'deploy' } = {}) {
177
- const frozen = findFrozenExports(liveOutputs, legacyRe);
178
- if (frozen.length === 0) return;
179
- if (frozenExportCheckSkipped(action)) return;
180
- const names = frozen.map((f) => f.exportName).join(', ');
181
- throw new Error(
182
- `Stack "${stackName}" emits export(s) matching a legacy prefix (${names}) from your clawform.config.json ` +
183
- `legacy_prefixes — even though the stack NAME isn't frozen. Clawform treats legacy-named exports as frozen ` +
184
- `too (rules/legacy-freeze.md). Refusing ${action === 'recovery' ? 'recovery' : 'to deploy'}.\n` +
185
- `If this is intentional (e.g. retiring the legacy-named export itself), re-run with ` +
186
- `${SKIP_FROZEN_ENV}=1, or use the raw aws cloudformation CLI.`,
187
- );
188
- }
189
-
190
- /**
191
- * C2 introduction gate: refuse a TEMPLATE whose literal Export.Name strings
192
- * would CREATE a legacy-prefixed export. Without this, deploy would happily
193
- * mint e.g. `OLDNET-NewThing` on a clean-named stack (the live-outputs gate only
194
- * sees exports that already exist) — and the live gate would then freeze the
195
- * stack against its own tooling. Intrinsic Export.Names can't be checked
196
- * offline; legacy names are literal in practice.
197
- *
198
- * @param templateExportNames Set<string> from extractLiteralExportNames()
199
- * Throws when a frozen name would be introduced. Honors the same break-glass.
200
- */
201
- export function assertNoFrozenTemplateExports({ templateExportNames, legacyRe, stackName } = {}) {
202
- if (!legacyRe) return;
203
- const frozen = [...(templateExportNames ?? [])].filter((n) => legacyRe.test(n));
204
- if (frozen.length === 0) return;
205
- if (frozenExportCheckSkipped('deploy')) return;
206
- throw new Error(
207
- `Template for stack "${stackName}" would CREATE export(s) named like a legacy prefix (${frozen.join(', ')}) ` +
208
- `from your clawform.config.json legacy_prefixes. New exports must follow the going-forward convention ` +
209
- `(rules/cfn-standards.md) — a legacy-prefixed export would immediately freeze this stack against ` +
210
- `clawform deploy/rollback (rules/legacy-freeze.md). Rename the export. ` +
211
- `Break-glass: ${SKIP_FROZEN_ENV}=1.`,
212
- );
213
- }
1
+ // Pre-flight checks on a stack's CloudFormation exports.
2
+ //
3
+ // Two day-2 traps live here, both computed from the LIVE stack's Outputs (which
4
+ // CloudFormation reports with resolved OutputKey + ExportName) so neither needs
5
+ // to resolve template intrinsics:
6
+ //
7
+ // 1. Export-lock (C1) — removing or un-exporting an Output whose export is
8
+ // still imported by another stack. CloudFormation rejects this at execute
9
+ // time with "Export <name> ... in use by <stack>", AFTER partial work and a
10
+ // rollback. We detect it before the changeset so the operator gets the
11
+ // two-step migration guidance instead of a wedged stack. See rules/limits.md.
12
+ //
13
+ // 2. Frozen-export (C2) — a stack whose NAME doesn't match a legacy prefix but
14
+ // that emits legacy-prefixed EXPORT names (e.g. stack `d-cfn-aao-vpc`
15
+ // exporting `OLDNET-VPC`). The name-only legacy gate in the PreToolUse hook
16
+ // can't see this (it's pure, no AWS access); the CLI can, because it has
17
+ // already fetched the live Outputs. See rules/legacy-freeze.md.
18
+
19
+ import { ListImportsCommand } from '@aws-sdk/client-cloudformation';
20
+ import { logger } from '../lib/logger.js';
21
+ import { readEnv, ENV_PREFIX } from '../lib/rename-compat.js';
22
+
23
+ // --- C1: export-lock --------------------------------------------------------
24
+
25
+ /**
26
+ * Live exports that the new template would remove.
27
+ *
28
+ * An export is "removed" when a live Output that carries an ExportName:
29
+ * - has an OutputKey NOT in `templateExportingKeys` (the set of Output keys
30
+ * the new template still exports — from extractExportingOutputKeys()), AND
31
+ * - has an ExportName NOT in `templateLiteralExportNames` (the literal
32
+ * Export.Name strings in the new template — from extractLiteralExportNames()).
33
+ *
34
+ * The second condition kills the OutputKey-rename false positive: renaming the
35
+ * logical key while keeping the same literal Export.Name moves the export, it
36
+ * doesn't remove it, and CloudFormation executes that fine. Intrinsic export
37
+ * names (`!Sub …`) can't be matched offline — an export moved to a key whose
38
+ * name became an intrinsic may still be flagged; ListImports then decides.
39
+ *
40
+ * Catches both "Output deleted entirely" and "Output kept but Export block
41
+ * dropped". NOT caught (documented in rules/limits.md): an in-place rename that
42
+ * keeps the OutputKey but changes Export.Name.
43
+ *
44
+ * @param liveOutputs stack.Outputs from DescribeStacks: [{OutputKey, OutputValue, ExportName}]
45
+ * @param templateExportingKeys Set<string> of OutputKeys the new template exports
46
+ * @param templateLiteralExportNames Set<string> of literal Export.Name strings in the new template
47
+ * @returns Array<{ outputKey, exportName }>
48
+ */
49
+ export function findRemovedExports(liveOutputs, templateExportingKeys, templateLiteralExportNames = new Set()) {
50
+ const keys = templateExportingKeys instanceof Set ? templateExportingKeys : new Set(templateExportingKeys ?? []);
51
+ const out = [];
52
+ for (const o of liveOutputs ?? []) {
53
+ if (!o || !o.ExportName) continue; // only exported outputs can hit the lock
54
+ if (keys.has(o.OutputKey)) continue; // still exported under the same key
55
+ if (templateLiteralExportNames.has(o.ExportName)) continue; // moved to another key, same export name
56
+ out.push({ outputKey: o.OutputKey, exportName: o.ExportName });
57
+ }
58
+ return out;
59
+ }
60
+
61
+ /**
62
+ * Paginate ListImports for one export name. Importers found so far are pushed
63
+ * into `sink` page-by-page, so a caller that catches a mid-pagination error
64
+ * still sees the importers already confirmed — checkExportLock uses this to
65
+ * fail CLOSED on partial proof.
66
+ *
67
+ * Hand-rolled NextToken loop instead of the SDK's paginateListImports on
68
+ * purpose: the generated paginators hard-check `client instanceof
69
+ * CloudFormationClient`, which would break every mocked-client test at this
70
+ * seam (catalog.js/inventory.js can use them because they only ever run
71
+ * against the real client).
72
+ */
73
+ export async function listImportsFor(client, exportName, sink = []) {
74
+ let nextToken;
75
+ do {
76
+ const resp = await client.send(new ListImportsCommand({ ExportName: exportName, NextToken: nextToken }));
77
+ for (const imp of resp.Imports ?? []) sink.push(imp);
78
+ nextToken = resp.NextToken;
79
+ } while (nextToken);
80
+ return sink;
81
+ }
82
+
83
+ /**
84
+ * For each removed export, ask CloudFormation who still imports it. The
85
+ * per-export checks are independent, so they run concurrently.
86
+ *
87
+ * Failure posture:
88
+ * - A confirmed in-use export → fail CLOSED (caller blocks the deploy): the
89
+ * changeset is guaranteed to fail, so blocking pre-flight strictly helps.
90
+ * This includes PARTIAL proof — importers seen on page 1 before page 2
91
+ * threw still block.
92
+ * - Our own inability to check (ListImports throws with zero importers seen —
93
+ * e.g. missing cloudformation:ListImports permission) → fail OPEN: record
94
+ * the error and let the deploy proceed rather than block on a check we
95
+ * couldn't run. CloudFormation still refuses a genuinely in-use removal.
96
+ *
97
+ * AWS quirk: ListImports throws a ValidationError ("Export ... is not imported
98
+ * by any stack") when an export has zero importers. That's a CLEAN result, not
99
+ * an error — treated as "no importers", not fail-open.
100
+ *
101
+ * @returns { blocked: Array<{exportName, importers}>, unchecked: Array<{exportName, error}> }
102
+ */
103
+ export async function checkExportLock(client, removedExports) {
104
+ const results = await Promise.all((removedExports ?? []).map(async ({ exportName }) => {
105
+ const importers = [];
106
+ try {
107
+ await listImportsFor(client, exportName, importers);
108
+ return importers.length > 0 ? { blocked: { exportName, importers } } : {};
109
+ } catch (err) {
110
+ if (importers.length > 0) return { blocked: { exportName, importers } }; // partial proof → fail closed
111
+ if (isNotImportedError(err)) return {}; // zero importers — safe to remove
112
+ return { unchecked: { exportName, error: err?.message ?? String(err) } };
113
+ }
114
+ }));
115
+ return {
116
+ blocked: results.map((r) => r.blocked).filter(Boolean),
117
+ unchecked: results.map((r) => r.unchecked).filter(Boolean),
118
+ };
119
+ }
120
+
121
+ function isNotImportedError(err) {
122
+ const msg = err?.message ?? '';
123
+ return err?.name === 'ValidationError' && /is not imported by any stack/i.test(msg);
124
+ }
125
+
126
+ // --- C2: frozen-export ------------------------------------------------------
127
+
128
+ /**
129
+ * Live export names matching the consumer's legacy prefixes.
130
+ *
131
+ * `legacyRe` must be the ANCHORED regex (buildAnchoredLegacyPrefixRegex) — the
132
+ * prefix has to be the export name's LEADING segment. The hook's unanchored
133
+ * command-line regex would false-positive on legacy tokens mid-name
134
+ * (`p-acme-pri-lmd-payroll-OLDAPP-arn`). Null regex = no gate (fresh consumer).
135
+ *
136
+ * @returns Array<{ outputKey, exportName }>
137
+ */
138
+ export function findFrozenExports(liveOutputs, legacyRe) {
139
+ if (!legacyRe) return [];
140
+ const out = [];
141
+ for (const o of liveOutputs ?? []) {
142
+ if (!o || !o.ExportName) continue;
143
+ if (legacyRe.test(o.ExportName)) {
144
+ out.push({ outputKey: o.OutputKey, exportName: o.ExportName });
145
+ }
146
+ }
147
+ return out;
148
+ }
149
+
150
+ // Break-glass for the frozen-export gates (mirrors CLAWFORM_SKIP_ACCOUNT_CHECK):
151
+ // intentionally a noisy env var, not a CLI flag. Needed because a frozen-named
152
+ // export, once live, would otherwise also block the remediation deploy that
153
+ // removes it — and unlike the stack-NAME freeze there is no `# FORCE LEGACY`
154
+ // hook path here (the hook never sees export names).
155
+ // Suffix for the lookup, full name for anything a human reads.
156
+ const SKIP_FROZEN_SUFFIX = 'SKIP_FROZEN_EXPORT_CHECK';
157
+ const SKIP_FROZEN_ENV = `${ENV_PREFIX}${SKIP_FROZEN_SUFFIX}`;
158
+
159
+ export function frozenExportCheckSkipped(action) {
160
+ if (readEnv(SKIP_FROZEN_SUFFIX) !== '1') return false;
161
+ logger.warn(
162
+ `${SKIP_FROZEN_ENV}=1 — bypassing the frozen-export gate for this ${action}. ` +
163
+ `Break-glass only; unset to re-enable.`,
164
+ );
165
+ return true;
166
+ }
167
+
168
+ /**
169
+ * Shared C2 gate for deploy AND rollback: refuse a stack that EMITS
170
+ * legacy-prefixed export names, even when the stack NAME isn't frozen. Closes
171
+ * the "stack-name-only" gap in rules/legacy-freeze.md.
172
+ *
173
+ * @param action 'deploy' | 'recovery' — only changes the refusal wording.
174
+ * Throws on a frozen export. Returns void when clean / skipped via break-glass.
175
+ */
176
+ export function assertNoFrozenExports({ liveOutputs, legacyRe, stackName, action = 'deploy' } = {}) {
177
+ const frozen = findFrozenExports(liveOutputs, legacyRe);
178
+ if (frozen.length === 0) return;
179
+ if (frozenExportCheckSkipped(action)) return;
180
+ const names = frozen.map((f) => f.exportName).join(', ');
181
+ throw new Error(
182
+ `Stack "${stackName}" emits export(s) matching a legacy prefix (${names}) from your clawform.config.json ` +
183
+ `legacy_prefixes — even though the stack NAME isn't frozen. Clawform treats legacy-named exports as frozen ` +
184
+ `too (rules/legacy-freeze.md). Refusing ${action === 'recovery' ? 'recovery' : 'to deploy'}.\n` +
185
+ `If this is intentional (e.g. retiring the legacy-named export itself), re-run with ` +
186
+ `${SKIP_FROZEN_ENV}=1, or use the raw aws cloudformation CLI.`,
187
+ );
188
+ }
189
+
190
+ /**
191
+ * C2 introduction gate: refuse a TEMPLATE whose literal Export.Name strings
192
+ * would CREATE a legacy-prefixed export. Without this, deploy would happily
193
+ * mint e.g. `OLDNET-NewThing` on a clean-named stack (the live-outputs gate only
194
+ * sees exports that already exist) — and the live gate would then freeze the
195
+ * stack against its own tooling. Intrinsic Export.Names can't be checked
196
+ * offline; legacy names are literal in practice.
197
+ *
198
+ * @param templateExportNames Set<string> from extractLiteralExportNames()
199
+ * Throws when a frozen name would be introduced. Honors the same break-glass.
200
+ */
201
+ export function assertNoFrozenTemplateExports({ templateExportNames, legacyRe, stackName } = {}) {
202
+ if (!legacyRe) return;
203
+ const frozen = [...(templateExportNames ?? [])].filter((n) => legacyRe.test(n));
204
+ if (frozen.length === 0) return;
205
+ if (frozenExportCheckSkipped('deploy')) return;
206
+ throw new Error(
207
+ `Template for stack "${stackName}" would CREATE export(s) named like a legacy prefix (${frozen.join(', ')}) ` +
208
+ `from your clawform.config.json legacy_prefixes. New exports must follow the going-forward convention ` +
209
+ `(rules/cfn-standards.md) — a legacy-prefixed export would immediately freeze this stack against ` +
210
+ `clawform deploy/rollback (rules/legacy-freeze.md). Rename the export. ` +
211
+ `Break-glass: ${SKIP_FROZEN_ENV}=1.`,
212
+ );
213
+ }