@intentius/chant-lexicon-gcp 0.0.16 → 0.0.22

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/dist/integrity.json +8 -4
  2. package/dist/manifest.json +1 -1
  3. package/dist/meta.json +18141 -0
  4. package/dist/rules/schema-registry.ts +91 -0
  5. package/dist/rules/wgc401.ts +59 -0
  6. package/dist/rules/wgc402.ts +54 -0
  7. package/dist/rules/wgc403.ts +84 -0
  8. package/dist/skills/chant-gcp.md +4 -1
  9. package/package.json +20 -2
  10. package/src/codegen/docs.test.ts +16 -0
  11. package/src/codegen/docs.ts +3 -0
  12. package/src/codegen/generate.test.ts +18 -0
  13. package/src/codegen/generate.ts +11 -0
  14. package/src/codegen/package.test.ts +16 -0
  15. package/src/generated/lexicon-gcp.json +18141 -0
  16. package/src/import/import-fixtures.test.ts +98 -0
  17. package/src/lint/post-synth/gcp-helpers.test.ts +166 -0
  18. package/src/lint/post-synth/post-synth.test.ts +130 -0
  19. package/src/lint/post-synth/schema-registry.ts +91 -0
  20. package/src/lint/post-synth/wgc101.test.ts +39 -0
  21. package/src/lint/post-synth/wgc102.test.ts +38 -0
  22. package/src/lint/post-synth/wgc103.test.ts +38 -0
  23. package/src/lint/post-synth/wgc104.test.ts +37 -0
  24. package/src/lint/post-synth/wgc105.test.ts +46 -0
  25. package/src/lint/post-synth/wgc106.test.ts +38 -0
  26. package/src/lint/post-synth/wgc107.test.ts +38 -0
  27. package/src/lint/post-synth/wgc108.test.ts +42 -0
  28. package/src/lint/post-synth/wgc109.test.ts +46 -0
  29. package/src/lint/post-synth/wgc110.test.ts +37 -0
  30. package/src/lint/post-synth/wgc111.test.ts +46 -0
  31. package/src/lint/post-synth/wgc112.test.ts +48 -0
  32. package/src/lint/post-synth/wgc113.test.ts +36 -0
  33. package/src/lint/post-synth/wgc201.test.ts +38 -0
  34. package/src/lint/post-synth/wgc202.test.ts +38 -0
  35. package/src/lint/post-synth/wgc203.test.ts +45 -0
  36. package/src/lint/post-synth/wgc204.test.ts +42 -0
  37. package/src/lint/post-synth/wgc301.test.ts +39 -0
  38. package/src/lint/post-synth/wgc302.test.ts +36 -0
  39. package/src/lint/post-synth/wgc303.test.ts +37 -0
  40. package/src/lint/post-synth/wgc401.test.ts +46 -0
  41. package/src/lint/post-synth/wgc401.ts +59 -0
  42. package/src/lint/post-synth/wgc402.test.ts +40 -0
  43. package/src/lint/post-synth/wgc402.ts +54 -0
  44. package/src/lint/post-synth/wgc403.test.ts +59 -0
  45. package/src/lint/post-synth/wgc403.ts +84 -0
  46. package/src/plugin.test.ts +4 -1
  47. package/src/plugin.ts +172 -3
  48. package/src/skills/chant-gcp.md +4 -1
