@intentius/chant 0.1.13 → 0.1.15

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 (52) hide show
  1. package/package.json +1 -1
  2. package/src/build.ts +18 -2
  3. package/src/cli/commands/build.ts +9 -1
  4. package/src/cli/commands/import-live.test.ts +126 -0
  5. package/src/cli/commands/import.ts +152 -2
  6. package/src/cli/commands/migrate.ts +2 -2
  7. package/src/cli/handlers/{state.test.ts → lifecycle.test.ts} +37 -37
  8. package/src/cli/handlers/{state.ts → lifecycle.ts} +148 -36
  9. package/src/cli/handlers/misc.ts +31 -2
  10. package/src/cli/handlers/run.test.ts +98 -0
  11. package/src/cli/handlers/run.ts +123 -0
  12. package/src/cli/main.test.ts +14 -0
  13. package/src/cli/main.ts +38 -12
  14. package/src/cli/mcp/{state-tools.ts → lifecycle-tools.ts} +9 -9
  15. package/src/cli/mcp/op-tools.ts +2 -2
  16. package/src/cli/mcp/resource-handlers.ts +1 -1
  17. package/src/cli/mcp/server.test.ts +2 -2
  18. package/src/cli/mcp/server.ts +1 -1
  19. package/src/cli/registry.ts +21 -2
  20. package/src/codegen/fetch.test.ts +103 -2
  21. package/src/codegen/fetch.ts +62 -10
  22. package/src/config.ts +31 -0
  23. package/src/detectLexicon.test.ts +2 -2
  24. package/src/index.ts +2 -2
  25. package/src/lexicon-export.test.ts +92 -0
  26. package/src/lexicon.ts +88 -9
  27. package/src/lifecycle/change-set.test.ts +151 -0
  28. package/src/lifecycle/change-set.ts +172 -0
  29. package/src/{state → lifecycle}/git.test.ts +15 -15
  30. package/src/{state → lifecycle}/git.ts +14 -14
  31. package/src/{state → lifecycle}/index.ts +2 -0
  32. package/src/{state → lifecycle}/snapshot.test.ts +5 -5
  33. package/src/{state → lifecycle}/snapshot.ts +9 -9
  34. package/src/{state → lifecycle}/types.ts +1 -1
  35. package/src/op/activity-registry.test.ts +59 -0
  36. package/src/op/activity-registry.ts +91 -0
  37. package/src/op/builders.ts +3 -3
  38. package/src/op/index.ts +6 -1
  39. package/src/op/local-executor.test.ts +247 -0
  40. package/src/op/local-executor.ts +300 -0
  41. package/src/op/local-output.test.ts +54 -0
  42. package/src/op/local-output.ts +63 -0
  43. package/src/op/op.test.ts +4 -4
  44. package/src/op/types.ts +1 -1
  45. package/src/ownership.test.ts +109 -0
  46. package/src/ownership.ts +142 -0
  47. package/src/serializer.ts +19 -1
  48. package/src/toml-parse.ts +3 -3
  49. /package/src/{state → lifecycle}/digest.test.ts +0 -0
  50. /package/src/{state → lifecycle}/digest.ts +0 -0
  51. /package/src/{state → lifecycle}/live-diff.test.ts +0 -0
  52. /package/src/{state → lifecycle}/live-diff.ts +0 -0
package/src/op/types.ts CHANGED
@@ -52,7 +52,7 @@ export interface ActivityStep {
52
52
  * The serializer captures the awaited result into a temporary, then emits
53
53
  * `upsertSearchAttributes({ <name>: [String(<from-path>)] })` immediately
54
54
  * after. Useful for filtering runs by outcome (e.g. `Drift: "true"/"false"`
55
- * from a stateDiff activity).
55
+ * from a lifecycleDiff activity).
56
56
  *
57
57
  * `from` is a dot-path into the return value (e.g. `"drifted"` for
58
58
  * `{ drifted: boolean }`); when omitted, the whole return value is
