@intentius/chant 0.44.14 → 0.46.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 (220) hide show
  1. package/dist/audit/discover.d.ts +26 -2
  2. package/dist/audit/discover.d.ts.map +1 -1
  3. package/dist/audit/report-model.d.ts +10 -0
  4. package/dist/audit/report-model.d.ts.map +1 -1
  5. package/dist/audit/rules-doc.d.ts.map +1 -1
  6. package/dist/cli/build-params-cli.d.ts +16 -7
  7. package/dist/cli/build-params-cli.d.ts.map +1 -1
  8. package/dist/cli/commands/audit.d.ts +20 -0
  9. package/dist/cli/commands/audit.d.ts.map +1 -1
  10. package/dist/cli/commands/build.d.ts +9 -0
  11. package/dist/cli/commands/build.d.ts.map +1 -1
  12. package/dist/cli/commands/check-lexicon-docs.d.ts +13 -0
  13. package/dist/cli/commands/check-lexicon-docs.d.ts.map +1 -1
  14. package/dist/cli/commands/check-lexicon-examples.d.ts +24 -14
  15. package/dist/cli/commands/check-lexicon-examples.d.ts.map +1 -1
  16. package/dist/cli/commands/check-lexicon.d.ts +14 -0
  17. package/dist/cli/commands/check-lexicon.d.ts.map +1 -1
  18. package/dist/cli/commands/init-lexicon/templates/codegen.d.ts.map +1 -1
  19. package/dist/cli/commands/init-lexicon/templates/docs.d.ts +5 -0
  20. package/dist/cli/commands/init-lexicon/templates/docs.d.ts.map +1 -1
  21. package/dist/cli/commands/init-lexicon.d.ts.map +1 -1
  22. package/dist/cli/commands/lexicon-surface-diff.d.ts +6 -0
  23. package/dist/cli/commands/lexicon-surface-diff.d.ts.map +1 -1
  24. package/dist/cli/commands/lint.d.ts.map +1 -1
  25. package/dist/cli/commands/onboard.d.ts +28 -0
  26. package/dist/cli/commands/onboard.d.ts.map +1 -1
  27. package/dist/cli/handlers/build.d.ts.map +1 -1
  28. package/dist/cli/handlers/graph.d.ts.map +1 -1
  29. package/dist/cli/handlers/lifecycle.d.ts +13 -0
  30. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  31. package/dist/cli/handlers/run.d.ts.map +1 -1
  32. package/dist/cli/handlers/search.d.ts +27 -0
  33. package/dist/cli/handlers/search.d.ts.map +1 -1
  34. package/dist/cli/main.d.ts.map +1 -1
  35. package/dist/cli/registry.d.ts +7 -0
  36. package/dist/cli/registry.d.ts.map +1 -1
  37. package/dist/codegen/docs-pages.d.ts +39 -0
  38. package/dist/codegen/docs-pages.d.ts.map +1 -0
  39. package/dist/codegen/docs-sections.d.ts.map +1 -1
  40. package/dist/codegen/docs-sidebar.d.ts +13 -2
  41. package/dist/codegen/docs-sidebar.d.ts.map +1 -1
  42. package/dist/codegen/docs-types.d.ts +30 -10
  43. package/dist/codegen/docs-types.d.ts.map +1 -1
  44. package/dist/codegen/docs.d.ts +14 -2
  45. package/dist/codegen/docs.d.ts.map +1 -1
  46. package/dist/codegen/lexicon-regen.d.ts +11 -0
  47. package/dist/codegen/lexicon-regen.d.ts.map +1 -1
  48. package/dist/codegen/surface-snapshot.d.ts +27 -3
  49. package/dist/codegen/surface-snapshot.d.ts.map +1 -1
  50. package/dist/codegen/validate.d.ts +21 -3
  51. package/dist/codegen/validate.d.ts.map +1 -1
  52. package/dist/components/capability-plugin.d.ts +13 -2
  53. package/dist/components/capability-plugin.d.ts.map +1 -1
  54. package/dist/components/driver.d.ts +38 -0
  55. package/dist/components/driver.d.ts.map +1 -1
  56. package/dist/components/index.d.ts +1 -1
  57. package/dist/components/index.d.ts.map +1 -1
  58. package/dist/components/starter-plugin.d.ts.map +1 -1
  59. package/dist/config.d.ts +85 -5
  60. package/dist/config.d.ts.map +1 -1
  61. package/dist/discovery/fold-import.d.ts.map +1 -1
  62. package/dist/env.d.ts +12 -1
  63. package/dist/env.d.ts.map +1 -1
  64. package/dist/fold/fold.d.ts +100 -1
  65. package/dist/fold/fold.d.ts.map +1 -1
  66. package/dist/graph-ir.d.ts +12 -0
  67. package/dist/graph-ir.d.ts.map +1 -1
  68. package/dist/graph-ops.d.ts +17 -0
  69. package/dist/graph-ops.d.ts.map +1 -0
  70. package/dist/lexicon.d.ts +182 -2
  71. package/dist/lexicon.d.ts.map +1 -1
  72. package/dist/lifecycle/change-set.d.ts +23 -2
  73. package/dist/lifecycle/change-set.d.ts.map +1 -1
  74. package/dist/lifecycle/deep-observe.d.ts.map +1 -1
  75. package/dist/lifecycle/index.d.ts +1 -0
  76. package/dist/lifecycle/index.d.ts.map +1 -1
  77. package/dist/lifecycle/observe.d.ts +8 -0
  78. package/dist/lifecycle/observe.d.ts.map +1 -1
  79. package/dist/lifecycle/replay.d.ts.map +1 -1
  80. package/dist/lifecycle/snapshot.d.ts.map +1 -1
  81. package/dist/lifecycle/teardown.d.ts +130 -0
  82. package/dist/lifecycle/teardown.d.ts.map +1 -0
  83. package/dist/lifecycle/types.d.ts +7 -0
  84. package/dist/lifecycle/types.d.ts.map +1 -1
  85. package/dist/lint/engine.d.ts +6 -2
  86. package/dist/lint/engine.d.ts.map +1 -1
  87. package/dist/lint/policy.d.ts.map +1 -1
  88. package/dist/lint/rule.d.ts +31 -0
  89. package/dist/lint/rule.d.ts.map +1 -1
  90. package/dist/lint/rules/cor021-env-literal-name.d.ts +3 -0
  91. package/dist/lint/rules/cor021-env-literal-name.d.ts.map +1 -0
  92. package/dist/lint/rules/index.d.ts +2 -1
  93. package/dist/lint/rules/index.d.ts.map +1 -1
  94. package/dist/managed-fields.d.ts +33 -21
  95. package/dist/managed-fields.d.ts.map +1 -1
  96. package/dist/observation.d.ts +23 -1
  97. package/dist/observation.d.ts.map +1 -1
  98. package/dist/op/builders.d.ts +36 -7
  99. package/dist/op/builders.d.ts.map +1 -1
  100. package/dist/op/index.d.ts +1 -1
  101. package/dist/op/index.d.ts.map +1 -1
  102. package/dist/testing.d.ts +136 -0
  103. package/dist/testing.d.ts.map +1 -0
  104. package/dist/yaml.d.ts +0 -8
  105. package/dist/yaml.d.ts.map +1 -1
  106. package/package.json +6 -1
  107. package/src/__snapshots__/okf.test.ts.snap +20 -0
  108. package/src/audit/discover.test.ts +55 -1
  109. package/src/audit/discover.ts +70 -2
  110. package/src/audit/edge-init-safety.test.ts +149 -18
  111. package/src/audit/report-model.ts +12 -1
  112. package/src/audit/rules-doc.ts +1 -0
  113. package/src/build-params.test.ts +52 -0
  114. package/src/cli/build-params-cli.test.ts +25 -8
  115. package/src/cli/build-params-cli.ts +37 -23
  116. package/src/cli/commands/__fixtures__/audit-coverage/.github/workflows/ci.yml +5 -0
  117. package/src/cli/commands/__fixtures__/audit-coverage/Dockerfile +2 -0
  118. package/src/cli/commands/__fixtures__/audit-coverage/infra/main.tf +3 -0
  119. package/src/cli/commands/__fixtures__/audit-coverage/infra/stack.json +6 -0
  120. package/src/cli/commands/__fixtures__/audit-coverage/k8s/deploy.yaml +12 -0
  121. package/src/cli/commands/__fixtures__/init-lexicon-output/docs/pages/getting-started.mdx +16 -0
  122. package/src/cli/commands/__fixtures__/init-lexicon-output/docs/src/content.config.ts +10 -2
  123. package/src/cli/commands/__fixtures__/init-lexicon-output/src/codegen/generate-cli.ts +3 -1
  124. package/src/cli/commands/__fixtures__/init-lexicon-output/src/codegen/generate.ts +2 -1
  125. package/src/cli/commands/__snapshots__/init-lexicon.test.ts.snap +2 -1
  126. package/src/cli/commands/audit.test.ts +83 -1
  127. package/src/cli/commands/audit.ts +121 -28
  128. package/src/cli/commands/build.test.ts +275 -5
  129. package/src/cli/commands/build.ts +86 -14
  130. package/src/cli/commands/check-lexicon-docs.ts +30 -0
  131. package/src/cli/commands/check-lexicon-examples.test.ts +73 -7
  132. package/src/cli/commands/check-lexicon-examples.ts +66 -17
  133. package/src/cli/commands/check-lexicon.test.ts +46 -2
  134. package/src/cli/commands/check-lexicon.ts +64 -4
  135. package/src/cli/commands/init-lexicon/templates/codegen.ts +5 -2
  136. package/src/cli/commands/init-lexicon/templates/docs.ts +35 -2
  137. package/src/cli/commands/init-lexicon.test.ts +42 -1
  138. package/src/cli/commands/init-lexicon.ts +3 -1
  139. package/src/cli/commands/lexicon-rolling-upgrade.ts +1 -1
  140. package/src/cli/commands/lexicon-surface-diff.test.ts +1 -1
  141. package/src/cli/commands/lexicon-surface-diff.ts +10 -1
  142. package/src/cli/commands/lexicon-surface-diff.update.test.ts +112 -0
  143. package/src/cli/commands/lint.ts +19 -6
  144. package/src/cli/commands/onboard.test.ts +319 -225
  145. package/src/cli/commands/onboard.ts +144 -58
  146. package/src/cli/handlers/build.test.ts +3 -3
  147. package/src/cli/handlers/build.ts +2 -0
  148. package/src/cli/handlers/components.ts +2 -2
  149. package/src/cli/handlers/graph.test.ts +44 -0
  150. package/src/cli/handlers/graph.ts +24 -4
  151. package/src/cli/handlers/lifecycle.test.ts +348 -1
  152. package/src/cli/handlers/lifecycle.ts +235 -3
  153. package/src/cli/handlers/run.test.ts +5 -5
  154. package/src/cli/handlers/run.ts +1 -0
  155. package/src/cli/handlers/search-live.test.ts +217 -0
  156. package/src/cli/handlers/search.test.ts +41 -1
  157. package/src/cli/handlers/search.ts +60 -8
  158. package/src/cli/main.ts +17 -4
  159. package/src/cli/registry.ts +7 -0
  160. package/src/cli/security-boundary.test.ts +2 -1
  161. package/src/codegen/docs-pages.test.ts +194 -0
  162. package/src/codegen/docs-pages.ts +138 -0
  163. package/src/codegen/docs-sections.ts +0 -8
  164. package/src/codegen/docs-sidebar.ts +50 -37
  165. package/src/codegen/docs-types.ts +32 -4
  166. package/src/codegen/docs.ts +78 -40
  167. package/src/codegen/lexicon-regen.ts +22 -2
  168. package/src/codegen/rolling-upgrade.test.ts +1 -1
  169. package/src/codegen/rolling-upgrade.ts +1 -1
  170. package/src/codegen/surface-snapshot.test.ts +139 -0
  171. package/src/codegen/surface-snapshot.ts +297 -122
  172. package/src/codegen/validate.test.ts +49 -3
  173. package/src/codegen/validate.ts +36 -7
  174. package/src/components/auto-release.ts +1 -1
  175. package/src/components/capability-plugin.ts +23 -2
  176. package/src/components/driver.test.ts +45 -0
  177. package/src/components/driver.ts +64 -27
  178. package/src/components/index.ts +2 -0
  179. package/src/components/starter-plugin.ts +5 -2
  180. package/src/config.test.ts +40 -0
  181. package/src/config.ts +159 -8
  182. package/src/discovery/fold-import.test.ts +427 -2
  183. package/src/discovery/fold-import.ts +163 -7
  184. package/src/discovery/sandbox/driver.test.ts +11 -0
  185. package/src/env.test.ts +35 -1
  186. package/src/env.ts +17 -3
  187. package/src/fold/fold.test.ts +41 -0
  188. package/src/fold/fold.ts +372 -2
  189. package/src/graph-ir-live.test.ts +42 -0
  190. package/src/graph-ir.ts +42 -2
  191. package/src/graph-ops.test.ts +69 -0
  192. package/src/graph-ops.ts +42 -0
  193. package/src/lexicon.ts +182 -2
  194. package/src/lifecycle/change-set.test.ts +41 -0
  195. package/src/lifecycle/change-set.ts +30 -2
  196. package/src/lifecycle/deep-observe.ts +4 -0
  197. package/src/lifecycle/index.ts +1 -0
  198. package/src/lifecycle/observe.ts +29 -2
  199. package/src/lifecycle/replay.ts +3 -0
  200. package/src/lifecycle/snapshot.ts +3 -0
  201. package/src/lifecycle/teardown.test.ts +537 -0
  202. package/src/lifecycle/teardown.ts +357 -0
  203. package/src/lifecycle/types.ts +7 -0
  204. package/src/lint/engine.ts +7 -1
  205. package/src/lint/policy.ts +14 -3
  206. package/src/lint/rule.ts +23 -0
  207. package/src/lint/rules/cor021-env-literal-name.test.ts +128 -0
  208. package/src/lint/rules/cor021-env-literal-name.ts +114 -0
  209. package/src/lint/rules/index.ts +4 -1
  210. package/src/managed-fields.test.ts +15 -42
  211. package/src/managed-fields.ts +43 -26
  212. package/src/observation.test.ts +24 -8
  213. package/src/observation.ts +48 -4
  214. package/src/op/builders.ts +40 -7
  215. package/src/op/index.ts +1 -1
  216. package/src/ownership.test.ts +63 -1
  217. package/src/testing.test.ts +261 -0
  218. package/src/testing.ts +338 -0
  219. package/src/yaml.test.ts +15 -0
  220. package/src/yaml.ts +19 -2
