@intentius/chant 0.45.0 → 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 (74) hide show
  1. package/dist/cli/commands/build.d.ts.map +1 -1
  2. package/dist/cli/commands/check-lexicon.d.ts +14 -0
  3. package/dist/cli/commands/check-lexicon.d.ts.map +1 -1
  4. package/dist/cli/commands/lexicon-surface-diff.d.ts +6 -0
  5. package/dist/cli/commands/lexicon-surface-diff.d.ts.map +1 -1
  6. package/dist/cli/commands/lint.d.ts.map +1 -1
  7. package/dist/cli/handlers/lifecycle.d.ts +13 -0
  8. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  9. package/dist/cli/handlers/search.d.ts.map +1 -1
  10. package/dist/cli/main.d.ts.map +1 -1
  11. package/dist/cli/registry.d.ts +7 -0
  12. package/dist/cli/registry.d.ts.map +1 -1
  13. package/dist/codegen/lexicon-regen.d.ts +11 -0
  14. package/dist/codegen/lexicon-regen.d.ts.map +1 -1
  15. package/dist/codegen/validate.d.ts +10 -0
  16. package/dist/codegen/validate.d.ts.map +1 -1
  17. package/dist/config.d.ts +28 -0
  18. package/dist/config.d.ts.map +1 -1
  19. package/dist/env.d.ts +12 -1
  20. package/dist/env.d.ts.map +1 -1
  21. package/dist/lexicon.d.ts +182 -2
  22. package/dist/lexicon.d.ts.map +1 -1
  23. package/dist/lifecycle/index.d.ts +1 -0
  24. package/dist/lifecycle/index.d.ts.map +1 -1
  25. package/dist/lifecycle/teardown.d.ts +130 -0
  26. package/dist/lifecycle/teardown.d.ts.map +1 -0
  27. package/dist/lint/engine.d.ts +6 -2
  28. package/dist/lint/engine.d.ts.map +1 -1
  29. package/dist/lint/rule.d.ts +31 -0
  30. package/dist/lint/rule.d.ts.map +1 -1
  31. package/dist/lint/rules/cor021-env-literal-name.d.ts +3 -0
  32. package/dist/lint/rules/cor021-env-literal-name.d.ts.map +1 -0
  33. package/dist/lint/rules/index.d.ts +2 -1
  34. package/dist/lint/rules/index.d.ts.map +1 -1
  35. package/dist/op/builders.d.ts +36 -7
  36. package/dist/op/builders.d.ts.map +1 -1
  37. package/dist/op/index.d.ts +1 -1
  38. package/dist/op/index.d.ts.map +1 -1
  39. package/dist/testing.d.ts +136 -0
  40. package/dist/testing.d.ts.map +1 -0
  41. package/package.json +6 -1
  42. package/src/cli/commands/build.test.ts +131 -0
  43. package/src/cli/commands/build.ts +20 -0
  44. package/src/cli/commands/check-lexicon.test.ts +45 -1
  45. package/src/cli/commands/check-lexicon.ts +45 -0
  46. package/src/cli/commands/lexicon-surface-diff.ts +9 -0
  47. package/src/cli/commands/lexicon-surface-diff.update.test.ts +112 -0
  48. package/src/cli/commands/lint.ts +19 -6
  49. package/src/cli/handlers/graph.ts +2 -2
  50. package/src/cli/handlers/lifecycle.test.ts +231 -1
  51. package/src/cli/handlers/lifecycle.ts +219 -3
  52. package/src/cli/handlers/search.ts +5 -2
  53. package/src/cli/main.ts +12 -1
  54. package/src/cli/registry.ts +7 -0
  55. package/src/codegen/lexicon-regen.ts +19 -1
  56. package/src/codegen/validate.test.ts +33 -0
  57. package/src/codegen/validate.ts +21 -2
  58. package/src/config.test.ts +40 -0
  59. package/src/config.ts +58 -1
  60. package/src/env.test.ts +35 -1
  61. package/src/env.ts +17 -3
  62. package/src/lexicon.ts +182 -2
  63. package/src/lifecycle/index.ts +1 -0
  64. package/src/lifecycle/teardown.test.ts +537 -0
  65. package/src/lifecycle/teardown.ts +357 -0
  66. package/src/lint/engine.ts +7 -1
  67. package/src/lint/rule.ts +23 -0
  68. package/src/lint/rules/cor021-env-literal-name.test.ts +128 -0
  69. package/src/lint/rules/cor021-env-literal-name.ts +114 -0
  70. package/src/lint/rules/index.ts +4 -1
  71. package/src/op/builders.ts +40 -7
  72. package/src/op/index.ts +1 -1
  73. package/src/testing.test.ts +261 -0
  74. package/src/testing.ts +338 -0