@@ -0,0 +1,109 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import {
3
+ ownershipEntries,
4
+ ownershipKeys,
5
+ hasOwnershipMarker,
6
+ readOwnership,
7
+ classifyOwnership,
8
+ tagArrayToMap,
9
+ OWNERSHIP_MANAGED_BY_VALUE,
10
+ } from "./ownership";
11
+ import { resolveOwnershipMarker } from "./config";
12
+
13
+ describe("ownershipEntries (#119)", () => {
14
+ test("label channel carries managed-by + stack + env", () => {
15
+ const e = ownershipEntries("label", { stack: "billing", env: "prod" });
16
+ expect(e["app.kubernetes.io/managed-by"]).toBe("chant");
17
+ expect(e["chant.intentius.io/stack"]).toBe("billing");
18
+ expect(e["chant.intentius.io/env"]).toBe("prod");
19
+ });
20
+
21
+ test("aws-tag channel uses colon keys", () => {
22
+ const e = ownershipEntries("aws-tag", { stack: "billing" });
23
+ expect(e["chant:managed-by"]).toBe("chant");
24
+ expect(e["chant:stack"]).toBe("billing");
25
+ expect(e["chant:env"]).toBeUndefined(); // env omitted when not set
26
+ });
27
+
28
+ test("azure-tag channel uses hyphen keys (no slash, which Azure forbids)", () => {
29
+ const e = ownershipEntries("azure-tag", { stack: "billing", env: "stg" });
30
+ expect(e["chant-managed-by"]).toBe("chant");
31
+ expect(e["chant-stack"]).toBe("billing");
32
+ expect(e["chant-env"]).toBe("stg");
33
+ expect(Object.keys(e).some((k) => k.includes("/"))).toBe(false);
34
+ });
35
+
36
+ test("carries stack identity, not just managed=true", () => {
37
+ const a = ownershipEntries("label", { stack: "stack-a" });
38
+ const b = ownershipEntries("label", { stack: "stack-b" });
39
+ expect(a[ownershipKeys("label").stack]).not.toBe(b[ownershipKeys("label").stack]);
40
+ });
41
+ });
42
+
43
+ describe("hasOwnershipMarker / readOwnership", () => {
44
+ test("detects chant's marker even when co-stamped with other tools", () => {
45
+ const tags = {
46
+ "chant:managed-by": OWNERSHIP_MANAGED_BY_VALUE,
47
+ "chant:stack": "billing",
48
+ "team": "payments", // foreign co-stamp
49
+ };
50
+ expect(hasOwnershipMarker(tags, "aws-tag")).toBe(true);
51
+ });
52
+
53
+ test("absent marker → not owned", () => {
54
+ expect(hasOwnershipMarker({ team: "payments" }, "aws-tag")).toBe(false);
55
+ expect(hasOwnershipMarker(undefined, "label")).toBe(false);
56
+ });
57
+
58
+ test("readOwnership recovers stack/env from a marked resource", () => {
59
+ const labels = ownershipEntries("label", { stack: "billing", env: "prod" });
60
+ expect(readOwnership(labels, "label")).toEqual({ stack: "billing", env: "prod" });
61
+ });
62
+
63
+ test("readOwnership returns undefined when unmarked", () => {
64
+ expect(readOwnership({ foo: "bar" }, "label")).toBeUndefined();
65
+ });
66
+ });
67
+
68
+ describe("classifyOwnership / tagArrayToMap (#120)", () => {
69
+ test("marker present → owned, absent → foreign", () => {
70
+ expect(classifyOwnership({ "chant:managed-by": "chant" }, "aws-tag")).toBe("owned");
71
+ expect(classifyOwnership({ team: "x" }, "aws-tag")).toBe("foreign");
72
+ expect(classifyOwnership(undefined, "label")).toBe("foreign");
73
+ });
74
+
75
+ test("tagArrayToMap converts CloudFormation {Key,Value} tags", () => {
76
+ const map = tagArrayToMap([
77
+ { Key: "chant:managed-by", Value: "chant" },
78
+ { Key: "chant:stack", Value: "billing" },
79
+ ]);
80
+ expect(map["chant:managed-by"]).toBe("chant");
81
+ expect(classifyOwnership(map, "aws-tag")).toBe("owned");
82
+ });
83
+
84
+ test("tagArrayToMap tolerates undefined / malformed entries", () => {
85
+ expect(tagArrayToMap(undefined)).toEqual({});
86
+ expect(tagArrayToMap([{ Value: "no-key" }])).toEqual({});
87
+ });
88
+ });
89
+
90
+ describe("resolveOwnershipMarker (config opt-in)", () => {
91
+ test("enabled when stack is set", () => {
92
+ expect(resolveOwnershipMarker({ ownership: { stack: "billing", env: "prod" } })).toEqual({
93
+ stack: "billing",
94
+ env: "prod",
95
+ });
96
+ });
97
+
98
+ test("off when no ownership config", () => {
99
+ expect(resolveOwnershipMarker({})).toBeUndefined();
100
+ });
101
+
102
+ test("off when stack missing", () => {
103
+ expect(resolveOwnershipMarker({ ownership: { env: "prod" } })).toBeUndefined();
104
+ });
105
+
106
+ test("off when explicitly disabled", () => {
107
+ expect(resolveOwnershipMarker({ ownership: { stack: "billing", enabled: false } })).toBeUndefined();
108
+ });
109
+ });
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Ownership marker contract.
3
+ *
4
+ * chant stamps managed resources with a provider-native marker at synthesis
5
+ * time. This is what later lets `delete` be precise without an authoritative
6
+ * state file — the ownership record lives on the cloud resource, not in a file
7
+ * chant has to host or lock. The marker is standard tags/labels, so walk-away
8
+ * cost stays zero: nothing proprietary lands in the output.
9
+ *
10
+ * The marker carries stack identity, not just `managed=true`, so one stack
11
+ * never mistakes another stack's resources for its own. Ownership means
12
+ * "carries chant's marker", not "carries only chant's marker" — co-stamping
13
+ * with other tools is fine.
14
+ */
15
+
16
+ /** The value of the managed-by marker for every channel. */
17
+ export const OWNERSHIP_MANAGED_BY_VALUE = "chant";
18
+
19
+ /**
20
+ * Stack (and optional environment) identity stamped onto a resource. Computed
21
+ * from project config and threaded into each serializer.
22
+ */
23
+ export interface OwnershipMarker {
24
+ /** Distinguishes one chant stack's resources from another's. */
25
+ stack: string;
26
+ /** Optional environment identity. */
27
+ env?: string;
28
+ }
29
+
30
+ /**
31
+ * The native metadata channel a target stamps into. Tag-key syntax differs:
32
+ * Kubernetes/GCP labels allow a `prefix/name` form; AWS tag keys allow `:`;
33
+ * Azure tag keys forbid `/`, so they use a hyphenated form.
34
+ */
35
+ export type OwnershipChannel = "label" | "aws-tag" | "azure-tag";
36
+
37
+ interface ChannelKeys {
38
+ readonly managedBy: string;
39
+ readonly stack: string;
40
+ readonly env: string;
41
+ }
42
+
43
+ const CHANNEL_KEYS: Record<OwnershipChannel, ChannelKeys> = {
44
+ label: {
45
+ managedBy: "app.kubernetes.io/managed-by",
46
+ stack: "chant.intentius.io/stack",
47
+ env: "chant.intentius.io/env",
48
+ },
49
+ "aws-tag": {
50
+ managedBy: "chant:managed-by",
51
+ stack: "chant:stack",
52
+ env: "chant:env",
53
+ },
54
+ "azure-tag": {
55
+ managedBy: "chant-managed-by",
56
+ stack: "chant-stack",
57
+ env: "chant-env",
58
+ },
59
+ };
60
+
61
+ /** The marker key names used in a given channel. */
62
+ export function ownershipKeys(channel: OwnershipChannel): ChannelKeys {
63
+ return CHANNEL_KEYS[channel];
64
+ }
65
+
66
+ /**
67
+ * The key/value entries to stamp for a channel: the managed-by marker, the
68
+ * stack identity, and the env identity when present.
69
+ */
70
+ export function ownershipEntries(
71
+ channel: OwnershipChannel,
72
+ marker: OwnershipMarker,
73
+ ): Record<string, string> {
74
+ const keys = CHANNEL_KEYS[channel];
75
+ const entries: Record<string, string> = {
76
+ [keys.managedBy]: OWNERSHIP_MANAGED_BY_VALUE,
77
+ [keys.stack]: marker.stack,
78
+ };
79
+ if (marker.env) entries[keys.env] = marker.env;
80
+ return entries;
81
+ }
82
+
83
+ /**
84
+ * Ownership test: does this resource carry chant's managed-by marker? Other
85
+ * tools may co-stamp; only the managed-by marker is required to count as owned.
86
+ */
87
+ export function hasOwnershipMarker(
88
+ tagsOrLabels: Record<string, unknown> | undefined,
89
+ channel: OwnershipChannel,
90
+ ): boolean {
91
+ if (!tagsOrLabels) return false;
92
+ return tagsOrLabels[CHANNEL_KEYS[channel].managedBy] === OWNERSHIP_MANAGED_BY_VALUE;
93
+ }
94
+
95
+ /**
96
+ * The two live-side ownership verdicts a marker query can produce.
97
+ *
98
+ * - `owned` — carries chant's marker; an undeclared owned resource is a safe
99
+ * delete candidate.
100
+ * - `foreign` — no marker; can be adopted but never auto-deleted.
101
+ *
102
+ * The third verdict, `unknown`, is reserved for when no marker channel was
103
+ * queried at all (see the `Ownership` type in the change set).
104
+ */
105
+ export function classifyOwnership(
106
+ tagsOrLabels: Record<string, unknown> | undefined,
107
+ channel: OwnershipChannel,
108
+ ): "owned" | "foreign" {
109
+ return hasOwnershipMarker(tagsOrLabels, channel) ? "owned" : "foreign";
110
+ }
111
+
112
+ /**
113
+ * Convert a tag array of `{Key, Value}` (AWS/CloudFormation form) into the
114
+ * key→value map the ownership helpers expect.
115
+ */
116
+ export function tagArrayToMap(
117
+ tags: ReadonlyArray<{ Key?: string; Value?: unknown }> | undefined,
118
+ ): Record<string, unknown> {
119
+ const out: Record<string, unknown> = {};
120
+ for (const t of tags ?? []) {
121
+ if (typeof t.Key === "string") out[t.Key] = t.Value;
122
+ }
123
+ return out;
124
+ }
125
+
126
+ /**
127
+ * Read the stack/env identity from a marked resource's tags/labels. Returns
128
+ * undefined when the managed-by marker is absent.
129
+ */
130
+ export function readOwnership(
131
+ tagsOrLabels: Record<string, unknown> | undefined,
132
+ channel: OwnershipChannel,
133
+ ): OwnershipMarker | undefined {
134
+ if (!hasOwnershipMarker(tagsOrLabels, channel)) return undefined;
135
+ const keys = CHANNEL_KEYS[channel];
136
+ const stack = tagsOrLabels![keys.stack];
137
+ const env = tagsOrLabels![keys.env];
138
+ return {
139
+ stack: typeof stack === "string" ? stack : "",
140
+ env: typeof env === "string" ? env : undefined,
141
+ };
142
+ }
package/src/serializer.ts CHANGED
@@ -1,5 +1,18 @@
1
1
  import type { Declarable } from "./declarable";