@@ -30,7 +30,9 @@ import { discoverComponents } from "../../components/discover";
30
30
  import { cfnDeployStacks } from "./components";
31
31
  import { affectedStacks } from "../../lifecycle/affected";
32
32
  import { rollbackToRevision } from "../../lifecycle/rollback";
33
- import { loadChantConfig, environmentNames } from "../../config";
33
+ import { loadChantConfig, environmentNames, matchesDeclaredEnvironment, resolveOwnershipStack } from "../../config";
34
+ import { unknownEnvError, isProdLikeEnvironment } from "../../env";
35
+ import { planTeardown, executeTeardown, type TeardownPlan, type TeardownReport } from "../../lifecycle/teardown";
34
36
  import { collectBuildRootContributors } from "../plugins";
35
37
  import { applyLiveEndpoint } from "../../live-endpoint";
36
38
  import { isResourceDeclarable } from "../../declarable";
@@ -109,7 +111,7 @@ export async function runLifecycleSnapshot(ctx: CommandContext): Promise<number>
109
111
  const declaredParams = await commandBuildParams(config.buildParams, args);
110
112
  if (!declaredParams) return 1;
111
113
  const declaredEnvNames = environmentNames(config.environments);
112
- if (declaredEnvNames && !declaredEnvNames.includes(environment)) {
114
+ if (declaredEnvNames && !matchesDeclaredEnvironment(config.environments, environment)) {
113
115
  console.error(formatError({
114
116
  message: `Unknown environment "${environment}"`,
115
117
  hint: `Defined environments: ${declaredEnvNames.join(", ")}`,
@@ -416,6 +418,7 @@ export async function runLifecycleDiff(ctx: CommandContext): Promise<number> {
416
418
  buildResult,
417
419
  json,
418
420
  stack: target.stack,
421
+ region: target.region,
419
422
  componentStacks,
420
423
  baseline,
421
424
  updateBaseline: args.updateBaseline,
@@ -650,6 +653,11 @@ interface LiveDiffArgs {
650
653
  /** Deployed stack name for a multi-stack project (#932); scopes the live
651
654
  * observation (which CloudFormation stack to query) and the snapshot read. */
652
655
  stack?: string;
656
+ /** Region that stack is deployed in, from `stacks[].region` (#1264). Same
657
+ * contract as snapshot (#1261): without it every stack is read against the
658
+ * ambient region, so a multi-region estate reports its out-of-region stacks
659
+ * as absent rather than unobserved. */
660
+ region?: string;
653
661
  /** Component projects deploy one CFN stack per component; observe them all and
654
662
  * union (the same fix graph/plan use), else every deployed resource reads as
655
663
  * "missing". Empty → the single-stack observe path. */
@@ -710,6 +718,9 @@ async function observeLexicon(
710
718
  declared: Set<string>;
711
719
  entities: Map<string, { entityType: string; props: Record<string, unknown> }>;
712
720
  stack?: string;
721
+ /** Region the stack is deployed in (#1264); the read targets it instead of
722
+ * the ambient region. */
723
+ region?: string;
713
724
  componentStacks?: string[];
714
725
  owned?: boolean;
715
726
  /** `--namespace <ns>` (#1629): where to read an entity that declares no
@@ -723,6 +734,7 @@ async function observeLexicon(
723
734
  buildOutput: opts.buildOutput,
724
735
  entityNames,
725
736
  entities: opts.entities,
737
+ ...(opts.region ? { region: opts.region } : {}),
726
738
  ...(opts.owned !== undefined ? { owned: opts.owned } : {}),
727
739
  ...(opts.namespace ? { namespace: opts.namespace } : {}),
728
740
  };
@@ -746,6 +758,7 @@ async function observeLexicon(
746
758
  resources: {},
747
759
  unobserved: unobservedAll(entityNames, "read-failed", message, opts.entities),
748
760
  queried: {},
761
+ notes: [],
749
762
  };
750
763
  }
751
764
  }
@@ -811,6 +824,7 @@ async function runLifecycleDiffLive(args: LiveDiffArgs): Promise<LiveDiffOutcome
811
824
  declared,
812
825
  entities,
813
826
  stack: args.stack,
827
+ region: args.region,
814
828
  componentStacks: args.componentStacks,
815
829
  ...(args.namespace ? { namespace: args.namespace } : {}),
816
830
  });
@@ -839,6 +853,7 @@ async function runLifecycleDiffLive(args: LiveDiffArgs): Promise<LiveDiffOutcome
839
853
  buildOutput,
840
854
  entities,
841
855
  stack: args.stack,
856
+ region: args.region,
842
857
  componentStacks: args.componentStacks,
843
858
  baseline: baselineForLexicon(args.baseline, lexiconName),
844
859
  });
@@ -1181,6 +1196,9 @@ export async function runLifecyclePlan(ctx: CommandContext): Promise<number> {
1181
1196
  // The resolved read address rides every entry, not just unobserved
1182
1197
  // ones — the diff path already passes it (#1620); plan lost it.
1183
1198
  queried: observed.queried,
1199
+ }, {
1200
+ // Attribution survives the flat merge below (#1674).
1201
+ lexicon: lexiconName,
1184
1202
  });
1185
1203
  merged.entries.push(...cs.entries);
1186
1204
  checked++;
@@ -1224,6 +1242,220 @@ export async function runLifecyclePlan(ctx: CommandContext): Promise<number> {
1224
1242
  return 0;
1225
1243
  }
1226
1244
 
1245
+ /**
1246
+ * chant lifecycle teardown <environment> (#1222).
1247
+ *
1248
+ * Enumerates what deleting the environment would remove: every live resource
1249
+ * carrying this project's ownership marker (managed-by + `ownership.stack`)
1250
+ * with the requested env identity. Stateless — live markers only, no build, no
1251
+ * snapshot. Without `--yes` nothing is deleted; with it the planned set is
1252
+ * executed per lexicon with one bounded retry pass over failures, and every
1253
+ * candidate's outcome is reported (deleted / failed / not-prunable / skipped —
1254
+ * never silence). A production-like environment name additionally requires an
1255
+ * interactive confirmation, or `--confirm-prod` non-interactively.
1256
+ */
1257
+ export async function runLifecycleTeardown(ctx: CommandContext): Promise<number> {
1258
+ const { args, plugins } = ctx;
1259
+ const environment = args.extraPositional;
1260
+
1261
+ if (!environment) {
1262
+ console.error(formatError({ message: "Environment is required: chant lifecycle teardown <environment>" }));
1263
+ return 1;
1264
+ }
1265
+
1266
+ const { config } = await loadChantConfig(resolve("."));
1267
+
1268
+ // Refuse an env the project does not declare — a typo here is the difference
1269
+ // between tearing down `dev` and tearing down `prod`. Literal `environments`
1270
+ // entries only (#1221's pattern entries would extend this same check).
1271
+ const envErr = unknownEnvError(environment, config.environments);
1272
+ if (envErr) {
1273
+ console.error(formatError({ message: envErr }));
1274
+ return 1;
1275
+ }
1276
+
1277
+ // Teardown selects on the ownership marker; a project that stamps none has
1278
+ // nothing to key on, and "delete what looks like mine" is not a fallback.
1279
+ const stack = resolveOwnershipStack(config);
1280
+ if (stack === undefined) {
1281
+ console.error(formatError({
1282
+ message: "This project declares no ownership.stack — teardown is marker-scoped and has nothing to select on",
1283
+ hint: 'Set `ownership: { stack: "<name>" }` in chant.config.ts and deploy, so resources carry the marker teardown keys on.',
1284
+ }));
1285
+ return 1;
1286
+ }
1287
+
1288
+ // The prod guard (#1222): a production-like name never falls to `--yes`
1289
+ // alone. Interactive runs re-type the environment name; non-interactive
1290
+ // runs say `--confirm-prod` explicitly. Checked before any live read so a
1291
+ // refused teardown touches nothing at all.
1292
+ if (args.yes && isProdLikeEnvironment(environment) && !args.confirmProd) {
1293
+ if (!process.stdin.isTTY) {
1294
+ console.error(formatError({
1295
+ message: `"${environment}" looks like a production environment — --yes alone is not enough`,
1296
+ hint: "Re-run with --yes --confirm-prod to tear it down non-interactively.",
1297
+ }));
1298
+ return 1;
1299
+ }
1300
+ const confirmed = await promptProdTeardown(environment);
1301
+ if (!confirmed) {
1302
+ console.error(formatError({ message: "Confirmation did not match — nothing was deleted." }));
1303
+ return 1;
1304
+ }
1305
+ }
1306
+
1307
+ // #1166 — teardown is always a live read (and with --yes, a live write), so
1308
+ // an environment's declared endpoint applies here too, unless the ambient
1309
+ // shell already set it.
1310
+ const readingPlugins = plugins.filter((p) => p.teardownOwned || p.describeResources || p.executeTeardown);
1311
+ const endpointResult = applyLiveEndpoint(config.environments, environment, readingPlugins);
1312
+ if (endpointResult.notice) console.error(formatWarning({ message: endpointResult.notice }));
1313
+
1314
+ let plan: TeardownPlan;
1315
+ let report: TeardownReport | undefined;
1316
+ try {
1317
+ // A multi-stack project's declared stacks, for stack-shaped teardowns
1318
+ // (aws enumerates and deletes whole CloudFormation stacks). A single-stack
1319
+ // project passes nothing and the env-named default convention applies.
1320
+ const deployedStacks = (config.stacks ?? []).map((s) => ({
1321
+ name: s.name,
1322
+ ...(s.region ? { region: s.region } : {}),
1323
+ }));
1324
+ plan = await planTeardown({
1325
+ environment,
1326
+ stack,
1327
+ plugins,
1328
+ ...(deployedStacks.length > 0 ? { deployedStacks } : {}),
1329
+ });
1330
+ if (args.yes) {
1331
+ report = await executeTeardown({
1332
+ environment,
1333
+ stack,
1334
+ plugins,
1335
+ plan,
1336
+ ...(deployedStacks.length > 0 ? { deployedStacks } : {}),
1337
+ });
1338
+ }
1339
+ } finally {
1340
+ endpointResult.restore();
1341
+ }
1342
+
1343
+ if (args.json) {
1344
+ console.log(JSON.stringify(report ?? plan, null, 2));
1345
+ return report && report.outcomes.some((o) => o.outcome === "failed") ? 1 : 0;
1346
+ }
1347
+
1348
+ console.log(formatBold(
1349
+ args.yes
1350
+ ? `Teardown — environment: ${environment}, stack: ${stack}`
1351
+ : `Teardown plan — environment: ${environment}, stack: ${stack} (plan only — nothing is deleted)`,
1352
+ ));
1353
+
1354
+ if (plan.entries.length === 0) {
1355
+ console.error(formatWarning({
1356
+ message: `No live resources carry the marker stack "${stack}" + env "${environment}" — nothing would be deleted.` +
1357
+ (plan.holes.length > 0
1358
+ ? " But this plan has holes (below) — parts of the estate could not be read, so \"nothing\" is a claim about what was readable, not about the environment."
1359
+ : " If this environment is deployed, check that its resources were stamped (ownership marking on, and the env identity resolved at build time)."),
1360
+ }));
1361
+ } else {
1362
+ console.log(`\n${plan.entries.length} resource(s) would be deleted:`);
1363
+ console.log("RESOURCE".padEnd(28) + "TYPE".padEnd(32) + "MARKER".padEnd(24) + "LEXICON");
1364
+ console.log("-".repeat(96));
1365
+ for (const entry of plan.entries) {
1366
+ console.log(
1367
+ entry.name.padEnd(28) +
1368
+ entry.type.padEnd(32) +
1369
+ `${entry.marker.stack}/${entry.marker.env}`.padEnd(24) +
1370
+ entry.lexicon,
1371
+ );
1372
+ }
1373
+ }
1374
+
1375
+ // Holes are loud (#1089): an unreadable kind is unknown, not absent, and the
1376
+ // execution half must not treat this plan as the whole delete set.
1377
+ if (plan.holes.length > 0) {
1378
+ console.error(formatWarning({
1379
+ message: `${plan.holes.length} hole(s) — resources chant may own but could not read. This plan is incomplete, not clean.`,
1380
+ }));
1381
+ console.log(formatBold("\nHOLES (stamped kinds the read could not cover):"));
1382
+ for (const hole of plan.holes) {
1383
+ console.log(` ? ${hole.lexicon}: ${hole.name}${hole.type ? ` (${hole.type})` : ""} — ${hole.reason}${hole.detail ? `: ${hole.detail}` : ""}`);
1384
+ }
1385
+ }
1386
+
1387
+ if (plan.skipped.length > 0) {
1388
+ console.error(formatWarning({
1389
+ message: `Skipped (no teardown enumeration and no describeResources): ${plan.skipped.join(", ")} — those lexicons' resources are not in this plan.`,
1390
+ }));
1391
+ }
1392
+
1393
+ if (!report) {
1394
+ console.error(formatWarning({ message: "Plan only — re-run with --yes to execute." }));
1395
+ return 0;
1396
+ }
1397
+
1398
+ if (report.outcomes.length > 0) {
1399
+ console.log(formatBold("\nOutcomes:"));
1400
+ console.log("RESOURCE".padEnd(28) + "OUTCOME".padEnd(14) + "LEXICON".padEnd(12) + "DETAIL");
1401
+ console.log("-".repeat(96));
1402
+ for (const o of report.outcomes) {
1403
+ console.log(
1404
+ o.name.padEnd(28) +
1405
+ o.outcome.padEnd(14) +
1406
+ o.lexicon.padEnd(12) +
1407
+ (o.detail ?? "") +
1408
+ (o.retried ? " (after retry)" : ""),
1409
+ );
1410
+ }
1411
+ }
1412
+
1413
+ const counts = { deleted: 0, failed: 0, "not-prunable": 0, skipped: 0 };
1414
+ for (const o of report.outcomes) counts[o.outcome]++;
1415
+ console.log(
1416
+ `\n${counts.deleted} deleted, ${counts.failed} failed, ${counts["not-prunable"]} not prunable, ${counts.skipped} skipped`,
1417
+ );
1418
+
1419
+ if (report.unimplemented.length > 0) {
1420
+ console.error(formatWarning({
1421
+ message: `Not executed (no teardown execution in these lexicons yet): ${report.unimplemented.join(", ")} — their candidates are reported as skipped, not deleted.`,
1422
+ }));
1423
+ }
1424
+ if (plan.holes.length > 0) {
1425
+ console.error(formatWarning({
1426
+ message: "This teardown ran over an incomplete plan (holes above) — the environment cannot be called clean.",
1427
+ }));
1428
+ }
1429
+ if (counts.failed > 0) {
1430
+ console.error(formatError({
1431
+ message: `${counts.failed} candidate(s) failed to delete after the retry pass — see the outcomes above.`,
1432
+ }));
1433
+ return 1;
1434
+ }
1435
+
1436
+ return 0;
1437
+ }
1438
+
1439
+ /**
1440
+ * The interactive half of the prod guard: the operator re-types the
1441
+ * environment name. Anything else — including EOF — refuses.
1442
+ */
1443
+ async function promptProdTeardown(environment: string): Promise<boolean> {
1444
+ const { createInterface } = await import("node:readline");
1445
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
1446
+ return new Promise((resolvePrompt) => {
1447
+ rl.question(
1448
+ `"${environment}" looks like a production environment. Type the environment name to confirm teardown: `,
1449
+ (answer) => {
1450
+ resolvePrompt(answer.trim() === environment);
1451
+ rl.close();
1452
+ },
1453
+ );
1454
+ // EOF (Ctrl-D) closes the interface without answering — that is a refusal.
1455
+ rl.on("close", () => resolvePrompt(false));
1456
+ });
1457
+ }
1458
+
1227
1459
  /**
1228
1460
  * chant lifecycle log [environment]
1229
1461
  */
@@ -1252,7 +1484,7 @@ export async function runLifecycleLog(ctx: CommandContext): Promise<number> {
1252
1484
  export async function runLifecycleUnknown(ctx: CommandContext): Promise<number> {
1253
1485
  console.error(formatError({
1254
1486
  message: `Unknown state subcommand: ${ctx.args.extraPositional ?? ctx.args.path}`,
1255
- hint: "Available: chant lifecycle snapshot, chant lifecycle show, chant lifecycle diff, chant lifecycle plan, chant lifecycle log",
1487
+ hint: "Available: chant lifecycle snapshot, chant lifecycle show, chant lifecycle diff, chant lifecycle plan, chant lifecycle teardown, chant lifecycle log",
1256
1488
  }));
1257
1489
  return 1;
1258
1490
  }
@@ -101,7 +101,7 @@ function makeOp(name: string, depends: string[] = []): [string, { config: { name
101
101
  function setupTemporalClient(mock: ReturnType<typeof createMockTemporalClient>) {
102
102
  loadTemporalClientMock.mockResolvedValue({
103
103
  Connection: { connect: vi.fn(async () => ({})) },
104
- Client: vi.fn(() => mock.client) as unknown as new () => unknown,
104
+ Client: vi.fn(function () { return mock.client; }) as unknown as new () => unknown,
105
105
  });
106
106
  loadChantConfigMock.mockResolvedValue({ config: {} });
107
107
  resolveProfileMock.mockReturnValue({ address: "localhost:7233", namespace: "default", taskQueue: "q" });
@@ -951,9 +951,9 @@ describe("runOpComponents", () => {
951
951
  expect(runComponentsMock).toHaveBeenCalledWith(expect.any(String), "svc", expect.objectContaining({
952
952
  buildParams: [{ name: "tier", value: "light", source: "default" }],
953
953
  }));
954
- // A parameter at its default collapses into the count line now; the
955
- // provenance forwarded above is where its value is asserted.
956
- expect(stderr.join("\n")).toContain("at their defaults");
954
+ // The echo is a one-line count (#1424); the provenance forwarded above
955
+ // is where the value is asserted.
956
+ expect(stderr.join("\n")).toContain("1 build parameter resolved (1 default)");
957
957
  });
958
958
 
959
959
  test("--param overrides a declared default and is threaded through to runComponents", async () => {
@@ -978,7 +978,7 @@ describe("runOpComponents", () => {
978
978
  expect(runComponentsMock).toHaveBeenCalledWith(expect.any(String), "svc", expect.objectContaining({
979
979
  buildParams: [{ name: "tier", value: "production", source: "cli" }],
980
980
  }));
981
- expect(stderr.join("\n")).toContain("[param] tier");
981
+ expect(stderr.join("\n")).toContain("1 build parameter resolved (1 from cli)");
982
982
  vi.restoreAllMocks();
983
983
  });
984
984
 
@@ -714,6 +714,7 @@ export async function runOpComponents(ctx: CommandContext): Promise<number> {
714
714
  const paramsResolution = resolveCliBuildParams(config.buildParams, {
715
715
  cli: parseParamFlags(ctx.args.param),
716
716
  paramsFile: ctx.args.paramsFile,
717
+ verbose: ctx.args.verbose,
717
718
  });
718
719
  if (!paramsResolution.success) {
719
720
  for (const message of paramsResolution.errors) console.error(message);
@@ -0,0 +1,217 @@
1
+ import { describe, test, expect, vi, beforeEach } from "vitest";
2
+ import type { ParsedArgs } from "../registry";
3
+ import { DECLARABLE_MARKER, type Declarable } from "../../declarable";
4
+ import { createMockPlugin } from "../../../../test-utils/src/mock-plugin";
5
+
6
+ // #1263 — `chant search --live` against an unreachable endpoint returned the
7
+ // declared rows, exit 0, and a footer naming derived facts it never computed.
8
+ // The observe layer already records a thrown read as NOT-OBSERVED
9
+ // (`read-failed`, #1089); these tests pin that the handler carries it through
10
+ // to the rows, the footer, stderr, and the exit code. `observeResources` is
11
+ // real here so the plumbing from a throwing `describeResources` is exercised.
12
+
13
+ const discoverMock = vi.fn();
14
+ vi.mock("../../discovery/index", () => ({
15
+ discover: (...a: unknown[]) => discoverMock(...a),
16
+ }));
17
+ const loadPluginsMock = vi.fn();
18
+ const resolveLexMock = vi.fn();
19
+ vi.mock("../plugins", async () => {
20
+ const actual = await vi.importActual<typeof import("../plugins")>("../plugins");
21
+ return {
22
+ ...actual,
23
+ loadPlugins: (...a: unknown[]) => loadPluginsMock(...a),
24
+ resolveProjectLexicons: (...a: unknown[]) => resolveLexMock(...a),
25
+ };
26
+ });
27
+ const hasSnapshotMock = vi.fn((..._a: unknown[]) => Promise.resolve(false));
28
+ vi.mock("../../lifecycle/replay", () => ({
29
+ replaySnapshots: vi.fn(),
30
+ hasSnapshot: (...a: unknown[]) => hasSnapshotMock(...a),
31
+ }));
32
+ const loadChantConfigMock = vi.fn();
33
+ vi.mock("../../config", async () => {
34
+ const actual = await vi.importActual<typeof import("../../config")>("../../config");
35
+ return { ...actual, loadChantConfig: (...a: unknown[]) => loadChantConfigMock(...a) };
36
+ });
37
+ const buildResultMock = vi.fn();
38
+ vi.mock("../../build", async () => {
39
+ const actual = await vi.importActual<typeof import("../../build")>("../../build");
40
+ return {
41
+ ...actual,
42
+ build: (...a: unknown[]) => Promise.resolve(buildResultMock(...a)),
43
+ buildProject: (...a: unknown[]) => Promise.resolve(buildResultMock(...a)),
44
+ };
45
+ });
46
+
47
+ const { runSearch } = await import("./search");
48
+
49
+ function decl<T extends object>(base: T): Declarable & T {
50
+ return { [DECLARABLE_MARKER]: true, ...base } as Declarable & T;
51
+ }
52
+
53
+ function makeArgs(overrides: Partial<ParsedArgs> = {}): ParsedArgs {
54
+ return {
55
+ command: "search", path: "kind:Instance",
56
+ format: "", fix: false, watch: false, verbose: false, help: false, live: true, env: "dev",
57
+ ...overrides,
58
+ };
59
+ }
60
+
61
+ describe("search --live when the live read fails (#1263)", () => {
62
+ let out: string[];
63
+ let err: string[];
64
+ const entities = new Map<string, Declarable>([
65
+ ["webServer", decl({ lexicon: "aws", entityType: "AWS::EC2::Instance", props: {} })],
66
+ ["privateServer", decl({ lexicon: "aws", entityType: "AWS::EC2::Instance", props: {} })],
67
+ ]);
68
+
69
+ beforeEach(() => {
70
+ out = [];
71
+ err = [];
72
+ vi.spyOn(console, "log").mockImplementation((s: string) => { out.push(s); });
73
+ vi.spyOn(console, "error").mockImplementation((s: string) => { err.push(s); });
74
+ discoverMock.mockReset();
75
+ discoverMock.mockResolvedValue({ entities, errors: [], sourceFiles: [] });
76
+ buildResultMock.mockReset();
77
+ buildResultMock.mockReturnValue({ errors: [], entities, outputs: new Map([["aws", ""]]), warnings: [] });
78
+ loadChantConfigMock.mockReset();
79
+ loadChantConfigMock.mockResolvedValue({ config: {} });
80
+ resolveLexMock.mockReset();
81
+ resolveLexMock.mockResolvedValue(["aws"]);
82
+ hasSnapshotMock.mockReset();
83
+ hasSnapshotMock.mockResolvedValue(false);
84
+ });
85
+
86
+ const deadEndpoint = createMockPlugin({
87
+ name: "aws",
88
+ describeResources: async () => {
89
+ throw new Error("connect ECONNREFUSED 127.0.0.1:9999");
90
+ },
91
+ });
92
+
93
+ test("exits non-zero, names the lexicon and cause, and marks every row unobserved", async () => {
94
+ loadPluginsMock.mockResolvedValue([deadEndpoint]);
95
+ const exit = await runSearch({ args: makeArgs(), plugins: [], serializers: [] });
96
+ expect(exit).toBe(1);
97
+ const stderr = err.join("\n");
98
+ expect(stderr).toContain("aws");
99
+ expect(stderr).toContain("ECONNREFUSED 127.0.0.1:9999");
100
+ expect(stderr).toContain("declared-only");
101
+ const stdout = out.join("\n");
102
+ expect(stdout).toContain("webServer AWS::EC2::Instance (unobserved: read-failed)");
103
+ expect(stdout).toContain("privateServer AWS::EC2::Instance (unobserved: read-failed)");
104
+ expect(stdout).toContain("live read failed (aws) · 2/2 rows unobserved");
105
+ // Nothing was read, so no fold over live topology could have run.
106
+ expect(stdout).not.toContain("also derived");
107
+ });
108
+
109
+ test("points at a recorded snapshot when one exists", async () => {
110
+ loadPluginsMock.mockResolvedValue([deadEndpoint]);
111
+ hasSnapshotMock.mockResolvedValue(true);
112
+ const exit = await runSearch({ args: makeArgs(), plugins: [], serializers: [] });
113
+ expect(exit).toBe(1);
114
+ expect(out.join("\n")).toContain("--at latest");
115
+ expect(err.join("\n")).toContain("--at latest");
116
+ });
117
+
118
+ test("a working live read still exits 0 with bound rows", async () => {
119
+ loadPluginsMock.mockResolvedValue([
120
+ createMockPlugin({
121
+ name: "aws",
122
+ describeResources: async () => ({
123
+ webServer: { type: "AWS::EC2::Instance", physicalId: "i-1", status: "OK", owned: true },
124
+ privateServer: { type: "AWS::EC2::Instance", physicalId: "i-2", status: "OK", owned: true },
125
+ }),
126
+ }),
127
+ ]);
128
+ const exit = await runSearch({ args: makeArgs(), plugins: [], serializers: [] });
129
+ expect(exit).toBe(0);
130
+ const stdout = out.join("\n");
131
+ expect(stdout).toContain("webServer AWS::EC2::Instance i-1");
132
+ expect(stdout).toContain("observed live · bound 2/2");
133
+ expect(err.join("\n")).not.toContain("live read failed");
134
+ });
135
+ });
136
+
137
+ // #1265 — a lexicon's run-level note ("ownership filter unavailable on this
138
+ // path") printed once per stack, ahead of the rows. It is one fact about the
139
+ // read, so it is said once, and it qualifies the answer, so it follows it.
140
+ describe("search --live run-level notes (#1265)", () => {
141
+ const NOTE =
142
+ "ownership filter unavailable on describeResources (no tags from describe-stack-resources) — returning all, each with an explicit `unknown` verdict; use `chant import --from <env> --owned` for ownership-filtered export";
143
+ let out: string[];
144
+ let err: string[];
145
+ // One log of everything, in the order it was written, so the test can see
146
+ // whether the note came before or after the rows.
147
+ let all: string[];
148
+ const entities = new Map<string, Declarable>([
149
+ ["webServer", decl({ lexicon: "aws", entityType: "AWS::EC2::Instance", props: {} })],
150
+ ["privateServer", decl({ lexicon: "aws", entityType: "AWS::EC2::Instance", props: {} })],
151
+ ]);
152
+ const calls: string[] = [];
153
+
154
+ beforeEach(() => {
155
+ out = [];
156
+ err = [];
157
+ all = [];
158
+ calls.length = 0;
159
+ vi.spyOn(console, "log").mockImplementation((s: string) => { out.push(s); all.push(s); });
160
+ vi.spyOn(console, "error").mockImplementation((s: string) => { err.push(s); all.push(s); });
161
+ vi.spyOn(console, "warn").mockImplementation((s: string) => { all.push(s); });
162
+ discoverMock.mockReset();
163
+ discoverMock.mockResolvedValue({ entities, errors: [], sourceFiles: [] });
164
+ buildResultMock.mockReset();
165
+ buildResultMock.mockReturnValue({ errors: [], entities, outputs: new Map([["aws", ""]]), warnings: [] });
166
+ loadChantConfigMock.mockReset();
167
+ loadChantConfigMock.mockResolvedValue({
168
+ config: { stacks: [{ name: "net" }, { name: "data" }, { name: "app" }, { name: "edge" }] },
169
+ });
170
+ resolveLexMock.mockReset();
171
+ resolveLexMock.mockResolvedValue(["aws"]);
172
+ hasSnapshotMock.mockReset();
173
+ hasSnapshotMock.mockResolvedValue(false);
174
+ loadPluginsMock.mockResolvedValue([
175
+ createMockPlugin({
176
+ name: "aws",
177
+ describeResources: async (opts: { stack?: string }) => {
178
+ calls.push(opts.stack ?? "");
179
+ return {
180
+ observation: "v1" as const,
181
+ resources: {
182
+ webServer: { type: "AWS::EC2::Instance", physicalId: "i-1", status: "OK", ownership: "unknown" as const },
183
+ privateServer: { type: "AWS::EC2::Instance", physicalId: "i-2", status: "OK", ownership: "unknown" as const },
184
+ },
185
+ notes: [NOTE],
186
+ };
187
+ },
188
+ }),
189
+ ]);
190
+ });
191
+
192
+ test("four stacks, one note, after the rows and the provenance line", async () => {
193
+ const exit = await runSearch({ args: makeArgs(), plugins: [], serializers: [] });
194
+ expect(exit).toBe(0);
195
+ expect(calls).toEqual(["net", "data", "app", "edge"]);
196
+ const notes = all.filter((line) => line.includes("ownership filter unavailable"));
197
+ expect(notes).toHaveLength(1);
198
+ expect(notes[0]).toContain(`[aws] ${NOTE}`);
199
+ const noteAt = all.findIndex((line) => line.includes("ownership filter unavailable"));
200
+ const lastRow = all.findIndex((line) => line.startsWith("privateServer AWS::EC2::Instance i-2"));
201
+ const footer = all.findIndex((line) => line.includes("observed live · bound 2/2"));
202
+ expect(lastRow).toBeGreaterThanOrEqual(0);
203
+ expect(footer).toBeGreaterThan(lastRow);
204
+ expect(noteAt).toBeGreaterThan(footer);
205
+ // stderr, like every other warning; the rows stay clean on stdout.
206
+ expect(out.join("\n")).not.toContain("ownership filter unavailable");
207
+ expect(err.join("\n")).toContain("ownership filter unavailable");
208
+ });
209
+
210
+ test("a miss still says the note, after the miss", async () => {
211
+ const exit = await runSearch({ args: makeArgs({ path: "kind:Nope" }), plugins: [], serializers: [] });
212
+ expect(exit).toBe(0);
213
+ const notes = all.filter((line) => line.includes("ownership filter unavailable"));
214
+ expect(notes).toHaveLength(1);
215
+ expect(all.indexOf("(no matches)")).toBeLessThan(all.findIndex((l) => l.includes("ownership filter unavailable")));
216
+ });
217
+ });
@@ -1,7 +1,7 @@
1
1
  import { describe, test, expect, vi } from "vitest";
2
2
  import { __searchInternals } from "./search";
3
3
 
4
- const { parseQuery, matchTerm, formatRow, explain, describeTerm, derivedSurface, availableAttrs, ambientHint, regionSpread, showMiss } = __searchInternals;
4
+ const { parseQuery, matchTerm, formatRow, explain, describeTerm, derivedSurface, availableAttrs, ambientHint, regionSpread, showMiss, provenance } = __searchInternals;
5
5
 
6
6
  function node(id: string, kind: string, attrs: Record<string, unknown> = {}) {
7
7
  return { id, kind, lexicon: "aws", attrs } as never;
@@ -53,6 +53,13 @@ describe("search formatting", () => {
53
53
  test("compact row with live physical id, skipping object placeholders", () => {
54
54
  const live = node("webServer", "AWS::EC2::Instance", { physicalId: "i-abc" });
55
55
  expect(formatRow(live, [])).toBe("webServer AWS::EC2::Instance i-abc");
56
+ });
57
+
58
+ // #1263 — a row nobody could read printed blank where the physical id goes,
59
+ // which reads as "declared, not provisioned".
60
+ test("a row the live read could not observe says so in place of the physical id", () => {
61
+ const n = node("webServer", "AWS::EC2::Instance", { _status: "neutral", _unobserved: "read-failed" });
62
+ expect(formatRow(n, [])).toBe("webServer AWS::EC2::Instance (unobserved: read-failed)");
56
63
  const src = node("webServer", "AWS::EC2::Instance", { InstanceId: { $ref: "webServer.InstanceId" } });
57
64
  expect(formatRow(src, [])).toBe("webServer AWS::EC2::Instance");
58
65
  });
@@ -353,3 +360,36 @@ describe("--show name matching", () => {
353
360
  expect(capture(() => showMiss([n] as never, ["Region"]))).toBe("");
354
361
  });
355
362
  });
363
+
364
+ // #1263 — a `--live` read that failed entirely returned the declared rows with
365
+ // the same footer as a working answer and exit 0.
366
+ describe("provenance of a failed live read", () => {
367
+ const capture = (fn: () => void): string => {
368
+ const lines: string[] = [];
369
+ const spy = vi.spyOn(console, "log").mockImplementation((s: string) => { lines.push(s); });
370
+ fn();
371
+ spy.mockRestore();
372
+ return lines.join("\n");
373
+ };
374
+ const rows = [
375
+ node("a", "AWS::EC2::Instance", { _unobserved: "read-failed" }),
376
+ node("b", "AWS::EC2::Instance", { _unobserved: "read-failed" }),
377
+ ] as never;
378
+
379
+ test("names the lexicon and counts the unobserved rows", () => {
380
+ const out = capture(() => provenance(rows, { kind: "live" }, undefined, ["aws: connect ECONNREFUSED 127.0.0.1:9999"]));
381
+ expect(out).toContain("live read failed (aws)");
382
+ expect(out).toContain("2/2 rows unobserved");
383
+ expect(out).toContain("physical ids unavailable");
384
+ });
385
+
386
+ test("points at a recorded snapshot when one exists", () => {
387
+ const out = capture(() => provenance(rows, { kind: "live" }, "yes", ["aws: connect ECONNREFUSED"]));
388
+ expect(out).toContain("--at latest");
389
+ });
390
+
391
+ test("a working live read is unchanged", () => {
392
+ const bound = [{ id: "a", kind: "AWS::EC2::Instance", lexicon: "aws", attrs: {}, physicalId: "i-1" }] as never;
393
+ expect(capture(() => provenance(bound, { kind: "live" }))).toBe("— observed live · bound 1/1");
394
+ });
395
+ });