@@ -27,6 +27,17 @@ export interface ValidateResult {
27
27
  */
28
28
  export const RELEASE_GATE_ENV = "CHANT_RELEASE_GATE";
29
29
 
30
+ /**
31
+ * Set by `chant dev surface-diff --update-snapshot` for the validate run it
32
+ * performs before rewriting the baseline (chant #1825). When set to "1", the
33
+ * `surface-matches-snapshot` check is skipped. That check fails on exactly the
34
+ * staleness the update run exists to fix, and with an `"always"` gate (#1475)
35
+ * the two would deadlock: validate cannot pass until the snapshot is updated,
36
+ * and the snapshot cannot be updated until validate passes. Every other check
37
+ * still runs, so a broken generate cannot be baselined.
38
+ */
39
+ export const SNAPSHOT_UPDATE_ENV = "CHANT_SNAPSHOT_UPDATE";
40
+
30
41
  export interface LexiconValidationConfig {
31
42
  /** Filename of the lexicon JSON (e.g. "lexicon-mydom.json") */
32
43
  lexiconJsonFilename: string;
@@ -201,10 +212,18 @@ export async function validateLexiconArtifacts(config: LexiconValidationConfig):
201
212
  // Runs on the artifacts already on disk — no second generation — and is
202
213
  // skipped for a lexicon with no committed snapshot, which is the case for a
203
214
  // new lexicon before its first baseline.
215
+ //
216
+ // A re-baseline run (`chant dev surface-diff --update-snapshot`) sets
217
+ // {@link SNAPSHOT_UPDATE_ENV} and is exempt: the check would fail on the
218
+ // stale snapshot that run is about to rewrite, deadlocking the documented
219
+ // update flow against an `"always"` gate (#1825). Only this check is exempt —
220
+ // every other check above still gates the run.
204
221
  const snapshotPath = join(config.basePath, "surface.snapshot.json");
222
+ const env = config.env ?? process.env;
205
223
  const surfaceGate =
206
- config.checkSurfaceSnapshot === "always" ||
207
- (config.checkSurfaceSnapshot === true && (config.env ?? process.env)[RELEASE_GATE_ENV] === "1");
224
+ (config.checkSurfaceSnapshot === "always" ||
225
+ (config.checkSurfaceSnapshot === true && env[RELEASE_GATE_ENV] === "1")) &&
226
+ env[SNAPSHOT_UPDATE_ENV] !== "1";
208
227
  if (surfaceGate && lexiconData && existsSync(snapshotPath) && existsSync(dtsPath)) {
209
228
  try {
210
229
  const fresh = extractSurface(readFileSync(lexiconPath, "utf-8"), readFileSync(dtsPath, "utf-8"));
@@ -9,6 +9,8 @@ import {
9
9
  environmentName,
10
10
  environmentNames,
11
11
  environmentEndpoint,
12
+ matchesEnvironmentPattern,
13
+ matchesDeclaredEnvironment,
12
14
  } from "./config";
13
15
  import { writeFileSync, mkdirSync, rmSync } from "fs";
14
16
  import { join } from "path";
@@ -336,6 +338,44 @@ describe("environmentName / environmentNames / environmentEndpoint (#1166)", ()
336
338
  expect(environmentEndpoint(environments, "unknown")).toBeUndefined(); // not declared at all
337
339
  expect(environmentEndpoint(undefined, "floci")).toBeUndefined();
338
340
  });
341
+
342
+ test("environmentEndpoint resolves through a pattern entry, literal entry winning (#1221)", () => {
343
+ const environments = [
344
+ { name: "pr-special", endpoint: "http://special:1111" },
345
+ { name: "pr-*", endpoint: "http://preview:4566" },
346
+ ];
347
+ expect(environmentEndpoint(environments, "pr-42")).toBe("http://preview:4566");
348
+ expect(environmentEndpoint(environments, "pr-special")).toBe("http://special:1111"); // literal wins over the pattern
349
+ expect(environmentEndpoint(environments, "prod")).toBeUndefined();
350
+ });
351
+ });
352
+
353
+ describe("environment glob patterns (#1221)", () => {
354
+ test("matchesEnvironmentPattern: a * matches any run of characters, anchored", () => {
355
+ expect(matchesEnvironmentPattern("pr-*", "pr-42")).toBe(true);
356
+ expect(matchesEnvironmentPattern("pr-*", "pr-")).toBe(true); // empty run is a run
357
+ expect(matchesEnvironmentPattern("pr-*", "pr")).toBe(false);
358
+ expect(matchesEnvironmentPattern("pr-*", "xpr-42")).toBe(false); // anchored at the start
359
+ expect(matchesEnvironmentPattern("*-preview", "42-preview")).toBe(true);
360
+ expect(matchesEnvironmentPattern("*-preview", "42-preview-old")).toBe(false); // anchored at the end
361
+ expect(matchesEnvironmentPattern("test-*-eu", "test-a-eu")).toBe(true);
362
+ expect(matchesEnvironmentPattern("test-*-eu", "test-a-us")).toBe(false);
363
+ });
364
+
365
+ test("matchesEnvironmentPattern without a * is plain equality", () => {
366
+ expect(matchesEnvironmentPattern("prod", "prod")).toBe(true);
367
+ expect(matchesEnvironmentPattern("prod", "prod2")).toBe(false);
368
+ });
369
+
370
+ test("matchesDeclaredEnvironment: literal first, then pattern, across entry forms", () => {
371
+ const environments = ["dev", "prod", "pr-*", { name: "test-*", endpoint: "http://localhost:4566" }];
372
+ expect(matchesDeclaredEnvironment(environments, "prod")).toBe(true);
373
+ expect(matchesDeclaredEnvironment(environments, "pr-42")).toBe(true);
374
+ expect(matchesDeclaredEnvironment(environments, "test-suite-a")).toBe(true); // object-form pattern entry
375
+ expect(matchesDeclaredEnvironment(environments, "stage")).toBe(false);
376
+ expect(matchesDeclaredEnvironment(undefined, "prod")).toBe(false);
377
+ expect(matchesDeclaredEnvironment([], "prod")).toBe(false);
378
+ });
339
379
  });
340
380
 
341
381
  describe("resolveFoldEnabled (#1134 — fold is the default build path)", () => {
package/src/config.ts CHANGED
@@ -44,6 +44,56 @@ export function environmentNames(environments: EnvironmentDeclaration[] | undefi
44
44
  return environments?.map(environmentName);
45
45
  }
46
46
 
47
+ /**
48
+ * Whether a declared environment name is a glob pattern rather than a literal
49
+ * (#1221) — it contains at least one `*`.
50
+ */
51
+ export function isEnvironmentPattern(declaredName: string): boolean {
52
+ return declaredName.includes("*");
53
+ }
54
+
55
+ /**
56
+ * Anchored `*`-glob match (#1221): each `*` in `pattern` matches any run of
57
+ * characters (possibly empty); everything else matches itself. Deliberately
58
+ * the whole surface — no `?`, no character classes, no regex. A pattern with
59
+ * no `*` degenerates to string equality.
60
+ */
61
+ export function matchesEnvironmentPattern(pattern: string, name: string): boolean {
62
+ const parts = pattern.split("*");
63
+ if (parts.length === 1) return pattern === name;
64
+ const first = parts[0];
65
+ const last = parts[parts.length - 1];
66
+ if (!name.startsWith(first)) return false;
67
+ let cursor = first.length;
68
+ for (let i = 1; i < parts.length - 1; i++) {
69
+ const part = parts[i];
70
+ if (part === "") continue;
71
+ const at = name.indexOf(part, cursor);
72
+ if (at === -1) return false;
73
+ cursor = at + part.length;
74
+ }
75
+ return name.length - cursor >= last.length && name.endsWith(last);
76
+ }
77
+
78
+ /**
79
+ * Environment-membership test (#1221): is `name` covered by this project's
80
+ * declared `environments`? A literal entry matches by equality; an entry
81
+ * containing `*` matches as a glob pattern (`"pr-*"` covers `pr-42`).
82
+ * Literals are checked first across the whole list, then patterns — so a
83
+ * name that is declared outright never depends on pattern order. `undefined`
84
+ * / empty `environments` returns `false`; callers that treat "no declared
85
+ * environments" as "anything goes" (e.g. {@link unknownEnvError} in
86
+ * `./env.ts`) guard that case themselves.
87
+ */
88
+ export function matchesDeclaredEnvironment(
89
+ environments: EnvironmentDeclaration[] | undefined,
90
+ name: string,
91
+ ): boolean {
92
+ const names = environmentNames(environments) ?? [];
93
+ if (names.includes(name)) return true;
94
+ return names.some((declared) => isEnvironmentPattern(declared) && matchesEnvironmentPattern(declared, name));
95
+ }
96
+
47
97
  /**
48
98
  * The endpoint `name` declares (chant #1166) — `undefined` for a bare-string
49
99
  * entry, an entry with no `endpoint` set, or a name this project doesn't
@@ -51,9 +101,16 @@ export function environmentNames(environments: EnvironmentDeclaration[] | undefi
51
101
  * it injects this into the ambient env var each observing lexicon's CLI
52
102
  * shell-out reads (e.g. `AWS_ENDPOINT_URL`), unless that var is already set —
53
103
  * ambient always wins.
104
+ *
105
+ * A pattern entry (#1221) can carry an endpoint too: `{ name: "pr-*",
106
+ * endpoint }` supplies the endpoint for every `pr-<n>` environment. A literal
107
+ * entry always wins over a pattern — declaring `pr-special` alongside
108
+ * `pr-*` resolves `pr-special` to the literal entry's endpoint (or none).
54
109
  */
55
110
  export function environmentEndpoint(environments: EnvironmentDeclaration[] | undefined, name: string): string | undefined {
56
- const found = environments?.find((e) => environmentName(e) === name);
111
+ const found =
112
+ environments?.find((e) => environmentName(e) === name) ??
113
+ environments?.find((e) => isEnvironmentPattern(environmentName(e)) && matchesEnvironmentPattern(environmentName(e), name));
57
114
  return found && typeof found !== "string" ? found.endpoint : undefined;
58
115
  }
59
116
 
package/src/env.test.ts CHANGED
@@ -2,7 +2,7 @@ import { describe, test, expect } from "vitest";
2
2
  import { mkdir, writeFile, rm } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
4
  import { tmpdir } from "node:os";
5
- import { env, unknownEnvError, ENV_VAR } from "./env";
5
+ import { env, unknownEnvError, isProdLikeEnvironment, ENV_VAR } from "./env";
6
6
  import { discover } from "./discovery/index";
7
7
 
8
8
  const withEnv = async (value: string | undefined, fn: () => void | Promise<void>): Promise<void> => {
@@ -52,6 +52,25 @@ describe("unknownEnvError", () => {
52
52
  unknownEnvError("stage", ["prod", { name: "floci", endpoint: "http://localhost:4566" }]),
53
53
  ).toMatch(/Unknown environment "stage".*prod, floci/);
54
54
  });
55
+
56
+ // #1221 — an entry containing `*` is a glob pattern, so an unbounded family
57
+ // (per-PR environments, per-suite test environments) is declarable without
58
+ // listing each name.
59
+ test("accepts an env covered by a declared glob pattern (#1221)", () => {
60
+ expect(unknownEnvError("pr-42", ["dev", "prod", "pr-*"])).toBeUndefined();
61
+ expect(unknownEnvError("test-suite-a", ["prod", { name: "test-*", endpoint: "http://localhost:4566" }])).toBeUndefined();
62
+ });
63
+
64
+ test("rejects an env no pattern covers, listing the patterns among the declared entries (#1221)", () => {
65
+ expect(unknownEnvError("stage", ["dev", "prod", "pr-*"])).toMatch(/Unknown environment "stage".*dev, prod, pr-\*/);
66
+ expect(unknownEnvError("pr", ["pr-*"])).toMatch(/Unknown environment "pr"/); // prefix alone is not covered
67
+ });
68
+
69
+ test("a literal entry still matches by equality, never as a pattern (#1221)", () => {
70
+ // `prod` contains no `*`: nothing about pattern support changes it.
71
+ expect(unknownEnvError("prod", ["prod", "pr-*"])).toBeUndefined();
72
+ expect(unknownEnvError("production", ["prod"])).toMatch(/Unknown environment "production"/);
73
+ });
55
74
  });
56
75
 
57
76
  describe("env-aware discovery (#505)", () => {
@@ -87,3 +106,18 @@ describe("env-aware discovery (#505)", () => {
87
106
  }
88
107
  });
89
108
  });