@@ -0,0 +1,59 @@
1
+ /**
2
+ * WGC401: Unknown spec field
3
+ *
4
+ * Flags fields in a Config Connector resource spec that are not defined
5
+ * in the CRD schema. Includes "did you mean?" suggestion via Levenshtein.
6
+ */
7
+
8
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
9
+ import { parseGcpManifests, isConfigConnectorResource, getResourceName, getSpec } from "./gcp-helpers";
10
+ import { getSchemaRegistry, suggestField } from "./schema-registry";
11
+
12
+ export const wgc401: PostSynthCheck = {
13
+ id: "WGC401",
14
+ description: "Config Connector resource spec contains unknown field",
15
+
16
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
17
+ const diagnostics: PostSynthDiagnostic[] = [];
18
+ const registry = getSchemaRegistry();
19
+
20
+ for (const [_lexicon, output] of ctx.outputs) {
21
+ if (typeof output !== "string") continue;
22
+
23
+ const manifests = parseGcpManifests(output);
24
+
25
+ for (const manifest of manifests) {
26
+ if (!isConfigConnectorResource(manifest)) continue;
27
+
28
+ const kind = manifest.kind;
29
+ if (!kind) continue;
30
+
31
+ const schema = registry.get(kind);
32
+ if (!schema) continue; // No schema available for this kind
33
+
34
+ const spec = getSpec(manifest);
35
+ if (!spec) continue;
36
+
37
+ const knownFields = Object.keys(schema.fields);
38
+ const resourceName = getResourceName(manifest);
39
+
40
+ for (const field of Object.keys(spec)) {
41
+ if (field in schema.fields) continue;
42
+
43
+ const suggestion = suggestField(field, knownFields);
44
+ const hint = suggestion ? ` — did you mean "${suggestion}"?` : "";
45
+
46
+ diagnostics.push({
47
+ checkId: "WGC401",
48
+ severity: "error",
49
+ message: `Resource "${resourceName}" (${kind}): unknown spec field "${field}"${hint}`,
50
+ entity: resourceName,
51
+ lexicon: "gcp",
52
+ });
53
+ }
54
+ }
55
+ }
56
+
57
+ return diagnostics;
58
+ },
59
+ };
@@ -0,0 +1,40 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import { wgc402 } from "./wgc402";
3
+
4
+ function makeCtx(yaml: string) {
5
+ return {
6
+ outputs: new Map([["gcp", yaml]]),
7
+ };
8
+ }
9
+
10
+ describe("WGC402: missing required field", () => {
11
+ test("flags missing required field", () => {
12
+ const yaml = `apiVersion: compute.cnrm.cloud.google.com/v1beta1
13
+ kind: ComputeAddress
14
+ metadata:
15
+ name: my-addr
16
+ spec:
17
+ description: test
18
+ `;
19
+ const diags = wgc402.check(makeCtx(yaml));
20
+ const requiredDiags = diags.filter(d => d.checkId === "WGC402");
21
+ // ComputeAddress requires "location" -- if schema is loaded, this flags it
22
+ if (requiredDiags.length > 0) {
23
+ expect(requiredDiags[0].severity).toBe("error");
24
+ expect(requiredDiags[0].message).toContain("required");
25
+ }
26
+ });
27
+
28
+ test("no diagnostic when all required fields present", () => {
29
+ const yaml = `apiVersion: storage.cnrm.cloud.google.com/v1beta1
30
+ kind: StorageBucket
31
+ metadata:
32
+ name: my-bucket
33
+ spec:
34
+ location: US
35
+ `;
36
+ const diags = wgc402.check(makeCtx(yaml));
37
+ const requiredDiags = diags.filter(d => d.checkId === "WGC402");
38
+ expect(requiredDiags).toHaveLength(0);
39
+ });
40
+ });
@@ -0,0 +1,54 @@
1
+ /**
2
+ * WGC402: Missing required spec field
3
+ *
4
+ * Flags Config Connector resources that are missing required fields
5
+ * according to their CRD schema.
6
+ */
7
+
8
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
9
+ import { parseGcpManifests, isConfigConnectorResource, getResourceName, getSpec } from "./gcp-helpers";
10
+ import { getSchemaRegistry } from "./schema-registry";
11
+
12
+ export const wgc402: PostSynthCheck = {
13
+ id: "WGC402",
14
+ description: "Config Connector resource is missing a required spec field",
15
+
16
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
17
+ const diagnostics: PostSynthDiagnostic[] = [];
18
+ const registry = getSchemaRegistry();
19
+
20
+ for (const [_lexicon, output] of ctx.outputs) {
21
+ if (typeof output !== "string") continue;
22
+
23
+ const manifests = parseGcpManifests(output);
24
+
25
+ for (const manifest of manifests) {
26
+ if (!isConfigConnectorResource(manifest)) continue;
27
+
28
+ const kind = manifest.kind;
29
+ if (!kind) continue;
30
+
31
+ const schema = registry.get(kind);
32
+ if (!schema) continue;
33
+
34
+ const spec = getSpec(manifest);
35
+ const specKeys = new Set(spec ? Object.keys(spec) : []);
36
+ const resourceName = getResourceName(manifest);
37
+
38
+ for (const requiredField of schema.required) {
39
+ if (!specKeys.has(requiredField)) {
40
+ diagnostics.push({
41
+ checkId: "WGC402",
42
+ severity: "error",
43
+ message: `Resource "${resourceName}" (${kind}): missing required field "${requiredField}"`,
44
+ entity: resourceName,
45
+ lexicon: "gcp",
46
+ });
47
+ }
48
+ }
49
+ }
50
+ }
51
+
52
+ return diagnostics;
53
+ },
54
+ };
@@ -0,0 +1,59 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import { wgc403 } from "./wgc403";
3
+
4
+ function makeCtx(yaml: string) {
5
+ return {
6
+ outputs: new Map([["gcp", yaml]]),
7
+ };
8
+ }
9
+
10
+ describe("WGC403: type/structure mismatch", () => {
11
+ test("flags string where number expected", () => {
12
+ const yaml = `apiVersion: cloudfunctions.cnrm.cloud.google.com/v1beta1
13
+ kind: CloudFunctionsFunction
14
+ metadata:
15
+ name: my-fn
16
+ spec:
17
+ runtime: nodejs18
18
+ availableMemoryMb: "512"
19
+ region: us-central1
20
+ `;
21
+ const diags = wgc403.check(makeCtx(yaml));
22
+ const typeDiags = diags.filter(d => d.checkId === "WGC403");
23
+ if (typeDiags.length > 0) {
24
+ expect(typeDiags[0].severity).toBe("error");
25
+ expect(typeDiags[0].message).toContain("number");
26
+ expect(typeDiags[0].message).toContain("string");
27
+ }
28
+ });
29
+
30
+ test("flags bare string instead of resourceRef object", () => {
31
+ const yaml = `apiVersion: pubsub.cnrm.cloud.google.com/v1beta1
32
+ kind: PubSubSubscription
33
+ metadata:
34
+ name: my-sub
35
+ spec:
36
+ topicRef: my-topic
37
+ `;
38
+ const diags = wgc403.check(makeCtx(yaml));
39
+ const typeDiags = diags.filter(d => d.checkId === "WGC403");
40
+ if (typeDiags.length > 0) {
41
+ expect(typeDiags[0].severity).toBe("error");
42
+ expect(typeDiags[0].message).toContain("resourceRef");
43
+ }
44
+ });
45
+
46
+ test("no diagnostic with correct types", () => {
47
+ const yaml = `apiVersion: storage.cnrm.cloud.google.com/v1beta1
48
+ kind: StorageBucket
49
+ metadata:
50
+ name: my-bucket
51
+ spec:
52
+ location: US
53
+ uniformBucketLevelAccess: true
54
+ `;
55
+ const diags = wgc403.check(makeCtx(yaml));
56
+ const typeDiags = diags.filter(d => d.checkId === "WGC403");
57
+ expect(typeDiags).toHaveLength(0);
58
+ });
59
+ });
@@ -0,0 +1,84 @@
1
+ /**
2
+ * WGC403: Type/structure mismatch in spec field
3
+ *
4
+ * Flags spec fields where the value type doesn't match the CRD schema:
5
+ * - String where number expected (e.g. availableMemoryMb: "512")
6
+ * - Scalar string where resourceRef object expected (e.g. topicRef: "name")
7
+ */
8
+
9
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
10
+ import { parseGcpManifests, isConfigConnectorResource, getResourceName, getSpec } from "./gcp-helpers";
11
+ import { getSchemaRegistry } from "./schema-registry";
12
+
13
+ export const wgc403: PostSynthCheck = {
14
+ id: "WGC403",
15
+ description: "Config Connector resource spec field has wrong type or structure",
16
+
17
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
18
+ const diagnostics: PostSynthDiagnostic[] = [];
19
+ const registry = getSchemaRegistry();
20
+
21
+ for (const [_lexicon, output] of ctx.outputs) {
22
+ if (typeof output !== "string") continue;
23
+
24
+ const manifests = parseGcpManifests(output);
25
+
26
+ for (const manifest of manifests) {
27
+ if (!isConfigConnectorResource(manifest)) continue;
28
+
29
+ const kind = manifest.kind;
30
+ if (!kind) continue;
31
+
32
+ const schema = registry.get(kind);
33
+ if (!schema) continue;
34
+
35
+ const spec = getSpec(manifest);
36
+ if (!spec) continue;
37
+
38
+ const resourceName = getResourceName(manifest);
39
+
40
+ for (const [field, value] of Object.entries(spec)) {
41
+ const fieldSchema = schema.fields[field];
42
+ if (!fieldSchema) continue; // Unknown fields handled by WGC401
43
+
44
+ // Check: resourceRef field expects an object, got a scalar
45
+ if (fieldSchema.ref && typeof value === "string") {
46
+ diagnostics.push({
47
+ checkId: "WGC403",
48
+ severity: "error",
49
+ message: `Resource "${resourceName}" (${kind}): field "${field}" expects a resourceRef object (e.g. { name, kind }), got string "${value}"`,
50
+ entity: resourceName,
51
+ lexicon: "gcp",
52
+ });
53
+ continue;
54
+ }
55
+
56
+ // Check: number field got a string that looks numeric
57
+ if (fieldSchema.type === "number" && typeof value === "string") {
58
+ diagnostics.push({
59
+ checkId: "WGC403",
60
+ severity: "error",
61
+ message: `Resource "${resourceName}" (${kind}): field "${field}" expects a number, got string "${value}"`,
62
+ entity: resourceName,
63
+ lexicon: "gcp",
64
+ });
65
+ continue;
66
+ }
67
+
68
+ // Check: boolean field got a string
69
+ if (fieldSchema.type === "boolean" && typeof value === "string") {
70
+ diagnostics.push({
71
+ checkId: "WGC403",
72
+ severity: "error",
73
+ message: `Resource "${resourceName}" (${kind}): field "${field}" expects a boolean, got string "${value}"`,
74
+ entity: resourceName,
75
+ lexicon: "gcp",
76
+ });
77
+ }
78
+ }
79
+ }
80
+ }
81
+
82
+ return diagnostics;
83
+ },
84
+ };
@@ -30,7 +30,7 @@ describe("gcpPlugin", () => {
30
30
 
31
31
  test("returns post-synth checks", () => {
32
32
  const checks = gcpPlugin.postSynthChecks!();
33
- expect(checks).toHaveLength(20);
33
+ expect(checks).toHaveLength(23);
34
34
  const ids = checks.map((c) => c.id);
35
35
  expect(ids).toContain("WGC101");
36
36
  expect(ids).toContain("WGC102");
@@ -52,6 +52,9 @@ describe("gcpPlugin", () => {
52
52
  expect(ids).toContain("WGC301");
53
53
  expect(ids).toContain("WGC302");
54
54
  expect(ids).toContain("WGC303");
55
+ expect(ids).toContain("WGC401");
56
+ expect(ids).toContain("WGC402");
57
+ expect(ids).toContain("WGC403");
55
58
  });
56
59
 
57
60
  // ── Intrinsics / pseudo-parameters ─────────────────────────────────
package/src/plugin.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  */
7
7
 
8
8
  import { createRequire } from "module";
9
- import type { LexiconPlugin, SkillDefinition, InitTemplateSet } from "@intentius/chant/lexicon";
9
+ import type { LexiconPlugin, SkillDefinition, InitTemplateSet, ResourceMetadata } from "@intentius/chant/lexicon";
10
10
  const require = createRequire(import.meta.url);
11
11
  import type { LintRule } from "@intentius/chant/lint/rule";
12
12
  import type { PostSynthCheck } from "@intentius/chant/lint/post-synth";
@@ -48,11 +48,15 @@ export const gcpPlugin: LexiconPlugin = {
48
48
  const { wgc111 } = require("./lint/post-synth/wgc111");
49
49
  const { wgc112 } = require("./lint/post-synth/wgc112");
50
50
  const { wgc113 } = require("./lint/post-synth/wgc113");
51
+ const { wgc401 } = require("./lint/post-synth/wgc401");
52
+ const { wgc402 } = require("./lint/post-synth/wgc402");
53
+ const { wgc403 } = require("./lint/post-synth/wgc403");
51
54
  return [
52
55
  wgc101, wgc102, wgc103, wgc104, wgc105, wgc106, wgc107, wgc108, wgc109, wgc110,
53
56
  wgc111, wgc112, wgc113,
54
57
  wgc201, wgc202, wgc203, wgc204,
55
58
  wgc301, wgc302, wgc303,
59
+ wgc401, wgc402, wgc403,
56
60
  ];
57
61
  },
58
62
 
@@ -87,10 +91,9 @@ export const annotations = defaultAnnotations({
87
91
  export const cluster = new GKECluster({
88
92
  location: GCP.Region,
89
93
  initialNodeCount: 1,
90
- removeDefaultNodePool: true,
91
94
  releaseChannel: { channel: "REGULAR" },
92
95
  workloadIdentityConfig: {
93
- workloadPool: "PROJECT_ID.svc.id.goog",
96
+ workloadPool: \`\${GCP.ProjectId}.svc.id.goog\`,
94
97
  },
95
98
  });
96
99
 
@@ -111,6 +114,54 @@ export const nodePool = new NodePool({
111
114
  };
112
115
  }
113
116
 
117
+ if (template === "cloud-function") {
118
+ return {
119
+ src: {
120
+ "infra.ts": `/**
121
+ * Cloud Function with Pub/Sub trigger
122
+ */
123
+
124
+ import { CloudFunction, StorageBucket, GCPServiceAccount, IAMPolicyMember, GCP } from "@intentius/chant-lexicon-gcp";
125
+ import { defaultAnnotations } from "@intentius/chant-lexicon-gcp";
126
+
127
+ export const annotations = defaultAnnotations({
128
+ "cnrm.cloud.google.com/project-id": GCP.ProjectId,
129
+ });
130
+
131
+ export const sourceBucket = new StorageBucket({
132
+ location: GCP.Region,
133
+ storageClass: "STANDARD",
134
+ uniformBucketLevelAccess: true,
135
+ });
136
+
137
+ export const functionSa = new GCPServiceAccount({
138
+ displayName: "Cloud Function Service Account",
139
+ });
140
+
141
+ export const fn = new CloudFunction({
142
+ location: GCP.Region,
143
+ runtime: "nodejs22",
144
+ entryPoint: "handler",
145
+ sourceArchiveBucket: sourceBucket,
146
+ sourceArchiveObject: "function-source.zip",
147
+ triggerHttp: true,
148
+ serviceAccountEmail: functionSa,
149
+ });
150
+
151
+ export const invoker = new IAMPolicyMember({
152
+ member: "allUsers",
153
+ role: "roles/cloudfunctions.invoker",
154
+ resourceRef: {
155
+ apiVersion: "cloudfunctions.cnrm.cloud.google.com/v1beta1",
156
+ kind: "CloudFunction",
157
+ name: fn,
158
+ },
159
+ });
160
+ `,
161
+ },
162
+ };
163
+ }
164
+
114
165
  // Default: StorageBucket + IAMPolicyMember
115
166
  return {
116
167
  src: {
@@ -184,6 +235,124 @@ export const bucketReader = new IAMPolicyMember({
184
235
  return gcpHover(ctx);
185
236
  },
186
237
 
238
+ async describeResources(options: {
239
+ environment: string;
240
+ buildOutput: string;
241
+ entityNames: string[];
242
+ }): Promise<Record<string, ResourceMetadata>> {
243
+ const { getRuntime } = await import("@intentius/chant/runtime-adapter");
244
+ const rt = getRuntime();
245
+ const resources: Record<string, ResourceMetadata> = {};
246
+
247
+ // Convert TypeScript variable names to kebab-case manifest names
248
+ // (mirrors serializer.ts:165 metadata.name assignment)
249
+ function entityToManifestName(name: string): string {
250
+ return name.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
251
+ }
252
+
253
+ // Detect namespace: prefer namespace from manifests, then kubectl context, fallback "default"
254
+ let namespace = "default";
255
+ try {
256
+ // Check manifests for an explicit namespace
257
+ const nsMatch = options.buildOutput.match(/^\s+namespace:\s*(.+)$/m);
258
+ if (nsMatch) {
259
+ namespace = nsMatch[1].trim();
260
+ } else {
261
+ // Try kubectl current context namespace
262
+ const nsResult = await rt.spawn([
263
+ "kubectl", "config", "view", "--minify", "-o", "jsonpath={..namespace}",
264
+ ]);
265
+ if (nsResult.exitCode === 0 && nsResult.stdout.trim()) {
266
+ namespace = nsResult.stdout.trim();
267
+ }
268
+ }
269
+ } catch (err) {
270
+ console.error(`[gcp] describeResources: namespace detection failed, using "default": ${err instanceof Error ? err.message : String(err)}`);
271
+ }
272
+
273
+ // Parse build output to extract kind/name pairs
274
+ let manifests: Array<{ kind: string; name: string; apiVersion: string; namespace?: string }> = [];
275
+ try {
276
+ const docs = options.buildOutput.split(/^---$/m).filter((d) => d.trim());
277
+ for (const doc of docs) {
278
+ const kindMatch = doc.match(/^kind:\s*(.+)$/m);
279
+ const nameMatch = doc.match(/^\s+name:\s*(.+)$/m);
280
+ const apiVersionMatch = doc.match(/^apiVersion:\s*(.+)$/m);
281
+ if (kindMatch && nameMatch && apiVersionMatch) {
282
+ const nsMatch = doc.match(/^\s+namespace:\s*(.+)$/m);
283
+ manifests.push({
284
+ kind: kindMatch[1].trim(),
285
+ name: nameMatch[1].trim(),
286
+ apiVersion: apiVersionMatch[1].trim(),
287
+ ...(nsMatch && { namespace: nsMatch[1].trim() }),
288
+ });
289
+ }
290
+ }
291
+ } catch (err) {
292
+ console.error(`[gcp] describeResources: failed to parse build output: ${err instanceof Error ? err.message : String(err)}`);
293
+ }
294
+
295
+ let resolved = 0;
296
+
297
+ for (const entityName of options.entityNames) {
298
+ const manifestName = entityToManifestName(entityName);
299
+ const manifest = manifests.find((m) => m.name === manifestName);
300
+ if (!manifest) {
301
+ console.error(`[gcp] describeResources: no manifest found for entity "${entityName}" (expected manifest name "${manifestName}")`);
302
+ continue;
303
+ }
304
+
305
+ const resourceNs = manifest.namespace ?? namespace;
306
+ const resourceType = manifest.kind.toLowerCase();
307
+ const getResult = await rt.spawn([
308
+ "kubectl", "get", resourceType, manifest.name,
309
+ "-n", resourceNs, "-o", "json",
310
+ ]);
311
+
312
+ if (getResult.exitCode !== 0) {
313
+ console.error(`[gcp] describeResources: kubectl get ${resourceType} ${manifest.name} -n ${resourceNs} failed (exit ${getResult.exitCode}): ${getResult.stderr.trim()}`);
314
+ continue;
315
+ }
316
+
317
+ try {
318
+ const obj = JSON.parse(getResult.stdout) as {
319
+ metadata: { name: string; uid: string; creationTimestamp: string };
320
+ status?: {
321
+ conditions?: Array<{ type: string; status: string }>;
322
+ externalRef?: string;
323
+ };
324
+ };
325
+
326
+ let status = "Unknown";
327
+ if (obj.status?.conditions) {
328
+ const ready = obj.status.conditions.find((c) => c.type === "Ready");
329
+ status = ready?.status === "True" ? "Ready" : "NotReady";
330
+ }
331
+
332
+ const attributes: Record<string, unknown> = {
333
+ uid: obj.metadata.uid,
334
+ };
335
+ if (obj.status?.externalRef) {
336
+ attributes.externalRef = obj.status.externalRef;
337
+ }
338
+
339
+ resources[entityName] = {
340
+ type: `${manifest.apiVersion}/${manifest.kind}`,
341
+ physicalId: obj.status?.externalRef ?? obj.metadata.name,
342
+ status,
343
+ lastUpdated: obj.metadata.creationTimestamp,
344
+ attributes,
345
+ };
346
+ resolved++;
347
+ } catch (err) {
348
+ console.error(`[gcp] describeResources: failed to parse kubectl output for "${entityName}": ${err instanceof Error ? err.message : String(err)}`);
349
+ }
350
+ }
351
+
352
+ console.error(`[gcp] describeResources: ${resolved}/${options.entityNames.length} resources resolved`);
353
+ return resources;
354
+ },
355
+
187
356
  mcpTools(): McpToolContribution[] {
188
357
  return [
189
358
  {
@@ -9,7 +9,7 @@ user-invocable: true
9
9
 
10
10
  ## How chant and Config Connector relate
11
11
 
12
- chant is a **synthesis-only** tool — it compiles TypeScript source files into Config Connector YAML manifests. chant does NOT call GCP APIs. Your job as an agent is to bridge the two:
12
+ chant is a **synthesis compiler** — it compiles TypeScript source files into Config Connector YAML manifests. `chant build` does not call GCP APIs; synthesis is pure and deterministic. Config Connector resources are Kubernetes objects, so the optional `chant state snapshot` command queries the Kubernetes API to capture deployment metadata (resource status, conditions, observed state) for observability. Your job as an agent is to bridge synthesis and deployment:
13
13
 
14
14
  - Use **chant** for: build, lint, diff (local YAML comparison)
15
15
  - Use **kubectl** for: apply, rollback, monitoring, troubleshooting
@@ -49,6 +49,9 @@ chant lint src/
49
49
  # Build
50
50
  chant build src/ --output manifests.yaml
51
51
 
52
+ # See what changed since last deploy (compares current build against last snapshot's digest)
53
+ chant state diff staging gcp
54
+
52
55
  # Dry run
53
56
  kubectl apply -f manifests.yaml --dry-run=server
54
57