2
2
  import type { LexiconOutput } from "./lexicon-output";
3
+ import type { OwnershipMarker } from "./ownership";
4
+
5
+ /**
6
+ * Build-time context passed to a serializer. Optional — serializers that don't
7
+ * need it ignore the parameter.
8
+ */
9
+ export interface SerializeContext {
10
+ /**
11
+ * When set, the serializer stamps this ownership marker into the target's
12
+ * native metadata channel (AWS/Azure tags, K8s/GCP labels).
13
+ */
14
+ ownership?: OwnershipMarker;
15
+ }
3
16
 
4
17
  /**
5
18
  * Result of serialization that may include additional files (e.g. nested stack templates).
@@ -29,8 +42,13 @@ export interface Serializer {
29
42
  * Serializes the entities to a string representation
30
43
  * @param entities - Map of entity name to Declarable entity
31
44
  * @param outputs - Optional array of LexiconOutputs produced by this lexicon
45
+ * @param context - Optional build-time context (e.g. ownership marker)
32
46
  */
33
- serialize(entities: Map<string, Declarable>, outputs?: LexiconOutput[]): string | SerializerResult;
47
+ serialize(
48
+ entities: Map<string, Declarable>,
49
+ outputs?: LexiconOutput[],
50
+ context?: SerializeContext,
51
+ ): string | SerializerResult;
34
52
 
35
53
  /**
36
54
  * Serialize a cross-lexicon reference to a foreign output.
package/src/toml-parse.ts CHANGED
@@ -10,8 +10,8 @@ import { unescapeString, stripInlineComment } from "./toml-utils";
10
10
  /**
11
11
  * Parse a TOML document string into a plain object.
12
12
  *
13
- * Uses a built-in parser that handles the TOML subset used by Flyway
14
- * configuration files.
13
+ * Uses a built-in parser that handles the common TOML subset used by tool
14
+ * configuration files (tables, dotted keys, inline tables, and arrays).
15
15
  */
16
16
  export function parseTOML(content: string): Record<string, unknown> {
17
17
  const result: Record<string, unknown> = {};
@@ -106,7 +106,7 @@ function findEquals(line: string): number {
106
106
  }
107
107
 
108
108
  /**
109
- * Parse a dotted key like `flyway.placeholders.name` into path segments.
109
+ * Parse a dotted key like `tool.placeholders.name` into path segments.
110
110
  */
111
111
  function parseDottedKey(raw: string): string[] {
112
112
  const parts: string[] = [];
File without changes
File without changes
File without changes
File without changes