109
+
110
+
111
+ describe("isProdLikeEnvironment (#1222)", () => {
112
+ test("matches prod, production, and separator-delimited variants", () => {
113
+ for (const name of ["prod", "production", "Prod", "PRODUCTION", "prod-eu", "us-prod", "prod2", "production-east", "eu_prod"]) {
114
+ expect(isProdLikeEnvironment(name)).toBe(true);
115
+ }
116
+ });
117
+
118
+ test("does not match names that merely contain the letters", () => {
119
+ for (const name of ["dev", "staging", "preprod", "product-demo", "reproduction", "pr-123"]) {
120
+ expect(isProdLikeEnvironment(name)).toBe(false);
121
+ }
122
+ });
123
+ });
package/src/env.ts CHANGED
@@ -18,7 +18,7 @@
18
18
  * pinhole renders/diffs them to show environment drift (INTENTIUS/pinhole#3).
19
19
  */
20
20
 
21
- import { environmentNames, type EnvironmentDeclaration } from "./config";
21
+ import { environmentNames, matchesDeclaredEnvironment, type EnvironmentDeclaration } from "./config";
22
22
 
23
23
  /** The environment variable the CLI sets from `--env`. */
24
24
  export const ENV_VAR = "CHANT_ENV";
@@ -34,14 +34,28 @@ export function env(fallback?: string): string | undefined {
34
34
  * when it's valid (or when the project declares no environments, in which case
35
35
  * any name is accepted). `declared` entries may be a bare name or `{ name,
36
36
  * endpoint }` (#1166) — {@link environmentNames} reduces either to the names
37
- * this checks against.
37
+ * this checks against. An entry containing `*` is a glob pattern (#1221):
38
+ * `"pr-*"` legalizes every `pr-<n>` environment. Literal entries are checked
39
+ * first, then patterns — see {@link matchesDeclaredEnvironment}.
38
40
  */
39
41
  export function unknownEnvError(
40
42
  requested: string | undefined,
41
43
  declared: EnvironmentDeclaration[] | undefined,
42
44
  ): string | undefined {
43
45
  if (!requested || !declared || declared.length === 0) return undefined;
46
+ if (matchesDeclaredEnvironment(declared, requested)) return undefined;
44
47
  const names = environmentNames(declared) ?? [];
45
- if (names.includes(requested)) return undefined;
46
48
  return `Unknown environment "${requested}". Declared environments: ${names.join(", ")}.`;
47
49
  }
50
+
51
+ /**
52
+ * True when an environment name looks like production — `prod`, `production`,
53
+ * and separator-delimited variants (`prod-eu`, `us-prod`, `production2`).
54
+ * `chant lifecycle teardown <env> --yes` demands an extra confirmation for
55
+ * these (#1222): a typo that survives the declared-environments check should
56
+ * still not delete production on one flag. Name-shaped, deliberately — chant
57
+ * has no other signal for which environment is the one that pays the bills.
58
+ */
59
+ export function isProdLikeEnvironment(name: string): boolean {
60
+ return /(^|[-_./])prod(uction)?([-_./0-9]|$)/i.test(name);
61
+ }
package/src/lexicon.ts CHANGED
@@ -11,12 +11,12 @@ import type { CompletionContext, CompletionItem, HoverContext, HoverInfo, CodeAc
11
11
  import type { McpToolContribution, McpResourceContribution } from "./mcp/types";
12
12
  import type { DriverComponent } from "./components/driver";
13
13
  import type { EmulatorDeclaration } from "./op/emulator-lifecycle";
14
- import type { OwnershipChannel } from "./ownership";
14
+ import type { OwnershipChannel, OwnershipMarker } from "./ownership";
15
15
  import type { LexiconConfigSchema } from "./lexicon-config";
16
16
  import type { RuleMeta } from "./audit/catalog";
17
17
  import type { ReferenceCatalog } from "./graph-refs";
18
18
  import type { IREdge } from "./graph-ir";
19
- import type { DescribeResourcesResult } from "./observation";
19
+ import type { DescribeResourcesResult, UnobservedReason } from "./observation";
20
20
  import type { DeepNormalizationHooks, DeepObservationResult } from "./deep-observation";
21
21
  import type { OwnerChainVerdict } from "./owner-chain";
22
22
  import type { CommandGroup } from "./cli/command-group";
@@ -540,6 +540,23 @@ export interface LexiconPlugin {
540
540
  */
541
541
  auditCatalog?(): Record<string, RuleMeta>;
542
542
 
543
+ /**
544
+ * Machine-readable spec-coverage accounting for `check-lexicon` (#1330).
545
+ *
546
+ * `coverage()` prints a report for humans; this returns the one fact the
547
+ * completeness gate cares about: which upstream spec kinds are neither
548
+ * modeled as declarables nor on the lexicon's exclusion list. fountain held
549
+ * this line in a lexicon-local vitest assertion (`coverage.test.ts`), which
550
+ * is a convention rather than a contract — the same class of gap #1342
551
+ * closed for LSP providers.
552
+ *
553
+ * Implementations must work offline from committed snapshots (fountain
554
+ * reads `spec/fountain-openapi.snapshot.json` plus its surface baseline):
555
+ * `check-lexicon` runs on every PR, so no network I/O. Omit when the
556
+ * lexicon has no kind-level spec accounting; the check passes vacuously.
557
+ */
558
+ coverageReport?(): Promise<{ unaccountedKinds?: string[] }>;
559
+
543
560
  /** Return intrinsic function definitions */
544
561
  intrinsics?(): IntrinsicDef[];
545
562
 
@@ -862,6 +879,86 @@ export interface LexiconPlugin {
862
879
  */
863
880
  describeStackStatus?(options: { environment: string; stack: string }): Promise<StackStatusObservation | null>;
864
881
 
882
+ /**
883
+ * Enumerate the resources this lexicon would delete for one marker identity
884
+ * (#1222). Opt-in, and read-only here: this method names the would-delete
885
+ * set, it never deletes. `chant lifecycle teardown <env>` calls it to plan;
886
+ * the execution half ({@link executeTeardown}) deletes from the same
887
+ * enumeration.
888
+ *
889
+ * Selection is marker-scoped by construction. `marker` carries this
890
+ * project's ownership stack plus the requested environment, and every
891
+ * returned candidate must have been read carrying exactly that identity on
892
+ * this lexicon's marker channel — managed-by present, stack equal, env
893
+ * equal. A resource whose marker is absent, foreign-stack, or foreign-env is
894
+ * not a candidate, ever. Core re-checks each candidate's `marker` and drops
895
+ * mismatches, so a buggy implementation cannot widen the set.
896
+ *
897
+ * The #1089 discipline applies: a kind this lexicon stamps but cannot read
898
+ * back (no reader for the kind, the read errored, no credentials) is a
899
+ * `hole`, named with a total {@link UnobservedReason} — never silently
900
+ * absent, because "absent from the plan" reads as "safe", and an unreadable
901
+ * kind is unknown, not safe.
902
+ *
903
+ * A lexicon without this capability still takes part in teardown planning:
904
+ * core falls back to {@link describeResources} and filters on
905
+ * {@link ResourceMetadata.marker}. Implement this when that read is the
906
+ * wrong shape for deletion — aws, whose thin read carries no tags and whose
907
+ * teardown is stack-level, is the motivating case.
908
+ */
909
+ teardownOwned?(options: {
910
+ environment: string;
911
+ /** The identity to select on: this project's ownership stack + the env being torn down. */
912
+ marker: OwnershipMarker;
913
+ /** Deployed stack name, for a multi-stack project (see `stacks` in {@link ChantConfig}). */
914
+ stack?: string;
915
+ /** Region that stack is deployed in (#1261's contract). */
916
+ region?: string;
917
+ /**
918
+ * Every deployed stack a multi-stack project declares (see `stacks` in
919
+ * {@link ChantConfig}), for a lexicon whose enumeration is stack-shaped
920
+ * (aws). When absent or empty, the single-stack convention applies:
921
+ * `stack`, else the stack named after the environment.
922
+ */
923
+ stacks?: Array<{ name: string; region?: string }>;
924
+ }): Promise<TeardownEnumeration>;
925
+
926
+ /**
927
+ * Delete the teardown candidates core hands over — the execution half of
928
+ * `chant lifecycle teardown <env> --yes` (#1222). Opt-in, and the sibling of
929
+ * {@link teardownOwned}: that method names the would-delete set, this one
930
+ * deletes it. A lexicon that enumerates but does not implement this reports
931
+ * its candidates as skipped rather than pretending.
932
+ *
933
+ * `candidates` is the marker-verified set core computed from the plan — an
934
+ * implementation deletes those and only those, in whatever order its target
935
+ * requires (k8s deletes namespaces last; fly deletes apps last). It never
936
+ * re-widens the set: a live resource not in `candidates` is not this call's
937
+ * business, whatever its labels say.
938
+ *
939
+ * Every candidate gets exactly one outcome per call, keyed by `name`:
940
+ * `deleted` (including already-gone — deletion is idempotent), `failed`
941
+ * (the delete errored; core runs one bounded retry pass over these), or
942
+ * `not-prunable` with a reason (the live object no longer carries the
943
+ * requested identity, the kind cannot be addressed, the target refuses).
944
+ * A candidate the implementation says nothing about is reported as failed
945
+ * by core — silence is never success.
946
+ */
947
+ executeTeardown?(options: {
948
+ environment: string;
949
+ /** The identity everything was selected on: ownership stack + env. */
950
+ marker: OwnershipMarker;
951
+ /** The marker-verified candidates to delete — from {@link teardownOwned} / the plan. */
952
+ candidates: TeardownCandidate[];
953
+ /** Deployed stack name, for a multi-stack project (see `stacks` in {@link ChantConfig}). */
954
+ stack?: string;
955
+ /** Region that stack is deployed in (#1261's contract). */
956
+ region?: string;
957
+ /** Every declared deployed stack, mirroring {@link teardownOwned} — how a
958
+ * stack-shaped execution (aws) finds each candidate's region. */
959
+ stacks?: Array<{ name: string; region?: string }>;
960
+ }): Promise<TeardownExecution>;
961
+
865
962
  /**
866
963
  * Where this lexicon can stamp and read chant's ownership marker (#1348).
867
964
  * Data, not a method.
@@ -962,6 +1059,74 @@ export interface LexiconPlugin {
962
1059
  }): Promise<ExportedTemplate>;
963
1060
  }
964
1061
 
1062
+ /**
1063
+ * One resource {@link LexiconPlugin.teardownOwned} would delete (#1222).
1064
+ * Identity only — no delete happens on this path.
1065
+ */
1066
+ export interface TeardownCandidate {
1067
+ /** chant entity name where a declared mapping exists, else the provider-side name. */
1068
+ name: string;
1069
+ /** Resource type (e.g. "AWS::S3::Bucket", "K8s::Apps::Deployment"). */
1070
+ type: string;
1071
+ /** Provider-side identifier, when the read surfaces one. */
1072
+ physicalId?: string;
1073
+ /**
1074
+ * The stack/env identity read off the resource's own marker — read back,
1075
+ * never inferred. Core verifies it equals the requested identity and drops
1076
+ * the candidate otherwise.
1077
+ */
1078
+ marker: OwnershipMarker;
1079
+ }
1080
+
1081
+ /**
1082
+ * One kind or entity a teardown enumeration could not read (#1089). A hole is
1083
+ * a claim of ignorance, not of absence: the plan must print it loudly, and the
1084
+ * execution half must refuse to call the env clean while holes exist.
1085
+ */
1086
+ export interface TeardownHole {
1087
+ /** The unreadable kind or entity name. */
1088
+ name: string;
1089
+ /** Resource type, when known. */
1090
+ type?: string;
1091
+ /** Total verdict — the same vocabulary the observation envelope uses. */
1092
+ reason: UnobservedReason;
1093
+ /** Human-readable detail: the failing command, the unsupported kind. */
1094
+ detail?: string;
1095
+ }
1096
+
1097
+ /** What {@link LexiconPlugin.teardownOwned} returns: the would-delete set plus its holes. */
1098
+ export interface TeardownEnumeration {
1099
+ candidates: TeardownCandidate[];
1100
+ /** Omit or leave empty when every stamped kind was readable. */
1101
+ holes?: TeardownHole[];
1102
+ }
1103
+
1104
+ /**
1105
+ * One candidate's fate after {@link LexiconPlugin.executeTeardown} (#1222).
1106
+ * `name` keys it back to the candidate it answers for.
1107
+ */
1108
+ export interface TeardownOutcome {
1109
+ /** The candidate's `name`, verbatim. */
1110
+ name: string;
1111
+ /** Resource type, when the implementation carries it through. */
1112
+ type?: string;
1113
+ /** Provider-side identifier, when known. */
1114
+ physicalId?: string;
1115
+ /**
1116
+ * - `deleted` — gone, including already-gone (deletion is idempotent);
1117
+ * - `failed` — the delete errored (core retries these once);
1118
+ * - `not-prunable` — deliberately not deleted; `detail` says why.
1119
+ */
1120
+ outcome: "deleted" | "failed" | "not-prunable";
1121
+ /** The error for `failed`, the reason for `not-prunable`. */
1122
+ detail?: string;
1123
+ }
1124
+
1125
+ /** What {@link LexiconPlugin.executeTeardown} returns: one outcome per candidate. */
1126
+ export interface TeardownExecution {
1127
+ outcomes: TeardownOutcome[];
1128
+ }
1129
+
965
1130
  /**
966
1131
  * The observation view of a lexicon — every capability except live export.
967
1132
  *
@@ -1047,6 +1212,21 @@ export interface ResourceMetadata {
1047
1212
  * a delete, and never escalates `unknown` to one.
1048
1213
  */
1049
1214
  ownership?: "owned" | "foreign" | "unknown";
1215
+ /**
1216
+ * The stack/env identity read off the resource's own ownership marker
1217
+ * (#1222) — the tags/labels/metadata chant stamped at synthesis, read back
1218
+ * verbatim on the same channel. This is what marker-scoped selection keys
1219
+ * on: {@link ownership} says "chant's", `marker` says *which* stack and env.
1220
+ *
1221
+ * Set only when the live model actually carries the channel and the
1222
+ * managed-by marker is present — an absent channel means an absent field,
1223
+ * never a guess. In particular aws's thin read is sourced from
1224
+ * `describe-stack-resources`, which returns no tags at all, so aws never
1225
+ * populates this here; aws teardown is stack-level and reads the stack's own
1226
+ * tags instead (later PR). A populated `marker` does not by itself imply an
1227
+ * {@link ownership} verdict on paths that do not declare a marker channel.
1228
+ */
1229
+ marker?: OwnershipMarker;
1050
1230
  /**
1051
1231
  * Where this resource's owner-reference chain leads, for a live resource
1052
1232
  * that is not itself declared (#1077). A lexicon that maintains an
@@ -13,3 +13,4 @@ export * from "./build-ledger";
13
13
  export * from "./build-ledger-store";
14
14
  export * from "./oras-referrer-lookup";
15
15
  export * from "./status";
16
+ export * from "./teardown";